28 Commits

Author SHA1 Message Date
Jason
052029b3b0 - merge: sync with origin/main, resolve renewed conflicts
- fix(tauri): union command handlers (import/export + speedtest/custom endpoints)
- fix(api): keep import/export APIs and provider-switched listener together
- chore(presets): retain legacy model keys alongside new defaults (GLM)
- chore(rust): keep chrono dep; lockfile updated by merge
2025-10-07 19:12:51 +08:00
Jason
5dc59dc7f8 - merge: merge origin/main, resolve conflicts and preserve both feature sets
- feat(tauri): register import/export and file dialogs; keep endpoint speed test and custom endpoints
- feat(api): add updateTrayMenu and onProviderSwitched; wire import/export APIs
- feat(types): extend global API declarations (import/export)
- chore(presets): GLM preset supports both new and legacy model keys
- chore(rust): add chrono dependency; refresh lockfile
2025-10-07 19:06:27 +08:00
Jason
331e48a530 refactor: improve endpoint management button UX
Replace ambiguous "Advanced" text with intuitive "Manage & Test" label accompanied by Zap icon, making the endpoint management panel entry point more discoverable and self-explanatory for both Claude and Codex configurations.
2025-10-07 16:25:36 +08:00
Jason
aefc5699a2 feat: add endpoint candidates support and code formatting improvements
- Add endpointCandidates field to ProviderPreset and CodexProviderPreset interfaces
- Integrate preset endpoint candidates into speed test endpoint selection
- Add multiple endpoint options for PackyCode providers (Claude & Codex)
- Apply consistent code formatting (trailing commas, line breaks)
- Improve template value type safety and readability
2025-10-07 12:03:11 +08:00
Jason
061aef1c2f - feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map
- feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit

- feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals

- fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends

- storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json)

- behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation
2025-10-07 11:09:00 +08:00
Jason
498920dea6 feat: persist custom endpoints to settings.json
- Extend AppSettings to store custom endpoints for Claude and Codex
- Add Tauri commands: get/add/remove/update custom endpoints
- Update frontend API with endpoint persistence methods
- Modify EndpointSpeedTest to load/save custom endpoints via API
- Track endpoint last used time for future sorting/cleanup
- Store endpoints per app type in settings.json instead of localStorage
2025-10-06 21:51:48 +08:00
Jason
9932b92745 refactor: improve endpoint list UI consistency
- Show delete button for all endpoints on hover for uniform UI
- Change selected state to use blue theme matching main interface:
  * Blue border (border-blue-500) for selected items
  * Light blue background (bg-blue-50/dark:bg-blue-900/20)
  * Blue indicator dot (bg-blue-500/dark:bg-blue-400)
- Switch from compact list (space-y-px) to card-based layout (space-y-2)
- Add rounded corners to each endpoint item for better visual separation
2025-10-06 21:30:11 +08:00
Jason
b4b176580e refactor: unify speed test panel UI with project design system
UI improvements:
- Update modal border radius from rounded-lg to rounded-xl
- Unify header padding from px-6 py-4 to p-6
- Change speed test button color to blue theme (bg-blue-500) for consistency
- Update footer background from bg-gray-50 to bg-gray-100
- Style "Done" button as primary action button with blue theme
- Adjust footer button spacing and hover states

Simplify endpoint display:
- Remove endpoint labels (e.g., "Current Address", "Custom 1")
- Display only URL for cleaner interface
- Clean up all label-related logic:
  * Remove label field from EndpointCandidate interface
  * Remove label generation in buildInitialEntries function
  * Remove label handling in useEffect merge logic
  * Remove label generation in handleAddEndpoint
  * Remove label parameters from claudeSpeedTestEndpoints
  * Remove label parameters from codexSpeedTestEndpoints
