29 Commits

Author SHA1 Message Date
Jason
ea81c3d839 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
2025-09-28 20:35:14 +08:00
TinsFox
aa05a8475f 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.
2025-09-28 18:23:23 +08:00
farion1231
fd0e83ebd5 fix: remove unnecessary openai auth requirement from third-party config
Remove the env_key and requires_openai_auth fields from third-party provider config generation as they are not needed for custom API endpoints.
2025-09-28 09:53:55 +08:00
farion1231
e969bdbd73 feat: improve SettingsModal UX with scrolling and save icon
- Add scrollable content area with max height constraint
- Add Save icon to save button for better visual clarity
2025-09-27 11:20:37 +08:00
Jason
7435a34c66 update roadmap 2025-09-26 21:57:38 +08:00
ShaSan
5d2d15690c feat: support minimizing window to tray on close (#41)
fix: grant set_skip_taskbar permission through default capability

chore: align settings casing and defaults between Rust and frontend

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-26 20:18:11 +08:00
farion1231
11ee8bddf7 refactor: consolidate chatgpt.config cleanup logic
Merged duplicate try-catch blocks that handle invalid chatgpt.config values.
Now handles all edge cases (scalar values, arrays, empty objects) in a single
unified check, reducing code duplication and improving performance by parsing
JSON only once.
2025-09-26 09:27:56 +08:00
Jason
186c361a79 refactor: reorganize documentation structure
- Remove docs/ from .gitignore to track documentation files
- Delete completed plan documents (encrypted-config-plan.md, updater-plan.md)
- Add roadmap.md with project milestones and future features
2025-09-25 23:51:48 +08:00
Jason
cc1caea36d Add support for qwen3-max 2025-09-24 22:28:32 +08:00
Jason
9ede0ad27d feat: add portable mode support and improve update handling
- Add portable.ini marker file creation in GitHub Actions for portable builds
- Implement is_portable_mode() command to detect portable execution
- Redirect portable users to GitHub releases page for manual updates
- Change update URL to point to latest releases page
- Integrate portable mode detection in Settings UI
2025-09-24 11:25:33 +08:00
farion1231
20f0dd7e1c fix: improve per-user MSI installer component tracking
- Change File KeyPath to "no" and use registry value as KeyPath instead
- Add registry entry for better component state tracking
- Enhance uninstall cleanup to remove LocalAppData files and folders
2025-09-23 20:55:30 +08:00
Jason
4dd07dfd85 Switch MSI installer to per-user LocalAppData target 2025-09-23 14:41:28 +08:00
Jason
8c01be42fa feat: add single instance plugin to prevent multiple app instances
Integrates tauri_plugin_single_instance to ensure only one instance of the application
runs at a time. When attempting to launch a second instance, it will focus the existing
window instead.
2025-09-23 10:02:23 +08:00
Jason
aaf1af0743 release: bump version to v3.3.1
Add support for DeepSeek-V3.1-Terminus model with emergency hotfix release
2025-09-22 23:31:59 +08:00
Jason
aeb0007957 feat: add support for DeepSeek-V3.1-Terminus 2025-09-22 23:20:50 +08:00
Jason
077d491720 release: bump version to 3.3.0
Update version across all package files and add comprehensive changelog for v3.3.0 release featuring VS Code integration, shared config snippets, enhanced Codex wizard, and cross-platform improvements.
2025-09-22 22:50:07 +08:00
Jason
7e9930fe50 fix: stabilize provider action button width and improve visual consistency
- Set fixed width (76px) for enable/active buttons to prevent layout shift
- Hide play icon in active state to optimize space usage
- Add center alignment and nowrap to ensure consistent appearance
2025-09-22 22:16:47 +08:00
Jason
b17d915086 refactor: optimize React state updates and improve UI text clarity
- Use functional setState to ensure proper state updates in ProviderForm
- Improve Chinese UI text consistency in CodexConfigEditor:
  - Change "API 基础地址" to "API 请求地址" for clarity
  - Simplify "供应商官网" to "官网地址"
  - Update placeholder text for consistency
- Move requires_openai_auth to model_providers section in Codex config template
2025-09-22 16:25:58 +08:00
Jason
3e834e2c38 fix: correct HTML pattern attribute regex escape in Codex config wizard
Fixed validation pattern in provider name input field by removing incorrect double backslash escape (\S to \S) to properly validate non-whitespace input
2025-09-22 15:50:33 +08:00
Jason
cae625dab1 refactor: update VSCode apply button style to match Linear design theme
- Changed from solid emerald button to bordered style for better visual hierarchy
- Apply action: gray border, blue on hover (consistent with theme color)
- Remove action: gray border, red on hover (indicates destructive action)
- Better distinction between apply/remove states while maintaining Linear's minimalist aesthetic
2025-09-22 15:35:46 +08:00
Jason
122d7f1ad6 feat: add created_at timestamp field to Provider struct
- Add optional created_at field to track provider creation time
- Serialize field as camelCase (createdAt) for JSON compatibility
- Skip serialization when field is None to maintain backward compatibility
2025-09-22 10:46:18 +08:00
Jason
7eaf284400 feat: add dedicated API key URL support for third-party providers
- Add optional apiKeyUrl field to ProviderPreset interface for third-party providers
- Update ProviderForm to prioritize apiKeyUrl over websiteUrl for third-party category
- Make provider display name required in CodexConfigEditor with validation
- Configure PackyCode preset with affiliate API key URL

This allows third-party providers to have separate URLs for their service homepage
and API key acquisition, improving user experience when obtaining API keys.
2025-09-21 23:09:53 +08:00
TinsFox
86ef7afbdf fix: add @codemirror/lint (#45)
LGTM!
2025-09-21 20:54:58 +08:00
farion1231
615c431875 feat: enhance Codex provider configuration wizard with display name field
- Add separate display name field for provider (supports Chinese)
- Keep provider code field for internal identifier (English only)
- Add onNameChange callback to update provider name from wizard
- Improve code formatting consistency in ProviderForm
2025-09-21 20:37:01 +08:00
farion1231
d041ea7a56 refactor: improve form validation in CodexConfigEditor using HTML5 validation API
- Replace custom error state with native HTML5 form validation
- Add useRef hooks for input field validation management
- Add pattern attributes to enforce non-empty input validation
- Leverage browser's built-in validation UI for better UX
- Extract closeTemplateModal function for consistent modal closing
2025-09-21 20:04:50 +08:00
farion1231
c4c1747563 feat: add configuration wizard for custom Codex providers
- Add quick configuration wizard modal for custom providers
- Generate auth.json and config.toml from simple inputs (API key, base URL, model name)
- Extract generation logic into reusable functions (generateThirdPartyAuth, generateThirdPartyConfig)
- Pre-populate custom template when selecting custom option
- Add wizard button link in PresetSelector for custom mode
- Update PackyCode preset to use the new generation functions
2025-09-21 19:04:56 +08:00
farion1231
c284fe8348 fix: prevent text wrapping in VSCode apply button on Windows
Add whitespace-nowrap class to ensure button text stays on single line
across different font rendering systems
2025-09-21 10:50:08 +08:00
Jason Young
8f932b7358 Merge pull request #44 from farion1231/feature/wsl-support
feat: improve WSL config directory support
2025-09-21 10:24:49 +08:00
Jason
8c826b3073 fix(codex): improve config snippet handling with consistent trimming
- Trim config snippets during initialization from localStorage and defaults
- Trim snippets before comparison in toggle and change handlers
- Store trimmed values to localStorage for consistency
- Prevents configuration matching issues caused by accidental whitespace
2025-09-20 23:00:53 +08:00
40 changed files with 2114 additions and 1073 deletions

View File

@@ -226,6 +226,12 @@ jobs:
$portableDir = 'release-assets/CC-Switch-Portable'
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath $portableDir
$portableIniPath = Join-Path $portableDir 'portable.ini'
$portableContent = @(
'# CC Switch portable build marker',
'portable=true'
)
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
Remove-Item -Recurse -Force $portableDir
Write-Host 'Windows portable zip created'

1
.gitignore vendored
View File

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

View File

@@ -5,6 +5,25 @@ 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.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
- 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
### 🔧 Improvements
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
- Add a `created_at` timestamp to provider records for future sorting and analytics
### 🐛 Fixes
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
- Bundle `@codemirror/lint` to reinstate live linting in config editors
## [3.2.0] - 2025-09-13
### ✨ New Features

View File

@@ -1,28 +1,27 @@
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.3.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.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.2.0
## 功能特性v3.3.0
- **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
- **系统托盘(菜单栏)快速切换**:按应用分组(Claude / Codex),勾选态展示当前供应商
- **内置更新器**:集成 Tauri Updater支持检测/下载/安装与一键重启
- **单一事实源SSOT**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
- **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
- **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
- **深色模式优化**Tailwind v4 适配与选择器修正
- **丰富预设与自定义**Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
- **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 和使用体验优化
## 界面预览
@@ -54,7 +53,7 @@
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
从 [Releases](../../releases) 页面下载最新版本的 `.deb`或者 `AppImage`安装包
## 使用说明
@@ -70,7 +69,7 @@
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Codex 说明(v3.2.0 SSOT
### Codex 说明SSOT
- 配置目录:`~/.codex/`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
@@ -82,7 +81,7 @@
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Claude Code 说明(v3.2.0 SSOT
### Claude Code 说明SSOT
- 配置目录:`~/.claude/`
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
@@ -94,9 +93,9 @@
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 迁移与归档v3.2.0
### 迁移与归档(v3.2.0
- 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重若当前为空将 live 合并项设为当前

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

@@ -1,193 +0,0 @@
# CC Switch 加密配置与切换重构方案V1
## 1. 目标与范围
- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源SSOT改为“加密落盘”切换时从解密后的内存配置写入目标应用主配置Claude/Codex
- 范围:
- 后端Rust/Tauri新增加密模块与读写改造。
- 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。
- 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。
- 兼容旧明文配置v1/v2首次保存迁移为加密文件。
## 2. 背景现状(简述)
- 当前:
- 全局配置:`~/.cc-switch/config.json`v2`MultiAppConfig`,含多个 `ProviderManager`)。
- 切换依赖“供应商副本文件”Claude`~/.claude/settings-<name>.json`Codex`~/.codex/auth-<name>.json``config-<name>.toml`)→ 恢复到主配置。
- 启动:若对应 App 的供应商列表为空,可从现有主配置自动创建一条“默认项”并设为当前。
- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。
## 3. 总体方案
- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。
- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到当前选中供应商(由 `manager.current` 指向),保留外部修改。
- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。
- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`v1/v2与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive/<ts>/`,生成 `manifest.json` 以便恢复;默认不做静默清理。
## 4. 密钥管理
- 存储系统级凭据管家keyring crate
- Service`cc-switch`Account`config-key-v1`内容Base64 编码的 32 字节随机密钥AES-256
- 首次运行:生成随机密钥,写入 Keychain。
- 进程内缓存:启动加载后缓存密钥,避免重复 IO。
- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。
- 回退策略Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。
## 5. 加密封装格式
- 文件:`~/.cc-switch/config.enc.json`
- 结构JSON 封装,便于演进):
```json
{
"v": 1,
"alg": "AES-256-GCM",
"nonce": "<base64-nonce>",
"ct": "<base64-ciphertext>"
}
```
- 明文:`serde_json::to_vec(MultiAppConfig)`加密AES-GCM12 字节随机 nonce每次保存生成新 nonce。
## 6. 模块与改造点
- 新增 `src-tauri/src/secure_store.rs`
- `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。
- `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)``decrypt_bytes(key, nonce, ciphertext)`。
- `read_encrypted_config() -> Result<MultiAppConfig, String>`:读取 `config.enc.json`、解析封装、解密、反序列化。
- `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。
- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档):
- `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`v1/v2与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`返回分组清单、大小、mtime永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。
- `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive/<ts>/` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`记录原路径、归档路径、大小、mtime、sha256、类别同分区 `rename`跨分区“copy + fsync + remove”。
- `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。
- 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。
- 安全护栏:操作前后做 mtime/hash 复核CAS发生变化中止并提示“外部已修改”。
- 调整 `src-tauri/src/app_config.rs`
- `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文:
- 若检测到 v1`ProviderManager`)→ 迁移到 v2原有逻辑保留
- `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup.<ts>.json`(或保留为只读,视实现选择)。
- 调整 `src-tauri/src/commands.rs::switch_provider`
- Claude
1. 回填:若 `~/.claude/settings.json` 存在且存在当前指针 → 读取 JSON写回 `manager.providers[manager.current].settings_config`。
2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。
- Codex
1. 回填:读取 `~/.codex/auth.json`JSON与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[manager.current].settings_config`。
2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML
- 更新 `manager.current = id``state.save()` → 触发加密保存。
- 保留/清理:
- 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。
- 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。
## 7. 数据流与时序
- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。
- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。
- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新当前指针(`manager.current`)→ `state.save()`。
- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。
## 8. 迁移策略
- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。
- 旧版支持:
- v1 明文(单 `ProviderManager`)→ 自动迁移为 v2已有逻辑
- v2 明文 → 直接加载。
- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup.<ts>.json`(或保留为只读)。
- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。
- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive/<ts>/`,保留 `manifest.json` 以支持恢复。
## 9. 回滚策略
- 加密回滚:保留 `config.v1.backup.<ts>.json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。
- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。
## 10. 安全与性能
- 算法AES-256-GCMAEAD随机 12 字节 nonce每次保存新 nonce。
- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 120ms可缓存。
- 可靠性:原子写入(临时文件 + rename写入失败不破坏现有文件。
- 可选增强:`zeroize` 清理密钥与明文Claude 配置 JSON Schema 校验。
- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。
## 11. API 与 UX 影响
- 前端 API现有行为不变新增清理相关命令Tauri供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive``purge_archived` 可选)。
- UI 提示:在“配置文件位置”旁提示“已加密存储”。
- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。
- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。
## 12. 开发任务拆解(阶段一为本次交付)
- 阶段一(核心改造 + 清理能力最小闭环)
- 新增模块 `secure_store.rs`Keychain 与加解密工具函数。
- 改造 `app_config.rs``load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。
- 改造 `commands.rs::switch_provider`
- 回填 live 配置 → 写入目标主配置Claude/Codex
- 去除对“副本恢复”的依赖(保留函数以便回退)。
- 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。
- 保持 `import_default_config`、`get_config_status` 行为不变。
- 阶段二(清理与增强)
- 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。
- Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。
- 阶段三(安全升级)
- 密钥轮换;可选 passphraseKDF: Argon2id + salt
## 14. 验收标准
- 功能:
- 无加密明文文件也能启动并正确读写;
- 切换成功将内存配置写入主配置;
- 外部手改在下一次切换前被回填保存;
- 旧配置自动迁移并生成加密文件;
- Keychain/解密异常时不损坏已有文件,给出可理解错误。
- 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。
- 质量:
- 关键路径加错误处理与日志;
- 写入采用原子替换;
- 代码变更集中、最小侵入,与现有风格一致。
- 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。
## 15. 风险与对策
- Keychain 不可用或权限受限:
- 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。
- 加密文件损坏:
- 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。
- 与“副本文件”并存导致混淆:
- 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。
- 清理误删或不可逆:
- 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。
## 16. 发布与回退
- 发布:随 Tauri 应用正常发布,无需前端变更。
- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。
## 17. 旧文件清理与归档(新增)
- 归档对象:
- `~/.cc-switch/config.json`v1/v2迁移成功后
- `~/.claude/settings-*.json`(保留 `settings.json`
- `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`
- 归档位置与结构:`~/.cc-switch/archive/<timestamp>/{cc-switch,claude,codex}/...`
- `manifest.json`记录原路径、归档路径、大小、mtime、sha256、类别v1/v2/claude/codex用于恢复与可视化。
- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。
- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验跨分区采用“copy+fsync+remove”失败即时回滚并提示。
## 18. 变更点清单(代码)
- 新增:`src-tauri/src/secure_store.rs`
- 修改:
- `src-tauri/src/app_config.rs`load/save 加密化、迁移与原子写入)
- `src-tauri/src/commands.rs`switch_provider 改为内存 → 主配置,并回填 live
- `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件)
- 保持:
- `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动)
- 前端 `src/lib/tauri-api.ts` 与 UI 逻辑
## 19. 开放问题(待确认)
- Keychain 失败时是否提供“本地明文密钥文件600 权限)”的应急模式(当前建议:不提供,保持只读)。
- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。
- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30
---
以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需我可以继续补充任务看板Issue 列表)与实施顺序的 PR 规划。

