diff --git a/CHANGELOG.md b/CHANGELOG.md index 305ca35..4e521d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.5.0] - 2025-01-15 +### ⚠ Breaking Changes + +- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。 +- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。 + ### ✨ New Features - **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system @@ -248,3 +253,17 @@ For users upgrading from v2.x (Electron version): - Basic provider management - Claude Code integration - Configuration file handling +## [Unreleased] + +### ⚠️ Breaking Changes + +- Tauri 命令统一仅接受 `app` 参数,移除历史 `app_type`/`appType` 兼容路径;传入未知 `app` 时会明确报错,并提示可选值。 + +### 🔧 Improvements + +- 统一 `AppType` 解析:集中到 `FromStr` 实现,命令层不再各自实现 `parse_app()`,减少重复与漂移。 +- 错误消息本地化与更友好:对不支持的 `app` 返回中英双语提示,并包含可选值清单。 + +### 🧪 Tests + +- 新增单元测试覆盖 `AppType::from_str`:大小写、裁剪空白、未知值错误消息。 diff --git a/README.md b/README.md index f87da3c..5a14c27 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ # Claude Code & Codex 供应商切换器 -[![Version](https://img.shields.io/badge/version-3.5.0-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.5.1-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) 一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。 -> **📢 重要通知**:CC Switch 即将进行大规模重构,请暂缓提交新的 PR,感谢理解与配合! - > v3.5.0 :新增 **MCP 管理**、**配置导入/导出**、**端点速度测试**功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。 > v3.4.0 :新增 i18next 国际化(还有部分未完成)、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。 @@ -97,6 +95,15 @@ brew upgrade --cask cc-switch 4. 重启或新开终端以确保生效 5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录 +### MCP 配置说明(v3.5.x) + +- 管理位置:所有 MCP 服务器定义集中保存在 `~/.cc-switch/config.json`(按客户端 `claude` / `codex` 分类) +- 同步机制: + - 启用的 Claude MCP 会投影到 `~/.claude.json`(路径可随覆盖目录而变化) + - 启用的 Codex MCP 会投影到 `~/.codex/config.toml` +- 校验与归一化:新增/导入时自动校验字段合法性(stdio/http),并自动修复/填充 `id` 等键名 +- 导入来源:支持从 `~/.claude.json` 与 `~/.codex/config.toml` 导入;已存在条目只强制 `enabled=true`,不覆盖其他字段 + ### 检查更新 - 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面 @@ -137,6 +144,32 @@ brew upgrade --cask cc-switch - v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup..json` 以便回滚 - 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案 +## 架构总览(v3.5.x) + +- 前端(Renderer) + - 技术栈:TypeScript + React 18 + Vite + TailwindCSS + - 数据层:TanStack React Query 统一查询与变更(`@/lib/query`),Tauri API 统一封装(`@/lib/api`) + - 事件流:监听后端 `provider-switched` 事件,驱动 UI 刷新与托盘状态一致 + - 组织结构:按领域拆分组件(providers/settings/mcp),动作逻辑下沉至 Hooks(如 `useProviderActions`) + +- 后端(Tauri + Rust) + - Commands(接口层):`src-tauri/src/commands/*` 按领域拆分(provider/config/mcp 等) + - Services(业务层):`src-tauri/src/services/*` 承载核心逻辑(Provider/MCP/Config/Speedtest) + - 模型与状态:`provider.rs`(领域模型)+ `app_config.rs`(多应用配置)+ `store.rs`(全局 RwLock) + - 可靠性: + - 统一错误类型 `AppError`(包含本地化消息) + - 事务式变更(配置快照 + 失败回滚)与原子写入(避免半写入) + - 托盘菜单与事件:切换后重建菜单并向前端发射 `provider-switched` 事件 + +- 设计要点(SSOT) + - 单一事实源:供应商配置集中存放于 `~/.cc-switch/config.json` + - 切换时仅写 live 配置(Claude: `settings.json`;Codex: `auth.json` + `config.toml`) + - 首次缺省导入:当某应用无任何供应商时,会从已有 live 配置生成默认项 + +- 兼容性与变更 + - 命令参数统一:Tauri 命令仅接受 `app`(值为 `claude` / `codex`) + - 前端类型统一:使用 `AppId` 表达应用标识(替代历史 `AppType` 导出) + ## 开发 ### 环境要求 @@ -164,6 +197,12 @@ pnpm format # 检查代码格式 pnpm format:check +# 运行前端单元测试 +pnpm test:unit + +# 监听模式运行测试 +pnpm test:unit:watch + # 构建应用 pnpm build @@ -193,18 +232,27 @@ cargo test - **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript - **[Vite](https://vitejs.dev/)** - 极速的前端构建工具 - **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端) +- **[TanStack Query](https://tanstack.com/query/latest)** - 前端数据获取与缓存 +- **[i18next](https://www.i18next.com/)** - 国际化框架 ## 项目结构 ``` -├── src/ # 前端代码 (React + TypeScript) -│ ├── components/ # React 组件 -│ ├── config/ # 预设供应商配置 -│ ├── lib/ # Tauri API 封装 -│ └── utils/ # 工具函数 +├── src/ # 前端代码 (React + TypeScript) +│ ├── components/ # React 组件(providers/settings/mcp/ui 等) +│ ├── hooks/ # 领域动作与状态(如 useProviderActions) +│ ├── lib/ +│ │ ├── api/ # Tauri API 封装(providers/settings/mcp 等) +│ │ └── query/ # TanStack Query 查询/变更与 client +│ ├── i18n/ # 国际化资源 +│ ├── config/ # 供应商/MCP 预设 +│ └── utils/ # 工具函数 ├── src-tauri/ # 后端代码 (Rust) │ ├── src/ # Rust 源代码 -│ │ ├── commands.rs # Tauri 命令定义 +│ │ ├── commands/ # Tauri 命令定义(按域拆分) +│ │ ├── services/ # 领域服务(Provider/MCP/Speedtest 等) +│ │ ├── mcp.rs # MCP 同步与规范化 +│ │ ├── migration.rs # 配置迁移逻辑 │ │ ├── config.rs # 配置文件管理 │ │ ├── provider.rs # 供应商管理逻辑 │ │ └── store.rs # 状态管理 @@ -227,6 +275,11 @@ cargo test 欢迎提交 Issue 反馈问题和建议! +提交 PR 前请确保: +- 通过类型检查:`pnpm typecheck` +- 通过格式检查:`pnpm format:check` +- 通过单元测试:`pnpm test:unit` + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date) diff --git a/README_i18n.md b/README_i18n.md index 1caf9b9..eef75a7 100644 --- a/README_i18n.md +++ b/README_i18n.md @@ -67,7 +67,7 @@ src/ - ✅ EditProviderModal.tsx - 编辑供应商弹窗 - ✅ ProviderList.tsx - 供应商列表 - ✅ LanguageSwitcher.tsx - 语言切换器 -- 🔄 SettingsModal.tsx - 设置弹窗(部分完成) +- ✅ settings/SettingsDialog.tsx - 设置对话框 ## 注意事项 diff --git a/components.json b/components.json new file mode 100644 index 0000000..6977d86 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md new file mode 100644 index 0000000..9c8b75f --- /dev/null +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -0,0 +1,169 @@ +# CC Switch Rust 后端重构方案 + +## 目录 +- [背景与现状](#背景与现状) +- [问题确认](#问题确认) +- [方案评估](#方案评估) +- [渐进式重构路线](#渐进式重构路线) +- [测试策略](#测试策略) +- [风险与对策](#风险与对策) +- [总结](#总结) + +## 背景与现状 +- 前端已完成重构,后端 (Tauri + Rust) 仍维持历史结构。 +- 核心文件集中在 `src-tauri/src/commands.rs`、`lib.rs` 等超大文件中,业务逻辑与界面事件耦合严重。 +- 测试覆盖率低,只有零散单元测试,缺乏集成验证。 + +## 问题确认 + +| 提案问题 | 实际情况 | 严重程度 | +| --- | --- | --- | +| `commands.rs` 过长 | ✅ 1526 行,包含 32 个命令,职责混杂 | 🔴 高 | +| `lib.rs` 缺少服务层 | ✅ 541 行,托盘/事件/业务逻辑耦合 | 🟡 中 | +| `Result` 泛滥 | ✅ 118 处,错误上下文丢失 | 🟡 中 | +| 全局 `Mutex` 阻塞 | ✅ 31 处 `.lock()` 调用,读写不分离 | 🟡 中 | +| 配置逻辑分散 | ✅ 分布在 5 个文件 (`config`/`app_config`/`app_store`/`settings`/`codex_config`) | 🟢 低 | + +代码规模分布(约 5.4k SLOC): +- `commands.rs`: 1526 行(28%)→ 第一优先级 🎯 +- `lib.rs`: 541 行(10%)→ 托盘逻辑与业务耦合 +- `mcp.rs`: 732 行(14%)→ 相对清晰 +- `migration.rs`: 431 行(8%)→ 一次性逻辑 +- 其他文件合计:2156 行(40%) + +## 方案评估 + +### ✅ 优点 +1. **分层架构清晰** + - `commands/`:Tauri 命令薄层 + - `services/`:业务流程,如供应商切换、MCP 同步 + - `infrastructure/`:配置读写、外设交互 + - `domain/`:数据模型 (`Provider`, `AppType` 等) + → 提升可测试性、降低耦合度、方便团队协作。 + +2. **统一错误处理** + - 引入 `AppError`(`thiserror`),保留错误链和上下文。 + - Tauri 命令仍返回 `Result`,通过 `From` 自动转换。 + - 改善日志可读性,利于排查。 + +3. **并发优化** + - `AppState` 切换为 `RwLock`。 + - 读多写少的场景提升吞吐(如频繁查询供应商列表)。 + +### ⚠️ 风险 +1. **过度设计** + - 完整 DDD 四层在 5k 行项目中会增加 30-50% 维护成本。 + - Rust trait + repository 样板较多,收益不足。 + - 推荐“轻量分层”而非正统 DDD。 + +2. **迁移成本高** + - `commands.rs` 拆分、错误统一、锁改造触及多文件。 + - 测试缺失导致重构风险高,需先补测试。 + - 估算完整改造需 5-6 周;建议分阶段输出可落地价值。 + +3. **技术选型需谨慎** + - `parking_lot` 相比标准库 `RwLock` 提升有限,不必引入。 + - `spawn_blocking` 仅用于 >100ms 的阻塞任务,避免滥用。 + - 以现有依赖为主,控制复杂度。 + +## 实施进度 +- **阶段 1:统一错误处理 ✅** + - 引入 `thiserror` 并在 `src-tauri/src/error.rs` 定义 `AppError`,提供常用构造函数和 `From for String`,保留错误链路。 + - 配置、存储、同步等核心模块(`config.rs`、`app_config.rs`、`app_store.rs`、`store.rs`、`codex_config.rs`、`claude_mcp.rs`、`claude_plugin.rs`、`import_export.rs`、`mcp.rs`、`migration.rs`、`speedtest.rs`、`usage_script.rs`、`settings.rs`、`lib.rs` 等)已统一返回 `Result<_, AppError>`,避免字符串错误丢失上下文。 + - Tauri 命令层继续返回 `Result<_, String>`,通过 `?` + `Into` 统一转换,前端无需调整。 + - `cargo check` 通过,`rg "Result<[^>]+, String"` 巡检确认除命令层外已无字符串错误返回。 +- **阶段 2:拆分命令层 ✅** + - 已将单一 `src-tauri/src/commands.rs` 拆分为 `commands/{provider,mcp,config,settings,misc,plugin}.rs` 并通过 `commands/mod.rs` 统一导出,保持对外 API 不变。 + - 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。 + - 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。 +- **阶段 3:补充测试 ✅** + - `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。 + - 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。 + - 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。 + - 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。 + - 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。 + - 补充 Claude 切换集成测试,验证 live `settings.json` 覆写、新旧供应商快照回填以及 `.cc-switch/config.json` 持久化结果,确保阶段四提取服务层时拥有可回归的用例。 + - 增加 Codex 缺失 `auth` 场景测试,确认 `switch_provider_internal` 在关键字段缺失时返回带上下文的 `AppError`,同时保持内存状态未被污染。 + - 为配置导入命令抽取复用逻辑 `import_config_from_path` 并补充成功/失败集成测试,校验备份生成、状态同步、JSON 解析与文件缺失等错误回退路径;`export_config_to_file` 亦具备成功/缺失源文件的命令级回归。 + - 新增 `tests/mcp_commands.rs`,通过测试钩子覆盖 `import_default_config`、`import_mcp_from_claude`、`set_mcp_enabled` 等命令层行为,验证缺失文件/非法 JSON 的错误回滚以及成功路径落盘效果;阶段三目标达成,命令层关键边界已具备回归保障。 +- **阶段 4:服务层抽象 🚧(进行中)** + - 新增 `services/provider.rs` 并实现 `ProviderService::switch` / `delete`,集中处理供应商切换、回填、MCP 同步等核心业务;命令层改为薄封装并在 `tests/provider_service.rs`、`tests/provider_commands.rs` 中完成成功与失败路径的集成验证。 + - 新增 `services/mcp.rs` 提供 `McpService`,封装 MCP 服务器的查询、增删改、启用同步与导入流程;命令层改为参数解析 + 调用服务,`tests/mcp_commands.rs` 直接使用 `McpService` 验证成功与失败路径,阶段三测试继续适配。 + - `McpService` 在内部先复制内存快照、释放写锁,再执行文件同步,避免阶段五升级后的 `RwLock` 在 I/O 场景被长时间占用;`upsert/delete/set_enabled/sync_enabled` 均已修正。 + - 新增 `services/config.rs` 提供 `ConfigService`,统一处理配置导入导出、备份与 live 同步;命令层迁移至 `commands/import_export.rs`,在落盘操作前释放锁并复用现有集成测试。 + - 新增 `services/speedtest.rs` 并实现 `SpeedtestService::test_endpoints`,将 URL 校验、超时裁剪与网络请求封装在服务层,命令改为薄封装;补充单元测试覆盖空列表与非法 URL 分支。 + - 后续可选:应用设置(Store)命令仍较薄,可按需评估是否抽象;当前阶段四核心服务已基本齐备。 +- **阶段 5:锁与阻塞优化 ✅(首轮)** + - `AppState` 已由 `Mutex` 切换为 `RwLock`,托盘、命令与测试均按读写语义区分 `read()` / `write()`;`cargo test` 全量通过验证并未破坏现有流程。 + - 针对高开销 IO 的配置导入/导出命令提取 `load_config_for_import`,并通过 `tauri::async_runtime::spawn_blocking` 将文件读写与备份迁至阻塞线程,保持命令处理线程轻量。 + - 其余命令梳理后确认仍属轻量同步操作,暂不额外引入 `spawn_blocking`;若后续出现新的长耗时流程,再按同一模式扩展。 + +## 渐进式重构路线 + +### 阶段 1:统一错误处理(高收益 / 低风险) +- 新增 `src-tauri/src/error.rs`,定义 `AppError`。 +- 底层文件 IO、配置解析等函数返回 `Result`。 +- 命令层通过 `?` 自动传播,最终 `.map_err(Into::into)`。 +- 预估 3-5 天,立即启动。 + +### 阶段 2:拆分 `commands.rs`(高收益 / 中风险) +- 按业务拆分为 `commands/provider.rs`、`commands/mcp.rs`、`commands/config.rs`、`commands/settings.rs`、`commands/misc.rs`。 +- `commands/mod.rs` 统一导出和注册。 +- 文件行数降低到 200-300 行/文件,职责单一。 +- 预估 5-7 天,可并行进行部分重构。 + +### 阶段 3:补充测试(中收益 / 中风险) +- 引入 `tests/` 或 `src-tauri/tests/` 集成测试,覆盖供应商切换、MCP 同步、配置迁移。 +- 使用 `tempfile`/`tempdir` 隔离文件系统,组合少量回归脚本。 +- 预估 5-7 天,为后续重构提供安全网。 + +### 阶段 4:提取轻量服务层(中收益 / 中风险) +- 新增 `services/provider_service.rs`、`services/mcp_service.rs`。 +- 不强制使用 trait;直接以自由函数/结构体实现业务流程。 + ```rust + pub struct ProviderService; + impl ProviderService { + pub fn switch(config: &mut MultiAppConfig, app: AppType, id: &str) -> Result<(), AppError> { + // 业务流程:验证、回填、落盘、更新 current、触发事件 + } + } + ``` +- 命令层负责参数解析,服务层处理业务逻辑,托盘逻辑重用同一接口。 +- 预估 7-10 天,可在测试补齐后执行。 + +### 阶段 5:锁与阻塞优化(低收益 / 低风险) +- ✅ `AppState` 已从 `Mutex` 切换为 `RwLock`,命令与托盘读写按需区分,现有测试全部通过。 +- ✅ 配置导入/导出命令通过 `spawn_blocking` 处理高开销文件 IO;其他命令维持同步执行以避免不必要调度。 +- 🔄 持续监控:若后续引入新的批量迁移或耗时任务,再按相同模式扩展到阻塞线程;观察运行时锁竞争情况,必要时考虑进一步拆分状态或引入缓存。 + +## 测试策略 +- **优先覆盖场景** + - 供应商切换:状态更新 + live 配置同步 + - MCP 同步:enabled 服务器快照与落盘 + - 配置迁移:归档、备份与版本升级 +- **推荐结构** + ```rust + #[cfg(test)] + mod integration { + use super::*; + #[test] + fn switch_provider_updates_live_config() { /* ... */ } + #[test] + fn sync_mcp_to_codex_updates_claude_config() { /* ... */ } + #[test] + fn migration_preserves_backup() { /* ... */ } + } + ``` +- 目标覆盖率:关键路径 >80%,文件 IO/迁移 >70%。 + +## 风险与对策 +- **测试不足** → 阶段 3 强制补齐,建立基础集成测试。 +- **重构跨度大** → 按阶段在独立分支推进(如 `refactor/backend-step1` 等)。 +- **回滚困难** → 每阶段结束打 tag(如 `v3.6.0-backend-step1`),保留回滚点。 +- **功能回归** → 重构后执行手动冒烟流程:供应商切换、托盘操作、MCP 同步、配置导入导出。 + +## 总结 +- 当前规模下不建议整体引入完整 DDD/四层架构,避免过度设计。 +- 建议遵循“错误统一 → 命令拆分 → 补测试 → 服务层抽象 → 锁优化”的渐进式策略。 +- 完成阶段 1-3 后即可显著提升可维护性与可靠性;阶段 4-5 可根据资源灵活安排。 +- 重构过程中同步维护文档与测试,确保团队成员对架构演进保持一致认知。 diff --git a/docs/REFACTORING_CHECKLIST.md b/docs/REFACTORING_CHECKLIST.md new file mode 100644 index 0000000..657a8e1 --- /dev/null +++ b/docs/REFACTORING_CHECKLIST.md @@ -0,0 +1,490 @@ +# CC Switch 重构实施清单 + +> 用于跟踪重构进度的详细检查清单 + +**开始日期**: ___________ +**预计完成**: ___________ +**当前阶段**: ___________ + +--- + +## 📋 阶段 0: 准备阶段 (预计 1 天) + +### 环境准备 + +- [ ] 创建新分支 `refactor/modernization` +- [ ] 创建备份标签 `git tag backup-before-refactor` +- [ ] 备份用户配置文件 `~/.cc-switch/config.json` +- [ ] 通知团队成员重构开始 + +### 依赖安装 + +```bash +pnpm add @tanstack/react-query +pnpm add react-hook-form @hookform/resolvers +pnpm add zod +pnpm add sonner +pnpm add next-themes +pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu +pnpm add @radix-ui/react-label @radix-ui/react-select +pnpm add @radix-ui/react-slot @radix-ui/react-switch @radix-ui/react-tabs +pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate +``` + +- [ ] 安装核心依赖 (上述命令) +- [ ] 验证依赖安装成功 `pnpm install` +- [ ] 验证编译通过 `pnpm typecheck` + +### 配置文件 + +- [ ] 创建 `components.json` +- [ ] 更新 `tsconfig.json` 添加路径别名 +- [ ] 更新 `vite.config.mts` 添加路径解析 +- [ ] 验证开发服务器启动 `pnpm dev` + +**完成时间**: ___________ +**遇到的问题**: ___________ + +--- + +## 📋 阶段 1: 基础设施 (预计 2-3 天) + +### 1.1 工具函数和基础组件 + +- [ ] 创建 `src/lib/utils.ts` (cn 函数) +- [ ] 创建 `src/components/ui/button.tsx` +- [ ] 创建 `src/components/ui/dialog.tsx` +- [ ] 创建 `src/components/ui/input.tsx` +- [ ] 创建 `src/components/ui/label.tsx` +- [ ] 创建 `src/components/ui/textarea.tsx` +- [ ] 创建 `src/components/ui/select.tsx` +- [ ] 创建 `src/components/ui/switch.tsx` +- [ ] 创建 `src/components/ui/tabs.tsx` +- [ ] 创建 `src/components/ui/sonner.tsx` +- [ ] 创建 `src/components/ui/form.tsx` + +**测试**: +- [ ] 验证所有 UI 组件可以正常导入 +- [ ] 创建一个测试页面验证组件样式 + +### 1.2 Query Client 设置 + +- [ ] 创建 `src/lib/query/queryClient.ts` +- [ ] 配置默认选项 (retry, staleTime 等) +- [ ] 导出 queryClient 实例 + +### 1.3 API 层 + +- [ ] 创建 `src/lib/api/providers.ts` + - [ ] getAll + - [ ] getCurrent + - [ ] add + - [ ] update + - [ ] delete + - [ ] switch + - [ ] importDefault + - [ ] updateTrayMenu + +- [ ] 创建 `src/lib/api/settings.ts` + - [ ] get + - [ ] save + +- [ ] 创建 `src/lib/api/mcp.ts` + - [ ] getConfig + - [ ] upsertServer + - [ ] deleteServer + +- [ ] 创建 `src/lib/api/index.ts` (聚合导出) + +**测试**: +- [ ] 验证 API 调用不会出现运行时错误 +- [ ] 确认类型定义正确 + +### 1.4 Query Hooks + +- [ ] 创建 `src/lib/query/queries.ts` + - [ ] useProvidersQuery + - [ ] useSettingsQuery + - [ ] useMcpConfigQuery + +- [ ] 创建 `src/lib/query/mutations.ts` + - [ ] useAddProviderMutation + - [ ] useSwitchProviderMutation + - [ ] useDeleteProviderMutation + - [ ] useUpdateProviderMutation + - [ ] useSaveSettingsMutation + +- [ ] 创建 `src/lib/query/index.ts` (聚合导出) + +**测试**: +- [ ] 在临时组件中测试每个 hook +- [ ] 验证 loading/error 状态正确 +- [ ] 验证缓存和自动刷新工作 + +**完成时间**: ___________ +**遇到的问题**: ___________ + +--- + +## 📋 阶段 2: 核心功能重构 (预计 3-4 天) + +### 2.1 主题系统 + +- [ ] 创建 `src/components/theme-provider.tsx` +- [ ] 创建 `src/components/mode-toggle.tsx` +- [ ] 更新 `src/index.css` 添加主题变量 +- [ ] 删除 `src/hooks/useDarkMode.ts` +- [ ] 更新所有组件使用新的主题系统 + +**测试**: +- [ ] 验证主题切换正常工作 +- [ ] 验证系统主题跟随功能 +- [ ] 验证主题持久化 + +### 2.2 更新 main.tsx + +- [ ] 引入 QueryClientProvider +- [ ] 引入 ThemeProvider +- [ ] 添加 Toaster 组件 +- [ ] 移除旧的 API 导入 + +**测试**: +- [ ] 验证应用可以正常启动 +- [ ] 验证 Context 正确传递 + +### 2.3 重构 App.tsx + +- [ ] 使用 useProvidersQuery 替代手动状态管理 +- [ ] 移除所有 loadProviders 相关代码 +- [ ] 移除手动 notification 状态 +- [ ] 简化事件监听逻辑 +- [ ] 更新对话框为新的 Dialog 组件 + +**目标**: 将 412 行代码减少到 ~100 行 + +**测试**: +- [ ] 验证供应商列表正常加载 +- [ ] 验证切换 Claude/Codex 正常工作 +- [ ] 验证事件监听正常工作 + +### 2.4 重构 ProviderList + +- [ ] 创建 `src/components/providers/ProviderList.tsx` +- [ ] 使用 mutation hooks 处理操作 +- [ ] 移除 onNotify prop +- [ ] 移除手动状态管理 + +**测试**: +- [ ] 验证供应商列表渲染 +- [ ] 验证切换操作 +- [ ] 验证删除操作 + +### 2.5 重构表单系统 + +- [ ] 创建 `src/lib/schemas/provider.ts` (Zod schema) +- [ ] 创建 `src/components/providers/ProviderForm.tsx` + - [ ] 使用 react-hook-form + - [ ] 使用 zodResolver + - [ ] 字段级验证 + +- [ ] 创建 `src/components/providers/AddProviderDialog.tsx` + - [ ] 使用新的 Dialog 组件 + - [ ] 集成 ProviderForm + - [ ] 使用 useAddProviderMutation + +- [ ] 创建 `src/components/providers/EditProviderDialog.tsx` + - [ ] 使用新的 Dialog 组件 + - [ ] 集成 ProviderForm + - [ ] 使用 useUpdateProviderMutation + +**测试**: +- [ ] 验证表单验证正常工作 +- [ ] 验证错误提示显示正确 +- [ ] 验证提交操作成功 +- [ ] 验证表单重置功能 + +### 2.6 清理旧组件 + +- [x] 删除 `src/components/AddProviderModal.tsx` +- [x] 删除 `src/components/EditProviderModal.tsx` +- [x] 更新所有引用这些组件的地方 +- [x] 删除 `src/components/ProviderForm.tsx` 及 `src/components/ProviderForm/` + +**完成时间**: ___________ +**遇到的问题**: ___________ + +--- + +## 📋 阶段 3: 设置和辅助功能 (预计 2-3 天) + +### 3.1 重构 SettingsDialog + +- [ ] 创建 `src/components/settings/SettingsDialog.tsx` + - [ ] 使用 Tabs 组件 + - [ ] 集成各个设置子组件 + +- [ ] 创建 `src/components/settings/GeneralSettings.tsx` + - [ ] 语言设置 + - [ ] 配置目录设置 + - [ ] 其他通用设置 + +- [ ] 创建 `src/components/settings/AboutSection.tsx` + - [ ] 版本信息 + - [ ] 更新检查 + - [ ] 链接 + +- [ ] 创建 `src/components/settings/ImportExportSection.tsx` + - [ ] 导入功能 + - [ ] 导出功能 + +**目标**: 将 643 行拆分为 4-5 个小组件,每个 100-150 行 + +**测试**: +- [ ] 验证设置保存功能 +- [ ] 验证导入导出功能 +- [ ] 验证更新检查功能 + +### 3.2 重构通知系统 + +- [ ] 在所有 mutations 中使用 `toast` 替代 `showNotification` +- [ ] 移除 App.tsx 中的 notification 状态 +- [ ] 移除自定义通知组件 + +**测试**: +- [ ] 验证成功通知显示 +- [ ] 验证错误通知显示 +- [ ] 验证通知自动消失 + +### 3.3 重构确认对话框 + +- [ ] 更新 `src/components/ConfirmDialog.tsx` 使用新的 Dialog +- [ ] 或者直接使用 shadcn/ui 的 AlertDialog + +**测试**: +- [ ] 验证删除确认对话框 +- [ ] 验证其他确认场景 + +**完成时间**: ___________ +**遇到的问题**: ___________ + +--- + +## 📋 阶段 4: 清理和优化 (预计 1-2 天) + +### 4.1 移除旧代码 + +- [x] 删除 `src/lib/styles.ts` +- [x] 从 `src/lib/tauri-api.ts` 移除 `window.api` 绑定 +- [x] 精简 `src/lib/tauri-api.ts`,只保留事件监听相关 +- [x] 删除或更新 `src/vite-env.d.ts` 中的过时类型 + +### 4.2 代码审查 + +- [ ] 检查所有 TODO 注释 +- [x] 检查是否还有 `window.api` 调用 +- [ ] 检查是否还有手动状态管理 +- [x] 统一代码风格 + +### 4.3 类型检查 + +- [x] 运行 `pnpm typecheck` 确保无错误 +- [x] 修复所有类型错误 +- [x] 更新类型定义 + +### 4.4 性能优化 + +- [ ] 检查是否有不必要的重渲染 +- [ ] 添加必要的 React.memo +- [ ] 优化 Query 缓存配置 + +**完成时间**: ___________ +**遇到的问题**: ___________ + +--- + +## 📋 阶段 5: 测试和修复 (预计 2-3 天) + +### 5.1 功能测试 + +#### 供应商管理 +- [ ] 添加供应商 (Claude) +- [ ] 添加供应商 (Codex) +- [ ] 编辑供应商 +- [ ] 删除供应商 +- [ ] 切换供应商 +- [ ] 导入默认配置 + +#### 应用切换 +- [ ] Claude <-> Codex 切换 +- [ ] 切换后数据正确加载 +- [ ] 切换后托盘菜单更新 + +#### 设置 +- [ ] 保存通用设置 +- [ ] 切换语言 +- [ ] 配置目录选择 +- [ ] 导入配置 +- [ ] 导出配置 + +#### UI 交互 +- [ ] 主题切换 (亮色/暗色) +- [ ] 对话框打开/关闭 +- [ ] 表单验证 +- [ ] Toast 通知 + +#### MCP 管理 +- [ ] 列表显示 +- [ ] 添加 MCP +- [ ] 编辑 MCP +- [ ] 删除 MCP +- [ ] 启用/禁用 MCP + +### 5.2 边界情况测试 + +- [ ] 空供应商列表 +- [ ] 无效配置文件 +- [ ] 网络错误 +- [ ] 后端错误响应 +- [ ] 并发操作 +- [ ] 表单输入边界值 + +### 5.3 兼容性测试 + +- [ ] Windows 测试 +- [ ] macOS 测试 +- [ ] Linux 测试 + +### 5.4 性能测试 + +- [ ] 100+ 供应商加载速度 +- [ ] 快速切换供应商 +- [ ] 内存使用情况 +- [ ] CPU 使用情况 + +### 5.5 Bug 修复 + +**Bug 列表** (发现后记录): + +1. ___________ + - [ ] 已修复 + - [ ] 已验证 + +2. ___________ + - [ ] 已修复 + - [ ] 已验证 + +**完成时间**: ___________ +**遇到的问题**: ___________ + +--- + +## 📋 最终检查 + +### 代码质量 + +- [ ] 所有 TypeScript 错误已修复 +- [ ] 运行 `pnpm format` 格式化代码 +- [ ] 运行 `pnpm typecheck` 通过 +- [ ] 代码审查完成 + +### 文档更新 + +- [ ] 更新 `CLAUDE.md` 反映新架构 +- [ ] 更新 `README.md` (如有必要) +- [ ] 添加 Migration Guide (可选) + +### 性能基准 + +记录性能数据: + +**旧版本**: +- 启动时间: _____ms +- 供应商加载: _____ms +- 内存占用: _____MB + +**新版本**: +- 启动时间: _____ms +- 供应商加载: _____ms +- 内存占用: _____MB + +### 代码统计 + +**代码行数对比**: + +| 文件 | 旧版本 | 新版本 | 减少 | +|------|--------|--------|------| +| App.tsx | 412 | ~100 | -76% | +| tauri-api.ts | 712 | ~50 | -93% | +| ProviderForm.tsx | 271 | ~150 | -45% | +| settings 模块 | 1046 | ~470 (拆分) | -55% | +| **总计** | 2038 | ~700 | **-66%** | + +--- + +## 📦 发布准备 + +### Pre-release 测试 + +- [ ] 创建 beta 版本 `v4.0.0-beta.1` +- [ ] 在测试环境验证 +- [ ] 收集用户反馈 + +### 正式发布 + +- [ ] 合并到 main 分支 +- [ ] 创建 Release Tag `v4.0.0` +- [ ] 更新 Changelog +- [ ] 发布 GitHub Release +- [ ] 通知用户更新 + +--- + +## 🚨 回滚触发条件 + +如果出现以下情况,考虑回滚: + +- [ ] 重大功能无法使用 +- [ ] 用户数据丢失 +- [ ] 严重性能问题 +- [ ] 无法修复的兼容性问题 + +**回滚命令**: +```bash +git reset --hard backup-before-refactor +# 或 +git revert +``` + +--- + +## 📝 总结报告 + +### 成功指标 + +- [ ] 所有现有功能正常工作 +- [ ] 代码量减少 40%+ +- [ ] 无用户数据丢失 +- [ ] 性能未下降 + +### 经验教训 + +**遇到的主要挑战**: +1. ___________ +2. ___________ +3. ___________ + +**解决方案**: +1. ___________ +2. ___________ +3. ___________ + +**未来改进**: +1. ___________ +2. ___________ +3. ___________ + +--- + +**重构完成日期**: ___________ +**总耗时**: _____ 天 +**参与人员**: ___________ diff --git a/docs/REFACTORING_MASTER_PLAN.md b/docs/REFACTORING_MASTER_PLAN.md new file mode 100644 index 0000000..f38e19d --- /dev/null +++ b/docs/REFACTORING_MASTER_PLAN.md @@ -0,0 +1,1658 @@ +# CC Switch 现代化重构完整方案 + +> Breaking Change 提醒(后续示例如仍出现 `app_type/appType` 字样,请按本规范理解与替换): +> +> - 后端 Tauri 命令统一仅接受 `app` 参数(值:`claude` 或 `codex`),不再接受 `app_type`/`appType`。 +> - 传入未知 `app` 会返回本地化错误,并提示“可选值: claude, codex”。 +> - 前端与文档中的旧示例如包含 `app_type`,一律替换为 `{ app }`。 + +## 📋 目录 + +- [第一部分: 战略规划](#第一部分-战略规划) + - [重构背景与目标](#重构背景与目标) + - [当前问题全面分析](#当前问题全面分析) + - [技术选型与理由](#技术选型与理由) +- [第二部分: 架构设计](#第二部分-架构设计) + - [新的目录结构](#新的目录结构) + - [数据流架构](#数据流架构) + - [组件拆分详细方案](#组件拆分详细方案) +- [第三部分: 实施计划](#第三部分-实施计划) + - [分阶段实施路线图](#分阶段实施路线图) + - [详细实施步骤](#详细实施步骤) +- [第四部分: 质量保障](#第四部分-质量保障) + - [测试策略](#测试策略) + - [风险控制](#风险控制) + - [回滚方案](#回滚方案) + +--- + +# 第一部分: 战略规划 + +## 🎯 重构背景与目标 + +### 为什么要重构? + +当前代码库存在以下核心问题: + +1. **状态管理混乱** + - 手动管理 20+ `useState` + - 大量复杂的 `useEffect` 依赖链 + - 数据同步逻辑分散 + +2. **组件过于臃肿** + - `SettingsModal.tsx`: **1046 行** 😱 + - `ProviderList.tsx`: **418 行** + - `ProviderForm.tsx`: **271 行** + +3. **代码重复严重** + - 相似的数据获取逻辑在多个组件重复 + - 表单验证逻辑手动编写 + - 错误处理不统一 + +4. **UI 缺乏统一性** + - 自定义样式分散 + - 缺乏设计系统 + - 响应式支持不足 + +5. **可维护性差** + - 组件职责不清晰 + - 耦合度高 + - 难以测试 + +### 重构目标 + +| 维度 | 目标 | 衡量标准 | +| -------------- | -------------------- | -------------- | +| **代码质量** | 减少 40-60% 样板代码 | 代码行数统计 | +| **开发效率** | 提升 50%+ 开发速度 | 新功能开发时间 | +| **用户体验** | 统一设计系统 | UI 一致性检查 | +| **可维护性** | 清晰的架构分层 | 代码审查时间 | +| **功能完整性** | 100% 功能无回归 | 全量测试通过 | + +--- + +## 🔍 当前问题全面分析 + +### 问题 1: App.tsx - 状态管理混乱 (412行) + +**现状**: + +```typescript +// 10+ 个 useState,状态管理混乱 +const [providers, setProviders] = useState>({}) +const [currentProviderId, setCurrentProviderId] = useState("") +const [notification, setNotification] = useState<{...} | null>(null) +const [isNotificationVisible, setIsNotificationVisible] = useState(false) +const [confirmDialog, setConfirmDialog] = useState<{...} | null>(null) +const [isSettingsOpen, setIsSettingsOpen] = useState(false) +const [isMcpOpen, setIsMcpOpen] = useState(false) +// ... 更多 + +// 手动数据加载,缺少 loading/error 状态 +const loadProviders = async () => { + const loadedProviders = await window.api.getProviders(activeApp) + const currentId = await window.api.getCurrentProvider(activeApp) + setProviders(loadedProviders) + setCurrentProviderId(currentId) +} + +// 复杂的 useEffect 依赖 +useEffect(() => { + loadProviders() +}, [activeApp]) +``` + +**核心问题**: + +- ❌ 状态同步困难 +- ❌ 没有 loading/error 处理 +- ❌ 错误处理不统一 +- ❌ 组件责任过重 + +**目标**: + +```typescript +// React Query: 3 行搞定 +const { data, isLoading, error } = useProvidersQuery(activeApp); +const providers = data?.providers || {}; +const currentProviderId = data?.currentProviderId || ""; +``` + +--- + +### 问题 2: SettingsModal.tsx - 超级巨无霸组件 (1046行) + +**现状结构**: + +``` +SettingsModal.tsx (1046 行) +├── 20+ useState (settings, configPath, version, isChecking...) +├── 15+ 处理函数 +│ ├── loadSettings() +│ ├── saveSettings() +│ ├── handleLanguageChange() +│ ├── handleCheckUpdate() +│ ├── handleExportConfig() +│ ├── handleImportConfig() +│ ├── handleBrowseConfigDir() +│ └── ... 更多 +├── 语言设置 UI +├── 窗口行为设置 UI +├── 配置文件位置 UI +├── 配置目录覆盖 UI (3个输入框) +├── 导入导出 UI +├── 关于和更新 UI +└── 2个子对话框 (ImportProgress, RestartConfirm) +``` + +**核心问题**: + +- ❌ 单个文件超过 1000 行 +- ❌ 多种职责混杂 +- ❌ 难以理解和维护 +- ❌ 无法并行开发 +- ❌ 难以测试 + +**目标**: 拆分为 **7 个小组件** (~470 行总计) + +--- + +### 问题 3: ProviderList.tsx - 内嵌组件和逻辑混杂 (418行) + +**现状结构**: + +``` +ProviderList.tsx (418 行) +├── SortableProviderItem (内嵌子组件, ~100行) +├── 拖拽排序逻辑 +├── 用量配置逻辑 +├── URL 处理逻辑 +├── Claude 插件同步逻辑 +└── 空状态 UI +``` + +**核心问题**: + +- ❌ 内嵌组件导致代码难读 +- ❌ 拖拽逻辑和 UI 混在一起 +- ❌ 业务逻辑分散 + +**目标**: 拆分为 **4 个独立组件** + **1 个自定义 Hook** + +--- + +### 问题 4: tauri-api.ts - 全局污染 (712行) + +**现状**: + +```typescript +// 问题 1: 污染全局命名空间 +if (typeof window !== "undefined") { + (window as any).api = tauriAPI; +} + +// 问题 2: 无缓存机制 +getProviders: async (app?: AppId) => { + try { + return await invoke("get_providers", { app }); + } catch (error) { + console.error("获取供应商列表失败:", error); + return {}; // 错误被吞掉 + } +}; +``` + +**核心问题**: + +- ❌ 全局 `window.api` 污染命名空间 +- ❌ 无缓存,重复请求 +- ❌ 无自动重试 +- ❌ 错误处理不统一 + +**目标**: + +- 封装为 API 层 (`lib/api/`) +- React Query 管理缓存和状态 + +--- + +### 问题 5: 表单验证 - 手动编写 (ProviderForm.tsx) + +**现状**: + +```typescript +const [name, setName] = useState(""); +const [nameError, setNameError] = useState(""); +const [apiKey, setApiKey] = useState(""); +const [apiKeyError, setApiKeyError] = useState(""); + +const validate = () => { + let valid = true; + if (!name) { + setNameError("请填写名称"); + valid = false; + } else { + setNameError(""); + } + if (!apiKey) { + setApiKeyError("请填写 API Key"); + valid = false; + } else if (apiKey.length < 10) { + setApiKeyError("API Key 长度不足"); + valid = false; + } else { + setApiKeyError(""); + } + return valid; +}; +``` + +**核心问题**: + +- ❌ 每个字段需要 2 个 state (值 + 错误) +- ❌ 验证逻辑手动编写 +- ❌ 代码冗长 + +**目标**: 使用 `react-hook-form` + `zod` + +```typescript +const schema = z.object({ + name: z.string().min(1, "请填写名称"), + apiKey: z.string().min(10, "API Key 长度不足"), +}); + +const form = useForm({ resolver: zodResolver(schema) }); +``` + +--- + +## 🛠 技术选型与理由 + +### 核心技术栈 + +| 技术 | 版本 | 用途 | 替代方案 | 为何选它? | +| ------------------------- | ------- | -------------- | --------------- | -------------------- | +| **@tanstack/react-query** | ^5.90.2 | 服务端状态管理 | SWR, RTK Query | 功能最全,生态最好 | +| **react-hook-form** | ^7.63.0 | 表单管理 | Formik | 性能更好,API 更简洁 | +| **zod** | ^4.1.11 | 运行时类型验证 | yup, joi | TypeScript 原生支持 | +| **shadcn/ui** | latest | UI 组件库 | Radix UI 原生 | 可定制,代码归属权 | +| **sonner** | ^2.0.7 | Toast 通知 | react-hot-toast | 更现代,动画更好 | +| **next-themes** | ^0.4.6 | 主题管理 | 自定义实现 | 开箱即用,SSR 友好 | + +--- + +# 第二部分: 架构设计 + +## 📁 新的目录结构 + +### 完整目录树 + +``` +src/ +├── components/ +│ ├── ui/ # shadcn/ui 基础组件 (由 CLI 生成) +│ │ ├── button.tsx +│ │ ├── dialog.tsx +│ │ ├── input.tsx +│ │ ├── label.tsx +│ │ ├── form.tsx +│ │ ├── select.tsx +│ │ ├── switch.tsx +│ │ ├── tabs.tsx +│ │ ├── card.tsx +│ │ ├── badge.tsx +│ │ └── sonner.tsx # Toast 组件 +│ │ +│ ├── providers/ # 供应商管理模块 +│ │ ├── ProviderList.tsx # 列表容器 (~100行) +│ │ ├── ProviderCard.tsx # 供应商卡片 (~120行) +│ │ ├── ProviderActions.tsx # 操作按钮组 (~80行) +│ │ ├── ProviderEmptyState.tsx # 空状态 (~30行) +│ │ ├── AddProviderDialog.tsx # 添加对话框 (~60行) +│ │ ├── EditProviderDialog.tsx # 编辑对话框 (~60行) +│ │ └── forms/ # 表单子模块 +│ │ ├── ProviderForm.tsx # 主表单 (~150行) +│ │ ├── PresetSelector.tsx # 预设选择器 (~60行) +│ │ ├── ApiKeyInput.tsx # API Key 输入 (~40行) +│ │ ├── ConfigEditor.tsx # 配置编辑器 (~80行) +│ │ └── KimiModelSelector.tsx # Kimi 模型选择器 (~40行) +│ │ +│ ├── settings/ # 设置管理模块 (拆分自 SettingsModal) +│ │ ├── SettingsDialog.tsx # 设置对话框容器 (~80行) +│ │ ├── LanguageSettings.tsx # 语言设置 (~40行) +│ │ ├── WindowSettings.tsx # 窗口行为设置 (~50行) +│ │ ├── ConfigPathDisplay.tsx # 配置路径显示 (~40行) +│ │ ├── DirectorySettings/ # 目录设置子模块 +│ │ │ ├── index.tsx # 目录设置容器 (~60行) +│ │ │ └── DirectoryInput.tsx # 单个目录输入组件 (~50行) +│ │ ├── ImportExportSection.tsx # 导入导出 (~120行) +│ │ ├── AboutSection.tsx # 关于和更新 (~100行) +│ │ └── RestartDialog.tsx # 重启确认对话框 (~40行) +│ │ +│ ├── usage/ # 用量查询模块 +│ │ ├── UsageFooter.tsx # 用量信息展示 +│ │ ├── UsageScriptModal.tsx # 用量脚本配置 +│ │ └── UsageEditor.tsx # 脚本编辑器 +│ │ +│ ├── mcp/ # MCP 管理模块 +│ │ ├── McpPanel.tsx # MCP 管理面板 +│ │ ├── McpList.tsx # MCP 列表 +│ │ ├── McpForm.tsx # MCP 表单 +│ │ └── McpTemplates.tsx # MCP 模板选择 +│ │ +│ ├── shared/ # 共享组件 +│ │ ├── AppSwitcher.tsx # Claude/Codex 切换器 +│ │ ├── ConfirmDialog.tsx # 确认对话框 +│ │ ├── UpdateBadge.tsx # 更新徽章 +│ │ ├── JsonEditor.tsx # JSON 编辑器 +│ │ ├── BrandIcons.tsx # 品牌图标 +│ │ └── ImportProgressModal.tsx # 导入进度 +│ │ +│ ├── theme-provider.tsx # 主题 Provider +│ └── mode-toggle.tsx # 主题切换按钮 +│ +├── hooks/ # 自定义 Hooks (业务逻辑层) +│ ├── useSettings.ts # 设置管理逻辑 +│ ├── useImportExport.ts # 导入导出逻辑 +│ ├── useDragSort.ts # 拖拽排序逻辑 +│ ├── useProviderActions.ts # 供应商操作 (可选) +│ ├── useVSCodeSync.ts # VS Code 同步 +│ ├── useClaudePlugin.ts # Claude 插件管理 +│ └── useAppVersion.ts # 版本信息 +│ +├── lib/ +│ ├── query/ # React Query 层 +│ │ ├── index.ts # 导出所有 hooks +│ │ ├── queryClient.ts # QueryClient 配置 +│ │ ├── queries.ts # 所有查询 hooks +│ │ └── mutations.ts # 所有变更 hooks +│ │ +│ ├── api/ # API 调用层 (封装 Tauri invoke) +│ │ ├── providers.ts # 供应商 API +│ │ ├── settings.ts # 设置 API +│ │ ├── mcp.ts # MCP API +│ │ ├── usage.ts # 用量查询 API +│ │ ├── vscode.ts # VS Code API +│ │ └── index.ts # 聚合导出 +│ │ +│ ├── schemas/ # Zod 验证 Schemas +│ │ ├── provider.ts # 供应商验证规则 +│ │ ├── settings.ts # 设置验证规则 +│ │ └── mcp.ts # MCP 验证规则 +│ │ +│ ├── utils/ # 工具函数 +│ │ ├── errorHandling.ts # 错误处理 +│ │ ├── providerUtils.ts # 供应商工具 +│ │ └── configUtils.ts # 配置工具 +│ │ +│ └── utils.ts # shadcn/ui 工具函数 (cn) +│ +├── types/ # TypeScript 类型定义 +│ └── index.ts +│ +├── contexts/ # React Contexts (保留现有) +│ └── UpdateContext.tsx # 更新管理 Context +│ +├── i18n/ # 国际化 (保留现有) +│ ├── index.ts +│ └── locales/ +│ +├── App.tsx # 主应用组件 (简化到 ~100行) +├── main.tsx # 入口文件 (添加 Providers) +└── index.css # 全局样式 +``` + +### 目录结构设计原则 + +1. **按功能模块分组** (providers/, settings/, mcp/) +2. **按技术层次分层** (components/, hooks/, lib/) +3. **UI 组件独立** (ui/ 目录) +4. **业务逻辑提取** (hooks/ 目录) +5. **数据层封装** (api/ 目录) + +--- + +## 🏗 数据流架构 + +### 分层架构图 + +``` +┌─────────────────────────────────────────┐ +│ UI 层 (Components) │ +│ ProviderList, SettingsDialog, etc. │ +└────────────────┬────────────────────────┘ + │ 使用 + ↓ +┌─────────────────────────────────────────┐ +│ 业务逻辑层 (Custom Hooks) │ +│ useSettings, useDragSort, etc. │ +└────────────────┬────────────────────────┘ + │ 调用 + ↓ +┌─────────────────────────────────────────┐ +│ 数据管理层 (React Query Hooks) │ +│ useProvidersQuery, useMutation, etc. │ +└────────────────┬────────────────────────┘ + │ 调用 + ↓ +┌─────────────────────────────────────────┐ +│ API 层 (API Functions) │ +│ providersApi, settingsApi, etc. │ +└────────────────┬────────────────────────┘ + │ invoke + ↓ +┌─────────────────────────────────────────┐ +│ Tauri Backend (Rust) │ +│ Commands, State, File System │ +└─────────────────────────────────────────┘ +``` + +### 数据流示例 + +**场景**: 切换供应商 + +``` +1. 用户点击按钮 + ↓ +2. ProviderCard 调用 onClick={() => switchMutation.mutate(id)} + ↓ +3. useSwitchProviderMutation (lib/query/mutations.ts) + - mutationFn: 调用 providersApi.switch(id, appType) + ↓ +4. providersApi.switch (lib/api/providers.ts) + - 调用 invoke('switch_provider', { id, app }) + ↓ +5. Tauri Backend (Rust) + - 执行切换逻辑 + - 更新配置文件 + - 返回结果 + ↓ +6. useSwitchProviderMutation + - onSuccess: invalidateQueries(['providers', appType]) + - onSuccess: updateTrayMenu() + - onSuccess: toast.success('切换成功') + ↓ +7. useProvidersQuery 自动重新获取数据 + ↓ +8. UI 自动更新 +``` + +### 关键设计原则 + +1. **单一职责**: 每层只做一件事 +2. **依赖倒置**: UI 依赖抽象 (hooks),不依赖具体实现 +3. **开闭原则**: 易于扩展,无需修改现有代码 +4. **状态分离**: + - 服务端状态 → React Query + - 客户端 UI 状态 → useState + - 全局状态 → Context + +--- + +## 🔧 组件拆分详细方案 + +### 拆分策略: SettingsModal (1046行 → 7个组件) + +#### 拆分前后对比 + +``` +┌───────────────────────────────────┐ +│ SettingsModal.tsx (1046 行) │ ❌ 过于臃肿 +│ │ +│ - 20+ useState │ +│ - 15+ 函数 │ +│ - 600+ 行 JSX │ +│ - 难以理解和维护 │ +└───────────────────────────────────┘ + + ↓ 重构 + +┌─────────────────────────────────────────────────┐ +│ settings/ 模块 (7个组件, ~470行) │ +│ │ +│ ├── SettingsDialog.tsx (容器, ~80行) │ +│ │ └── 使用 useSettings hook │ +│ │ │ +│ ├── LanguageSettings.tsx (~40行) │ +│ ├── WindowSettings.tsx (~50行) │ +│ ├── ConfigPathDisplay.tsx (~40行) │ +│ ├── DirectorySettings/ (~110行) │ +│ │ ├── index.tsx (~60行) │ +│ │ └── DirectoryInput.tsx (~50行) │ +│ ├── ImportExportSection.tsx (~120行) │ +│ │ └── 使用 useImportExport hook │ +│ └── AboutSection.tsx (~100行) │ +│ └── 使用 useAppVersion, useUpdate hooks │ +└─────────────────────────────────────────────────┘ + +✅ 每个组件 30-120 行 +✅ 职责清晰 +✅ 易于测试 +✅ 可独立开发 +``` + +#### 拆分详细方案 + +**1. SettingsDialog.tsx (容器组件, ~80行)** + +职责: 组织整体布局,协调子组件 + +```typescript +import { LanguageSettings } from './LanguageSettings' +import { WindowSettings } from './WindowSettings' +import { DirectorySettings } from './DirectorySettings' +import { ImportExportSection } from './ImportExportSection' +import { AboutSection } from './AboutSection' +import { useSettings } from '@/hooks/useSettings' + +export function SettingsDialog({ open, onOpenChange }) { + const { settings, updateSettings, saveSettings, isPending } = useSettings() + + return ( + + + + 设置 + + + + + 通用 + 高级 + 关于 + + + + updateSettings({ language: lang })} + /> + + + + + + + + + + + + + + + + + + + + + ) +} +``` + +**2. LanguageSettings.tsx (~40行)** + +职责: 语言切换 UI + +```typescript +interface LanguageSettingsProps { + value: 'zh' | 'en' + onChange: (lang: 'zh' | 'en') => void +} + +export function LanguageSettings({ value, onChange }: LanguageSettingsProps) { + return ( +
+

语言设置

+
+ + +
+
+ ) +} +``` + +**3. DirectoryInput.tsx (~50行)** + +职责: 可复用的目录选择输入框 + +```typescript +import { FolderSearch, Undo2 } from 'lucide-react' + +interface DirectoryInputProps { + label: string + description?: string + value?: string + onChange: (value: string | undefined) => void + type: 'app' | 'claude' | 'codex' +} + +export function DirectoryInput({ label, description, value, onChange }: DirectoryInputProps) { + const handleBrowse = async () => { + const selected = await window.api.selectConfigDirectory(value) + if (selected) onChange(selected) + } + + const handleReset = () => { + onChange(undefined) + } + + return ( +
+ + {description &&

{description}

} +
+ onChange(e.target.value)} + className="flex-1 font-mono text-xs" + /> + + +
+
+ ) +} +``` + +**4. useSettings Hook (业务逻辑提取)** + +```typescript +export function useSettings() { + const queryClient = useQueryClient(); + + // 获取设置 + const { data: settings, isLoading } = useQuery({ + queryKey: ["settings"], + queryFn: async () => await settingsApi.get(), + }); + + // 保存设置 + const saveMutation = useMutation({ + mutationFn: async (newSettings: Settings) => + await settingsApi.save(newSettings), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + toast.success("设置已保存"); + }, + }); + + // 本地临时状态 (保存前) + const [localSettings, setLocalSettings] = useState(null); + const currentSettings = localSettings || settings || {}; + + return { + settings: currentSettings, + updateSettings: (updates: Partial) => { + setLocalSettings((prev) => ({ ...prev, ...updates })); + }, + saveSettings: () => { + if (localSettings) saveMutation.mutate(localSettings); + }, + resetSettings: () => setLocalSettings(null), + isPending: saveMutation.isPending, + isLoading, + }; +} +``` + +--- + +### 拆分策略: ProviderList (418行 → 4个组件 + 1个Hook) + +#### 拆分方案 + +``` +ProviderList.tsx (418 行) ❌ 内嵌组件、逻辑混杂 + + ↓ 重构 + +providers/ 模块 (4个组件 + 1个Hook, ~330行) + +├── ProviderList.tsx (容器, ~100行) +│ └── 使用 useDragSort hook +│ +├── ProviderCard.tsx (~120行) +│ └── 显示单个供应商信息 +│ +├── ProviderActions.tsx (~80行) +│ └── 操作按钮组 (switch, edit, delete, usage) +│ +├── ProviderEmptyState.tsx (~30行) +│ └── 空状态提示 +│ +└── hooks/useDragSort.ts (~100行) + └── 拖拽排序逻辑 +``` + +#### 代码示例 + +**ProviderList.tsx (容器)** + +```typescript +import { ProviderCard } from './ProviderCard' +import { ProviderEmptyState } from './ProviderEmptyState' +import { useDragSort } from '@/hooks/useDragSort' + +export function ProviderList({ providers, currentProviderId, appType }) { + const { sortedProviders, handleDragEnd, sensors } = useDragSort(providers, appType) + + if (sortedProviders.length === 0) { + return + } + + return ( + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {sortedProviders.map(provider => ( + + ))} +
+
+
+ ) +} +``` + +**useDragSort.ts (逻辑提取)** + +```typescript +export function useDragSort( + providers: Record, + appType: AppId +) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + // 排序逻辑 + const sortedProviders = useMemo(() => { + return Object.values(providers).sort((a, b) => { + if (a.sortIndex !== undefined && b.sortIndex !== undefined) { + return a.sortIndex - b.sortIndex; + } + const timeA = a.createdAt || 0; + const timeB = b.createdAt || 0; + if (timeA === 0 && timeB === 0) { + return a.name.localeCompare(b.name, "zh-CN"); + } + return timeA === 0 ? -1 : timeB === 0 ? 1 : timeA - timeB; + }); + }, [providers]); + + // 拖拽传感器 + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor) + ); + + // 拖拽结束处理 + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = sortedProviders.findIndex((p) => p.id === active.id); + const newIndex = sortedProviders.findIndex((p) => p.id === over.id); + + const reordered = arrayMove(sortedProviders, oldIndex, newIndex); + const updates = reordered.map((p, i) => ({ id: p.id, sortIndex: i })); + + try { + await providersApi.updateSortOrder(updates, appType); + queryClient.invalidateQueries({ queryKey: ["providers", appType] }); + toast.success(t("provider.sortUpdated")); + } catch (error) { + toast.error(t("provider.sortUpdateFailed")); + } + }, + [sortedProviders, appType, queryClient, t] + ); + + return { sortedProviders, sensors, handleDragEnd }; +} +``` + +--- + +### 代码量对比总结 + +| 组件 | 重构前 | 重构后 | 变化 | +| -------------------- | ---------- | ---------------- | -------- | +| **SettingsModal** | 1046 行 | 7个组件 ~470行 | **-55%** | +| **ProviderList** | 418 行 | 4个组件 ~330行 | **-21%** | +| **业务逻辑 (Hooks)** | 混在组件中 | 5个 hooks ~400行 | 提取独立 | +| **总计** | 1464 行 | ~1200 行 | **-18%** | + +**注意**: 代码总量略有减少,但**可维护性大幅提升**: + +- ✅ 每个文件 30-120 行,易于理解 +- ✅ 关注点分离,职责清晰 +- ✅ 业务逻辑可复用 +- ✅ 易于测试和调试 + +--- + +# 第三部分: 实施计划 + +## 📅 分阶段实施路线图 + +### 总览 + +| 阶段 | 目标 | 工期 | 产出 | +| ---------- | -------------- | ------------ | ---------------------------- | +| **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 | +| **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 | +| **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 | +| **阶段 3** | 重构设置和辅助(✅ 已完成) | 2-3 天 | SettingsDialog、通知系统完成 | +| **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 | +| **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 | +| **总计** | - | **11-16 天** | v4.0.0 发布 | + +--- + +### 阶段 0: 准备阶段 (1天) + +**目标**: 环境准备和依赖安装 + +#### 任务清单 + +- [ ] 创建新分支 `refactor/modernization` +- [ ] 创建备份标签 `git tag backup-before-refactor` +- [ ] 安装核心依赖 +- [ ] 配置 shadcn/ui +- [ ] 配置 TypeScript 路径别名 +- [ ] 配置 Vite 路径解析 +- [ ] 验证开发服务器启动 + +#### 详细步骤 + +**1. 创建分支和备份** + +```bash +# 创建新分支 +git checkout -b refactor/modernization + +# 创建备份标签 +git tag backup-before-refactor + +# 推送标签到远程 (可选) +git push origin backup-before-refactor +``` + +**2. 安装依赖** + +```bash +# 核心依赖 +pnpm add @tanstack/react-query +pnpm add react-hook-form @hookform/resolvers +pnpm add zod +pnpm add sonner +pnpm add next-themes + +# Radix UI 组件 (shadcn/ui 依赖) +pnpm add @radix-ui/react-dialog +pnpm add @radix-ui/react-dropdown-menu +pnpm add @radix-ui/react-label +pnpm add @radix-ui/react-select +pnpm add @radix-ui/react-slot +pnpm add @radix-ui/react-switch +pnpm add @radix-ui/react-tabs +pnpm add @radix-ui/react-checkbox + +# 样式工具 +pnpm add class-variance-authority +pnpm add clsx +pnpm add tailwind-merge +``` + +**3. 创建 `components.json`** + +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} +``` + +**4. 更新 `tsconfig.json`** + +```json +{ + "compilerOptions": { + // ... 现有配置 + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +**5. 更新 `vite.config.mts`** + +```typescript +import path from "path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); +``` + +**6. 验证** + +```bash +pnpm dev # 确保开发服务器正常启动 +pnpm typecheck # 确保类型检查通过 +``` + +--- + +### 阶段 1: 基础设施 (2-3天) + +**目标**: 搭建新架构的基础层 + +#### 任务清单 + +- [x] 创建工具函数 (`lib/utils.ts`) +- [x] 添加基础 UI 组件 (Button, Dialog, Input, Form 等) +- [x] 创建 Query Client 配置 +- [x] 封装 API 层 (providers, settings, mcp) +- [x] 创建 Query Hooks (queries, mutations) +- [x] 创建 Zod Schemas + +#### 详细步骤 + +**Step 1.1: 创建 `src/lib/utils.ts`** + +```typescript +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +**Step 1.2: 添加 shadcn/ui 基础组件** + +创建 `src/components/ui/button.tsx`: + +```typescript +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } +``` + +类似地创建: + +- `dialog.tsx` +- `input.tsx` +- `label.tsx` +- `form.tsx` +- `select.tsx` +- `switch.tsx` +- `tabs.tsx` +- `textarea.tsx` +- `sonner.tsx` + +**参考**: https://ui.shadcn.com/docs/components + +**Step 1.3: 创建 Query Client** + +`src/lib/query/queryClient.ts`: + +```typescript +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, // 5 分钟 + }, + mutations: { + retry: false, + }, + }, +}); +``` + +**Step 1.4: 封装 API 层** + +`src/lib/api/providers.ts`: + +```typescript +import { invoke } from "@tauri-apps/api/core"; +import { Provider } from "@/types"; +import type { AppId } from "@/lib/api"; + +export const providersApi = { + getAll: async (appId: AppId): Promise> => { + return await invoke("get_providers", { app: appId }); + }, + + getCurrent: async (appId: AppId): Promise => { + return await invoke("get_current_provider", { app: appId }); + }, + + add: async (provider: Provider, appId: AppId): Promise => { + return await invoke("add_provider", { provider, app: appId }); + }, + + update: async (provider: Provider, appId: AppId): Promise => { + return await invoke("update_provider", { provider, app: appId }); + }, + + delete: async (id: string, appId: AppId): Promise => { + return await invoke("delete_provider", { id, app: appId }); + }, + + switch: async (id: string, appId: AppId): Promise => { + return await invoke("switch_provider", { id, app: appId }); + }, + + importDefault: async (appId: AppId): Promise => { + return await invoke("import_default_config", { app: appId }); + }, + + updateTrayMenu: async (): Promise => { + return await invoke("update_tray_menu"); + }, + + updateSortOrder: async ( + updates: Array<{ id: string; sortIndex: number }>, + appId: AppId + ): Promise => { + return await invoke("update_providers_sort_order", { updates, app: appId }); + }, +}; +``` + +类似地创建: + +- `src/lib/api/settings.ts` +- `src/lib/api/mcp.ts` +- `src/lib/api/index.ts` (聚合导出) + +**Step 1.5: 创建 Query Hooks** + +`src/lib/query/queries.ts`: + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { providersApi, type AppId } from "@/lib/api"; +import { Provider } from "@/types"; + +// 排序辅助函数 +const sortProviders = ( + providers: Record +): Record => { + return Object.fromEntries( + Object.values(providers) + .sort((a, b) => { + const timeA = a.createdAt || 0; + const timeB = b.createdAt || 0; + if (timeA === 0 && timeB === 0) { + return a.name.localeCompare(b.name, "zh-CN"); + } + if (timeA === 0) return -1; + if (timeB === 0) return 1; + return timeA - timeB; + }) + .map((provider) => [provider.id, provider]) + ); +}; + +export const useProvidersQuery = (appType: AppId) => { + return useQuery({ + queryKey: ["providers", appType], + queryFn: async () => { + let providers: Record = {}; + let currentProviderId = ""; + + try { + providers = await providersApi.getAll(appType); + } catch (error) { + console.error("获取供应商列表失败:", error); + } + + try { + currentProviderId = await providersApi.getCurrent(appType); + } catch (error) { + console.error("获取当前供应商失败:", error); + } + + // 自动导入默认配置 + if (Object.keys(providers).length === 0) { + try { + const success = await providersApi.importDefault(appType); + if (success) { + providers = await providersApi.getAll(appType); + currentProviderId = await providersApi.getCurrent(appType); + } + } catch (error) { + console.error("导入默认配置失败:", error); + } + } + + return { providers: sortProviders(providers), currentProviderId }; + }, + }); +}; +``` + +`src/lib/query/mutations.ts`: + +```typescript +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { providersApi, type AppId } from "@/lib/api"; +import { Provider } from "@/types"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; + +export const useAddProviderMutation = (appType: AppId) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (provider: Omit) => { + const newProvider: Provider = { + ...provider, + id: crypto.randomUUID(), + createdAt: Date.now(), + }; + await providersApi.add(newProvider, appType); + return newProvider; + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["providers", appType] }); + await providersApi.updateTrayMenu(); + toast.success(t("notifications.providerAdded")); + }, + onError: (error: Error) => { + toast.error(t("notifications.addFailed", { error: error.message })); + }, + }); +}; + +export const useSwitchProviderMutation = (appType: AppId) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (providerId: string) => { + return await providersApi.switch(providerId, appType); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["providers", appType] }); + await providersApi.updateTrayMenu(); + toast.success( + t("notifications.switchSuccess", { appName: t(`apps.${appType}`) }) + ); + }, + onError: (error: Error) => { + toast.error(t("notifications.switchFailed") + ": " + error.message); + }, + }); +}; + +// 类似地创建: useDeleteProviderMutation, useUpdateProviderMutation +``` + +**Step 1.6: 创建 Zod Schemas** + +`src/lib/schemas/provider.ts`: + +```typescript +import { z } from "zod"; + +export const providerSchema = z.object({ + name: z.string().min(1, "请填写供应商名称"), + websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")), + settingsConfig: z + .string() + .min(1, "请填写配置内容") + .refine( + (val) => { + try { + JSON.parse(val); + return true; + } catch { + return false; + } + }, + { message: "配置 JSON 格式错误" } + ), +}); + +export type ProviderFormData = z.infer; +``` + +--- + +### 阶段 2: 核心功能重构 (3-4天) + +**目标**: 重构 App.tsx 和供应商管理 + +#### 任务清单 + +- [x] 更新 `main.tsx` (添加 Providers) +- [x] 创建主题 Provider +- [x] 重构 `App.tsx` (412行 → ~100行) +- [x] 拆分 ProviderList (4个组件) +- [x] 创建 `useDragSort` Hook +- [x] 重构表单组件 (使用 react-hook-form) +- [x] 创建 AddProvider / EditProvider Dialog + +#### 详细步骤 + +**Step 2.1: 更新 `main.tsx`** + +```typescript +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import { UpdateProvider } from './contexts/UpdateContext' +import './index.css' +import './i18n' +import { QueryClientProvider } from '@tanstack/react-query' +import { queryClient } from '@/lib/query' +import { ThemeProvider } from '@/components/theme-provider' +import { Toaster } from '@/components/ui/sonner' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + +) +``` + +**Step 2.2: 创建 `theme-provider.tsx`** + +```typescript +import { createContext, useContext, useEffect, useState } from 'react' + +type Theme = 'dark' | 'light' | 'system' + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const ThemeProviderContext = createContext({ + theme: 'system', + setTheme: () => null, +}) + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'ui-theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ) + + useEffect(() => { + const root = window.document.documentElement + root.classList.remove('light', 'dark') + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + root.classList.add(systemTheme) + return + } + + root.classList.add(theme) + }, [theme]) + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme) + setTheme(theme) + }, + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} +``` + +**Step 2.3: 重构 `App.tsx`** + +(参考前面的代码示例,从 412 行简化到 ~100 行) + +**Step 2.4-2.7: 拆分 ProviderList** + +(参考前面的组件拆分详细方案) + +--- + +### 阶段 3: 设置和辅助功能 (2-3天) + +**目标**: 重构设置模块和通知系统 + +#### 任务清单 + +- [x] 拆分 SettingsDialog (7个组件) +- [x] 创建 `useSettings` Hook +- [x] 创建 `useImportExport` Hook +- [x] 替换通知系统为 Sonner +- [x] 重构 ConfirmDialog + +#### 详细步骤 + +(参考前面的组件拆分详细方案) + +--- + +### 阶段 4: 清理和优化 (1-2天) + +**目标**: 清理旧代码,优化性能 + +#### 任务清单 + +- [x] 删除 `lib/styles.ts` +- [x] 删除旧的 Modal 组件 +- [x] 移除 `window.api` 全局绑定 +- [x] 清理无用的 state 和函数 +- [x] 更新类型定义 +- [x] 代码格式化 +- [x] TypeScript 检查 + +--- + +### 阶段 5: 测试和修复 (2-3天) + +**目标**: 全面测试,修复 Bug + +#### 功能测试清单 + +- [ ] 添加供应商 (Claude/Codex) +- [ ] 编辑供应商 +- [ ] 删除供应商 +- [ ] 切换供应商 +- [ ] 拖拽排序 +- [ ] 设置保存 +- [ ] 导入导出配置 +- [ ] 主题切换 +- [ ] MCP 管理 +- [ ] 用量查询 +- [ ] 托盘菜单同步 + +#### 边界情况测试 + +- [ ] 空供应商列表 +- [ ] 网络错误 +- [ ] 表单验证 +- [ ] 并发操作 +- [ ] 大量数据 (100+ 供应商) + +--- + +# 第四部分: 质量保障 + +## 🧪 测试策略 + +### 手动测试 + +每完成一个阶段后进行全量功能测试。 + +### 自动化测试 (可选) + +可以考虑添加: + +- Vitest 单元测试 (hooks, utils) +- Testing Library 组件测试 + +--- + +## 🚨 风险控制 + +### 潜在风险 + +1. **功能回归**: 重构可能引入 bug +2. **用户数据丢失**: 配置文件操作失败 +3. **性能下降**: 新架构可能影响性能 +4. **兼容性问题**: 依赖库平台兼容性 + +### 缓解措施 + +1. **逐步重构**: 按阶段进行,每阶段后测试 +2. **保留备份**: Git tag + 配置文件备份 +3. **Beta 测试**: 先发布 beta 版本 +4. **回滚方案**: 准备快速回滚机制 + +--- + +## ⏪ 回滚方案 + +### 如果需要回滚 + +```bash +# 方案 1: 回到重构前 +git reset --hard backup-before-refactor + +# 方案 2: 创建回滚分支 +git checkout -b rollback-refactor +git revert +``` + +### 用户数据保护 + +在重构前自动备份配置: + +```rust +// Rust 后端 +fn backup_config_before_refactor() -> Result<()> { + let config_path = get_app_config_path()?; + let backup_path = config_path.with_extension("backup.json"); + fs::copy(config_path, backup_path)?; + Ok(()) +} +``` + +--- + +## 🎯 成功标准 + +### 必须达成 (Must Have) + +- ✅ 所有现有功能正常工作 +- ✅ 无用户数据丢失 +- ✅ 性能不下降 +- ✅ TypeScript 检查通过 + +### 期望达成 (Should Have) + +- ✅ 代码量减少 40%+ +- ✅ 用户反馈积极 +- ✅ 开发体验提升明显 + +### 可选达成 (Nice to Have) + +- ⭕ 添加自动化测试 +- ⭕ 性能优化 20%+ + +--- + +## 📊 预期成果 + +### 代码质量 + +- **代码行数**: 减少 40-60% +- **文件数量**: UI 组件增加,但单文件更小 +- **可维护性**: 大幅提升 + +### 开发效率 + +- **新功能开发**: 提升 50%+ +- **Bug 修复**: 提升 30%+ +- **代码审查**: 提升 40%+ + +### 用户体验 + +- **界面一致性**: 统一的设计语言 +- **响应速度**: 更好的加载反馈 +- **错误提示**: 更友好的错误信息 + +--- + +## 📚 参考资料 + +- [TanStack Query 文档](https://tanstack.com/query/latest) +- [react-hook-form 文档](https://react-hook-form.com/) +- [shadcn/ui 文档](https://ui.shadcn.com/) +- [Zod 文档](https://zod.dev/) +- [原始 PR #76](https://github.com/farion1231/cc-switch/pull/76) + +--- + +## 📝 注意事项 + +1. **分支管理**: 在新分支进行,不要直接在 main 上修改 +2. **提交粒度**: 每完成一小步就提交,便于回滚 +3. **文档更新**: 同步更新 CLAUDE.md +4. **依赖锁定**: 锁定依赖版本 +5. **沟通协作**: 定期同步进度 + +--- + +**祝重构顺利! 🚀** diff --git a/docs/REFACTORING_REFERENCE.md b/docs/REFACTORING_REFERENCE.md new file mode 100644 index 0000000..9102d0f --- /dev/null +++ b/docs/REFACTORING_REFERENCE.md @@ -0,0 +1,834 @@ +# 重构快速参考指南 + +> 常见模式和代码示例的速查表 + +--- + +## 📑 目录 + +1. [React Query 使用](#react-query-使用) +2. [react-hook-form 使用](#react-hook-form-使用) +3. [shadcn/ui 组件使用](#shadcnui-组件使用) +4. [代码迁移示例](#代码迁移示例) + +--- + +## React Query 使用 + +### 基础查询 + +```typescript +// 定义查询 Hook +export const useProvidersQuery = (appId: AppId) => { + return useQuery({ + queryKey: ['providers', appId], + queryFn: async () => { + const data = await providersApi.getAll(appId) + return data + }, + }) +} + +// 在组件中使用 +function MyComponent() { + const { data, isLoading, error } = useProvidersQuery('claude') + + if (isLoading) return
Loading...
+ if (error) return
Error: {error.message}
+ + return
{/* 使用 data */}
+} +``` + +### Mutation (变更操作) + +```typescript +// 定义 Mutation Hook +export const useAddProviderMutation = (appId: AppId) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (provider: Provider) => { + return await providersApi.add(provider, appId) + }, + onSuccess: () => { + // 重新获取数据 + queryClient.invalidateQueries({ queryKey: ['providers', appId] }) + toast.success('添加成功') + }, + onError: (error: Error) => { + toast.error(`添加失败: ${error.message}`) + }, + }) +} + +// 在组件中使用 +function AddProviderDialog() { +const mutation = useAddProviderMutation('claude') + + const handleSubmit = (data: Provider) => { + mutation.mutate(data) + } + + return ( + + ) +} +``` + +### 乐观更新 + +```typescript +export const useSwitchProviderMutation = (appId: AppId) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (providerId: string) => { + return await providersApi.switch(providerId, appId) + }, + // 乐观更新: 在请求发送前立即更新 UI + onMutate: async (providerId) => { + // 取消正在进行的查询 + await queryClient.cancelQueries({ queryKey: ['providers', appId] }) + + // 保存当前数据(以便回滚) + const previousData = queryClient.getQueryData(['providers', appId]) + + // 乐观更新 + queryClient.setQueryData(['providers', appId], (old: any) => ({ + ...old, + currentProviderId: providerId, + })) + + return { previousData } + }, + // 如果失败,回滚 + onError: (err, providerId, context) => { + queryClient.setQueryData(['providers', appId], context?.previousData) + toast.error('切换失败') + }, + // 无论成功失败,都重新获取数据 + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['providers', appId] }) + }, + }) +} +``` + +### 依赖查询 + +```typescript +// 第二个查询依赖第一个查询的结果 +const { data: providers } = useProvidersQuery(appId) +const currentProviderId = providers?.currentProviderId + +const { data: currentProvider } = useQuery({ + queryKey: ['provider', currentProviderId], + queryFn: () => providersApi.getById(currentProviderId!), + enabled: !!currentProviderId, // 只有当 ID 存在时才执行 +}) +``` + +--- + +## react-hook-form 使用 + +### 基础表单 + +```typescript +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' + +// 定义验证 schema +const schema = z.object({ + name: z.string().min(1, '请输入名称'), + email: z.string().email('邮箱格式不正确'), + age: z.number().min(18, '年龄必须大于18'), +}) + +type FormData = z.infer + +function MyForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + email: '', + age: 0, + }, + }) + + const onSubmit = (data: FormData) => { + console.log(data) + } + + return ( +
+ + {form.formState.errors.name && ( + {form.formState.errors.name.message} + )} + + +
+ ) +} +``` + +### 使用 shadcn/ui Form 组件 + +```typescript +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' + +function MyForm() { + const form = useForm({ + resolver: zodResolver(schema), + }) + + return ( +
+ + ( + + 名称 + + + + + + )} + /> + + + + + ) +} +``` + +### 动态表单验证 + +```typescript +// 根据条件动态验证 +const schema = z.object({ + type: z.enum(['official', 'custom']), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), +}).refine( + (data) => { + // 如果是自定义供应商,必须填写 baseUrl + if (data.type === 'custom') { + return !!data.baseUrl + } + return true + }, + { + message: '自定义供应商必须填写 Base URL', + path: ['baseUrl'], + } +) +``` + +### 手动触发验证 + +```typescript +function MyForm() { + const form = useForm() + + const handleBlur = async () => { + // 验证单个字段 + await form.trigger('name') + + // 验证多个字段 + await form.trigger(['name', 'email']) + + // 验证所有字段 + const isValid = await form.trigger() + } + + return
...
+} +``` + +--- + +## shadcn/ui 组件使用 + +### Dialog (对话框) + +```typescript +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +function MyDialog() { + const [open, setOpen] = useState(false) + + return ( + + + + 标题 + 描述信息 + + + {/* 内容 */} +
对话框内容
+ + + + + +
+
+ ) +} +``` + +### Select (选择器) + +```typescript +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +function MySelect() { + const [value, setValue] = useState('') + + return ( + + ) +} +``` + +### Tabs (标签页) + +```typescript +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + +function MyTabs() { + return ( + + + 标签1 + 标签2 + 标签3 + + + +
标签1的内容
+
+ + +
标签2的内容
+
+ + +
标签3的内容
+
+
+ ) +} +``` + +### Toast 通知 (Sonner) + +```typescript +import { toast } from 'sonner' + +// 成功通知 +toast.success('操作成功') + +// 错误通知 +toast.error('操作失败') + +// 加载中 +const toastId = toast.loading('处理中...') +// 完成后更新 +toast.success('处理完成', { id: toastId }) +// 或 +toast.dismiss(toastId) + +// 自定义持续时间 +toast.success('消息', { duration: 5000 }) + +// 带操作按钮 +toast('确认删除?', { + action: { + label: '删除', + onClick: () => handleDelete(), + }, +}) +``` + +--- + +## 代码迁移示例 + +### 示例 1: 状态管理迁移 + +**旧代码** (手动状态管理): + +```typescript +const [providers, setProviders] = useState>({}) +const [currentProviderId, setCurrentProviderId] = useState('') +const [loading, setLoading] = useState(false) +const [error, setError] = useState(null) + +useEffect(() => { + const load = async () => { + setLoading(true) + setError(null) + try { + const data = await window.api.getProviders(appType) + const currentId = await window.api.getCurrentProvider(appType) + setProviders(data) + setCurrentProviderId(currentId) + } catch (err) { + setError(err as Error) + } finally { + setLoading(false) + } + } + load() +}, [appId]) +``` + +**新代码** (React Query): + +```typescript +const { data, isLoading, error } = useProvidersQuery(appId) +const providers = data?.providers || {} +const currentProviderId = data?.currentProviderId || '' +``` + +**减少**: 从 20+ 行到 3 行 + +--- + +### 示例 2: 表单验证迁移 + +**旧代码** (手动验证): + +```typescript +const [name, setName] = useState('') +const [nameError, setNameError] = useState('') +const [apiKey, setApiKey] = useState('') +const [apiKeyError, setApiKeyError] = useState('') + +const validate = () => { + let valid = true + + if (!name.trim()) { + setNameError('请输入名称') + valid = false + } else { + setNameError('') + } + + if (!apiKey.trim()) { + setApiKeyError('请输入 API Key') + valid = false + } else if (apiKey.length < 10) { + setApiKeyError('API Key 长度不足') + valid = false + } else { + setApiKeyError('') + } + + return valid +} + +const handleSubmit = () => { + if (validate()) { + // 提交 + } +} + +return ( +
+ setName(e.target.value)} /> + {nameError && {nameError}} + + setApiKey(e.target.value)} /> + {apiKeyError && {apiKeyError}} + + +
+) +``` + +**新代码** (react-hook-form + zod): + +```typescript +const schema = z.object({ + name: z.string().min(1, '请输入名称'), + apiKey: z.string().min(10, 'API Key 长度不足'), +}) + +const form = useForm({ + resolver: zodResolver(schema), +}) + +return ( +
+ + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + + +) +``` + +**减少**: 从 40+ 行到 30 行,且更健壮 + +--- + +### 示例 3: 通知系统迁移 + +**旧代码** (自定义通知): + +```typescript +const [notification, setNotification] = useState<{ + message: string + type: 'success' | 'error' +} | null>(null) +const [isVisible, setIsVisible] = useState(false) + +const showNotification = (message: string, type: 'success' | 'error') => { + setNotification({ message, type }) + setIsVisible(true) + setTimeout(() => { + setIsVisible(false) + setTimeout(() => setNotification(null), 300) + }, 3000) +} + +return ( + <> + {notification && ( +
+ {notification.message} +
+ )} + {/* 其他内容 */} + +) +``` + +**新代码** (Sonner): + +```typescript +import { toast } from 'sonner' + +// 在需要的地方直接调用 +toast.success('操作成功') +toast.error('操作失败') + +// 在 main.tsx 中只需添加一次 +import { Toaster } from '@/components/ui/sonner' + + +``` + +**减少**: 从 20+ 行到 1 行调用 + +--- + +### 示例 4: 对话框迁移 + +**旧代码** (自定义 Modal): + +```typescript +const [isOpen, setIsOpen] = useState(false) + +return ( + <> + + + {isOpen && ( +
setIsOpen(false)}> +
e.stopPropagation()}> +
+

标题

+ +
+
+ {/* 内容 */} +
+
+ + +
+
+
+ )} + +) +``` + +**新代码** (shadcn/ui Dialog): + +```typescript +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' + +const [isOpen, setIsOpen] = useState(false) + +return ( + <> + + + + + + 标题 + + {/* 内容 */} + + + + + + + +) +``` + +**优势**: +- 无需自定义样式 +- 内置无障碍支持 +- 自动管理焦点和 ESC 键 + +--- + +### 示例 5: API 调用迁移 + +**旧代码** (window.api): + +```typescript +// 添加供应商 +const handleAdd = async (provider: Provider) => { + try { + await window.api.addProvider(provider, appType) + await loadProviders() + showNotification('添加成功', 'success') + } catch (error) { + showNotification('添加失败', 'error') + } +} +``` + +**新代码** (React Query Mutation): + +```typescript +// 在组件中 +const addMutation = useAddProviderMutation(appId) + +const handleAdd = (provider: Provider) => { + addMutation.mutate(provider) + // 成功和错误处理已在 mutation 定义中处理 +} +``` + +**优势**: +- 自动处理 loading 状态 +- 统一的错误处理 +- 自动刷新数据 +- 更少的样板代码 + +--- + +## 常见问题 + +### Q: 如何在 mutation 成功后关闭对话框? + +```typescript +const mutation = useAddProviderMutation(appId) + +const handleSubmit = (data: Provider) => { + mutation.mutate(data, { + onSuccess: () => { + setIsOpen(false) // 关闭对话框 + }, + }) +} +``` + +### Q: 如何在表单中使用异步验证? + +```typescript +const schema = z.object({ + name: z.string().refine( + async (name) => { + // 检查名称是否已存在 + const exists = await checkNameExists(name) + return !exists + }, + { message: '名称已存在' } + ), +}) +``` + +### Q: 如何手动刷新 Query 数据? + +```typescript +const queryClient = useQueryClient() + +// 方式1: 使缓存失效,触发重新获取 +queryClient.invalidateQueries({ queryKey: ['providers', appId] }) + +// 方式2: 直接刷新 +queryClient.refetchQueries({ queryKey: ['providers', appId] }) + +// 方式3: 更新缓存数据 +queryClient.setQueryData(['providers', appId], newData) +``` + +### Q: 如何在组件外部使用 toast? + +```typescript +// 直接导入并使用即可 +import { toast } from 'sonner' + +export const someUtil = () => { + toast.success('工具函数中的通知') +} +``` + +--- + +## 调试技巧 + +### React Query DevTools + +```typescript +// 在 main.tsx 中添加 +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + + + + + +``` + +### 查看表单状态 + +```typescript +const form = useForm() + +// 在开发模式下打印表单状态 +console.log('Form values:', form.watch()) +console.log('Form errors:', form.formState.errors) +console.log('Is valid:', form.formState.isValid) +``` + +--- + +## 性能优化建议 + +### 1. 避免不必要的重渲染 + +```typescript +// 使用 React.memo +export const ProviderCard = React.memo(({ provider, onEdit }: Props) => { + // ... +}) + +// 或使用 useMemo +const sortedProviders = useMemo( + () => Object.values(providers).sort(...), + [providers] +) +``` + +### 2. Query 配置优化 + +```typescript +const { data } = useQuery({ + queryKey: ['providers', appId], + queryFn: fetchProviders, + staleTime: 1000 * 60 * 5, // 5分钟内不重新获取 + gcTime: 1000 * 60 * 10, // 10分钟后清除缓存 +}) +``` + +### 3. 表单性能优化 + +```typescript +// 使用 mode 控制验证时机 +const form = useForm({ + mode: 'onBlur', // 失去焦点时验证 + // mode: 'onChange', // 每次输入都验证(较慢) + // mode: 'onSubmit', // 提交时验证(最快) +}) +``` + +--- + +**提示**: 将此文档保存在浏览器书签或编辑器中,方便随时查阅! diff --git a/docs/TEST_DEVELOPMENT_PLAN.md b/docs/TEST_DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..dc5d870 --- /dev/null +++ b/docs/TEST_DEVELOPMENT_PLAN.md @@ -0,0 +1,73 @@ +# 前端测试开发计划 + +## 1. 背景与目标 +- **背景**:v3.5.0 起前端功能快速扩张(供应商管理、MCP、导入导出、端点测速、国际化),缺失系统化测试导致回归风险与人工验证成本攀升。 +- **目标**:在 3 个迭代内建立覆盖关键业务的自动化测试体系,形成稳定的手动冒烟流程,并将测试执行纳入 CI/CD。 + +## 2. 范围与优先级 +| 范围 | 内容 | 优先级 | +| --- | --- | --- | +| 供应商管理 | 列表、排序、预设/自定义表单、切换、复制、删除 | P0 | +| 配置导入导出 | JSON 校验、备份、进度反馈、失败回滚 | P0 | +| MCP 管理 | 列表、启停、模板、命令校验 | P1 | +| 设置面板 | 主题/语言切换、目录设置、关于、更新检查 | P1 | +| 端点速度测试 & 使用脚本 | 启动测试、状态指示、脚本保存 | P2 | +| 国际化 | 中英切换、缺省文案回退 | P2 | + +## 3. 测试分层策略 +- **单元测试(Vitest)**:纯函数与 Hook(`useProviderActions`、`useSettingsForm`、`useDragSort`、`useImportExport` 等)验证数据处理、错误分支、排序逻辑。 +- **组件测试(React Testing Library)**:关键组件(`ProviderList`、`AddProviderDialog`、`SettingsDialog`、`McpPanel`)模拟交互、校验、提示;结合 MSW 模拟 API。 +- **集成测试(App 级别)**:挂载 `App.tsx`,覆盖应用切换、编辑模式、导入导出回调、语言切换,验证状态同步与 toast 提示。 +- **端到端测试(Playwright)**:依赖 `pnpm dev:renderer`,串联供应商 CRUD、排序拖拽、MCP 启停、语言切换即时刷新、更新检查跳转。 +- **手动冒烟**:Tauri 桌面包 + dev server 双通道,验证托盘、系统权限、真实文件写入。 + +## 4. 环境与工具 +- 依赖:Node 18+、pnpm 8+、Vitest、React Testing Library、MSW、Playwright、Testing Library User Event、Playwright Trace Viewer。 +- 配置要点: + - 在 `tsconfig` 中共享别名,Vitest 配合 `vite.config.mts`。 + - `setupTests.ts` 统一注册 MSW/RTL、自定义 matcher。 + - Playwright 使用多浏览器矩阵(Chromium 必选,WebKit 可选),并共享 `.env.test`。 + - Mock `@tauri-apps/api` 与 `providersApi`/`settingsApi`,隔离 Rust 层。 + +## 5. 自动化建设里程碑 +| 周期 | 目标 | 交付 | +| --- | --- | --- | +| Sprint 1 | Vitest 基础设施、核心 Hook 单测(P0) | `pnpm test:unit`、覆盖率报告、10+ 用例 | +| Sprint 2 | 组件/集成测试、MSW Mock 层 | `pnpm test:component`、App 主流程用例 | +| Sprint 3 | Playwright E2E、CI 接入 | `pnpm test:e2e`、CI job、冒烟脚本 | +| 持续 | 回归用例补齐、视觉比对探索 | Playwright Trace、截图基线 | + +## 6. 用例规划概览 +- **供应商管理**:新增(预设+自定义)、编辑校验、复制排序、切换失败回退、删除确认、使用脚本保存。 +- **导入导出**:成功、重复导入、校验失败、备份失败提示、导入后托盘刷新。 +- **MCP**:模板应用、协议切换(stdio/http)、命令校验、启停状态持久化。 +- **设置**:主题/语言即时生效、目录路径更新、更新检查按钮外链、关于信息渲染。 +- **端点速度测试**:触发测试、loading/成功/失败状态、指示器颜色、测速数据排序。 +- **国际化**:默认中文、切换英文后主界面/对话框文案变化、缺失 key fallback。 + +## 7. 数据与 Mock 策略 +- 在 `tests/fixtures/` 维护标准供应商、MCP、设置数据集。 +- 使用 MSW 拦截 `providersApi`、`settingsApi`、`providersApi.onSwitched` 等调用;提供延迟/错误注入接口以覆盖异常分支。 +- Playwright 端提供临时用户目录(`TMP_CC_SWITCH_HOME`)+ 伪配置文件,以验证真实文件交互路径。 + +## 8. 质量门禁与指标 +- 覆盖率目标:单元 ≥75%,分支 ≥70%,逐步提升至 80%+。 +- CI 阶段:`pnpm typecheck` → `pnpm format:check` → `pnpm test:unit` → `pnpm test:component` → `pnpm test:e2e`(可在 nightly 执行)。 +- 缺陷处理:修复前补充最小复现测试;E2E 冒烟必须陪跑重大功能发布。 + +## 9. 工作流与职责 +- **测试负责人**:前端工程师轮值;负责测试计划维护、PR 流水线健康。 +- **开发者职责**:提交功能需附新增/更新测试、列出手动验证步骤、如涉及 UI 提交截图。 +- **Code Review 检查**:测试覆盖说明、mock 合理性、易读性。 + +## 10. 风险与缓解 +| 风险 | 影响 | 缓解 | +| --- | --- | --- | +| Tauri API Mock 难度高 | 单测无法稳定 | 抽象 API 适配层 + MSW 统一模拟 | +| Playwright 运行时间长 | CI 变慢 | 拆分冒烟/完整版,冒烟只跑关键路径 | +| 国际化文案频繁变化 | 用例脆弱 | 优先断言 data-testid/结构,文案使用翻译 key | + +## 11. 输出与维护 +- 文档维护者:前端团队;每个版本更新后检查测试覆盖清单。 +- 交付物:测试报告(CI artifact)、Playwright Trace、覆盖率摘要。 +- 复盘:每次发布后召开 30 分钟测试复盘,记录缺陷、补齐用例。 diff --git a/package.json b/package.json index 3f3b086..9cef59e 100644 --- a/package.json +++ b/package.json @@ -10,20 +10,29 @@ "build:renderer": "vite build", "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"", - "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"" + "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"", + "test:unit": "vitest run", + "test:unit:watch": "vitest watch" }, "keywords": [], "author": "Jason Young", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.8.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/node": "^20.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", + "cross-fetch": "^4.1.0", + "jsdom": "^25.0.0", + "msw": "^2.11.6", "prettier": "^3.6.2", "typescript": "^5.3.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^2.0.5" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.4", @@ -35,20 +44,36 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.13", + "@tanstack/react-query": "^5.90.3", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-store": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "codemirror": "^6.0.2", "i18next": "^25.5.2", "jsonc-parser": "^3.2.1", "lucide-react": "^0.542.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.65.0", "react-i18next": "^16.0.0", "smol-toml": "^1.4.2", - "tailwindcss": "^4.1.13" + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.13", + "zod": "^4.1.12" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f46cb7c..ed7d06b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,39 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@18.3.1) + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.65.0(react@18.3.1)) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) + '@tanstack/react-query': + specifier: ^5.90.3 + version: 5.90.3(react@18.3.1) '@tauri-apps/api': specifier: ^2.8.0 version: 2.8.0 @@ -53,6 +83,12 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.0.0 version: 2.9.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 codemirror: specifier: ^6.0.2 version: 6.0.2 @@ -71,19 +107,40 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.65.0 + version: 7.65.0(react@18.3.1) react-i18next: specifier: ^16.0.0 version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) smol-toml: specifier: ^1.4.2 version: 1.4.2 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 tailwindcss: specifier: ^4.1.13 version: 4.1.13 + zod: + specifier: ^4.1.12 + version: 4.1.12 devDependencies: '@tauri-apps/cli': specifier: ^2.8.0 version: 2.8.1 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.1 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^20.0.0 version: 20.19.9 @@ -96,6 +153,15 @@ importers: '@vitejs/plugin-react': specifier: ^4.2.0 version: 4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) + cross-fetch: + specifier: ^4.1.0 + version: 4.1.0 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + msw: + specifier: ^2.11.6 + version: 2.11.6(@types/node@20.19.9)(typescript@5.9.2) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -105,13 +171,22 @@ importers: vite: specifier: ^5.0.0 version: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + vitest: + specifier: ^2.0.5 + version: 2.1.9(@types/node@20.19.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2)) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -229,6 +304,34 @@ packages: '@codemirror/view@6.38.2': resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -389,6 +492,61 @@ packages: cpu: [x64] os: [win32] + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@inquirer/ansi@1.0.1': + resolution: {integrity: sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.0': + resolution: {integrity: sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.14': + resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.9': + resolution: {integrity: sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -430,6 +588,388 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mswjs/interceptors@0.40.0': + resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} + engines: {node: '>=18'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -544,6 +1084,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.1.13': resolution: {integrity: sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==} @@ -638,6 +1181,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.3': + resolution: {integrity: sha512-HtPOnCwmx4dd35PfXU8jjkhwYrsHfuqgC8RCJIwWglmhIUIlzPP0ZcEkDAc+UtAWCiLm7T8rxeEfHZlz3hYMCA==} + + '@tanstack/react-query@5.90.3': + resolution: {integrity: sha512-i/LRL6DtuhG6bjGzavIMIVuKKPWx2AnEBIsBfuMm3YoHne0a20nWmsatOCBcVSaT0/8/5YFjNkebHAPLVUSi0Q==} + peerDependencies: + react: ^18 || ^19 + '@tauri-apps/api@2.8.0': resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} @@ -729,6 +1280,38 @@ packages: '@tauri-apps/plugin-updater@2.9.0': resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -758,36 +1341,162 @@ packages: '@types/react@18.3.23': resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + browserslist@4.25.1: resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001731: resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -797,17 +1506,71 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + electron-to-chromium@1.5.197: resolution: {integrity: sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -817,21 +1580,86 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + i18next@25.5.2: resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==} peerDependencies: @@ -840,6 +1668,24 @@ packages: typescript: optional: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@2.5.1: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true @@ -847,6 +1693,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -932,6 +1787,12 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -940,9 +1801,29 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.18: resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -959,14 +1840,56 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.11.6: + resolution: {integrity: sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nwsapi@2.2.22: + resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -979,11 +1902,25 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: react: ^18.3.1 + react-hook-form@7.65.0: + resolution: {integrity: sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@16.0.0: resolution: {integrity: sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==} peerDependencies: @@ -1000,19 +1937,76 @@ packages: typescript: optional: true + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rettime@0.7.0: + resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} + rollup@4.46.2: resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1020,17 +2014,61 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + smol-toml@1.4.2: resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==} engines: {node: '>= 18'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + style-mod@4.1.2: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwindcss@4.1.13: resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==} @@ -1042,9 +2080,60 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.0.17: + resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.0.17: + resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1053,12 +2142,40 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite@5.4.19: resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1090,6 +2207,31 @@ packages: terser: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -1097,6 +2239,68 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1104,13 +2308,38 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + snapshots: + '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -1293,6 +2522,26 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': dependencies: react: 18.3.1 @@ -1387,6 +2636,56 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.10': {} + + '@hookform/resolvers@5.2.2(react-hook-form@7.65.0(react@18.3.1))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.65.0(react@18.3.1) + + '@inquirer/ansi@1.0.1': {} + + '@inquirer/confirm@5.1.19(@types/node@20.19.9)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.9) + '@inquirer/type': 3.0.9(@types/node@20.19.9) + optionalDependencies: + '@types/node': 20.19.9 + + '@inquirer/core@10.3.0(@types/node@20.19.9)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@20.19.9) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.9 + + '@inquirer/figures@1.0.14': {} + + '@inquirer/type@3.0.9(@types/node@20.19.9)': + optionalDependencies: + '@types/node': 20.19.9 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -1436,6 +2735,388 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@mswjs/interceptors@0.40.0': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-label@2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/rect@1.1.1': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.46.2': @@ -1498,6 +3179,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.1.13': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1569,6 +3252,13 @@ snapshots: tailwindcss: 4.1.13 vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + '@tanstack/query-core@5.90.3': {} + + '@tanstack/react-query@5.90.3(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.90.3 + react: 18.3.1 + '@tauri-apps/api@2.8.0': {} '@tauri-apps/cli-darwin-arm64@2.8.1': @@ -1634,6 +3324,42 @@ snapshots: dependencies: '@tauri-apps/api': 2.8.0 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 @@ -1672,6 +3398,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.1.3 + '@types/statuses@2.0.6': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))': dependencies: '@babel/core': 7.28.0 @@ -1684,6 +3412,71 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2))(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.18 + optionalDependencies: + msw: 2.11.6(@types/node@20.19.9)(typescript@5.9.2) + vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.18 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + browserslist@4.25.1: dependencies: caniuse-lite: 1.0.30001731 @@ -1691,10 +3484,41 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + caniuse-lite@1.0.30001731: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.1: {} + chownr@3.0.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.18.7 @@ -1705,25 +3529,96 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/view': 6.38.2 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + convert-source-map@2.0.0: {} + cookie@1.0.2: {} + crelt@1.0.6: {} + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.1.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.1: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + detect-libc@2.0.4: {} + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + electron-to-chromium@1.5.197: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.2.3 + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1752,27 +3647,139 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.2.2: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphql@16.11.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@4.0.3: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + i18next@25.5.2(typescript@5.9.2): dependencies: '@babel/runtime': 7.28.4 optionalDependencies: typescript: 5.9.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-node-process@1.2.0: {} + + is-potential-custom-element-name@1.0.1: {} + jiti@2.5.1: {} js-tokens@4.0.0: {} + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.4 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.22 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json5@2.2.3: {} @@ -1828,6 +3835,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -1836,10 +3847,22 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.30.18: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + minipass@7.1.2: {} minizlib@3.0.2: @@ -1850,10 +3873,55 @@ snapshots: ms@2.1.3: {} + msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2): + dependencies: + '@inquirer/confirm': 5.1.19(@types/node@20.19.9) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.0.2 + graphql: 16.11.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 4.41.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + nanoid@3.3.11: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.19: {} + nwsapi@2.2.22: {} + + outvariant@1.4.3: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-to-regexp@6.3.0: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} postcss@8.5.6: @@ -1864,12 +3932,24 @@ snapshots: prettier@3.6.2: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.65.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-i18next@16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2): dependencies: '@babel/runtime': 7.28.4 @@ -1880,12 +3960,50 @@ snapshots: react-dom: 18.3.1(react@18.3.1) typescript: 5.9.2 + react-is@17.0.2: {} + react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.23)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.23 + + react-remove-scroll@2.7.1(@types/react@18.3.23)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.23)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.23)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.23)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.23)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + + react-style-singleton@2.2.3(@types/react@18.3.23)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.23 + react@18.3.1: dependencies: loose-envify: 1.4.0 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-directory@2.1.1: {} + + rettime@0.7.0: {} + rollup@4.46.2: dependencies: '@types/estree': 1.0.8 @@ -1912,18 +4030,63 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.46.2 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 semver@6.3.1: {} + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + smol-toml@1.4.2: {} + sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + style-mod@4.1.2: {} + symbol-tree@3.2.4: {} + + tailwind-merge@3.3.1: {} + tailwindcss@4.1.13: {} tapable@2.2.3: {} @@ -1937,18 +4100,91 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts-core@7.0.17: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.0.17: + dependencies: + tldts-core: 7.0.17 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.17 + + tr46@0.0.3: {} + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: {} + type-fest@4.41.0: {} + typescript@5.9.2: {} undici-types@6.21.0: {} + until-async@3.0.2: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 escalade: 3.2.0 picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@18.3.23)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.23 + + use-sidecar@1.1.3(@types/react@18.3.23)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.23 + + vite-node@2.1.9(@types/node@20.19.9)(lightningcss@1.30.1): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1): dependencies: esbuild: 0.21.5 @@ -1959,10 +4195,111 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 + vitest@2.1.9(@types/node@20.19.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2)): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2))(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.18 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + vite-node: 2.1.9(@types/node@20.19.9)(lightningcss@1.30.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.9 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + void-elements@3.1.0: {} w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yoctocolors-cjs@2.1.3: {} + + zod@4.1.12: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b1f90cf..fb70ac3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -585,8 +585,10 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", + "thiserror 1.0.69", "tokio", "toml 0.8.2", + "toml_edit 0.22.27", ] [[package]] @@ -1572,7 +1574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", @@ -3109,11 +3111,10 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -4875,7 +4876,7 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "toml_edit 0.20.2", ] @@ -4896,9 +4897,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -4919,7 +4920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.11.4", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -4932,10 +4933,22 @@ dependencies = [ "indexmap 2.11.4", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.13", +] + [[package]] name = "toml_edit" version = "0.23.6" @@ -4957,6 +4970,12 @@ dependencies = [ "winnow 0.7.13", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47333a9..dc8dd69 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,10 @@ rust-version = "1.85.0" name = "cc_switch_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +default = [] +test-hooks = [] + [build-dependencies] tauri-build = { version = "2.4.0", features = [] } @@ -31,11 +35,13 @@ tauri-plugin-dialog = "2" tauri-plugin-store = "2" dirs = "5.0" toml = "0.8" +toml_edit = "0.22" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } futures = "0.3" regex = "1.10" rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } +thiserror = "1.0" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index c46f910..c66adad 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::str::FromStr; /// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器) #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -19,6 +20,7 @@ pub struct McpRoot { } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; +use crate::error::AppError; use crate::provider::ProviderManager; /// 应用类型 @@ -38,11 +40,19 @@ impl AppType { } } -impl From<&str> for AppType { - fn from(s: &str) -> Self { - match s.to_lowercase().as_str() { - "codex" => AppType::Codex, - _ => AppType::Claude, // 默认为 Claude +impl FromStr for AppType { + type Err = AppError; + + fn from_str(s: &str) -> Result { + let normalized = s.trim().to_lowercase(); + match normalized.as_str() { + "claude" => Ok(AppType::Claude), + "codex" => Ok(AppType::Codex), + other => Err(AppError::localized( + "unsupported_app", + format!("不支持的应用标识: '{other}'。可选值: claude, codex。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex."), + )), } } } @@ -80,7 +90,7 @@ impl Default for MultiAppConfig { impl MultiAppConfig { /// 从文件加载配置(处理v1到v2的迁移) - pub fn load() -> Result { + pub fn load() -> Result { let config_path = get_app_config_path(); if !config_path.exists() { @@ -89,8 +99,8 @@ impl MultiAppConfig { } // 尝试读取文件 - let content = std::fs::read_to_string(&config_path) - .map_err(|e| format!("读取配置文件失败: {}", e))?; + let content = + std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; // 检查是否是旧版本格式(v1) if let Ok(v1_config) = serde_json::from_str::(&content) { @@ -130,11 +140,11 @@ impl MultiAppConfig { } // 尝试读取v2格式 - serde_json::from_str::(&content).map_err(|e| format!("解析配置文件失败: {}", e)) + serde_json::from_str::(&content).map_err(|e| AppError::json(&config_path, e)) } /// 保存配置到文件 - pub fn save(&self) -> Result<(), String> { + pub fn save(&self) -> Result<(), AppError> { let config_path = get_app_config_path(); // 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容 if config_path.exists() { diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index e08d3ba..485fdf7 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -3,43 +3,37 @@ use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; use tauri_plugin_store::StoreExt; +use crate::error::AppError; + /// Store 中的键名 const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override"; -/// 全局缓存的 AppHandle (在应用启动时设置) -static APP_HANDLE: OnceLock>> = OnceLock::new(); +/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle +static APP_CONFIG_DIR_OVERRIDE: OnceLock>> = OnceLock::new(); -/// 设置全局 AppHandle -pub fn set_app_handle(handle: tauri::AppHandle) { - let store = APP_HANDLE.get_or_init(|| RwLock::new(None)); - if let Ok(mut guard) = store.write() { - *guard = Some(handle); +fn override_cache() -> &'static RwLock> { + APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None)) +} + +fn update_cached_override(value: Option) { + if let Ok(mut guard) = override_cache().write() { + *guard = value; } } -/// 获取全局 AppHandle -fn get_app_handle() -> Option { - let store = APP_HANDLE.get()?; - let guard = store.read().ok()?; - guard.as_ref().cloned() -} - -/// 从 Tauri Store 读取 app_config_dir 覆盖配置 (无需 AppHandle 版本) +/// 获取缓存中的 app_config_dir 覆盖路径 pub fn get_app_config_dir_override() -> Option { - let app = get_app_handle()?; - get_app_config_dir_from_store(&app) + override_cache().read().ok()?.clone() } -/// 从 Tauri Store 读取 app_config_dir 覆盖配置(公开函数) -pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option { - let store = app.store_builder("app_paths.json").build(); - - if let Err(e) = &store { - log::warn!("无法创建 Store: {}", e); - return None; - } - - let store = store.unwrap(); +fn read_override_from_store(app: &tauri::AppHandle) -> Option { + let store = match app.store_builder("app_paths.json").build() { + Ok(store) => store, + Err(e) => { + log::warn!("无法创建 Store: {}", e); + return None; + } + }; match store.get(STORE_KEY_APP_CONFIG_DIR) { Some(Value::String(path_str)) => { @@ -50,7 +44,6 @@ pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option let path = resolve_path(path_str); - // 验证路径是否存在 if !path.exists() { log::warn!( "Store 中配置的 app_config_dir 不存在: {:?}\n\ @@ -64,22 +57,32 @@ pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option Some(path) } Some(_) => { - log::warn!("Store 中的 {} 类型不正确,应为字符串", STORE_KEY_APP_CONFIG_DIR); + log::warn!( + "Store 中的 {} 类型不正确,应为字符串", + STORE_KEY_APP_CONFIG_DIR + ); None } None => None, } } +/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存 +pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option { + let value = read_override_from_store(app); + update_cached_override(value.clone()); + value +} + /// 写入 app_config_dir 到 Tauri Store pub fn set_app_config_dir_to_store( app: &tauri::AppHandle, path: Option<&str>, -) -> Result<(), String> { +) -> Result<(), AppError> { let store = app .store_builder("app_paths.json") .build() - .map_err(|e| format!("创建 Store 失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?; match path { Some(p) => { @@ -88,20 +91,21 @@ pub fn set_app_config_dir_to_store( store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string())); log::info!("已将 app_config_dir 写入 Store: {}", trimmed); } else { - // 空字符串 = 删除配置 store.delete(STORE_KEY_APP_CONFIG_DIR); log::info!("已从 Store 中删除 app_config_dir 配置"); } } None => { - // None = 删除配置 store.delete(STORE_KEY_APP_CONFIG_DIR); log::info!("已从 Store 中删除 app_config_dir 配置"); } } - store.save().map_err(|e| format!("保存 Store 失败: {}", e))?; + store + .save() + .map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?; + refresh_app_config_dir_override(app); Ok(()) } @@ -125,13 +129,11 @@ fn resolve_path(raw: &str) -> PathBuf { } /// 从旧的 settings.json 迁移 app_config_dir 到 Store -pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), String> { +pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> { // app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移 // 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置 log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置"); - // 确保 Store 初始化正常 - let _ = get_app_config_dir_from_store(app); - + let _ = refresh_app_config_dir_override(app); Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index d53c604..603a73d 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -4,7 +4,8 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; -use crate::config::atomic_write; +use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; +use crate::error::AppError; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -15,34 +16,70 @@ pub struct McpStatus { } fn user_config_path() -> PathBuf { - // 用户级 MCP 配置文件:~/.claude.json - dirs::home_dir() - .expect("无法获取用户主目录") - .join(".claude.json") + ensure_mcp_override_migrated(); + get_claude_mcp_path() } -fn read_json_value(path: &Path) -> Result { +fn ensure_mcp_override_migrated() { + if crate::settings::get_claude_override_dir().is_none() { + return; + } + + let new_path = get_claude_mcp_path(); + if new_path.exists() { + return; + } + + let legacy_path = get_default_claude_mcp_path(); + if !legacy_path.exists() { + return; + } + + if let Some(parent) = new_path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + log::warn!("创建 MCP 目录失败: {}", err); + return; + } + } + + match fs::copy(&legacy_path, &new_path) { + Ok(_) => { + log::info!( + "已根据覆盖目录复制 MCP 配置: {} -> {}", + legacy_path.display(), + new_path.display() + ); + } + Err(err) => { + log::warn!( + "复制 MCP 配置失败: {} -> {}: {}", + legacy_path.display(), + new_path.display(), + err + ); + } + } +} + +fn read_json_value(path: &Path) -> Result { if !path.exists() { return Ok(serde_json::json!({})); } - let content = - fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?; - let value: Value = serde_json::from_str(&content) - .map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?; + let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; + let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?; Ok(value) } -fn write_json_value(path: &Path, value: &Value) -> Result<(), String> { +fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let json = - serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?; + serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?; atomic_write(path, json.as_bytes()) } -pub fn get_mcp_status() -> Result { +pub fn get_mcp_status() -> Result { let path = user_config_path(); let (exists, count) = if path.exists() { let v = read_json_value(&path)?; @@ -59,35 +96,41 @@ pub fn get_mcp_status() -> Result { }) } -pub fn read_mcp_json() -> Result, String> { +pub fn read_mcp_json() -> Result, AppError> { let path = user_config_path(); if !path.exists() { return Ok(None); } - let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?; + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; Ok(Some(content)) } -pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { +pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); } // 基础字段校验(尽量宽松) if !spec.is_object() { - return Err("MCP 服务器定义必须为 JSON 对象".into()); + return Err(AppError::McpValidation( + "MCP 服务器定义必须为 JSON 对象".into(), + )); } let t_opt = spec.get("type").and_then(|x| x.as_str()); let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理) let is_http = t_opt.map(|t| t == "http").unwrap_or(false); if !(is_stdio || is_http) { - return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into()); + return Err(AppError::McpValidation( + "MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(), + )); } // stdio 类型必须有 command if is_stdio { let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); if cmd.is_empty() { - return Err("stdio 类型的 MCP 服务器缺少 command 字段".into()); + return Err(AppError::McpValidation( + "stdio 类型的 MCP 服务器缺少 command 字段".into(), + )); } } @@ -95,7 +138,9 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { if is_http { let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); if url.is_empty() { - return Err("http 类型的 MCP 服务器缺少 url 字段".into()); + return Err(AppError::McpValidation( + "http 类型的 MCP 服务器缺少 url 字段".into(), + )); } } @@ -110,7 +155,7 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { { let obj = root .as_object_mut() - .ok_or_else(|| "mcp.json 根必须是对象".to_string())?; + .ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?; if !obj.contains_key("mcpServers") { obj.insert("mcpServers".into(), serde_json::json!({})); } @@ -129,9 +174,9 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { Ok(true) } -pub fn delete_mcp_server(id: &str) -> Result { +pub fn delete_mcp_server(id: &str) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); } let path = user_config_path(); if !path.exists() { @@ -149,7 +194,7 @@ pub fn delete_mcp_server(id: &str) -> Result { Ok(true) } -pub fn validate_command_in_path(cmd: &str) -> Result { +pub fn validate_command_in_path(cmd: &str) -> Result { if cmd.trim().is_empty() { return Ok(false); } @@ -190,7 +235,7 @@ pub fn validate_command_in_path(cmd: &str) -> Result { /// 仅覆盖 mcpServers,其他字段保持不变 pub fn set_mcp_servers_map( servers: &std::collections::HashMap, -) -> Result<(), String> { +) -> Result<(), AppError> { let path = user_config_path(); let mut root = if path.exists() { read_json_value(&path)? @@ -204,14 +249,16 @@ pub fn set_mcp_servers_map( let mut obj = if let Some(map) = spec.as_object() { map.clone() } else { - return Err(format!("MCP 服务器 '{}' 不是对象", id)); + return Err(AppError::McpValidation(format!( + "MCP 服务器 '{}' 不是对象", + id + ))); }; if let Some(server_val) = obj.remove("server") { - let server_obj = server_val - .as_object() - .cloned() - .ok_or_else(|| format!("MCP 服务器 '{}' server 字段不是对象", id))?; + let server_obj = server_val.as_object().cloned().ok_or_else(|| { + AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id)) + })?; obj = server_obj; } @@ -230,7 +277,7 @@ pub fn set_mcp_servers_map( { let obj = root .as_object_mut() - .ok_or_else(|| "~/.claude.json 根必须是对象".to_string())?; + .ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?; obj.insert("mcpServers".into(), Value::Object(out)); } diff --git a/src-tauri/src/claude_plugin.rs b/src-tauri/src/claude_plugin.rs index a4773b4..08acf19 100644 --- a/src-tauri/src/claude_plugin.rs +++ b/src-tauri/src/claude_plugin.rs @@ -1,35 +1,36 @@ use std::fs; use std::path::PathBuf; +use crate::error::AppError; + const CLAUDE_DIR: &str = ".claude"; const CLAUDE_CONFIG_FILE: &str = "config.json"; -fn claude_dir() -> Result { +fn claude_dir() -> Result { // 优先使用设置中的覆盖目录 if let Some(dir) = crate::settings::get_claude_override_dir() { return Ok(dir); } - let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?; + let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?; Ok(home.join(CLAUDE_DIR)) } -pub fn claude_config_path() -> Result { +pub fn claude_config_path() -> Result { Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE)) } -pub fn ensure_claude_dir_exists() -> Result { +pub fn ensure_claude_dir_exists() -> Result { let dir = claude_dir()?; if !dir.exists() { - fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?; + fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?; } Ok(dir) } -pub fn read_claude_config() -> Result, String> { +pub fn read_claude_config() -> Result, AppError> { let path = claude_config_path()?; if path.exists() { - let content = - fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?; + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; Ok(Some(content)) } else { Ok(None) @@ -47,7 +48,7 @@ fn is_managed_config(content: &str) -> bool { } } -pub fn write_claude_config() -> Result { +pub fn write_claude_config() -> Result { // 增量写入:仅设置 primaryApiKey = "any",保留其它字段 let path = claude_config_path()?; ensure_claude_dir_exists()?; @@ -78,16 +79,15 @@ pub fn write_claude_config() -> Result { if changed || !path.exists() { let serialized = serde_json::to_string_pretty(&obj) - .map_err(|e| format!("序列化 Claude 配置失败: {}", e))?; - fs::write(&path, format!("{}\n", serialized)) - .map_err(|e| format!("写入 Claude 配置失败: {}", e))?; + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?; Ok(true) } else { Ok(false) } } -pub fn clear_claude_config() -> Result { +pub fn clear_claude_config() -> Result { let path = claude_config_path()?; if !path.exists() { return Ok(false); @@ -112,19 +112,18 @@ pub fn clear_claude_config() -> Result { return Ok(false); } - let serialized = serde_json::to_string_pretty(&value) - .map_err(|e| format!("序列化 Claude 配置失败: {}", e))?; - fs::write(&path, format!("{}\n", serialized)) - .map_err(|e| format!("写入 Claude 配置失败: {}", e))?; + let serialized = + serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?; Ok(true) } -pub fn claude_config_status() -> Result<(bool, PathBuf), String> { +pub fn claude_config_status() -> Result<(bool, PathBuf), AppError> { let path = claude_config_path()?; Ok((path.exists(), path)) } -pub fn is_claude_config_applied() -> Result { +pub fn is_claude_config_applied() -> Result { match read_claude_config()? { Some(content) => Ok(is_managed_config(&content)), None => Ok(false), diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 24c3004..3d5f9c7 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::config::{ atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file, }; +use crate::error::AppError; use serde_json::Value; use std::fs; use std::path::Path; @@ -43,7 +44,10 @@ pub fn get_codex_provider_paths( } /// 删除 Codex 供应商配置文件 -pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> { +pub fn delete_codex_provider_config( + provider_id: &str, + provider_name: &str, +) -> Result<(), AppError> { let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name)); delete_file(&auth_path).ok(); @@ -55,32 +59,28 @@ pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> R //(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警) /// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步 -pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> { +pub fn write_codex_live_atomic( + auth: &Value, + config_text_opt: Option<&str>, +) -> Result<(), AppError> { 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 目录失败: {}: {}", parent.display(), e))?; + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } // 读取旧内容用于回滚 let old_auth = if auth_path.exists() { - Some( - fs::read(&auth_path) - .map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?, - ) + Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?) + } else { + None + }; + let _old_config = if config_path.exists() { + Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?) } else { None }; - let _old_config = - if config_path.exists() { - Some(fs::read(&config_path).map_err(|e| { - format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e) - })?) - } else { - None - }; // 准备写入内容 let cfg_text = match config_text_opt { @@ -88,13 +88,7 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R None => String::new(), }; if !cfg_text.trim().is_empty() { - toml::from_str::(&cfg_text).map_err(|e| { - format!( - "config.toml 语法错误: {} (路径: {})", - e, - config_path.display() - ) - })?; + toml::from_str::(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?; } // 第一步:写 auth.json @@ -115,43 +109,43 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R } /// 读取 `~/.codex/config.toml`,若不存在返回空字符串 -pub fn read_codex_config_text() -> Result { +pub fn read_codex_config_text() -> Result { let path = get_codex_config_path(); if path.exists() { - std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e)) + std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e)) } else { Ok(String::new()) } } /// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串 -pub fn read_config_text_from_path(path: &Path) -> Result { +pub fn read_config_text_from_path(path: &Path) -> Result { if path.exists() { - std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e)) + std::fs::read_to_string(path).map_err(|e| AppError::io(path, e)) } else { Ok(String::new()) } } /// 对非空的 TOML 文本进行语法校验 -pub fn validate_config_toml(text: &str) -> Result<(), String> { +pub fn validate_config_toml(text: &str) -> Result<(), AppError> { if text.trim().is_empty() { return Ok(()); } toml::from_str::(text) .map(|_| ()) - .map_err(|e| format!("config.toml 语法错误: {}", e)) + .map_err(|e| AppError::toml(Path::new("config.toml"), e)) } /// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空) -pub fn read_and_validate_codex_config_text() -> Result { +pub fn read_and_validate_codex_config_text() -> Result { let s = read_codex_config_text()?; validate_config_toml(&s)?; Ok(s) } /// 从指定路径读取并校验 config.toml,返回文本(可能为空) -pub fn read_and_validate_config_from_path(path: &Path) -> Result { +pub fn read_and_validate_config_from_path(path: &Path) -> Result { let s = read_config_text_from_path(path)?; validate_config_toml(&s)?; Ok(s) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs deleted file mode 100644 index 734846d..0000000 --- a/src-tauri/src/commands.rs +++ /dev/null @@ -1,1542 +0,0 @@ -#![allow(non_snake_case)] - -use std::collections::HashMap; -use tauri::State; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_opener::OpenerExt; - -use crate::app_config::AppType; -use crate::claude_mcp; -use crate::claude_plugin; -use crate::codex_config; -use crate::config::{self, get_claude_settings_path, ConfigStatus}; -use crate::provider::{Provider, ProviderMeta}; -use crate::speedtest; -use crate::store::AppState; - -fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> { - match app_type { - AppType::Claude => { - if !provider.settings_config.is_object() { - return Err("Claude 配置必须是 JSON 对象".to_string()); - } - } - AppType::Codex => { - let settings = provider - .settings_config - .as_object() - .ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?; - let auth = settings - .get("auth") - .ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?; - if !auth.is_object() { - return Err("Codex auth 配置必须是 JSON 对象".to_string()); - } - if let Some(config_value) = settings.get("config") { - if !(config_value.is_string() || config_value.is_null()) { - return Err("Codex config 字段必须是字符串".to_string()); - } - if let Some(cfg_text) = config_value.as_str() { - codex_config::validate_config_toml(cfg_text)?; - } - } - } - } - Ok(()) -} - -/// 获取所有供应商 -#[tauri::command] -pub async fn get_providers( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, -) -> Result, 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.get_all_providers().clone()) -} - -/// 获取当前供应商ID -#[tauri::command] -pub async fn get_current_provider( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - let config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - let manager = config - .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - Ok(manager.current.clone()) -} - -/// 添加供应商 -#[tauri::command] -pub async fn add_provider( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, - provider: Provider, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - validate_provider_settings(&app_type, &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 - }; - - // 若目标为当前供应商,则先写 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) -} - -/// 更新供应商 -#[tauri::command] -pub async fn update_provider( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, - provider: Provider, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - validate_provider_settings(&app_type, &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)); - } - - // 若更新的是当前供应商,先写 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)?; - } - } - } - - // 更新内存并保存(保留/合并已有的 meta.custom_endpoints,避免丢失在编辑流程中新增的自定义端点) - { - 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))?; - - // 若已存在旧供应商,合并其 meta(尤其是 custom_endpoints)到新对象 - let merged_provider = if let Some(existing) = manager.providers.get(&provider.id) { - // 克隆入参作为基准 - let mut updated = provider.clone(); - - match (existing.meta.as_ref(), updated.meta.take()) { - // 入参未携带 meta:直接沿用旧 meta - (Some(old_meta), None) => { - updated.meta = Some(old_meta.clone()); - } - // 入参携带 meta:与旧 meta 合并(以旧值为准,保留新增项) - (Some(old_meta), Some(mut new_meta)) => { - // 合并 custom_endpoints(URL 去重,保留旧端点的时间信息,补充新增端点) - let mut merged_map = old_meta.custom_endpoints.clone(); - for (url, ep) in new_meta.custom_endpoints.drain() { - merged_map.entry(url).or_insert(ep); - } - updated.meta = Some(crate::provider::ProviderMeta { - custom_endpoints: merged_map, - usage_script: new_meta.usage_script.clone(), - }); - } - // 旧 meta 不存在:使用入参(可能为 None) - (None, maybe_new) => { - updated.meta = maybe_new; - } - } - - updated - } else { - // 不存在旧供应商(理论上不应发生,因为前面已校验 exists) - provider.clone() - }; - - manager - .providers - .insert(merged_provider.id.clone(), merged_provider); - } - state.save()?; - - Ok(true) -} - -/// 删除供应商 -#[tauri::command] -pub async fn delete_provider( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, - id: String, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - let mut config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - 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(config); // 释放锁 - state.save()?; - - Ok(true) -} - -/// 切换供应商 -#[tauri::command] -pub async fn switch_provider( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, - id: String, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - let mut config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - // 为避免长期可变借用,尽快获取必要数据并缩小借用范围 - let provider = { - 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(); - provider - }; - - // SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置 - match app_type { - AppType::Codex => { - use serde_json::Value; - - // 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config - if !{ - let cur = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - cur.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 失败: {}: {}", config_path.display(), e) - })? - } else { - String::new() - }; - - let live = serde_json::json!({ - "auth": auth, - "config": config_str, - }); - - let cur_id2 = { - let m = config - .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - m.current.clone() - }; - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - if let Some(cur) = m.providers.get_mut(&cur_id2) { - 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() { - let cur_id = { - let m = config - .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - m.current.clone() - }; - if !cur_id.is_empty() { - if let Ok(live) = read_json_file::(&settings_path) { - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - if let Some(cur) = m.providers.get_mut(&cur_id) { - 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)?; - - // 写入后回读 live,并回填到目标供应商的 SSOT,保证一致 - if settings_path.exists() { - if let Ok(live_after) = read_json_file::(&settings_path) { - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - if let Some(target) = m.providers.get_mut(&id) { - target.settings_config = live_after; - } - } - } - } - } - - // 更新当前供应商(短借用范围) - { - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - manager.current = id; - } - - // 对 Codex:切换完成后,同步 MCP 到 config.toml,并将最新的 config.toml 回填到当前供应商 settings_config.config - if let AppType::Codex = app_type { - // 1) 依据 SSOT 将启用的 MCP 投影到 ~/.codex/config.toml - crate::mcp::sync_enabled_to_codex(&config)?; - - // 2) 读取投影后的 live config.toml 文本 - let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; - - // 3) 回填到当前(目标)供应商的 settings_config.config,确保编辑面板读取到最新 MCP - let cur_id = { - let m = config - .get_manager(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - m.current.clone() - }; - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - if let Some(p) = m.providers.get_mut(&cur_id) { - if let Some(obj) = p.settings_config.as_object_mut() { - obj.insert( - "config".to_string(), - serde_json::Value::String(cfg_text_after), - ); - } - } - } - - log::info!("成功切换到供应商: {}", provider.name); - - // 保存配置 - drop(config); // 释放锁 - state.save()?; - - Ok(true) -} - -/// 导入当前配置为默认供应商 -#[tauri::command] -pub async fn import_default_config( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - // 仅当 providers 为空时才从 live 导入一条默认项 - { - let config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - if let Some(manager) = config.get_manager(&app_type) { - if !manager.get_all_providers().is_empty() { - return Ok(true); - } - } - } - - // 根据应用类型导入配置 - // 读取当前主配置为默认供应商(不再写入副本文件) - 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::(&auth_path)?; - let config_str = match crate::codex_config::read_and_validate_codex_config_text() { - Ok(s) => s, - Err(e) => return Err(e), - }; - serde_json::json!({ "auth": auth, "config": config_str }) - } - AppType::Claude => { - let settings_path = get_claude_settings_path(); - if !settings_path.exists() { - return Err("Claude Code 配置文件不存在".to_string()); - } - crate::config::read_json_file::(&settings_path)? - } - }; - - // 创建默认供应商(仅首次初始化) - let provider = Provider::with_id( - "default".to_string(), - "default".to_string(), - settings_config, - None, - ); - - // 添加到管理器 - 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); - // 设置当前供应商为默认项 - manager.current = "default".to_string(); - - // 保存配置 - drop(config); // 释放锁 - state.save()?; - - Ok(true) -} - -/// 获取 Claude Code 配置状态 -#[tauri::command] -pub async fn get_claude_config_status() -> Result { - Ok(crate::config::get_claude_config_status()) -} - -/// 获取应用配置状态(通用) -/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串) -#[tauri::command] -pub async fn get_config_status( - app_type: Option, - app: Option, - appType: Option, -) -> Result { - let app = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - match app { - AppType::Claude => Ok(crate::config::get_claude_config_status()), - AppType::Codex => { - use crate::codex_config::{get_codex_auth_path, get_codex_config_dir}; - let auth_path = get_codex_auth_path(); - - // 放宽:只要 auth.json 存在即可认为已配置;config.toml 允许为空 - let exists = auth_path.exists(); - let path = get_codex_config_dir().to_string_lossy().to_string(); - - Ok(ConfigStatus { exists, path }) - } - } -} - -/// 获取 Claude Code 配置文件路径 -#[tauri::command] -pub async fn get_claude_code_config_path() -> Result { - Ok(get_claude_settings_path().to_string_lossy().to_string()) -} - -/// 获取当前生效的配置目录 -#[tauri::command] -pub async fn get_config_dir( - app_type: Option, - app: Option, - appType: Option, -) -> Result { - let app = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - let dir = match app { - AppType::Claude => config::get_claude_config_dir(), - AppType::Codex => codex_config::get_codex_config_dir(), - }; - - Ok(dir.to_string_lossy().to_string()) -} - -/// 打开配置文件夹 -/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串) -#[tauri::command] -pub async fn open_config_folder( - handle: tauri::AppHandle, - app_type: Option, - app: Option, - appType: Option, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - let config_dir = match app_type { - AppType::Claude => crate::config::get_claude_config_dir(), - AppType::Codex => crate::codex_config::get_codex_config_dir(), - }; - - // 确保目录存在 - if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?; - } - - // 使用 opener 插件打开文件夹 - handle - .opener() - .open_path(config_dir.to_string_lossy().to_string(), None::) - .map_err(|e| format!("打开文件夹失败: {}", e))?; - - Ok(true) -} - -/// 弹出系统目录选择器并返回用户选择的路径 -#[tauri::command] -pub async fn pick_directory( - app: tauri::AppHandle, - default_path: Option, -) -> Result, String> { - let initial = default_path - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()); - - let result = tauri::async_runtime::spawn_blocking(move || { - let mut builder = app.dialog().file(); - if let Some(path) = initial { - builder = builder.set_directory(path); - } - builder.blocking_pick_folder() - }) - .await - .map_err(|e| format!("弹出目录选择器失败: {}", e))?; - - match result { - Some(file_path) => { - let resolved = file_path - .simplified() - .into_path() - .map_err(|e| format!("解析选择的目录失败: {}", e))?; - Ok(Some(resolved.to_string_lossy().to_string())) - } - None => Ok(None), - } -} - -/// 打开外部链接 -#[tauri::command] -pub async fn open_external(app: tauri::AppHandle, url: String) -> Result { - // 规范化 URL,缺少协议时默认加 https:// - let url = if url.starts_with("http://") || url.starts_with("https://") { - url - } else { - format!("https://{}", url) - }; - - // 使用 opener 插件打开链接 - app.opener() - .open_url(&url, None::) - .map_err(|e| format!("打开链接失败: {}", e))?; - - Ok(true) -} - -/// 获取应用配置文件路径 -#[tauri::command] -pub async fn get_app_config_path() -> Result { - use crate::config::get_app_config_path; - - let config_path = get_app_config_path(); - Ok(config_path.to_string_lossy().to_string()) -} - -/// 打开应用配置文件夹 -#[tauri::command] -pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result { - use crate::config::get_app_config_dir; - - let config_dir = get_app_config_dir(); - - // 确保目录存在 - if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?; - } - - // 使用 opener 插件打开文件夹 - handle - .opener() - .open_path(config_dir.to_string_lossy().to_string(), None::) - .map_err(|e| format!("打开文件夹失败: {}", e))?; - - Ok(true) -} - -// ===================== -// Claude MCP 管理命令 -// ===================== - -/// 获取 Claude MCP 状态(settings.local.json 与 mcp.json) -#[tauri::command] -pub async fn get_claude_mcp_status() -> Result { - claude_mcp::get_mcp_status() -} - -/// 读取 mcp.json 文本内容(不存在则返回 Ok(None)) -#[tauri::command] -pub async fn read_claude_mcp_config() -> Result, String> { - claude_mcp::read_mcp_json() -} - -/// 新增或更新一个 MCP 服务器条目 -#[tauri::command] -pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result { - claude_mcp::upsert_mcp_server(&id, spec) -} - -/// 删除一个 MCP 服务器条目 -#[tauri::command] -pub async fn delete_claude_mcp_server(id: String) -> Result { - claude_mcp::delete_mcp_server(&id) -} - -/// 校验命令是否在 PATH 中可用(不执行) -#[tauri::command] -pub async fn validate_mcp_command(cmd: String) -> Result { - claude_mcp::validate_command_in_path(&cmd) -} - -// ===================== -// 用量查询命令 -// ===================== - -/// 查询供应商用量 -#[tauri::command] -pub async fn query_provider_usage( - state: State<'_, AppState>, - provider_id: Option, - providerId: Option, - app_type: Option, - app: Option, - appType: Option, -) -> Result { - use crate::provider::{UsageData, UsageResult}; - - // 解析参数 - let provider_id = provider_id - .or(providerId) - .ok_or("缺少 providerId 参数")?; - - 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); - - // 1. 获取供应商配置并克隆所需数据 - let (api_key, base_url, usage_script_code, timeout) = { - let config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - let manager = config - .get_manager(&app_type) - .ok_or("应用类型不存在")?; - - let provider = manager - .providers - .get(&provider_id) - .ok_or("供应商不存在")?; - - // 2. 检查脚本配置 - let usage_script = provider - .meta - .as_ref() - .and_then(|m| m.usage_script.as_ref()) - .ok_or("未配置用量查询脚本")?; - - if !usage_script.enabled { - return Err("用量查询未启用".to_string()); - } - - // 3. 提取凭证和脚本配置 - let (api_key, base_url) = extract_credentials(provider, &app_type)?; - let timeout = usage_script.timeout.unwrap_or(10); - let code = usage_script.code.clone(); - - // 显式释放锁 - drop(config); - - (api_key, base_url, code, timeout) - }; - - // 5. 执行脚本 - let result = crate::usage_script::execute_usage_script( - &usage_script_code, - &api_key, - &base_url, - timeout, - ) - .await; - - // 6. 构建结果(支持单对象或数组) - match result { - Ok(data) => { - // 尝试解析为数组 - let usage_list: Vec = if data.is_array() { - // 直接解析为数组 - serde_json::from_value(data) - .map_err(|e| format!("数据格式错误: {}", e))? - } else { - // 单对象包装为数组(向后兼容) - let single: UsageData = serde_json::from_value(data) - .map_err(|e| format!("数据格式错误: {}", e))?; - vec![single] - }; - - Ok(UsageResult { - success: true, - data: Some(usage_list), - error: None, - }) - } - Err(e) => { - Ok(UsageResult { - success: false, - data: None, - error: Some(e), - }) - } - } -} - -/// 从供应商配置中提取 API Key 和 Base URL -fn extract_credentials( - provider: &crate::provider::Provider, - app_type: &AppType, -) -> Result<(String, String), String> { - match app_type { - AppType::Claude => { - let env = provider - .settings_config - .get("env") - .and_then(|v| v.as_object()) - .ok_or("配置格式错误: 缺少 env")?; - - let api_key = env - .get("ANTHROPIC_AUTH_TOKEN") - .and_then(|v| v.as_str()) - .ok_or("缺少 API Key")? - .to_string(); - - let base_url = env - .get("ANTHROPIC_BASE_URL") - .and_then(|v| v.as_str()) - .ok_or("缺少 ANTHROPIC_BASE_URL 配置")? - .to_string(); - - Ok((api_key, base_url)) - } - AppType::Codex => { - let auth = provider - .settings_config - .get("auth") - .and_then(|v| v.as_object()) - .ok_or("配置格式错误: 缺少 auth")?; - - let api_key = auth - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .ok_or("缺少 API Key")? - .to_string(); - - // 从 config TOML 中提取 base_url - let config_toml = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let base_url = if config_toml.contains("base_url") { - let re = regex::Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).unwrap(); - re.captures(config_toml) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str().to_string()) - .ok_or("config.toml 中 base_url 格式错误")? - } else { - return Err("config.toml 中缺少 base_url 配置".to_string()); - }; - - Ok((api_key, base_url)) - } - } -} - -// ===================== -// 新:集中以 config.json 为 SSOT 的 MCP 配置命令 -// ===================== - -#[derive(serde::Serialize)] -pub struct McpConfigResponse { - pub config_path: String, - pub servers: std::collections::HashMap, -} - -/// 获取 MCP 配置(来自 ~/.cc-switch/config.json) -#[tauri::command] -pub async fn get_mcp_config( - state: State<'_, AppState>, - app: Option, -) -> Result { - let config_path = crate::config::get_app_config_path() - .to_string_lossy() - .to_string(); - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); - let (servers, normalized) = crate::mcp::get_servers_snapshot_for(&mut cfg, &app_ty); - let need_save = normalized > 0; - drop(cfg); - if need_save { - state.save()?; - } - Ok(McpConfigResponse { - config_path, - servers, - }) -} - -/// 在 config.json 中新增或更新一个 MCP 服务器定义 -#[tauri::command] -pub async fn upsert_mcp_server_in_config( - state: State<'_, AppState>, - app: Option, - id: String, - spec: serde_json::Value, - sync_other_side: Option, -) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); - let mut sync_targets: Vec = Vec::new(); - - let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec.clone())?; - - let should_sync_current = cfg - .mcp_for(&app_ty) - .servers - .get(&id) - .and_then(|entry| entry.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if should_sync_current { - sync_targets.push(app_ty.clone()); - } - - if sync_other_side.unwrap_or(false) { - let other_app = match app_ty.clone() { - crate::app_config::AppType::Claude => crate::app_config::AppType::Codex, - crate::app_config::AppType::Codex => crate::app_config::AppType::Claude, - }; - crate::mcp::upsert_in_config_for(&mut cfg, &other_app, &id, spec)?; - - let should_sync_other = cfg - .mcp_for(&other_app) - .servers - .get(&id) - .and_then(|entry| entry.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if should_sync_other { - sync_targets.push(other_app.clone()); - } - } - drop(cfg); - state.save()?; - - let cfg2 = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - for app_ty_to_sync in sync_targets { - match app_ty_to_sync { - crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?, - crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?, - }; - } - Ok(changed) -} - -/// 在 config.json 中删除一个 MCP 服务器定义 -#[tauri::command] -pub async fn delete_mcp_server_in_config( - state: State<'_, AppState>, - app: Option, - id: String, -) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); - let existed = crate::mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?; - drop(cfg); - state.save()?; - // 若删除的是 Claude/Codex 客户端的条目,则同步一次,确保启用项从对应 live 配置中移除 - let cfg2 = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - match app_ty { - crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?, - crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?, - } - Ok(existed) -} - -/// 设置启用状态并同步到 ~/.claude.json -#[tauri::command] -pub async fn set_mcp_enabled( - state: State<'_, AppState>, - app: Option, - id: String, - enabled: bool, -) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); - let changed = crate::mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, &id, enabled)?; - drop(cfg); - state.save()?; - Ok(changed) -} - -/// 手动同步:将启用的 MCP 投影到 ~/.claude.json(不更改 config.json) -#[tauri::command] -pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Claude); - crate::mcp::sync_enabled_to_claude(&cfg)?; - let need_save = normalized > 0; - drop(cfg); - if need_save { - state.save()?; - } - Ok(true) -} - -/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml(不更改 config.json) -#[tauri::command] -pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Codex); - crate::mcp::sync_enabled_to_codex(&cfg)?; - let need_save = normalized > 0; - drop(cfg); - if need_save { - state.save()?; - } - Ok(true) -} - -/// 从 ~/.claude.json 导入 MCP 定义到 config.json,返回变更数量 -#[tauri::command] -pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let changed = crate::mcp::import_from_claude(&mut cfg)?; - drop(cfg); - if changed > 0 { - state.save()?; - } - Ok(changed) -} - -/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json(Codex 作用域),返回变更数量 -#[tauri::command] -pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result { - let mut cfg = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let changed = crate::mcp::import_from_codex(&mut cfg)?; - drop(cfg); - if changed > 0 { - state.save()?; - } - Ok(changed) -} - -/// 读取当前生效(live)的配置内容,返回可直接作为 provider.settings_config 的对象 -/// - Codex: 返回 { auth: JSON, config: string } -/// - Claude: 返回 settings.json 的 JSON 内容 -#[tauri::command] -pub async fn read_live_provider_settings( - app_type: Option, - app: Option, - appType: Option, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - match app_type { - AppType::Codex => { - let auth_path = crate::codex_config::get_codex_auth_path(); - if !auth_path.exists() { - return Err("Codex 配置文件不存在:缺少 auth.json".to_string()); - } - let auth: serde_json::Value = crate::config::read_json_file(&auth_path)?; - let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?; - Ok(serde_json::json!({ "auth": auth, "config": cfg_text })) - } - AppType::Claude => { - let path = crate::config::get_claude_settings_path(); - if !path.exists() { - return Err("Claude Code 配置文件不存在".to_string()); - } - let v: serde_json::Value = crate::config::read_json_file(&path)?; - Ok(v) - } - } -} - -/// 获取设置 -#[tauri::command] -pub async fn get_settings() -> Result { - Ok(crate::settings::get_settings()) -} - -/// 保存设置 -#[tauri::command] -pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { - crate::settings::update_settings(settings)?; - Ok(true) -} - -/// 重启应用程序(当 app_config_dir 变更后使用) -#[tauri::command] -pub async fn restart_app(app: tauri::AppHandle) -> Result { - // 使用 tauri-plugin-process 重启应用 - app.restart(); -} - -/// 检查更新 -#[tauri::command] -pub async fn check_for_updates(handle: tauri::AppHandle) -> Result { - // 打开 GitHub releases 页面 - handle - .opener() - .open_url( - "https://github.com/farion1231/cc-switch/releases/latest", - None::, - ) - .map_err(|e| format!("打开更新页面失败: {}", e))?; - - Ok(true) -} - -/// 判断是否为便携版(绿色版)运行 -#[tauri::command] -pub async fn is_portable_mode() -> Result { - let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?; - if let Some(dir) = exe_path.parent() { - Ok(dir.join("portable.ini").is_file()) - } else { - Ok(false) - } -} - -/// Claude 插件:获取 ~/.claude/config.json 状态 -#[tauri::command] -pub async fn get_claude_plugin_status() -> Result { - match claude_plugin::claude_config_status() { - Ok((exists, path)) => Ok(ConfigStatus { - exists, - path: path.to_string_lossy().to_string(), - }), - Err(err) => Err(err), - } -} - -/// Claude 插件:读取配置内容(若不存在返回 Ok(None)) -#[tauri::command] -pub async fn read_claude_plugin_config() -> Result, String> { - claude_plugin::read_claude_config() -} - -/// Claude 插件:写入/清除固定配置 -#[tauri::command] -pub async fn apply_claude_plugin_config(official: bool) -> Result { - if official { - claude_plugin::clear_claude_config() - } else { - claude_plugin::write_claude_config() - } -} - -/// Claude 插件:检测是否已写入目标配置 -#[tauri::command] -pub async fn is_claude_plugin_applied() -> Result { - claude_plugin::is_claude_config_applied() -} - -/// 测试第三方/自定义供应商端点的网络延迟 -#[tauri::command] -pub async fn test_api_endpoints( - urls: Vec, - timeout_secs: Option, -) -> Result, String> { - let filtered: Vec = urls - .into_iter() - .filter(|url| !url.trim().is_empty()) - .collect(); - speedtest::test_endpoints(filtered, timeout_secs).await -} - -/// 获取自定义端点列表 -#[tauri::command] -pub async fn get_custom_endpoints( - state: State<'_, crate::store::AppState>, - app_type: Option, - app: Option, - appType: Option, - provider_id: Option, - providerId: Option, -) -> Result, 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 provider_id = provider_id - .or(providerId) - .ok_or_else(|| "缺少 providerId".to_string())?; - let mut cfg_guard = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - let manager = cfg_guard - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - let Some(provider) = manager.providers.get_mut(&provider_id) else { - return Ok(vec![]); - }; - - // 首选从 provider.meta 读取 - let meta = provider.meta.get_or_insert_with(ProviderMeta::default); - if !meta.custom_endpoints.is_empty() { - let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect(); - result.sort_by(|a, b| b.added_at.cmp(&a.added_at)); - return Ok(result); - } - - Ok(vec![]) -} - -/// 添加自定义端点 -#[tauri::command] -pub async fn add_custom_endpoint( - state: State<'_, crate::store::AppState>, - app_type: Option, - app: Option, - appType: Option, - provider_id: Option, - providerId: Option, - url: String, -) -> Result<(), 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 provider_id = provider_id - .or(providerId) - .ok_or_else(|| "缺少 providerId".to_string())?; - let normalized = url.trim().trim_end_matches('/').to_string(); - if normalized.is_empty() { - return Err("URL 不能为空".to_string()); - } - - let mut cfg_guard = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let manager = cfg_guard - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - let Some(provider) = manager.providers.get_mut(&provider_id) else { - return Err("供应商不存在或未选择".to_string()); - }; - let meta = provider.meta.get_or_insert_with(ProviderMeta::default); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - let endpoint = crate::settings::CustomEndpoint { - url: normalized.clone(), - added_at: timestamp, - last_used: None, - }; - meta.custom_endpoints.insert(normalized, endpoint); - drop(cfg_guard); - state.save()?; - Ok(()) -} - -/// 删除自定义端点 -#[tauri::command] -pub async fn remove_custom_endpoint( - state: State<'_, crate::store::AppState>, - app_type: Option, - app: Option, - appType: Option, - provider_id: Option, - providerId: Option, - url: String, -) -> Result<(), 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 provider_id = provider_id - .or(providerId) - .ok_or_else(|| "缺少 providerId".to_string())?; - let normalized = url.trim().trim_end_matches('/').to_string(); - - let mut cfg_guard = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let manager = cfg_guard - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - if let Some(provider) = manager.providers.get_mut(&provider_id) { - if let Some(meta) = provider.meta.as_mut() { - meta.custom_endpoints.remove(&normalized); - } - } - drop(cfg_guard); - state.save()?; - Ok(()) -} - -/// 更新端点最后使用时间 -#[tauri::command] -pub async fn update_endpoint_last_used( - state: State<'_, crate::store::AppState>, - app_type: Option, - app: Option, - appType: Option, - provider_id: Option, - providerId: Option, - url: String, -) -> Result<(), 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 provider_id = provider_id - .or(providerId) - .ok_or_else(|| "缺少 providerId".to_string())?; - let normalized = url.trim().trim_end_matches('/').to_string(); - - let mut cfg_guard = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - let manager = cfg_guard - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - if let Some(provider) = manager.providers.get_mut(&provider_id) { - if let Some(meta) = provider.meta.as_mut() { - if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - endpoint.last_used = Some(timestamp); - } - } - } - drop(cfg_guard); - state.save()?; - Ok(()) -} - -/// 获取 app_config_dir 覆盖配置 (从 Store) -#[tauri::command] -pub async fn get_app_config_dir_override(app: tauri::AppHandle) -> Result, String> { - Ok(crate::app_store::get_app_config_dir_from_store(&app) - .map(|p| p.to_string_lossy().to_string())) -} - -/// 设置 app_config_dir 覆盖配置 (到 Store) -#[tauri::command] -pub async fn set_app_config_dir_override( - app: tauri::AppHandle, - path: Option, -) -> Result { - crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; - Ok(true) -} - -// ===================== -// Provider Sort Order Management -// ===================== - -#[derive(serde::Deserialize)] -pub struct ProviderSortUpdate { - pub id: String, - #[serde(rename = "sortIndex")] - pub sort_index: usize, -} - -/// Update sort order for multiple providers -#[tauri::command] -pub async fn update_providers_sort_order( - state: State<'_, AppState>, - app_type: Option, - app: Option, - appType: Option, - updates: Vec, -) -> Result { - let app_type = app_type - .or_else(|| app.as_deref().map(|s| s.into())) - .or_else(|| appType.as_deref().map(|s| s.into())) - .unwrap_or(AppType::Claude); - - let mut config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - // Update sort_index for each provider - for update in updates { - if let Some(provider) = manager.providers.get_mut(&update.id) { - provider.sort_index = Some(update.sort_index); - } - } - - drop(config); - state.save()?; - - Ok(true) -} \ No newline at end of file diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs new file mode 100644 index 0000000..c66798d --- /dev/null +++ b/src-tauri/src/commands/config.rs @@ -0,0 +1,126 @@ +#![allow(non_snake_case)] + +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +use crate::app_config::AppType; +use crate::codex_config; +use crate::config::{self, get_claude_settings_path, ConfigStatus}; + +/// 获取 Claude Code 配置状态 +#[tauri::command] +pub async fn get_claude_config_status() -> Result { + Ok(config::get_claude_config_status()) +} + +use std::str::FromStr; + +#[tauri::command] +pub async fn get_config_status(app: String) -> Result { + match AppType::from_str(&app).map_err(|e| e.to_string())? { + AppType::Claude => Ok(config::get_claude_config_status()), + AppType::Codex => { + let auth_path = codex_config::get_codex_auth_path(); + let exists = auth_path.exists(); + let path = codex_config::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 { + Ok(get_claude_settings_path().to_string_lossy().to_string()) +} + +/// 获取当前生效的配置目录 +#[tauri::command] +pub async fn get_config_dir(app: String) -> Result { + let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { + AppType::Claude => config::get_claude_config_dir(), + AppType::Codex => codex_config::get_codex_config_dir(), + }; + + Ok(dir.to_string_lossy().to_string()) +} + +/// 打开配置文件夹 +#[tauri::command] +pub async fn open_config_folder(handle: AppHandle, app: String) -> Result { + let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { + AppType::Claude => config::get_claude_config_dir(), + AppType::Codex => codex_config::get_codex_config_dir(), + }; + + if !config_dir.exists() { + std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?; + } + + handle + .opener() + .open_path(config_dir.to_string_lossy().to_string(), None::) + .map_err(|e| format!("打开文件夹失败: {}", e))?; + + Ok(true) +} + +/// 弹出系统目录选择器并返回用户选择的路径 +#[tauri::command] +pub async fn pick_directory( + app: AppHandle, + default_path: Option, +) -> Result, String> { + let initial = default_path + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()); + + let result = tauri::async_runtime::spawn_blocking(move || { + let mut builder = app.dialog().file(); + if let Some(path) = initial { + builder = builder.set_directory(path); + } + builder.blocking_pick_folder() + }) + .await + .map_err(|e| format!("弹出目录选择器失败: {}", e))?; + + match result { + Some(file_path) => { + let resolved = file_path + .simplified() + .into_path() + .map_err(|e| format!("解析选择的目录失败: {}", e))?; + Ok(Some(resolved.to_string_lossy().to_string())) + } + None => Ok(None), + } +} + +/// 获取应用配置文件路径 +#[tauri::command] +pub async fn get_app_config_path() -> Result { + let config_path = config::get_app_config_path(); + Ok(config_path.to_string_lossy().to_string()) +} + +/// 打开应用配置文件夹 +#[tauri::command] +pub async fn open_app_config_folder(handle: AppHandle) -> Result { + let config_dir = config::get_app_config_dir(); + + if !config_dir.exists() { + std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?; + } + + handle + .opener() + .open_path(config_dir.to_string_lossy().to_string(), None::) + .map_err(|e| format!("打开文件夹失败: {}", e))?; + + Ok(true) +} diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs new file mode 100644 index 0000000..f0070ef --- /dev/null +++ b/src-tauri/src/commands/import_export.rs @@ -0,0 +1,104 @@ +#![allow(non_snake_case)] + +use serde_json::{json, Value}; +use std::path::PathBuf; +use tauri::State; +use tauri_plugin_dialog::DialogExt; + +use crate::error::AppError; +use crate::services::ConfigService; +use crate::store::AppState; + +/// 导出配置文件 +#[tauri::command] +pub async fn export_config_to_file(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let target_path = PathBuf::from(&file_path); + ConfigService::export_config_to_path(&target_path)?; + Ok::<_, AppError>(json!({ + "success": true, + "message": "Configuration exported successfully", + "filePath": file_path + })) + }) + .await + .map_err(|e| format!("导出配置失败: {}", e))? + .map_err(|e: AppError| e.to_string()) +} + +/// 从文件导入配置 +#[tauri::command] +pub async fn import_config_from_file( + file_path: String, + state: State<'_, AppState>, +) -> Result { + let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || { + let path_buf = PathBuf::from(&file_path); + ConfigService::load_config_for_import(&path_buf) + }) + .await + .map_err(|e| format!("导入配置失败: {}", e))? + .map_err(|e: AppError| e.to_string())?; + + { + let mut guard = state + .config + .write() + .map_err(|e| AppError::from(e).to_string())?; + *guard = new_config; + } + + Ok(json!({ + "success": true, + "message": "Configuration imported successfully", + "backupId": backup_id + })) +} + +/// 同步当前供应商配置到对应的 live 文件 +#[tauri::command] +pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result { + { + let mut config_state = state + .config + .write() + .map_err(|e| AppError::from(e).to_string())?; + ConfigService::sync_current_providers_to_live(&mut config_state) + .map_err(|e| e.to_string())?; + } + + Ok(json!({ + "success": true, + "message": "Live configuration synchronized" + })) +} + +/// 保存文件对话框 +#[tauri::command] +pub async fn save_file_dialog( + app: tauri::AppHandle, + default_name: String, +) -> Result, String> { + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("JSON", &["json"]) + .set_file_name(&default_name) + .blocking_save_file(); + + Ok(result.map(|p| p.to_string())) +} + +/// 打开文件对话框 +#[tauri::command] +pub async fn open_file_dialog( + app: tauri::AppHandle, +) -> Result, String> { + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("JSON", &["json"]) + .blocking_pick_file(); + + Ok(result.map(|p| p.to_string())) +} diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs new file mode 100644 index 0000000..ed6dc1b --- /dev/null +++ b/src-tauri/src/commands/mcp.rs @@ -0,0 +1,131 @@ +#![allow(non_snake_case)] + +use std::collections::HashMap; + +use serde::Serialize; +use tauri::State; + +use crate::app_config::AppType; +use crate::claude_mcp; +use crate::services::McpService; +use crate::store::AppState; + +/// 获取 Claude MCP 状态 +#[tauri::command] +pub async fn get_claude_mcp_status() -> Result { + claude_mcp::get_mcp_status().map_err(|e| e.to_string()) +} + +/// 读取 mcp.json 文本内容 +#[tauri::command] +pub async fn read_claude_mcp_config() -> Result, String> { + claude_mcp::read_mcp_json().map_err(|e| e.to_string()) +} + +/// 新增或更新一个 MCP 服务器条目 +#[tauri::command] +pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result { + claude_mcp::upsert_mcp_server(&id, spec).map_err(|e| e.to_string()) +} + +/// 删除一个 MCP 服务器条目 +#[tauri::command] +pub async fn delete_claude_mcp_server(id: String) -> Result { + claude_mcp::delete_mcp_server(&id).map_err(|e| e.to_string()) +} + +/// 校验命令是否在 PATH 中可用(不执行) +#[tauri::command] +pub async fn validate_mcp_command(cmd: String) -> Result { + claude_mcp::validate_command_in_path(&cmd).map_err(|e| e.to_string()) +} + +#[derive(Serialize)] +pub struct McpConfigResponse { + pub config_path: String, + pub servers: HashMap, +} + +/// 获取 MCP 配置(来自 ~/.cc-switch/config.json) +use std::str::FromStr; + +#[tauri::command] +pub async fn get_mcp_config( + state: State<'_, AppState>, + app: String, +) -> Result { + let config_path = crate::config::get_app_config_path() + .to_string_lossy() + .to_string(); + let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?; + let servers = McpService::get_servers(&state, app_ty).map_err(|e| e.to_string())?; + Ok(McpConfigResponse { + config_path, + servers, + }) +} + +/// 在 config.json 中新增或更新一个 MCP 服务器定义 +#[tauri::command] +pub async fn upsert_mcp_server_in_config( + state: State<'_, AppState>, + app: String, + id: String, + spec: serde_json::Value, + sync_other_side: Option, +) -> Result { + let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?; + McpService::upsert_server(&state, app_ty, &id, spec, sync_other_side.unwrap_or(false)) + .map_err(|e| e.to_string()) +} + +/// 在 config.json 中删除一个 MCP 服务器定义 +#[tauri::command] +pub async fn delete_mcp_server_in_config( + state: State<'_, AppState>, + app: String, + id: String, +) -> Result { + let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?; + McpService::delete_server(&state, app_ty, &id).map_err(|e| e.to_string()) +} + +/// 设置启用状态并同步到客户端配置 +#[tauri::command] +pub async fn set_mcp_enabled( + state: State<'_, AppState>, + app: String, + id: String, + enabled: bool, +) -> Result { + let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?; + McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string()) +} + +/// 手动同步:将启用的 MCP 投影到 ~/.claude.json +#[tauri::command] +pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result { + McpService::sync_enabled(&state, AppType::Claude) + .map(|_| true) + .map_err(|e| e.to_string()) +} + +/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml +#[tauri::command] +pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result { + McpService::sync_enabled(&state, AppType::Codex) + .map(|_| true) + .map_err(|e| e.to_string()) +} + +/// 从 ~/.claude.json 导入 MCP 定义到 config.json +#[tauri::command] +pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result { + McpService::import_from_claude(&state).map_err(|e| e.to_string()) +} + +/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json +#[tauri::command] +pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result { + McpService::import_from_codex(&state).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs new file mode 100644 index 0000000..9a97f75 --- /dev/null +++ b/src-tauri/src/commands/misc.rs @@ -0,0 +1,45 @@ +#![allow(non_snake_case)] + +use tauri::AppHandle; +use tauri_plugin_opener::OpenerExt; + +/// 打开外部链接 +#[tauri::command] +pub async fn open_external(app: AppHandle, url: String) -> Result { + let url = if url.starts_with("http://") || url.starts_with("https://") { + url + } else { + format!("https://{}", url) + }; + + app.opener() + .open_url(&url, None::) + .map_err(|e| format!("打开链接失败: {}", e))?; + + Ok(true) +} + +/// 检查更新 +#[tauri::command] +pub async fn check_for_updates(handle: AppHandle) -> Result { + handle + .opener() + .open_url( + "https://github.com/farion1231/cc-switch/releases/latest", + None::, + ) + .map_err(|e| format!("打开更新页面失败: {}", e))?; + + Ok(true) +} + +/// 判断是否为便携版(绿色版)运行 +#[tauri::command] +pub async fn is_portable_mode() -> Result { + let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?; + if let Some(dir) = exe_path.parent() { + Ok(dir.join("portable.ini").is_file()) + } else { + Ok(false) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..170dc7e --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,17 @@ +#![allow(non_snake_case)] + +mod config; +mod import_export; +mod mcp; +mod misc; +mod plugin; +mod provider; +mod settings; + +pub use config::*; +pub use import_export::*; +pub use mcp::*; +pub use misc::*; +pub use plugin::*; +pub use provider::*; +pub use settings::*; diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs new file mode 100644 index 0000000..30fab6d --- /dev/null +++ b/src-tauri/src/commands/plugin.rs @@ -0,0 +1,36 @@ +#![allow(non_snake_case)] + +use crate::config::ConfigStatus; + +/// Claude 插件:获取 ~/.claude/config.json 状态 +#[tauri::command] +pub async fn get_claude_plugin_status() -> Result { + crate::claude_plugin::claude_config_status() + .map(|(exists, path)| ConfigStatus { + exists, + path: path.to_string_lossy().to_string(), + }) + .map_err(|e| e.to_string()) +} + +/// Claude 插件:读取配置内容(若不存在返回 Ok(None)) +#[tauri::command] +pub async fn read_claude_plugin_config() -> Result, String> { + crate::claude_plugin::read_claude_config().map_err(|e| e.to_string()) +} + +/// Claude 插件:写入/清除固定配置 +#[tauri::command] +pub async fn apply_claude_plugin_config(official: bool) -> Result { + if official { + crate::claude_plugin::clear_claude_config().map_err(|e| e.to_string()) + } else { + crate::claude_plugin::write_claude_config().map_err(|e| e.to_string()) + } +} + +/// Claude 插件:检测是否已写入目标配置 +#[tauri::command] +pub async fn is_claude_plugin_applied() -> Result { + crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs new file mode 100644 index 0000000..23daba9 --- /dev/null +++ b/src-tauri/src/commands/provider.rs @@ -0,0 +1,211 @@ +use std::collections::HashMap; +use tauri::State; + +use crate::app_config::AppType; +use crate::error::AppError; +use crate::provider::Provider; +use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService}; +use crate::store::AppState; + +fn missing_param(param: &str) -> String { + format!("缺少 {} 参数 (Missing {} parameter)", param, param) +} + +use std::str::FromStr; + +/// 获取所有供应商 +#[tauri::command] +pub fn get_providers( + state: State<'_, AppState>, + app: String, +) -> Result, String> { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string()) +} + +/// 获取当前供应商ID +#[tauri::command] +pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string()) +} + +/// 添加供应商 +#[tauri::command] +pub fn add_provider( + state: State<'_, AppState>, + app: String, + provider: Provider, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) +} + +/// 更新供应商 +#[tauri::command] +pub fn update_provider( + state: State<'_, AppState>, + app: String, + provider: Provider, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) +} + +/// 删除供应商 +#[tauri::command] +pub fn delete_provider( + state: State<'_, AppState>, + app: String, + id: String, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::delete(state.inner(), app_type, &id) + .map(|_| true) + .map_err(|e| e.to_string()) +} + +/// 切换供应商 +fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + ProviderService::switch(state, app_type, id) +} + +#[cfg_attr(not(feature = "test-hooks"), doc(hidden))] +pub fn switch_provider_test_hook( + state: &AppState, + app_type: AppType, + id: &str, +) -> Result<(), AppError> { + switch_provider_internal(state, app_type, id) +} + +#[tauri::command] +pub fn switch_provider( + state: State<'_, AppState>, + app: String, + id: String, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + switch_provider_internal(&state, app_type, &id) + .map(|_| true) + .map_err(|e| e.to_string()) +} + +fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> { + ProviderService::import_default_config(state, app_type) +} + +#[cfg_attr(not(feature = "test-hooks"), doc(hidden))] +pub fn import_default_config_test_hook( + state: &AppState, + app_type: AppType, +) -> Result<(), AppError> { + import_default_config_internal(state, app_type) +} + +/// 导入当前配置为默认供应商 +#[tauri::command] +pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + import_default_config_internal(&state, app_type) + .map(|_| true) + .map_err(Into::into) +} + +/// 查询供应商用量 +#[tauri::command] +pub async fn query_provider_usage( + state: State<'_, AppState>, + provider_id: Option, + app: String, +) -> Result { + let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?; + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::query_usage(state.inner(), app_type, &provider_id) + .await + .map_err(|e| e.to_string()) +} + +/// 读取当前生效的配置内容 +#[tauri::command] +pub fn read_live_provider_settings(app: String) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::read_live_settings(app_type).map_err(|e| e.to_string()) +} + +/// 测试第三方/自定义供应商端点的网络延迟 +#[tauri::command] +pub async fn test_api_endpoints( + urls: Vec, + timeout_secs: Option, +) -> Result, String> { + SpeedtestService::test_endpoints(urls, timeout_secs) + .await + .map_err(|e| e.to_string()) +} + +/// 获取自定义端点列表 +#[tauri::command] +pub fn get_custom_endpoints( + state: State<'_, AppState>, + app: String, + provider_id: Option, +) -> Result, String> { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?; + ProviderService::get_custom_endpoints(state.inner(), app_type, &provider_id) + .map_err(|e| e.to_string()) +} + +/// 添加自定义端点 +#[tauri::command] +pub fn add_custom_endpoint( + state: State<'_, AppState>, + app: String, + provider_id: Option, + url: String, +) -> Result<(), String> { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?; + ProviderService::add_custom_endpoint(state.inner(), app_type, &provider_id, url) + .map_err(|e| e.to_string()) +} + +/// 删除自定义端点 +#[tauri::command] +pub fn remove_custom_endpoint( + state: State<'_, AppState>, + app: String, + provider_id: Option, + url: String, +) -> Result<(), String> { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?; + ProviderService::remove_custom_endpoint(state.inner(), app_type, &provider_id, url) + .map_err(|e| e.to_string()) +} + +/// 更新端点最后使用时间 +#[tauri::command] +pub fn update_endpoint_last_used( + state: State<'_, AppState>, + app: String, + provider_id: Option, + url: String, +) -> Result<(), String> { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?; + ProviderService::update_endpoint_last_used(state.inner(), app_type, &provider_id, url) + .map_err(|e| e.to_string()) +} + +/// 更新多个供应商的排序 +#[tauri::command] +pub fn update_providers_sort_order( + state: State<'_, AppState>, + app: String, + updates: Vec, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..ee76526 --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,39 @@ +#![allow(non_snake_case)] + +use tauri::AppHandle; + +/// 获取设置 +#[tauri::command] +pub async fn get_settings() -> Result { + Ok(crate::settings::get_settings()) +} + +/// 保存设置 +#[tauri::command] +pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { + crate::settings::update_settings(settings).map_err(|e| e.to_string())?; + Ok(true) +} + +/// 重启应用程序(当 app_config_dir 变更后使用) +#[tauri::command] +pub async fn restart_app(app: AppHandle) -> Result { + app.restart(); +} + +/// 获取 app_config_dir 覆盖配置 (从 Store) +#[tauri::command] +pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { + Ok(crate::app_store::refresh_app_config_dir_override(&app) + .map(|p| p.to_string_lossy().to_string())) +} + +/// 设置 app_config_dir 覆盖配置 (到 Store) +#[tauri::command] +pub async fn set_app_config_dir_override( + app: AppHandle, + path: Option, +) -> Result { + crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; + Ok(true) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 6523835..99192bb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,9 +1,10 @@ use serde::{Deserialize, Serialize}; -// unused import removed use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use crate::error::AppError; + /// 获取 Claude Code 配置目录路径 pub fn get_claude_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_claude_override_dir() { @@ -15,6 +16,36 @@ pub fn get_claude_config_dir() -> PathBuf { .join(".claude") } +/// 默认 Claude MCP 配置文件路径 (~/.claude.json) +pub fn get_default_claude_mcp_path() -> PathBuf { + dirs::home_dir() + .expect("无法获取用户主目录") + .join(".claude.json") +} + +fn derive_mcp_path_from_override(dir: &Path) -> Option { + let file_name = dir + .file_name() + .map(|name| name.to_string_lossy().to_string())? + .trim() + .to_string(); + if file_name.is_empty() { + return None; + } + let parent = dir.parent().unwrap_or_else(|| Path::new("")); + Some(parent.join(format!("{}.json", file_name))) +} + +/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级 +pub fn get_claude_mcp_path() -> PathBuf { + if let Some(custom_dir) = crate::settings::get_claude_override_dir() { + if let Some(path) = derive_mcp_path_from_override(&custom_dir) { + return path; + } + } + get_default_claude_mcp_path() +} + /// 获取 Claude Code 主配置文件路径 pub fn get_claude_settings_path() -> PathBuf { let dir = get_claude_config_dir(); @@ -76,14 +107,14 @@ fn ensure_unique_path(dest: PathBuf) -> PathBuf { } /// 将现有文件归档到 `~/.cc-switch/archive///` 下,返回归档路径 -pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result, String> { +pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result, AppError> { 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))?; + fs::create_dir_all(&dest_dir).map_err(|e| AppError::io(&dest_dir, e))?; let file_name = src .file_name() @@ -117,52 +148,50 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) } /// 读取 JSON 配置文件 -pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result { +pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result { if !path.exists() { - return Err(format!("文件不存在: {}", path.display())); + return Err(AppError::Config(format!("文件不存在: {}", path.display()))); } - let content = - fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?; + let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; - serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e)) + serde_json::from_str(&content).map_err(|e| AppError::json(path, e)) } /// 写入 JSON 配置文件 -pub fn write_json_file(path: &Path, data: &T) -> Result<(), String> { +pub fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { // 确保目录存在 if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let json = - serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?; + serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?; atomic_write(path, json.as_bytes()) } /// 原子写入文本文件(用于 TOML/纯文本) -pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> { +pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } atomic_write(path, data.as_bytes()) } /// 原子写入:写入临时文件后 rename 替换,避免半写状态 -pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { +pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?; + let parent = path + .parent() + .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; let mut tmp = parent.to_path_buf(); let file_name = path .file_name() - .ok_or_else(|| "无效的文件名".to_string())? + .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() .to_string(); let ts = std::time::SystemTime::now() @@ -172,12 +201,9 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { tmp.push(format!("{}.tmp.{}", file_name, ts)); { - let mut f = fs::File::create(&tmp) - .map_err(|e| format!("创建临时文件失败: {}: {}", tmp.display(), e))?; - f.write_all(data) - .map_err(|e| format!("写入临时文件失败: {}: {}", tmp.display(), e))?; - f.flush() - .map_err(|e| format!("刷新临时文件失败: {}: {}", tmp.display(), e))?; + let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; + f.write_all(data).map_err(|e| AppError::io(&tmp, e))?; + f.flush().map_err(|e| AppError::io(&tmp, e))?; } #[cfg(unix)] @@ -195,40 +221,70 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { if path.exists() { let _ = fs::remove_file(path); } - fs::rename(&tmp, path).map_err(|e| { - format!( - "原子替换失败: {} -> {}: {}", - tmp.display(), - path.display(), - e - ) + fs::rename(&tmp, path).map_err(|e| AppError::IoContext { + context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), + source: e, })?; } #[cfg(not(windows))] { - fs::rename(&tmp, path).map_err(|e| { - format!( - "原子替换失败: {} -> {}: {}", - tmp.display(), - path.display(), - e - ) + fs::rename(&tmp, path).map_err(|e| AppError::IoContext { + context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), + source: e, })?; } Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_mcp_path_from_override_preserves_folder_name() { + let override_dir = PathBuf::from("/tmp/profile/.claude"); + let derived = derive_mcp_path_from_override(&override_dir) + .expect("should derive path for nested dir"); + assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json")); + } + + #[test] + fn derive_mcp_path_from_override_handles_non_hidden_folder() { + let override_dir = PathBuf::from("/data/claude-config"); + let derived = derive_mcp_path_from_override(&override_dir) + .expect("should derive path for standard dir"); + assert_eq!(derived, PathBuf::from("/data/claude-config.json")); + } + + #[test] + fn derive_mcp_path_from_override_supports_relative_rootless_dir() { + let override_dir = PathBuf::from("claude"); + let derived = derive_mcp_path_from_override(&override_dir) + .expect("should derive path for single segment"); + assert_eq!(derived, PathBuf::from("claude.json")); + } + + #[test] + fn derive_mcp_path_from_root_like_dir_returns_none() { + let override_dir = PathBuf::from("/"); + assert!(derive_mcp_path_from_override(&override_dir).is_none()); + } +} + /// 复制文件 -pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> { - fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?; +pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> { + fs::copy(from, to).map_err(|e| AppError::IoContext { + context: format!("复制文件失败 ({} -> {})", from.display(), to.display()), + source: e, + })?; Ok(()) } /// 删除文件 -pub fn delete_file(path: &Path) -> Result<(), String> { +pub fn delete_file(path: &Path) -> Result<(), AppError> { if path.exists() { - fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?; + fs::remove_file(path).map_err(|e| AppError::io(path, e))?; } Ok(()) } @@ -249,4 +305,4 @@ pub fn get_claude_config_status() -> ConfigStatus { } } -//(移除未使用的备份/导入函数,避免 dead_code 告警) \ No newline at end of file +//(移除未使用的备份/导入函数,避免 dead_code 告警) diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..62d68e6 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,98 @@ +use std::path::Path; +use std::sync::PoisonError; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("配置错误: {0}")] + Config(String), + #[error("无效输入: {0}")] + InvalidInput(String), + #[error("IO 错误: {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("{context}: {source}")] + IoContext { + context: String, + #[source] + source: std::io::Error, + }, + #[error("JSON 解析错误: {path}: {source}")] + Json { + path: String, + #[source] + source: serde_json::Error, + }, + #[error("JSON 序列化失败: {source}")] + JsonSerialize { + #[source] + source: serde_json::Error, + }, + #[error("TOML 解析错误: {path}: {source}")] + Toml { + path: String, + #[source] + source: toml::de::Error, + }, + #[error("锁获取失败: {0}")] + Lock(String), + #[error("供应商不存在: {0}")] + ProviderNotFound(String), + #[error("MCP 校验失败: {0}")] + McpValidation(String), + #[error("{0}")] + Message(String), + #[error("{zh} ({en})")] + Localized { + key: &'static str, + zh: String, + en: String, + }, +} + +impl AppError { + pub fn io(path: impl AsRef, source: std::io::Error) -> Self { + Self::Io { + path: path.as_ref().display().to_string(), + source, + } + } + + pub fn json(path: impl AsRef, source: serde_json::Error) -> Self { + Self::Json { + path: path.as_ref().display().to_string(), + source, + } + } + + pub fn toml(path: impl AsRef, source: toml::de::Error) -> Self { + Self::Toml { + path: path.as_ref().display().to_string(), + source, + } + } + + pub fn localized(key: &'static str, zh: impl Into, en: impl Into) -> Self { + Self::Localized { + key, + zh: zh.into(), + en: en.into(), + } + } +} + +impl From> for AppError { + fn from(err: PoisonError) -> Self { + Self::Lock(err.to_string()) + } +} + +impl From for String { + fn from(err: AppError) -> Self { + err.to_string() + } +} diff --git a/src-tauri/src/import_export.rs b/src-tauri/src/import_export.rs deleted file mode 100644 index 6f500b3..0000000 --- a/src-tauri/src/import_export.rs +++ /dev/null @@ -1,170 +0,0 @@ -use chrono::Utc; -use serde_json::{json, Value}; -use std::fs; -use std::path::PathBuf; - -// 默认仅保留最近 10 份备份,避免目录无限膨胀 -const MAX_BACKUPS: usize = 10; - -/// 创建配置文件备份 -pub fn create_backup(config_path: &PathBuf) -> Result { - if !config_path.exists() { - return Ok(String::new()); - } - - let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); - let backup_id = format!("backup_{}", timestamp); - - let backup_dir = config_path - .parent() - .ok_or("Invalid config path")? - .join("backups"); - - // 创建备份目录 - fs::create_dir_all(&backup_dir) - .map_err(|e| format!("Failed to create backup directory: {}", e))?; - - let backup_path = backup_dir.join(format!("{}.json", backup_id)); - - // 复制配置文件到备份 - fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?; - - // 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份) - cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; - - Ok(backup_id) -} - -fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> { - if retain == 0 { - return Ok(()); - } - - let mut entries: Vec<_> = match fs::read_dir(backup_dir) { - Ok(iter) => iter - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry - .path() - .extension() - .map(|ext| ext == "json") - .unwrap_or(false) - }) - .collect(), - Err(_) => return Ok(()), - }; - - if entries.len() <= retain { - return Ok(()); - } - - let remove_count = entries.len().saturating_sub(retain); - - entries.sort_by(|a, b| { - let a_time = a.metadata().and_then(|m| m.modified()).ok(); - let b_time = b.metadata().and_then(|m| m.modified()).ok(); - a_time.cmp(&b_time) - }); - - for entry in entries.into_iter().take(remove_count) { - if let Err(err) = fs::remove_file(entry.path()) { - log::warn!( - "Failed to remove old backup {}: {}", - entry.path().display(), - err - ); - } - } - - Ok(()) -} - -/// 导出配置文件 -#[tauri::command] -pub async fn export_config_to_file(file_path: String) -> Result { - // 读取当前配置文件 - let config_path = crate::config::get_app_config_path(); - let config_content = fs::read_to_string(&config_path) - .map_err(|e| format!("Failed to read configuration: {}", e))?; - - // 写入到指定文件 - fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?; - - Ok(json!({ - "success": true, - "message": "Configuration exported successfully", - "filePath": file_path - })) -} - -/// 从文件导入配置 -#[tauri::command] -pub async fn import_config_from_file( - file_path: String, - state: tauri::State<'_, crate::store::AppState>, -) -> Result { - // 读取导入的文件 - let import_content = - fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?; - - // 验证并解析为配置对象 - let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content) - .map_err(|e| format!("Invalid configuration file: {}", e))?; - - // 备份当前配置 - let config_path = crate::config::get_app_config_path(); - let backup_id = create_backup(&config_path)?; - - // 写入新配置到磁盘 - fs::write(&config_path, &import_content) - .map_err(|e| format!("Failed to write configuration: {}", e))?; - - // 更新内存中的状态 - { - let mut config_state = state - .config - .lock() - .map_err(|e| format!("Failed to lock config: {}", e))?; - *config_state = new_config; - } - - Ok(json!({ - "success": true, - "message": "Configuration imported successfully", - "backupId": backup_id - })) -} - -/// 保存文件对话框 -#[tauri::command] -pub async fn save_file_dialog( - app: tauri::AppHandle, - default_name: String, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - - let dialog = app.dialog(); - let result = dialog - .file() - .add_filter("JSON", &["json"]) - .set_file_name(&default_name) - .blocking_save_file(); - - Ok(result.map(|p| p.to_string())) -} - -/// 打开文件对话框 -#[tauri::command] -pub async fn open_file_dialog( - app: tauri::AppHandle, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - - let dialog = app.dialog(); - let result = dialog - .file() - .add_filter("JSON", &["json"]) - .blocking_pick_file(); - - Ok(result.map(|p| p.to_string())) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1150b38..e9ff51c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,16 +5,28 @@ mod claude_plugin; mod codex_config; mod commands; mod config; -mod import_export; +mod error; mod mcp; mod migration; mod provider; +mod services; mod settings; -mod speedtest; -mod usage_script; mod store; +mod usage_script; + +pub use app_config::{AppType, MultiAppConfig}; +pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; +pub use commands::*; +pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; +pub use error::AppError; +pub use mcp::{ + import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex, +}; +pub use provider::Provider; +pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService}; +pub use settings::{update_settings, AppSettings}; +pub use store::AppState; -use store::AppState; use tauri::{ menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}, tray::{TrayIconBuilder, TrayIconEvent}, @@ -23,21 +35,46 @@ use tauri::{ use tauri::{ActivationPolicy, RunEvent}; use tauri::{Emitter, Manager}; +#[derive(Clone, Copy)] +struct TrayTexts { + show_main: &'static str, + no_provider_hint: &'static str, + quit: &'static str, +} + +impl TrayTexts { + fn from_language(language: &str) -> Self { + match language { + "en" => Self { + show_main: "Open main window", + no_provider_hint: " (No providers yet, please add them from the main window)", + quit: "Quit", + }, + _ => Self { + show_main: "打开主界面", + no_provider_hint: " (无供应商,请在主界面添加)", + quit: "退出", + }, + } + } +} + /// 创建动态托盘菜单 fn create_tray_menu( app: &tauri::AppHandle, app_state: &AppState, -) -> Result, String> { - let config = app_state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; +) -> Result, AppError> { + let app_settings = crate::settings::get_settings(); + let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh")); + + let config = app_state.config.read().map_err(AppError::from)?; let mut menu_builder = MenuBuilder::new(app); // 顶部:打开主界面 - let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>) - .map_err(|e| format!("创建打开主界面菜单失败: {}", e))?; + let show_main_item = + MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>) + .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {}", e)))?; menu_builder = menu_builder.item(&show_main_item).separator(); // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) @@ -45,7 +82,7 @@ fn create_tray_menu( // 添加Claude标题(禁用状态,仅作为分组标识) let claude_header = MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>) - .map_err(|e| format!("创建Claude标题失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建Claude标题失败: {}", e)))?; menu_builder = menu_builder.item(&claude_header); if !claude_manager.providers.is_empty() { @@ -80,7 +117,7 @@ fn create_tray_menu( is_current, None::<&str>, ) - .map_err(|e| format!("创建菜单项失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?; menu_builder = menu_builder.item(&item); } } else { @@ -88,11 +125,11 @@ fn create_tray_menu( let empty_hint = MenuItem::with_id( app, "claude_empty", - " (无供应商,请在主界面添加)", + tray_texts.no_provider_hint, false, None::<&str>, ) - .map_err(|e| format!("创建Claude空提示失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建Claude空提示失败: {}", e)))?; menu_builder = menu_builder.item(&empty_hint); } } @@ -101,7 +138,7 @@ fn create_tray_menu( // 添加Codex标题(禁用状态,仅作为分组标识) let codex_header = MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>) - .map_err(|e| format!("创建Codex标题失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建Codex标题失败: {}", e)))?; menu_builder = menu_builder.item(&codex_header); if !codex_manager.providers.is_empty() { @@ -136,7 +173,7 @@ fn create_tray_menu( is_current, None::<&str>, ) - .map_err(|e| format!("创建菜单项失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?; menu_builder = menu_builder.item(&item); } } else { @@ -144,24 +181,24 @@ fn create_tray_menu( let empty_hint = MenuItem::with_id( app, "codex_empty", - " (无供应商,请在主界面添加)", + tray_texts.no_provider_hint, false, None::<&str>, ) - .map_err(|e| format!("创建Codex空提示失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建Codex空提示失败: {}", e)))?; menu_builder = menu_builder.item(&empty_hint); } } // 分隔符和退出菜单 - let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>) - .map_err(|e| format!("创建退出菜单失败: {}", e))?; + let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>) + .map_err(|e| AppError::Message(format!("创建退出菜单失败: {}", e)))?; menu_builder = menu_builder.separator().item(&quit_item); menu_builder .build() - .map_err(|e| format!("构建菜单失败: {}", e)) + .map_err(|e| AppError::Message(format!("构建菜单失败: {}", e))) } #[cfg(target_os = "macos")] @@ -212,14 +249,12 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { // 执行切换 let app_handle = app.clone(); let provider_id = provider_id.to_string(); - tauri::async_runtime::spawn(async move { + tauri::async_runtime::spawn_blocking(move || { if let Err(e) = switch_provider_internal( &app_handle, crate::app_config::AppType::Claude, provider_id, - ) - .await - { + ) { log::error!("切换Claude供应商失败: {}", e); } }); @@ -231,14 +266,12 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { // 执行切换 let app_handle = app.clone(); let provider_id = provider_id.to_string(); - tauri::async_runtime::spawn(async move { + tauri::async_runtime::spawn_blocking(move || { if let Err(e) = switch_provider_internal( &app_handle, crate::app_config::AppType::Codex, provider_id, - ) - .await - { + ) { log::error!("切换Codex供应商失败: {}", e); } }); @@ -252,24 +285,18 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { // /// 内部切换供应商函数 -async fn switch_provider_internal( +fn switch_provider_internal( app: &tauri::AppHandle, app_type: crate::app_config::AppType, provider_id: String, -) -> Result<(), String> { +) -> Result<(), AppError> { if let Some(app_state) = app.try_state::() { // 在使用前先保存需要的值 let app_type_str = app_type.as_str().to_string(); let provider_id_clone = provider_id.clone(); - crate::commands::switch_provider( - app_state.clone(), - Some(app_type), - None, - None, - provider_id, - ) - .await?; + crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id) + .map_err(AppError::Message)?; // 切换成功后重新创建托盘菜单 if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { @@ -298,14 +325,20 @@ async fn update_tray_menu( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { - if let Ok(new_menu) = create_tray_menu(&app, state.inner()) { - if let Some(tray) = app.tray_by_id("main") { - tray.set_menu(Some(new_menu)) - .map_err(|e| format!("更新托盘菜单失败: {}", e))?; - return Ok(true); + match create_tray_menu(&app, state.inner()) { + Ok(new_menu) => { + if let Some(tray) = app.tray_by_id("main") { + tray.set_menu(Some(new_menu)) + .map_err(|e| format!("更新托盘菜单失败: {}", e))?; + return Ok(true); + } + Ok(false) + } + Err(err) => { + log::error!("创建托盘菜单失败: {}", err); + Ok(false) } } - Ok(false) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -350,8 +383,6 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { - // 设置全局 AppHandle 以供 Store 使用 - app_store::set_app_handle(app.handle().clone()); // 注册 Updater 插件(桌面端) #[cfg(desktop)] { @@ -402,17 +433,20 @@ pub fn run() { )?; } + // 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径 + app_store::refresh_app_config_dir_override(app.handle()); + // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage) let app_state = AppState::new(); // 迁移旧的 app_config_dir 配置到 Store - if let Err(e) = app_store::migrate_app_config_dir_from_settings(&app.handle()) { + if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { log::warn!("迁移 app_config_dir 失败: {}", e); } // 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档 { - let mut config_guard = app_state.config.lock().unwrap(); + let mut config_guard = app_state.config.write().unwrap(); let migrated = migration::migrate_copies_into_config(&mut config_guard)?; if migrated { log::info!("已将副本文件导入到 config.json,并完成归档"); @@ -505,10 +539,11 @@ pub fn run() { // provider sort order management commands::update_providers_sort_order, // theirs: config import/export and dialogs - import_export::export_config_to_file, - import_export::import_config_from_file, - import_export::save_file_dialog, - import_export::open_file_dialog, + commands::export_config_to_file, + commands::import_config_from_file, + commands::save_file_dialog, + commands::open_file_dialog, + commands::sync_current_providers_live, update_tray_menu, ]); @@ -537,4 +572,4 @@ pub fn run() { let _ = (app_handle, event); } }); -} \ No newline at end of file +} diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 05bb056..a7139af 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -2,11 +2,14 @@ use serde_json::{json, Value}; use std::collections::HashMap; use crate::app_config::{AppType, McpConfig, MultiAppConfig}; +use crate::error::AppError; /// 基础校验:允许 stdio/http;或省略 type(视为 stdio)。对应必填字段存在 -fn validate_server_spec(spec: &Value) -> Result<(), String> { +fn validate_server_spec(spec: &Value) -> Result<(), AppError> { if !spec.is_object() { - return Err("MCP 服务器连接定义必须为 JSON 对象".into()); + return Err(AppError::McpValidation( + "MCP 服务器连接定义必须为 JSON 对象".into(), + )); } let t_opt = spec.get("type").and_then(|x| x.as_str()); // 支持两种:stdio/http;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) @@ -14,38 +17,47 @@ fn validate_server_spec(spec: &Value) -> Result<(), String> { let is_http = t_opt.map(|t| t == "http").unwrap_or(false); if !(is_stdio || is_http) { - return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into()); + return Err(AppError::McpValidation( + "MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(), + )); } if is_stdio { let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); if cmd.trim().is_empty() { - return Err("stdio 类型的 MCP 服务器缺少 command 字段".into()); + return Err(AppError::McpValidation( + "stdio 类型的 MCP 服务器缺少 command 字段".into(), + )); } } if is_http { let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); if url.trim().is_empty() { - return Err("http 类型的 MCP 服务器缺少 url 字段".into()); + return Err(AppError::McpValidation( + "http 类型的 MCP 服务器缺少 url 字段".into(), + )); } } Ok(()) } -fn validate_mcp_entry(entry: &Value) -> Result<(), String> { +fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> { let obj = entry .as_object() - .ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; let server = obj .get("server") - .ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?; validate_server_spec(server)?; for key in ["name", "description", "homepage", "docs"] { if let Some(val) = obj.get(key) { if !val.is_string() { - return Err(format!("MCP 服务器 {} 必须为字符串", key)); + return Err(AppError::McpValidation(format!( + "MCP 服务器 {} 必须为字符串", + key + ))); } } } @@ -53,15 +65,19 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), String> { if let Some(tags) = obj.get("tags") { let arr = tags .as_array() - .ok_or_else(|| "MCP 服务器 tags 必须为字符串数组".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?; if !arr.iter().all(|item| item.is_string()) { - return Err("MCP 服务器 tags 必须为字符串数组".into()); + return Err(AppError::McpValidation( + "MCP 服务器 tags 必须为字符串数组".into(), + )); } } if let Some(enabled) = obj.get("enabled") { if !enabled.is_boolean() { - return Err("MCP 服务器 enabled 必须为布尔值".into()); + return Err(AppError::McpValidation( + "MCP 服务器 enabled 必须为布尔值".into(), + )); } } @@ -159,16 +175,18 @@ pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usiz normalize_server_keys(servers) } -fn extract_server_spec(entry: &Value) -> Result { +fn extract_server_spec(entry: &Value) -> Result { let obj = entry .as_object() - .ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; let server = obj .get("server") - .ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?; if !server.is_object() { - return Err("MCP 服务器 server 字段必须为 JSON 对象".into()); + return Err(AppError::McpValidation( + "MCP 服务器 server 字段必须为 JSON 对象".into(), + )); } Ok(server.clone()) @@ -227,9 +245,9 @@ pub fn upsert_in_config_for( app: &AppType, id: &str, spec: Value, -) -> Result { +) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); } normalize_servers_for(config, app); validate_mcp_entry(&spec)?; @@ -237,16 +255,16 @@ pub fn upsert_in_config_for( let mut entry_obj = spec .as_object() .cloned() - .ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; if let Some(existing_id) = entry_obj.get("id") { let Some(existing_id_str) = existing_id.as_str() else { - return Err("MCP 服务器 id 必须为字符串".into()); + return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into())); }; if existing_id_str != id { - return Err(format!( + return Err(AppError::McpValidation(format!( "MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致", existing_id_str, id - )); + ))); } } else { entry_obj.insert(String::from("id"), json!(id)); @@ -265,24 +283,24 @@ pub fn delete_in_config_for( config: &mut MultiAppConfig, app: &AppType, id: &str, -) -> Result { +) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); } normalize_servers_for(config, app); let existed = config.mcp_for_mut(app).servers.remove(id).is_some(); Ok(existed) } -/// 设置启用状态并同步到 ~/.claude.json -pub fn set_enabled_and_sync_for( +/// 设置启用状态(不执行落盘或文件同步) +pub fn set_enabled_flag_for( config: &mut MultiAppConfig, app: &AppType, id: &str, enabled: bool, -) -> Result { +) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); } normalize_servers_for(config, app); if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) { @@ -290,7 +308,7 @@ pub fn set_enabled_and_sync_for( let mut obj = spec .as_object() .cloned() - .ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?; + .ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?; obj.insert("enabled".into(), json!(enabled)); *spec = Value::Object(obj); } else { @@ -298,34 +316,23 @@ pub fn set_enabled_and_sync_for( return Ok(false); } - // 同步启用项 - match app { - AppType::Claude => { - // 将启用项投影到 ~/.claude.json - sync_enabled_to_claude(config)?; - } - AppType::Codex => { - // 将启用项投影到 ~/.codex/config.toml - sync_enabled_to_codex(config)?; - } - } Ok(true) } /// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json -pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> { +pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> { let enabled = collect_enabled_servers(&config.mcp.claude); crate::claude_mcp::set_mcp_servers_map(&enabled) } /// 从 ~/.claude.json 导入 mcpServers 到 config.json(设为 enabled=true)。 /// 已存在的项仅强制 enabled=true,不覆盖其他字段。 -pub fn import_from_claude(config: &mut MultiAppConfig) -> Result { +pub fn import_from_claude(config: &mut MultiAppConfig) -> Result { let text_opt = crate::claude_mcp::read_mcp_json()?; let Some(text) = text_opt else { return Ok(0) }; let mut changed = normalize_servers_for(config, &AppType::Claude); - let v: Value = - serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?; + let v: Value = serde_json::from_str(&text) + .map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {}", e)))?; let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { return Ok(changed); }; @@ -394,15 +401,15 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result /// 从 ~/.codex/config.toml 导入 MCP 到 config.json(Codex 作用域),并将导入项设为 enabled=true。 /// 支持两种 schema:[mcp.servers.] 与 [mcp_servers.]。 /// 已存在的项仅强制 enabled=true,不覆盖其他字段。 -pub fn import_from_codex(config: &mut MultiAppConfig) -> Result { +pub fn import_from_codex(config: &mut MultiAppConfig) -> Result { let text = crate::codex_config::read_and_validate_codex_config_text()?; if text.trim().is_empty() { return Ok(0); } let mut changed_total = normalize_servers_for(config, &AppType::Codex); - let root: toml::Table = - toml::from_str(&text).map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?; + let root: toml::Table = toml::from_str(&text) + .map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {}", e)))?; // helper:处理一组 servers 表 let mut import_servers_tbl = |servers_tbl: &toml::value::Table| { @@ -565,168 +572,149 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result { /// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖 /// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键 /// - 仅写入启用项;无启用项时清理对应子表 -pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> { - use toml::{value::Value as TomlValue, Table as TomlTable}; +pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { + use toml_edit::{DocumentMut, Item, Table}; // 1) 收集启用项(Codex 维度) let enabled = collect_enabled_servers(&config.mcp.codex); - // 2) 读取现有 config.toml 并解析为 Table(允许空文件) + // 2) 读取现有 config.toml 文本;保持无效 TOML 的错误返回(不覆盖文件) let base_text = crate::codex_config::read_and_validate_codex_config_text()?; - let mut root: TomlTable = if base_text.trim().is_empty() { - TomlTable::new() + + // 3) 使用 toml_edit 解析(允许空文件) + let mut doc: DocumentMut = if base_text.trim().is_empty() { + DocumentMut::default() } else { - toml::from_str::(&base_text) - .map_err(|e| format!("解析 config.toml 失败: {}", e))? + base_text + .parse::() + .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {}", e)))? }; - // 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers) - let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none(); - if enabled.is_empty() { - // 无启用项:移除两种节点 - // 清除 mcp.servers,但保留其他 mcp 字段 - let mut should_drop_mcp = false; - if let Some(mcp_val) = root.get_mut("mcp") { - match mcp_val { - TomlValue::Table(tbl) => { - tbl.remove("servers"); - should_drop_mcp = tbl.is_empty(); - } - _ => should_drop_mcp = true, - } - } - if should_drop_mcp { - root.remove("mcp"); - } + enum Target { + McpServers, // 顶层 mcp_servers + McpDotServers, // mcp.servers + } - // 清除顶层 mcp_servers - root.remove("mcp_servers"); + // 4) 选择目标风格:优先沿用既有子表;其次在 mcp 表下新建;最后退回顶层 mcp_servers + let has_mcp_dot_servers = doc + .get("mcp") + .and_then(|m| m.get("servers")) + .and_then(|s| s.as_table_like()) + .is_some(); + let has_mcp_servers = doc + .get("mcp_servers") + .and_then(|s| s.as_table_like()) + .is_some(); + let mcp_is_table = doc.get("mcp").and_then(|m| m.as_table_like()).is_some(); + + let target = if has_mcp_dot_servers { + Target::McpDotServers + } else if has_mcp_servers { + Target::McpServers + } else if mcp_is_table { + Target::McpDotServers } else { - let mut servers_tbl = TomlTable::new(); + Target::McpServers + }; - for (id, spec) in enabled.iter() { - let mut s = TomlTable::new(); - - // 类型(缺省视为 stdio) + // 构造目标 servers 表(稳定的键顺序) + let build_servers_table = || -> Table { + let mut servers = Table::new(); + let mut ids: Vec<_> = enabled.keys().cloned().collect(); + ids.sort(); + for id in ids { + let spec = enabled.get(&id).expect("spec must exist"); + let mut t = Table::new(); let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); - s.insert("type".into(), TomlValue::String(typ.to_string())); - + t["type"] = toml_edit::value(typ); match typ { "stdio" => { - let cmd = spec - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - s.insert("command".into(), TomlValue::String(cmd)); - + let cmd = spec.get("command").and_then(|v| v.as_str()).unwrap_or(""); + t["command"] = toml_edit::value(cmd); if let Some(args) = spec.get("args").and_then(|v| v.as_array()) { - let arr = args - .iter() - .filter_map(|x| x.as_str()) - .map(|x| TomlValue::String(x.to_string())) - .collect::>(); - if !arr.is_empty() { - s.insert("args".into(), TomlValue::Array(arr)); + let mut arr_v = toml_edit::Array::default(); + for a in args.iter().filter_map(|x| x.as_str()) { + arr_v.push(a); + } + if !arr_v.is_empty() { + t["args"] = toml_edit::Item::Value(toml_edit::Value::Array(arr_v)); } } - if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) { if !cwd.trim().is_empty() { - s.insert("cwd".into(), TomlValue::String(cwd.to_string())); + t["cwd"] = toml_edit::value(cwd); } } - if let Some(env) = spec.get("env").and_then(|v| v.as_object()) { - let mut env_tbl = TomlTable::new(); + let mut env_tbl = Table::new(); for (k, v) in env.iter() { - if let Some(sv) = v.as_str() { - env_tbl.insert(k.clone(), TomlValue::String(sv.to_string())); + if let Some(s) = v.as_str() { + env_tbl[&k[..]] = toml_edit::value(s); } } if !env_tbl.is_empty() { - s.insert("env".into(), TomlValue::Table(env_tbl)); + t["env"] = Item::Table(env_tbl); } } } "http" => { - let url = spec - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - s.insert("url".into(), TomlValue::String(url)); - + let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or(""); + t["url"] = toml_edit::value(url); if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) { - let mut h_tbl = TomlTable::new(); + let mut h_tbl = Table::new(); for (k, v) in headers.iter() { - if let Some(sv) = v.as_str() { - h_tbl.insert(k.clone(), TomlValue::String(sv.to_string())); + if let Some(s) = v.as_str() { + h_tbl[&k[..]] = toml_edit::value(s); } } if !h_tbl.is_empty() { - s.insert("headers".into(), TomlValue::Table(h_tbl)); + t["headers"] = Item::Table(h_tbl); } } } _ => {} } - - servers_tbl.insert(id.clone(), TomlValue::Table(s)); + servers[&id[..]] = Item::Table(t); } + servers + }; - let servers_value = TomlValue::Table(servers_tbl.clone()); - - if prefer_mcp_servers { - root.insert("mcp_servers".into(), servers_value); - - // 若存在 mcp,则仅移除 servers 字段,保留其他键 - let mut should_drop_mcp = false; - if let Some(mcp_val) = root.get_mut("mcp") { - match mcp_val { - TomlValue::Table(tbl) => { + // 5) 应用更新:仅就地更新目标子表;避免改动其它键/注释/空白 + if enabled.is_empty() { + // 无启用项:移除两种 servers 表(如果存在),但保留 mcp 其它字段 + if let Some(mcp_item) = doc.get_mut("mcp") { + if let Some(tbl) = mcp_item.as_table_like_mut() { + tbl.remove("servers"); + } + } + doc.as_table_mut().remove("mcp_servers"); + } else { + let servers_tbl = build_servers_table(); + match target { + Target::McpDotServers => { + // 确保 mcp 为表 + if doc.get("mcp").and_then(|m| m.as_table_like()).is_none() { + doc["mcp"] = Item::Table(Table::new()); + } + doc["mcp"]["servers"] = Item::Table(servers_tbl); + // 去重:若存在顶层 mcp_servers,则移除以避免重复定义 + doc.as_table_mut().remove("mcp_servers"); + } + Target::McpServers => { + doc["mcp_servers"] = Item::Table(servers_tbl); + // 去重:若存在 mcp.servers,则移除该子表,保留 mcp 其它键 + if let Some(mcp_item) = doc.get_mut("mcp") { + if let Some(tbl) = mcp_item.as_table_like_mut() { tbl.remove("servers"); - should_drop_mcp = tbl.is_empty(); - } - _ => should_drop_mcp = true, - } - } - if should_drop_mcp { - root.remove("mcp"); - } - } else { - let mut inserted = false; - - if let Some(mcp_val) = root.get_mut("mcp") { - match mcp_val { - TomlValue::Table(tbl) => { - tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone())); - inserted = true; - } - _ => { - let mut mcp_tbl = TomlTable::new(); - mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone())); - *mcp_val = TomlValue::Table(mcp_tbl); - inserted = true; } } } - - if !inserted { - let mut mcp_tbl = TomlTable::new(); - mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl)); - root.insert("mcp".into(), TomlValue::Table(mcp_tbl)); - } - - root.remove("mcp_servers"); } } - // 4) 序列化并写回 config.toml(仅改 TOML,不触碰 auth.json) - let new_text = toml::to_string(&TomlValue::Table(root)) - .map_err(|e| format!("序列化 config.toml 失败: {}", e))?; + // 6) 写回(仅改 TOML,不触碰 auth.json);toml_edit 会尽量保留未改区域的注释/空白/顺序 + let new_text = doc.to_string(); let path = crate::codex_config::get_codex_config_path(); crate::config::write_text_file(&path, &new_text)?; - Ok(()) } diff --git a/src-tauri/src/migration.rs b/src-tauri/src/migration.rs index ff40780..f253e2e 100644 --- a/src-tauri/src/migration.rs +++ b/src-tauri/src/migration.rs @@ -2,6 +2,7 @@ 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 crate::error::AppError; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; @@ -144,11 +145,11 @@ fn scan_codex_copies() -> Vec<(String, Option, Option, Value)> items } -pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result { +pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result { // 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败 let marker = get_marker_path(); if let Some(parent) = marker.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?; + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } if marker.exists() { return Ok(false); @@ -158,7 +159,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result Result Result { + if !config_path.exists() { + return Ok(String::new()); + } + + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_id = format!("backup_{}", timestamp); + + let backup_dir = config_path + .parent() + .ok_or_else(|| AppError::Config("Invalid config path".into()))? + .join("backups"); + + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let backup_path = backup_dir.join(format!("{}.json", backup_id)); + let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?; + fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?; + + Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; + + Ok(backup_id) + } + + fn cleanup_old_backups(backup_dir: &Path, retain: usize) -> Result<(), AppError> { + if retain == 0 { + return Ok(()); + } + + let entries = match fs::read_dir(backup_dir) { + Ok(iter) => iter + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "json") + .unwrap_or(false) + }) + .collect::>(), + Err(_) => return Ok(()), + }; + + if entries.len() <= retain { + return Ok(()); + } + + let remove_count = entries.len().saturating_sub(retain); + let mut sorted = entries; + + sorted.sort_by(|a, b| { + let a_time = a.metadata().and_then(|m| m.modified()).ok(); + let b_time = b.metadata().and_then(|m| m.modified()).ok(); + a_time.cmp(&b_time) + }); + + for entry in sorted.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old backup {}: {}", + entry.path().display(), + err + ); + } + } + + Ok(()) + } + + /// 将当前 config.json 拷贝到目标路径。 + pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> { + let config_path = crate::config::get_app_config_path(); + let config_content = + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; + fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e)) + } + + /// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。 + pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> { + let import_content = + fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?; + + let new_config: MultiAppConfig = + serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?; + + let config_path = crate::config::get_app_config_path(); + let backup_id = Self::create_backup(&config_path)?; + + fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?; + + Ok((new_config, backup_id)) + } + + /// 将外部配置文件内容加载并写入应用状态。 + pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result { + let (new_config, backup_id) = Self::load_config_for_import(file_path)?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + *guard = new_config; + } + + Ok(backup_id) + } + + /// 同步当前供应商到对应的 live 配置。 + pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> { + Self::sync_current_provider_for_app(config, &AppType::Claude)?; + Self::sync_current_provider_for_app(config, &AppType::Codex)?; + Ok(()) + } + + fn sync_current_provider_for_app( + config: &mut MultiAppConfig, + app_type: &AppType, + ) -> Result<(), AppError> { + let (current_id, provider) = { + let manager = match config.get_manager(app_type) { + Some(manager) => manager, + None => return Ok(()), + }; + + if manager.current.is_empty() { + return Ok(()); + } + + let current_id = manager.current.clone(); + let provider = match manager.providers.get(¤t_id) { + Some(provider) => provider.clone(), + None => { + log::warn!( + "当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步", + app_type, + current_id + ); + return Ok(()); + } + }; + (current_id, provider) + }; + + match app_type { + AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, + AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, + } + + Ok(()) + } + + fn sync_codex_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + let settings = provider.settings_config.as_object().ok_or_else(|| { + AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id)) + })?; + let auth = settings.get("auth").ok_or_else(|| { + AppError::Config(format!( + "供应商 {} 的 Codex 配置缺少 auth 字段", + provider_id + )) + })?; + if !auth.is_object() { + return Err(AppError::Config(format!( + "供应商 {} 的 Codex auth 配置必须是 JSON 对象", + provider_id + ))); + } + let cfg_text = settings.get("config").and_then(Value::as_str); + + crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; + crate::mcp::sync_enabled_to_codex(config)?; + + let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + if let Some(target) = manager.providers.get_mut(provider_id) { + if let Some(obj) = target.settings_config.as_object_mut() { + obj.insert( + "config".to_string(), + serde_json::Value::String(cfg_text_after), + ); + } + } + } + + Ok(()) + } + + fn sync_claude_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + use crate::config::{read_json_file, write_json_file}; + + let settings_path = crate::config::get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&settings_path, &provider.settings_config)?; + + let live_after = read_json_file::(&settings_path)?; + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + Ok(()) + } +} diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs new file mode 100644 index 0000000..5bd8f81 --- /dev/null +++ b/src-tauri/src/services/mcp.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; + +use serde_json::Value; + +use crate::app_config::{AppType, MultiAppConfig}; +use crate::error::AppError; +use crate::mcp; +use crate::store::AppState; + +/// MCP 相关业务逻辑 +pub struct McpService; + +impl McpService { + /// 获取指定应用的 MCP 服务器快照,并在必要时回写归一化后的配置。 + pub fn get_servers(state: &AppState, app: AppType) -> Result, AppError> { + let mut cfg = state.config.write()?; + let (snapshot, normalized) = mcp::get_servers_snapshot_for(&mut cfg, &app); + drop(cfg); + if normalized > 0 { + state.save()?; + } + Ok(snapshot) + } + + /// 在 config.json 中新增或更新指定 MCP 服务器,并按需同步到对应客户端。 + pub fn upsert_server( + state: &AppState, + app: AppType, + id: &str, + spec: Value, + sync_other_side: bool, + ) -> Result { + let (changed, snapshot, sync_claude, sync_codex): ( + bool, + Option, + bool, + bool, + ) = { + let mut cfg = state.config.write()?; + let changed = mcp::upsert_in_config_for(&mut cfg, &app, id, spec)?; + + // 修复:默认启用(unwrap_or(true)) + // 新增的 MCP 如果缺少 enabled 字段,应该默认为启用状态 + let enabled = cfg + .mcp_for(&app) + .servers + .get(id) + .and_then(|entry| entry.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let mut sync_claude = matches!(app, AppType::Claude) && enabled; + let mut sync_codex = matches!(app, AppType::Codex) && enabled; + + // 修复:sync_other_side=true 时,先将 MCP 复制到另一侧,然后强制同步 + // 这才是"同步到另一侧"的正确语义:将 MCP 跨应用复制 + if sync_other_side { + // 获取当前 MCP 条目的克隆(刚刚插入的,不可能失败) + let current_entry = cfg + .mcp_for(&app) + .servers + .get(id) + .cloned() + .expect("刚刚插入的 MCP 条目必定存在"); + + // 将该 MCP 复制到另一侧的 servers + let other_app = match app { + AppType::Claude => AppType::Codex, + AppType::Codex => AppType::Claude, + }; + + cfg.mcp_for_mut(&other_app) + .servers + .insert(id.to_string(), current_entry); + + // 强制同步另一侧 + match app { + AppType::Claude => sync_codex = true, + AppType::Codex => sync_claude = true, + } + } + + let snapshot = if sync_claude || sync_codex { + Some(cfg.clone()) + } else { + None + }; + + (changed, snapshot, sync_claude, sync_codex) + }; + + // 保持原有行为:始终尝试持久化,避免遗漏 normalize 带来的隐式变更 + state.save()?; + + if let Some(snapshot) = snapshot { + if sync_claude { + mcp::sync_enabled_to_claude(&snapshot)?; + } + if sync_codex { + mcp::sync_enabled_to_codex(&snapshot)?; + } + } + + Ok(changed) + } + + /// 删除 config.json 中的 MCP 服务器条目,并同步客户端配置。 + pub fn delete_server(state: &AppState, app: AppType, id: &str) -> Result { + let (existed, snapshot): (bool, Option) = { + let mut cfg = state.config.write()?; + let existed = mcp::delete_in_config_for(&mut cfg, &app, id)?; + let snapshot = if existed { Some(cfg.clone()) } else { None }; + (existed, snapshot) + }; + if existed { + state.save()?; + if let Some(snapshot) = snapshot { + match app { + AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?, + AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?, + } + } + } + Ok(existed) + } + + /// 设置 MCP 启用状态,并同步到客户端配置。 + pub fn set_enabled( + state: &AppState, + app: AppType, + id: &str, + enabled: bool, + ) -> Result { + let (existed, snapshot): (bool, Option) = { + let mut cfg = state.config.write()?; + let existed = mcp::set_enabled_flag_for(&mut cfg, &app, id, enabled)?; + let snapshot = if existed { Some(cfg.clone()) } else { None }; + (existed, snapshot) + }; + + if existed { + state.save()?; + if let Some(snapshot) = snapshot { + match app { + AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?, + AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?, + } + } + } + Ok(existed) + } + + /// 手动同步已启用的 MCP 服务器到客户端配置。 + pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> { + let (snapshot, normalized): (MultiAppConfig, usize) = { + let mut cfg = state.config.write()?; + let normalized = mcp::normalize_servers_for(&mut cfg, &app); + (cfg.clone(), normalized) + }; + if normalized > 0 { + state.save()?; + } + match app { + AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?, + AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?, + } + Ok(()) + } + + /// 从 Claude 客户端配置导入 MCP 定义。 + pub fn import_from_claude(state: &AppState) -> Result { + let mut cfg = state.config.write()?; + let changed = mcp::import_from_claude(&mut cfg)?; + drop(cfg); + if changed > 0 { + state.save()?; + } + Ok(changed) + } + + /// 从 Codex 客户端配置导入 MCP 定义。 + pub fn import_from_codex(state: &AppState) -> Result { + let mut cfg = state.config.write()?; + let changed = mcp::import_from_codex(&mut cfg)?; + drop(cfg); + if changed > 0 { + state.save()?; + } + Ok(changed) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs new file mode 100644 index 0000000..9efe214 --- /dev/null +++ b/src-tauri/src/services/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod mcp; +pub mod provider; +pub mod speedtest; + +pub use config::ConfigService; +pub use mcp::McpService; +pub use provider::{ProviderService, ProviderSortUpdate}; +pub use speedtest::{EndpointLatency, SpeedtestService}; diff --git a/src-tauri/src/services/provider.rs b/src-tauri/src/services/provider.rs new file mode 100644 index 0000000..b9e88bd --- /dev/null +++ b/src-tauri/src/services/provider.rs @@ -0,0 +1,1111 @@ +use regex::Regex; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::app_config::{AppType, MultiAppConfig}; +use crate::codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; +use crate::config::{ + delete_file, get_claude_settings_path, get_provider_config_path, read_json_file, + write_json_file, write_text_file, +}; +use crate::error::AppError; +use crate::mcp; +use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult}; +use crate::settings::CustomEndpoint; +use crate::store::AppState; +use crate::usage_script; + +/// 供应商相关业务逻辑 +pub struct ProviderService; + +#[derive(Clone)] +enum LiveSnapshot { + Claude { + settings: Option, + }, + Codex { + auth: Option, + config: Option, + }, +} + +#[derive(Clone)] +struct PostCommitAction { + app_type: AppType, + provider: Provider, + backup: LiveSnapshot, + sync_mcp: bool, + refresh_snapshot: bool, +} + +impl LiveSnapshot { + fn restore(&self) -> Result<(), AppError> { + match self { + LiveSnapshot::Claude { settings } => { + let path = get_claude_settings_path(); + if let Some(value) = settings { + write_json_file(&path, value)?; + } else if path.exists() { + delete_file(&path)?; + } + } + LiveSnapshot::Codex { auth, config } => { + let auth_path = get_codex_auth_path(); + let config_path = get_codex_config_path(); + if let Some(value) = auth { + write_json_file(&auth_path, value)?; + } else if auth_path.exists() { + delete_file(&auth_path)?; + } + + if let Some(text) = config { + write_text_file(&config_path, text)?; + } else if config_path.exists() { + delete_file(&config_path)?; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_provider_settings_rejects_missing_auth() { + let provider = Provider::with_id( + "codex".into(), + "Codex".into(), + json!({ "config": "base_url = \"https://example.com\"" }), + None, + ); + let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider) + .expect_err("missing auth should be rejected"); + assert!( + err.to_string().contains("auth"), + "expected auth error, got {err:?}" + ); + } + + #[test] + fn extract_credentials_returns_expected_values() { + let provider = Provider::with_id( + "claude".into(), + "Claude".into(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token", + "ANTHROPIC_BASE_URL": "https://claude.example" + } + }), + None, + ); + let (api_key, base_url) = + ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap(); + assert_eq!(api_key, "token"); + assert_eq!(base_url, "https://claude.example"); + } +} + +impl ProviderService { + fn run_transaction(state: &AppState, f: F) -> Result + where + F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, + { + let mut guard = state.config.write().map_err(AppError::from)?; + let original = guard.clone(); + let (result, action) = match f(&mut guard) { + Ok(value) => value, + Err(err) => { + *guard = original; + return Err(err); + } + }; + drop(guard); + + if let Err(save_err) = state.save() { + if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) { + return Err(AppError::localized( + "config.save.rollback_failed", + format!("保存配置失败: {};回滚失败: {}", save_err, rollback_err), + format!( + "Failed to save config: {}; rollback failed: {}", + save_err, rollback_err + ), + )); + } + return Err(save_err); + } + + if let Some(action) = action { + if let Err(err) = Self::apply_post_commit(state, &action) { + if let Err(rollback_err) = + Self::rollback_after_failure(state, original.clone(), action.backup.clone()) + { + return Err(AppError::localized( + "post_commit.rollback_failed", + format!("后置操作失败: {};回滚失败: {}", err, rollback_err), + format!( + "Post-commit step failed: {}; rollback failed: {}", + err, rollback_err + ), + )); + } + return Err(err); + } + } + + Ok(result) + } + + fn restore_config_only(state: &AppState, snapshot: MultiAppConfig) -> Result<(), AppError> { + { + let mut guard = state.config.write().map_err(AppError::from)?; + *guard = snapshot; + } + state.save() + } + + fn rollback_after_failure( + state: &AppState, + snapshot: MultiAppConfig, + backup: LiveSnapshot, + ) -> Result<(), AppError> { + Self::restore_config_only(state, snapshot)?; + backup.restore() + } + + fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> { + Self::write_live_snapshot(&action.app_type, &action.provider)?; + if action.sync_mcp { + let config_clone = { + let guard = state.config.read().map_err(AppError::from)?; + guard.clone() + }; + mcp::sync_enabled_to_codex(&config_clone)?; + } + if action.refresh_snapshot { + Self::refresh_provider_snapshot(state, &action.app_type, &action.provider.id)?; + } + Ok(()) + } + + fn refresh_provider_snapshot( + state: &AppState, + app_type: &AppType, + provider_id: &str, + ) -> Result<(), AppError> { + match app_type { + AppType::Claude => { + let settings_path = get_claude_settings_path(); + if !settings_path.exists() { + return Err(AppError::localized( + "claude.live.missing", + "Claude 设置文件不存在,无法刷新快照", + "Claude settings file missing; cannot refresh snapshot", + )); + } + let live_after = read_json_file::(&settings_path)?; + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + } + state.save()?; + } + AppType::Codex => { + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Err(AppError::localized( + "codex.live.missing", + "Codex auth.json 不存在,无法刷新快照", + "Codex auth.json missing; cannot refresh snapshot", + )); + } + let auth: Value = read_json_file(&auth_path)?; + let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + let obj = target.settings_config.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "供应商 {} 的 Codex 配置必须是 JSON 对象", + provider_id + )) + })?; + obj.insert("auth".to_string(), auth.clone()); + obj.insert("config".to_string(), Value::String(cfg_text.clone())); + } + } + } + state.save()?; + } + } + Ok(()) + } + + fn capture_live_snapshot(app_type: &AppType) -> Result { + match app_type { + AppType::Claude => { + let path = get_claude_settings_path(); + let settings = if path.exists() { + Some(read_json_file::(&path)?) + } else { + None + }; + Ok(LiveSnapshot::Claude { settings }) + } + AppType::Codex => { + let auth_path = get_codex_auth_path(); + let config_path = get_codex_config_path(); + let auth = if auth_path.exists() { + Some(read_json_file::(&auth_path)?) + } else { + None + }; + let config = if config_path.exists() { + Some( + std::fs::read_to_string(&config_path) + .map_err(|e| AppError::io(&config_path, e))?, + ) + } else { + None + }; + Ok(LiveSnapshot::Codex { auth, config }) + } + } + } + + /// 列出指定应用下的所有供应商 + pub fn list( + state: &AppState, + app_type: AppType, + ) -> Result, AppError> { + let config = state.config.read().map_err(AppError::from)?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + Ok(manager.get_all_providers().clone()) + } + + /// 获取当前供应商 ID + pub fn current(state: &AppState, app_type: AppType) -> Result { + let config = state.config.read().map_err(AppError::from)?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + Ok(manager.current.clone()) + } + + /// 新增供应商 + pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result { + Self::validate_provider_settings(&app_type, &provider)?; + + let app_type_clone = app_type.clone(); + let provider_clone = provider.clone(); + + Self::run_transaction(state, move |config| { + config.ensure_app(&app_type_clone); + let manager = config + .get_manager_mut(&app_type_clone) + .ok_or_else(|| Self::app_not_found(&app_type_clone))?; + + let is_current = manager.current == provider_clone.id; + manager + .providers + .insert(provider_clone.id.clone(), provider_clone.clone()); + + let action = if is_current { + let backup = Self::capture_live_snapshot(&app_type_clone)?; + Some(PostCommitAction { + app_type: app_type_clone.clone(), + provider: provider_clone.clone(), + backup, + sync_mcp: false, + refresh_snapshot: false, + }) + } else { + None + }; + + Ok((true, action)) + }) + } + + /// 更新供应商 + pub fn update( + state: &AppState, + app_type: AppType, + provider: Provider, + ) -> Result { + Self::validate_provider_settings(&app_type, &provider)?; + let provider_id = provider.id.clone(); + let app_type_clone = app_type.clone(); + let provider_clone = provider.clone(); + + Self::run_transaction(state, move |config| { + let manager = config + .get_manager_mut(&app_type_clone) + .ok_or_else(|| Self::app_not_found(&app_type_clone))?; + + if !manager.providers.contains_key(&provider_id) { + return Err(AppError::ProviderNotFound(provider_id.clone())); + } + + let is_current = manager.current == provider_id; + let merged = if let Some(existing) = manager.providers.get(&provider_id) { + let mut updated = provider_clone.clone(); + match (existing.meta.as_ref(), updated.meta.take()) { + (Some(old_meta), None) => { + updated.meta = Some(old_meta.clone()); + } + (Some(old_meta), Some(mut new_meta)) => { + let mut merged_map = old_meta.custom_endpoints.clone(); + for (url, ep) in new_meta.custom_endpoints.drain() { + merged_map.entry(url).or_insert(ep); + } + updated.meta = Some(ProviderMeta { + custom_endpoints: merged_map, + usage_script: new_meta.usage_script.clone(), + }); + } + (None, maybe_new) => { + updated.meta = maybe_new; + } + } + updated + } else { + provider_clone.clone() + }; + + manager.providers.insert(provider_id.clone(), merged); + + let action = if is_current { + let backup = Self::capture_live_snapshot(&app_type_clone)?; + Some(PostCommitAction { + app_type: app_type_clone.clone(), + provider: provider_clone.clone(), + backup, + sync_mcp: false, + refresh_snapshot: false, + }) + } else { + None + }; + + Ok((true, action)) + }) + } + + /// 导入当前 live 配置为默认供应商 + pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> { + { + let config = state.config.read().map_err(AppError::from)?; + if let Some(manager) = config.get_manager(&app_type) { + if !manager.get_all_providers().is_empty() { + return Ok(()); + } + } + } + + let settings_config = match app_type { + AppType::Codex => { + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Err(AppError::localized( + "codex.live.missing", + "Codex 配置文件不存在", + "Codex configuration file is missing", + )); + } + let auth: Value = read_json_file(&auth_path)?; + let config_str = crate::codex_config::read_and_validate_codex_config_text()?; + json!({ "auth": auth, "config": config_str }) + } + AppType::Claude => { + let settings_path = get_claude_settings_path(); + if !settings_path.exists() { + return Err(AppError::localized( + "claude.live.missing", + "Claude Code 配置文件不存在", + "Claude settings file is missing", + )); + } + read_json_file(&settings_path)? + } + }; + + let provider = Provider::with_id( + "default".to_string(), + "default".to_string(), + settings_config, + None, + ); + + { + let mut config = state.config.write().map_err(AppError::from)?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + manager + .providers + .insert(provider.id.clone(), provider.clone()); + manager.current = provider.id.clone(); + } + + state.save()?; + Ok(()) + } + + /// 读取当前 live 配置 + pub fn read_live_settings(app_type: AppType) -> Result { + match app_type { + AppType::Codex => { + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Err(AppError::localized( + "codex.auth.missing", + "Codex 配置文件不存在:缺少 auth.json", + "Codex configuration missing: auth.json not found", + )); + } + let auth: Value = read_json_file(&auth_path)?; + let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?; + Ok(json!({ "auth": auth, "config": cfg_text })) + } + AppType::Claude => { + let path = get_claude_settings_path(); + if !path.exists() { + return Err(AppError::localized( + "claude.live.missing", + "Claude Code 配置文件不存在", + "Claude settings file is missing", + )); + } + read_json_file(&path) + } + } + } + + /// 获取自定义端点列表 + pub fn get_custom_endpoints( + state: &AppState, + app_type: AppType, + provider_id: &str, + ) -> Result, AppError> { + let cfg = state.config.read().map_err(AppError::from)?; + let manager = cfg + .get_manager(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + + let Some(provider) = manager.providers.get(provider_id) else { + return Ok(vec![]); + }; + let Some(meta) = provider.meta.as_ref() else { + return Ok(vec![]); + }; + if meta.custom_endpoints.is_empty() { + return Ok(vec![]); + } + + let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect(); + result.sort_by(|a, b| b.added_at.cmp(&a.added_at)); + Ok(result) + } + + /// 新增自定义端点 + pub fn add_custom_endpoint( + state: &AppState, + app_type: AppType, + provider_id: &str, + url: String, + ) -> Result<(), AppError> { + let normalized = url.trim().trim_end_matches('/').to_string(); + if normalized.is_empty() { + return Err(AppError::localized( + "provider.endpoint.url_required", + "URL 不能为空", + "URL cannot be empty", + )); + } + + { + let mut cfg = state.config.write().map_err(AppError::from)?; + let manager = cfg + .get_manager_mut(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + let provider = manager + .providers + .get_mut(provider_id) + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?; + let meta = provider.meta.get_or_insert_with(ProviderMeta::default); + + let endpoint = CustomEndpoint { + url: normalized.clone(), + added_at: Self::now_millis(), + last_used: None, + }; + meta.custom_endpoints.insert(normalized, endpoint); + } + + state.save()?; + Ok(()) + } + + /// 删除自定义端点 + pub fn remove_custom_endpoint( + state: &AppState, + app_type: AppType, + provider_id: &str, + url: String, + ) -> Result<(), AppError> { + let normalized = url.trim().trim_end_matches('/').to_string(); + + { + let mut cfg = state.config.write().map_err(AppError::from)?; + if let Some(manager) = cfg.get_manager_mut(&app_type) { + if let Some(provider) = manager.providers.get_mut(provider_id) { + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.remove(&normalized); + } + } + } + } + + state.save()?; + Ok(()) + } + + /// 更新端点最后使用时间 + pub fn update_endpoint_last_used( + state: &AppState, + app_type: AppType, + provider_id: &str, + url: String, + ) -> Result<(), AppError> { + let normalized = url.trim().trim_end_matches('/').to_string(); + + { + let mut cfg = state.config.write().map_err(AppError::from)?; + if let Some(manager) = cfg.get_manager_mut(&app_type) { + if let Some(provider) = manager.providers.get_mut(provider_id) { + if let Some(meta) = provider.meta.as_mut() { + if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) { + endpoint.last_used = Some(Self::now_millis()); + } + } + } + } + } + + state.save()?; + Ok(()) + } + + /// 更新供应商排序 + pub fn update_sort_order( + state: &AppState, + app_type: AppType, + updates: Vec, + ) -> Result { + { + let mut cfg = state.config.write().map_err(AppError::from)?; + let manager = cfg + .get_manager_mut(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + + for update in updates { + if let Some(provider) = manager.providers.get_mut(&update.id) { + provider.sort_index = Some(update.sort_index); + } + } + } + + state.save()?; + Ok(true) + } + + /// 查询供应商用量 + pub async fn query_usage( + state: &AppState, + app_type: AppType, + provider_id: &str, + ) -> Result { + let (provider, script_code, timeout) = { + let config = state.config.read().map_err(AppError::from)?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + let provider = manager + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?; + let (script_code, timeout) = { + let usage_script = provider + .meta + .as_ref() + .and_then(|m| m.usage_script.as_ref()) + .ok_or_else(|| { + AppError::localized( + "provider.usage.script.missing", + "未配置用量查询脚本", + "Usage script is not configured", + ) + })?; + if !usage_script.enabled { + return Err(AppError::localized( + "provider.usage.disabled", + "用量查询未启用", + "Usage query is disabled", + )); + } + ( + usage_script.code.clone(), + usage_script.timeout.unwrap_or(10), + ) + }; + + (provider, script_code, timeout) + }; + + let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?; + + match usage_script::execute_usage_script(&script_code, &api_key, &base_url, timeout).await { + Ok(data) => { + let usage_list: Vec = if data.is_array() { + serde_json::from_value(data) + .map_err(|e| AppError::Message(format!("数据格式错误: {}", e)))? + } else { + let single: UsageData = serde_json::from_value(data) + .map_err(|e| AppError::Message(format!("数据格式错误: {}", e)))?; + vec![single] + }; + + Ok(UsageResult { + success: true, + data: Some(usage_list), + error: None, + }) + } + Err(err) => Ok(UsageResult { + success: false, + data: None, + error: Some(err.to_string()), + }), + } + } + + /// 切换指定应用的供应商 + pub fn switch(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { + let app_type_clone = app_type.clone(); + let provider_id_owned = provider_id.to_string(); + + Self::run_transaction(state, move |config| { + let backup = Self::capture_live_snapshot(&app_type_clone)?; + let provider = match app_type_clone { + AppType::Codex => Self::prepare_switch_codex(config, &provider_id_owned)?, + AppType::Claude => Self::prepare_switch_claude(config, &provider_id_owned)?, + }; + + let action = PostCommitAction { + app_type: app_type_clone.clone(), + provider, + backup, + sync_mcp: matches!(app_type_clone, AppType::Codex), + refresh_snapshot: true, + }; + + Ok(((), Some(action))) + }) + } + + fn prepare_switch_codex( + config: &mut MultiAppConfig, + provider_id: &str, + ) -> Result { + let provider = config + .get_manager(&AppType::Codex) + .ok_or_else(|| Self::app_not_found(&AppType::Codex))? + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?; + + Self::backfill_codex_current(config, provider_id)?; + + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + manager.current = provider_id.to_string(); + } + + Ok(provider) + } + + fn backfill_codex_current( + config: &mut MultiAppConfig, + next_provider: &str, + ) -> Result<(), AppError> { + let current_id = config + .get_manager(&AppType::Codex) + .map(|m| m.current.clone()) + .unwrap_or_default(); + + if current_id.is_empty() || current_id == next_provider { + return Ok(()); + } + + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Ok(()); + } + + let auth: Value = read_json_file(&auth_path)?; + let config_path = get_codex_config_path(); + let config_text = if config_path.exists() { + std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? + } else { + String::new() + }; + + let live = json!({ + "auth": auth, + "config": config_text, + }); + + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + if let Some(current) = manager.providers.get_mut(¤t_id) { + current.settings_config = live; + } + } + + Ok(()) + } + + fn write_codex_live(provider: &Provider) -> Result<(), AppError> { + let settings = provider + .settings_config + .as_object() + .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?; + let auth = settings + .get("auth") + .ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?; + if !auth.is_object() { + return Err(AppError::Config(format!( + "供应商 {} 的 auth 必须是对象", + provider.id + ))); + } + let cfg_text = settings.get("config").and_then(Value::as_str); + + write_codex_live_atomic(auth, cfg_text)?; + Ok(()) + } + + fn prepare_switch_claude( + config: &mut MultiAppConfig, + provider_id: &str, + ) -> Result { + let provider = config + .get_manager(&AppType::Claude) + .ok_or_else(|| Self::app_not_found(&AppType::Claude))? + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?; + + Self::backfill_claude_current(config, provider_id)?; + + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + manager.current = provider_id.to_string(); + } + + Ok(provider) + } + + fn backfill_claude_current( + config: &mut MultiAppConfig, + next_provider: &str, + ) -> Result<(), AppError> { + let settings_path = get_claude_settings_path(); + if !settings_path.exists() { + return Ok(()); + } + + let current_id = config + .get_manager(&AppType::Claude) + .map(|m| m.current.clone()) + .unwrap_or_default(); + if current_id.is_empty() || current_id == next_provider { + return Ok(()); + } + + let live = read_json_file::(&settings_path)?; + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + if let Some(current) = manager.providers.get_mut(¤t_id) { + current.settings_config = live; + } + } + + Ok(()) + } + + fn write_claude_live(provider: &Provider) -> Result<(), AppError> { + let settings_path = get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&settings_path, &provider.settings_config)?; + Ok(()) + } + + fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { + match app_type { + AppType::Codex => Self::write_codex_live(provider), + AppType::Claude => Self::write_claude_live(provider), + } + } + + fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { + match app_type { + AppType::Claude => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.claude.settings.not_object", + "Claude 配置必须是 JSON 对象", + "Claude configuration must be a JSON object", + )); + } + } + AppType::Codex => { + let settings = provider.settings_config.as_object().ok_or_else(|| { + AppError::localized( + "provider.codex.settings.not_object", + "Codex 配置必须是 JSON 对象", + "Codex configuration must be a JSON object", + ) + })?; + + let auth = settings.get("auth").ok_or_else(|| { + AppError::localized( + "provider.codex.auth.missing", + format!("供应商 {} 缺少 auth 配置", provider.id), + format!("Provider {} is missing auth configuration", provider.id), + ) + })?; + if !auth.is_object() { + return Err(AppError::localized( + "provider.codex.auth.not_object", + format!("供应商 {} 的 auth 配置必须是 JSON 对象", provider.id), + format!( + "Provider {} auth configuration must be a JSON object", + provider.id + ), + )); + } + + if let Some(config_value) = settings.get("config") { + if !(config_value.is_string() || config_value.is_null()) { + return Err(AppError::localized( + "provider.codex.config.invalid_type", + "Codex config 字段必须是字符串", + "Codex config field must be a string", + )); + } + if let Some(cfg_text) = config_value.as_str() { + crate::codex_config::validate_config_toml(cfg_text)?; + } + } + } + } + + Ok(()) + } + + fn extract_credentials( + provider: &Provider, + app_type: &AppType, + ) -> Result<(String, String), AppError> { + match app_type { + AppType::Claude => { + let env = provider + .settings_config + .get("env") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::localized( + "provider.claude.env.missing", + "配置格式错误: 缺少 env", + "Invalid configuration: missing env section", + ) + })?; + + let api_key = env + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.claude.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })? + .to_string(); + + let base_url = env + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.claude.base_url.missing", + "缺少 ANTHROPIC_BASE_URL 配置", + "Missing ANTHROPIC_BASE_URL configuration", + ) + })? + .to_string(); + + Ok((api_key, base_url)) + } + AppType::Codex => { + let auth = provider + .settings_config + .get("auth") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::localized( + "provider.codex.auth.missing", + "配置格式错误: 缺少 auth", + "Invalid configuration: missing auth section", + ) + })?; + + let api_key = auth + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.codex.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })? + .to_string(); + + let config_toml = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let base_url = if config_toml.contains("base_url") { + let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#) + .map_err(|e| AppError::Message(format!("正则初始化失败: {}", e)))?; + re.captures(config_toml) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .ok_or_else(|| { + AppError::localized( + "provider.codex.base_url.invalid", + "config.toml 中 base_url 格式错误", + "base_url in config.toml has invalid format", + ) + })? + } else { + return Err(AppError::localized( + "provider.codex.base_url.missing", + "config.toml 中缺少 base_url 配置", + "base_url is missing from config.toml", + )); + }; + + Ok((api_key, base_url)) + } + } + } + + fn app_not_found(app_type: &AppType) -> AppError { + AppError::Message(format!("应用类型不存在: {:?}", app_type)) + } + + fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 + } + + pub fn delete(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { + let provider_snapshot = { + let config = state.config.read().map_err(AppError::from)?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + + if manager.current == provider_id { + return Err(AppError::localized( + "provider.delete.current", + "不能删除当前正在使用的供应商", + "Cannot delete the provider currently in use", + )); + } + + manager + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))? + }; + + match app_type { + AppType::Codex => { + crate::codex_config::delete_codex_provider_config( + provider_id, + &provider_snapshot.name, + )?; + } + AppType::Claude => { + // 兼容旧版本:历史上会在 Claude 目录内为每个供应商生成 settings-*.json 副本 + // 这里继续清理这些遗留文件,避免堆积过期配置。 + let by_name = get_provider_config_path(provider_id, Some(&provider_snapshot.name)); + let by_id = get_provider_config_path(provider_id, None); + delete_file(&by_name)?; + delete_file(&by_id)?; + } + } + + { + let mut config = state.config.write().map_err(AppError::from)?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| Self::app_not_found(&app_type))?; + + if manager.current == provider_id { + return Err(AppError::localized( + "provider.delete.current", + "不能删除当前正在使用的供应商", + "Cannot delete the provider currently in use", + )); + } + + manager.providers.remove(provider_id); + } + + state.save() + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProviderSortUpdate { + pub id: String, + #[serde(rename = "sortIndex")] + pub sort_index: usize, +} diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs new file mode 100644 index 0000000..83ec872 --- /dev/null +++ b/src-tauri/src/services/speedtest.rs @@ -0,0 +1,168 @@ +use futures::future::join_all; +use reqwest::{Client, Url}; +use serde::Serialize; +use std::time::{Duration, Instant}; + +use crate::error::AppError; + +const DEFAULT_TIMEOUT_SECS: u64 = 8; +const MAX_TIMEOUT_SECS: u64 = 30; +const MIN_TIMEOUT_SECS: u64 = 2; + +/// 端点测速结果 +#[derive(Debug, Clone, Serialize)] +pub struct EndpointLatency { + pub url: String, + pub latency: Option, + pub status: Option, + pub error: Option, +} + +/// 网络测速相关业务 +pub struct SpeedtestService; + +impl SpeedtestService { + /// 测试一组端点的响应延迟。 + pub async fn test_endpoints( + urls: Vec, + timeout_secs: Option, + ) -> Result, AppError> { + if urls.is_empty() { + return Ok(vec![]); + } + + let timeout = Self::sanitize_timeout(timeout_secs); + let client = Self::build_client(timeout)?; + + let tasks = urls.into_iter().map(|raw_url| { + let client = client.clone(); + async move { + let trimmed = raw_url.trim().to_string(); + if trimmed.is_empty() { + return EndpointLatency { + url: raw_url, + latency: None, + status: None, + error: Some("URL 不能为空".to_string()), + }; + } + + let parsed_url = match Url::parse(&trimmed) { + Ok(url) => url, + Err(err) => { + return EndpointLatency { + url: trimmed, + latency: None, + status: None, + error: Some(format!("URL 无效: {err}")), + }; + } + }; + + // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 + let _ = client.get(parsed_url.clone()).send().await; + + // 第二次请求开始计时,并将其作为结果返回。 + let start = Instant::now(); + match client.get(parsed_url).send().await { + Ok(resp) => EndpointLatency { + url: trimmed, + latency: Some(start.elapsed().as_millis()), + status: Some(resp.status().as_u16()), + error: None, + }, + Err(err) => { + let status = err.status().map(|s| s.as_u16()); + let error_message = if err.is_timeout() { + "请求超时".to_string() + } else if err.is_connect() { + "连接失败".to_string() + } else { + err.to_string() + }; + + EndpointLatency { + url: trimmed, + latency: None, + status, + error: Some(error_message), + } + } + } + } + }); + + Ok(join_all(tasks).await) + } + + fn build_client(timeout_secs: u64) -> Result { + Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .redirect(reqwest::redirect::Policy::limited(5)) + .user_agent("cc-switch-speedtest/1.0") + .build() + .map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}"))) + } + + fn sanitize_timeout(timeout_secs: Option) -> u64 { + let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); + secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_timeout_clamps_values() { + assert_eq!( + SpeedtestService::sanitize_timeout(Some(1)), + MIN_TIMEOUT_SECS + ); + assert_eq!( + SpeedtestService::sanitize_timeout(Some(999)), + MAX_TIMEOUT_SECS + ); + assert_eq!( + SpeedtestService::sanitize_timeout(Some(10)), + 10.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS) + ); + assert_eq!( + SpeedtestService::sanitize_timeout(None), + DEFAULT_TIMEOUT_SECS + ); + } + + #[test] + fn test_endpoints_handles_empty_list() { + let result = + tauri::async_runtime::block_on(SpeedtestService::test_endpoints(Vec::new(), Some(5))) + .expect("empty list should succeed"); + assert!(result.is_empty()); + } + + #[test] + fn test_endpoints_reports_invalid_url() { + let result = tauri::async_runtime::block_on(SpeedtestService::test_endpoints( + vec!["not a url".into(), "".into()], + None, + )) + .expect("invalid inputs should still succeed"); + + assert_eq!(result.len(), 2); + assert!( + result[0] + .error + .as_deref() + .unwrap_or_default() + .starts_with("URL 无效"), + "invalid url should yield parse error" + ); + assert_eq!( + result[1].error.as_deref(), + Some("URL 不能为空"), + "empty url should report validation error" + ); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 079a98c..b4d783e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -4,6 +4,8 @@ use std::fs; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; +use crate::error::AppError; + /// 自定义端点配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -117,18 +119,18 @@ impl AppSettings { } } - pub fn save(&self) -> Result<(), String> { + pub fn save(&self) -> Result<(), AppError> { let mut normalized = self.clone(); normalized.normalize_paths(); let path = Self::settings_path(); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let json = serde_json::to_string_pretty(&normalized) - .map_err(|e| format!("序列化设置失败: {}", e))?; - fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?; + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; Ok(()) } } @@ -160,7 +162,7 @@ pub fn get_settings() -> AppSettings { settings_store().read().expect("读取设置锁失败").clone() } -pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> { +pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); new_settings.save()?; @@ -183,4 +185,4 @@ pub fn get_codex_override_dir() -> Option { .codex_config_dir .as_ref() .map(|p| resolve_override_path(p)) -} \ No newline at end of file +} diff --git a/src-tauri/src/speedtest.rs b/src-tauri/src/speedtest.rs deleted file mode 100644 index 4e87747..0000000 --- a/src-tauri/src/speedtest.rs +++ /dev/null @@ -1,106 +0,0 @@ -use futures::future::join_all; -use reqwest::{Client, Url}; -use serde::Serialize; -use std::time::{Duration, Instant}; - -const DEFAULT_TIMEOUT_SECS: u64 = 8; -const MAX_TIMEOUT_SECS: u64 = 30; -const MIN_TIMEOUT_SECS: u64 = 2; - -#[derive(Debug, Clone, Serialize)] -pub struct EndpointLatency { - pub url: String, - pub latency: Option, - pub status: Option, - pub error: Option, -} - -fn build_client(timeout_secs: u64) -> Result { - Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .redirect(reqwest::redirect::Policy::limited(5)) - .user_agent("cc-switch-speedtest/1.0") - .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {e}")) -} - -fn sanitize_timeout(timeout_secs: Option) -> u64 { - let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); - secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS) -} - -pub async fn test_endpoints( - urls: Vec, - timeout_secs: Option, -) -> Result, String> { - if urls.is_empty() { - return Ok(vec![]); - } - - let timeout = sanitize_timeout(timeout_secs); - let client = build_client(timeout)?; - - let tasks = urls.into_iter().map(|raw_url| { - let client = client.clone(); - async move { - let trimmed = raw_url.trim().to_string(); - if trimmed.is_empty() { - return EndpointLatency { - url: raw_url, - latency: None, - status: None, - error: Some("URL 不能为空".to_string()), - }; - } - - let parsed_url = match Url::parse(&trimmed) { - Ok(url) => url, - Err(err) => { - return EndpointLatency { - url: trimmed, - latency: None, - status: None, - error: Some(format!("URL 无效: {err}")), - }; - } - }; - - // 先进行一次“热身”请求,忽略其结果,仅用于复用连接/绕过首包惩罚 - let _ = client.get(parsed_url.clone()).send().await; - - // 第二次请求开始计时,并将其作为结果返回 - let start = Instant::now(); - match client.get(parsed_url).send().await { - Ok(resp) => { - let latency = start.elapsed().as_millis(); - EndpointLatency { - url: trimmed, - latency: Some(latency), - status: Some(resp.status().as_u16()), - error: None, - } - } - Err(err) => { - let status = err.status().map(|s| s.as_u16()); - let error_message = if err.is_timeout() { - "请求超时".to_string() - } else if err.is_connect() { - "连接失败".to_string() - } else { - err.to_string() - }; - - EndpointLatency { - url: trimmed, - latency: None, - status, - error: Some(error_message), - } - } - } - } - }); - - let results = join_all(tasks).await; - Ok(results) -} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 194c33b..d5eef7c 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,9 +1,16 @@ use crate::app_config::MultiAppConfig; -use std::sync::Mutex; +use crate::error::AppError; +use std::sync::RwLock; /// 全局应用状态 pub struct AppState { - pub config: Mutex, + pub config: RwLock, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } } impl AppState { @@ -15,16 +22,13 @@ impl AppState { }); Self { - config: Mutex::new(config), + config: RwLock::new(config), } } /// 保存配置到文件 - pub fn save(&self) -> Result<(), String> { - let config = self - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; + pub fn save(&self) -> Result<(), AppError> { + let config = self.config.read().map_err(AppError::from)?; config.save() } diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 50fb49f..29e041d 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -1,16 +1,18 @@ use reqwest::Client; -use rquickjs::{Context, Runtime, Function}; +use rquickjs::{Context, Function, Runtime}; use serde_json::Value; use std::collections::HashMap; use std::time::Duration; +use crate::error::AppError; + /// 执行用量查询脚本 pub async fn execute_usage_script( script_code: &str, api_key: &str, base_url: &str, timeout_secs: u64, -) -> Result { +) -> Result { // 1. 替换变量 let replaced = script_code .replace("{{apiKey}}", api_key) @@ -18,75 +20,80 @@ pub async fn execute_usage_script( // 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) let request_config = { - let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?; - let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?; + let runtime = + Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?; + let context = Context::full(&runtime) + .map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?; context.with(|ctx| { // 执行用户代码,获取配置对象 let config: rquickjs::Object = ctx .eval(replaced.clone()) - .map_err(|e| format!("解析配置失败: {}", e))?; + .map_err(|e| AppError::Message(format!("解析配置失败: {}", e)))?; // 提取 request 配置 let request: rquickjs::Object = config .get("request") - .map_err(|e| format!("缺少 request 配置: {}", e))?; + .map_err(|e| AppError::Message(format!("缺少 request 配置: {}", e)))?; // 将 request 转换为 JSON 字符串 let request_json: String = ctx .json_stringify(request) - .map_err(|e| format!("序列化 request 失败: {}", e))? - .ok_or("序列化返回 None")? + .map_err(|e| AppError::Message(format!("序列化 request 失败: {}", e)))? + .ok_or_else(|| AppError::Message("序列化返回 None".into()))? .get() - .map_err(|e| format!("获取字符串失败: {}", e))?; + .map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?; - Ok::<_, String>(request_json) + Ok::<_, AppError>(request_json) })? }; // Runtime 和 Context 在这里被 drop // 3. 解析 request 配置 let request: RequestConfig = serde_json::from_str(&request_config) - .map_err(|e| format!("request 配置格式错误: {}", e))?; + .map_err(|e| AppError::Message(format!("request 配置格式错误: {}", e)))?; // 4. 发送 HTTP 请求 let response_data = send_http_request(&request, timeout_secs).await?; // 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放) let result: Value = { - let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?; - let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?; + let runtime = + Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?; + let context = Context::full(&runtime) + .map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?; context.with(|ctx| { // 重新 eval 获取配置对象 let config: rquickjs::Object = ctx .eval(replaced.clone()) - .map_err(|e| format!("重新解析配置失败: {}", e))?; + .map_err(|e| AppError::Message(format!("重新解析配置失败: {}", e)))?; // 提取 extractor 函数 let extractor: Function = config .get("extractor") - .map_err(|e| format!("缺少 extractor 函数: {}", e))?; + .map_err(|e| AppError::Message(format!("缺少 extractor 函数: {}", e)))?; // 将响应数据转换为 JS 值 let response_js: rquickjs::Value = ctx .json_parse(response_data.as_str()) - .map_err(|e| format!("解析响应 JSON 失败: {}", e))?; + .map_err(|e| AppError::Message(format!("解析响应 JSON 失败: {}", e)))?; // 调用 extractor(response) let result_js: rquickjs::Value = extractor .call((response_js,)) - .map_err(|e| format!("执行 extractor 失败: {}", e))?; + .map_err(|e| AppError::Message(format!("执行 extractor 失败: {}", e)))?; // 转换为 JSON 字符串 let result_json: String = ctx .json_stringify(result_js) - .map_err(|e| format!("序列化结果失败: {}", e))? - .ok_or("序列化返回 None")? + .map_err(|e| AppError::Message(format!("序列化结果失败: {}", e)))? + .ok_or_else(|| AppError::Message("序列化返回 None".into()))? .get() - .map_err(|e| format!("获取字符串失败: {}", e))?; + .map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?; // 解析为 serde_json::Value - serde_json::from_str(&result_json).map_err(|e| format!("JSON 解析失败: {}", e)) + serde_json::from_str(&result_json) + .map_err(|e| AppError::Message(format!("JSON 解析失败: {}", e))) })? }; // Runtime 和 Context 在这里被 drop @@ -108,16 +115,13 @@ struct RequestConfig { } /// 发送 HTTP 请求 -async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { +async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { let client = Client::builder() .timeout(Duration::from_secs(timeout_secs)) .build() - .map_err(|e| format!("创建客户端失败: {}", e))?; + .map_err(|e| AppError::Message(format!("创建客户端失败: {}", e)))?; - let method = config - .method - .parse() - .unwrap_or(reqwest::Method::GET); + let method = config.method.parse().unwrap_or(reqwest::Method::GET); let mut req = client.request(method.clone(), &config.url); @@ -135,13 +139,13 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< let resp = req .send() .await - .map_err(|e| format!("请求失败: {}", e))?; + .map_err(|e| AppError::Message(format!("请求失败: {}", e)))?; let status = resp.status(); let text = resp .text() .await - .map_err(|e| format!("读取响应失败: {}", e))?; + .map_err(|e| AppError::Message(format!("读取响应失败: {}", e)))?; if !status.is_success() { let preview = if text.len() > 200 { @@ -149,22 +153,22 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< } else { text.clone() }; - return Err(format!("HTTP {} : {}", status, preview)); + return Err(AppError::Message(format!("HTTP {} : {}", status, preview))); } Ok(text) } /// 验证脚本返回值(支持单对象或数组) -fn validate_result(result: &Value) -> Result<(), String> { +fn validate_result(result: &Value) -> Result<(), AppError> { // 如果是数组,验证每个元素 if let Some(arr) = result.as_array() { if arr.is_empty() { - return Err("脚本返回的数组不能为空".to_string()); + return Err(AppError::InvalidInput("脚本返回的数组不能为空".into())); } for (idx, item) in arr.iter().enumerate() { validate_single_usage(item) - .map_err(|e| format!("数组索引[{}]验证失败: {}", idx, e))?; + .map_err(|e| AppError::InvalidInput(format!("数组索引[{}]验证失败: {}", idx, e)))?; } return Ok(()); } @@ -174,33 +178,51 @@ fn validate_result(result: &Value) -> Result<(), String> { } /// 验证单个用量数据对象 -fn validate_single_usage(result: &Value) -> Result<(), String> { - let obj = result.as_object().ok_or("脚本必须返回对象或对象数组")?; +fn validate_single_usage(result: &Value) -> Result<(), AppError> { + let obj = result + .as_object() + .ok_or_else(|| AppError::InvalidInput("脚本必须返回对象或对象数组".into()))?; // 所有字段均为可选,只进行类型检查 - if obj.contains_key("isValid") && !result["isValid"].is_null() && !result["isValid"].is_boolean() { - return Err("isValid 必须是布尔值或 null".to_string()); + if obj.contains_key("isValid") + && !result["isValid"].is_null() + && !result["isValid"].is_boolean() + { + return Err(AppError::InvalidInput("isValid 必须是布尔值或 null".into())); } - if obj.contains_key("invalidMessage") && !result["invalidMessage"].is_null() && !result["invalidMessage"].is_string() { - return Err("invalidMessage 必须是字符串或 null".to_string()); + if obj.contains_key("invalidMessage") + && !result["invalidMessage"].is_null() + && !result["invalidMessage"].is_string() + { + return Err(AppError::InvalidInput( + "invalidMessage 必须是字符串或 null".into(), + )); } - if obj.contains_key("remaining") && !result["remaining"].is_null() && !result["remaining"].is_number() { - return Err("remaining 必须是数字或 null".to_string()); + if obj.contains_key("remaining") + && !result["remaining"].is_null() + && !result["remaining"].is_number() + { + return Err(AppError::InvalidInput("remaining 必须是数字或 null".into())); } if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() { - return Err("unit 必须是字符串或 null".to_string()); + return Err(AppError::InvalidInput("unit 必须是字符串或 null".into())); } if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() { - return Err("total 必须是数字或 null".to_string()); + return Err(AppError::InvalidInput("total 必须是数字或 null".into())); } if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() { - return Err("used 必须是数字或 null".to_string()); + return Err(AppError::InvalidInput("used 必须是数字或 null".into())); } - if obj.contains_key("planName") && !result["planName"].is_null() && !result["planName"].is_string() { - return Err("planName 必须是字符串或 null".to_string()); + if obj.contains_key("planName") + && !result["planName"].is_null() + && !result["planName"].is_string() + { + return Err(AppError::InvalidInput( + "planName 必须是字符串或 null".into(), + )); } if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() { - return Err("extra 必须是字符串或 null".to_string()); + return Err(AppError::InvalidInput("extra 必须是字符串或 null".into())); } Ok(()) diff --git a/src-tauri/tests/app_type_parse.rs b/src-tauri/tests/app_type_parse.rs new file mode 100644 index 0000000..4abd316 --- /dev/null +++ b/src-tauri/tests/app_type_parse.rs @@ -0,0 +1,22 @@ +use std::str::FromStr; + +use cc_switch_lib::AppType; + +#[test] +fn parse_known_apps_case_insensitive_and_trim() { + assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude))); + assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex))); + assert!(matches!( + AppType::from_str(" ClAuDe \n"), + Ok(AppType::Claude) + )); + assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex))); +} + +#[test] +fn parse_unknown_app_returns_localized_error_message() { + let err = AppType::from_str("unknown").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("可选值") || msg.contains("Allowed")); + assert!(msg.contains("unknown")); +} diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs new file mode 100644 index 0000000..fcd4a82 --- /dev/null +++ b/src-tauri/tests/import_export_sync.rs @@ -0,0 +1,960 @@ +use serde_json::json; +use std::{fs, path::Path, sync::RwLock}; +use tauri::async_runtime; + +use cc_switch_lib::{ + get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService, + MultiAppConfig, Provider, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn sync_claude_provider_writes_live_settings() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + let provider_config = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "test-key", + "ANTHROPIC_BASE_URL": "https://api.test" + }, + "ui": { + "displayName": "Test Provider" + } + }); + + let provider = Provider::with_id( + "prov-1".to_string(), + "Test Claude".to_string(), + provider_config.clone(), + None, + ); + + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.providers.insert("prov-1".to_string(), provider); + manager.current = "prov-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings"); + + let settings_path = get_claude_settings_path(); + assert!( + settings_path.exists(), + "live settings should be written to {}", + settings_path.display() + ); + + let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file"); + assert_eq!(live_value, provider_config); + + // 确认 SSOT 中的供应商也同步了最新内容 + let updated = config + .get_manager(&AppType::Claude) + .and_then(|m| m.providers.get("prov-1")) + .expect("provider in config"); + assert_eq!(updated.settings_config, provider_config); + + // 额外确认写入位置位于测试 HOME 下 + assert!( + settings_path.starts_with(home), + "settings path {:?} should reside under test HOME {:?}", + settings_path, + home + ); +} + +#[test] +fn sync_codex_provider_writes_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + + // 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML + config.mcp.codex.servers.insert( + "echo-server".into(), + json!({ + "id": "echo-server", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo", + "args": ["hello"] + } + }), + ); + + let provider_config = json!({ + "auth": { + "OPENAI_API_KEY": "codex-key" + }, + "config": r#"base_url = "https://codex.test""# + }); + + let provider = Provider::with_id( + "codex-1".to_string(), + "Codex Test".to_string(), + provider_config.clone(), + None, + ); + + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert("codex-1".to_string(), provider); + manager.current = "codex-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live"); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + let config_path = cc_switch_lib::get_codex_config_path(); + + assert!( + auth_path.exists(), + "auth.json should exist at {}", + auth_path.display() + ); + assert!( + config_path.exists(), + "config.toml should exist at {}", + config_path.display() + ); + + let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth"); + assert_eq!( + auth_value, + provider_config.get("auth").cloned().expect("auth object") + ); + + let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); + assert!( + toml_text.contains("command = \"echo\""), + "config.toml should contain serialized enabled MCP server" + ); + + // 当前供应商应同步最新 config 文本 + let manager = config.get_manager(&AppType::Codex).expect("codex manager"); + let synced = manager.providers.get("codex-1").expect("codex provider"); + let synced_cfg = synced + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + assert_eq!(synced_cfg, toml_text); +} + +#[test] +fn sync_enabled_to_codex_writes_enabled_servers() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + config.mcp.codex.servers.insert( + "stdio-enabled".into(), + json!({ + "id": "stdio-enabled", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo", + "args": ["ok"], + } + }), + ); + + cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); + + let path = cc_switch_lib::get_codex_config_path(); + assert!(path.exists(), "config.toml should be created"); + let text = fs::read_to_string(&path).expect("read config.toml"); + assert!( + text.contains("mcp_servers") && text.contains("stdio-enabled"), + "enabled servers should be serialized" + ); +} + +#[test] +fn sync_enabled_to_codex_preserves_non_mcp_content_and_style() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + // 预置含有顶层注释与非 MCP 键的 config.toml + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + let seed = r#"# top-comment +title = "keep-me" + +[profile] +mode = "dev" +"#; + fs::write(&path, seed).expect("seed config.toml"); + + // 启用一个 MCP 项,触发增量写入 + let mut config = MultiAppConfig::default(); + config.mcp.codex.servers.insert( + "echo".into(), + json!({ + "id": "echo", + "enabled": true, + "server": { "type": "stdio", "command": "echo" } + }), + ); + + cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); + + let text = fs::read_to_string(&path).expect("read config.toml"); + // 顶层注释与非 MCP 键应保留 + assert!( + text.contains("# top-comment"), + "top comment should be preserved" + ); + assert!( + text.contains("title = \"keep-me\""), + "top key should be preserved" + ); + assert!( + text.contains("[profile]"), + "non-MCP table should be preserved" + ); + // 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo + assert!( + text.contains("mcp_servers") || text.contains("[mcp.servers]"), + "one server table style should be present" + ); + assert!( + text.contains("echo") && text.contains("command = \"echo\""), + "echo server should be serialized" + ); +} + +#[test] +fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + // 预置 mcp.servers 风格 + let seed = r#"[mcp] + other = "keep" + [mcp.servers] +"#; + fs::write(&path, seed).expect("seed config.toml"); + + let mut config = MultiAppConfig::default(); + config.mcp.codex.servers.insert( + "echo".into(), + json!({ + "id": "echo", + "enabled": true, + "server": { "type": "stdio", "command": "echo" } + }), + ); + + cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); + let text = fs::read_to_string(&path).expect("read config.toml"); + // 仍应采用 mcp.servers 风格 + assert!( + text.contains("[mcp.servers]"), + "should keep mcp.servers style" + ); + assert!( + !text.contains("mcp_servers"), + "should not switch to mcp_servers" + ); +} + +#[test] +fn sync_enabled_to_codex_removes_servers_when_none_enabled() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write( + &path, + r#"[mcp_servers] +disabled = { type = "stdio", command = "noop" } +"#, + ) + .expect("seed config file"); + + let config = MultiAppConfig::default(); // 无启用项 + cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); + + let text = fs::read_to_string(&path).expect("read config.toml"); + assert!( + !text.contains("mcp_servers") && !text.contains("servers"), + "disabled entries should be removed from config.toml" + ); +} + +#[test] +fn sync_enabled_to_codex_returns_error_on_invalid_toml() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write(&path, "invalid = [").expect("write invalid config"); + + let mut config = MultiAppConfig::default(); + config.mcp.codex.servers.insert( + "broken".into(), + json!({ + "id": "broken", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo" + } + }), + ); + + let err = cc_switch_lib::sync_enabled_to_codex(&config).expect_err("sync should fail"); + match err { + cc_switch_lib::AppError::Toml { path, .. } => { + assert!( + path.ends_with("config.toml"), + "path should reference config.toml" + ); + } + cc_switch_lib::AppError::McpValidation(msg) => { + assert!( + msg.contains("config.toml"), + "error message should mention config.toml" + ); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn sync_codex_provider_missing_auth_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + let provider = Provider::with_id( + "codex-missing-auth".to_string(), + "No Auth".to_string(), + json!({ + "config": "model = \"test\"" + }), + None, + ); + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert(provider.id.clone(), provider); + manager.current = "codex-missing-auth".to_string(); + + let err = ConfigService::sync_current_providers_to_live(&mut config) + .expect_err("sync should fail when auth missing"); + match err { + cc_switch_lib::AppError::Config(msg) => { + assert!(msg.contains("auth"), "error message should mention auth"); + } + other => panic!("unexpected error variant: {other:?}"), + } + + // 确认未产生任何 live 配置文件 + assert!( + !cc_switch_lib::get_codex_auth_path().exists(), + "auth.json should not be created on failure" + ); + assert!( + !cc_switch_lib::get_codex_config_path().exists(), + "config.toml should not be created on failure" + ); +} + +#[test] +fn write_codex_live_atomic_persists_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let auth = json!({ "OPENAI_API_KEY": "dev-key" }); + let config_text = r#" +[mcp_servers.echo] +type = "stdio" +command = "echo" +args = ["ok"] +"#; + + cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text)) + .expect("atomic write should succeed"); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + let config_path = cc_switch_lib::get_codex_config_path(); + assert!(auth_path.exists(), "auth.json should be created"); + assert!(config_path.exists(), "config.toml should be created"); + + let stored_auth: serde_json::Value = + cc_switch_lib::read_json_file(&auth_path).expect("read auth"); + assert_eq!(stored_auth, auth, "auth.json should match input"); + + let stored_config = std::fs::read_to_string(&config_path).expect("read config"); + assert!( + stored_config.contains("mcp_servers.echo"), + "config.toml should contain serialized table" + ); +} + +#[test] +fn write_codex_live_atomic_rolls_back_auth_when_config_write_fails() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + if let Some(parent) = auth_path.parent() { + std::fs::create_dir_all(parent).expect("create codex dir"); + } + std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"legacy"}"#).expect("seed auth"); + + let config_path = cc_switch_lib::get_codex_config_path(); + std::fs::create_dir_all(&config_path).expect("create blocking directory"); + + let auth = json!({ "OPENAI_API_KEY": "new-key" }); + let config_text = r#"[mcp_servers.sample] +type = "stdio" +command = "noop" +"#; + + let err = cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text)) + .expect_err("config write should fail when target is directory"); + match err { + cc_switch_lib::AppError::Io { path, .. } => { + assert!( + path.ends_with("config.toml"), + "io error path should point to config.toml" + ); + } + cc_switch_lib::AppError::IoContext { context, .. } => { + assert!( + context.contains("config.toml"), + "error context should mention config path" + ); + } + other => panic!("unexpected error variant: {other:?}"), + } + + let stored = std::fs::read_to_string(&auth_path).expect("read existing auth"); + assert!( + stored.contains("legacy"), + "auth.json should roll back to legacy content" + ); + assert!( + std::fs::metadata(&config_path) + .expect("config path metadata") + .is_dir(), + "config path should remain a directory after failure" + ); +} + +#[test] +fn import_from_codex_adds_servers_from_mcp_servers_table() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write( + &path, + r#"[mcp_servers.echo_server] +type = "stdio" +command = "echo" +args = ["hello"] + +[mcp_servers.http_server] +type = "http" +url = "https://example.com" +"#, + ) + .expect("write codex config"); + + let mut config = MultiAppConfig::default(); + let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex"); + assert!(changed >= 2, "should import both servers"); + + let servers = &config.mcp.codex.servers; + let echo = servers + .get("echo_server") + .and_then(|v| v.as_object()) + .expect("echo server"); + assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true)); + let server_spec = echo + .get("server") + .and_then(|v| v.as_object()) + .expect("server spec"); + assert_eq!( + server_spec + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""), + "echo" + ); + + let http = servers + .get("http_server") + .and_then(|v| v.as_object()) + .expect("http server"); + let http_spec = http + .get("server") + .and_then(|v| v.as_object()) + .expect("http spec"); + assert_eq!( + http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""), + "https://example.com" + ); +} + +#[test] +fn import_from_codex_merges_into_existing_entries() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write( + &path, + r#"[mcp.servers.existing] +type = "stdio" +command = "echo" +"#, + ) + .expect("write codex config"); + + let mut config = MultiAppConfig::default(); + config.mcp.codex.servers.insert( + "existing".into(), + json!({ + "id": "existing", + "name": "existing", + "enabled": false, + "server": { + "type": "stdio", + "command": "prev" + } + }), + ); + + let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex"); + assert!(changed >= 1, "should mark change for enabled flag"); + + let entry = config + .mcp + .codex + .servers + .get("existing") + .and_then(|v| v.as_object()) + .expect("existing entry"); + assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true)); + let spec = entry + .get("server") + .and_then(|v| v.as_object()) + .expect("server spec"); + // 保留原 command,确保导入不会覆盖现有 server 细节 + assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev")); +} + +#[test] +fn sync_claude_enabled_mcp_projects_to_user_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let mut config = MultiAppConfig::default(); + + config.mcp.claude.servers.insert( + "stdio-enabled".into(), + json!({ + "id": "stdio-enabled", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo", + "args": ["hi"], + } + }), + ); + config.mcp.claude.servers.insert( + "http-disabled".into(), + json!({ + "id": "http-disabled", + "enabled": false, + "server": { + "type": "http", + "url": "https://example.com", + } + }), + ); + + cc_switch_lib::sync_enabled_to_claude(&config).expect("sync Claude MCP"); + + let claude_path = cc_switch_lib::get_claude_mcp_path(); + assert!(claude_path.exists(), "claude config should exist"); + let text = fs::read_to_string(&claude_path).expect("read .claude.json"); + let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude json"); + let servers = value + .get("mcpServers") + .and_then(|v| v.as_object()) + .expect("mcpServers map"); + assert_eq!(servers.len(), 1, "only enabled entries should be written"); + let enabled = servers.get("stdio-enabled").expect("enabled entry"); + assert_eq!( + enabled + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "echo" + ); + assert!(servers.get("http-disabled").is_none()); +} + +#[test] +fn import_from_claude_merges_into_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let claude_path = home.join(".claude.json"); + + fs::write( + &claude_path, + serde_json::to_string_pretty(&json!({ + "mcpServers": { + "stdio-enabled": { + "type": "stdio", + "command": "echo", + "args": ["hello"] + } + } + })) + .unwrap(), + ) + .expect("write claude json"); + + let mut config = MultiAppConfig::default(); + config.mcp.claude.servers.insert( + "stdio-enabled".into(), + json!({ + "id": "stdio-enabled", + "name": "stdio-enabled", + "enabled": false, + "server": { + "type": "stdio", + "command": "prev" + } + }), + ); + + let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude"); + assert!(changed >= 1, "should mark at least one change"); + + let entry = config + .mcp + .claude + .servers + .get("stdio-enabled") + .and_then(|v| v.as_object()) + .expect("entry exists"); + assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true)); + let server = entry + .get("server") + .and_then(|v| v.as_object()) + .expect("server obj"); + assert_eq!( + server.get("command").and_then(|v| v.as_str()).unwrap_or(""), + "prev", + "existing server config should be preserved" + ); +} + +#[test] +fn create_backup_skips_missing_file() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let config_path = home.join(".cc-switch").join("config.json"); + + // 未创建文件时应返回空字符串,不报错 + let result = ConfigService::create_backup(&config_path).expect("create backup"); + assert!( + result.is_empty(), + "expected empty backup id when config file missing" + ); +} + +#[test] +fn create_backup_generates_snapshot_file() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let config_dir = home.join(".cc-switch"); + let config_path = config_dir.join("config.json"); + fs::create_dir_all(&config_dir).expect("prepare config dir"); + fs::write(&config_path, r#"{"version":2}"#).expect("write config file"); + + let backup_id = ConfigService::create_backup(&config_path).expect("backup success"); + assert!( + !backup_id.is_empty(), + "backup id should contain timestamp information" + ); + + let backup_path = config_dir.join("backups").join(format!("{backup_id}.json")); + assert!( + backup_path.exists(), + "expected backup file at {}", + backup_path.display() + ); + + let backup_content = fs::read_to_string(&backup_path).expect("read backup"); + assert!( + backup_content.contains(r#""version":2"#), + "backup content should match original config" + ); +} + +#[test] +fn create_backup_retains_only_latest_entries() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let config_dir = home.join(".cc-switch"); + let config_path = config_dir.join("config.json"); + fs::create_dir_all(&config_dir).expect("prepare config dir"); + fs::write(&config_path, r#"{"version":3}"#).expect("write config file"); + + let backups_dir = config_dir.join("backups"); + fs::create_dir_all(&backups_dir).expect("create backups dir"); + for idx in 0..12 { + let manual = backups_dir.join(format!("manual_{idx:02}.json")); + fs::write(&manual, format!("{{\"idx\":{idx}}}")).expect("seed manual backup"); + } + + std::thread::sleep(std::time::Duration::from_secs(1)); + + let latest_backup_id = + ConfigService::create_backup(&config_path).expect("create backup with cleanup"); + assert!( + !latest_backup_id.is_empty(), + "backup id should not be empty when config exists" + ); + + let entries: Vec<_> = fs::read_dir(&backups_dir) + .expect("read backups dir") + .filter_map(|entry| entry.ok()) + .collect(); + assert!( + entries.len() <= 10, + "expected backups to be trimmed to at most 10 files, got {}", + entries.len() + ); + + let latest_path = backups_dir.join(format!("{latest_backup_id}.json")); + assert!( + latest_path.exists(), + "latest backup {} should be preserved", + latest_path.display() + ); + + // 进一步确认保留的条目包含一些历史文件,说明清理逻辑仅裁剪多余部分 + let manual_kept = entries + .iter() + .filter_map(|entry| entry.file_name().into_string().ok()) + .any(|name| name.starts_with("manual_")); + assert!( + manual_kept, + "cleanup should keep part of the older backups to maintain history" + ); +} + +#[test] +fn import_config_from_path_overwrites_state_and_creates_backup() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let config_dir = home.join(".cc-switch"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let config_path = config_dir.join("config.json"); + fs::write(&config_path, r#"{"version":1}"#).expect("seed original config"); + + let import_payload = serde_json::json!({ + "version": 2, + "claude": { + "providers": { + "p-new": { + "id": "p-new", + "name": "Test Claude", + "settingsConfig": { + "env": { "ANTHROPIC_API_KEY": "new-key" } + } + } + }, + "current": "p-new" + }, + "codex": { + "providers": {}, + "current": "" + }, + "mcp": { + "claude": { "servers": {} }, + "codex": { "servers": {} } + } + }); + + let import_path = config_dir.join("import.json"); + fs::write( + &import_path, + serde_json::to_string_pretty(&import_payload).expect("serialize import payload"), + ) + .expect("write import file"); + + let app_state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let backup_id = ConfigService::import_config_from_path(&import_path, &app_state) + .expect("import should succeed"); + assert!( + !backup_id.is_empty(), + "expected backup id when original config exists" + ); + + let backup_path = config_dir.join("backups").join(format!("{backup_id}.json")); + assert!( + backup_path.exists(), + "backup file should exist at {}", + backup_path.display() + ); + + let updated_content = fs::read_to_string(&config_path).expect("read updated config"); + let parsed: serde_json::Value = + serde_json::from_str(&updated_content).expect("parse updated config"); + assert_eq!( + parsed + .get("claude") + .and_then(|c| c.get("current")) + .and_then(|c| c.as_str()), + Some("p-new"), + "saved config should record new current provider" + ); + + let guard = app_state.config.read().expect("lock state after import"); + let claude_manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager in state"); + assert_eq!( + claude_manager.current, "p-new", + "state should reflect new current provider" + ); + assert!( + claude_manager.providers.contains_key("p-new"), + "new provider should exist in state" + ); +} + +#[test] +fn import_config_from_path_invalid_json_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let config_dir = home.join(".cc-switch"); + fs::create_dir_all(&config_dir).expect("create config dir"); + + let invalid_path = config_dir.join("broken.json"); + fs::write(&invalid_path, "{ not-json ").expect("write invalid json"); + + let app_state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let err = ConfigService::import_config_from_path(&invalid_path, &app_state) + .expect_err("import should fail"); + match err { + AppError::Json { .. } => {} + other => panic!("expected json error, got {other:?}"), + } +} + +#[test] +fn import_config_from_path_missing_file_produces_io_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let missing_path = Path::new("/nonexistent/import.json"); + let app_state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let err = ConfigService::import_config_from_path(missing_path, &app_state) + .expect_err("import should fail for missing file"); + match err { + AppError::Io { .. } => {} + other => panic!("expected io error, got {other:?}"), + } +} + +#[test] +fn export_config_to_file_writes_target_path() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let config_dir = home.join(".cc-switch"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let config_path = config_dir.join("config.json"); + fs::write(&config_path, r#"{"version":42,"flag":true}"#).expect("write config"); + + let export_path = home.join("exported-config.json"); + if export_path.exists() { + fs::remove_file(&export_path).expect("cleanup export target"); + } + + let result = async_runtime::block_on(cc_switch_lib::export_config_to_file( + export_path.to_string_lossy().to_string(), + )) + .expect("export should succeed"); + assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true)); + + let exported = fs::read_to_string(&export_path).expect("read exported file"); + assert!( + exported.contains(r#""version":42"#) && exported.contains(r#""flag":true"#), + "exported file should mirror source config content" + ); +} + +#[test] +fn export_config_to_file_returns_error_when_source_missing() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let export_path = home.join("export-missing.json"); + if export_path.exists() { + fs::remove_file(&export_path).expect("cleanup export target"); + } + + let err = async_runtime::block_on(cc_switch_lib::export_config_to_file( + export_path.to_string_lossy().to_string(), + )) + .expect_err("export should fail when config.json missing"); + assert!( + err.contains("IO 错误"), + "expected IO error message, got {err}" + ); +} diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs new file mode 100644 index 0000000..4006bad --- /dev/null +++ b/src-tauri/tests/mcp_commands.rs @@ -0,0 +1,234 @@ +use std::{fs, sync::RwLock}; + +use serde_json::json; + +use cc_switch_lib::{ + get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError, + AppState, AppType, McpService, MultiAppConfig, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn import_default_config_claude_persists_provider() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let settings_path = get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent).expect("create claude settings dir"); + } + let settings = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "test-key", + "ANTHROPIC_BASE_URL": "https://api.test" + } + }); + fs::write( + &settings_path, + serde_json::to_string_pretty(&settings).expect("serialize settings"), + ) + .expect("seed claude settings.json"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = AppState { + config: RwLock::new(config), + }; + + import_default_config_test_hook(&state, AppType::Claude) + .expect("import default config succeeds"); + + // 验证内存状态 + let guard = state.config.read().expect("lock config"); + let manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager present"); + assert_eq!(manager.current, "default"); + let default_provider = manager.providers.get("default").expect("default provider"); + assert_eq!( + default_provider.settings_config, settings, + "default provider should capture live settings" + ); + drop(guard); + + // 验证配置已持久化 + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + config_path.exists(), + "importing default config should persist config.json" + ); +} + +#[test] +fn import_default_config_without_live_file_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let err = import_default_config_test_hook(&state, AppType::Claude) + .expect_err("missing live file should error"); + match err { + AppError::Localized { zh, .. } => assert!( + zh.contains("Claude Code 配置文件不存在"), + "unexpected error message: {zh}" + ), + AppError::Message(msg) => assert!( + msg.contains("Claude Code 配置文件不存在"), + "unexpected error message: {msg}" + ), + other => panic!("unexpected error variant: {other:?}"), + } + + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + !config_path.exists(), + "failed import should not create config.json" + ); +} + +#[test] +fn import_mcp_from_claude_creates_config_and_enables_servers() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mcp_path = get_claude_mcp_path(); + let claude_json = json!({ + "mcpServers": { + "echo": { + "type": "stdio", + "command": "echo" + } + } + }); + fs::write( + &mcp_path, + serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), + ) + .expect("seed ~/.claude.json"); + + let state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds"); + assert!( + changed > 0, + "import should report inserted or normalized entries" + ); + + let guard = state.config.read().expect("lock config"); + let claude_servers = &guard.mcp.claude.servers; + let entry = claude_servers + .get("echo") + .expect("server imported into config.json"); + assert!( + entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "imported server should be marked enabled" + ); + drop(guard); + + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + config_path.exists(), + "state.save should persist config.json when changes detected" + ); +} + +#[test] +fn import_mcp_from_claude_invalid_json_preserves_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mcp_path = get_claude_mcp_path(); + fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON + .expect("seed invalid ~/.claude.json"); + + let state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let err = + McpService::import_from_claude(&state).expect_err("invalid json should bubble up error"); + match err { + AppError::McpValidation(msg) => assert!( + msg.contains("解析 ~/.claude.json 失败"), + "unexpected error message: {msg}" + ), + other => panic!("unexpected error variant: {other:?}"), + } + + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + !config_path.exists(), + "failed import should not persist config.json" + ); +} + +#[test] +fn set_mcp_enabled_for_codex_writes_live_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + ensure_test_home(); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + config.mcp.codex.servers.insert( + "codex-server".into(), + json!({ + "id": "codex-server", + "name": "Codex Server", + "server": { + "type": "stdio", + "command": "echo" + }, + "enabled": false + }), + ); + + let state = AppState { + config: RwLock::new(config), + }; + + McpService::set_enabled(&state, AppType::Codex, "codex-server", true) + .expect("set enabled should succeed"); + + let guard = state.config.read().expect("lock config"); + let entry = guard + .mcp + .codex + .servers + .get("codex-server") + .expect("codex server exists"); + assert!( + entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "server should be marked enabled after command" + ); + drop(guard); + + let toml_path = cc_switch_lib::get_codex_config_path(); + assert!( + toml_path.exists(), + "enabling server should trigger sync to ~/.codex/config.toml" + ); + let toml_text = fs::read_to_string(&toml_path).expect("read codex config"); + assert!( + toml_text.contains("codex-server"), + "codex config should include the enabled server definition" + ); +} diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs new file mode 100644 index 0000000..4b3b605 --- /dev/null +++ b/src-tauri/tests/provider_commands.rs @@ -0,0 +1,327 @@ +use serde_json::json; +use std::sync::RwLock; + +use cc_switch_lib::{ + get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook, + write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn switch_provider_updates_codex_live_and_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"}); + let legacy_config = r#"[mcp_servers.legacy] +type = "stdio" +command = "echo" +"#; + write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing codex live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "stale"}, + "config": "stale-config" + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Latest".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "fresh-key"}, + "config": r#"[mcp_servers.latest] +type = "stdio" +command = "say" +"# + }), + None, + ), + ); + } + + config.mcp.codex.servers.insert( + "echo-server".into(), + json!({ + "id": "echo-server", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo" + } + }), + ); + + let app_state = AppState { + config: RwLock::new(config), + }; + + switch_provider_test_hook(&app_state, AppType::Codex, "new-provider") + .expect("switch provider should succeed"); + + let auth_value: serde_json::Value = + read_json_file(&get_codex_auth_path()).expect("read auth.json"); + assert_eq!( + auth_value + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .unwrap_or(""), + "fresh-key", + "live auth.json should reflect new provider" + ); + + let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml"); + assert!( + config_text.contains("mcp_servers.echo-server"), + "config.toml should contain synced MCP servers" + ); + + let locked = app_state.config.read().expect("lock config after switch"); + let manager = locked + .get_manager(&AppType::Codex) + .expect("codex manager after switch"); + assert_eq!(manager.current, "new-provider", "current provider updated"); + + let new_provider = manager + .providers + .get("new-provider") + .expect("new provider exists"); + let new_config_text = new_provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!( + new_config_text, config_text, + "provider config snapshot should match live file" + ); + + let legacy = manager + .providers + .get("old-provider") + .expect("legacy provider still exists"); + let legacy_auth_value = legacy + .settings_config + .get("auth") + .and_then(|v| v.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!( + legacy_auth_value, "legacy-key", + "previous provider should be backfilled with live auth" + ); +} + +#[test] +fn switch_provider_missing_provider_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + config + .get_manager_mut(&AppType::Claude) + .expect("claude manager") + .current = "does-not-exist".to_string(); + + let app_state = AppState { + config: RwLock::new(config), + }; + + let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider") + .expect_err("switching to a missing provider should fail"); + + assert!( + err.to_string().contains("供应商不存在"), + "error message should mention missing provider" + ); +} + +#[test] +fn switch_provider_updates_claude_live_and_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let settings_path = cc_switch_lib::get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).expect("create claude settings dir"); + } + let legacy_live = json!({ + "env": { + "ANTHROPIC_API_KEY": "legacy-key" + }, + "workspace": { + "path": "/tmp/workspace" + } + }); + std::fs::write( + &settings_path, + serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"), + ) + .expect("seed claude live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "stale-key" } + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Fresh Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "fresh-key" }, + "workspace": { "path": "/tmp/new-workspace" } + }), + None, + ), + ); + } + + let app_state = AppState { + config: RwLock::new(config), + }; + + switch_provider_test_hook(&app_state, AppType::Claude, "new-provider") + .expect("switch provider should succeed"); + + let live_after: serde_json::Value = + read_json_file(&settings_path).expect("read claude live settings"); + assert_eq!( + live_after + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("fresh-key"), + "live settings.json should reflect new provider auth" + ); + + let locked = app_state.config.read().expect("lock config after switch"); + let manager = locked + .get_manager(&AppType::Claude) + .expect("claude manager after switch"); + assert_eq!(manager.current, "new-provider", "current provider updated"); + + let legacy_provider = manager + .providers + .get("old-provider") + .expect("legacy provider still exists"); + assert_eq!( + legacy_provider.settings_config, legacy_live, + "previous provider should receive backfilled live config" + ); + + let new_provider = manager + .providers + .get("new-provider") + .expect("new provider exists"); + assert_eq!( + new_provider + .settings_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("fresh-key"), + "new provider snapshot should retain fresh auth" + ); + + drop(locked); + + let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home"); + let config_path = std::path::Path::new(&home_dir) + .join(".cc-switch") + .join("config.json"); + assert!( + config_path.exists(), + "switching provider should persist config.json" + ); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config")) + .expect("parse saved config"); + assert_eq!( + persisted + .get("claude") + .and_then(|claude| claude.get("current")) + .and_then(|current| current.as_str()), + Some("new-provider"), + "saved config.json should record the new current provider" + ); +} + +#[test] +fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert( + "invalid".to_string(), + Provider::with_id( + "invalid".to_string(), + "Broken Codex".to_string(), + json!({ + "config": "[mcp_servers.test]\ncommand = \"noop\"" + }), + None, + ), + ); + } + + let app_state = AppState { + config: RwLock::new(config), + }; + + let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid") + .expect_err("switching should fail when auth missing"); + match err { + AppError::Config(msg) => assert!( + msg.contains("auth"), + "expected auth missing error message, got {msg}" + ), + other => panic!("expected config error, got {other:?}"), + } + + let locked = app_state.config.read().expect("lock config after failure"); + let manager = locked.get_manager(&AppType::Codex).expect("codex manager"); + assert!( + manager.current.is_empty(), + "current provider should remain empty on failure" + ); +} diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs new file mode 100644 index 0000000..c24cc36 --- /dev/null +++ b/src-tauri/tests/provider_service.rs @@ -0,0 +1,450 @@ +use serde_json::json; +use std::sync::RwLock; + +use cc_switch_lib::{ + get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType, + MultiAppConfig, Provider, ProviderService, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +fn sanitize_provider_name(name: &str) -> String { + name.chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-', + _ => c, + }) + .collect::() + .to_lowercase() +} + +#[test] +fn provider_service_switch_codex_updates_live_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" }); + let legacy_config = r#"[mcp_servers.legacy] +type = "stdio" +command = "echo" +"#; + write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing codex live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "stale"}, + "config": "stale-config" + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Latest".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "fresh-key"}, + "config": r#"[mcp_servers.latest] +type = "stdio" +command = "say" +"# + }), + None, + ), + ); + } + + initial_config.mcp.codex.servers.insert( + "echo-server".into(), + json!({ + "id": "echo-server", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo" + } + }), + ); + + let state = AppState { + config: RwLock::new(initial_config), + }; + + ProviderService::switch(&state, AppType::Codex, "new-provider") + .expect("switch provider should succeed"); + + let auth_value: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); + assert_eq!( + auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()), + Some("fresh-key"), + "live auth.json should reflect new provider" + ); + + let config_text = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + assert!( + config_text.contains("mcp_servers.echo-server"), + "config.toml should contain synced MCP servers" + ); + + let guard = state.config.read().expect("read config after switch"); + let manager = guard + .get_manager(&AppType::Codex) + .expect("codex manager after switch"); + assert_eq!(manager.current, "new-provider", "current provider updated"); + + let new_provider = manager + .providers + .get("new-provider") + .expect("new provider exists"); + let new_config_text = new_provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!( + new_config_text, config_text, + "provider config snapshot should match live file" + ); + + let legacy = manager + .providers + .get("old-provider") + .expect("legacy provider still exists"); + let legacy_auth_value = legacy + .settings_config + .get("auth") + .and_then(|v| v.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!( + legacy_auth_value, "legacy-key", + "previous provider should be backfilled with live auth" + ); +} + +#[test] +fn provider_service_switch_claude_updates_live_and_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let settings_path = get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).expect("create claude settings dir"); + } + let legacy_live = json!({ + "env": { + "ANTHROPIC_API_KEY": "legacy-key" + }, + "workspace": { + "path": "/tmp/workspace" + } + }); + std::fs::write( + &settings_path, + serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"), + ) + .expect("seed claude live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "stale-key" } + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Fresh Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "fresh-key" }, + "workspace": { "path": "/tmp/new-workspace" } + }), + None, + ), + ); + } + + let state = AppState { + config: RwLock::new(config), + }; + + ProviderService::switch(&state, AppType::Claude, "new-provider") + .expect("switch provider should succeed"); + + let live_after: serde_json::Value = + read_json_file(&settings_path).expect("read claude live settings"); + assert_eq!( + live_after + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("fresh-key"), + "live settings.json should reflect new provider auth" + ); + + let guard = state + .config + .read() + .expect("read claude config after switch"); + let manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager after switch"); + assert_eq!(manager.current, "new-provider", "current provider updated"); + + let legacy_provider = manager + .providers + .get("old-provider") + .expect("legacy provider still exists"); + assert_eq!( + legacy_provider.settings_config, legacy_live, + "previous provider should receive backfilled live config" + ); +} + +#[test] +fn provider_service_switch_missing_provider_returns_error() { + let state = AppState { + config: RwLock::new(MultiAppConfig::default()), + }; + + let err = ProviderService::switch(&state, AppType::Claude, "missing") + .expect_err("switching missing provider should fail"); + match err { + AppError::ProviderNotFound(id) => assert_eq!(id, "missing"), + other => panic!("expected ProviderNotFound, got {other:?}"), + } +} + +#[test] +fn provider_service_switch_codex_missing_auth_returns_error() { + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert( + "invalid".to_string(), + Provider::with_id( + "invalid".to_string(), + "Broken Codex".to_string(), + json!({ + "config": "[mcp_servers.test]\ncommand = \"noop\"" + }), + None, + ), + ); + } + + let state = AppState { + config: RwLock::new(config), + }; + + let err = ProviderService::switch(&state, AppType::Codex, "invalid") + .expect_err("switching should fail without auth"); + match err { + AppError::Config(msg) => assert!( + msg.contains("auth"), + "expected auth related message, got {msg}" + ), + other => panic!("expected config error, got {other:?}"), + } +} + +#[test] +fn provider_service_delete_codex_removes_provider_and_files() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "keep".to_string(); + manager.providers.insert( + "keep".to_string(), + Provider::with_id( + "keep".to_string(), + "Keep".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "keep-key"}, + "config": "" + }), + None, + ), + ); + manager.providers.insert( + "to-delete".to_string(), + Provider::with_id( + "to-delete".to_string(), + "DeleteCodex".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "delete-key"}, + "config": "" + }), + None, + ), + ); + } + + let sanitized = sanitize_provider_name("DeleteCodex"); + let codex_dir = home.join(".codex"); + std::fs::create_dir_all(&codex_dir).expect("create codex dir"); + let auth_path = codex_dir.join(format!("auth-{}.json", sanitized)); + let cfg_path = codex_dir.join(format!("config-{}.toml", sanitized)); + std::fs::write(&auth_path, "{}").expect("seed auth file"); + std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file"); + + let app_state = AppState { + config: RwLock::new(config), + }; + + ProviderService::delete(&app_state, AppType::Codex, "to-delete") + .expect("delete provider should succeed"); + + let locked = app_state.config.read().expect("lock config after delete"); + let manager = locked.get_manager(&AppType::Codex).expect("codex manager"); + assert!( + !manager.providers.contains_key("to-delete"), + "provider entry should be removed" + ); + assert!( + !auth_path.exists() && !cfg_path.exists(), + "provider-specific files should be deleted" + ); +} + +#[test] +fn provider_service_delete_claude_removes_provider_files() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "keep".to_string(); + manager.providers.insert( + "keep".to_string(), + Provider::with_id( + "keep".to_string(), + "Keep".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "keep-key" } + }), + None, + ), + ); + manager.providers.insert( + "delete".to_string(), + Provider::with_id( + "delete".to_string(), + "DeleteClaude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "delete-key" } + }), + None, + ), + ); + } + + let sanitized = sanitize_provider_name("DeleteClaude"); + let claude_dir = home.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("create claude dir"); + let by_name = claude_dir.join(format!("settings-{}.json", sanitized)); + let by_id = claude_dir.join("settings-delete.json"); + std::fs::write(&by_name, "{}").expect("seed settings by name"); + std::fs::write(&by_id, "{}").expect("seed settings by id"); + + let app_state = AppState { + config: RwLock::new(config), + }; + + ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider"); + + let locked = app_state.config.read().expect("lock config after delete"); + let manager = locked + .get_manager(&AppType::Claude) + .expect("claude manager"); + assert!( + !manager.providers.contains_key("delete"), + "claude provider should be removed" + ); + assert!( + !by_name.exists() && !by_id.exists(), + "provider config files should be deleted" + ); +} + +#[test] +fn provider_service_delete_current_provider_returns_error() { + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "keep".to_string(); + manager.providers.insert( + "keep".to_string(), + Provider::with_id( + "keep".to_string(), + "Keep".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "keep-key" } + }), + None, + ), + ); + } + + let app_state = AppState { + config: RwLock::new(config), + }; + + let err = ProviderService::delete(&app_state, AppType::Claude, "keep") + .expect_err("deleting current provider should fail"); + match err { + AppError::Localized { zh, .. } => assert!( + zh.contains("不能删除当前正在使用的供应商"), + "unexpected message: {zh}" + ), + AppError::Config(msg) => assert!( + msg.contains("不能删除当前正在使用的供应商"), + "unexpected message: {msg}" + ), + other => panic!("expected Config error, got {other:?}"), + } +} diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs new file mode 100644 index 0000000..21d947f --- /dev/null +++ b/src-tauri/tests/support.rs @@ -0,0 +1,47 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use cc_switch_lib::{update_settings, AppSettings}; + +/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 +pub fn ensure_test_home() -> &'static Path { + static HOME: OnceLock = OnceLock::new(); + HOME.get_or_init(|| { + let base = std::env::temp_dir().join("cc-switch-test-home"); + if base.exists() { + let _ = std::fs::remove_dir_all(&base); + } + std::fs::create_dir_all(&base).expect("create test home"); + std::env::set_var("HOME", &base); + #[cfg(windows)] + std::env::set_var("USERPROFILE", &base); + base + }) + .as_path() +} + +/// 清理测试目录中生成的配置文件与缓存。 +pub fn reset_test_fs() { + let home = ensure_test_home(); + for sub in [".claude", ".codex", ".cc-switch"] { + let path = home.join(sub); + if path.exists() { + if let Err(err) = std::fs::remove_dir_all(&path) { + eprintln!("failed to clean {}: {}", path.display(), err); + } + } + } + let claude_json = home.join(".claude.json"); + if claude_json.exists() { + let _ = std::fs::remove_file(&claude_json); + } + + // 重置内存中的设置缓存,确保测试环境不受上一次调用影响 + let _ = update_settings(AppSettings::default()); +} + +/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。 +pub fn test_mutex() -> &'static Mutex<()> { + static MUTEX: OnceLock> = OnceLock::new(); + MUTEX.get_or_init(|| Mutex::new(())) +} diff --git a/src/App.tsx b/src/App.tsx index c50d259..7bc4ad1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,409 +1,297 @@ -import { useState, useEffect, useRef } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -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 SettingsModal from "./components/SettingsModal"; -import { UpdateBadge } from "./components/UpdateBadge"; -import { Plus, Settings, Moon, Sun } from "lucide-react"; -import McpPanel from "./components/mcp/McpPanel"; -import { buttonStyles } from "./lib/styles"; -import { useDarkMode } from "./hooks/useDarkMode"; -import { extractErrorMessage } from "./utils/errorUtils"; +import { toast } from "sonner"; +import { Plus, Settings, Edit3 } from "lucide-react"; +import type { Provider } from "@/types"; +import { useProvidersQuery } from "@/lib/query"; +import { + providersApi, + settingsApi, + type AppId, + type ProviderSwitchEvent, +} from "@/lib/api"; +import { useProviderActions } from "@/hooks/useProviderActions"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { AppSwitcher } from "@/components/AppSwitcher"; +import { ProviderList } from "@/components/providers/ProviderList"; +import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; +import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { SettingsDialog } from "@/components/settings/SettingsDialog"; +import { UpdateBadge } from "@/components/UpdateBadge"; +import UsageScriptModal from "@/components/UsageScriptModal"; +import McpPanel from "@/components/mcp/McpPanel"; +import { Button } from "@/components/ui/button"; function App() { const { t } = useTranslation(); - const { isDarkMode, toggleDarkMode } = useDarkMode(); - const [activeApp, setActiveApp] = useState("claude"); - const [providers, setProviders] = useState>({}); - const [currentProviderId, setCurrentProviderId] = useState(""); - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [editingProviderId, setEditingProviderId] = useState( - null, - ); - const [notification, setNotification] = useState<{ - message: string; - type: "success" | "error"; - } | null>(null); - const [isNotificationVisible, setIsNotificationVisible] = useState(false); - const [confirmDialog, setConfirmDialog] = useState<{ - isOpen: boolean; - title: string; - message: string; - onConfirm: () => void; - } | null>(null); + + const [activeApp, setActiveApp] = useState("claude"); + const [isEditMode, setIsEditMode] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isAddOpen, setIsAddOpen] = useState(false); const [isMcpOpen, setIsMcpOpen] = useState(false); - const timeoutRef = useRef | null>(null); + const [editingProvider, setEditingProvider] = useState(null); + const [usageProvider, setUsageProvider] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); - // 设置通知的辅助函数 - const showNotification = ( - message: string, - type: "success" | "error", - duration = 3000, - ) => { - // 清除之前的定时器 - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + const { data, isLoading, refetch } = useProvidersQuery(activeApp); + const providers = useMemo(() => data?.providers ?? {}, [data]); + const currentProviderId = data?.currentProviderId ?? ""; - // 立即显示通知 - setNotification({ message, type }); - setIsNotificationVisible(true); + // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 + const { + addProvider, + updateProvider, + switchProvider, + deleteProvider, + saveUsageScript, + } = useProviderActions(activeApp); - // 设置淡出定时器 - timeoutRef.current = setTimeout(() => { - setIsNotificationVisible(false); - // 等待淡出动画完成后清除通知 - setTimeout(() => { - setNotification(null); - timeoutRef.current = null; - }, 300); // 与CSS动画时间匹配 - }, duration); - }; - - // 加载供应商列表 + // 监听来自托盘菜单的切换事件 useEffect(() => { - loadProviders(); - }, [activeApp]); // 当切换应用时重新加载 - - // 清理定时器 - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - // 监听托盘切换事件(包括菜单切换) - useEffect(() => { - let unlisten: (() => void) | null = null; + let unsubscribe: (() => void) | undefined; const setupListener = async () => { try { - unlisten = await window.api.onProviderSwitched(async (data) => { - if (import.meta.env.DEV) { - console.log(t("console.providerSwitchReceived"), data); - } - - // 如果当前应用类型匹配,则重新加载数据 - if (data.appType === activeApp) { - await loadProviders(); - } - - // 若为 Claude,则同步插件配置 - if (data.appType === "claude") { - await syncClaudePlugin(data.providerId, true); - } - }); + unsubscribe = await providersApi.onSwitched( + async (event: ProviderSwitchEvent) => { + if (event.appType === activeApp) { + await refetch(); + } + }, + ); } catch (error) { - console.error(t("console.setupListenerFailed"), error); + console.error("[App] Failed to subscribe provider switch event", error); } }; setupListener(); - - // 清理监听器 return () => { - if (unlisten) { - unlisten(); - } + unsubscribe?.(); }; - }, [activeApp]); + }, [activeApp, refetch]); - const loadProviders = async () => { - const loadedProviders = await window.api.getProviders(activeApp); - const currentId = await window.api.getCurrentProvider(activeApp); - setProviders(loadedProviders); - setCurrentProviderId(currentId); - - // 如果供应商列表为空,尝试自动从 live 导入一条默认供应商 - if (Object.keys(loadedProviders).length === 0) { - await handleAutoImportDefault(); + // 打开网站链接 + const handleOpenWebsite = async (url: string) => { + try { + await settingsApi.openExternal(url); + } catch (error) { + const detail = + extractErrorMessage(error) || + t("notifications.openLinkFailed", { + defaultValue: "链接打开失败", + }); + toast.error(detail); } }; - // 生成唯一ID - const generateId = () => { - return crypto.randomUUID(); - }; - - const handleAddProvider = async (provider: Omit) => { - const newProvider: Provider = { - ...provider, - id: generateId(), - createdAt: Date.now(), // 添加创建时间戳 - }; - await window.api.addProvider(newProvider, activeApp); - await loadProviders(); - setIsAddModalOpen(false); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - }; - + // 编辑供应商 const handleEditProvider = async (provider: Provider) => { - try { - await window.api.updateProvider(provider, activeApp); - await loadProviders(); - setEditingProviderId(null); - // 显示编辑成功提示 - showNotification(t("notifications.providerSaved"), "success", 2000); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - } catch (error) { - console.error(t("console.updateProviderFailed"), error); - setEditingProviderId(null); - const errorMessage = extractErrorMessage(error); - const message = errorMessage - ? t("notifications.saveFailed", { error: errorMessage }) - : t("notifications.saveFailedGeneric"); - showNotification(message, "error", errorMessage ? 6000 : 3000); - } + await updateProvider(provider); + setEditingProvider(null); }; - const handleDeleteProvider = async (id: string) => { - const provider = providers[id]; - setConfirmDialog({ - isOpen: true, - title: t("confirm.deleteProvider"), - message: t("confirm.deleteProviderMessage", { name: provider?.name }), - onConfirm: async () => { - await window.api.deleteProvider(id, activeApp); - await loadProviders(); - setConfirmDialog(null); - showNotification(t("notifications.providerDeleted"), "success"); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - }, - }); + // 确认删除供应商 + const handleConfirmDelete = async () => { + if (!confirmDelete) return; + await deleteProvider(confirmDelete.id); + setConfirmDelete(null); }; - // 同步 Claude 插件配置(按设置决定是否联动;开启时:非官方写入,官方移除) - const syncClaudePlugin = async (providerId: string, silent = false) => { - try { - const settings = await window.api.getSettings(); - if (!(settings as any)?.enableClaudePluginIntegration) { - // 未开启联动:不执行写入/移除 - return; - } - const provider = providers[providerId]; - if (!provider) return; - const isOfficial = provider.category === "official"; - await window.api.applyClaudePluginConfig({ official: isOfficial }); - if (!silent) { - showNotification( - isOfficial - ? t("notifications.removedFromClaudePlugin") - : t("notifications.appliedToClaudePlugin"), - "success", - 2000, - ); - } - } catch (error: any) { - console.error("同步 Claude 插件失败:", error); - if (!silent) { - const message = - error?.message || t("notifications.syncClaudePluginFailed"); - showNotification(message, "error", 5000); - } - } - }; + // 复制供应商 + const handleDuplicateProvider = async (provider: Provider) => { + // 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它 + const newSortIndex = + provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; - const handleSwitchProvider = async (id: string) => { - try { - const success = await window.api.switchProvider(id, activeApp); - if (success) { - setCurrentProviderId(id); - // 显示重启提示 - const appName = t(`apps.${activeApp}`); - showNotification( - t("notifications.switchSuccess", { appName }), - "success", - 2000, - ); - // 更新托盘菜单 - await window.api.updateTrayMenu(); + const duplicatedProvider: Omit = { + name: `${provider.name} copy`, + settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 + websiteUrl: provider.websiteUrl, + category: provider.category, + sortIndex: newSortIndex, // 复制原 sortIndex + 1 + meta: provider.meta + ? JSON.parse(JSON.stringify(provider.meta)) + : undefined, // 深拷贝 + }; - if (activeApp === "claude") { - await syncClaudePlugin(id, true); + // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 + if (provider.sortIndex !== undefined) { + const updates = Object.values(providers) + .filter( + (p) => + p.sortIndex !== undefined && + p.sortIndex >= newSortIndex! && + p.id !== provider.id, + ) + .map((p) => ({ + id: p.id, + sortIndex: p.sortIndex! + 1, + })); + + // 先更新现有供应商的 sortIndex,为新供应商腾出位置 + if (updates.length > 0) { + try { + await providersApi.updateSortOrder(updates, activeApp); + } catch (error) { + console.error("[App] Failed to update sort order", error); + toast.error( + t("provider.sortUpdateFailed", { + defaultValue: "排序更新失败", + }), + ); + return; // 如果排序更新失败,不继续添加 } - } else { - showNotification(t("notifications.switchFailed"), "error"); } - } catch (error) { - const detail = extractErrorMessage(error); - const msg = detail - ? `${t("notifications.switchFailed")}: ${detail}` - : t("notifications.switchFailed"); - // 详细错误展示稍长时间,便于用户阅读 - showNotification(msg, "error", detail ? 6000 : 3000); } + + // 3️⃣ 添加复制的供应商 + await addProvider(duplicatedProvider); }; + // 导入配置成功后刷新 const handleImportSuccess = async () => { - await loadProviders(); + await refetch(); try { - await window.api.updateTrayMenu(); + await providersApi.updateTrayMenu(); } catch (error) { - console.error("[App] Failed to refresh tray menu after import", error); - } - }; - - // 自动从 live 导入一条默认供应商(仅首次初始化时) - const handleAutoImportDefault = async () => { - try { - const result = await window.api.importCurrentConfigAsDefault(activeApp); - - if (result.success) { - await loadProviders(); - showNotification(t("notifications.autoImported"), "success", 3000); - // 更新托盘菜单 - await window.api.updateTrayMenu(); - } - // 如果导入失败(比如没有现有配置),静默处理,不显示错误 - } catch (error) { - console.error(t("console.autoImportFailed"), error); - // 静默处理,不影响用户体验 + console.error("[App] Failed to refresh tray menu", error); } }; return ( -
- {/* 顶部导航区域 - 固定高度 */} -
-
-
+
+
+
+
CC Switch - + -
- - setIsSettingsOpen(true)} /> -
+ + + setIsSettingsOpen(true)} />
-
+
- - - - + +
- {/* 主内容区域 - 独立滚动 */}
-
-
- {/* 通知组件 - 相对于视窗定位 */} - {notification && ( -
- {notification.message} -
- )} - - -
+
+ setIsAddOpen(true)} + />
- {isAddModalOpen && ( - setIsAddModalOpen(false)} + + + { + if (!open) { + setEditingProvider(null); + } + }} + onSubmit={handleEditProvider} + appId={activeApp} + /> + + {usageProvider && ( + setUsageProvider(null)} + onSave={(script) => { + void saveUsageScript(usageProvider, script); + }} /> )} - {editingProviderId && providers[editingProviderId] && ( - setEditingProviderId(null)} - /> - )} + void handleConfirmDelete()} + onCancel={() => setConfirmDelete(null)} + /> - {confirmDialog && ( - setConfirmDialog(null)} - /> - )} + - {isSettingsOpen && ( - setIsSettingsOpen(false)} - onImportSuccess={handleImportSuccess} - onNotify={showNotification} - /> - )} - - {isMcpOpen && ( - setIsMcpOpen(false)} - onNotify={showNotification} - /> - )} +
); } diff --git a/src/components/AddProviderModal.tsx b/src/components/AddProviderModal.tsx deleted file mode 100644 index 3b443ec..0000000 --- a/src/components/AddProviderModal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Provider } from "../types"; -import { AppType } from "../lib/tauri-api"; -import ProviderForm from "./ProviderForm"; - -interface AddProviderModalProps { - appType: AppType; - onAdd: (provider: Omit) => void; - onClose: () => void; -} - -const AddProviderModal: React.FC = ({ - appType, - onAdd, - onClose, -}) => { - const { t } = useTranslation(); - - const title = - appType === "claude" - ? t("provider.addClaudeProvider") - : t("provider.addCodexProvider"); - - return ( - - ); -}; - -export default AddProviderModal; diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 5c21cd5..76a2ef6 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -1,19 +1,19 @@ -import { AppType } from "../lib/tauri-api"; +import type { AppId } from "@/lib/api"; import { ClaudeIcon, CodexIcon } from "./BrandIcons"; interface AppSwitcherProps { - activeApp: AppType; - onSwitch: (app: AppType) => void; + activeApp: AppId; + onSwitch: (app: AppId) => void; } export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { - const handleSwitch = (app: AppType) => { + const handleSwitch = (app: AppId) => { if (app === activeApp) return; onSwitch(app); }; return ( -
+
-
- - {/* Content */} -
-

+

{ + if (!open) { + onCancel(); + } + }} + > + + + + + {title} + + {message} -

-
- - {/* Actions */} -
- - + -
-
-
+ + + + ); -}; +} diff --git a/src/components/EditProviderModal.tsx b/src/components/EditProviderModal.tsx deleted file mode 100644 index d2e4c03..0000000 --- a/src/components/EditProviderModal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Provider } from "../types"; -import { AppType } from "../lib/tauri-api"; -import ProviderForm from "./ProviderForm"; - -interface EditProviderModalProps { - appType: AppType; - provider: Provider; - onSave: (provider: Provider) => void; - onClose: () => void; -} - -const EditProviderModal: React.FC = ({ - appType, - provider, - onSave, - onClose, -}) => { - const { t } = useTranslation(); - const [effectiveProvider, setEffectiveProvider] = - useState(provider); - - // 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值(Claude/Codex 均适用) - useEffect(() => { - let mounted = true; - const maybeLoadLive = async () => { - try { - const currentId = await window.api.getCurrentProvider(appType); - if (currentId && currentId === provider.id) { - const live = await window.api.getLiveProviderSettings(appType); - if (!mounted) return; - setEffectiveProvider({ ...provider, settingsConfig: live }); - } else { - setEffectiveProvider(provider); - } - } catch (e) { - // 读取失败则回退到原 provider - setEffectiveProvider(provider); - } - }; - maybeLoadLive(); - return () => { - mounted = false; - }; - }, [appType, provider]); - - const handleSubmit = (data: Omit) => { - onSave({ - ...provider, - ...data, - }); - }; - - const title = - appType === "claude" - ? t("provider.editClaudeProvider") - : t("provider.editCodexProvider"); - - return ( - - ); -}; - -export default EditProviderModal; diff --git a/src/components/ImportProgressModal.tsx b/src/components/ImportProgressModal.tsx deleted file mode 100644 index 186eba3..0000000 --- a/src/components/ImportProgressModal.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect } from "react"; -import { CheckCircle, Loader2, AlertCircle } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -interface ImportProgressModalProps { - status: "importing" | "success" | "error"; - message?: string; - backupId?: string; - onComplete?: () => void; - onSuccess?: () => void; -} - -export function ImportProgressModal({ - status, - message, - backupId, - onComplete, - onSuccess, -}: ImportProgressModalProps) { - const { t } = useTranslation(); - - useEffect(() => { - if (status === "success") { - console.log( - "[ImportProgressModal] Success detected, starting 2 second countdown", - ); - // 成功后等待2秒自动关闭并刷新数据 - const timer = setTimeout(() => { - console.log( - "[ImportProgressModal] 2 seconds elapsed, calling callbacks...", - ); - if (onSuccess) { - onSuccess(); - } - if (onComplete) { - onComplete(); - } - }, 2000); - - return () => { - console.log("[ImportProgressModal] Cleanup timer"); - clearTimeout(timer); - }; - } - }, [status, onComplete, onSuccess]); - - return ( -
-
- -
-
- {status === "importing" && ( - <> - -

- {t("settings.importing")} -

-

- {t("common.loading")} -

- - )} - - {status === "success" && ( - <> - -

- {t("settings.importSuccess")} -

- {backupId && ( -

- {t("settings.backupId")}: {backupId} -

- )} -

- {t("settings.autoReload")} -

- - )} - - {status === "error" && ( - <> - -

- {t("settings.importFailed")} -

-

- {message || t("settings.configCorrupted")} -

- - - )} -
-
-
- ); -} diff --git a/src/components/JsonEditor.tsx b/src/components/JsonEditor.tsx index 6da1f8c..8f16ca7 100644 --- a/src/components/JsonEditor.tsx +++ b/src/components/JsonEditor.tsx @@ -78,6 +78,20 @@ const JsonEditor: React.FC = ({ // 创建编辑器扩展 const minHeightPx = height ? undefined : Math.max(1, rows) * 18; + + // 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题 + const baseTheme = EditorView.baseTheme({ + "&light .cm-editor, &dark .cm-editor": { + border: "1px solid hsl(var(--border))", + borderRadius: "0.5rem", + }, + "&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": { + outline: "none", + borderColor: "hsl(var(--primary))", + }, + }); + + // 使用 theme 定义尺寸和字体样式 const sizingTheme = EditorView.theme({ "&": height ? { height } : { minHeight: `${minHeightPx}px` }, ".cm-scroller": { overflow: "auto" }, @@ -92,6 +106,7 @@ const JsonEditor: React.FC = ({ basicSetup, language === "javascript" ? javascript() : json(), placeholder(placeholderText || ""), + baseTheme, sizingTheme, jsonLinter, EditorView.updateListener.of((update) => { @@ -105,6 +120,19 @@ const JsonEditor: React.FC = ({ // 如果启用深色模式,添加深色主题 if (darkMode) { extensions.push(oneDark); + // 在 oneDark 之后强制覆盖边框样式 + extensions.push( + EditorView.theme({ + ".cm-editor": { + border: "1px solid hsl(var(--border))", + borderRadius: "0.5rem", + }, + ".cm-editor.cm-focused": { + outline: "none", + borderColor: "hsl(var(--primary))", + }, + }), + ); } // 创建初始状态 diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx deleted file mode 100644 index 39555a8..0000000 --- a/src/components/ProviderForm.tsx +++ /dev/null @@ -1,1941 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Provider, ProviderCategory, CustomEndpoint } from "../types"; -import { AppType } from "../lib/tauri-api"; -import { - updateCommonConfigSnippet, - hasCommonConfigSnippet, - getApiKeyFromConfig, - hasApiKeyField, - setApiKeyInConfig, - updateTomlCommonConfigSnippet, - hasTomlCommonConfigSnippet, - validateJsonConfig, - applyTemplateValues, - extractCodexBaseUrl, - setCodexBaseUrl as setCodexBaseUrlInConfig, -} from "../utils/providerConfigUtils"; -import { providerPresets } from "../config/providerPresets"; -import type { TemplateValueConfig } from "../config/providerPresets"; -import { - codexProviderPresets, - generateThirdPartyAuth, - generateThirdPartyConfig, -} from "../config/codexProviderPresets"; -import PresetSelector from "./ProviderForm/PresetSelector"; -import ApiKeyInput from "./ProviderForm/ApiKeyInput"; -import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor"; -import CodexConfigEditor from "./ProviderForm/CodexConfigEditor"; -import KimiModelSelector from "./ProviderForm/KimiModelSelector"; -import { X, AlertCircle, Save, Zap } from "lucide-react"; -import { isLinux } from "../lib/platform"; -import EndpointSpeedTest, { - EndpointCandidate, -} from "./ProviderForm/EndpointSpeedTest"; -// 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件 - -type TemplateValueMap = Record; - -type TemplatePath = Array; - -const collectTemplatePaths = ( - source: unknown, - templateKeys: string[], - currentPath: TemplatePath = [], - acc: TemplatePath[] = [], -): TemplatePath[] => { - if (typeof source === "string") { - const hasPlaceholder = templateKeys.some((key) => - source.includes(`\${${key}}`), - ); - if (hasPlaceholder) { - acc.push([...currentPath]); - } - return acc; - } - - if (Array.isArray(source)) { - source.forEach((item, index) => - collectTemplatePaths(item, templateKeys, [...currentPath, index], acc), - ); - return acc; - } - - if (source && typeof source === "object") { - Object.entries(source).forEach(([key, value]) => - collectTemplatePaths(value, templateKeys, [...currentPath, key], acc), - ); - } - - return acc; -}; - -const getValueAtPath = (source: any, path: TemplatePath) => { - return path.reduce((acc, key) => { - if (acc === undefined || acc === null) { - return undefined; - } - return acc[key as keyof typeof acc]; - }, source); -}; - -const setValueAtPath = ( - target: any, - path: TemplatePath, - value: unknown, -): any => { - if (path.length === 0) { - return value; - } - - let current = target; - - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]; - const nextKey = path[i + 1]; - const isNextIndex = typeof nextKey === "number"; - - if (current[key as keyof typeof current] === undefined) { - current[key as keyof typeof current] = isNextIndex ? [] : {}; - } else { - const currentValue = current[key as keyof typeof current]; - if (isNextIndex && !Array.isArray(currentValue)) { - current[key as keyof typeof current] = []; - } else if ( - !isNextIndex && - (typeof currentValue !== "object" || currentValue === null) - ) { - current[key as keyof typeof current] = {}; - } - } - - current = current[key as keyof typeof current]; - } - - const finalKey = path[path.length - 1]; - current[finalKey as keyof typeof current] = value; - return target; -}; - -const applyTemplateValuesToConfigString = ( - presetConfig: any, - currentConfigString: string, - values: TemplateValueMap, -) => { - const replacedConfig = applyTemplateValues(presetConfig, values); - const templateKeys = Object.keys(values); - if (templateKeys.length === 0) { - return JSON.stringify(replacedConfig, null, 2); - } - - const placeholderPaths = collectTemplatePaths(presetConfig, templateKeys); - - try { - const parsedConfig = currentConfigString.trim() - ? JSON.parse(currentConfigString) - : {}; - let targetConfig: any; - if (Array.isArray(parsedConfig)) { - targetConfig = [...parsedConfig]; - } else if (parsedConfig && typeof parsedConfig === "object") { - targetConfig = JSON.parse(JSON.stringify(parsedConfig)); - } else { - targetConfig = {}; - } - - if (placeholderPaths.length === 0) { - return JSON.stringify(targetConfig, null, 2); - } - - let mutatedConfig = targetConfig; - - for (const path of placeholderPaths) { - const nextValue = getValueAtPath(replacedConfig, path); - if (path.length === 0) { - mutatedConfig = nextValue; - } else { - setValueAtPath(mutatedConfig, path, nextValue); - } - } - - return JSON.stringify(mutatedConfig, null, 2); - } catch { - return JSON.stringify(replacedConfig, null, 2); - } -}; - -const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet"; -const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; -const DEFAULT_COMMON_CONFIG_SNIPPET = `{ - "includeCoAuthoredBy": false -}`; -const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config -# Add your common TOML configuration here`; - -interface ProviderFormProps { - appType?: AppType; - title: string; - submitText: string; - initialData?: Provider; - showPresets?: boolean; - onSubmit: (data: Omit) => void; - onClose: () => void; -} - -const ProviderForm: React.FC = ({ - appType = "claude", - title, - submitText, - initialData, - showPresets = false, - onSubmit, - onClose, -}) => { - const { t } = useTranslation(); - // 对于 Codex,需要分离 auth 和 config - const isCodex = appType === "codex"; - - const [formData, setFormData] = useState({ - name: initialData?.name || "", - websiteUrl: initialData?.websiteUrl || "", - settingsConfig: initialData - ? JSON.stringify(initialData.settingsConfig, null, 2) - : "", - }); - const [category, setCategory] = useState( - initialData?.category, - ); - - // Claude 模型配置状态 - const [claudeModel, setClaudeModel] = useState(""); - const [claudeSmallFastModel, setClaudeSmallFastModel] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态 - // 模板变量状态 - const [templateValues, setTemplateValues] = useState< - Record - >({}); - - // Codex 特有的状态 - const [codexAuth, setCodexAuthState] = useState(""); - const [codexConfig, setCodexConfigState] = useState(""); - const [codexApiKey, setCodexApiKey] = useState(""); - const [codexBaseUrl, setCodexBaseUrl] = useState(""); - const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] = - useState(false); - // 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints - const [draftCustomEndpoints, setDraftCustomEndpoints] = useState( - [], - ); - // 端点测速弹窗状态 - const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); - const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = - useState(false); - // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 - const [selectedCodexPreset, setSelectedCodexPreset] = useState( - showPresets && isCodex ? -1 : null, - ); - - const setCodexAuth = (value: string) => { - setCodexAuthState(value); - setCodexAuthError(validateCodexAuth(value)); - }; - - const setCodexConfig = (value: string | ((prev: string) => string)) => { - setCodexConfigState((prev) => - typeof value === "function" - ? (value as (input: string) => string)(prev) - : value, - ); - }; - - const setCodexCommonConfigSnippet = (value: string) => { - setCodexCommonConfigSnippetState(value); - }; - - // 初始化 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 || ""); - const initialBaseUrl = extractCodexBaseUrl(config.config); - if (initialBaseUrl) { - setCodexBaseUrl(initialBaseUrl); - } - 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 [useCommonConfig, setUseCommonConfig] = useState(false); - const [commonConfigSnippet, setCommonConfigSnippet] = useState(() => { - if (typeof window === "undefined") { - return DEFAULT_COMMON_CONFIG_SNIPPET; - } - try { - const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY); - if (stored && stored.trim()) { - return stored; - } - } catch { - // ignore localStorage 读取失败 - } - return DEFAULT_COMMON_CONFIG_SNIPPET; - }); - const [commonConfigError, setCommonConfigError] = useState(""); - const [settingsConfigError, setSettingsConfigError] = useState(""); - // 用于跟踪是否正在通过通用配置更新 - const isUpdatingFromCommonConfig = useRef(false); - - // Codex 通用配置状态 - const [useCodexCommonConfig, setUseCodexCommonConfig] = useState(false); - const [codexCommonConfigSnippet, setCodexCommonConfigSnippetState] = - useState(() => { - if (typeof window === "undefined") { - return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim(); - } - try { - const stored = window.localStorage.getItem( - CODEX_COMMON_CONFIG_STORAGE_KEY, - ); - if (stored && stored.trim()) { - return stored.trim(); - } - } catch { - // ignore localStorage 读取失败 - } - return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim(); - }); - const [codexCommonConfigError, setCodexCommonConfigError] = useState(""); - const isUpdatingFromCodexCommonConfig = useRef(false); - const isUpdatingBaseUrlRef = useRef(false); - const isUpdatingCodexBaseUrlRef = useRef(false); - - // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 - const [selectedPreset, setSelectedPreset] = useState( - showPresets ? -1 : null, - ); - const [apiKey, setApiKey] = useState(""); - const [codexAuthError, setCodexAuthError] = useState(""); - - // Kimi 模型选择状态 - const [kimiAnthropicModel, setKimiAnthropicModel] = useState(""); - const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] = - useState(""); - - const validateSettingsConfig = (value: string): string => { - const err = validateJsonConfig(value, "配置内容"); - return err ? t("providerForm.configJsonError") : ""; - }; - - const validateCodexAuth = (value: string): string => { - if (!value.trim()) return ""; - try { - const parsed = JSON.parse(value); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return t("providerForm.authJsonRequired"); - } - return ""; - } catch { - return t("providerForm.authJsonError"); - } - }; - - const updateSettingsConfigValue = (value: string) => { - setFormData((prev) => ({ - ...prev, - settingsConfig: value, - })); - setSettingsConfigError(validateSettingsConfig(value)); - }; - - // 初始化自定义模式的默认配置 - useEffect(() => { - if ( - showPresets && - selectedPreset === -1 && - !initialData && - formData.settingsConfig === "" - ) { - // 设置自定义模板 - const customTemplate = { - env: { - ANTHROPIC_BASE_URL: "https://your-api-endpoint.com", - ANTHROPIC_AUTH_TOKEN: "", - // 可选配置 - // ANTHROPIC_MODEL: "your-model-name", - // ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name" - }, - }; - const templateString = JSON.stringify(customTemplate, null, 2); - - updateSettingsConfigValue(templateString); - setApiKey(""); - } - }, []); // 只在组件挂载时执行一次 - - // 初始化时检查通用配置片段 - useEffect(() => { - if (initialData) { - if (!isCodex) { - const configString = JSON.stringify( - initialData.settingsConfig, - null, - 2, - ); - const hasCommon = hasCommonConfigSnippet( - configString, - commonConfigSnippet, - ); - setUseCommonConfig(hasCommon); - setSettingsConfigError(validateSettingsConfig(configString)); - - // 初始化模型配置(编辑模式) - if ( - initialData.settingsConfig && - typeof initialData.settingsConfig === "object" - ) { - const config = initialData.settingsConfig as { - env?: Record; - }; - if (config.env) { - setClaudeModel(config.env.ANTHROPIC_MODEL || ""); - setClaudeSmallFastModel( - config.env.ANTHROPIC_SMALL_FAST_MODEL || "", - ); - setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL - - // 初始化 Kimi 模型选择 - setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || ""); - setKimiAnthropicSmallFastModel( - config.env.ANTHROPIC_SMALL_FAST_MODEL || "", - ); - } - } - } else { - // Codex 初始化时检查 TOML 通用配置 - const hasCommon = hasTomlCommonConfigSnippet( - codexConfig, - codexCommonConfigSnippet, - ); - setUseCodexCommonConfig(hasCommon); - } - } - }, [ - initialData, - commonConfigSnippet, - codexCommonConfigSnippet, - isCodex, - codexConfig, - ]); - - // 当选择预设变化时,同步类别 - useEffect(() => { - if (!showPresets) return; - if (!isCodex) { - if (selectedPreset !== null && selectedPreset >= 0) { - const preset = providerPresets[selectedPreset]; - setCategory( - preset?.category || (preset?.isOfficial ? "official" : undefined), - ); - } else if (selectedPreset === -1) { - setCategory("custom"); - } - } else { - if (selectedCodexPreset !== null && selectedCodexPreset >= 0) { - const preset = codexProviderPresets[selectedCodexPreset]; - setCategory( - preset?.category || (preset?.isOfficial ? "official" : undefined), - ); - } else if (selectedCodexPreset === -1) { - setCategory("custom"); - } - } - }, [showPresets, isCodex, selectedPreset, selectedCodexPreset]); - - // 与 JSON 配置保持基础 URL 同步(Claude 第三方/自定义) - useEffect(() => { - if (isCodex) return; - const currentCategory = category ?? initialData?.category; - if (currentCategory !== "third_party" && currentCategory !== "custom") { - return; - } - if (isUpdatingBaseUrlRef.current) { - return; - } - try { - const config = JSON.parse(formData.settingsConfig || "{}"); - const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL; - if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) { - setBaseUrl(envUrl.trim()); - } - } catch { - // ignore JSON parse errors - } - }, [isCodex, category, initialData, formData.settingsConfig, baseUrl]); - - // 与 TOML 配置保持基础 URL 同步(Codex 第三方/自定义) - useEffect(() => { - if (!isCodex) return; - const currentCategory = category ?? initialData?.category; - if (currentCategory !== "third_party" && currentCategory !== "custom") { - return; - } - if (isUpdatingCodexBaseUrlRef.current) { - return; - } - const extracted = extractCodexBaseUrl(codexConfig) || ""; - if (extracted !== codexBaseUrl) { - setCodexBaseUrl(extracted); - } - }, [isCodex, category, initialData, codexConfig, codexBaseUrl]); - - // 同步本地存储的通用配置片段 - useEffect(() => { - if (typeof window === "undefined") return; - try { - if (commonConfigSnippet.trim()) { - window.localStorage.setItem( - COMMON_CONFIG_STORAGE_KEY, - commonConfigSnippet, - ); - } else { - window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY); - } - } catch { - // ignore - } - }, [commonConfigSnippet]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - - if (!formData.name) { - setError(t("providerForm.fillSupplierName")); - return; - } - - let settingsConfig: Record; - - if (isCodex) { - const currentAuthError = validateCodexAuth(codexAuth); - setCodexAuthError(currentAuthError); - if (currentAuthError) { - setError(currentAuthError); - return; - } - // Codex: 仅要求 auth.json 必填;config.toml 可为空 - if (!codexAuth.trim()) { - setError(t("providerForm.fillAuthJson")); - 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(t("providerForm.fillApiKey")); - return; - } - } - } - - settingsConfig = { - auth: authJson, - config: codexConfig ?? "", - }; - } catch (err) { - setError(t("providerForm.authJsonError")); - return; - } - } else { - const currentSettingsError = validateSettingsConfig( - formData.settingsConfig, - ); - setSettingsConfigError(currentSettingsError); - if (currentSettingsError) { - setError(t("providerForm.configJsonError")); - return; - } - - if (selectedTemplatePreset && templateValueEntries.length > 0) { - for (const [key, config] of templateValueEntries) { - const entry = templateValues[key]; - const resolvedValue = ( - entry?.editorValue ?? - entry?.defaultValue ?? - config.defaultValue ?? - "" - ).trim(); - if (!resolvedValue) { - setError(t("providerForm.fillParameter", { label: config.label })); - return; - } - } - } - // Claude: 原有逻辑 - if (!formData.settingsConfig.trim()) { - setError(t("providerForm.fillConfigContent")); - return; - } - - try { - settingsConfig = JSON.parse(formData.settingsConfig); - } catch (err) { - setError(t("providerForm.configJsonError")); - return; - } - } - - // 构造基础提交数据 - const basePayload: Omit = { - name: formData.name, - websiteUrl: formData.websiteUrl, - settingsConfig, - // 仅在用户选择了预设或手动选择“自定义”时持久化分类 - ...(category ? { category } : {}), - }; - - // 若为"新建供应商",将端点候选一并随提交落盘到 meta.custom_endpoints: - // - 用户在弹窗中新增的自定义端点(draftCustomEndpoints,已去重) - // - 预设中的 endpointCandidates(若存在) - // - 当前选中的基础 URL(baseUrl/codexBaseUrl) - if (!initialData) { - const urlSet = new Set(); - const push = (raw?: string) => { - const url = (raw || "").trim().replace(/\/+$/, ""); - if (url) urlSet.add(url); - }; - - // 自定义端点(仅来自用户新增) - for (const u of draftCustomEndpoints) push(u); - - // 预设端点候选 - if (!isCodex) { - if ( - selectedPreset !== null && - selectedPreset >= 0 && - selectedPreset < providerPresets.length - ) { - const preset = providerPresets[selectedPreset] as any; - if (Array.isArray(preset?.endpointCandidates)) { - for (const u of preset.endpointCandidates as string[]) push(u); - } - } - // 当前 Claude 基础地址 - push(baseUrl); - } else { - if ( - selectedCodexPreset !== null && - selectedCodexPreset >= 0 && - selectedCodexPreset < codexProviderPresets.length - ) { - const preset = codexProviderPresets[selectedCodexPreset] as any; - if (Array.isArray(preset?.endpointCandidates)) { - for (const u of preset.endpointCandidates as string[]) push(u); - } - } - // 当前 Codex 基础地址 - push(codexBaseUrl); - } - - const urls = Array.from(urlSet.values()); - if (urls.length > 0) { - const now = Date.now(); - const customMap: Record = {}; - for (const url of urls) { - if (!customMap[url]) { - customMap[url] = { url, addedAt: now, lastUsed: undefined }; - } - } - onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } }); - return; - } - } - - onSubmit(basePayload); - }; - - const handleChange = ( - e: React.ChangeEvent, - ) => { - const { name, value } = e.target; - - if (name === "settingsConfig") { - // 只有在不是通过通用配置更新时,才检查并同步选择框状态 - if (!isUpdatingFromCommonConfig.current) { - const hasCommon = hasCommonConfigSnippet(value, commonConfigSnippet); - setUseCommonConfig(hasCommon); - } - - // 同步 API Key 输入框显示与值 - const parsedKey = getApiKeyFromConfig(value); - setApiKey(parsedKey); - - // 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容 - updateSettingsConfigValue(value); - } else { - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - } - }; - - // 处理通用配置开关 - const handleCommonConfigToggle = (checked: boolean) => { - const { updatedConfig, error: snippetError } = updateCommonConfigSnippet( - formData.settingsConfig, - commonConfigSnippet, - checked, - ); - - if (snippetError) { - setCommonConfigError(snippetError); - if (snippetError.includes("配置 JSON 解析失败")) { - setSettingsConfigError(t("providerForm.configJsonError")); - } - setUseCommonConfig(false); - return; - } - - setCommonConfigError(""); - setUseCommonConfig(checked); - // 标记正在通过通用配置更新 - isUpdatingFromCommonConfig.current = true; - updateSettingsConfigValue(updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - }; - - const handleCommonConfigSnippetChange = (value: string) => { - const previousSnippet = commonConfigSnippet; - setCommonConfigSnippet(value); - - if (!value.trim()) { - setCommonConfigError(""); - if (useCommonConfig) { - const { updatedConfig } = updateCommonConfigSnippet( - formData.settingsConfig, - previousSnippet, - false, - ); - // 直接更新 formData,不通过 handleChange - updateSettingsConfigValue(updatedConfig); - setUseCommonConfig(false); - } - return; - } - - // 验证JSON格式 - const validationError = validateJsonConfig(value, "通用配置片段"); - if (validationError) { - setCommonConfigError(validationError); - } else { - setCommonConfigError(""); - } - - // 若当前启用通用配置且格式正确,需要替换为最新片段 - if (useCommonConfig && !validationError) { - const removeResult = updateCommonConfigSnippet( - formData.settingsConfig, - previousSnippet, - false, - ); - if (removeResult.error) { - setCommonConfigError(removeResult.error); - if (removeResult.error.includes("配置 JSON 解析失败")) { - setSettingsConfigError(t("providerForm.configJsonError")); - } - return; - } - const addResult = updateCommonConfigSnippet( - removeResult.updatedConfig, - value, - true, - ); - - if (addResult.error) { - setCommonConfigError(addResult.error); - if (addResult.error.includes("配置 JSON 解析失败")) { - setSettingsConfigError(t("providerForm.configJsonError")); - } - return; - } - - // 标记正在通过通用配置更新,避免触发状态检查 - isUpdatingFromCommonConfig.current = true; - updateSettingsConfigValue(addResult.updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } - - // 保存通用配置到 localStorage - if (!validationError && typeof window !== "undefined") { - try { - window.localStorage.setItem(COMMON_CONFIG_STORAGE_KEY, value); - } catch { - // ignore localStorage 写入失败 - } - } - }; - - const applyPreset = (preset: (typeof providerPresets)[0], index: number) => { - let appliedSettingsConfig = preset.settingsConfig; - let initialTemplateValues: TemplateValueMap = {}; - - if (preset.templateValues) { - initialTemplateValues = Object.fromEntries( - Object.entries(preset.templateValues).map(([key, config]) => [ - key, - { - ...config, - editorValue: config.editorValue - ? config.editorValue - : (config.defaultValue ?? ""), - }, - ]), - ); - appliedSettingsConfig = applyTemplateValues( - preset.settingsConfig, - initialTemplateValues, - ); - } - - setTemplateValues(initialTemplateValues); - - const configString = JSON.stringify(appliedSettingsConfig, null, 2); - - setFormData({ - name: preset.name, - websiteUrl: preset.websiteUrl, - settingsConfig: configString, - }); - setSettingsConfigError(validateSettingsConfig(configString)); - setCategory( - preset.category || (preset.isOfficial ? "official" : undefined), - ); - - // 设置选中的预设 - setSelectedPreset(index); - - // 清空 API Key 输入框,让用户重新输入 - setApiKey(""); - - // 同步通用配置状态 - const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet); - setUseCommonConfig(hasCommon); - setCommonConfigError(""); - - // 如果预设包含模型配置,初始化模型输入框 - if (appliedSettingsConfig && typeof appliedSettingsConfig === "object") { - const config = appliedSettingsConfig as { env?: Record }; - if (config.env) { - setClaudeModel(config.env.ANTHROPIC_MODEL || ""); - setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || ""); - const presetBaseUrl = - typeof config.env.ANTHROPIC_BASE_URL === "string" - ? config.env.ANTHROPIC_BASE_URL - : ""; - setBaseUrl(presetBaseUrl); - - // 如果是 Kimi 预设,同步 Kimi 模型选择 - if (preset.name?.includes("Kimi")) { - setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || ""); - setKimiAnthropicSmallFastModel( - config.env.ANTHROPIC_SMALL_FAST_MODEL || "", - ); - } - } else { - setClaudeModel(""); - setClaudeSmallFastModel(""); - setBaseUrl(""); - } - } - }; - - // 处理点击自定义按钮 - const handleCustomClick = () => { - setSelectedPreset(-1); - setTemplateValues({}); - - // 设置自定义模板 - const customTemplate = { - env: { - ANTHROPIC_BASE_URL: "https://your-api-endpoint.com", - ANTHROPIC_AUTH_TOKEN: "", - // 可选配置 - // ANTHROPIC_MODEL: "your-model-name", - // ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name" - }, - }; - const templateString = JSON.stringify(customTemplate, null, 2); - - setFormData({ - name: "", - websiteUrl: "", - settingsConfig: templateString, - }); - setSettingsConfigError(validateSettingsConfig(templateString)); - setApiKey(""); - setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL - setUseCommonConfig(false); - setCommonConfigError(""); - setClaudeModel(""); - setClaudeSmallFastModel(""); - setKimiAnthropicModel(""); - setKimiAnthropicSmallFastModel(""); - setCategory("custom"); - }; - - // Codex: 应用预设 - const applyCodexPreset = ( - preset: (typeof codexProviderPresets)[0], - index: number, - ) => { - const authString = JSON.stringify(preset.auth || {}, null, 2); - setCodexAuth(authString); - setCodexConfig(preset.config || ""); - const presetBaseUrl = extractCodexBaseUrl(preset.config); - if (presetBaseUrl) { - setCodexBaseUrl(presetBaseUrl); - } - - setFormData((prev) => ({ - ...prev, - name: preset.name, - websiteUrl: preset.websiteUrl, - })); - - setSelectedCodexPreset(index); - setCategory( - preset.category || (preset.isOfficial ? "official" : undefined), - ); - - // 清空 API Key,让用户重新输入 - setCodexApiKey(""); - }; - - // Codex: 处理点击自定义按钮 - const handleCodexCustomClick = () => { - setSelectedCodexPreset(-1); - - // 设置自定义模板 - const customAuth = generateThirdPartyAuth(""); - const customConfig = generateThirdPartyConfig( - "custom", - "https://your-api-endpoint.com/v1", - "gpt-5-codex", - ); - - setFormData({ - name: "", - websiteUrl: "", - settingsConfig: "", - }); - setSettingsConfigError(validateSettingsConfig("")); - setCodexAuth(JSON.stringify(customAuth, null, 2)); - setCodexConfig(customConfig); - setCodexApiKey(""); - setCodexBaseUrl("https://your-api-endpoint.com/v1"); - setCategory("custom"); - }; - - // 处理 API Key 输入并自动更新配置 - const handleApiKeyChange = (key: string) => { - setApiKey(key); - - const configString = setApiKeyInConfig( - formData.settingsConfig, - key.trim(), - { createIfMissing: selectedPreset !== null && selectedPreset !== -1 }, - ); - - // 更新表单配置 - updateSettingsConfigValue(configString); - - // 同步通用配置开关 - const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet); - setUseCommonConfig(hasCommon); - }; - - // 处理基础 URL 变化 - const handleBaseUrlChange = (url: string) => { - const sanitized = url.trim().replace(/\/+$/, ""); - setBaseUrl(sanitized); - isUpdatingBaseUrlRef.current = true; - - try { - const config = JSON.parse(formData.settingsConfig || "{}"); - if (!config.env) { - config.env = {}; - } - config.env.ANTHROPIC_BASE_URL = sanitized; - - updateSettingsConfigValue(JSON.stringify(config, null, 2)); - } catch { - // ignore - } finally { - setTimeout(() => { - isUpdatingBaseUrlRef.current = false; - }, 0); - } - }; - - const handleCodexBaseUrlChange = (url: string) => { - const sanitized = url.trim().replace(/\/+$/, ""); - setCodexBaseUrl(sanitized); - - if (!sanitized) { - return; - } - - isUpdatingCodexBaseUrlRef.current = true; - setCodexConfig((prev) => setCodexBaseUrlInConfig(prev, sanitized)); - setTimeout(() => { - isUpdatingCodexBaseUrlRef.current = false; - }, 0); - }; - - // 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 - } - }; - - // Codex: 处理通用配置开关 - const handleCodexCommonConfigToggle = (checked: boolean) => { - const snippet = codexCommonConfigSnippet.trim(); - const { updatedConfig, error: snippetError } = - updateTomlCommonConfigSnippet(codexConfig, snippet, checked); - - if (snippetError) { - setCodexCommonConfigError(snippetError); - setUseCodexCommonConfig(false); - return; - } - - setCodexCommonConfigError(""); - setUseCodexCommonConfig(checked); - // 标记正在通过通用配置更新 - isUpdatingFromCodexCommonConfig.current = true; - setCodexConfig(updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCodexCommonConfig.current = false; - }, 0); - }; - - // Codex: 处理通用配置片段变化 - const handleCodexCommonConfigSnippetChange = (value: string) => { - const previousSnippet = codexCommonConfigSnippet.trim(); - const sanitizedValue = value.trim(); - setCodexCommonConfigSnippet(value); - - if (!sanitizedValue) { - setCodexCommonConfigError(""); - if (useCodexCommonConfig) { - const { updatedConfig } = updateTomlCommonConfigSnippet( - codexConfig, - previousSnippet, - false, - ); - setCodexConfig(updatedConfig); - setUseCodexCommonConfig(false); - } - return; - } - - // TOML 不需要验证 JSON 格式,直接更新 - if (useCodexCommonConfig) { - const removeResult = updateTomlCommonConfigSnippet( - codexConfig, - previousSnippet, - false, - ); - const addResult = updateTomlCommonConfigSnippet( - removeResult.updatedConfig, - sanitizedValue, - true, - ); - - if (addResult.error) { - setCodexCommonConfigError(addResult.error); - return; - } - - // 标记正在通过通用配置更新 - isUpdatingFromCodexCommonConfig.current = true; - setCodexConfig(addResult.updatedConfig); - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCodexCommonConfig.current = false; - }, 0); - } - - // 保存 Codex 通用配置到 localStorage - if (typeof window !== "undefined") { - try { - window.localStorage.setItem( - CODEX_COMMON_CONFIG_STORAGE_KEY, - sanitizedValue, - ); - } catch { - // ignore localStorage 写入失败 - } - } - }; - - // Codex: 处理 config 变化 - const handleCodexConfigChange = (value: string) => { - if (!isUpdatingFromCodexCommonConfig.current) { - const hasCommon = hasTomlCommonConfigSnippet( - value, - codexCommonConfigSnippet, - ); - setUseCodexCommonConfig(hasCommon); - } - setCodexConfig(value); - if (!isUpdatingCodexBaseUrlRef.current) { - const extracted = extractCodexBaseUrl(value) || ""; - if (extracted !== codexBaseUrl) { - setCodexBaseUrl(extracted); - } - } - }; - - // 根据当前配置决定是否展示 API Key 输入框 - // 自定义模式(-1)也需要显示 API Key 输入框 - const showApiKey = - selectedPreset !== null || - (!showPresets && hasApiKeyField(formData.settingsConfig)); - - const normalizedCategory = category ?? initialData?.category; - const shouldShowSpeedTest = - normalizedCategory === "third_party" || normalizedCategory === "custom"; - - const selectedTemplatePreset = - !isCodex && - selectedPreset !== null && - selectedPreset >= 0 && - selectedPreset < providerPresets.length - ? providerPresets[selectedPreset] - : null; - - const templateValueEntries: Array<[string, TemplateValueConfig]> = - selectedTemplatePreset?.templateValues - ? (Object.entries(selectedTemplatePreset.templateValues) as Array< - [string, TemplateValueConfig] - >) - : []; - - // 判断当前选中的预设是否是官方 - const isOfficialPreset = - (selectedPreset !== null && - selectedPreset >= 0 && - (providerPresets[selectedPreset]?.isOfficial === true || - providerPresets[selectedPreset]?.category === "official")) || - category === "official"; - - // 判断当前选中的预设是否是 Kimi - const isKimiPreset = - selectedPreset !== null && - selectedPreset >= 0 && - providerPresets[selectedPreset]?.name?.includes("Kimi"); - - // 判断当前编辑的是否是 Kimi 提供商(通过名称或配置判断) - const isEditingKimi = - initialData && - (formData.name.includes("Kimi") || - formData.name.includes("kimi") || - (formData.settingsConfig.includes("api.moonshot.cn") && - formData.settingsConfig.includes("ANTHROPIC_MODEL"))); - - // 综合判断是否应该显示 Kimi 模型选择器 - const shouldShowKimiSelector = isKimiPreset || isEditingKimi; - - const claudeSpeedTestEndpoints = useMemo(() => { - if (isCodex) return []; - const map = new Map(); - const add = (url?: string) => { - if (!url) return; - const sanitized = url.trim().replace(/\/+$/, ""); - if (!sanitized || map.has(sanitized)) return; - map.set(sanitized, { url: sanitized }); - }; - - if (baseUrl) { - add(baseUrl); - } - - if (initialData && typeof initialData.settingsConfig === "object") { - const envUrl = (initialData.settingsConfig as any)?.env - ?.ANTHROPIC_BASE_URL; - if (typeof envUrl === "string") { - add(envUrl); - } - } - - if ( - selectedPreset !== null && - selectedPreset >= 0 && - selectedPreset < providerPresets.length - ) { - const preset = providerPresets[selectedPreset]; - const presetEnv = (preset.settingsConfig as any)?.env?.ANTHROPIC_BASE_URL; - if (typeof presetEnv === "string") { - add(presetEnv); - } - // 合并预设内置的请求地址候选 - if (Array.isArray((preset as any).endpointCandidates)) { - ((preset as any).endpointCandidates as string[]).forEach((u) => add(u)); - } - } - - return Array.from(map.values()); - }, [isCodex, baseUrl, initialData, selectedPreset]); - - const codexSpeedTestEndpoints = useMemo(() => { - if (!isCodex) return []; - const map = new Map(); - const add = (url?: string) => { - if (!url) return; - const sanitized = url.trim().replace(/\/+$/, ""); - if (!sanitized || map.has(sanitized)) return; - map.set(sanitized, { url: sanitized }); - }; - - if (codexBaseUrl) { - add(codexBaseUrl); - } - - const initialCodexConfig = - initialData && typeof initialData.settingsConfig?.config === "string" - ? (initialData.settingsConfig as any).config - : ""; - const existing = extractCodexBaseUrl(initialCodexConfig); - if (existing) { - add(existing); - } - - if ( - selectedCodexPreset !== null && - selectedCodexPreset >= 0 && - selectedCodexPreset < codexProviderPresets.length - ) { - const preset = codexProviderPresets[selectedCodexPreset]; - const presetBase = extractCodexBaseUrl(preset?.config || ""); - if (presetBase) { - add(presetBase); - } - // 合并预设内置的请求地址候选 - if (Array.isArray((preset as any)?.endpointCandidates)) { - ((preset as any).endpointCandidates as string[]).forEach((u) => add(u)); - } - } - - return Array.from(map.values()); - }, [isCodex, codexBaseUrl, initialData, selectedCodexPreset]); - - // 判断是否显示"获取 API Key"链接(国产官方、聚合站和第三方显示) - const shouldShowApiKeyLink = - !isCodex && - !isOfficialPreset && - (category === "cn_official" || - category === "aggregator" || - category === "third_party" || - (selectedPreset !== null && - selectedPreset >= 0 && - (providerPresets[selectedPreset]?.category === "cn_official" || - providerPresets[selectedPreset]?.category === "aggregator" || - providerPresets[selectedPreset]?.category === "third_party"))); - - // 获取当前供应商的网址 - const getCurrentWebsiteUrl = () => { - if (selectedPreset !== null && selectedPreset >= 0) { - const preset = providerPresets[selectedPreset]; - if (!preset) return ""; - // 仅第三方供应商使用专用 apiKeyUrl,其余使用官网地址 - return preset.category === "third_party" - ? preset.apiKeyUrl || preset.websiteUrl || "" - : preset.websiteUrl || ""; - } - return formData.websiteUrl || ""; - }; - - // 获取 Codex 当前供应商的网址 - const getCurrentCodexWebsiteUrl = () => { - if (selectedCodexPreset !== null && selectedCodexPreset >= 0) { - const preset = codexProviderPresets[selectedCodexPreset]; - if (!preset) return ""; - // 仅第三方供应商使用专用 apiKeyUrl,其余使用官网地址 - return preset.category === "third_party" - ? preset.apiKeyUrl || preset.websiteUrl || "" - : preset.websiteUrl || ""; - } - return formData.websiteUrl || ""; - }; - - // 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 || - codexProviderPresets[selectedCodexPreset]?.category === "official")) || - category === "official"; - - // 判断是否显示 Codex 的"获取 API Key"链接(国产官方、聚合站和第三方显示) - const shouldShowCodexApiKeyLink = - isCodex && - !isCodexOfficialPreset && - (category === "cn_official" || - category === "aggregator" || - category === "third_party" || - (selectedCodexPreset !== null && - selectedCodexPreset >= 0 && - (codexProviderPresets[selectedCodexPreset]?.category === - "cn_official" || - codexProviderPresets[selectedCodexPreset]?.category === - "aggregator" || - codexProviderPresets[selectedCodexPreset]?.category === - "third_party"))); - - // 处理模型输入变化,自动更新 JSON 配置 - const handleModelChange = ( - field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL", - value: string, - ) => { - if (field === "ANTHROPIC_MODEL") { - setClaudeModel(value); - } else { - setClaudeSmallFastModel(value); - } - - // 更新 JSON 配置 - try { - const currentConfig = formData.settingsConfig - ? JSON.parse(formData.settingsConfig) - : { env: {} }; - if (!currentConfig.env) currentConfig.env = {}; - - if (value.trim()) { - currentConfig.env[field] = value.trim(); - } else { - delete currentConfig.env[field]; - } - - updateSettingsConfigValue(JSON.stringify(currentConfig, null, 2)); - } catch (err) { - // 如果 JSON 解析失败,不做处理 - } - }; - - // Kimi 模型选择处理函数 - const handleKimiModelChange = ( - field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL", - value: string, - ) => { - if (field === "ANTHROPIC_MODEL") { - setKimiAnthropicModel(value); - } else { - setKimiAnthropicSmallFastModel(value); - } - - // 更新配置 JSON - try { - const currentConfig = JSON.parse(formData.settingsConfig || "{}"); - if (!currentConfig.env) currentConfig.env = {}; - currentConfig.env[field] = value; - - const updatedConfigString = JSON.stringify(currentConfig, null, 2); - updateSettingsConfigValue(updatedConfigString); - } catch (err) { - console.error("更新 Kimi 模型配置失败:", err); - } - }; - - // 初始时从配置中同步 API Key(编辑模式) - useEffect(() => { - if (!initialData) return; - const parsedKey = getApiKeyFromConfig( - JSON.stringify(initialData.settingsConfig), - ); - if (parsedKey) setApiKey(parsedKey); - }, [initialData]); - - // 支持按下 ESC 关闭弹窗 - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - // 若有子弹窗(端点测速/模板向导)处于打开状态,则交由子弹窗自身处理,避免级联关闭 - if ( - isEndpointModalOpen || - isCodexEndpointModalOpen || - isCodexTemplateModalOpen - ) { - return; - } - e.preventDefault(); - onClose(); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [ - onClose, - isEndpointModalOpen, - isCodexEndpointModalOpen, - isCodexTemplateModalOpen, - ]); - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > - {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-

- {title} -

- -
- -
-
- {error && ( -
- -

- {error} -

-
- )} - - {showPresets && !isCodex && ( - - applyPreset(providerPresets[index], index) - } - onCustomClick={handleCustomClick} - /> - )} - - {showPresets && isCodex && ( - - applyCodexPreset(codexProviderPresets[index], index) - } - onCustomClick={handleCodexCustomClick} - renderCustomDescription={() => ( - <> - {t("providerForm.manualConfig")} - - - )} - /> - )} - -
- - -
- -
- - -
- - {!isCodex && showApiKey && ( -
- - {shouldShowApiKeyLink && getCurrentWebsiteUrl() && ( - - )} -
- )} - - {!isCodex && - selectedTemplatePreset && - templateValueEntries.length > 0 && ( -
-

- {t("providerForm.parameterConfig", { - name: selectedTemplatePreset.name.trim(), - })} -

-
- {templateValueEntries.map(([key, config]) => ( -
- - { - const newValue = e.target.value; - setTemplateValues((prev) => { - const prevEntry = prev[key]; - const nextEntry: TemplateValueConfig = { - ...config, - ...(prevEntry ?? {}), - editorValue: newValue, - }; - const nextValues: TemplateValueMap = { - ...prev, - [key]: nextEntry, - }; - - if (selectedTemplatePreset) { - try { - const configString = - applyTemplateValuesToConfigString( - selectedTemplatePreset.settingsConfig, - formData.settingsConfig, - nextValues, - ); - setFormData((prevForm) => ({ - ...prevForm, - settingsConfig: configString, - })); - setSettingsConfigError( - validateSettingsConfig(configString), - ); - } catch (err) { - console.error("更新模板值失败:", err); - } - } - - return nextValues; - }); - }} - aria-label={config.label} - autoComplete="off" - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- ))} -
-
- )} - - {!isCodex && shouldShowSpeedTest && ( -
-
- - -
- handleBaseUrlChange(e.target.value)} - placeholder={t("providerForm.apiEndpointPlaceholder")} - autoComplete="off" - className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" - /> -
-

- {t("providerForm.apiHint")} -

-
-
- )} - - {/* 端点测速弹窗 - Claude */} - {!isCodex && shouldShowSpeedTest && isEndpointModalOpen && ( - setIsEndpointModalOpen(false)} - onCustomEndpointsChange={setDraftCustomEndpoints} - /> - )} - - {!isCodex && shouldShowKimiSelector && ( - - )} - - {isCodex && showCodexApiKey && ( -
- = 0 && - !isCodexOfficialPreset - } - /> - {shouldShowCodexApiKeyLink && getCurrentCodexWebsiteUrl() && ( - - )} -
- )} - - {isCodex && shouldShowSpeedTest && ( -
-
- - -
- handleCodexBaseUrlChange(e.target.value)} - placeholder={t("providerForm.codexApiEndpointPlaceholder")} - autoComplete="off" - className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" - /> -
-

- {t("providerForm.codexApiHint")} -

-
-
- )} - - {/* 端点测速弹窗 - Codex */} - {isCodex && shouldShowSpeedTest && isCodexEndpointModalOpen && ( - setIsCodexEndpointModalOpen(false)} - onCustomEndpointsChange={setDraftCustomEndpoints} - /> - )} - - {/* Claude 或 Codex 的配置部分 */} - {isCodex ? ( - { - try { - const auth = JSON.parse(codexAuth || "{}"); - const key = - typeof auth.OPENAI_API_KEY === "string" - ? auth.OPENAI_API_KEY - : ""; - setCodexApiKey(key); - } catch { - // ignore - } - }} - useCommonConfig={useCodexCommonConfig} - onCommonConfigToggle={handleCodexCommonConfigToggle} - commonConfigSnippet={codexCommonConfigSnippet} - onCommonConfigSnippetChange={ - handleCodexCommonConfigSnippetChange - } - commonConfigError={codexCommonConfigError} - authError={codexAuthError} - isCustomMode={selectedCodexPreset === -1} - onWebsiteUrlChange={(url) => { - setFormData((prev) => ({ - ...prev, - websiteUrl: url, - })); - }} - onNameChange={(name) => { - setFormData((prev) => ({ - ...prev, - name, - })); - }} - isTemplateModalOpen={isCodexTemplateModalOpen} - setIsTemplateModalOpen={setIsCodexTemplateModalOpen} - /> - ) : ( - <> - {/* 可选的模型配置输入框 - 仅在非官方且非 Kimi 时显示 */} - {!isOfficialPreset && !shouldShowKimiSelector && ( -
-
-
- - - handleModelChange("ANTHROPIC_MODEL", e.target.value) - } - placeholder={t("providerForm.mainModelPlaceholder")} - autoComplete="off" - className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" - /> -
- -
- - - handleModelChange( - "ANTHROPIC_SMALL_FAST_MODEL", - e.target.value, - ) - } - placeholder={t("providerForm.fastModelPlaceholder")} - autoComplete="off" - className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" - /> -
-
- -
-

- {t("providerForm.modelHint")} -

-
-
- )} - - - handleChange({ - target: { name: "settingsConfig", value }, - } as React.ChangeEvent) - } - useCommonConfig={useCommonConfig} - onCommonConfigToggle={handleCommonConfigToggle} - commonConfigSnippet={commonConfigSnippet} - onCommonConfigSnippetChange={handleCommonConfigSnippetChange} - commonConfigError={commonConfigError} - configError={settingsConfigError} - /> - - )} -
- - {/* Footer */} -
- - -
-
-
-
- ); -}; - -export default ProviderForm; diff --git a/src/components/ProviderForm/ClaudeConfigEditor.tsx b/src/components/ProviderForm/ClaudeConfigEditor.tsx deleted file mode 100644 index 1def4e6..0000000 --- a/src/components/ProviderForm/ClaudeConfigEditor.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useEffect, useState } from "react"; -import JsonEditor from "../JsonEditor"; -import { X, Save } from "lucide-react"; -import { isLinux } from "../../lib/platform"; -import { useTranslation } from "react-i18next"; - -interface ClaudeConfigEditorProps { - value: string; - onChange: (value: string) => void; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - commonConfigSnippet: string; - onCommonConfigSnippetChange: (value: string) => void; - commonConfigError: string; - configError: string; -} - -const ClaudeConfigEditor: React.FC = ({ - value, - onChange, - useCommonConfig, - onCommonConfigToggle, - commonConfigSnippet, - onCommonConfigSnippetChange, - commonConfigError, - configError, -}) => { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); - - useEffect(() => { - // 检测暗色模式 - const checkDarkMode = () => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }; - - checkDarkMode(); - - // 监听暗色模式变化 - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.attributeName === "class") { - checkDarkMode(); - } - }); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (commonConfigError && !isCommonConfigModalOpen) { - setIsCommonConfigModalOpen(true); - } - }, [commonConfigError, isCommonConfigModalOpen]); - - // 支持按下 ESC 关闭弹窗 - useEffect(() => { - if (!isCommonConfigModalOpen) return; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - closeModal(); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [isCommonConfigModalOpen]); - - const closeModal = () => { - setIsCommonConfigModalOpen(false); - }; - return ( -
-
- - -
-
- -
- {commonConfigError && !isCommonConfigModalOpen && ( -

- {commonConfigError} -

- )} - - {configError && ( -

{configError}

- )} -

- {t("claudeConfig.fullSettingsHint")} -

- {isCommonConfigModalOpen && ( -
{ - if (e.target === e.currentTarget) closeModal(); - }} - > - {/* Backdrop - 统一背景样式 */} -
- - {/* Modal - 统一窗口样式 */} -
- {/* Header - 统一标题栏样式 */} -
-

- {t("claudeConfig.editCommonConfigTitle")} -

- -
- - {/* Content - 统一内容区域样式 */} -
-

- {t("claudeConfig.commonConfigHint")} -

- - {commonConfigError && ( -

- {commonConfigError} -

- )} -
- - {/* Footer - 统一底部按钮样式 */} -
- - -
-
-
- )} -
- ); -}; - -export default ClaudeConfigEditor; diff --git a/src/components/ProviderForm/CodexConfigEditor.tsx b/src/components/ProviderForm/CodexConfigEditor.tsx deleted file mode 100644 index 17f6201..0000000 --- a/src/components/ProviderForm/CodexConfigEditor.tsx +++ /dev/null @@ -1,667 +0,0 @@ -import React, { useState, useEffect, useRef } from "react"; - -import { X, Save } from "lucide-react"; - -import { isLinux } from "../../lib/platform"; -import { useTranslation } from "react-i18next"; - -import { - generateThirdPartyAuth, - generateThirdPartyConfig, -} from "../../config/codexProviderPresets"; - -interface CodexConfigEditorProps { - authValue: string; - - configValue: string; - - onAuthChange: (value: string) => void; - - onConfigChange: (value: string) => void; - - onAuthBlur?: () => void; - - useCommonConfig: boolean; - - onCommonConfigToggle: (checked: boolean) => void; - - commonConfigSnippet: string; - - onCommonConfigSnippetChange: (value: string) => void; - - commonConfigError: string; - - authError: string; - - isCustomMode?: boolean; // 新增:是否为自定义模式 - - onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调 - - isTemplateModalOpen?: boolean; // 新增:模态框状态 - - setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态 - - onNameChange?: (name: string) => void; // 新增:更新供应商名称回调 -} - -const CodexConfigEditor: React.FC = ({ - authValue, - - configValue, - - onAuthChange, - - onConfigChange, - - onAuthBlur, - - useCommonConfig, - - onCommonConfigToggle, - - commonConfigSnippet, - - onCommonConfigSnippetChange, - - commonConfigError, - - authError, - - onWebsiteUrlChange, - - onNameChange, - - isTemplateModalOpen: externalTemplateModalOpen, - - setIsTemplateModalOpen: externalSetTemplateModalOpen, -}) => { - const { t } = useTranslation(); - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); - - // 使用内部状态或外部状态 - - const [internalTemplateModalOpen, setInternalTemplateModalOpen] = - useState(false); - - const isTemplateModalOpen = - externalTemplateModalOpen ?? internalTemplateModalOpen; - - const setIsTemplateModalOpen = - externalSetTemplateModalOpen ?? setInternalTemplateModalOpen; - - const [templateApiKey, setTemplateApiKey] = useState(""); - - const [templateProviderName, setTemplateProviderName] = useState(""); - - const [templateBaseUrl, setTemplateBaseUrl] = useState(""); - - const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState(""); - - const [templateModelName, setTemplateModelName] = useState("gpt-5-codex"); - const apiKeyInputRef = useRef(null); - - const baseUrlInputRef = useRef(null); - - const modelNameInputRef = useRef(null); - const displayNameInputRef = useRef(null); - - // 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充 - - const [templateDisplayName, setTemplateDisplayName] = useState(""); - - useEffect(() => { - if (commonConfigError && !isCommonConfigModalOpen) { - setIsCommonConfigModalOpen(true); - } - }, [commonConfigError, isCommonConfigModalOpen]); - - // 支持按下 ESC 关闭弹窗 - - useEffect(() => { - if (!isCommonConfigModalOpen) return; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - - closeModal(); - } - }; - - window.addEventListener("keydown", onKeyDown); - - return () => window.removeEventListener("keydown", onKeyDown); - }, [isCommonConfigModalOpen]); - - const closeModal = () => { - setIsCommonConfigModalOpen(false); - }; - - const closeTemplateModal = () => { - setIsTemplateModalOpen(false); - }; - - const applyTemplate = () => { - const requiredInputs = [ - displayNameInputRef.current, - apiKeyInputRef.current, - baseUrlInputRef.current, - modelNameInputRef.current, - ]; - - for (const input of requiredInputs) { - if (input && !input.checkValidity()) { - input.reportValidity(); - input.focus(); - return; - } - } - - const trimmedKey = templateApiKey.trim(); - - const trimmedBaseUrl = templateBaseUrl.trim(); - - const trimmedModel = templateModelName.trim(); - - const auth = generateThirdPartyAuth(trimmedKey); - - const config = generateThirdPartyConfig( - templateProviderName || "custom", - - trimmedBaseUrl, - - trimmedModel, - ); - - onAuthChange(JSON.stringify(auth, null, 2)); - - onConfigChange(config); - - if (onWebsiteUrlChange) { - const trimmedWebsite = templateWebsiteUrl.trim(); - - if (trimmedWebsite) { - onWebsiteUrlChange(trimmedWebsite); - } - } - - if (onNameChange) { - const trimmedName = templateDisplayName.trim(); - if (trimmedName) { - onNameChange(trimmedName); - } - } - - setTemplateApiKey(""); - - setTemplateProviderName(""); - - setTemplateBaseUrl(""); - - setTemplateWebsiteUrl(""); - - setTemplateModelName("gpt-5-codex"); - - setTemplateDisplayName(""); - - closeTemplateModal(); - }; - - const handleTemplateInputKeyDown = ( - e: React.KeyboardEvent, - ) => { - if (e.key === "Enter") { - e.preventDefault(); - - e.stopPropagation(); - - applyTemplate(); - } - }; - - const handleAuthChange = (value: string) => { - onAuthChange(value); - }; - - const handleConfigChange = (value: string) => { - onConfigChange(value); - }; - - const handleCommonConfigSnippetChange = (value: string) => { - onCommonConfigSnippetChange(value); - }; - - return ( -
-
- - -