2025-10-06 21:14:54 +08:00
Jason
1c9a9af11c fix: prevent modal cascade closing when ESC is pressed
- Add state checks to prevent parent modal from closing when child modals (endpoint speed test or template wizard) are open
- Update ESC key handler dependencies to track all modal states
- Ensures only the topmost modal responds to ESC key
2025-10-06 17:16:20 +08:00
WormW
3ad11acdb2 add: local config import and export (#84)
* add: local config import and export

* Fix import refresh flow and typings

* Clarify import refresh messaging

* Limit stored import backups

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-10-05 23:33:07 +08:00
Jason
d7fe4a7165 refactor: convert endpoint speed test to modal dialog
- Transform EndpointSpeedTest component into a modal dialog
- Add "Advanced" button next to base URL input to open modal
- Support ESC key and backdrop click to close modal
- Apply Linear design principles: minimal styling, clean layout
- Remove unused showBaseUrlInput variable
- Implement same modal pattern for both Claude and Codex
2025-10-05 20:43:11 +08:00
zhangweiii
f8c40d591f feat: update GLM provider model configuration (#90)
- Update GLM-4.5 to GLM-4.6 as default Sonnet and Opus models
- Use new model naming convention (glm-4.5-air, glm-4.6)
- Align with latest GLM API model naming standards
2025-10-05 20:41:39 +08:00
QuentinHsu
ce593248fc fix: update layout for Claude app type provider display (#87)
* feat: add .node-version file with Node.js version 22.4.1

* fix: update layout for Claude app type provider display
2025-10-05 11:09:03 +08:00
Jason
4fc76200e8 feat: add unified endpoint speed test for API providers
Add a comprehensive endpoint latency testing system that allows users to:
- Test multiple API endpoints concurrently
- Auto-select the fastest endpoint based on latency
- Add/remove custom endpoints dynamically
- View latency results with color-coded indicators

Backend (Rust):
- Implement parallel HTTP HEAD requests with configurable timeout
- Handle various error scenarios (timeout, connection failure, invalid URL)
- Return structured latency data with status codes

Frontend (React):
- Create interactive speed test UI component with auto-sort by latency
- Support endpoint management (add/remove custom endpoints)
- Extract and update Codex base_url from TOML configuration
- Integrate with provider presets for default endpoint candidates

This feature improves user experience when selecting optimal API endpoints,
especially useful for users with multiple provider options or proxy setups.
2025-10-04 18:04:40 +08:00
Jason
e0908701b4 Remove deprecated VS Code Codex integration 2025-10-03 20:03:55 +08:00
Lakr
d86994eb7e feat: support kat-coder & template value (#77) 2025-10-02 22:14:35 +08:00
Jason
94e93137a2 chore: bump version to 3.4.0
- Add i18next internationalization with Chinese/English support
- Add Claude plugin sync alongside VS Code integration
- Extend provider presets with new models (DeepSeek-V3.2-Exp, Qwen3-Max, GLM-4.6)
- Support portable mode and single instance enforcement
- Add tray minimize and macOS Dock visibility management
- Improve Settings UI with scrollable layout and save icon
- Fix layout shifts and provider toggle consistency
- Remove unnecessary OpenAI auth requirement
- Update Windows MSI installer to target per-user LocalAppData
2025-10-02 09:59:38 +08:00
Jason
db832a9654 fix: eliminate layout shift when switching app types with Claude plugin sync
- Separate sync button containers for Codex and Claude modes
- Only render the container in corresponding app type to prevent layout jumping
- Apply same fix pattern as commit 0bcc04a for VS Code sync button
2025-10-01 21:33:29 +08:00
Jason
45a639e73f feat: add optional apiKeyUrl field to provider presets
Allow third-party providers to specify a dedicated API key URL separate from the main website URL for easier key acquisition.
2025-10-01 21:28:09 +08:00
Jason
f74d641f86 Add Claude plugin sync alongside VS Code integration 2025-10-01 21:23:55 +08:00
Jason
fcfa9574e8 Update AI model versions in provider presets
- Update DeepSeek model from V3.1-Terminus to V3.2-Exp
- Update ModelScope model from GLM-4.5 to GLM-4.6
2025-09-30 22:19:20 +08:00
Jason
d739bb36e5 feat: add macOS Dock visibility management for tray mode
- Hide Dock icon when minimizing to tray
- Show Dock icon when restoring window from tray
- Apply appropriate activation policy (Accessory/Regular) based on window state
- Add error handling with logging for Dock operations
2025-09-29 17:03:13 +08:00
Jason
0bcc04adce fix: eliminate layout shift when switching between Claude and Codex
- Wrap VS Code sync button in fixed-width container to maintain stable layout
- Only render the container in Codex mode to avoid unnecessary space in Claude mode
- Change card transition from 'all' to specific properties (border-color, box-shadow) to prevent layout animations
- These changes prevent the horizontal position jumping of provider cards during app switching
2025-09-28 23:23:43 +08:00
Jason
fee0762e3e fix: improve Enable/In Use button consistency with fixed width and icons 2025-09-28 23:00:43 +08:00
Jason
1a8ae85e55 refactor: simplify language settings UI by removing description text and general section 2025-09-28 22:40:14 +08:00
Jason
c5aa244d65 feat: integrate language switcher into settings with modern segment control UI
- Move language switcher from header to settings modal for better organization
- Implement modern segment control UI instead of radio buttons for language selection
- Add language preference persistence in localStorage and backend settings
- Support instant language preview with cancel/revert functionality
- Remove standalone LanguageSwitcher component
- Improve initial language detection logic (localStorage -> browser -> default)
- Add proper i18n keys for language settings UI text
2025-09-28 22:23:49 +08:00
Jason
0bedbb2663 feat: change default language to Chinese with English fallback 2025-09-28 21:11:22 +08:00
TinsFox
5f3caa1484 feat: integrate i18next for internationalization support (#65)
* feat: integrate i18next for internationalization support

- Added i18next and react-i18next dependencies for localization.
- Updated various components to utilize translation functions for user-facing text.
- Enhanced user experience by providing multilingual support across the application.

* feat: improve i18n implementation with better translations and accessibility

- Add proper i18n keys for language switcher tooltips and aria-labels
- Replace hardcoded Chinese console error messages with i18n keys
- Add missing translation keys for new UI elements
- Improve accessibility with proper aria-label attributes

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-28 20:47:44 +08:00
41 changed files with 3460 additions and 882 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ release/
.npmrc
CLAUDE.md
AGENTS.md
/.claude

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v22.4.1

View File

@@ -5,11 +5,30 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.4.0] - 2025-10-01
### ✨ Features
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
- Support portable mode launches and enforce a single running instance to avoid conflicts
### 🔧 Improvements
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
### 🐛 Fixes
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
- Fix layout shifts while switching app types with Claude plugin sync enabled
- Align Enable/In Use button states to avoid visual jank across app views
## [3.3.0] - 2025-09-22
### ✨ Features
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants *(Removed in 3.4.x)*
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently *(Removed in 3.4.x)*
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces

View File

@@ -1,12 +1,14 @@
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.4.0-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 不同供应商配置的桌面应用。
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。
> v3.4.0 新增 i18next 国际化还有部分未完成、对新模型qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp的支持、Claude 插件、单实例守护、托盘最小化及安装器优化
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
@@ -14,14 +16,15 @@
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.3.0
## 功能特性v3.4.0
- **VS Code Codex 插件一键配置**:供应商卡片支持「应用到 VS Code / 从 VS Code 移除」,默认开启自动同步,并可跨 Code / Insiders / VSCodium 写入 `settings.json`
- **通用配置片段**Claude 与 Codex 共用 JSON/TOML 片段,提供编辑器 lint、内容校验、统一错误提示与本地持久化
- **Codex 配置向导**:新增显示名称、专用 API Key URL、HTML5 校验与预设模板,方便快速配置第三方服务
- **系统托盘与快捷操作**:窗口隐藏时仍可通过托盘切换供应商,并在自动同步开启时触发 VS Code 写入
- **平台适配**:新增 Windows WSL 环境支持、Linux 自动禁用模态背景模糊解决白屏问题、macOS Dock 点击即可恢复窗口
- **UI优化**:多处 UI 和使用体验优化
- **国际化与语言切换**:内置 i18next默认显示中文可在设置中快速切换到英文界面文文案自动实时刷新。
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
- **VS Code Codex 设置停用**:由于新版 Codex 插件无需修改 `settings.json`,应用不再写入 VS Code 设置,避免潜在冲突。
- **供应商预设扩展**:新增 DeepSeek--V3.2-Exp、Qwen3-Max、GLM-4.6 等最新模型。
- **系统托盘与窗口行为**窗口关闭可最小化到托盘macOS 支持托盘模式下隐藏/显示 Dock托盘切换时同步 Claude/Codex/插件状态。
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
- **UI 与安装体验优化**设置面板改为可滚动布局并加入保存图标按钮宽度与状态一致性加强Windows MSI 安装默认写入 per-user LocalAppData 并改进组件跟踪Windows 便携版现在指向最新 release 页面,不再自动更为为安装版。
## 界面预览

76
README_i18n.md Normal file
View File

@@ -0,0 +1,76 @@
# CC Switch 国际化功能说明
## 已完成的工作
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文和中文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
## 文件结构
```
src/
├── i18n/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
└── main.tsx # 导入国际化配置
```
## 默认语言设置
- **默认语言**:英文 (en)
- **回退语言**:英文 (en)
## 使用方式
1. 在组件中导入 `useTranslation`
```tsx
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return <div>{t('common.save')}</div>;
}
```
2. 切换语言:
```tsx
const { i18n } = useTranslation();
i18n.changeLanguage('zh'); // 切换到中文
```
## 翻译键结构
- `common.*` - 通用文案(保存、取消、设置等)
- `header.*` - 头部相关文案
- `provider.*` - 供应商相关文案
- `notifications.*` - 通知消息
- `settings.*` - 设置页面文案
- `apps.*` - 应用名称
- `console.*` - 控制台日志信息
## 测试功能
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
## 已更新的组件
- ✅ App.tsx - 主应用组件
- ✅ ConfirmDialog.tsx - 确认对话框
- ✅ AddProviderModal.tsx - 添加供应商弹窗
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
- ✅ ProviderList.tsx - 供应商列表
- ✅ LanguageSwitcher.tsx - 语言切换器
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
## 注意事项
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
2. 翻译键名应该有意义且结构化
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言

View File

@@ -5,4 +5,3 @@
- i18n
- gemini cli
- homebrew 支持
- 自定义 vscode 路径

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.3.1",
"version": "3.4.0",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
@@ -37,10 +37,12 @@
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"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-i18next": "^16.0.0",
"tailwindcss": "^4.1.13"
}
}

65
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
i18next:
specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2)
jsonc-parser:
specifier: ^3.2.1
version: 3.3.1
@@ -53,6 +56,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(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)
tailwindcss:
specifier: ^4.1.13
version: 4.1.13
@@ -159,6 +165,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -750,6 +760,17 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
i18next@25.5.2:
resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
jiti@2.5.1:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
@@ -890,6 +911,22 @@ packages:
peerDependencies:
react: ^18.3.1
react-i18next@16.0.0:
resolution: {integrity: sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==}
peerDependencies:
i18next: '>= 25.5.2'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -973,6 +1010,10 @@ packages:
terser:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -1079,6 +1120,8 @@ snapshots:
'@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
'@babel/runtime@7.28.4': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -1591,6 +1634,16 @@ snapshots:
graceful-fs@4.2.11: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
i18next@25.5.2(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.9.2
jiti@2.5.1: {}
js-tokens@4.0.0: {}
@@ -1692,6 +1745,16 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
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
html-parse-stringify: 3.0.1
i18next: 25.5.2(typescript@5.9.2)
react: 18.3.1
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
typescript: 5.9.2
react-refresh@0.17.0: {}
react@18.3.1:
@@ -1767,6 +1830,8 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.30.1
void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
yallist@3.1.1: {}

291
src-tauri/Cargo.lock generated
View File

@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
@@ -173,7 +173,7 @@ dependencies = [
"polling",
"rustix",
"slab",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -231,7 +231,7 @@ dependencies = [
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -288,9 +288,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backtrace"
version = "0.3.75"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
@@ -298,7 +298,7 @@ dependencies = [
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
"windows-link 0.2.0",
]
[[package]]
@@ -465,9 +465,9 @@ dependencies = [
[[package]]
name = "bytemuck"
version = "1.23.2"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "byteorder"
@@ -511,9 +511,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603"
checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609"
dependencies = [
"serde_core",
]
@@ -538,7 +538,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.16",
"thiserror 2.0.17",
]
[[package]]
@@ -553,9 +553,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.38"
version = "1.2.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9"
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -563,12 +563,15 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.3.1"
version = "3.4.0"
dependencies = [
"chrono",
"dirs 5.0.1",
"futures",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -579,6 +582,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"toml 0.8.2",
]
@@ -628,8 +632,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.0",
]
@@ -825,12 +831,12 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
dependencies = [
"powerfmt",
"serde",
"serde_core",
]
[[package]]
@@ -906,7 +912,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -1093,7 +1099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -1165,9 +1171,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959"
checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
[[package]]
name = "flate2"
@@ -1237,6 +1243,21 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1244,6 +1265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1311,6 +1333,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1480,9 +1503,9 @@ dependencies = [
[[package]]
name = "gimli"
version = "0.31.1"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "gio"
@@ -1797,7 +1820,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.0",
"windows-core 0.62.1",
]
[[package]]
@@ -2063,9 +2086,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "js-sys"
version = "0.3.80"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -2148,9 +2171,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.175"
version = "0.2.176"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
[[package]]
name = "libloading"
@@ -2197,11 +2220,10 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "lock_api"
version = "0.4.13"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"autocfg",
"scopeguard",
]
@@ -2259,9 +2281,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "memchr"
version = "2.7.5"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memoffset"
@@ -2322,7 +2344,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.16",
"thiserror 2.0.17",
"windows-sys 0.60.2",
]
@@ -2718,9 +2740,9 @@ dependencies = [
[[package]]
name = "object"
version = "0.36.7"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
@@ -2770,7 +2792,7 @@ dependencies = [
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.16",
"thiserror 2.0.17",
]
[[package]]
@@ -2806,9 +2828,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.4"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
@@ -2816,15 +2838,15 @@ dependencies = [
[[package]]
name = "parking_lot_core"
version = "0.9.11"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
"windows-link 0.2.0",
]
[[package]]
@@ -3039,7 +3061,7 @@ dependencies = [
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -3192,7 +3214,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
@@ -3213,7 +3235,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
@@ -3235,9 +3257,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.40"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
@@ -3398,23 +3420,23 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.16",
"thiserror 2.0.17",
]
[[package]]
name = "ref-cast"
version = "1.0.24"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.24"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
@@ -3423,9 +3445,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.2"
version = "1.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
dependencies = [
"aho-corasick",
"memchr",
@@ -3435,9 +3457,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
dependencies = [
"aho-corasick",
"memchr",
@@ -3615,7 +3637,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -3644,9 +3666,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.6"
version = "0.103.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb"
checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3773,9 +3795,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.226"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
@@ -3795,18 +3817,18 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.226"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.226"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -3880,9 +3902,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.14.1"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -3891,8 +3913,7 @@ dependencies = [
"indexmap 2.11.4",
"schemars 0.9.0",
"schemars 1.0.4",
"serde",
"serde_derive",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
@@ -3900,9 +3921,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.14.1"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e"
checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27"
dependencies = [
"darling",
"proc-macro2",
@@ -4292,7 +4313,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tokio",
"tray-icon",
"url",
@@ -4345,7 +4366,7 @@ dependencies = [
"sha2",
"syn 2.0.106",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"time",
"url",
"uuid",
@@ -4397,7 +4418,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
]
@@ -4418,7 +4439,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"toml 0.9.7",
"url",
]
@@ -4441,7 +4462,7 @@ dependencies = [
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.16",
"thiserror 2.0.17",
"time",
]
@@ -4461,7 +4482,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
"windows",
"zbus",
@@ -4486,7 +4507,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.16",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
@@ -4516,7 +4537,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.16",
"thiserror 2.0.17",
"time",
"tokio",
"url",
@@ -4542,7 +4563,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
"webkit2gtk",
"webview2-com",
@@ -4606,7 +4627,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.16",
"thiserror 2.0.17",
"toml 0.9.7",
"url",
"urlpattern",
@@ -4626,15 +4647,15 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.22.0"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -4659,11 +4680,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.16"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl 2.0.16",
"thiserror-impl 2.0.17",
]
[[package]]
@@ -4679,9 +4700,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.16"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
@@ -4761,15 +4782,27 @@ dependencies = [
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "tokio-rustls"
version = "0.26.3"
name = "tokio-macros"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
@@ -4978,7 +5011,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.16",
"thiserror 2.0.17",
"windows-sys 0.59.0",
]
@@ -4996,9 +5029,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.18.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
@@ -5213,9 +5246,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.103"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
dependencies = [
"cfg-if",
"once_cell",
@@ -5226,9 +5259,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.103"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
@@ -5240,9 +5273,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.53"
version = "0.4.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67"
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
dependencies = [
"cfg-if",
"js-sys",
@@ -5253,9 +5286,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.103"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5263,9 +5296,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.103"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [
"proc-macro2",
"quote",
@@ -5276,9 +5309,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.103"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
dependencies = [
"unicode-ident",
]
@@ -5358,9 +5391,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.80"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5460,7 +5493,7 @@ version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [
"thiserror 2.0.16",
"thiserror 2.0.17",
"windows",
"windows-core 0.61.2",
]
@@ -5487,7 +5520,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -5548,9 +5581,9 @@ dependencies = [
[[package]]
name = "windows-core"
version = "0.62.0"
version = "0.62.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9"
dependencies = [
"windows-implement",
"windows-interface",
@@ -5572,9 +5605,9 @@ dependencies = [
[[package]]
name = "windows-implement"
version = "0.60.0"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0"
dependencies = [
"proc-macro2",
"quote",
@@ -5583,9 +5616,9 @@ dependencies = [
[[package]]
name = "windows-interface"
version = "0.59.1"
version = "0.59.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5"
dependencies = [
"proc-macro2",
"quote",
@@ -5692,14 +5725,14 @@ version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.3",
"windows-targets 0.53.4",
]
[[package]]
name = "windows-sys"
version = "0.61.0"
version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
dependencies = [
"windows-link 0.2.0",
]
@@ -5752,11 +5785,11 @@ dependencies = [
[[package]]
name = "windows-targets"
version = "0.53.3"
version = "0.53.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
dependencies = [
"windows-link 0.1.3",
"windows-link 0.2.0",
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
@@ -5778,9 +5811,9 @@ dependencies = [
[[package]]
name = "windows-version"
version = "0.1.5"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb"
checksum = "700dad7c058606087f6fdc1f88da5841e06da40334413c6cd4367b25ef26d24e"
dependencies = [
"windows-link 0.2.0",
]
@@ -6039,7 +6072,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.16",
"thiserror 2.0.17",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -6218,9 +6251,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.1"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.3.1"
version = "3.4.0"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
@@ -21,6 +21,7 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = "0.4"
tauri = { version = "2.8.2", features = ["tray-icon"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
@@ -29,6 +30,9 @@ tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
dirs = "5.0"
toml = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
futures = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

View File

@@ -0,0 +1,103 @@
use std::fs;
use std::path::PathBuf;
const CLAUDE_DIR: &str = ".claude";
const CLAUDE_CONFIG_FILE: &str = "config.json";
const CLAUDE_CONFIG_PAYLOAD: &str = "{\n \"primaryApiKey\": \"any\"\n}\n";
fn claude_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
Ok(home.join(CLAUDE_DIR))
}
pub fn claude_config_path() -> Result<PathBuf, String> {
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
}
pub fn ensure_claude_dir_exists() -> Result<PathBuf, String> {
let dir = claude_dir()?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
}
Ok(dir)
}
pub fn read_claude_config() -> Result<Option<String>, String> {
let path = claude_config_path()?;
if path.exists() {
let content =
fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?;
Ok(Some(content))
} else {
Ok(None)
}
}
fn is_managed_config(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(value) => value
.get("primaryApiKey")
.and_then(|v| v.as_str())
.map(|val| val == "any")
.unwrap_or(false),
Err(_) => false,
}
}
pub fn write_claude_config() -> Result<bool, String> {
let path = claude_config_path()?;
ensure_claude_dir_exists()?;
let need_write = match read_claude_config()? {
Some(existing) => existing != CLAUDE_CONFIG_PAYLOAD,
None => true,
};
if need_write {
fs::write(&path, CLAUDE_CONFIG_PAYLOAD)
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
}
Ok(need_write)
}
pub fn clear_claude_config() -> Result<bool, String> {
let path = claude_config_path()?;
if !path.exists() {
return Ok(false);
}
let content = match read_claude_config()? {
Some(content) => content,
None => return Ok(false),
};
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(_) => return Ok(false),
};
let obj = match value.as_object_mut() {
Some(obj) => obj,
None => return Ok(false),
};
if obj.remove("primaryApiKey").is_none() {
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))?;
Ok(true)
}
pub fn claude_config_status() -> Result<(bool, PathBuf), String> {
let path = claude_config_path()?;
Ok((path.exists(), path))
}
pub fn is_claude_config_applied() -> Result<bool, String> {
match read_claude_config()? {
Some(content) => Ok(is_managed_config(&content)),
None => Ok(false),
}
}

View File

@@ -6,11 +6,12 @@ use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::claude_plugin;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::provider::{Provider, ProviderMeta};
use crate::speedtest;
use crate::store::AppState;
use crate::vscode;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
@@ -692,41 +693,224 @@ pub async fn is_portable_mode() -> Result<bool, String> {
}
}
/// VS Code: 获取用户 settings.json 状态
/// Claude 插件:获取 ~/.claude/config.json 状态
#[tauri::command]
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
if let Some(p) = vscode::find_existing_settings() {
Ok(ConfigStatus {
exists: true,
path: p.to_string_lossy().to_string(),
})
} else {
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
let preferred = vscode::candidate_settings_paths().into_iter().next();
Ok(ConfigStatus {
exists: false,
path: preferred.unwrap_or_default().to_string_lossy().to_string(),
})
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
match claude_plugin::claude_config_status() {
Ok((exists, path)) => Ok(ConfigStatus {
exists,
path: path.to_string_lossy().to_string(),
}),
Err(err) => Err(err),
}
}
/// VS Code: 读取 settings.json 文本(仅当文件存在
/// Claude 插件:读取配置内容(若不存在返回 Ok(None)
#[tauri::command]
pub async fn read_vscode_settings() -> Result<String, String> {
if let Some(p) = vscode::find_existing_settings() {
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
claude_plugin::read_claude_config()
}
/// Claude 插件:写入/清除固定配置
#[tauri::command]
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
if official {
claude_plugin::clear_claude_config()
} else {
Err("未找到 VS Code 用户设置文件".to_string())
claude_plugin::write_claude_config()
}
}
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
/// Claude 插件:检测是否已写入目标配置
#[tauri::command]
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
if let Some(p) = vscode::find_existing_settings() {
config::write_text_file(&p, &content)?;
Ok(true)
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
claude_plugin::is_claude_config_applied()
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<speedtest::EndpointLatency>, String> {
let filtered: Vec<String> = 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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
) -> Result<Vec<crate::settings::CustomEndpoint>, 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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
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(())
}

View File

@@ -0,0 +1,170 @@
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<String, String> {
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<Value, String> {
// 读取当前配置文件
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<Value, String> {
// 读取导入的文件
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<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
default_name: String,
) -> Result<Option<String>, 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<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, 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()))
}

View File

@@ -1,20 +1,22 @@
mod app_config;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod import_export;
mod migration;
mod provider;
mod settings;
mod store;
mod vscode;
mod speedtest;
use store::AppState;
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::{Emitter, Manager};
/// 创建动态托盘菜单
@@ -116,6 +118,23 @@ fn create_tray_menu(
.map_err(|e| format!("构建菜单失败: {}", e))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {}", err);
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {}", err);
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {}", event_id);
@@ -130,6 +149,10 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
@@ -267,6 +290,10 @@ pub fn run() {
{
let _ = window.set_skip_taskbar(true);
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(&window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
}
@@ -390,9 +417,21 @@ pub fn run() {
commands::save_settings,
commands::check_for_updates,
commands::is_portable_mode,
commands::get_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
commands::get_claude_plugin_status,
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
commands::add_custom_endpoint,
commands::remove_custom_endpoint,
commands::update_endpoint_last_used,
// 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,
update_tray_menu,
]);
@@ -413,6 +452,7 @@ pub fn run() {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
}
}
_ => {}

View File

@@ -19,6 +19,9 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
}
impl Provider {
@@ -36,6 +39,7 @@ impl Provider {
website_url,
category: None,
created_at: None,
meta: None,
}
}
}
@@ -56,6 +60,14 @@ impl Default for ProviderManager {
}
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
/// 自定义端点列表(按 URL 去重存储)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
}
impl ProviderManager {
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {

View File

@@ -1,8 +1,19 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
pub url: String,
pub added_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -15,6 +26,14 @@ pub struct AppSettings {
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
@@ -32,6 +51,9 @@ impl Default for AppSettings {
minimize_to_tray_on_close: true,
claude_config_dir: None,
codex_config_dir: None,
language: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
@@ -55,6 +77,13 @@ impl AppSettings {
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh"))
.map(|s| s.to_string());
}
pub fn load() -> Self {

102
src-tauri/src/speedtest.rs Normal file
View File

@@ -0,0 +1,102 @@
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<u128>,
pub status: Option<u16>,
pub error: Option<String>,
}
fn build_client(timeout_secs: u64) -> Result<Client, String> {
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>) -> 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<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<EndpointLatency>, 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 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)
}

View File

@@ -1,65 +0,0 @@
use std::path::PathBuf;
/// 枚举可能的 VS Code 发行版配置目录名称
fn vscode_product_dirs() -> Vec<&'static str> {
vec![
"Code", // VS Code Stable
"Code - Insiders", // VS Code Insiders
"VSCodium", // VSCodium
"Code - OSS", // OSS 发行版
]
}
/// 获取 VS Code 用户 settings.json 的候选路径列表(按优先级排序)
pub fn candidate_settings_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(target_os = "macos")]
{
if let Some(home) = dirs::home_dir() {
for prod in vscode_product_dirs() {
paths.push(
home.join("Library")
.join("Application Support")
.join(prod)
.join("User")
.join("settings.json"),
);
}
}
}
#[cfg(target_os = "windows")]
{
// Windows: %APPDATA%\Code\User\settings.json
if let Some(roaming) = dirs::config_dir() {
for prod in vscode_product_dirs() {
paths.push(roaming.join(prod).join("User").join("settings.json"));
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Linux: ~/.config/Code/User/settings.json
if let Some(config) = dirs::config_dir() {
for prod in vscode_product_dirs() {
paths.push(config.join(prod).join("User").join("settings.json"));
}
}
}
paths
}
/// 返回第一个存在的 settings.json 路径
pub fn find_existing_settings() -> Option<PathBuf> {
for p in candidate_settings_paths() {
if let Ok(meta) = std::fs::metadata(&p) {
if meta.is_file() {
return Some(p);
}
}
}
None
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.3.1",
"version": "3.4.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "./types";
import { AppType } from "./lib/tauri-api";
import ProviderList from "./components/ProviderList";
@@ -12,19 +13,16 @@ import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
import { extractErrorMessage } from "./utils/errorUtils";
import { applyProviderToVSCode } from "./utils/vscodeSettings";
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
function App() {
const { t } = useTranslation();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { isAutoSyncEnabled } = useVSCodeAutoSync();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
null
);
const [notification, setNotification] = useState<{
message: string;
@@ -44,7 +42,7 @@ function App() {
const showNotification = (
message: string,
type: "success" | "error",
duration = 3000,
duration = 3000
) => {
// 清除之前的定时器
if (timeoutRef.current) {
@@ -88,7 +86,7 @@ function App() {
try {
unlisten = await window.api.onProviderSwitched(async (data) => {
if (import.meta.env.DEV) {
console.log("收到供应商切换事件:", data);
console.log(t("console.providerSwitchReceived"), data);
}
// 如果当前应用类型匹配,则重新加载数据
@@ -96,13 +94,13 @@ function App() {
await loadProviders();
}
// 若为 Codex 且开启自动同步,则静默同步到 VS Code覆盖
if (data.appType === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(data.providerId, true);
// 若为 Claude则同步插件配置
if (data.appType === "claude") {
await syncClaudePlugin(data.providerId, true);
}
});
} catch (error) {
console.error("设置供应商切换监听器失败:", error);
console.error(t("console.setupListenerFailed"), error);
}
};
@@ -114,7 +112,7 @@ function App() {
unlisten();
}
};
}, [activeApp, isAutoSyncEnabled]);
}, [activeApp]);
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
@@ -152,16 +150,16 @@ function App() {
await loadProviders();
setEditingProviderId(null);
// 显示编辑成功提示
showNotification("供应商配置已保存", "success", 2000);
showNotification(t("notifications.providerSaved"), "success", 2000);
// 更新托盘菜单
await window.api.updateTrayMenu();
} catch (error) {
console.error("更新供应商失败:", error);
console.error(t("console.updateProviderFailed"), error);
setEditingProviderId(null);
const errorMessage = extractErrorMessage(error);
const message = errorMessage
? `保存失败:${errorMessage}`
: "保存失败,请重试";
? t("notifications.saveFailed", { error: errorMessage })
: t("notifications.saveFailedGeneric");
showNotification(message, "error", errorMessage ? 6000 : 3000);
}
};
@@ -170,73 +168,41 @@ function App() {
const provider = providers[id];
setConfirmDialog({
isOpen: true,
title: "删除供应商",
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
title: t("confirm.deleteProvider"),
message: t("confirm.deleteProviderMessage", { name: provider?.name }),
onConfirm: async () => {
await window.api.deleteProvider(id, activeApp);
await loadProviders();
setConfirmDialog(null);
showNotification("供应商删除成功", "success");
showNotification(t("notifications.providerDeleted"), "success");
// 更新托盘菜单
await window.api.updateTrayMenu();
},
});
};
// 同步Codex供应商到VS Code设置静默覆盖
const syncCodexToVSCode = async (providerId: string, silent = false) => {
// 同步 Claude 插件配置(写入/移除固定 JSON
const syncClaudePlugin = async (providerId: string, silent = false) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
if (!silent) {
showNotification(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000,
);
}
return;
}
const raw = await window.api.readVSCodeSettings();
const provider = providers[providerId];
const isOfficial = provider?.category === "official";
// 非官方供应商需要解析 base_url使用公共工具函数
let baseUrl: string | undefined = undefined;
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
if (!silent) {
showNotification(
"当前配置缺少 base_url无法写入 VS Code",
"error",
4000,
);
}
return;
}
baseUrl = parsed;
}
const updatedSettings = applyProviderToVSCode(raw, {
baseUrl,
isOfficial,
});
if (updatedSettings !== raw) {
await window.api.writeVSCodeSettings(updatedSettings);
if (!silent) {
showNotification("已同步到 VS Code", "success", 1500);
}
}
// 触发providers重新加载以更新VS Code按钮状态
await loadProviders();
} catch (error: any) {
console.error("同步到VS Code失败:", error);
if (!provider) return;
const isOfficial = provider.category === "official";
await window.api.applyClaudePluginConfig({ official: isOfficial });
if (!silent) {
const errorMessage = error?.message || "同步 VS Code 失败";
showNotification(errorMessage, "error", 5000);
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);
}
}
};
@@ -246,21 +212,29 @@ function App() {
if (success) {
setCurrentProviderId(id);
// 显示重启提示
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
const appName = t(`apps.${activeApp}`);
showNotification(
`切换成功!请重启 ${appName} 终端以生效`,
t("notifications.switchSuccess", { appName }),
"success",
2000,
2000
);
// 更新托盘菜单
await window.api.updateTrayMenu();
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
if (activeApp === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(id, true); // silent模式不显示通知
if (activeApp === "claude") {
await syncClaudePlugin(id, true);
}
} else {
showNotification("切换失败,请检查配置", "error");
showNotification(t("notifications.switchFailed"), "error");
}
};
const handleImportSuccess = async () => {
await loadProviders();
try {
await window.api.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to refresh tray menu after import", error);
}
};
@@ -271,13 +245,13 @@ function App() {
if (result.success) {
await loadProviders();
showNotification("已从现有配置创建默认供应商", "success", 3000);
showNotification(t("notifications.autoImported"), "success", 3000);
// 更新托盘菜单
await window.api.updateTrayMenu();
}
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) {
console.error("自动导入默认配置失败:", error);
console.error(t("console.autoImportFailed"), error);
// 静默处理,不影响用户体验
}
};
@@ -293,14 +267,18 @@ function App() {
target="_blank"
rel="noopener noreferrer"
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
title="在 GitHub 上查看"
title={t("header.viewOnGithub")}
>
CC Switch
</a>
<button
onClick={toggleDarkMode}
className={buttonStyles.icon}
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
title={
isDarkMode
? t("header.toggleLightMode")
: t("header.toggleDarkMode")
}
>
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
@@ -308,7 +286,7 @@ function App() {
<button
onClick={() => setIsSettingsOpen(true)}
className={buttonStyles.icon}
title="设置"
title={t("common.settings")}
>
<Settings size={18} />
</button>
@@ -324,7 +302,7 @@ function App() {
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
>
<Plus size={16} />
{t("header.addProvider")}
</button>
</div>
</div>
@@ -388,7 +366,10 @@ function App() {
)}
{isSettingsOpen && (
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess}
/>
)}
</div>
);

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
@@ -14,11 +15,13 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
onAdd,
onClose,
}) => {
const { t } = useTranslation();
return (
<ProviderForm
appType={appType}
title="添加新供应商"
submitText="添加"
title={t("provider.addNewProvider")}
submitText={t("common.add")}
showPresets={true}
onSubmit={onAdd}
onClose={onClose}

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
@@ -16,11 +17,13 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = "确定",
cancelText = "取消",
confirmText,
cancelText,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
@@ -65,13 +68,13 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
autoFocus
>
{cancelText}
{cancelText || t("common.cancel")}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
>
{confirmText}
{confirmText || t("common.confirm")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
@@ -16,6 +17,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
onSave,
onClose,
}) => {
const { t } = useTranslation();
const handleSubmit = (data: Omit<Provider, "id">) => {
onSave({
...provider,
@@ -26,8 +29,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
return (
<ProviderForm
appType={appType}
title="编辑供应商"
submitText="保存"
title={t("common.edit")}
submitText={t("common.save")}
initialData={provider}
showPresets={false}
onSubmit={handleSubmit}

View File

@@ -0,0 +1,103 @@
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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
<div className="flex flex-col items-center text-center">
{status === 'importing' && (
<>
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importing")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("common.loading")}
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importSuccess")}
</h3>
{backupId && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t("settings.backupId")}: {backupId}
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("settings.autoReload")}
</p>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importFailed")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{message || t("settings.configCorrupted")}
</p>
<button
onClick={() => {
if (onComplete) {
onComplete();
}
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
{t("common.close")}
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { Provider, ProviderCategory } from "../types";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Provider, ProviderCategory, CustomEndpoint } from "../types";
import { AppType } from "../lib/tauri-api";
import {
updateCommonConfigSnippet,
@@ -10,8 +10,12 @@ import {
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,
@@ -22,10 +26,143 @@ import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react";
import { X, AlertCircle, Save, Zap } from "lucide-react";
import { isLinux } from "../lib/platform";
import EndpointSpeedTest, {
EndpointCandidate,
} from "./ProviderForm/EndpointSpeedTest";
// 分类仅用于控制少量交互(如官方禁用 API Key不显示介绍组件
type TemplateValueMap = Record<string, TemplateValueConfig>;
type TemplatePath = Array<string | number>;
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<any>((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 = `{
@@ -71,13 +208,26 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [claudeModel, setClaudeModel] = useState("");
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
// 模板变量状态
const [templateValues, setTemplateValues] = useState<
Record<string, TemplateValueConfig>
>({});
// 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<string[]>(
[]
);
// 端点测速弹窗状态
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null
@@ -88,8 +238,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuthError(validateCodexAuth(value));
};
const setCodexConfig = (value: string) => {
setCodexConfigState(value);
const setCodexConfig = (value: string | ((prev: string) => string)) => {
setCodexConfigState((prev) =>
typeof value === "function"
? (value as (input: string) => string)(prev)
: value
);
};
const setCodexCommonConfigSnippet = (value: string) => {
@@ -103,6 +257,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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") {
@@ -157,6 +315,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
});
const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
const isUpdatingFromCodexCommonConfig = useRef(false);
const isUpdatingBaseUrlRef = useRef(false);
const isUpdatingCodexBaseUrlRef = useRef(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null
@@ -300,6 +461,43 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}, [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;
@@ -377,6 +575,22 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setError(currentSettingsError);
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(`请填写 ${config.label}`);
return;
}
}
}
// Claude: 原有逻辑
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
@@ -391,13 +605,31 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}
onSubmit({
// 构造基础提交数据
const basePayload: Omit<Provider, "id"> = {
name: formData.name,
websiteUrl: formData.websiteUrl,
settingsConfig,
// 仅在用户选择了预设或手动选择“自定义”时持久化分类
...(category ? { category } : {}),
});
};
// 若为“新建供应商”,且已在弹窗中添加了自定义端点,则随提交一并落盘
if (!initialData && draftCustomEndpoints.length > 0) {
const now = Date.now();
const customMap: Record<string, CustomEndpoint> = {};
for (const raw of draftCustomEndpoints) {
const url = raw.trim().replace(/\/+$/, "");
if (!url) continue;
if (!customMap[url]) {
customMap[url] = { url, addedAt: now };
}
}
onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } });
return;
}
onSubmit(basePayload);
};
const handleChange = (
@@ -529,7 +761,30 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
};
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
const configString = JSON.stringify(preset.settingsConfig, null, 2);
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,
@@ -546,7 +801,6 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 清空 API Key 输入框,让用户重新输入
setApiKey("");
setBaseUrl(""); // 清空基础 URL
// 同步通用配置状态
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
@@ -554,11 +808,16 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCommonConfigError("");
// 如果预设包含模型配置,初始化模型输入框
if (preset.settingsConfig && typeof preset.settingsConfig === "object") {
const config = preset.settingsConfig as { env?: Record<string, any> };
if (appliedSettingsConfig && typeof appliedSettingsConfig === "object") {
const config = appliedSettingsConfig as { env?: Record<string, any> };
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")) {
@@ -570,6 +829,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
} else {
setClaudeModel("");
setClaudeSmallFastModel("");
setBaseUrl("");
}
}
};
@@ -577,6 +837,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理点击自定义按钮
const handleCustomClick = () => {
setSelectedPreset(-1);
setTemplateValues({});
// 设置自定义模板
const customTemplate = {
@@ -615,6 +876,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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,
@@ -652,6 +917,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuth(JSON.stringify(customAuth, null, 2));
setCodexConfig(customConfig);
setCodexApiKey("");
setCodexBaseUrl("https://your-api-endpoint.com/v1");
setCategory("custom");
};
@@ -675,21 +941,42 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理基础 URL 变化
const handleBaseUrlChange = (url: string) => {
setBaseUrl(url);
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 = url.trim();
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);
@@ -795,6 +1082,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setUseCodexCommonConfig(hasCommon);
}
setCodexConfig(value);
if (!isUpdatingCodexBaseUrlRef.current) {
const extracted = extractCodexBaseUrl(value) || "";
if (extracted !== codexBaseUrl) {
setCodexBaseUrl(extracted);
}
}
};
// 根据当前配置决定是否展示 API Key 输入框
@@ -803,6 +1096,25 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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 &&
@@ -828,8 +1140,88 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 综合判断是否应该显示 Kimi 模型选择器
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
// 判断是否显示基础 URL 输入框(仅自定义模式显示)
const showBaseUrlInput = selectedPreset === -1 && !isCodex;
const claudeSpeedTestEndpoints = useMemo<EndpointCandidate[]>(() => {
if (isCodex) return [];
const map = new Map<string, EndpointCandidate>();
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<EndpointCandidate[]>(() => {
if (!isCodex) return [];
const map = new Map<string, EndpointCandidate>();
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 =
@@ -977,13 +1369,26 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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]);
}, [
onClose,
isEndpointModalOpen,
isCodexEndpointModalOpen,
isCodexTemplateModalOpen,
]);
return (
<div
@@ -1133,15 +1538,95 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div>
)}
{/* 基础 URL 输入框 - 仅在自定义模式下显示 */}
{!isCodex && showBaseUrlInput && (
{!isCodex &&
selectedTemplatePreset &&
templateValueEntries.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
- {selectedTemplatePreset.name.trim()} *
</h3>
<div className="space-y-4">
{templateValueEntries.map(([key, config]) => (
<div key={key} className="space-y-2">
<label className="sr-only" htmlFor={`template-${key}`}>
{config.label}
</label>
<input
id={`template-${key}`}
type="text"
required
placeholder={`${config.label} *`}
value={
templateValues[key]?.editorValue ??
config.editorValue ??
config.defaultValue ??
""
}
onChange={(e) => {
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"
/>
</div>
))}
</div>
</div>
)}
{!isCodex && shouldShowSpeedTest && (
<div className="space-y-2">
<label
htmlFor="baseUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<div className="flex items-center justify-between">
<label
htmlFor="baseUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<button
type="button"
onClick={() => setIsEndpointModalOpen(true)}
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<Zap className="h-3.5 w-3.5" />
</button>
</div>
<input
type="url"
id="baseUrl"
@@ -1159,6 +1644,20 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div>
)}
{/* 端点测速弹窗 - Claude */}
{!isCodex && shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appType={appType}
providerId={initialData?.id}
value={baseUrl}
onChange={handleBaseUrlChange}
initialEndpoints={claudeSpeedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => setIsEndpointModalOpen(false)}
onCustomEndpointsChange={setDraftCustomEndpoints}
/>
)}
{!isCodex && shouldShowKimiSelector && (
<KimiModelSelector
apiKey={apiKey}
@@ -1203,6 +1702,50 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div>
)}
{isCodex && shouldShowSpeedTest && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="codexBaseUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<button
type="button"
onClick={() => setIsCodexEndpointModalOpen(true)}
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<Zap className="h-3.5 w-3.5" />
</button>
</div>
<input
type="url"
id="codexBaseUrl"
value={codexBaseUrl}
onChange={(e) => handleCodexBaseUrlChange(e.target.value)}
placeholder="https://your-api-endpoint.com/v1"
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"
/>
</div>
)}
{/* 端点测速弹窗 - Codex */}
{isCodex && shouldShowSpeedTest && isCodexEndpointModalOpen && (
<EndpointSpeedTest
appType={appType}
providerId={initialData?.id}
value={codexBaseUrl}
onChange={handleCodexBaseUrlChange}
initialEndpoints={codexSpeedTestEndpoints}
visible={isCodexEndpointModalOpen}
onClose={() => setIsCodexEndpointModalOpen(false)}
onCustomEndpointsChange={setDraftCustomEndpoints}
/>
)}
{/* Claude 或 Codex 的配置部分 */}
{isCodex ? (
<CodexConfigEditor

View File

@@ -0,0 +1,602 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Zap, Loader2, Plus, X, AlertCircle } from "lucide-react";
import { isLinux } from "../../lib/platform";
import type { AppType } from "../../lib/tauri-api";
export interface EndpointCandidate {
id?: string;
url: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps {
appType: AppType;
providerId?: string;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
onCustomEndpointsChange?: (urls: string[]) => void;
}
interface EndpointEntry extends EndpointCandidate {
id: string;
latency: number | null;
status?: number;
error?: string | null;
}
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
const normalizeEndpointUrl = (url: string): string =>
url.trim().replace(/\/+$/, "");
const buildInitialEntries = (
candidates: EndpointCandidate[],
selected: string,
): EndpointEntry[] => {
const map = new Map<string, EndpointEntry>();
const addCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
if (!sanitized) return;
if (map.has(sanitized)) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
};
candidates.forEach(addCandidate);
const selectedUrl = normalizeEndpointUrl(selected);
if (selectedUrl && !map.has(selectedUrl)) {
addCandidate({ url: selectedUrl, isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
providerId,
value,
onChange,
initialEndpoints,
visible = true,
onClose,
onCustomEndpointsChange,
}) => {
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
// 加载保存的自定义端点(按正在编辑的供应商)
useEffect(() => {
const loadCustomEndpoints = async () => {
try {
if (!providerId) return;
const customEndpoints = await window.api.getCustomEndpoints(
appType,
providerId,
);
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
url: ep.url,
isCustom: true,
}));
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 合并自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
});
}
});
return Array.from(map.values());
});
} catch (error) {
console.error("加载自定义端点失败:", error);
}
};
if (visible) {
loadCustomEndpoints();
}
}, [appType, visible, providerId]);
useEffect(() => {
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
prev.forEach((entry) => {
map.set(entry.url, entry);
});
let changed = false;
const mergeCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url
? normalizeEndpointUrl(candidate.url)
: "";
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected && !map.has(normalizedSelected)) {
mergeCandidate({ url: normalizedSelected, isCustom: true });
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
// 将自定义端点变化透传给父组件(仅限 isCustom
useEffect(() => {
if (!onCustomEndpointsChange) return;
try {
const customUrls = Array.from(
new Set(
entries
.filter((e) => e.isCustom)
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
.filter(Boolean),
),
);
onCustomEndpointsChange(customUrls);
} catch (err) {
// ignore
}
// 仅在 entries 变化时同步
}, [entries, onCustomEndpointsChange]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a, b) => {
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
if (aLatency === bLatency) {
return a.url.localeCompare(b.url);
}
return aLatency - bLatency;
});
}, [entries]);
const handleAddEndpoint = useCallback(
async () => {
const candidate = customUrl.trim();
let errorMsg: string | null = null;
if (!candidate) {
errorMsg = "请输入有效的 URL";
}
let parsed: URL | null = null;
if (!errorMsg) {
try {
parsed = new URL(candidate);
} catch {
errorMsg = "URL 格式不正确";
}
}
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
errorMsg = "仅支持 HTTP/HTTPS";
}
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = "该地址已存在";
}
}
if (errorMsg) {
setAddError(errorMsg);
return;
}
setAddError(null);
// 保存到后端
try {
if (providerId) {
await window.api.addCustomEndpoint(appType, providerId, sanitized);
}
// 更新本地状态
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setAddError(message || "保存失败,请重试");
console.error("添加自定义端点失败:", error);
}
},
[customUrl, entries, normalizedSelected, onChange, appType, providerId],
);
const handleRemoveEndpoint = useCallback(
async (entry: EndpointEntry) => {
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
if (entry.isCustom && providerId) {
try {
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
} catch (error) {
console.error("删除自定义端点失败:", error);
return;
}
}
// 更新本地状态
setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) {
const fallback = next[0];
onChange(fallback ? fallback.url : "");
}
return next;
});
},
[normalizedSelected, onChange, appType, providerId],
);
const runSpeedTest = useCallback(async () => {
const urls = entries.map((entry) => entry.url);
if (urls.length === 0) {
setLastError("请先添加端点");
return;
}
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
setLastError("测速功能不可用");
return;
}
setIsTesting(true);
setLastError(null);
try {
const results = await window.api.testApiEndpoints(urls, {
timeoutSecs: appType === "codex" ? 12 : 8,
});
const resultMap = new Map(
results.map((item) => [normalizeEndpointUrl(item.url), item]),
);
setEntries((prev) =>
prev.map((entry) => {
const match = resultMap.get(entry.url);
if (!match) {
return {
...entry,
latency: null,
status: undefined,
error: "未返回结果",
};
}
return {
...entry,
latency:
typeof match.latency === "number" ? Math.round(match.latency) : null,
status: match.status,
error: match.error ?? null,
};
}),
);
if (autoSelect) {
const successful = results
.filter((item) => typeof item.latency === "number" && item.latency !== null)
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
const best = successful[0];
if (best && best.url && best.url !== normalizedSelected) {
onChange(best.url);
}
}
} catch (error) {
const message =
error instanceof Error ? error.message : `测速失败: ${String(error)}`;
setLastError(message);
} finally {
setIsTesting(false);
}
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
const handleSelect = useCallback(
async (url: string) => {
if (!url || url === normalizedSelected) return;
// 更新最后使用时间(对自定义端点)
const entry = entries.find((e) => e.url === url);
if (entry?.isCustom && providerId) {
await window.api.updateEndpointLastUsed(appType, providerId, url);
}
onChange(url);
},
[normalizedSelected, onChange, appType, entries, providerId],
);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
if (!visible) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{entries.length}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
/>
</label>
<button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
className="flex h-7 items-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
</>
)}
</button>
</div>
</div>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<input
type="url"
value={customUrl}
placeholder="https://api.example.com"
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
/>
<button
type="button"
onClick={handleAddEndpoint}
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
>
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div className="font-mono text-sm font-medium text-gray-900 dark:text-gray-100">
{latency}ms
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400"></div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium"
>
</button>
</div>
</div>
</div>
);
};
export default EndpointSpeedTest;

View File

@@ -1,15 +1,9 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
import { AppType } from "../lib/tauri-api";
import {
applyProviderToVSCode,
detectApplied,
normalizeBaseUrl,
} from "../utils/vscodeSettings";
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps {
@@ -35,6 +29,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
appType,
onNotify,
}) => {
const { t } = useTranslation();
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
@@ -49,9 +44,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
if (match && match[2]) return match[2];
}
return "未配置官网地址";
return t("provider.notConfigured");
} catch {
return "配置错误";
return t("provider.configError");
}
};
@@ -59,128 +54,56 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
await window.api.openExternal(url);
} catch (error) {
console.error("打开链接失败:", error);
console.error(t("console.openLinkFailed"), error);
}
};
// 解析 Codex 配置中的 base_url已提取到公共工具
const [claudeApplied, setClaudeApplied] = useState<boolean>(false);
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
// 检查 Claude 插件配置是否已应用
useEffect(() => {
const check = async () => {
if (appType !== "codex" || !currentProviderId) {
setVscodeAppliedFor(null);
return;
}
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
setVscodeAppliedFor(null);
const checkClaude = async () => {
if (appType !== "claude" || !currentProviderId) {
setClaudeApplied(false);
return;
}
try {
const content = await window.api.readVSCodeSettings();
const detected = detectApplied(content);
// 认为“已应用”的条件非官方供应商VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
const current = providers[currentProviderId];
let applied = false;
if (current && current.category !== "official") {
const base = getCodexBaseUrl(current);
if (detected.apiBase && base) {
applied =
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
}
}
setVscodeAppliedFor(applied ? currentProviderId : null);
} catch {
setVscodeAppliedFor(null);
const applied = await window.api.isClaudePluginApplied();
setClaudeApplied(applied);
} catch (error) {
console.error("检测 Claude 插件配置失败:", error);
setClaudeApplied(false);
}
};
check();
checkClaude();
}, [appType, currentProviderId, providers]);
const handleApplyToVSCode = async (provider: Provider) => {
const handleApplyToClaudePlugin = async () => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000
);
return;
}
const raw = await window.api.readVSCodeSettings();
const isOfficial = provider.category === "official";
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
onNotify?.("当前配置缺少 base_url无法写入 VS Code", "error", 4000);
return;
}
}
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
if (next === raw) {
// 幂等:没有变化也提示成功
onNotify?.("已应用到 VS Code重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已应用到 VS Code重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
await window.api.applyClaudePluginConfig({ official: false });
onNotify?.(t("notifications.appliedToClaudePlugin"), "success", 3000);
setClaudeApplied(true);
} catch (error: any) {
console.error(error);
const msg =
error && error.message
? error.message
: t("notifications.syncClaudePluginFailed");
onNotify?.(msg, "error", 5000);
}
};
const handleRemoveFromVSCode = async () => {
const handleRemoveFromClaudePlugin = async () => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000
);
return;
}
const raw = await window.api.readVSCodeSettings();
const next = applyProviderToVSCode(raw, {
baseUrl: undefined,
isOfficial: true,
});
if (next === raw) {
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "移除失败";
await window.api.applyClaudePluginConfig({ official: true });
onNotify?.(t("notifications.removedFromClaudePlugin"), "success", 3000);
setClaudeApplied(false);
} catch (error: any) {
console.error(error);
const msg =
error && error.message
? error.message
: t("notifications.syncClaudePluginFailed");
onNotify?.(msg, "error", 5000);
}
};
@@ -214,10 +137,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
<Users size={24} className="text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("provider.noProviders")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
"添加供应商"API供应商
{t("provider.noProvidersDescription")}
</p>
</div>
) : (
@@ -247,7 +170,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
)}
>
<CheckCircle2 size={12} />
使
{t("provider.currentlyUsing")}
</div>
</div>
@@ -275,50 +198,52 @@ const ProviderList: React.FC<ProviderListProps> = ({
</div>
<div className="flex items-center gap-2 ml-4">
{appType === "codex" &&
provider.category !== "official" && (
<button
onClick={() =>
vscodeAppliedFor === provider.id
? handleRemoveFromVSCode()
: handleApplyToVSCode(provider)
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] whitespace-nowrap justify-center",
!isCurrent && "invisible",
vscodeAppliedFor === provider.id
? "border border-gray-300 text-gray-600 hover:border-red-300 hover:text-red-600 hover:bg-red-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-red-800 dark:hover:text-red-400 dark:hover:bg-red-900/20"
: "border border-gray-300 text-gray-700 hover:border-blue-300 hover:text-blue-600 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-700 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
)}
title={
vscodeAppliedFor === provider.id
? "从 VS Code 移除我们写入的配置"
: "将当前供应商应用到 VS Code"
}
>
{vscodeAppliedFor === provider.id
? "从 VS Code 移除"
: "应用到 VS Code"}
</button>
)}
{appType === "claude" ? (
<div className="flex-shrink-0">
{provider.category !== "official" && isCurrent && (
<button
onClick={() =>
claudeApplied
? handleRemoveFromClaudePlugin()
: handleApplyToClaudePlugin()
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-full whitespace-nowrap justify-center",
claudeApplied
? "border border-gray-300 text-gray-600 hover:border-red-300 hover:text-red-600 hover:bg-red-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-red-800 dark:hover:text-red-400 dark:hover:bg-red-900/20"
: "border border-gray-300 text-gray-700 hover:border-green-300 hover:text-green-600 hover:bg-green-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-green-700 dark:hover:text-green-400 dark:hover:bg-green-900/20"
)}
title={
claudeApplied
? t("provider.removeFromClaudePlugin")
: t("provider.applyToClaudePlugin")
}
>
{claudeApplied
? t("provider.removeFromClaudePlugin")
: t("provider.applyToClaudePlugin")}
</button>
)}
</div>
) : null}
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[76px] justify-center whitespace-nowrap",
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
)}
>
{!isCurrent && <Play size={14} />}
{isCurrent ? "使用中" : "启用"}
{isCurrent ? <Check size={14} /> : <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title="编辑供应商"
title={t("provider.editProvider")}
>
<Edit3 size={16} />
</button>
@@ -332,7 +257,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
)}
title="删除供应商"
title={t("provider.deleteProvider")}
>
<Trash2 size={16} />
</button>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
X,
RefreshCw,
@@ -11,6 +12,7 @@ import {
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { ImportProgressModal } from "./ImportProgressModal";
import { homeDir, join } from "@tauri-apps/api/path";
import "../lib/tauri-api";
import { relaunchApp } from "../lib/updater";
@@ -21,15 +23,37 @@ import { isLinux } from "../lib/platform";
interface SettingsModalProps {
onClose: () => void;
onImportSuccess?: () => void | Promise<void>;
}
export default function SettingsModal({ onClose }: SettingsModalProps) {
export default function SettingsModal({ onClose, onImportSuccess }: SettingsModalProps) {
const { t, i18n } = useTranslation();
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
lang === "en" ? "en" : "zh";
const readPersistedLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
};
const persistedLanguage = readPersistedLanguage();
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
language: persistedLanguage,
});
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
persistedLanguage,
);
const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
@@ -41,6 +65,13 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
// 导入/导出相关状态
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'success' | 'error'>('idle');
const [importError, setImportError] = useState<string>("");
const [importBackupId, setImportBackupId] = useState<string>("");
const [selectedImportFile, setSelectedImportFile] = useState<string>('');
useEffect(() => {
loadSettings();
loadConfigPath();
@@ -54,9 +85,9 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error("获取版本信息失败:", error);
console.error(t("console.getVersionFailed"), error);
// 失败时不硬编码版本号,显示为未知
setVersion("未知");
setVersion(t("common.unknown"));
}
};
@@ -71,6 +102,12 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
(loadedSettings as any)?.minimizeToTrayOnClose ??
(loadedSettings as any)?.minimize_to_tray_on_close ??
true;
const storedLanguage = normalizeLanguage(
typeof (loadedSettings as any)?.language === "string"
? (loadedSettings as any).language
: persistedLanguage,
);
setSettings({
showInTray,
minimizeToTrayOnClose,
@@ -82,9 +119,14 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
typeof (loadedSettings as any)?.codexConfigDir === "string"
? (loadedSettings as any).codexConfigDir
: undefined,
language: storedLanguage,
});
setInitialLanguage(storedLanguage);
if (i18n.language !== storedLanguage) {
void i18n.changeLanguage(storedLanguage);
}
} catch (error) {
console.error("加载设置失败:", error);
console.error(t("console.loadSettingsFailed"), error);
}
};
@@ -95,7 +137,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setConfigPath(path);
}
} catch (error) {
console.error("获取配置路径失败:", error);
console.error(t("console.getConfigPathFailed"), error);
}
};
@@ -108,7 +150,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setResolvedClaudeDir(claudeDir || "");
setResolvedCodexDir(codexDir || "");
} catch (error) {
console.error("获取配置目录失败:", error);
console.error(t("console.getConfigDirFailed"), error);
}
};
@@ -117,12 +159,13 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const portable = await window.api.isPortable();
setIsPortable(portable);
} catch (error) {
console.error("检测便携模式失败:", error);
console.error(t("console.detectPortableFailed"), error);
}
};
const saveSettings = async () => {
try {
const selectedLanguage = settings.language === "en" ? "en" : "zh";
const payload: Settings = {
...settings,
claudeConfigDir:
@@ -133,15 +176,42 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
language: selectedLanguage,
};
await window.api.saveSettings(payload);
setSettings(payload);
try {
window.localStorage.setItem("language", selectedLanguage);
} catch (error) {
console.warn("[Settings] Failed to persist language preference", error);
}
setInitialLanguage(selectedLanguage);
if (i18n.language !== selectedLanguage) {
void i18n.changeLanguage(selectedLanguage);
}
onClose();
} catch (error) {
console.error("保存设置失败:", error);
console.error(t("console.saveSettingsFailed"), error);
}
};
const handleLanguageChange = (lang: "zh" | "en") => {
setSettings((prev) => ({ ...prev, language: lang }));
if (i18n.language !== lang) {
void i18n.changeLanguage(lang);
}
};
const handleCancel = () => {
if (settings.language !== initialLanguage) {
setSettings((prev) => ({ ...prev, language: initialLanguage }));
if (i18n.language !== initialLanguage) {
void i18n.changeLanguage(initialLanguage);
}
}
onClose();
};
const handleCheckUpdate = async () => {
if (hasUpdate && updateHandle) {
if (isPortable) {
@@ -155,7 +225,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
await updateHandle.downloadAndInstall();
await relaunchApp();
} catch (error) {
console.error("更新失败:", error);
console.error(t("console.updateFailed"), error);
// 更新失败时回退到打开 Releases 页面
await window.api.checkForUpdates();
} finally {
@@ -176,7 +246,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
}, 3000);
}
} catch (error) {
console.error("检查更新失败:", error);
console.error(t("console.checkUpdateFailed"), error);
// 在开发模式下,模拟已是最新版本的响应
if (import.meta.env.DEV) {
setShowUpToDate(true);
@@ -197,7 +267,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
try {
await window.api.openAppConfigFolder();
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error(t("console.openConfigFolderFailed"), error);
}
};
@@ -228,7 +298,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setResolvedCodexDir(sanitized);
}
} catch (error) {
console.error("选择配置目录失败:", error);
console.error(t("console.selectConfigDirFailed"), error);
}
};
@@ -238,7 +308,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const folder = app === "claude" ? ".claude" : ".codex";
return await join(home, folder);
} catch (error) {
console.error("获取默认配置目录失败:", error);
console.error(t("console.getDefaultConfigDirFailed"), error);
return "";
}
};
@@ -266,8 +336,9 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const handleOpenReleaseNotes = async () => {
try {
const targetVersion = updateInfo?.availableVersion || version;
const unknownLabel = t("common.unknown");
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === "未知") {
if (!targetVersion || targetVersion === unknownLabel) {
await window.api.openExternal(
"https://github.com/farion1231/cc-switch/releases"
);
@@ -280,7 +351,67 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
);
} catch (error) {
console.error("打开更新日志失败:", error);
console.error(t("console.openReleaseNotesFailed"), error);
}
};
// 导出配置处理函数
const handleExportConfig = async () => {
try {
const defaultName = `cc-switch-config-${new Date().toISOString().split('T')[0]}.json`;
const filePath = await window.api.saveFileDialog(defaultName);
if (!filePath) return; // 用户取消了
const result = await window.api.exportConfigToFile(filePath);
if (result.success) {
alert(`${t("settings.configExported")}\n${result.filePath}`);
}
} catch (error) {
console.error("导出配置失败:", error);
alert(`${t("settings.exportFailed")}: ${error}`);
}
};
// 选择要导入的文件
const handleSelectImportFile = async () => {
try {
const filePath = await window.api.openFileDialog();
if (filePath) {
setSelectedImportFile(filePath);
setImportStatus('idle'); // 重置状态
setImportError('');
}
} catch (error) {
console.error('选择文件失败:', error);
alert(`${t("settings.selectFileFailed")}: ${error}`);
}
};
// 执行导入
const handleExecuteImport = async () => {
if (!selectedImportFile || isImporting) return;
setIsImporting(true);
setImportStatus('importing');
try {
const result = await window.api.importConfigFromFile(selectedImportFile);
if (result.success) {
setImportBackupId(result.backupId || '');
setImportStatus('success');
// ImportProgressModal 会在2秒后触发数据刷新回调
} else {
setImportError(result.message || t("settings.configCorrupted"));
setImportStatus('error');
}
} catch (error) {
setImportError(String(error));
setImportStatus('error');
} finally {
setIsImporting(false);
}
};
@@ -288,7 +419,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
@@ -300,10 +431,10 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
{t("settings.title")}
</h2>
<button
onClick={onClose}
onClick={handleCancel}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
@@ -312,19 +443,50 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
{/* 语言设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.language")}
</h3>
<div className="inline-flex p-0.5 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
type="button"
onClick={() => handleLanguageChange("zh")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
(settings.language ?? "zh") === "zh"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionChinese")}
</button>
<button
type="button"
onClick={() => handleLanguageChange("en")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
settings.language === "en"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionEnglish")}
</button>
</div>
</div>
{/* 窗口行为设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.windowBehavior")}
</h3>
<div className="space-y-3">
<label className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("settings.minimizeToTray")}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
退
{t("settings.minimizeToTrayDescription")}
</p>
</div>
<input
@@ -342,23 +504,21 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
</div>
{/* VS Code 自动同步设置已移除 */}
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.configFileLocation")}
</h3>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{configPath || "加载中..."}
{configPath || t("common.loading")}
</span>
</div>
<button
onClick={handleOpenConfigFolder}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="打开文件夹"
title={t("settings.openFolder")}
>
<FolderOpen
size={18}
@@ -371,16 +531,15 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 配置目录覆盖 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("settings.configDirectoryOverride")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
WSL 使 Claude Code Codex WSL
{t("settings.configDirectoryDescription")}
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Claude Code
{t("settings.claudeConfigDir")}
</label>
<div className="flex gap-2">
<input
@@ -392,14 +551,14 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
claudeConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.claude"
placeholder={t("settings.browsePlaceholderClaude")}
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="浏览目录"
title={t("settings.browseDirectory")}
>
<FolderSearch size={16} />
</button>
@@ -407,7 +566,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
type="button"
onClick={() => handleResetConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="恢复默认目录(需保存后生效)"
title={t("settings.resetDefault")}
>
<Undo2 size={16} />
</button>
@@ -416,7 +575,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Codex
{t("settings.codexConfigDir")}
</label>
<div className="flex gap-2">
<input
@@ -428,14 +587,14 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
codexConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.codex"
placeholder={t("settings.browsePlaceholderCodex")}
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="浏览目录"
title={t("settings.browseDirectory")}
>
<FolderSearch size={16} />
</button>
@@ -443,7 +602,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
type="button"
onClick={() => handleResetConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="恢复默认目录(需保存后生效)"
title={t("settings.resetDefault")}
>
<Undo2 size={16} />
</button>
@@ -452,10 +611,60 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
</div>
{/* 导入导出 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.importExport")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="space-y-3">
{/* 导出按钮 */}
<button
onClick={handleExportConfig}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<Save size={12} />
{t("settings.exportConfig")}
</button>
{/* 导入区域 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={handleSelectImportFile}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<FolderOpen size={12} />
{t("settings.selectConfigFile")}
</button>
<button
onClick={handleExecuteImport}
disabled={!selectedImportFile || isImporting}
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
!selectedImportFile || isImporting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
}`}
>
{isImporting ? t("settings.importing") : t("settings.import")}
</button>
</div>
{/* 显示选择的文件 */}
{selectedImportFile && (
<div className="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-900 rounded break-all">
{selectedImportFile.split('/').pop() || selectedImportFile.split('\\').pop() || selectedImportFile}
</div>
)}
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("common.about")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between">
@@ -465,7 +674,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
CC Switch
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{version}
{t("common.version")} {version}
</p>
</div>
</div>
@@ -474,12 +683,14 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
onClick={handleOpenReleaseNotes}
className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
title={
hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"
hasUpdate
? t("settings.viewReleaseNotes")
: t("settings.viewCurrentReleaseNotes")
}
>
<span className="inline-flex items-center gap-1">
<ExternalLink size={12} />
{t("settings.releaseNotes")}
</span>
</button>
<button
@@ -498,25 +709,27 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{isDownloading ? (
<span className="flex items-center gap-1">
<Download size={12} className="animate-pulse" />
...
{t("settings.updating")}
</span>
) : isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
{t("settings.checking")}
</span>
) : hasUpdate ? (
<span className="flex items-center gap-1">
<Download size={12} />
v{updateInfo?.availableVersion}
{t("settings.updateTo", {
version: updateInfo?.availableVersion ?? "",
})}
</span>
) : showUpToDate ? (
<span className="flex items-center gap-1">
<Check size={12} />
{t("settings.upToDate")}
</span>
) : (
"检查更新"
t("settings.checkForUpdates")
)}
</button>
</div>
@@ -528,20 +741,42 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={onClose}
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={saveSettings}
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors flex items-center gap-2"
>
<Save size={16} />
{t("common.save")}
</button>
</div>
</div>
{/* Import Progress Modal */}
{importStatus !== 'idle' && (
<ImportProgressModal
status={importStatus}
message={importError}
backupId={importBackupId}
onComplete={() => {
setImportStatus('idle');
setImportError('');
setSelectedImportFile('');
}}
onSuccess={() => {
if (onImportSuccess) {
void onImportSuccess();
}
void window.api
.updateTrayMenu()
.catch((error) => console.error("[SettingsModal] Failed to refresh tray menu", error));
}}
/>
)}
</div>
);
}

View File

@@ -6,11 +6,15 @@ import { ProviderCategory } from "../types";
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
// 第三方供应商可提供单独的获取 API Key 链接
apiKeyUrl?: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
/**
@@ -69,5 +73,11 @@ export const codexProviderPresets: CodexProviderPreset[] = [
"https://codex-api.packycode.com/v1",
"gpt-5-codex"
),
// Codex 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://codex-api.packycode.com/v1",
"https://codex-api-hk-cn2.packycode.com/v1",
"https://codex-api-hk-cdn.packycode.com/v1",
],
},
];

View File

@@ -3,6 +3,13 @@
*/
import { ProviderCategory } from "../types";
export interface TemplateValueConfig {
label: string;
placeholder: string;
defaultValue?: string;
editorValue: string;
}
export interface ProviderPreset {
name: string;
websiteUrl: string;
@@ -11,6 +18,10 @@ export interface ProviderPreset {
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
// 新增:模板变量定义,用于动态替换配置中的值
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
export const providerPresets: ProviderPreset[] = [
@@ -30,8 +41,8 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "DeepSeek-V3.1-Terminus",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.1-Terminus",
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.2-Exp",
},
},
category: "cn_official",
@@ -43,8 +54,12 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air",
// 兼容旧键名,保持前端读取一致
ANTHROPIC_MODEL: "GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
},
},
category: "cn_official",
@@ -83,8 +98,8 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.6",
},
},
category: "aggregator",
@@ -99,6 +114,36 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://api.packycode.com",
"https://api-hk-cn2.packycode.com",
"https://api-hk-g.packycode.com",
"https://api-us-cn2.packycode.com",
"https://api-cf-pro.packycode.com",
],
category: "third_party",
},
{
name: "KAT-Coder 官方",
websiteUrl: "https://console.streamlake.ai/wanqing/",
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "KAT-Coder",
ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder",
},
},
category: "cn_official",
templateValues: {
ENDPOINT_ID: {
label: "Vanchin Endpoint ID",
placeholder: "ep-xxx-xxx",
defaultValue: "",
editorValue: "",
},
},
},
];