8
docs/roadmap.md Normal file
View File

@@ -0,0 +1,8 @@
- 自动升级自定义路径 ✅
- win 绿色版报毒问题 ✅
- codex 更多预设供应商
- mcp 管理器
- i18n
- gemini cli
- homebrew 支持
- 自定义 vscode 路径

View File

@@ -1,91 +0,0 @@
# 更新功能开发计划Tauri v2 Updater
> 目标:基于 Tauri v2 官方 Updater完成“检查更新 → 下载 → 安装 → 重启”的完整闭环;提供清晰的前后端接口、配置与测试/发布流程。
## 范围与目标
- 能力:静态 JSON 与动态接口两种更新源;可选稳定/测试通道;进度反馈与错误处理。
- 平台macOS `.app` 优先Windows 使用安装器NSIS/MSI
- 安全:启用 Ed25519 更新签名校验;上线前建议平台代码签名与公证。
## 架构与依赖
- 插件:`tauri-plugin-updater`(更新)、`@tauri-apps/plugin-updater`JS`tauri-plugin-process``@tauri-apps/plugin-process`(重启)。
- 签名与构建:`tauri signer generate` 生成密钥CI/本机注入 `TAURI_SIGNING_PRIVATE_KEY``bundle.createUpdaterArtifacts: true` 生成签名制品。
- 权限:在 `src-tauri/capabilities/default.json` 启用 `updater:default``process:allow-restart`
- 配置(`src-tauri/tauri.conf.json`
- `plugins.updater.pubkey: "<PUBLICKEY.PEM>"`
- `plugins.updater.endpoints: ["<更新源 URL 列表>"]`
- Windows可选`plugins.updater.windows.installMode: "passive|basicUi|quiet"`
## 前端接口设计TypeScript
- 类型
- `type UpdateChannel = 'stable' | 'beta'`
- `type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'restarting' | 'upToDate' | 'error'`
- `type UpdateInfo = { currentVersion: string; availableVersion: string; notes?: string; pubDate?: string }`
- `type UpdateProgressEvent = { event: 'Started' | 'Progress' | 'Finished'; total?: number; downloaded?: number }`
- `type UpdateError = { code: string; message: string; cause?: unknown }`
- `type CheckOptions = { timeout?: number; channel?: UpdateChannel }`
- API`src/lib/updater.ts`
- `getCurrentVersion(): Promise<string>` 读取当前版本。
- `checkForUpdate(opts?: CheckOptions)``up-to-date``{ status: 'available', info, update }`
- `downloadAndInstall(update, onProgress?)` 下载并安装,进度回调映射 Started/Progress/Finished。
- `relaunchApp()` 调用 `@tauri-apps/plugin-process.relaunch()`
- `runUpdateFlow(opts?)` 编排:检查 → 下载安装 → 重启;错误统一抛出 `UpdateError`
- `setUpdateChannel(channel)` 前端记录偏好;实际端点切换见“端点动态化”。
- Hook可选 `useUpdater()`
- 返回 `{ phase, info?, progress?, error?, actions: { check, startUpdate, relaunch } }`
- UI组件建议
- `UpdateBanner`:发现新版本时展示;`UpdaterDialog`:显示说明、进度与错误/重试。
## Rust 集成与权限
- 插件注册(`src-tauri/src/main.rs`
- `app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;`
- `.plugin(tauri_plugin_process::init())` 用于重启。
- Windows 清理钩子(可选):`UpdaterExt::on_before_exit(app.cleanup_before_exit)`,避免安装器启动前文件占用。
- 端点动态化(可选):在 `setup` 根据配置/环境切换 `endpoints`、超时、代理或 headers。
## 更新源与格式
- 静态 JSONlatest.json字段 `version``platforms[target].url``platforms[target].signature``.sig` 内容);可选 `notes``pub_date`
- 动态接口:
- 无更新HTTP 204
- 有更新HTTP 200 → `{ version, url, signature, notes?, pub_date? }`
- 通道组织:`/stable/latest.json``/beta/latest.json`CDN 缓存需可控,回滚可强制刷新。
## 用户流程与 UX
- 流程:检查 → 展示版本/日志 → 下载进度(累计/百分比)→ 安装 → 提示并重启。
- 错误:网络异常(超时/断网/证书)、签名不匹配、权限/文件占用Win。提供“重试/稍后更新”。
- 平台提示:
- macOS建议安装在 `~/Applications`,避免 `/Applications` 提权导致失败。
- Windows优先安装器分发并选择合适 `installMode`
## 测试计划
- 功能:有更新/无更新204/下载中断/重试/安装后重启成功与版本号提升。
- 安全:签名不匹配必须拒绝更新;端点不可用/被劫持有清晰提示。
- 网络:超时/断网/代理场景提示与恢复。
- 平台:
- macOS`/Applications``~/Applications` 的权限差异。
- Windows`passive|basicUi|quiet` 行为差异与成功率。
- 本地自测:以 v1.0.0 运行,构建 v1.0.1 制品+`.sig`,本地 HTTP 托管 `latest.json`,验证全链路。
## 发布与回滚
- 发布CI 推荐):注入 `TAURI_SIGNING_PRIVATE_KEY` → 构建生成各平台制品+签名 → 上传产物与 `latest.json` 至 Releases/CDN。
- 回滚:撤下问题版本或将 `latest.json` 指回上一个稳定版本如需降级Rust 侧可定制版本比较策略(可选)。
## 里程碑与验收
- D1密钥与基础集成插件/配置/权限)。
- D2前端入口与进度 UI静态 JSON 自测通过。
- D3Releases/CDN 端到端验证,平台专项测试。
- D4文档完善、回滚与异常流程演练。
- 验收:两平台完成“发现→下载→安装→重启→版本提升”;签名校验生效;异常有明确提示与可行恢复。
## 待确认
- 更新源托管GitHub Releases 还是自有 CDN
- 是否需要 beta 通道与运行时切换。
- Windows 是否仅支持安装器分发;便携版兼容策略是否需要明确说明。
- UI 文案与样式偏好。
## 落地步骤(实施顺序)
1) 生成 Ed25519 密钥,将公钥写入 `plugins.updater.pubkey`,在构建环境配置 `TAURI_SIGNING_PRIVATE_KEY`
2) `src-tauri` 注册 `tauri-plugin-updater``tauri-plugin-process`,补齐 `capabilities/default.json``tauri.conf.json`
3) 前端新增 `src/lib/updater.ts` 封装与 `UpdateBanner`/`UpdaterDialog` 组件,接入入口按钮。
4) 本地静态 `latest.json` 自测全链路;完善错误与进度提示。
5) 配置 CI 发布产物与 `latest.json`;编写发布/回滚操作手册。

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.2.0",
"version": "3.3.1",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
@@ -27,6 +27,7 @@
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2",
@@ -36,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"
}
}

