Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d4c9b48d | ||
|
|
da4f7b5fe4 | ||
|
|
e119d1cb31 | ||
|
|
54003d69e2 | ||
|
|
ab6be1d510 | ||
|
|
a1dfdf4e68 | ||
|
|
464ca70d7b | ||
|
|
2dca85c881 | ||
|
|
837435223a | ||
|
|
79ad0b9368 | ||
|
|
5624a2d11a | ||
|
|
29367ff576 | ||
|
|
64c94804ee | ||
|
|
1d9fb7bf26 | ||
|
|
30a441d9ec | ||
|
|
33753c72cd | ||
|
|
02d7eca2ad | ||
|
|
2c6fe6c31a | ||
|
|
ab71b11532 | ||
|
|
a858596fa2 | ||
|
|
5176134c28 | ||
|
|
79370dd8a1 | ||
|
|
3c32f12152 | ||
|
|
64f7e47b20 | ||
|
|
25c112856d | ||
|
|
3665a79e50 | ||
|
|
4dce31aff7 | ||
|
|
451ca949ec | ||
|
|
a9ff8ce01c | ||
|
|
7848248df7 | ||
|
|
b00e8de26f | ||
|
|
47b06b7773 | ||
|
|
4e66f0c105 | ||
|
|
84c7726940 | ||
|
|
b8f59a4740 | ||
|
|
06a19519c5 | ||
|
|
b4ebb7c9e5 | ||
|
|
5edc3e07a4 | ||
|
|
417dcc1d37 | ||
|
|
72f6068e86 | ||
|
|
97e7f34260 | ||
|
|
74babf9730 | ||
|
|
30fe800ebe | ||
|
|
c98a724935 | ||
|
|
0cb89c8f67 | ||
|
|
7b5d5c6ce1 | ||
|
|
eea5e4123b | ||
|
|
c10ace7a84 | ||
|
|
0e803b53d8 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -5,6 +5,44 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.1.1] - 2025-09-03
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed the default codex config.toml to match the latest modifications
|
||||
- Improved provider configuration UX with custom option
|
||||
|
||||
### 📝 Documentation
|
||||
- Updated README with latest information
|
||||
|
||||
## [3.1.0] - 2025-09-01
|
||||
|
||||
### ✨ New Features
|
||||
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
||||
- Manage auth.json and config.toml for Codex
|
||||
- Support for backup and restore operations
|
||||
- Preset providers for Codex (Official, PackyCode)
|
||||
- API Key auto-write to auth.json when using presets
|
||||
- **New UI components**
|
||||
- App switcher with segmented control design
|
||||
- Dual editor form for Codex configuration
|
||||
- Pills-style app switcher with consistent button widths
|
||||
- **Enhanced configuration management**
|
||||
- Multi-app config v2 structure (claude/codex)
|
||||
- Automatic v1→v2 migration with backup
|
||||
- OPENAI_API_KEY validation for non-official presets
|
||||
- TOML syntax validation for config.toml
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- Unified Tauri command API with app_type parameter
|
||||
- Backward compatibility for app/appType parameters
|
||||
- Added get_config_status/open_config_folder/open_external commands
|
||||
- Improved error handling for empty config.toml
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed config path reporting and folder opening for Codex
|
||||
- Corrected default import behavior when main config is missing
|
||||
- Fixed non_snake_case warnings in commands.rs
|
||||
|
||||
## [3.0.0] - 2025-08-27
|
||||
|
||||
### 🚀 Major Changes
|
||||
@@ -44,6 +82,11 @@ For users upgrading from v2.x (Electron version):
|
||||
- The app will automatically migrate your existing provider configurations
|
||||
- Window position and size preferences have been reset to defaults
|
||||
|
||||
#### Backup on v1→v2 Migration (cc-switch internal config)
|
||||
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
|
||||
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
|
||||
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
||||
|
||||
### 🛠️ Development
|
||||
- Added `pnpm typecheck` command for TypeScript validation
|
||||
- Added `pnpm format` and `pnpm format:check` for code formatting
|
||||
@@ -64,4 +107,4 @@ For users upgrading from v2.x (Electron version):
|
||||
### Features
|
||||
- Basic provider management
|
||||
- Claude Code integration
|
||||
- Configuration file handling
|
||||
- Configuration file handling
|
||||
|
||||
58
README.md
58
README.md
@@ -1,19 +1,23 @@
|
||||
# Claude Code 供应商切换器
|
||||
# Claude Code & Codex 供应商切换器
|
||||
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
|
||||
> **v3.0.0 重大更新**:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
||||
- 一键切换不同供应商
|
||||
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
|
||||
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||
- 支持添加自定义供应商
|
||||
- 随时切换官方登录
|
||||
- 简洁美观的图形界面
|
||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
||||
- 超小体积 - 仅 ~5MB 安装包
|
||||
@@ -38,30 +42,50 @@
|
||||
|
||||
### Windows 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch_3.0.0_x64.msi` 或 `.exe` 安装包。
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
|
||||
|
||||
### macOS 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本:
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
|
||||
|
||||
- **推荐**: `CC-Switch.zip` - 解压即用,无需安装
|
||||
- `CC-Switch_3.0.0_aarch64.dmg` (Apple Silicon) - 需要安装
|
||||
|
||||
> **注意**:由于应用未签名,macOS 可能提示"已损坏"。解决方法:
|
||||
> 1. **推荐**:下载 zip 版本,解压后直接使用
|
||||
> 2. 或在终端运行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
> 3. 或在"系统设置 > 隐私与安全"中选择"仍要打开"
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Linux 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `.AppImage` 或 `.deb` 包。
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 选择要使用的供应商,点击单选按钮切换
|
||||
3. 配置会自动保存到 Claude Code 的配置文件中
|
||||
3. 配置会自动保存到对应应用的配置文件中
|
||||
4. 重启或者新打开终端以生效
|
||||
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
||||
|
||||
### Codex 说明
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
|
||||
|
||||
### Claude Code 说明
|
||||
|
||||
- 配置目录:`~/.claude/`
|
||||
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
|
||||
- 供应商副本:`settings-<name>.json`
|
||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
|
||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
|
||||
|
||||
### 迁移与备份
|
||||
|
||||
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`。
|
||||
- 实际生效的应用配置文件(如 `~/.claude/settings.json`、`~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -143,6 +167,12 @@ cargo test
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||
|
||||
## Electron 旧版
|
||||
|
||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||
|
||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
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 规划。
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.0.0",
|
||||
"description": "Claude Code 供应商切换工具",
|
||||
"version": "3.1.1",
|
||||
"description": "Claude Code & Codex 供应商切换工具",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
"build": "pnpm tauri build",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 247 KiB |
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -550,7 +550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.0.0"
|
||||
version = "3.1.1"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"log",
|
||||
@@ -562,6 +562,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"toml 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.0.0"
|
||||
description = "Claude Code MCP 服务器配置管理工具"
|
||||
version = "3.1.1"
|
||||
description = "Claude Code & Codex 供应商配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/jasonyoung/cc-switch"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@@ -25,6 +25,7 @@ tauri = { version = "2.8.2", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
|
||||
139
src-tauri/src/app_config.rs
Normal file
139
src-tauri/src/app_config.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
use crate::provider::ProviderManager;
|
||||
|
||||
/// 应用类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppType {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"codex" => AppType::Codex,
|
||||
_ => AppType::Claude, // 默认为 Claude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多应用配置结构(向后兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiAppConfig {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
#[serde(flatten)]
|
||||
pub apps: HashMap<String, ProviderManager>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
impl Default for MultiAppConfig {
|
||||
fn default() -> Self {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
|
||||
Self { version: 2, apps }
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiAppConfig {
|
||||
/// 从文件加载配置(处理v1到v2的迁移)
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let config_path = get_app_config_path();
|
||||
|
||||
if !config_path.exists() {
|
||||
log::info!("配置文件不存在,创建新的多应用配置");
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取配置文件失败: {}", e))?;
|
||||
|
||||
// 检查是否是旧版本格式(v1)
|
||||
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
||||
log::info!("检测到v1配置,自动迁移到v2");
|
||||
|
||||
// 迁移到新格式
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), v1_config);
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
|
||||
let config = Self { version: 2, apps };
|
||||
|
||||
// 迁移前备份旧版(v1)配置文件
|
||||
let backup_dir = get_app_config_dir();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts));
|
||||
|
||||
match copy_file(&config_path, &backup_path) {
|
||||
Ok(()) => log::info!(
|
||||
"已备份旧版配置文件: {} -> {}",
|
||||
config_path.display(),
|
||||
backup_path.display()
|
||||
),
|
||||
Err(e) => log::warn!("备份旧版配置文件失败: {}", e),
|
||||
}
|
||||
|
||||
// 保存迁移后的配置
|
||||
config.save()?;
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// 尝试读取v2格式
|
||||
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config_path = get_app_config_path();
|
||||
// 先备份旧版(若存在)到 ~/.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(())
|
||||
}
|
||||
|
||||
/// 获取指定应用的管理器
|
||||
pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
|
||||
self.apps.get(app.as_str())
|
||||
}
|
||||
|
||||
/// 获取指定应用的管理器(可变引用)
|
||||
pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
|
||||
self.apps.get_mut(app.as_str())
|
||||
}
|
||||
|
||||
/// 确保应用存在
|
||||
pub fn ensure_app(&mut self, app: &AppType) {
|
||||
if !self.apps.contains_key(app.as_str()) {
|
||||
self.apps
|
||||
.insert(app.as_str().to_string(), ProviderManager::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src-tauri/src/codex_config.rs
Normal file
139
src-tauri/src/codex_config.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
// unused imports removed
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{
|
||||
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
}
|
||||
|
||||
/// 获取 Codex auth.json 路径
|
||||
pub fn get_codex_auth_path() -> PathBuf {
|
||||
get_codex_config_dir().join("auth.json")
|
||||
}
|
||||
|
||||
/// 获取 Codex config.toml 路径
|
||||
pub fn get_codex_config_path() -> PathBuf {
|
||||
get_codex_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// 获取 Codex 供应商配置文件路径
|
||||
pub fn get_codex_provider_paths(
|
||||
provider_id: &str,
|
||||
provider_name: Option<&str>,
|
||||
) -> (PathBuf, PathBuf) {
|
||||
let base_name = provider_name
|
||||
.map(|name| sanitize_provider_name(name))
|
||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||
|
||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
||||
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
||||
|
||||
(auth_path, config_path)
|
||||
}
|
||||
|
||||
/// 删除 Codex 供应商配置文件
|
||||
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||
|
||||
delete_file(&auth_path).ok();
|
||||
delete_file(&config_path).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
|
||||
|
||||
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
||||
pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let config_path = get_codex_config_path();
|
||||
|
||||
if let Some(parent) = auth_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 读取旧内容用于回滚
|
||||
let old_auth = if auth_path.exists() {
|
||||
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _old_config = if config_path.exists() {
|
||||
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 准备写入内容
|
||||
let cfg_text = match config_text_opt {
|
||||
Some(s) => s.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
if !cfg_text.trim().is_empty() {
|
||||
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| format!("config.toml 格式错误: {}", e))?;
|
||||
}
|
||||
|
||||
// 第一步:写 auth.json
|
||||
write_json_file(&auth_path, auth)?;
|
||||
|
||||
// 第二步:写 config.toml(失败则回滚 auth.json)
|
||||
if let Err(e) = write_text_file(&config_path, &cfg_text) {
|
||||
// 回滚 auth.json
|
||||
if let Some(bytes) = old_auth {
|
||||
let _ = atomic_write(&auth_path, &bytes);
|
||||
} else {
|
||||
let _ = delete_file(&auth_path);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
|
||||
pub fn read_codex_config_text() -> Result<String, String> {
|
||||
let path = get_codex_config_path();
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e))
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
|
||||
pub fn read_config_text_from_path(path: &Path) -> Result<String, String> {
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e))
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 对非空的 TOML 文本进行语法校验
|
||||
pub fn validate_config_toml(text: &str) -> Result<(), String> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
toml::from_str::<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)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{ConfigStatus, get_claude_settings_path};
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
|
||||
@@ -10,38 +14,110 @@ use crate::store::AppState;
|
||||
#[tauri::command]
|
||||
pub async fn get_providers(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<HashMap<String, Provider>, String> {
|
||||
let manager = state
|
||||
.provider_manager
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.get_all_providers().clone())
|
||||
}
|
||||
|
||||
/// 获取当前供应商ID
|
||||
#[tauri::command]
|
||||
pub async fn get_current_provider(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let manager = state
|
||||
.provider_manager
|
||||
pub async fn get_current_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.current.clone())
|
||||
}
|
||||
|
||||
/// 添加供应商
|
||||
#[tauri::command]
|
||||
pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Result<bool, String> {
|
||||
let mut manager = state
|
||||
.provider_manager
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
pub async fn add_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider: Provider,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
manager.add_provider(provider)?;
|
||||
// 读取当前是否是激活供应商(短锁)
|
||||
let is_current = {
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager.current == provider.id
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
drop(manager); // 释放锁
|
||||
// 若目标为当前供应商,则先写 live,成功后再落盘配置
|
||||
if is_current {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新内存并保存配置
|
||||
{
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager.providers.insert(provider.id.clone(), provider.clone());
|
||||
}
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
@@ -51,17 +127,63 @@ pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Res
|
||||
#[tauri::command]
|
||||
pub async fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider: Provider,
|
||||
) -> Result<bool, String> {
|
||||
let mut manager = state
|
||||
.provider_manager
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
manager.update_provider(provider)?;
|
||||
// 读取校验 & 是否当前(短锁)
|
||||
let (exists, is_current) = {
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
(manager.providers.contains_key(&provider.id), manager.current == provider.id)
|
||||
};
|
||||
if !exists {
|
||||
return Err(format!("供应商不存在: {}", provider.id));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
drop(manager); // 释放锁
|
||||
// 若更新的是当前供应商,先写 live 成功再保存
|
||||
if is_current {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新内存并保存
|
||||
{
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager.providers.insert(provider.id.clone(), provider.clone());
|
||||
}
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
@@ -69,16 +191,59 @@ pub async fn update_provider(
|
||||
|
||||
/// 删除供应商
|
||||
#[tauri::command]
|
||||
pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
let mut manager = state
|
||||
.provider_manager
|
||||
pub async fn delete_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
manager.delete_provider(&id)?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查是否为当前供应商
|
||||
if manager.current == id {
|
||||
return Err("不能删除当前正在使用的供应商".to_string());
|
||||
}
|
||||
|
||||
// 获取供应商信息
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||
.clone();
|
||||
|
||||
// 删除配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
codex_config::delete_codex_provider_config(&id, &provider.name)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{delete_file, get_provider_config_path};
|
||||
// 兼容历史两种命名:settings-{name}.json 与 settings-{id}.json
|
||||
let by_name = get_provider_config_path(&id, Some(&provider.name));
|
||||
let by_id = get_provider_config_path(&id, None);
|
||||
delete_file(&by_name)?;
|
||||
delete_file(&by_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 从管理器删除
|
||||
manager.providers.remove(&id);
|
||||
|
||||
// 保存配置
|
||||
drop(manager); // 释放锁
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
@@ -86,16 +251,105 @@ pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<b
|
||||
|
||||
/// 切换供应商
|
||||
#[tauri::command]
|
||||
pub async fn switch_provider(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
let mut manager = state
|
||||
.provider_manager
|
||||
pub async fn switch_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
manager.switch_provider(&id)?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查供应商是否存在
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||
.clone();
|
||||
|
||||
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
use serde_json::Value;
|
||||
|
||||
// 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config
|
||||
if !manager.current.is_empty() {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
let config_path = codex_config::get_codex_config_path();
|
||||
if auth_path.exists() {
|
||||
let auth: Value = crate::config::read_json_file(&auth_path)?;
|
||||
let config_str = if config_path.exists() {
|
||||
std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let live = serde_json::json!({
|
||||
"auth": auth,
|
||||
"config": config_str,
|
||||
});
|
||||
|
||||
if let Some(cur) = manager.providers.get_mut(&manager.current) {
|
||||
cur.settings_config = live;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换:从目标供应商 settings_config 写入主配置(Codex 双文件原子+回滚)
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{read_json_file, write_json_file};
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
|
||||
// 回填:读取 live settings.json 写回当前供应商 settings_config
|
||||
if settings_path.exists() && !manager.current.is_empty() {
|
||||
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
|
||||
if let Some(cur) = manager.providers.get_mut(&manager.current) {
|
||||
cur.settings_config = live;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换:从目标供应商 settings_config 写入主配置
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 不做归档,直接写入
|
||||
write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前供应商
|
||||
manager.current = id;
|
||||
|
||||
log::info!("成功切换到供应商: {}", provider.name);
|
||||
|
||||
// 保存配置
|
||||
drop(manager); // 释放锁
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
@@ -103,22 +357,56 @@ pub async fn switch_provider(state: State<'_, AppState>, id: String) -> Result<b
|
||||
|
||||
/// 导入当前配置为默认供应商
|
||||
#[tauri::command]
|
||||
pub async fn import_default_config(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
// 若已存在 default 供应商,则直接返回,避免重复导入
|
||||
pub async fn import_default_config(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
// 仅当 providers 为空时才从 live 导入一条默认项
|
||||
{
|
||||
let manager = state
|
||||
.provider_manager
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
if manager.get_all_providers().contains_key("default") {
|
||||
return Ok(true);
|
||||
|
||||
if let Some(manager) = config.get_manager(&app_type) {
|
||||
if !manager.get_all_providers().is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入配置
|
||||
let settings_config = import_current_config_as_default()?;
|
||||
// 根据应用类型导入配置
|
||||
// 读取当前主配置为默认供应商(不再写入副本文件)
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Err("Codex 配置文件不存在".to_string());
|
||||
}
|
||||
let auth: serde_json::Value = crate::config::read_json_file::<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(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
@@ -127,20 +415,21 @@ pub async fn import_default_config(state: State<'_, AppState>) -> Result<bool, S
|
||||
);
|
||||
|
||||
// 添加到管理器
|
||||
let mut manager = state
|
||||
.provider_manager
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
manager.add_provider(provider)?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 如果没有当前供应商,设置为 default
|
||||
if manager.current.is_empty() {
|
||||
manager.current = "default".to_string();
|
||||
}
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
// 设置当前供应商为默认项
|
||||
manager.current = "default".to_string();
|
||||
|
||||
// 保存配置
|
||||
drop(manager); // 释放锁
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
@@ -152,6 +441,34 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
Ok(crate::config::get_claude_config_status())
|
||||
}
|
||||
|
||||
/// 获取应用配置状态(通用)
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<ConfigStatus, String> {
|
||||
let app = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
match app {
|
||||
AppType::Claude => Ok(crate::config::get_claude_config_status()),
|
||||
AppType::Codex => {
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_dir};
|
||||
let auth_path = get_codex_auth_path();
|
||||
|
||||
// 放宽:只要 auth.json 存在即可认为已配置;config.toml 允许为空
|
||||
let exists = auth_path.exists();
|
||||
let path = get_codex_config_dir().to_string_lossy().to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置文件路径
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
@@ -159,9 +476,23 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// 打开配置文件夹
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
pub async fn open_config_folder(app: tauri::AppHandle) -> Result<bool, String> {
|
||||
let config_dir = crate::config::get_claude_config_dir();
|
||||
pub async fn open_config_folder(
|
||||
handle: tauri::AppHandle,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config_dir = match app_type {
|
||||
AppType::Claude => crate::config::get_claude_config_dir(),
|
||||
AppType::Codex => crate::codex_config::get_codex_config_dir(),
|
||||
};
|
||||
|
||||
// 确保目录存在
|
||||
if !config_dir.exists() {
|
||||
@@ -169,7 +500,7 @@ pub async fn open_config_folder(app: tauri::AppHandle) -> Result<bool, String> {
|
||||
}
|
||||
|
||||
// 使用 opener 插件打开文件夹
|
||||
app.opener()
|
||||
handle.opener()
|
||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
// unused import removed
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
@@ -38,6 +39,56 @@ pub fn get_app_config_path() -> PathBuf {
|
||||
get_app_config_dir().join("config.json")
|
||||
}
|
||||
|
||||
/// 归档根目录 ~/.cc-switch/archive
|
||||
pub fn get_archive_root() -> PathBuf {
|
||||
get_app_config_dir().join("archive")
|
||||
}
|
||||
|
||||
fn ensure_unique_path(dest: PathBuf) -> PathBuf {
|
||||
if !dest.exists() {
|
||||
return dest;
|
||||
}
|
||||
let file_name = dest
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
let ext = dest
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let parent = dest.parent().map(|p| p.to_path_buf()).unwrap_or_default();
|
||||
for i in 2..1000 {
|
||||
let mut candidate = parent.clone();
|
||||
candidate.push(format!("{}-{}{}", file_name, i, ext));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
dest
|
||||
}
|
||||
|
||||
/// 将现有文件归档到 `~/.cc-switch/archive/<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 {
|
||||
name.chars()
|
||||
@@ -79,7 +130,54 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String
|
||||
let json =
|
||||
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
||||
|
||||
fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e))
|
||||
atomic_write(path, json.as_bytes())
|
||||
}
|
||||
|
||||
/// 原子写入文本文件(用于 TOML/纯文本)
|
||||
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
atomic_write(path, data.as_bytes())
|
||||
}
|
||||
|
||||
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
|
||||
let mut tmp = parent.to_path_buf();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| "无效的文件名".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
||||
|
||||
{
|
||||
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
|
||||
f.write_all(data)
|
||||
.map_err(|e| format!("写入临时文件失败: {}", e))?;
|
||||
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let perm = meta.permissions().mode();
|
||||
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 复制文件
|
||||
@@ -112,30 +210,4 @@ pub fn get_claude_config_status() -> ConfigStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份配置文件
|
||||
pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> {
|
||||
if from.exists() {
|
||||
copy_file(from, to)?;
|
||||
log::info!("已备份配置文件: {} -> {}", from.display(), to.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入当前 Claude Code 配置为默认供应商
|
||||
pub fn import_current_config_as_default() -> Result<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)
|
||||
}
|
||||
//(移除未使用的备份/导入函数,避免 dead_code 告警)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
mod app_config;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod provider;
|
||||
mod store;
|
||||
mod migration;
|
||||
|
||||
use store::AppState;
|
||||
use tauri::Manager;
|
||||
@@ -53,36 +56,21 @@ pub fn run() {
|
||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
||||
let app_state = AppState::new();
|
||||
|
||||
// 如果没有供应商且存在 Claude Code 配置,自动导入
|
||||
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
||||
{
|
||||
let manager = app_state.provider_manager.lock().unwrap();
|
||||
if manager.providers.is_empty() {
|
||||
drop(manager); // 释放锁
|
||||
|
||||
let settings_path = config::get_claude_settings_path();
|
||||
if settings_path.exists() {
|
||||
log::info!("检测到 Claude Code 配置,自动导入为默认供应商");
|
||||
|
||||
if let Ok(settings_config) = config::import_current_config_as_default() {
|
||||
let mut manager = app_state.provider_manager.lock().unwrap();
|
||||
let provider = provider::Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
if manager.add_provider(provider).is_ok() {
|
||||
manager.current = "default".to_string();
|
||||
drop(manager);
|
||||
let _ = app_state.save();
|
||||
log::info!("成功导入默认供应商");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut config_guard = app_state.config.lock().unwrap();
|
||||
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
|
||||
if migrated {
|
||||
log::info!("已将副本文件导入到 config.json,并完成归档");
|
||||
}
|
||||
// 确保两个 App 条目存在
|
||||
config_guard.ensure_app(&app_config::AppType::Claude);
|
||||
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
let _ = app_state.save();
|
||||
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
Ok(())
|
||||
@@ -96,6 +84,7 @@ pub fn run() {
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
commands::open_config_folder,
|
||||
commands::open_external,
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::{
|
||||
backup_config, copy_file, delete_file, get_claude_settings_path, get_provider_config_path,
|
||||
read_json_file, write_json_file,
|
||||
};
|
||||
// SSOT 模式:不再写供应商副本文件
|
||||
|
||||
/// 供应商结构体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -54,124 +50,6 @@ impl Default for ProviderManager {
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// 加载供应商列表
|
||||
pub fn load_from_file(path: &Path) -> Result<Self, String> {
|
||||
if !path.exists() {
|
||||
log::info!("配置文件不存在,创建新的供应商管理器");
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
read_json_file(path)
|
||||
}
|
||||
|
||||
/// 保存供应商列表
|
||||
pub fn save_to_file(&self, path: &Path) -> Result<(), String> {
|
||||
write_json_file(path, self)
|
||||
}
|
||||
|
||||
/// 添加供应商
|
||||
pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> {
|
||||
// 保存供应商配置到独立文件
|
||||
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 update_provider(&mut self, provider: Provider) -> Result<(), String> {
|
||||
// 检查供应商是否存在
|
||||
if !self.providers.contains_key(&provider.id) {
|
||||
return Err(format!("供应商不存在: {}", provider.id));
|
||||
}
|
||||
|
||||
// 如果名称改变了,需要处理配置文件
|
||||
if let Some(old_provider) = self.providers.get(&provider.id) {
|
||||
if old_provider.name != provider.name {
|
||||
// 删除旧配置文件
|
||||
let old_config_path =
|
||||
get_provider_config_path(&provider.id, Some(&old_provider.name));
|
||||
delete_file(&old_config_path).ok(); // 忽略删除错误
|
||||
}
|
||||
}
|
||||
|
||||
// 保存新配置文件
|
||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||
write_json_file(&config_path, &provider.settings_config)?;
|
||||
|
||||
// 更新管理器
|
||||
self.providers.insert(provider.id.clone(), provider);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除供应商
|
||||
pub fn delete_provider(&mut self, provider_id: &str) -> Result<(), String> {
|
||||
// 检查是否为当前供应商
|
||||
if self.current == provider_id {
|
||||
return Err("不能删除当前正在使用的供应商".to_string());
|
||||
}
|
||||
|
||||
// 获取供应商信息
|
||||
let provider = self
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", provider_id))?;
|
||||
|
||||
// 删除配置文件
|
||||
let config_path = get_provider_config_path(provider_id, Some(&provider.name));
|
||||
delete_file(&config_path)?;
|
||||
|
||||
// 从管理器删除
|
||||
self.providers.remove(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 切换供应商
|
||||
pub fn switch_provider(&mut self, provider_id: &str) -> Result<(), String> {
|
||||
// 检查供应商是否存在
|
||||
let provider = self
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", provider_id))?;
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
let provider_config_path = get_provider_config_path(provider_id, Some(&provider.name));
|
||||
|
||||
// 检查供应商配置文件是否存在
|
||||
if !provider_config_path.exists() {
|
||||
return Err(format!(
|
||||
"供应商配置文件不存在: {}",
|
||||
provider_config_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 如果当前有配置,先备份到当前供应商
|
||||
if settings_path.exists() && !self.current.is_empty() {
|
||||
if let Some(current_provider) = self.providers.get(&self.current) {
|
||||
let current_provider_path =
|
||||
get_provider_config_path(&self.current, Some(¤t_provider.name));
|
||||
backup_config(&settings_path, ¤t_provider_path)?;
|
||||
log::info!("已备份当前供应商配置: {}", current_provider.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保主配置父目录存在
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 复制新供应商配置到主配置
|
||||
copy_file(&provider_config_path, &settings_path)?;
|
||||
|
||||
// 更新当前供应商
|
||||
self.current = provider_id.to_string();
|
||||
|
||||
log::info!("成功切换到供应商: {}", provider.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有供应商
|
||||
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
||||
&self.providers
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
use crate::config::get_app_config_path;
|
||||
use crate::provider::ProviderManager;
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 全局应用状态
|
||||
pub struct AppState {
|
||||
pub provider_manager: Mutex<ProviderManager>,
|
||||
pub config: Mutex<MultiAppConfig>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new() -> Self {
|
||||
let config_path = get_app_config_path();
|
||||
let provider_manager = ProviderManager::load_from_file(&config_path).unwrap_or_else(|e| {
|
||||
let config = MultiAppConfig::load().unwrap_or_else(|e| {
|
||||
log::warn!("加载配置失败: {}, 使用默认配置", e);
|
||||
ProviderManager::default()
|
||||
MultiAppConfig::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
provider_manager: Mutex::new(provider_manager),
|
||||
config: Mutex::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config_path = get_app_config_path();
|
||||
let manager = self
|
||||
.provider_manager
|
||||
let config = self
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
manager.save_to_file(&config_path)
|
||||
config.save()
|
||||
}
|
||||
|
||||
// 保留按需扩展:若未来需要热加载,可在此实现
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.1",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -12,6 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "",
|
||||
"width": 900,
|
||||
"height": 650,
|
||||
@@ -23,7 +24,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:"
|
||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
81
src/App.css
81
src/App.css
@@ -5,25 +5,94 @@
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #3498db;
|
||||
background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
|
||||
color: white;
|
||||
padding: 0.75rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 2rem 0.45rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
". title ."
|
||||
"tabs . actions";
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
row-gap: 0.6rem;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
user-select: none;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.app-tabs {
|
||||
grid-area: tabs;
|
||||
}
|
||||
|
||||
/* Segmented control */
|
||||
.segmented {
|
||||
--seg-bg: rgba(255, 255, 255, 0.16);
|
||||
--seg-thumb: #ffffff;
|
||||
--seg-color: rgba(255, 255, 255, 0.85);
|
||||
--seg-active: #2d89c7;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 280px;
|
||||
background: var(--seg-bg);
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: saturate(140%) blur(2px);
|
||||
}
|
||||
|
||||
.segmented-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: calc(50% - 4px);
|
||||
height: calc(100% - 8px);
|
||||
background: var(--seg-thumb);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
width 220ms ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.segmented-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 6px 16px; /* 更紧凑的高度 */
|
||||
color: var(--seg-color);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
|
||||
.segmented-item.active {
|
||||
color: var(--seg-active);
|
||||
}
|
||||
|
||||
.segmented-item:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
grid-area: title;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.refresh-btn,
|
||||
|
||||
43
src/App.tsx
43
src/App.tsx
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Provider } from "./types";
|
||||
import { AppType } from "./lib/tauri-api";
|
||||
import ProviderList from "./components/ProviderList";
|
||||
import AddProviderModal from "./components/AddProviderModal";
|
||||
import EditProviderModal from "./components/EditProviderModal";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { AppSwitcher } from "./components/AppSwitcher";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
@@ -60,7 +63,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
loadConfigStatus();
|
||||
}, []);
|
||||
}, [activeApp]); // 当切换应用时重新加载
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
@@ -72,19 +75,19 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const loadProviders = async () => {
|
||||
const loadedProviders = await window.api.getProviders();
|
||||
const currentId = await window.api.getCurrentProvider();
|
||||
const loadedProviders = await window.api.getProviders(activeApp);
|
||||
const currentId = await window.api.getCurrentProvider(activeApp);
|
||||
setProviders(loadedProviders);
|
||||
setCurrentProviderId(currentId);
|
||||
|
||||
// 如果供应商列表为空,尝试自动导入现有配置为"default"供应商
|
||||
// 如果供应商列表为空,尝试自动从 live 导入一条默认供应商
|
||||
if (Object.keys(loadedProviders).length === 0) {
|
||||
await handleAutoImportDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfigStatus = async () => {
|
||||
const status = await window.api.getClaudeConfigStatus();
|
||||
const status = await window.api.getConfigStatus(activeApp);
|
||||
setConfigStatus({
|
||||
exists: Boolean(status?.exists),
|
||||
path: String(status?.path || ""),
|
||||
@@ -101,14 +104,14 @@ function App() {
|
||||
...provider,
|
||||
id: generateId(),
|
||||
};
|
||||
await window.api.addProvider(newProvider);
|
||||
await window.api.addProvider(newProvider, activeApp);
|
||||
await loadProviders();
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const handleEditProvider = async (provider: Provider) => {
|
||||
try {
|
||||
await window.api.updateProvider(provider);
|
||||
await window.api.updateProvider(provider, activeApp);
|
||||
await loadProviders();
|
||||
setEditingProviderId(null);
|
||||
// 显示编辑成功提示
|
||||
@@ -127,7 +130,7 @@ function App() {
|
||||
title: "删除供应商",
|
||||
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
|
||||
onConfirm: async () => {
|
||||
await window.api.deleteProvider(id);
|
||||
await window.api.deleteProvider(id, activeApp);
|
||||
await loadProviders();
|
||||
setConfirmDialog(null);
|
||||
showNotification("供应商删除成功", "success");
|
||||
@@ -136,12 +139,13 @@ function App() {
|
||||
};
|
||||
|
||||
const handleSwitchProvider = async (id: string) => {
|
||||
const success = await window.api.switchProvider(id);
|
||||
const success = await window.api.switchProvider(id, activeApp);
|
||||
if (success) {
|
||||
setCurrentProviderId(id);
|
||||
// 显示重启提示
|
||||
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
|
||||
showNotification(
|
||||
"切换成功!请重启 Claude Code 终端以生效",
|
||||
`切换成功!请重启 ${appName} 终端以生效`,
|
||||
"success",
|
||||
2000,
|
||||
);
|
||||
@@ -150,18 +154,14 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// 自动导入现有配置为"default"供应商
|
||||
// 自动从 live 导入一条默认供应商(仅首次初始化时)
|
||||
const handleAutoImportDefault = async () => {
|
||||
try {
|
||||
const result = await window.api.importCurrentConfigAsDefault();
|
||||
const result = await window.api.importCurrentConfigAsDefault(activeApp);
|
||||
|
||||
if (result.success) {
|
||||
await loadProviders();
|
||||
showNotification(
|
||||
"已自动导入现有配置为 default 供应商",
|
||||
"success",
|
||||
3000,
|
||||
);
|
||||
showNotification("已从现有配置创建默认供应商", "success", 3000);
|
||||
}
|
||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
||||
} catch (error) {
|
||||
@@ -171,13 +171,16 @@ function App() {
|
||||
};
|
||||
|
||||
const handleOpenConfigFolder = async () => {
|
||||
await window.api.openConfigFolder();
|
||||
await window.api.openConfigFolder(activeApp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Claude Code 供应商切换器</h1>
|
||||
<h1>CC Switch</h1>
|
||||
<div className="app-tabs">
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
|
||||
添加供应商
|
||||
@@ -228,6 +231,7 @@ function App() {
|
||||
|
||||
{isAddModalOpen && (
|
||||
<AddProviderModal
|
||||
appType={activeApp}
|
||||
onAdd={handleAddProvider}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
/>
|
||||
@@ -235,6 +239,7 @@ function App() {
|
||||
|
||||
{editingProviderId && providers[editingProviderId] && (
|
||||
<EditProviderModal
|
||||
appType={activeApp}
|
||||
provider={providers[editingProviderId]}
|
||||
onSave={handleEditProvider}
|
||||
onClose={() => setEditingProviderId(null)}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React from "react";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
appType: AppType;
|
||||
onAdd: (provider: Omit<Provider, "id">) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
||||
appType,
|
||||
onAdd,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="添加新供应商"
|
||||
submitText="添加"
|
||||
showPresets={true}
|
||||
|
||||
73
src/components/AppSwitcher.css
Normal file
73
src/components/AppSwitcher.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* 药丸式切换按钮 */
|
||||
.switcher-pills {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 6px 8px;
|
||||
border-radius: 50px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.switcher-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.switcher-pill:hover:not(.active) {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.switcher-pill.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
box-shadow:
|
||||
inset 0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.4;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.switcher-pill.active .pill-dot {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.pills-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
36
src/components/AppSwitcher.tsx
Normal file
36
src/components/AppSwitcher.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import "./AppSwitcher.css";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppType;
|
||||
onSwitch: (app: AppType) => void;
|
||||
}
|
||||
|
||||
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
const handleSwitch = (app: AppType) => {
|
||||
if (app === activeApp) return;
|
||||
onSwitch(app);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="switcher-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
|
||||
onClick={() => handleSwitch("claude")}
|
||||
>
|
||||
<span className="pill-dot" />
|
||||
<span>Claude Code</span>
|
||||
</button>
|
||||
<div className="pills-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
|
||||
onClick={() => handleSwitch("codex")}
|
||||
>
|
||||
<span className="pill-dot" />
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from "react";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface EditProviderModalProps {
|
||||
appType: AppType;
|
||||
provider: Provider;
|
||||
onSave: (provider: Provider) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
appType,
|
||||
provider,
|
||||
onSave,
|
||||
onClose,
|
||||
@@ -22,6 +25,7 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="编辑供应商"
|
||||
submitText="保存"
|
||||
initialData={provider}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import {
|
||||
updateCoAuthoredSetting,
|
||||
checkCoAuthoredSetting,
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
setApiKeyInConfig,
|
||||
} from "../utils/providerConfigUtils";
|
||||
import { providerPresets } from "../config/providerPresets";
|
||||
import { codexProviderPresets } from "../config/codexProviderPresets";
|
||||
import "./AddProviderModal.css";
|
||||
|
||||
interface ProviderFormProps {
|
||||
appType?: AppType;
|
||||
title: string;
|
||||
submitText: string;
|
||||
initialData?: Provider;
|
||||
@@ -21,6 +24,7 @@ interface ProviderFormProps {
|
||||
}
|
||||
|
||||
const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
appType = "claude",
|
||||
title,
|
||||
submitText,
|
||||
initialData,
|
||||
@@ -28,6 +32,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
onSubmit,
|
||||
onClose,
|
||||
}) => {
|
||||
// 对于 Codex,需要分离 auth 和 config
|
||||
const isCodex = appType === "codex";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || "",
|
||||
websiteUrl: initialData?.websiteUrl || "",
|
||||
@@ -35,9 +42,40 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||
: "",
|
||||
});
|
||||
|
||||
// Codex 特有的状态
|
||||
const [codexAuth, setCodexAuth] = useState("");
|
||||
const [codexConfig, setCodexConfig] = useState("");
|
||||
const [codexApiKey, setCodexApiKey] = useState("");
|
||||
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
||||
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
|
||||
showPresets && isCodex ? -1 : null,
|
||||
);
|
||||
|
||||
// 初始化 Codex 配置
|
||||
useEffect(() => {
|
||||
if (isCodex && initialData) {
|
||||
const config = initialData.settingsConfig;
|
||||
if (typeof config === "object" && config !== null) {
|
||||
setCodexAuth(JSON.stringify(config.auth || {}, null, 2));
|
||||
setCodexConfig(config.config || "");
|
||||
try {
|
||||
const auth = config.auth || {};
|
||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||
setCodexApiKey(auth.OPENAI_API_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isCodex, initialData]);
|
||||
const [error, setError] = useState("");
|
||||
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
|
||||
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
||||
showPresets ? -1 : null,
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
// 初始化时检查禁用签名状态
|
||||
@@ -58,18 +96,55 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.settingsConfig.trim()) {
|
||||
setError("请填写配置内容");
|
||||
return;
|
||||
}
|
||||
|
||||
let settingsConfig: Record<string, any>;
|
||||
|
||||
try {
|
||||
settingsConfig = JSON.parse(formData.settingsConfig);
|
||||
} catch (err) {
|
||||
setError("配置JSON格式错误,请检查语法");
|
||||
return;
|
||||
if (isCodex) {
|
||||
// Codex: 仅要求 auth.json 必填;config.toml 可为空
|
||||
if (!codexAuth.trim()) {
|
||||
setError("请填写 auth.json 配置");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authJson = JSON.parse(codexAuth);
|
||||
|
||||
// 非官方预设强制要求 OPENAI_API_KEY
|
||||
if (selectedCodexPreset !== null) {
|
||||
const preset = codexProviderPresets[selectedCodexPreset];
|
||||
const isOfficial = Boolean(preset?.isOfficial);
|
||||
if (!isOfficial) {
|
||||
const key =
|
||||
typeof authJson.OPENAI_API_KEY === "string"
|
||||
? authJson.OPENAI_API_KEY.trim()
|
||||
: "";
|
||||
if (!key) {
|
||||
setError("请填写 OPENAI_API_KEY");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settingsConfig = {
|
||||
auth: authJson,
|
||||
config: codexConfig ?? "",
|
||||
};
|
||||
} catch (err) {
|
||||
setError("auth.json 格式错误,请检查JSON语法");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Claude: 原有逻辑
|
||||
if (!formData.settingsConfig.trim()) {
|
||||
setError("请填写配置内容");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
settingsConfig = JSON.parse(formData.settingsConfig);
|
||||
} catch (err) {
|
||||
setError("配置JSON格式错误,请检查语法");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
@@ -145,6 +220,52 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
};
|
||||
|
||||
// 处理点击自定义按钮
|
||||
const handleCustomClick = () => {
|
||||
setSelectedPreset(-1);
|
||||
setFormData({
|
||||
name: "",
|
||||
websiteUrl: "",
|
||||
settingsConfig: "",
|
||||
});
|
||||
setApiKey("");
|
||||
setDisableCoAuthored(false);
|
||||
};
|
||||
|
||||
// Codex: 应用预设
|
||||
const applyCodexPreset = (
|
||||
preset: (typeof codexProviderPresets)[0],
|
||||
index: number,
|
||||
) => {
|
||||
const authString = JSON.stringify(preset.auth || {}, null, 2);
|
||||
setCodexAuth(authString);
|
||||
setCodexConfig(preset.config || "");
|
||||
|
||||
setFormData({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl,
|
||||
settingsConfig: formData.settingsConfig,
|
||||
});
|
||||
|
||||
setSelectedCodexPreset(index);
|
||||
|
||||
// 清空 API Key,让用户重新输入
|
||||
setCodexApiKey("");
|
||||
};
|
||||
|
||||
// Codex: 处理点击自定义按钮
|
||||
const handleCodexCustomClick = () => {
|
||||
setSelectedCodexPreset(-1);
|
||||
setFormData({
|
||||
name: "",
|
||||
websiteUrl: "",
|
||||
settingsConfig: "",
|
||||
});
|
||||
setCodexAuth("");
|
||||
setCodexConfig("");
|
||||
setCodexApiKey("");
|
||||
};
|
||||
|
||||
// 处理 API Key 输入并自动更新配置
|
||||
const handleApiKeyChange = (key: string) => {
|
||||
setApiKey(key);
|
||||
@@ -152,7 +273,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
const configString = setApiKeyInConfig(
|
||||
formData.settingsConfig,
|
||||
key.trim(),
|
||||
{ createIfMissing: selectedPreset !== null },
|
||||
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
|
||||
);
|
||||
|
||||
// 更新表单配置
|
||||
@@ -166,15 +287,48 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
};
|
||||
|
||||
// Codex: 处理 API Key 输入并写回 auth.json
|
||||
const handleCodexApiKeyChange = (key: string) => {
|
||||
setCodexApiKey(key);
|
||||
try {
|
||||
const auth = JSON.parse(codexAuth || "{}");
|
||||
auth.OPENAI_API_KEY = key.trim();
|
||||
setCodexAuth(JSON.stringify(auth, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 根据当前配置决定是否展示 API Key 输入框
|
||||
// 自定义模式(-1)不显示独立的 API Key 输入框
|
||||
const showApiKey =
|
||||
selectedPreset !== null || hasApiKeyField(formData.settingsConfig);
|
||||
(selectedPreset !== null && selectedPreset !== -1) ||
|
||||
(!showPresets && hasApiKeyField(formData.settingsConfig));
|
||||
|
||||
// 判断当前选中的预设是否是官方
|
||||
const isOfficialPreset =
|
||||
selectedPreset !== null &&
|
||||
selectedPreset >= 0 &&
|
||||
providerPresets[selectedPreset]?.isOfficial === true;
|
||||
|
||||
// Codex: 控制显示 API Key 与官方标记
|
||||
const getCodexAuthApiKey = (authString: string): string => {
|
||||
try {
|
||||
const auth = JSON.parse(authString || "{}");
|
||||
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
// 自定义模式(-1)不显示独立的 API Key 输入框
|
||||
const showCodexApiKey =
|
||||
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
|
||||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
|
||||
const isCodexOfficialPreset =
|
||||
selectedCodexPreset !== null &&
|
||||
selectedCodexPreset >= 0 &&
|
||||
codexProviderPresets[selectedCodexPreset]?.isOfficial === true;
|
||||
|
||||
// 初始时从配置中同步 API Key(编辑模式)
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
@@ -226,10 +380,19 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
<div className="modal-body">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{showPresets && (
|
||||
{showPresets && !isCodex && (
|
||||
<div className="presets">
|
||||
<label>一键导入(只需要填写 key)</label>
|
||||
<label>选择配置类型</label>
|
||||
<div className="preset-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className={`preset-btn ${
|
||||
selectedPreset === -1 ? "selected" : ""
|
||||
}`}
|
||||
onClick={handleCustomClick}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
{providerPresets.map((preset, index) => {
|
||||
return (
|
||||
<button
|
||||
@@ -245,6 +408,71 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedPreset === -1 && (
|
||||
<small
|
||||
className="field-hint"
|
||||
style={{ marginTop: "8px", display: "block" }}
|
||||
>
|
||||
手动配置供应商,需要填写完整的配置信息
|
||||
</small>
|
||||
)}
|
||||
{selectedPreset !== -1 && selectedPreset !== null && (
|
||||
<small
|
||||
className="field-hint"
|
||||
style={{ marginTop: "8px", display: "block" }}
|
||||
>
|
||||
{isOfficialPreset
|
||||
? "Claude 官方登录,不需要填写 API Key"
|
||||
: "使用预设配置,只需填写 API Key"}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPresets && isCodex && (
|
||||
<div className="presets">
|
||||
<label>选择配置类型</label>
|
||||
<div className="preset-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className={`preset-btn ${
|
||||
selectedCodexPreset === -1 ? "selected" : ""
|
||||
}`}
|
||||
onClick={handleCodexCustomClick}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
{codexProviderPresets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`preset-btn ${
|
||||
selectedCodexPreset === index ? "selected" : ""
|
||||
} ${preset.isOfficial ? "official" : ""}`}
|
||||
onClick={() => applyCodexPreset(preset, index)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedCodexPreset === -1 && (
|
||||
<small
|
||||
className="field-hint"
|
||||
style={{ marginTop: "8px", display: "block" }}
|
||||
>
|
||||
手动配置供应商,需要填写完整的配置信息
|
||||
</small>
|
||||
)}
|
||||
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
|
||||
<small
|
||||
className="field-hint"
|
||||
style={{ marginTop: "8px", display: "block" }}
|
||||
>
|
||||
{isCodexOfficialPreset
|
||||
? "Codex 官方登录,不需要填写 API Key"
|
||||
: "使用预设配置,只需填写 API Key"}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -262,33 +490,70 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
|
||||
>
|
||||
<label htmlFor="apiKey">API Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={
|
||||
isOfficialPreset
|
||||
? "官方登录无需填写 API Key,直接保存即可"
|
||||
: "只需要填这里,下方配置会自动填充"
|
||||
}
|
||||
disabled={isOfficialPreset}
|
||||
autoComplete="off"
|
||||
style={
|
||||
isOfficialPreset
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
cursor: "not-allowed",
|
||||
color: "#999",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!isCodex && (
|
||||
<div
|
||||
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
|
||||
>
|
||||
<label htmlFor="apiKey">API Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={
|
||||
isOfficialPreset
|
||||
? "官方登录无需填写 API Key,直接保存即可"
|
||||
: "只需要填这里,下方配置会自动填充"
|
||||
}
|
||||
disabled={isOfficialPreset}
|
||||
autoComplete="off"
|
||||
style={
|
||||
isOfficialPreset
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
cursor: "not-allowed",
|
||||
color: "#999",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCodex && (
|
||||
<div
|
||||
className={`form-group api-key-group ${!showCodexApiKey ? "hidden" : ""}`}
|
||||
>
|
||||
<label htmlFor="codexApiKey">API Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="codexApiKey"
|
||||
value={codexApiKey}
|
||||
onChange={(e) => handleCodexApiKeyChange(e.target.value)}
|
||||
placeholder={
|
||||
isCodexOfficialPreset
|
||||
? "官方无需填写 API Key,直接保存即可"
|
||||
: "只需要填这里,下方 auth.json 会自动填充"
|
||||
}
|
||||
disabled={isCodexOfficialPreset}
|
||||
required={
|
||||
selectedCodexPreset !== null &&
|
||||
selectedCodexPreset >= 0 &&
|
||||
!isCodexOfficialPreset
|
||||
}
|
||||
autoComplete="off"
|
||||
style={
|
||||
isCodexOfficialPreset
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
cursor: "not-allowed",
|
||||
color: "#999",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="websiteUrl">官网地址</label>
|
||||
@@ -303,39 +568,90 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="label-with-checkbox">
|
||||
<label htmlFor="settingsConfig">
|
||||
Claude Code 配置 (JSON) *
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCoAuthored}
|
||||
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
|
||||
{/* Claude 或 Codex 的配置部分 */}
|
||||
{isCodex ? (
|
||||
// Codex: 双编辑器
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="codexAuth">auth.json (JSON) *</label>
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
value={codexAuth}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setCodexAuth(value);
|
||||
try {
|
||||
const auth = JSON.parse(value || "{}");
|
||||
const key =
|
||||
typeof auth.OPENAI_API_KEY === "string"
|
||||
? auth.OPENAI_API_KEY
|
||||
: "";
|
||||
setCodexApiKey(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
placeholder={`{
|
||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
||||
}`}
|
||||
rows={6}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
required
|
||||
/>
|
||||
禁止 Claude Code 签名
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
id="settingsConfig"
|
||||
name="settingsConfig"
|
||||
value={formData.settingsConfig}
|
||||
onChange={handleChange}
|
||||
placeholder={`{
|
||||
<small className="field-hint">Codex auth.json 配置内容</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="codexConfig">config.toml (TOML)</label>
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
value={codexConfig}
|
||||
onChange={(e) => setCodexConfig(e.target.value)}
|
||||
placeholder={``}
|
||||
rows={8}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
/>
|
||||
<small className="field-hint">
|
||||
Codex config.toml 配置内容
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Claude: 原有的单编辑器
|
||||
<div className="form-group">
|
||||
<div className="label-with-checkbox">
|
||||
<label htmlFor="settingsConfig">
|
||||
Claude Code 配置 (JSON) *
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCoAuthored}
|
||||
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
|
||||
/>
|
||||
禁止 Claude Code 签名
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
id="settingsConfig"
|
||||
name="settingsConfig"
|
||||
value={formData.settingsConfig}
|
||||
onChange={handleChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</small>
|
||||
</div>
|
||||
rows={12}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
|
||||
@@ -94,7 +94,6 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={() => onEdit(provider.id)}
|
||||
disabled={isCurrent}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
|
||||
41
src/config/codexProviderPresets.ts
Normal file
41
src/config/codexProviderPresets.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
export interface CodexProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
}
|
||||
|
||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
{
|
||||
name: "Codex官方",
|
||||
websiteUrl: "https://chatgpt.com/codex",
|
||||
isOfficial: true,
|
||||
// 官方的 key 为null
|
||||
auth: {
|
||||
OPENAI_API_KEY: null,
|
||||
},
|
||||
config: ``,
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://codex.packycode.com/",
|
||||
// PackyCode 一般通过 API Key;请将占位符替换为你的实际 key
|
||||
auth: {
|
||||
OPENAI_API_KEY: "sk-your-api-key-here",
|
||||
},
|
||||
config: `model_provider = "packycode"
|
||||
model = "gpt-5"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.packycode]
|
||||
name = "packycode"
|
||||
base_url = "https://codex-api.packycode.com/v1"
|
||||
wire_api = "responses"
|
||||
env_key = "packycode"`,
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,9 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Provider } from "../types";
|
||||
|
||||
// 应用类型
|
||||
export type AppType = "claude" | "codex";
|
||||
|
||||
// 定义配置状态类型
|
||||
interface ConfigStatus {
|
||||
exists: boolean;
|
||||
@@ -17,9 +20,9 @@ interface ImportResult {
|
||||
// Tauri API 封装,提供统一的全局 API 接口
|
||||
export const tauriAPI = {
|
||||
// 获取所有供应商
|
||||
getProviders: async (): Promise<Record<string, Provider>> => {
|
||||
getProviders: async (app?: AppType): Promise<Record<string, Provider>> => {
|
||||
try {
|
||||
return await invoke("get_providers");
|
||||
return await invoke("get_providers", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取供应商列表失败:", error);
|
||||
return {};
|
||||
@@ -27,9 +30,9 @@ export const tauriAPI = {
|
||||
},
|
||||
|
||||
// 获取当前供应商ID
|
||||
getCurrentProvider: async (): Promise<string> => {
|
||||
getCurrentProvider: async (app?: AppType): Promise<string> => {
|
||||
try {
|
||||
return await invoke("get_current_provider");
|
||||
return await invoke("get_current_provider", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取当前供应商失败:", error);
|
||||
return "";
|
||||
@@ -37,9 +40,9 @@ export const tauriAPI = {
|
||||
},
|
||||
|
||||
// 添加供应商
|
||||
addProvider: async (provider: Provider): Promise<boolean> => {
|
||||
addProvider: async (provider: Provider, app?: AppType): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("add_provider", { provider });
|
||||
return await invoke("add_provider", { provider, app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("添加供应商失败:", error);
|
||||
throw error;
|
||||
@@ -47,9 +50,12 @@ export const tauriAPI = {
|
||||
},
|
||||
|
||||
// 更新供应商
|
||||
updateProvider: async (provider: Provider): Promise<boolean> => {
|
||||
updateProvider: async (
|
||||
provider: Provider,
|
||||
app?: AppType,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("update_provider", { provider });
|
||||
return await invoke("update_provider", { provider, app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("更新供应商失败:", error);
|
||||
throw error;
|
||||
@@ -57,9 +63,9 @@ export const tauriAPI = {
|
||||
},
|
||||
|
||||
// 删除供应商
|
||||
deleteProvider: async (id: string): Promise<boolean> => {
|
||||
deleteProvider: async (id: string, app?: AppType): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("delete_provider", { id });
|
||||
return await invoke("delete_provider", { id, app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("删除供应商失败:", error);
|
||||
throw error;
|
||||
@@ -67,9 +73,16 @@ export const tauriAPI = {
|
||||
},
|
||||
|
||||
// 切换供应商
|
||||
switchProvider: async (providerId: string): Promise<boolean> => {
|
||||
switchProvider: async (
|
||||
providerId: string,
|
||||
app?: AppType,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("switch_provider", { id: providerId });
|
||||
return await invoke("switch_provider", {
|
||||
id: providerId,
|
||||
app_type: app,
|
||||
app,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("切换供应商失败:", error);
|
||||
return false;
|
||||
@@ -77,9 +90,14 @@ export const tauriAPI = {
|
||||
},
|
||||
|
||||
// 导入当前配置为默认供应商
|
||||
importCurrentConfigAsDefault: async (): Promise<ImportResult> => {
|
||||
importCurrentConfigAsDefault: async (
|
||||
app?: AppType,
|
||||
): Promise<ImportResult> => {
|
||||
try {
|
||||
const success = await invoke<boolean>("import_default_config");
|
||||
const success = await invoke<boolean>("import_default_config", {
|
||||
app_type: app,
|
||||
app,
|
||||
});
|
||||
return {
|
||||
success,
|
||||
message: success ? "成功导入默认配置" : "导入失败",
|
||||
@@ -117,10 +135,24 @@ export const tauriAPI = {
|
||||
}
|
||||
},
|
||||
|
||||
// 打开配置文件夹
|
||||
openConfigFolder: async (): Promise<void> => {
|
||||
// 获取应用配置状态(通用)
|
||||
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
|
||||
try {
|
||||
await invoke("open_config_folder");
|
||||
return await invoke("get_config_status", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取配置状态失败:", error);
|
||||
return {
|
||||
exists: false,
|
||||
path: "",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 打开配置文件夹
|
||||
openConfigFolder: async (app?: AppType): Promise<void> => {
|
||||
try {
|
||||
await invoke("open_config_folder", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("打开配置文件夹失败:", error);
|
||||
}
|
||||
@@ -135,6 +167,8 @@ export const tauriAPI = {
|
||||
}
|
||||
},
|
||||
|
||||
// (保留空位,取消迁移提示)
|
||||
|
||||
// 选择配置文件(Tauri 暂不实现,保留接口兼容性)
|
||||
selectConfigFile: async (): Promise<string | null> => {
|
||||
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
settingsConfig: Record<string, any>; // 完整的 Claude Code settings.json 配置
|
||||
settingsConfig: Record<string, any>; // 应用配置对象:Claude 为 settings.json;Codex 为 { auth, config }
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
18
src/vite-env.d.ts
vendored
18
src/vite-env.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { Provider } from "./types";
|
||||
import { AppType } from "./lib/tauri-api";
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
@@ -16,17 +17,18 @@ interface ConfigStatus {
|
||||
declare global {
|
||||
interface Window {
|
||||
api: {
|
||||
getProviders: () => Promise<Record<string, Provider>>;
|
||||
getCurrentProvider: () => Promise<string>;
|
||||
addProvider: (provider: Provider) => Promise<boolean>;
|
||||
deleteProvider: (id: string) => Promise<boolean>;
|
||||
updateProvider: (provider: Provider) => Promise<boolean>;
|
||||
switchProvider: (providerId: string) => Promise<boolean>;
|
||||
importCurrentConfigAsDefault: () => Promise<ImportResult>;
|
||||
getProviders: (app?: AppType) => Promise<Record<string, Provider>>;
|
||||
getCurrentProvider: (app?: AppType) => Promise<string>;
|
||||
addProvider: (provider: Provider, app?: AppType) => Promise<boolean>;
|
||||
deleteProvider: (id: string, app?: AppType) => Promise<boolean>;
|
||||
updateProvider: (provider: Provider, app?: AppType) => Promise<boolean>;
|
||||
switchProvider: (providerId: string, app?: AppType) => Promise<boolean>;
|
||||
importCurrentConfigAsDefault: (app?: AppType) => Promise<ImportResult>;
|
||||
getClaudeCodeConfigPath: () => Promise<string>;
|
||||
getClaudeConfigStatus: () => Promise<ConfigStatus>;
|
||||
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
|
||||
selectConfigFile: () => Promise<string | null>;
|
||||
openConfigFolder: () => Promise<void>;
|
||||
openConfigFolder: (app?: AppType) => Promise<void>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
};
|
||||
platform: {
|
||||
|
||||
Reference in New Issue
Block a user