View File

@@ -1,99 +0,0 @@
import { useState, useEffect, useCallback } from "react";
const VSCODE_AUTO_SYNC_KEY = "vscode-auto-sync-enabled";
const VSCODE_AUTO_SYNC_EVENT = "vscode-auto-sync-changed";
export function useVSCodeAutoSync() {
// 默认开启自动同步;若本地存储存在记录,则以记录为准
const [isAutoSyncEnabled, setIsAutoSyncEnabled] = useState<boolean>(true);
// 从 localStorage 读取初始状态
useEffect(() => {
try {
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
if (saved !== null) {
setIsAutoSyncEnabled(saved === "true");
}
} catch (error) {
console.error("读取自动同步状态失败:", error);
}
}, []);
// 订阅同窗口的自定义事件,以及跨窗口的 storage 事件,实现全局同步
useEffect(() => {
const onCustom = (e: Event) => {
try {
const detail = (e as CustomEvent).detail as
| { enabled?: boolean }
| undefined;
if (detail && typeof detail.enabled === "boolean") {
setIsAutoSyncEnabled(detail.enabled);
} else {
// 兜底:从 localStorage 读取
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
if (saved !== null) setIsAutoSyncEnabled(saved === "true");
}
} catch {
// 忽略
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === VSCODE_AUTO_SYNC_KEY) {
setIsAutoSyncEnabled(e.newValue === "true");
}
};
window.addEventListener(VSCODE_AUTO_SYNC_EVENT, onCustom as EventListener);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(
VSCODE_AUTO_SYNC_EVENT,
onCustom as EventListener,
);
window.removeEventListener("storage", onStorage);
};
}, []);
// 启用自动同步
const enableAutoSync = useCallback(() => {
try {
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "true");
setIsAutoSyncEnabled(true);
// 通知同窗口其他订阅者
window.dispatchEvent(
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: true } }),
);
} catch (error) {
console.error("保存自动同步状态失败:", error);
}
}, []);
// 禁用自动同步
const disableAutoSync = useCallback(() => {
try {
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "false");
setIsAutoSyncEnabled(false);
// 通知同窗口其他订阅者
window.dispatchEvent(
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: false } }),
);
} catch (error) {
console.error("保存自动同步状态失败:", error);
}
}, []);
// 切换自动同步状态
const toggleAutoSync = useCallback(() => {
if (isAutoSyncEnabled) {
disableAutoSync();
} else {
enableAutoSync();
}
}, [isAutoSyncEnabled, enableAutoSync, disableAutoSync]);
return {
isAutoSyncEnabled,
enableAutoSync,
disableAutoSync,
toggleAutoSync,
};
}