68
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lint':
specifier: ^6.8.5
version: 6.8.5
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.2
@@ -38,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
@@ -50,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
@@ -156,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'}
@@ -747,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
@@ -887,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'}
@@ -970,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==}
@@ -1076,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
@@ -1588,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: {}
@@ -1689,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:
@@ -1764,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: {}

893
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.2.0"
version = "3.3.1"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
@@ -30,6 +30,9 @@ tauri-plugin-dialog = "2"
dirs = "5.0"
toml = "0.8"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }

View File

@@ -9,6 +9,7 @@
"core:default",
"opener:default",
"updater:default",
"core:window:allow-set-skip-taskbar",
"process:allow-restart",
"dialog:default"
]

View File

@@ -2,8 +2,8 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
@@ -655,9 +655,8 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<serde_json::Value, String> {
serde_json::to_value(crate::settings::get_settings())
.map_err(|e| format!("序列化设置失败: {}", e))
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
}
/// 保存设置
@@ -674,7 +673,7 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases",
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
@@ -682,15 +681,32 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// VS Code: 获取用户 settings.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() })
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() })
Ok(ConfigStatus {
exists: false,
path: preferred.unwrap_or_default().to_string_lossy().to_string(),
})
}
}

View File

@@ -123,6 +123,10 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
@@ -237,12 +241,35 @@ async fn update_tray_menu(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
// 拦截窗口关闭:仅隐藏窗口,保持进程与托盘常驻
let mut builder = tauri::Builder::default();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}));
}
let builder = builder
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
let _ = window.hide();
let settings = crate::settings::get_settings();
if settings.minimize_to_tray_on_close {
api.prevent_close();
let _ = window.hide();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(true);
}
} else {
window.app_handle().exit(0);
}
}
_ => {}
})
@@ -362,6 +389,7 @@ pub fn run() {
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
commands::is_portable_mode,
commands::get_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
@@ -378,6 +406,10 @@ pub fn run() {
match event {
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();

View File

@@ -16,6 +16,9 @@ pub struct Provider {
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
}
impl Provider {
@@ -32,6 +35,7 @@ impl Provider {
settings_config,
website_url,
category: None,
created_at: None,
}
}
}

View File

@@ -9,6 +9,8 @@ use std::sync::{OnceLock, RwLock};
pub struct AppSettings {
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -19,10 +21,15 @@ fn default_show_in_tray() -> bool {
true
}
fn default_minimize_to_tray_on_close() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
claude_config_dir: None,
codex_config_dir: None,
}
@@ -78,8 +85,7 @@ impl AppSettings {
let path = Self::settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建设置目录失败: {}", e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
}
let json = serde_json::to_string_pretty(&normalized)
@@ -113,19 +119,14 @@ fn resolve_override_path(raw: &str) -> PathBuf {
}
pub fn get_settings() -> AppSettings {
settings_store()
.read()
.expect("读取设置锁失败")
.clone()
settings_store().read().expect("读取设置锁失败").clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
new_settings.normalize_paths();
new_settings.save()?;
let mut guard = settings_store()
.write()
.expect("写入设置锁失败");
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}

View File

@@ -1,12 +1,12 @@
use std::path::{PathBuf};
use std::path::PathBuf;
/// 枚举可能的 VS Code 发行版配置目录名称
fn vscode_product_dirs() -> Vec<&'static str> {
vec![
"Code", // VS Code Stable
"Code", // VS Code Stable
"Code - Insiders", // VS Code Insiders
"VSCodium", // VSCodium
"Code - OSS", // OSS 发行版
"VSCodium", // VSCodium
"Code - OSS", // OSS 发行版
]
}
@@ -19,7 +19,11 @@ pub fn candidate_settings_paths() -> Vec<PathBuf> {
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")
home.join("Library")
.join("Application Support")
.join(prod)
.join("User")
.join("settings.json"),
);
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.2.0",
"version": "3.3.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -37,9 +37,13 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
,
],
"windows": {
"wix": {
"template": "wix/per-user-main.wxs"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",

View File

@@ -0,0 +1,360 @@
<?if $(sys.BUILDARCH)="x86"?>
<?define Win64 = "no" ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?elseif $(sys.BUILDARCH)="x64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?elseif $(sys.BUILDARCH)="arm64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else?>
<?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>
<?endif?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="*"
Name="{{product_name}}"
UpgradeCode="{{upgrade_code}}"
Language="!(loc.TauriLanguage)"
Manufacturer="{{manufacturer}}"
Version="{{version}}">
<Package Id="*"
Keywords="Installer"
InstallerVersion="450"
Languages="0"
Compressed="yes"
InstallScope="perUser"
InstallPrivileges="limited"
SummaryCodepage="!(loc.TauriCodepage)"/>
<!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->
<!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->
<Property Id="REINSTALLMODE" Value="amus" />
<!-- Auto launch app after installation, useful for passive mode which usually used in updates -->
<Property Id="AUTOLAUNCHAPP" Secure="yes" />
<!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->
<Property Id="LAUNCHAPPARGS" Secure="yes" />
{{#if allow_downgrades}}
<MajorUpgrade Schedule="afterInstallInitialize" AllowDowngrades="yes" />
{{else}}
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="!(loc.DowngradeErrorMessage)" AllowSameVersionUpgrades="yes" />
{{/if}}
<InstallExecuteSequence>
<RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>
</InstallExecuteSequence>
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
{{#if banner_path}}
<WixVariable Id="WixUIBannerBmp" Value="{{banner_path}}" />
{{/if}}
{{#if dialog_image_path}}
<WixVariable Id="WixUIDialogBmp" Value="{{dialog_image_path}}" />
{{/if}}
{{#if license}}
<WixVariable Id="WixUILicenseRtf" Value="{{license}}" />
{{/if}}
<Icon Id="ProductIcon" SourceFile="{{icon_path}}"/>
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" /> <!-- Remove repair -->
<SetProperty Id="ARPNOMODIFY" Value="1" After="InstallValidate" Sequence="execute"/>
{{#if homepage}}
<Property Id="ARPURLINFOABOUT" Value="{{homepage}}"/>
<Property Id="ARPHELPLINK" Value="{{homepage}}"/>
<Property Id="ARPURLUPDATEINFO" Value="{{homepage}}"/>
{{/if}}
<Property Id="INSTALLDIR">
<!-- First attempt: Search for "InstallDir" -->
<RegistrySearch Id="PrevInstallDirWithName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="InstallDir" Type="raw" />
<!-- Second attempt: If the first fails, search for the default key value (this is how the nsis installer currently stores the path) -->
<RegistrySearch Id="PrevInstallDirNoName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Type="raw" />
</Property>
<!-- launch app checkbox -->
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.LaunchApp)" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
<CustomAction Id="LaunchApplication" Impersonate="yes" FileKey="Path" ExeCommand="[LAUNCHAPPARGS]" Return="asyncNoWait" />
<UI>
<!-- launch app checkbox -->
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
{{#unless license}}
<!-- Skip license dialog -->
<Publish Dialog="WelcomeDlg"
Control="Next"
Event="NewDialog"
Value="InstallDirDlg"
Order="2">1</Publish>
<Publish Dialog="InstallDirDlg"
Control="Back"
Event="NewDialog"
Value="WelcomeDlg"
Order="2">1</Publish>
{{/unless}}
</UI>
<UIRef Id="WixUI_InstallDir" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="DesktopFolder" Name="Desktop">
<Component Id="ApplicationShortcutDesktop" Guid="*">
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
<RemoveFolder Id="DesktopFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
</Component>
</Directory>
<Directory Id="LocalAppDataFolder">
<Directory Id="TauriLocalAppDataPrograms" Name="Programs">
<Directory Id="INSTALLDIR" Name="{{product_name}}"/>
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="{{product_name}}"/>
</Directory>
</Directory>
<DirectoryRef Id="INSTALLDIR">
<Component Id="RegistryEntries" Guid="*">
<RegistryKey Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}">
<RegistryValue Name="InstallDir" Type="string" Value="[INSTALLDIR]" KeyPath="yes" />
</RegistryKey>
<!-- Change the Root to HKCU for perUser installations -->
{{#each deep_link_protocols as |protocol| ~}}
<RegistryKey Root="HKCU" Key="Software\Classes\\{{protocol}}">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:{{bundle_id}} protocol"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="&quot;[!Path]&quot;,0" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[!Path]&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
{{/each~}}
</Component>
<Component Id="Path" Guid="{{path_component_guid}}" Win64="$(var.Win64)">
<File Id="Path" Source="{{main_binary_path}}" KeyPath="no" Checksum="yes"/>
<RegistryValue Root="HKCU" Key="Software\{{manufacturer}}\{{product_name}}" Name="PathComponent" Type="integer" Value="1" KeyPath="yes" />
{{#each file_associations as |association| ~}}
{{#each association.ext as |ext| ~}}
<ProgId Id="{{../../product_name}}.{{ext}}" Advertise="yes" Description="{{association.description}}">
<Extension Id="{{ext}}" Advertise="yes">
<Verb Id="open" Command="Open with {{../../product_name}}" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
{{/each~}}
{{/each~}}
</Component>
{{#each binaries as |bin| ~}}
<Component Id="{{ bin.id }}" Guid="{{bin.guid}}" Win64="$(var.Win64)">
<File Id="Bin_{{ bin.id }}" Source="{{bin.path}}" KeyPath="yes"/>
</Component>
{{/each~}}
{{#if enable_elevated_update_task}}
<Component Id="UpdateTask" Guid="C492327D-9720-4CD5-8DB8-F09082AF44BE" Win64="$(var.Win64)">
<File Id="UpdateTask" Source="update.xml" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskInstaller" Guid="011F25ED-9BE3-50A7-9E9B-3519ED2B9932" Win64="$(var.Win64)">
<File Id="UpdateTaskInstaller" Source="install-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskUninstaller" Guid="D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1" Win64="$(var.Win64)">
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
{{/if}}
{{resources}}
<Component Id="CMP_UninstallShortcut" Guid="*">
<Shortcut Id="UninstallShortcut"
Name="Uninstall {{product_name}}"
Description="Uninstalls {{product_name}}"
Target="[System64Folder]msiexec.exe"
Arguments="/x [ProductCode]" />
<RemoveFile Id="RemoveUserProgramsFiles" Directory="TauriLocalAppDataPrograms" Name="*" On="uninstall" />
<RemoveFolder Id="RemoveUserProgramsFolder" Directory="TauriLocalAppDataPrograms" On="uninstall" />
<RemoveFolder Id="INSTALLDIR"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\\{{manufacturer}}\\{{product_name}}"
Name="Uninstaller Shortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="{{product_name}}"
Description="Runs {{product_name}}"
Target="[!Path]"
Icon="ProductIcon"
WorkingDirectory="INSTALLDIR">
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
</Shortcut>
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Start Menu Shortcut" Type="integer" Value="1" KeyPath="yes"/>
</Component>
</DirectoryRef>
{{#each merge_modules as |msm| ~}}
<DirectoryRef Id="TARGETDIR">
<Merge Id="{{ msm.name }}" SourceFile="{{ msm.path }}" DiskId="1" Language="!(loc.TauriLanguage)" />
</DirectoryRef>
<Feature Id="{{ msm.name }}" Title="{{ msm.name }}" AllowAdvertise="no" Display="hidden" Level="1">
<MergeRef Id="{{ msm.name }}"/>
</Feature>
{{/each~}}
<Feature
Id="MainProgram"
Title="Application"
Description="!(loc.InstallAppFeature)"
Level="1"
ConfigurableDirectory="INSTALLDIR"
AllowAdvertise="no"
Display="expand"
Absent="disallow">
<ComponentRef Id="RegistryEntries"/>
{{#each resource_file_ids as |resource_file_id| ~}}
<ComponentRef Id="{{ resource_file_id }}"/>
{{/each~}}
{{#if enable_elevated_update_task}}
<ComponentRef Id="UpdateTask" />
<ComponentRef Id="UpdateTaskInstaller" />
<ComponentRef Id="UpdateTaskUninstaller" />
{{/if}}
<Feature Id="ShortcutsFeature"
Title="Shortcuts"
Level="1">
<ComponentRef Id="Path"/>
<ComponentRef Id="CMP_UninstallShortcut" />
<ComponentRef Id="ApplicationShortcut" />
<ComponentRef Id="ApplicationShortcutDesktop" />
</Feature>
<Feature
Id="Environment"
Title="PATH Environment Variable"
Description="!(loc.PathEnvVarFeature)"
Level="1"
Absent="allow">
<ComponentRef Id="Path"/>
{{#each binaries as |bin| ~}}
<ComponentRef Id="{{ bin.id }}"/>
{{/each~}}
</Feature>
</Feature>
<Feature Id="External" AllowAdvertise="no" Absent="disallow">
{{#each component_group_refs as |id| ~}}
<ComponentGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each component_refs as |id| ~}}
<ComponentRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_group_refs as |id| ~}}
<FeatureGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_refs as |id| ~}}
<FeatureRef Id="{{ id }}"/>
{{/each~}}
{{#each merge_refs as |id| ~}}
<MergeRef Id="{{ id }}"/>
{{/each~}}
</Feature>
{{#if install_webview}}
<!-- WebView2 -->
<Property Id="WVRTINSTALLED">
<RegistrySearch Id="WVRTInstalledSystem" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" Win64="no" />
<RegistrySearch Id="WVRTInstalledUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw"/>
</Property>
{{#if download_bootstrapper}}
<CustomAction Id='DownloadAndInvokeBootstrapper' Directory="INSTALLDIR" Execute="deferred" ExeCommand='powershell.exe -NoProfile -windowstyle hidden try [\{] [\[]Net.ServicePointManager[\]]::SecurityProtocol = [\[]Net.SecurityProtocolType[\]]::Tls12 [\}] catch [\{][\}]; Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" ; Start-Process -FilePath "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" -ArgumentList ({{webview_installer_args}} &apos;/install&apos;) -Wait' Return='check'/>
<InstallExecuteSequence>
<Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- Embedded webview bootstrapper mode -->
{{#if webview2_bootstrapper_path}}
<Binary Id="MicrosoftEdgeWebview2Setup.exe" SourceFile="{{webview2_bootstrapper_path}}"/>
<CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- Embedded offline installer -->
{{#if webview2_installer_path}}
<Binary Id="MicrosoftEdgeWebView2RuntimeInstaller.exe" SourceFile="{{webview2_installer_path}}"/>
<CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeStandalone' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
{{/if}}
{{#if enable_elevated_update_task}}
<!-- Install an elevated update task within Windows Task Scheduler -->
<CustomAction
Id="CreateUpdateTask"
Return="check"
Directory="INSTALLDIR"
Execute="commit"
Impersonate="yes"
ExeCommand="powershell.exe -WindowStyle hidden .\install-task.ps1" />
<InstallExecuteSequence>
<Custom Action='CreateUpdateTask' Before='InstallFinalize'>
NOT(REMOVE)
</Custom>
</InstallExecuteSequence>
<!-- Remove elevated update task during uninstall -->
<CustomAction
Id="DeleteUpdateTask"
Return="check"
Directory="INSTALLDIR"
ExeCommand="powershell.exe -WindowStyle hidden .\uninstall-task.ps1" />
<InstallExecuteSequence>
<Custom Action="DeleteUpdateTask" Before='InstallFinalize'>
(REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE
</Custom>
</InstallExecuteSequence>
{{/if}}
<InstallExecuteSequence>
<Custom Action="LaunchApplication" After="InstallFinalize">AUTOLAUNCHAPP AND NOT Installed</Custom>
</InstallExecuteSequence>
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]" After="CostFinalize"/>
</Product>
</Wix>

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";
@@ -8,6 +9,7 @@ import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher";
import SettingsModal from "./components/SettingsModal";
import { UpdateBadge } from "./components/UpdateBadge";
import LanguageSwitcher from "./components/LanguageSwitcher";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
@@ -17,6 +19,7 @@ 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");
@@ -24,7 +27,7 @@ function App() {
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 +47,7 @@ function App() {
const showNotification = (
message: string,
type: "success" | "error",
duration = 3000,
duration = 3000
) => {
// 清除之前的定时器
if (timeoutRef.current) {
@@ -88,7 +91,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);
}
// 如果当前应用类型匹配,则重新加载数据
@@ -102,7 +105,7 @@ function App() {
}
});
} catch (error) {
console.error("设置供应商切换监听器失败:", error);
console.error(t("console.setupListenerFailed"), error);
}
};
@@ -152,16 +155,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,13 +173,13 @@ 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();
},
@@ -190,9 +193,9 @@ function App() {
if (!status.exists) {
if (!silent) {
showNotification(
"未找到 VS Code 用户设置文件 (settings.json)",
t("notifications.vscodeSettingsNotFound"),
"error",
3000,
3000
);
}
return;
@@ -208,11 +211,7 @@ function App() {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
if (!silent) {
showNotification(
"当前配置缺少 base_url无法写入 VS Code",
"error",
4000,
);
showNotification(t("notifications.missingBaseUrl"), "error", 4000);
}
return;
}
@@ -226,16 +225,17 @@ function App() {
if (updatedSettings !== raw) {
await window.api.writeVSCodeSettings(updatedSettings);
if (!silent) {
showNotification("已同步到 VS Code", "success", 1500);
showNotification(t("notifications.syncedToVSCode"), "success", 1500);
}
}
// 触发providers重新加载以更新VS Code按钮状态
await loadProviders();
} catch (error: any) {
console.error("同步到VS Code失败:", error);
console.error(t("console.syncToVSCodeFailed"), error);
if (!silent) {
const errorMessage = error?.message || "同步 VS Code 失败";
const errorMessage =
error?.message || t("notifications.syncVSCodeFailed");
showNotification(errorMessage, "error", 5000);
}
}
@@ -246,11 +246,11 @@ 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();
@@ -260,7 +260,7 @@ function App() {
await syncCodexToVSCode(id, true); // silent模式不显示通知
}
} else {
showNotification("切换失败,请检查配置", "error");
showNotification(t("notifications.switchFailed"), "error");
}
};
@@ -271,13 +271,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,22 +293,27 @@ 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>
<LanguageSwitcher />
<div className="flex items-center gap-2">
<button
onClick={() => setIsSettingsOpen(true)}
className={buttonStyles.icon}
title="设置"
title={t("common.settings")}
>
<Settings size={18} />
</button>
@@ -324,7 +329,7 @@ function App() {
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
>
<Plus size={16} />
{t("header.addProvider")}
</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";
@@ -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,31 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Globe } from "lucide-react";
import { buttonStyles } from "../lib/styles";
const LanguageSwitcher: React.FC = () => {
const { t, i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === "en" ? "zh" : "en";
i18n.changeLanguage(newLang);
};
const titleKey =
i18n.language === "en"
? "header.switchToChinese"
: "header.switchToEnglish";
return (
<button
onClick={toggleLanguage}
className={buttonStyles.icon}
title={t(titleKey)}
aria-label={t(titleKey)}
>
<Globe size={18} />
</button>
);
};
export default LanguageSwitcher;

View File

@@ -12,7 +12,11 @@ import {
validateJsonConfig,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets";
import {
codexProviderPresets,
generateThirdPartyAuth,
generateThirdPartyConfig,
} from "../config/codexProviderPresets";
import PresetSelector from "./ProviderForm/PresetSelector";
import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
@@ -60,7 +64,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
: "",
});
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category,
initialData?.category
);
// Claude 模型配置状态
@@ -72,9 +76,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexAuth, setCodexAuthState] = useState("");
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null,
showPresets && isCodex ? -1 : null
);
const setCodexAuth = (value: string) => {
@@ -135,25 +141,25 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexCommonConfigSnippet, setCodexCommonConfigSnippetState] =
useState<string>(() => {
if (typeof window === "undefined") {
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim();
}
try {
const stored = window.localStorage.getItem(
CODEX_COMMON_CONFIG_STORAGE_KEY,
CODEX_COMMON_CONFIG_STORAGE_KEY
);
if (stored && stored.trim()) {
return stored;
return stored.trim();
}
} catch {
// ignore localStorage 读取失败
}
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim();
});
const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
const isUpdatingFromCodexCommonConfig = useRef(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null,
showPresets ? -1 : null
);
const [apiKey, setApiKey] = useState("");
const [codexAuthError, setCodexAuthError] = useState("");
@@ -222,11 +228,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = JSON.stringify(
initialData.settingsConfig,
null,
2,
2
);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet,
commonConfigSnippet
);
setUseCommonConfig(hasCommon);
setSettingsConfigError(validateSettingsConfig(configString));
@@ -242,14 +248,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (config.env) {
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
setClaudeSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
// 初始化 Kimi 模型选择
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
}
}
@@ -257,7 +263,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex 初始化时检查 TOML 通用配置
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
codexCommonConfigSnippet,
codexCommonConfigSnippet
);
setUseCodexCommonConfig(hasCommon);
}
@@ -277,7 +283,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (selectedPreset !== null && selectedPreset >= 0) {
const preset = providerPresets[selectedPreset];
setCategory(
preset?.category || (preset?.isOfficial ? "official" : undefined),
preset?.category || (preset?.isOfficial ? "official" : undefined)
);
} else if (selectedPreset === -1) {
setCategory("custom");
@@ -286,7 +292,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
const preset = codexProviderPresets[selectedCodexPreset];
setCategory(
preset?.category || (preset?.isOfficial ? "official" : undefined),
preset?.category || (preset?.isOfficial ? "official" : undefined)
);
} else if (selectedCodexPreset === -1) {
setCategory("custom");
@@ -301,7 +307,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (commonConfigSnippet.trim()) {
window.localStorage.setItem(
COMMON_CONFIG_STORAGE_KEY,
commonConfigSnippet,
commonConfigSnippet
);
} else {
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
@@ -364,7 +370,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
} else {
const currentSettingsError = validateSettingsConfig(
formData.settingsConfig,
formData.settingsConfig
);
setSettingsConfigError(currentSettingsError);
if (currentSettingsError) {
@@ -395,7 +401,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
@@ -413,10 +419,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容
updateSettingsConfigValue(value);
} else {
setFormData({
...formData,
setFormData((prev) => ({
...prev,
[name]: value,
});
}));
}
};
@@ -425,7 +431,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
formData.settingsConfig,
commonConfigSnippet,
checked,
checked
);
if (snippetError) {
@@ -458,7 +464,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const { updatedConfig } = updateCommonConfigSnippet(
formData.settingsConfig,
previousSnippet,
false,
false
);
// 直接更新 formData不通过 handleChange
updateSettingsConfigValue(updatedConfig);
@@ -480,7 +486,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const removeResult = updateCommonConfigSnippet(
formData.settingsConfig,
previousSnippet,
false,
false
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
@@ -492,7 +498,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const addResult = updateCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
true
);
if (addResult.error) {
@@ -532,7 +538,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
});
setSettingsConfigError(validateSettingsConfig(configString));
setCategory(
preset.category || (preset.isOfficial ? "official" : undefined),
preset.category || (preset.isOfficial ? "official" : undefined)
);
// 设置选中的预设
@@ -558,7 +564,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (preset.name?.includes("Kimi")) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
}
} else {
@@ -604,7 +610,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 应用预设
const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0],
index: number,
index: number
) => {
const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString);
@@ -618,7 +624,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setSelectedCodexPreset(index);
setCategory(
preset.category || (preset.isOfficial ? "official" : undefined),
preset.category || (preset.isOfficial ? "official" : undefined)
);
// 清空 API Key让用户重新输入
@@ -628,14 +634,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 处理点击自定义按钮
const handleCodexCustomClick = () => {
setSelectedCodexPreset(-1);
// 设置自定义模板
const customAuth = generateThirdPartyAuth("");
const customConfig = generateThirdPartyConfig(
"custom",
"https://your-api-endpoint.com/v1",
"gpt-5-codex"
);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setSettingsConfigError(validateSettingsConfig(""));
setCodexAuth("");
setCodexConfig("");
setCodexAuth(JSON.stringify(customAuth, null, 2));
setCodexConfig(customConfig);
setCodexApiKey("");
setCategory("custom");
};
@@ -647,7 +662,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 }
);
// 更新表单配置
@@ -689,12 +704,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 处理通用配置开关
const handleCodexCommonConfigToggle = (checked: boolean) => {
const snippet = codexCommonConfigSnippet.trim();
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(
codexConfig,
codexCommonConfigSnippet,
checked,
);
updateTomlCommonConfigSnippet(codexConfig, snippet, checked);
if (snippetError) {
setCodexCommonConfigError(snippetError);
@@ -715,16 +727,17 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 处理通用配置片段变化
const handleCodexCommonConfigSnippetChange = (value: string) => {
const previousSnippet = codexCommonConfigSnippet;
const previousSnippet = codexCommonConfigSnippet.trim();
const sanitizedValue = value.trim();
setCodexCommonConfigSnippet(value);
if (!value.trim()) {
if (!sanitizedValue) {
setCodexCommonConfigError("");
if (useCodexCommonConfig) {
const { updatedConfig } = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
false
);
setCodexConfig(updatedConfig);
setUseCodexCommonConfig(false);
@@ -737,12 +750,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
false
);
const addResult = updateTomlCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
sanitizedValue,
true
);
if (addResult.error) {
@@ -762,7 +775,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 保存 Codex 通用配置到 localStorage
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(CODEX_COMMON_CONFIG_STORAGE_KEY, value);
window.localStorage.setItem(
CODEX_COMMON_CONFIG_STORAGE_KEY,
sanitizedValue
);
} catch {
// ignore localStorage 写入失败
}
@@ -774,7 +790,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (!isUpdatingFromCodexCommonConfig.current) {
const hasCommon = hasTomlCommonConfigSnippet(
value,
codexCommonConfigSnippet,
codexCommonConfigSnippet
);
setUseCodexCommonConfig(hasCommon);
}
@@ -831,7 +847,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 获取当前供应商的网址
const getCurrentWebsiteUrl = () => {
if (selectedPreset !== null && selectedPreset >= 0) {
return providerPresets[selectedPreset]?.websiteUrl || "";
const preset = providerPresets[selectedPreset];
if (!preset) return "";
// 仅第三方供应商使用专用 apiKeyUrl其余使用官网地址
return preset.category === "third_party"
? preset.apiKeyUrl || preset.websiteUrl || ""
: preset.websiteUrl || "";
}
return formData.websiteUrl || "";
};
@@ -839,7 +860,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 获取 Codex 当前供应商的网址
const getCurrentCodexWebsiteUrl = () => {
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
return codexProviderPresets[selectedCodexPreset]?.websiteUrl || "";
const preset = codexProviderPresets[selectedCodexPreset];
if (!preset) return "";
// 仅第三方供应商使用专用 apiKeyUrl其余使用官网地址
return preset.category === "third_party"
? preset.apiKeyUrl || preset.websiteUrl || ""
: preset.websiteUrl || "";
}
return formData.websiteUrl || "";
};
@@ -868,7 +894,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
codexProviderPresets[selectedCodexPreset]?.category === "official")) ||
category === "official";
// 判断是否显示 Codex 的"获取 API Key"链接
// 判断是否显示 Codex 的"获取 API Key"链接(国产官方、聚合站和第三方显示)
const shouldShowCodexApiKeyLink =
isCodex &&
!isCodexOfficialPreset &&
@@ -887,7 +913,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理模型输入变化,自动更新 JSON 配置
const handleModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
value: string
) => {
if (field === "ANTHROPIC_MODEL") {
setClaudeModel(value);
@@ -917,7 +943,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Kimi 模型选择处理函数
const handleKimiModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
value: string
) => {
if (field === "ANTHROPIC_MODEL") {
setKimiAnthropicModel(value);
@@ -942,7 +968,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
useEffect(() => {
if (!initialData) return;
const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig),
JSON.stringify(initialData.settingsConfig)
);
if (parsedKey) setApiKey(parsedKey);
}, [initialData]);
@@ -1023,6 +1049,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
applyCodexPreset(codexProviderPresets[index], index)
}
onCustomClick={handleCodexCustomClick}
renderCustomDescription={() => (
<>
<button
type="button"
onClick={() => setIsCodexTemplateModalOpen(true)}
className="text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors ml-1"
>
使
</button>
</>
)}
/>
)}
@@ -1192,6 +1230,21 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
isCustomMode={selectedCodexPreset === -1}
onWebsiteUrlChange={(url) => {
setFormData((prev) => ({
...prev,
websiteUrl: url,
}));
}}
onNameChange={(name) => {
setFormData((prev) => ({
...prev,
name,
}));
}}
isTemplateModalOpen={isCodexTemplateModalOpen}
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
/>
) : (
<>
@@ -1233,7 +1286,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
onChange={(e) =>
handleModelChange(
"ANTHROPIC_SMALL_FAST_MODEL",
e.target.value,
e.target.value
)
}
placeholder="例如: GLM-4.5-Air"

View File

@@ -1,36 +1,112 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import {
generateThirdPartyAuth,
generateThirdPartyConfig,
} from "../../config/codexProviderPresets";
interface CodexConfigEditorProps {
authValue: string;
configValue: string;
onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void;
onAuthBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
authError: string;
isCustomMode?: boolean; // 新增:是否为自定义模式
onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调
isTemplateModalOpen?: boolean; // 新增:模态框状态
setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态
onNameChange?: (name: string) => void; // 新增:更新供应商名称回调
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
authValue,
configValue,
onAuthChange,
onConfigChange,
onAuthBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
authError,
onWebsiteUrlChange,
onNameChange,
isTemplateModalOpen: externalTemplateModalOpen,
setIsTemplateModalOpen: externalSetTemplateModalOpen,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// 使用内部状态或外部状态
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
useState(false);
const isTemplateModalOpen =
externalTemplateModalOpen ?? internalTemplateModalOpen;
const setIsTemplateModalOpen =
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
const [templateApiKey, setTemplateApiKey] = useState("");
const [templateProviderName, setTemplateProviderName] = useState("");
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
const apiKeyInputRef = useRef<HTMLInputElement>(null);
const baseUrlInputRef = useRef<HTMLInputElement>(null);
const modelNameInputRef = useRef<HTMLInputElement>(null);
const displayNameInputRef = useRef<HTMLInputElement>(null);
// 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充
const [templateDisplayName, setTemplateDisplayName] = useState("");
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
@@ -38,16 +114,20 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
}, [commonConfigError, isCommonConfigModalOpen]);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
if (!isCommonConfigModalOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isCommonConfigModalOpen]);
@@ -55,6 +135,88 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
setIsCommonConfigModalOpen(false);
};
const closeTemplateModal = () => {
setIsTemplateModalOpen(false);
};
const applyTemplate = () => {
const requiredInputs = [
displayNameInputRef.current,
apiKeyInputRef.current,
baseUrlInputRef.current,
modelNameInputRef.current,
];
for (const input of requiredInputs) {
if (input && !input.checkValidity()) {
input.reportValidity();
input.focus();
return;
}
}
const trimmedKey = templateApiKey.trim();
const trimmedBaseUrl = templateBaseUrl.trim();
const trimmedModel = templateModelName.trim();
const auth = generateThirdPartyAuth(trimmedKey);
const config = generateThirdPartyConfig(
templateProviderName || "custom",
trimmedBaseUrl,
trimmedModel
);
onAuthChange(JSON.stringify(auth, null, 2));
onConfigChange(config);
if (onWebsiteUrlChange) {
const trimmedWebsite = templateWebsiteUrl.trim();
if (trimmedWebsite) {
onWebsiteUrlChange(trimmedWebsite);
}
}
if (onNameChange) {
const trimmedName = templateDisplayName.trim();
if (trimmedName) {
onNameChange(trimmedName);
}
}
setTemplateApiKey("");
setTemplateProviderName("");
setTemplateBaseUrl("");
setTemplateWebsiteUrl("");
setTemplateModelName("gpt-5-codex");
setTemplateDisplayName("");
closeTemplateModal();
};
const handleTemplateInputKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
applyTemplate();
}
};
const handleAuthChange = (value: string) => {
onAuthChange(value);
};
@@ -76,6 +238,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={authValue}
@@ -97,9 +260,11 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{authError && (
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Codex auth.json
</p>
@@ -113,6 +278,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
>
config.toml (TOML)
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
@@ -123,6 +289,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
@@ -132,11 +299,13 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<textarea
id="codexConfig"
value={configValue}
@@ -154,11 +323,249 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Codex config.toml
</p>
</div>
{isTemplateModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
closeTemplateModal();
}
}}
>
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
<div className="flex h-full min-h-0 flex-col" role="form">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h2>
<button
type="button"
onClick={closeTemplateModal}
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
auth.json config.toml
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
API *
</label>
<input
type="text"
value={templateApiKey}
ref={apiKeyInputRef}
onChange={(e) => setTemplateApiKey(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title="请输入有效的内容"
placeholder="sk-your-api-key-here"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
*
</label>
<input
type="text"
value={templateDisplayName}
ref={displayNameInputRef}
onChange={(e) => {
setTemplateDisplayName(e.target.value);
if (onNameChange) {
onNameChange(e.target.value);
}
}}
onKeyDown={handleTemplateInputKeyDown}
placeholder="例如Codex 官方"
required
pattern=".*\S.*"
title="请输入有效的内容"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
使
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
</label>
<input
type="text"
value={templateProviderName}
onChange={(e) => setTemplateProviderName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="custom可选"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
custom
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
API *
</label>
<input
type="url"
value={templateBaseUrl}
ref={baseUrlInputRef}
onChange={(e) => setTemplateBaseUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="https://your-api-endpoint.com/v1"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
</label>
<input
type="url"
value={templateWebsiteUrl}
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="https://example.com"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
*
</label>
<input
type="text"
value={templateModelName}
ref={modelNameInputRef}
onChange={(e) => setTemplateModelName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title="请输入有效的内容"
placeholder="gpt-5-codex"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
{(templateApiKey ||
templateProviderName ||
templateBaseUrl) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
</h3>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
auth.json
</label>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{JSON.stringify(
generateThirdPartyAuth(templateApiKey),
null,
2
)}
</pre>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
config.toml
</label>
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{templateProviderName && templateBaseUrl
? generateThirdPartyConfig(
templateProviderName,
templateBaseUrl,
templateModelName
)
: ""}
</pre>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
<button
type="button"
onClick={closeTemplateModal}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
applyTemplate();
}}
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)}
{isCommonConfigModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
@@ -167,6 +574,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
}}
>
{/* Backdrop - 统一背景样式 */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
@@ -174,12 +582,15 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
/>
{/* Modal - 统一窗口样式 */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header - 统一标题栏样式 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Codex
</h2>
<button
type="button"
onClick={closeModal}
@@ -191,16 +602,21 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
</div>
{/* Content - 统一内容区域样式 */}
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
"写入通用配置" config.toml
</p>
<textarea
value={commonConfigSnippet}
onChange={(e) =>
handleCommonConfigSnippetChange(e.target.value)
}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
rows={12}
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 font-mono 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 resize-y"
@@ -214,6 +630,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
@@ -222,6 +639,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
</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"
@@ -230,6 +648,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
>
</button>
<button
type="button"
onClick={closeModal}

View File

@@ -16,6 +16,7 @@ interface PresetSelectorProps {
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
@@ -25,6 +26,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
onSelectPreset,
onCustomClick,
customLabel = "自定义",
renderCustomDescription,
}) => {
const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index;
@@ -48,6 +50,10 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
const getDescription = () => {
if (selectedIndex === -1) {
// 如果提供了自定义描述渲染函数,使用它
if (renderCustomDescription) {
return renderCustomDescription();
}
return "手动配置供应商,需要填写完整的配置信息";
}
@@ -99,9 +105,9 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
</div>
</div>
{getDescription() && (
<p className="text-sm text-gray-500 dark:text-gray-400">
<div className="text-sm text-gray-500 dark:text-gray-400">
{getDescription()}
</p>
</div>
)}
</div>
);

View File

@@ -1,4 +1,5 @@
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 { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
@@ -22,7 +23,7 @@ interface ProviderListProps {
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
duration?: number
) => void;
}
@@ -35,6 +36,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
appType,
onNotify,
}) => {
const { t } = useTranslation();
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
@@ -49,9 +51,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,7 +61,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
await window.api.openExternal(url);
} catch (error) {
console.error("打开链接失败:", error);
console.error(t("console.openLinkFailed"), error);
}
};
@@ -106,11 +108,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000,
);
onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000);
return;
}
@@ -121,7 +119,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
onNotify?.("当前配置缺少 base_url无法写入 VS Code", "error", 4000);
onNotify?.(t("notifications.missingBaseUrl"), "error", 4000);
return;
}
}
@@ -131,7 +129,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
if (next === raw) {
// 幂等:没有变化也提示成功
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
onNotify?.(t("notifications.appliedToVSCode"), "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
@@ -139,13 +137,14 @@ const ProviderList: React.FC<ProviderListProps> = ({
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
onNotify?.(t("notifications.appliedToVSCode"), "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
const msg =
e && e.message ? e.message : t("notifications.syncVSCodeFailed");
onNotify?.(msg, "error", 5000);
}
};
@@ -154,11 +153,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000,
);
onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000);
return;
}
const raw = await window.api.readVSCodeSettings();
@@ -167,20 +162,21 @@ const ProviderList: React.FC<ProviderListProps> = ({
isOfficial: true,
});
if (next === raw) {
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
onNotify?.(t("notifications.removedFromVSCode"), "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
onNotify?.(t("notifications.removedFromVSCode"), "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "移除失败";
const msg =
e && e.message ? e.message : t("notifications.syncVSCodeFailed");
onNotify?.(msg, "error", 5000);
}
};
@@ -214,10 +210,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>
) : (
@@ -230,7 +226,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div
key={provider.id}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive,
isCurrent ? cardStyles.selected : cardStyles.interactive
)}
>
<div className="flex items-start justify-between">
@@ -243,11 +239,11 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible",
!isCurrent && "invisible"
)}
>
<CheckCircle2 size={12} />
使
{t("provider.currentlyUsing")}
</div>
</div>
@@ -284,41 +280,41 @@ const ProviderList: React.FC<ProviderListProps> = ({
: 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] justify-center",
"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
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
? "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"
? t("provider.removeFromVSCode")
: t("provider.applyToVSCode")
}
>
{vscodeAppliedFor === provider.id
? "从 VS Code 移除"
: "应用到 VS Code"}
? t("provider.removeFromVSCode")
: t("provider.applyToVSCode")}
</button>
)}
<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",
"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",
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",
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
)}
>
<Play size={14} />
{isCurrent ? "使用中" : "启用"}
{!isCurrent && <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>
@@ -330,9 +326,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
buttonStyles.icon,
isCurrent
? "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",
: "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,
@@ -8,6 +9,7 @@ import {
Check,
Undo2,
FolderSearch,
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { homeDir, join } from "@tauri-apps/api/path";
@@ -23,8 +25,10 @@ interface SettingsModalProps {
}
export default function SettingsModal({ onClose }: SettingsModalProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
});
@@ -35,6 +39,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const [showUpToDate, setShowUpToDate] = useState(false);
const [resolvedClaudeDir, setResolvedClaudeDir] = useState<string>("");
const [resolvedCodexDir, setResolvedCodexDir] = useState<string>("");
const [isPortable, setIsPortable] = useState(false);
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
@@ -43,6 +48,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
loadConfigPath();
loadVersion();
loadResolvedDirs();
loadPortableFlag();
}, []);
const loadVersion = async () => {
@@ -50,9 +56,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"));
}
};
@@ -63,8 +69,13 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
(loadedSettings as any)?.showInTray ??
(loadedSettings as any)?.showInDock ??
true;
const minimizeToTrayOnClose =
(loadedSettings as any)?.minimizeToTrayOnClose ??
(loadedSettings as any)?.minimize_to_tray_on_close ??
true;
setSettings({
showInTray,
minimizeToTrayOnClose,
claudeConfigDir:
typeof (loadedSettings as any)?.claudeConfigDir === "string"
? (loadedSettings as any).claudeConfigDir
@@ -75,7 +86,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
: undefined,
});
} catch (error) {
console.error("加载设置失败:", error);
console.error(t("console.loadSettingsFailed"), error);
}
};
@@ -86,7 +97,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setConfigPath(path);
}
} catch (error) {
console.error("获取配置路径失败:", error);
console.error(t("console.getConfigPathFailed"), error);
}
};
@@ -99,7 +110,16 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setResolvedClaudeDir(claudeDir || "");
setResolvedCodexDir(codexDir || "");
} catch (error) {
console.error("获取配置目录失败:", error);
console.error(t("console.getConfigDirFailed"), error);
}
};
const loadPortableFlag = async () => {
try {
const portable = await window.api.isPortable();
setIsPortable(portable);
} catch (error) {
console.error(t("console.detectPortableFailed"), error);
}
};
@@ -120,12 +140,16 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setSettings(payload);
onClose();
} catch (error) {
console.error("保存设置失败:", error);
console.error(t("console.saveSettingsFailed"), error);
}
};
const handleCheckUpdate = async () => {
if (hasUpdate && updateHandle) {
if (isPortable) {
await window.api.checkForUpdates();
return;
}
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
setIsDownloading(true);
try {
@@ -133,7 +157,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 {
@@ -154,7 +178,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);
@@ -175,7 +199,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
try {
await window.api.openAppConfigFolder();
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error(t("console.openConfigFolderFailed"), error);
}
};
@@ -206,7 +230,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setResolvedCodexDir(sanitized);
}
} catch (error) {
console.error("选择配置目录失败:", error);
console.error(t("console.selectConfigDirFailed"), error);
}
};
@@ -216,7 +240,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 "";
}
};
@@ -244,8 +268,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"
);
@@ -258,7 +283,7 @@ 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);
}
};
@@ -274,11 +299,11 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] max-h-[90vh] flex flex-col overflow-hidden">
{/* 标题栏 */}
<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}
@@ -289,45 +314,54 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6">
{/* 系统托盘设置(未实现)
说明:此开关用于控制是否在系统托盘/菜单栏显示应用图标。 */}
{/* <div>
<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.windowBehavior")}
</h3>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-500">
在菜单栏显示图标(系统托盘)
</span>
<input
type="checkbox"
checked={settings.showInTray}
onChange={(e) =>
setSettings({ ...settings, showInTray: e.target.checked })
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div> */}
<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
type="checkbox"
checked={settings.minimizeToTrayOnClose}
onChange={(e) =>
setSettings((prev) => ({
...prev,
minimizeToTrayOnClose: e.target.checked,
}))
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</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}
@@ -340,16 +374,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
@@ -361,14 +394,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>
@@ -376,7 +409,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>
@@ -385,7 +418,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
@@ -397,14 +430,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>
@@ -412,7 +445,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>
@@ -424,7 +457,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 关于 */}
<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">
@@ -434,7 +467,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>
@@ -443,12 +476,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
@@ -467,25 +502,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>
@@ -500,13 +537,14 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
onClick={onClose}
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"
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>