59
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,59 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
const getInitialLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en") {
return stored;
}
} catch (error) {
console.warn("[i18n] Failed to read stored language preference", error);
}
}
const navigatorLang =
typeof navigator !== "undefined"
? navigator.language?.toLowerCase() ?? navigator.languages?.[0]?.toLowerCase()
: undefined;
if (navigatorLang?.startsWith("zh")) {
return "zh";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
return DEFAULT_LANGUAGE;
};
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n.use(initReactI18next).init({
resources,
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
fallbackLng: "en", // 如果缺少中文翻译则退回英文
interpolation: {
escapeValue: false, // React 已经默认转义
},
// 开发模式下显示调试信息
debug: false,
});
export default i18n;

124
src/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,124 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex Provider Switching Tool"
},
"common": {
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"settings": "Settings",
"about": "About",
"version": "Version",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"unknown": "Unknown"
},
"header": {
"viewOnGithub": "View on GitHub",
"toggleDarkMode": "Switch to Dark Mode",
"toggleLightMode": "Switch to Light Mode",
"addProvider": "Add Provider",
"switchToChinese": "Switch to Chinese",
"switchToEnglish": "Switch to English"
},
"provider": {
"noProviders": "No providers added yet",
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
"currentlyUsing": "Currently Using",
"enable": "Enable",
"inUse": "In Use",
"editProvider": "Edit Provider",
"deleteProvider": "Delete Provider",
"addNewProvider": "Add New Provider",
"configError": "Configuration Error",
"notConfigured": "Not configured for official website",
"applyToClaudePlugin": "Apply to Claude plugin",
"removeFromClaudePlugin": "Remove from Claude plugin"
},
"notifications": {
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
"switchFailed": "Switch failed, please check configuration",
"autoImported": "Default provider created from existing configuration",
"saveFailed": "Save failed: {{error}}",
"saveFailedGeneric": "Save failed, please try again",
"appliedToClaudePlugin": "Applied to Claude plugin",
"removedFromClaudePlugin": "Removed from Claude plugin",
"syncClaudePluginFailed": "Sync Claude plugin failed"
},
"confirm": {
"deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
},
"settings": {
"title": "Settings",
"general": "General",
"language": "Language",
"importExport": "Import/Export Config",
"exportConfig": "Export Config to File",
"selectConfigFile": "Select Config File",
"import": "Import",
"importing": "Importing...",
"importSuccess": "Import Successful!",
"importFailed": "Import Failed",
"configExported": "Config exported to:",
"exportFailed": "Export failed",
"selectFileFailed": "Failed to select file",
"configCorrupted": "Config file may be corrupted or invalid",
"backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "Window Behavior",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"configFileLocation": "Configuration File Location",
"openFolder": "Open Folder",
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory in WSL to keep provider data consistent with the main environment.",
"claudeConfigDir": "Claude Code Configuration Directory",
"codexConfigDir": "Codex Configuration Directory",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
"updateTo": "Update to v{{version}}",
"updating": "Updating...",
"checking": "Checking...",
"upToDate": "Up to Date",
"releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes"
},
"apps": {
"claude": "Claude Code",
"codex": "Codex"
},
"console": {
"providerSwitchReceived": "Received provider switch event:",
"setupListenerFailed": "Failed to setup provider switch listener:",
"updateProviderFailed": "Update provider failed:",
"autoImportFailed": "Auto import default configuration failed:",
"openLinkFailed": "Failed to open link:",
"getVersionFailed": "Failed to get version info:",
"loadSettingsFailed": "Failed to load settings:",
"getConfigPathFailed": "Failed to get config path:",
"getConfigDirFailed": "Failed to get config directory:",
"detectPortableFailed": "Failed to detect portable mode:",
"saveSettingsFailed": "Failed to save settings:",
"updateFailed": "Update failed:",
"checkUpdateFailed": "Check for updates failed:",
"openConfigFolderFailed": "Failed to open config folder:",
"selectConfigDirFailed": "Failed to select config directory:",
"getDefaultConfigDirFailed": "Failed to get default config directory:",
"openReleaseNotesFailed": "Failed to open release notes:"
}
}