View File

@@ -10,6 +10,42 @@ export interface CodexProviderPreset {
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
}
/**
* 生成第三方供应商的 auth.json
*/
export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
return {
OPENAI_API_KEY: apiKey || "sk-your-api-key-here",
};
}
/**
* 生成第三方供应商的 config.toml
*/
export function generateThirdPartyConfig(
providerName: string,
baseUrl: string,
modelName = "gpt-5-codex"
): string {
// 清理供应商名称确保符合TOML键名规范
const cleanProviderName =
providerName
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_")
.replace(/^_+|_+$/g, "") || "custom";
return `model_provider = "${cleanProviderName}"
model = "${modelName}"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.${cleanProviderName}]
name = "${cleanProviderName}"
base_url = "${baseUrl}"
wire_api = "responses"`;
}
export const codexProviderPresets: CodexProviderPreset[] = [
@@ -18,7 +54,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
category: "official",
// 官方的 key 为null
auth: {
OPENAI_API_KEY: null,
},
@@ -28,21 +63,11 @@ export const codexProviderPresets: CodexProviderPreset[] = [
name: "PackyCode",
websiteUrl: "https://codex.packycode.com/",
category: "third_party",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: {
OPENAI_API_KEY: "sk-your-api-key-here",
},
config: `model_provider = "packycode"
model = "gpt-5-codex"
model_reasoning_effort = "high"
disable_response_storage = true
requires_openai_auth = true
[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"
env_key = "packycode"`,
auth: generateThirdPartyAuth("sk-your-api-key-here"),
config: generateThirdPartyConfig(
"packycode",
"https://codex-api.packycode.com/v1",
"gpt-5-codex"
),
},
];

View File

@@ -6,6 +6,8 @@ import { ProviderCategory } from "../types";
export interface ProviderPreset {
name: string;
websiteUrl: string;
// 新增:第三方/聚合等可单独配置获取 API Key 的链接
apiKeyUrl?: string;
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
@@ -28,8 +30,8 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
ANTHROPIC_MODEL: "DeepSeek-V3.1-Terminus",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.1-Terminus",
},
},
category: "cn_official",
@@ -55,8 +57,8 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "qwen3-coder-plus",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-coder-plus",
ANTHROPIC_MODEL: "qwen3-max",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-max",
},
},
category: "cn_official",
@@ -90,6 +92,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
apiKeyUrl: "https://www.packycode.com/?aff=rlo54mgz",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.packycode.com",

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

@@ -0,0 +1,29 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n.use(initReactI18next).init({
resources,
lng: "en", // 默认语言设置为英文
fallbackLng: "en", // 回退语言也设置为英文
interpolation: {
escapeValue: false, // React 已经默认转义
},
// 开发模式下显示调试信息
debug: false,
});
export default i18n;

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

@@ -0,0 +1,111 @@
{
"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",
"applyToVSCode": "Apply to VS Code",
"removeFromVSCode": "Remove from VS Code"
},
"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",
"appliedToVSCode": "Applied to VS Code, restart Codex plugin to take effect",
"removedFromVSCode": "Removed from VS Code, restart Codex plugin to take effect",
"syncedToVSCode": "Synced to VS Code",
"vscodeSettingsNotFound": "VS Code user settings file (settings.json) not found",
"missingBaseUrl": "Current configuration missing base_url, cannot write to VS Code",
"saveFailed": "Save failed: {{error}}",
"saveFailedGeneric": "Save failed, please try again",
"syncVSCodeFailed": "Sync to VS Code failed"
},
"confirm": {
"deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
},
"settings": {
"title": "Settings",
"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:",
"syncToVSCodeFailed": "Sync to VS Code 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:"
}
}

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