124
src/i18n/locales/zh.json Normal file
View File

@@ -0,0 +1,124 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex 供应商切换工具"
},
"common": {
"add": "添加",
"edit": "编辑",
"delete": "删除",
"save": "保存",
"cancel": "取消",
"confirm": "确定",
"close": "关闭",
"settings": "设置",
"about": "关于",
"version": "版本",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"unknown": "未知"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
"toggleDarkMode": "切换到暗色模式",
"toggleLightMode": "切换到亮色模式",
"addProvider": "添加供应商",
"switchToChinese": "切换到中文",
"switchToEnglish": "切换到英文"
},
"provider": {
"noProviders": "还没有添加任何供应商",
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
"currentlyUsing": "当前使用",
"enable": "启用",
"inUse": "使用中",
"editProvider": "编辑供应商",
"deleteProvider": "删除供应商",
"addNewProvider": "添加新供应商",
"configError": "配置错误",
"notConfigured": "未配置官网地址",
"applyToClaudePlugin": "应用到 Claude 插件",
"removeFromClaudePlugin": "从 Claude 插件移除"
},
"notifications": {
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchFailed": "切换失败,请检查配置",
"autoImported": "已从现有配置创建默认供应商",
"saveFailed": "保存失败:{{error}}",
"saveFailedGeneric": "保存失败,请重试",
"appliedToClaudePlugin": "已应用到 Claude 插件",
"removedFromClaudePlugin": "已从 Claude 插件移除",
"syncClaudePluginFailed": "同步 Claude 插件失败"
},
"confirm": {
"deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
},
"settings": {
"title": "设置",
"general": "通用",
"language": "界面语言",
"importExport": "导入导出配置",
"exportConfig": "导出配置到文件",
"selectConfigFile": "选择配置文件",
"import": "导入",
"importing": "导入中...",
"importSuccess": "导入成功!",
"importFailed": "导入失败",
"configExported": "配置已导出到:",
"exportFailed": "导出失败",
"selectFileFailed": "选择文件失败",
"configCorrupted": "配置文件可能已损坏或格式不正确",
"backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "窗口行为",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"configFileLocation": "配置文件位置",
"openFolder": "打开文件夹",
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定 WSL 里的配置目录,供应商数据与主环境保持一致。",
"claudeConfigDir": "Claude Code 配置目录",
"codexConfigDir": "Codex 配置目录",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
"updateTo": "更新到 v{{version}}",
"updating": "更新中...",
"checking": "检查中...",
"upToDate": "已是最新",
"releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志"
},
"apps": {
"claude": "Claude Code",
"codex": "Codex"
},
"console": {
"providerSwitchReceived": "收到供应商切换事件:",
"setupListenerFailed": "设置供应商切换监听器失败:",
"updateProviderFailed": "更新供应商失败:",
"autoImportFailed": "自动导入默认配置失败:",
"openLinkFailed": "打开链接失败:",
"getVersionFailed": "获取版本信息失败:",
"loadSettingsFailed": "加载设置失败:",
"getConfigPathFailed": "获取配置路径失败:",
"getConfigDirFailed": "获取配置目录失败:",
"detectPortableFailed": "检测便携模式失败:",
"saveSettingsFailed": "保存设置失败:",
"updateFailed": "更新失败:",
"checkUpdateFailed": "检查更新失败:",
"openConfigFolderFailed": "打开配置文件夹失败:",
"selectConfigDirFailed": "选择配置目录失败:",
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
"openReleaseNotesFailed": "打开更新日志失败:"
}
}