@@ -0,0 +1,111 @@
{
"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": "未配置官网地址",
"applyToVSCode": "应用到 VS Code",
"removeFromVSCode": "从 VS Code 移除"
},
"notifications": {
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchFailed": "切换失败,请检查配置",
"autoImported": "已从现有配置创建默认供应商",
"appliedToVSCode": "已应用到 VS Code重启 Codex 插件以生效",
"removedFromVSCode": "已从 VS Code 移除,重启 Codex 插件以生效",
"syncedToVSCode": "已同步到 VS Code",
"vscodeSettingsNotFound": "未找到 VS Code 用户设置文件 (settings.json)",
"missingBaseUrl": "当前配置缺少 base_url无法写入 VS Code",
"saveFailed": "保存失败:{{error}}",
"saveFailedGeneric": "保存失败,请重试",
"syncVSCodeFailed": "同步 VS Code 失败"
},
"confirm": {
"deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
},
"settings": {
"title": "设置",
"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": "更新供应商失败:",
"syncToVSCodeFailed": "同步到VS Code失败:",
"autoImportFailed": "自动导入默认配置失败:",
"openLinkFailed": "打开链接失败:",
"getVersionFailed": "获取版本信息失败:",
"loadSettingsFailed": "加载设置失败:",
"getConfigPathFailed": "获取配置路径失败:",
"getConfigDirFailed": "获取配置目录失败:",
"detectPortableFailed": "检测便携模式失败:",
"saveSettingsFailed": "保存设置失败:",
"updateFailed": "更新失败:",
"checkUpdateFailed": "检查更新失败:",
"openConfigFolderFailed": "打开配置文件夹失败:",
"selectConfigDirFailed": "选择配置目录失败:",
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
"openReleaseNotesFailed": "打开更新日志失败:"
}
}