View File

@@ -34,7 +34,7 @@ export const cardStyles = {
// 带悬浮效果的卡片
interactive:
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-[border-color,box-shadow] duration-200",
// 选中/激活态卡片
selected:

View File

@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { Provider, Settings } from "../types";
import { Provider, Settings, CustomEndpoint } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -18,6 +18,13 @@ interface ImportResult {
message?: string;
}
export interface EndpointLatencyResult {
url: string;
latency: number | null;
status?: number;
error?: string;
}
// Tauri API 封装,提供统一的全局 API 接口
export const tauriAPI = {
// 获取所有供应商
@@ -132,40 +139,22 @@ export const tauriAPI = {
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
try {
return await invoke("get_claude_config_status");
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 获取应用配置状态(通用)
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
try {
return await invoke("get_config_status", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 打开配置文件夹
// 打开配置目录(按应用类型)
openConfigFolder: async (app?: AppType): Promise<void> => {
try {
await invoke("open_config_folder", { app_type: app, app });
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error("打开配置目录失败:", error);
}
},
// 选择配置目录(可选默认路径)
selectConfigDirectory: async (defaultPath?: string): Promise<string | null> => {
try {
return await invoke("pick_directory", { defaultPath });
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
@@ -181,49 +170,20 @@ export const tauriAPI = {
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke("update_tray_menu");
return await invoke<boolean>("update_tray_menu");
} catch (error) {
console.error("更新托盘菜单失败:", error);
return false;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
return await listen("provider-switched", (event) => {
callback(event.payload as { appType: string; providerId: string });
});
},
// (保留空位,取消迁移提示)
// 选择配置目录
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
const sanitized =
defaultPath && defaultPath.trim() !== ""
? defaultPath
: undefined;
return await invoke<string | null>("pick_directory", {
defaultPath: sanitized,
});
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
// 获取设置
// 获取应用设置
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
return { showInTray: true, minimizeToTrayOnClose: true };
throw error;
}
},
@@ -275,37 +235,214 @@ export const tauriAPI = {
}
},
// VS Code: 获取 settings.json 状态
getVSCodeSettingsStatus: async (): Promise<{
exists: boolean;
path: string;
error?: string;
}> => {
// Claude 插件:获取 ~/.claude/config.json 状态
getClaudePluginStatus: async (): Promise<ConfigStatus> => {
try {
return await invoke("get_vscode_settings_status");
return await invoke<ConfigStatus>("get_claude_plugin_status");
} catch (error) {
console.error("获取 VS Code 设置状态失败:", error);
console.error("获取 Claude 插件状态失败:", error);
return { exists: false, path: "", error: String(error) };
}
},
// VS Code: 读取 settings.json 文本
readVSCodeSettings: async (): Promise<string> => {
// Claude 插件:读取配置内容
readClaudePluginConfig: async (): Promise<string | null> => {
try {
return await invoke("read_vscode_settings");
return await invoke<string | null>("read_claude_plugin_config");
} catch (error) {
throw new Error(`读取 VS Code 设置失败: ${String(error)}`);
throw new Error(`读取 Claude 插件配置失败: ${String(error)}`);
}
},
// VS Code: 写回 settings.json 文本(不自动创建)
writeVSCodeSettings: async (content: string): Promise<boolean> => {
// Claude 插件:应用或移除固定配置
applyClaudePluginConfig: async (options: {
official: boolean;
}): Promise<boolean> => {
const { official } = options;
try {
return await invoke("write_vscode_settings", { content });
return await invoke<boolean>("apply_claude_plugin_config", { official });
} catch (error) {
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
throw new Error(`写入 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude 插件:检测是否已应用目标配置
isClaudePluginApplied: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_claude_plugin_applied");
} catch (error) {
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
}
},
// ours: 第三方/自定义供应商——测速与端点管理
// 第三方/自定义供应商:批量测试端点延迟
testApiEndpoints: async (
urls: string[],
options?: { timeoutSecs?: number },
): Promise<EndpointLatencyResult[]> => {
try {
return await invoke<EndpointLatencyResult[]>("test_api_endpoints", {
urls,
timeout_secs: options?.timeoutSecs,
});
} catch (error) {
console.error("测速调用失败:", error);
throw error;
}
},
// 获取自定义端点列表
getCustomEndpoints: async (
appType: AppType,
providerId: string,
): Promise<CustomEndpoint[]> => {
try {
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
// 兼容不同后端参数命名
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
});
} catch (error) {
console.error("获取自定义端点列表失败:", error);
return [];
}
},
// 添加自定义端点
addCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("add_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("添加自定义端点失败:", error);
// 尽量抛出可读信息
if (error instanceof Error) {
throw error;
} else {
throw new Error(String(error));
}
}
},
// 删除自定义端点
removeCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("remove_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("删除自定义端点失败:", error);
throw error;
}
},
// 更新端点最后使用时间
updateEndpointLastUsed: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("update_endpoint_last_used", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("更新端点最后使用时间失败:", error);
// 不抛出错误,因为这不是关键操作
}
},
// theirs: 导入导出与文件对话框
// 导出配置到文件
exportConfigToFile: async (filePath: string): Promise<{
success: boolean;
message: string;
filePath: string;
}> => {
try {
return await invoke("export_config_to_file", { filePath });
} catch (error) {
throw new Error(`导出配置失败: ${String(error)}`);
}
},
// 从文件导入配置
importConfigFromFile: async (filePath: string): Promise<{
success: boolean;
message: string;
backupId?: string;
}> => {
try {
return await invoke("import_config_from_file", { filePath });
} catch (error) {
throw new Error(`导入配置失败: ${String(error)}`);
}
},
// 保存文件对话框
saveFileDialog: async (defaultName: string): Promise<string | null> => {
try {
const result = await invoke<string | null>("save_file_dialog", { defaultName });
return result;
} catch (error) {
console.error("打开保存对话框失败:", error);
return null;
}
},
// 打开文件对话框
openFileDialog: async (): Promise<string | null> => {
try {
const result = await invoke<string | null>("open_file_dialog");
return result;
} catch (error) {
console.error("打开文件对话框失败:", error);
return null;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
const unlisten = await listen("provider-switched", (event) => {
try {
// 事件 payload 形如 { appType: string, providerId: string }
callback(event.payload as any);
} catch (e) {
console.error("处理 provider-switched 事件失败: ", e);
}
});
return unlisten;
},
};
// 创建全局 API 对象,兼容现有代码

View File

@@ -5,6 +5,8 @@ import { UpdateProvider } from "./contexts/UpdateContext";
import "./index.css";
// 导入 Tauri API自动绑定到 window.api
import "./lib/tauri-api";
// 导入国际化配置
import "./i18n";
// 根据平台添加 body class便于平台特定样式
try {
@@ -23,5 +25,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<UpdateProvider>
<App />
</UpdateProvider>
</React.StrictMode>,
</React.StrictMode>
);

View File

@@ -13,6 +13,8 @@ export interface Provider {
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
meta?: ProviderMeta;
}
export interface AppConfig {
@@ -20,6 +22,19 @@ export interface AppConfig {
current: string;
}
// 自定义端点配置
export interface CustomEndpoint {
url: string;
addedAt: number;
lastUsed?: number;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
@@ -30,4 +45,10 @@ export interface Settings {
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
}

View File

@@ -1,5 +1,7 @@
// 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/providerPresets";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
@@ -173,6 +175,51 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
}
};
// 模板变量替换
export const applyTemplateValues = (
config: any,
templateValues: Record<string, TemplateValueConfig> | undefined
): any => {
const resolvedValues = Object.fromEntries(
Object.entries(templateValues ?? {}).map(([key, value]) => {
const resolvedValue =
value.editorValue !== undefined
? value.editorValue
: value.defaultValue ?? "";
return [key, resolvedValue];
})
);
const replaceInString = (str: string): string => {
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
const placeholder = `\${${key}}`;
if (!acc.includes(placeholder)) {
return acc;
}
return acc.split(placeholder).join(value ?? "");
}, str);
};
const traverse = (obj: any): any => {
if (typeof obj === "string") {
return replaceInString(obj);
}
if (Array.isArray(obj)) {
return obj.map(traverse);
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = traverse(value);
}
return result;
}
return obj;
};
return traverse(config);
};
// 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (jsonString: string): boolean => {
try {
@@ -318,3 +365,25 @@ export const getCodexBaseUrl = (
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
export const setCodexBaseUrl = (
configText: string,
baseUrl: string,
): string => {
const trimmed = baseUrl.trim();
if (!trimmed) {
return configText;
}
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(configText)) {
return configText.replace(pattern, replacementLine);
}
const prefix = configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
return `${prefix}${replacementLine}\n`;
};

View File

@@ -1,134 +0,0 @@
import { applyEdits, modify, parse } from "jsonc-parser";
const fmt = { insertSpaces: true, tabSize: 2, eol: "\n" } as const;
export interface AppliedCheck {
hasApiBase: boolean;
apiBase?: string;
hasPreferredAuthMethod: boolean;
}
export function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}
const isDocEmpty = (s: string) => s.trim().length === 0;
// 检查 settings.jsonJSONC 文本)中是否已经应用了我们的键
export function detectApplied(content: string): AppliedCheck {
try {
// 允许 JSONC 的宽松解析jsonc-parser 的 parse 可以直接处理注释
const data = parse(content) as any;
const apiBase = data?.["chatgpt.apiBase"];
const method = data?.["chatgpt.config"]?.preferred_auth_method;
return {
hasApiBase: typeof apiBase === "string",
apiBase,
hasPreferredAuthMethod: typeof method === "string",
};
} catch {
return { hasApiBase: false, hasPreferredAuthMethod: false };
}
}
// 生成“清理我们管理的键”后的文本(仅删除我们写入的两个键)
export function removeManagedKeys(content: string): string {
if (isDocEmpty(content)) return content; // 空文档无需删除
let out = content;
// 删除 chatgpt.apiBase
try {
out = applyEdits(
out,
modify(out, ["chatgpt.apiBase"], undefined, { formattingOptions: fmt }),
);
} catch {
// 忽略删除失败
}
// 删除 chatgpt.config.preferred_auth_method注意 chatgpt.config 是顶层带点的键)
try {
out = applyEdits(
out,
modify(out, ["chatgpt.config", "preferred_auth_method"], undefined, {
formattingOptions: fmt,
}),
);
} catch {
// 忽略删除失败
}
// 兼容早期错误写入:若曾写成嵌套 chatgpt.config.preferred_auth_method也一并清理
try {
out = applyEdits(
out,
modify(out, ["chatgpt", "config", "preferred_auth_method"], undefined, {
formattingOptions: fmt,
}),
);
} catch {
// 忽略删除失败
}
// 清理 chatgpt.config 的异常情况:
// 1. 早期遗留的标量值(字符串/数字/null等
// 2. 空对象
// 3. 数组类型
try {
const data = parse(out) as any;
const cfg = data?.["chatgpt.config"];
// 需要清理的情况:
// - 标量值null、字符串、数字等
// - 数组
// - 空对象
const shouldRemove = cfg !== undefined && (
cfg === null ||
typeof cfg !== "object" ||
Array.isArray(cfg) ||
(typeof cfg === "object" && Object.keys(cfg).length === 0)
);
if (shouldRemove) {
out = applyEdits(
out,
modify(out, ["chatgpt.config"], undefined, { formattingOptions: fmt }),
);
}
} catch {
// 忽略解析失败,保持已删除的键
}
return out;
}
// 生成“应用供应商到 VS Code”后的文本
// - 先清理我们管理的键
// - 再根据是否官方决定写入(官方:不写入;非官方:写入两个键)
export function applyProviderToVSCode(
content: string,
opts: { baseUrl?: string | null; isOfficial?: boolean },
): string {
let out = removeManagedKeys(content);
if (!opts.isOfficial && opts.baseUrl) {
const apiBase = normalizeBaseUrl(opts.baseUrl);
if (isDocEmpty(out)) {
// 简化:空文档直接写入新对象
const obj: any = {
"chatgpt.apiBase": apiBase,
"chatgpt.config": { preferred_auth_method: "apikey" },
};
out = JSON.stringify(obj, null, 2) + "\n";
} else {
out = applyEdits(
out,
modify(out, ["chatgpt.apiBase"], apiBase, { formattingOptions: fmt }),
);
out = applyEdits(
out,
modify(out, ["chatgpt.config", "preferred_auth_method"], "apikey", {
formattingOptions: fmt,
}),
);
}
}
return out;
}

54
src/vite-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="vite/client" />
import { Provider, Settings } from "./types";
import { Provider, Settings, CustomEndpoint } from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -29,6 +29,18 @@ declare global {
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
getConfigDir: (app?: AppType) => Promise<string>;
saveFileDialog: (defaultName: string) => Promise<string | null>;
openFileDialog: () => Promise<string | null>;
exportConfigToFile: (filePath: string) => Promise<{
success: boolean;
message: string;
filePath: string;
}>;
importConfigFromFile: (filePath: string) => Promise<{
success: boolean;
message: string;
backupId?: string;
}>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
@@ -42,10 +54,42 @@ declare global {
isPortable: () => Promise<boolean>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// VS Code settings.json 能力
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
readVSCodeSettings: () => Promise<string>;
writeVSCodeSettings: (content: string) => Promise<boolean>;
// Claude 插件配置能力
getClaudePluginStatus: () => Promise<ConfigStatus>;
readClaudePluginConfig: () => Promise<string | null>;
applyClaudePluginConfig: (options: {
official: boolean;
}) => Promise<boolean>;
isClaudePluginApplied: () => Promise<boolean>;
testApiEndpoints: (
urls: string[],
options?: { timeoutSecs?: number },
) => Promise<Array<{
url: string;
latency: number | null;
status?: number;
error?: string;
}>>;
// 自定义端点管理
getCustomEndpoints: (
appType: AppType,
providerId: string
) => Promise<CustomEndpoint[]>;
addCustomEndpoint: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
removeCustomEndpoint: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
updateEndpointLastUsed: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
};
platform: {
isMac: boolean;