View File

@@ -223,7 +223,7 @@ export const tauriAPI = {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
return { showInTray: true };
return { showInTray: true, minimizeToTrayOnClose: true };
}
},
@@ -246,6 +246,16 @@ export const tauriAPI = {
}
},
// 判断是否为便携模式
isPortable: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_portable_mode");
} catch (error) {
console.error("检测便携模式失败:", error);
return false;
}
},
// 获取应用配置文件路径
getAppConfigPath: async (): Promise<string> => {
try {

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

@@ -24,6 +24,8 @@ export interface AppConfig {
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)

View File

@@ -68,16 +68,26 @@ export function removeManagedKeys(content: string): string {
// 忽略删除失败
}
// chatgpt.config 变为空对象,顺便移除(不影响其他 chatgpt* 键)
// 清理 chatgpt.config 的异常情况:
// 1. 早期遗留的标量值(字符串/数字/null等
// 2. 空对象
// 3. 数组类型
try {
const data = parse(out) as any;
const cfg = data?.["chatgpt.config"];
if (
cfg &&
typeof cfg === "object" &&
!Array.isArray(cfg) &&
Object.keys(cfg).length === 0
) {
// 需要清理的情况:
// - 标量值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 }),

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

@@ -39,6 +39,7 @@ declare global {
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
checkForUpdates: () => Promise<void>;
isPortable: () => Promise<boolean>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// VS Code settings.json 能力