52 Commits

Author SHA1 Message Date
Jason
2cf116280f feat: add prettier formatter and MIT license
- Add prettier dev dependency for code formatting
- Create MIT LICENSE file
- Format TypeScript files with prettier
- Update provider order in README (Qwen coder first)
- Update add provider screenshot with new UI
2025-08-29 11:35:17 +08:00
Jason
73cf337c42 fix(config): create settings.json on first run; keep legacy claude.json read compatibility 2025-08-29 10:50:10 +08:00
Jason
fa2d64b692 feat: add official preset orange theme and disabled API input
- Add Anthropic orange theme styling for official preset buttons
- Auto-disable API Key input field when official preset is selected
- Add isOfficial field for precise official preset identification
- Enhance UX: official login requires no manual API Key input
2025-08-29 09:03:11 +08:00
Jason
9c17be1b59 security(tauri): remove unused shell plugin and capability
- Remove tauri-plugin-shell from Cargo.toml
- Drop tauri_plugin_shell::init() from src-tauri/src/lib.rs
- Delete "shell:allow-open" from src-tauri/capabilities/default.json
- No runtime behavior change; opener plugin still handles links/paths
- Motivation: reduce permissions surface and slightly shrink bundle
2025-08-28 22:26:02 +08:00
Jason
fe1574a026 docs: update README for v3.0.0 Tauri release
- Add version badges and Tauri branding
- Update performance metrics (85% size reduction, 10x startup speed)
- Add detailed system requirements for all platforms
- Update installation instructions with specific file names
- Add comprehensive development setup guide
- Include new npm scripts (typecheck, format)
- Add Rust development commands
- Enhance project structure documentation
- Link to CHANGELOG for version details
- Update screenshots for new UI
2025-08-27 22:26:07 +08:00
Jason
9254c5d291 feat: add development scripts and CHANGELOG for v3.0.0
- Add typecheck script for TypeScript validation
- Add format and format:check scripts for code formatting
- Create comprehensive CHANGELOG documenting migration from Electron to Tauri
- Document all major changes, improvements, and migration notes for v3.0.0
2025-08-27 11:15:29 +08:00
Jason
642e7a3817 chore: format code and fix bundle identifier for v3.0.0 release
- Format all TypeScript/React code with Prettier
- Format all Rust code with cargo fmt
- Fix bundle identifier from .app to .desktop to avoid macOS conflicts
- Prepare codebase for v3.0.0 Tauri release
2025-08-27 11:00:53 +08:00
Jason
5e2e80b00d fix: prevent modal header jumping when toggling API key field
Reserve fixed height for API key input container and use visibility/opacity
for show/hide instead of conditional rendering to maintain consistent modal
height when selecting presets
2025-08-27 10:39:39 +08:00
Jason
2a43f1f54d update: regenerate all platform icons 2025-08-27 10:30:29 +08:00
Jason
7e6ce83158 update: regenerate all platform icons 2025-08-27 09:01:36 +08:00
Jason
6932e89ea8 update: regenerate all platform icons from unified source
- Regenerated all desktop platform icons (Windows .ico, macOS .icns, Linux PNG)
- Added mobile platform icons for future cross-platform support
- Ensures consistent icon appearance across all platforms
2025-08-27 08:43:41 +08:00
Jason
d144d5c2fc ci(workflow): fix pnpm cache path context by using step outputs
- Replace env var STORE_PATH with step output\n- Add id to pnpm-store step and write to \n- Reference cache path via steps.pnpm-store.outputs.path\n- Resolves linter warning: Context access might be invalid: STORE_PATH\n- No behavior change; caching remains the same
2025-08-26 23:32:13 +08:00
Jason
adee37ab66 feat(icons): update application icon with new colorful radial design
Replace all platform-specific icon files with new radial burst design featuring teal, orange, and yellow gradients. Updated icons include:
- PNG variants for all required sizes (32x32 to 1024x1024)
- macOS ICNS bundle with all resolutions
- Windows Square logo variants for modern app packaging
2025-08-26 22:57:57 +08:00
Jason
dcf49cc094 fix(tauri): correct bundle.targets schema to array
- Replace per-OS map with array: ["app","dmg","nsis","appimage"].

- Align with Tauri v2 config; resolves schema validation error.

- Keeps Windows portable ("app") target enabled.
2025-08-26 22:51:22 +08:00
Jason
f8e39594fa chore: prepare v3.0.0 release
- Bump version to 3.0.0 across all files
- Update Cargo.toml with proper package metadata
- Rename crate from 'app' to 'cc-switch'
- Use Rust edition 2024 with minimum rust-version 1.85.0
- Update library name to cc_switch_lib
2025-08-26 15:45:42 +08:00
Jason
374649750b feat(ui): update preset template description for clarity 2025-08-26 15:12:27 +08:00
Jason
6d26115368 update gitignore 2025-08-26 12:39:01 +08:00
Jason
606ee67778 fix(ui): Pin modal action bar; prevent bottom content overflow\n\n- Move action buttons to fixed .modal-footer at the bottom\n- Make modal a column flex container; scroll only body\n- Ensure buttons remain visible on small viewports\n- Remove sticky edge cases causing leaked content 2025-08-26 12:34:47 +08:00
Jason
57d21fabcf feat(providers): add Kimi K2 support and optimize provider configurations
- Add Kimi K2 (Moonshot AI) preset with k2-turbo-preview model support
- Set specific models for ANTHROPIC_MODEL and ANTHROPIC_SMALL_FAST_MODEL
- Fix DeepSeek platform URL (remove trailing slash)
- Reorganize provider order for better categorization
2025-08-26 11:28:10 +08:00
Jason
001664c67d - feat(form): Support API Key ⇄ JSON two-way binding in edit modal
- feat(utils): Add helpers to read/write/detect API Key in config
- refactor(form): Reuse unified linking logic for preset and edit flows
- chore: Preserve website URL extraction and signature-disable behaviors
- build: Verify renderer build locally
2025-08-26 10:41:16 +08:00
Jason
616e230218 style: remove window title and adjust header padding for cleaner UI 2025-08-25 23:30:25 +08:00
Jason
70f9a68e5c feat(macos): implement transparent titlebar with custom background color
- Add transparent titlebar configuration in tauri.conf.json
- Implement macOS titlebar background color matching main UI banner (#3498db)
- Replace deprecated cocoa crate with modern objc2-app-kit
- Preserve native window functionality (drag, traffic lights)
- Remove all deprecation warnings from build process

The titlebar now seamlessly matches the application's blue theme while
maintaining all native macOS window management features.
2025-08-25 23:06:54 +08:00
Jason
78bc0a1a31 chore(tauri): remove dead code warnings and drop unused uuid dep
- Delete unused Provider::new, ProviderManager::get_current_provider
- Delete unused AppState::reload
- Remove uuid crate and related imports
- Keep functionality unchanged; frontend uses ID string for current provider
2025-08-25 21:41:35 +08:00
Jason
dac8ebe03b feat: upgrade Rust code to full Tauri 2.0 compatibility
- Add tauri-plugin-shell and tauri-plugin-opener dependencies
- Update permissions configuration to support shell and opener operations
- Refactor open_config_folder and open_external commands to use secure plugin APIs
- Remove unsafe direct std::process::Command usage
- Initialize necessary Tauri plugins
- Ensure all external operations comply with Tauri 2.0 security standards
2025-08-25 20:16:29 +08:00
Jason
9f370bf429 fix: remove @tauri-apps/api/os import and use local UA/platform detection for mac class 2025-08-25 10:37:19 +08:00
Jason
bac2c3db36 refactor: remove window.platform shim; platform detection handled via @tauri-apps/api/os 2025-08-25 10:33:54 +08:00
Jason
326e975748 feat: use @tauri-apps/api/os for reliable platform detection (mac body class) 2025-08-25 10:33:19 +08:00
Jason
b5696b4511 security: add restrictive default CSP for Tauri app 2025-08-25 10:32:47 +08:00
Jason
ef7e9d2f73 style: remove Electron-specific drag region styles for bordered Tauri window 2025-08-25 10:31:09 +08:00
Jason
d78013562c refactor: rename global API from electronAPI to api and update references 2025-08-25 10:30:45 +08:00
Jason
d3adfc480d ci: migrate release workflow to Tauri action and correct bundle handling 2025-08-25 10:29:58 +08:00
Jason
731cfc47be chore(deps): remove unused @tauri-apps/plugin-shell dependency 2025-08-24 23:40:32 +08:00
Jason
95b3746e49 chore(version): align package.json version to 3.0.0-beta.1 to match Tauri app version 2025-08-24 23:39:41 +08:00
Jason
c8670aede6 feat(ui): drive config path UI from getClaudeConfigStatus (show path + existence hint) and remove direct getClaudeCodeConfigPath usage 2025-08-24 23:36:09 +08:00
Jason
95549473bd fix(tauri): ensure ~/.claude directory exists before copying provider settings into main settings file 2025-08-24 23:35:44 +08:00
Jason
f3f484a04b fix(tauri): normalize external URLs by auto-prepending https:// when protocol is missing 2025-08-24 23:35:07 +08:00
Jason
1458f1e45d fix(ui): use browser-safe timeout ref type (ReturnType<typeof setTimeout>) to avoid NodeJS.Timeout mismatch 2025-08-24 23:31:56 +08:00
Jason
0301d1aee7 fix(tauri): avoid duplicate import of default provider in import_default_config by early-exit when default exists 2025-08-24 23:30:35 +08:00
Jason
224d7a8be0 fix: 修复 Tauri 重构导致的配置读取与渲染问题
- 前端:始终绑定 ,避免环境判断失误造成白屏
- 后端: 仅初始化一次,并通过  注入,避免双实例不一致
- 配置: 兼容  回退,提高旧配置兼容性
- 结果:主页面数据正常加载,底部配置路径组件恢复显示
2025-08-24 23:04:55 +08:00
Jason
c4791ff523 - chore: 添加 Tauri CLI 开发依赖
- 在 `package.json` 新增 `@tauri-apps/cli`
- 更新 `pnpm-lock.yaml` 锁定文件
- 仅依赖变更,无业务代码改动
2025-08-24 22:38:13 +08:00
Jason
55c62a3753 - fix(types): 统一导入到 src/types.ts,移除 shared/types 残留路径
- chore(tsconfig): 将 include 扩展为 src/**/* 覆盖迁移后的源文件
- feat(build): Vite 设置 root 为 src,并将 build.outDir 设为 ../dist 以匹配 Tauri frontendDist
- refactor(api): 去除未使用的 plugin-shell import;统一 electronAPI 类型定义至 vite-env.d.ts
- build: 验证 renderer 构建通过,产物输出至 dist/
2025-08-23 23:11:39 +08:00
farion1231
12fa80e002 refactor: 清理 Electron 遗留代码并优化项目结构
- 删除 Electron 主进程代码 (src/main/)
- 删除构建产物文件夹 (build/, dist/, release/)
- 清理 package.json 中的 Electron 依赖和脚本
- 删除 TypeScript 配置中的 Electron 相关文件
- 优化前端代码结构至 Tauri 标准结构 (src/renderer → src/)
- 删除移动端图标和不必要文件
- 更新文档说明技术栈变更为 Tauri
2025-08-23 21:13:25 +08:00
farion1231
29581b85d9 fix: 修复 Rust 编译错误并成功启动 Tauri 应用
- 修复 commands.rs 中的重复导入问题
- 清理未使用的导入
- 统一 Vite 和 Tauri 配置的端口为 3000
- 添加 Tauri 前端依赖包
- 应用已成功编译并运行
2025-08-23 21:00:50 +08:00
farion1231
88e69e844a docs: 更新迁移计划 - 标记 Phase 4 已完成 2025-08-23 20:52:01 +08:00
farion1231
2a658af5b9 feat: 完成前端窗口控制和配置适配
- 更新 tauri.conf.json 配置正确的前端构建路径
- 调整开发服务器端口为 Vite 默认端口 5173
- 添加 Tauri 前端依赖包
- 窗口拖拽样式已兼容
2025-08-23 20:41:14 +08:00
farion1231
1402fd0cc5 feat: 创建 Tauri API 层替换 Electron IPC 调用
- 创建 tauri-api.ts 封装所有 Tauri invoke 调用
- 保持与 Electron API 相同的接口,确保代码兼容性
- 添加类型声明文件 vite-env.d.ts
- 在 main.tsx 中导入 Tauri API
2025-08-23 20:38:57 +08:00
farion1231
8a3133be43 feat: 实现 Tauri Commands - 完成所有供应商和配置管理命令 2025-08-23 20:15:10 +08:00
farion1231
f64320fbd6 feat: 实现 Rust 后端核心模块 - 配置管理、供应商管理和数据存储 2025-08-23 20:12:35 +08:00
farion1231
3479780639 feat: configure Tauri build system and app metadata
- Update Vite config for Tauri integration
- Configure package.json scripts for Tauri commands
- Generate multi-platform app icons
- Update app metadata and window configuration
2025-08-23 20:05:50 +08:00
farion1231
1b0ab269fb feat: initialize Tauri project structure
- Add @tauri-apps/api dependency
- Create src-tauri directory with base configuration
- Generate Tauri project Rust code framework
- Add application icon resources
2025-08-23 19:59:29 +08:00
farion1231
6706889387 删除多余文件 2025-08-22 20:54:45 +08:00
farion1231
093e54f23c 更新文档 2025-08-22 15:50:25 +08:00
105 changed files with 7822 additions and 3556 deletions

View File

@@ -11,97 +11,68 @@ permissions:
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-latest
platform: win32
- os: ubuntu-latest
platform: linux
- os: ubuntu-latest
- os: macos-latest
platform: darwin
steps:
- name: Checkout code
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10.12.3
run_install: false
- name: Get pnpm store directory
id: pnpm-store
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
restore-keys: ${{ runner.os }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Build application
run: |
pnpm run build
pnpm run dist
- name: Build and Release (Tauri)
uses: tauri-apps/tauri-action@v0
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: false
- name: List build files (debug)
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: CC Switch ${{ github.ref_name }}
releaseBody: |
## CC Switch ${{ github.ref_name }}
Claude Code 供应商切换工具Tauri 构建)
- Windows: .msi / NSIS 安装包
- macOS: .dmg / .app 压缩包
- Linux: AppImage / deb / rpm
如遇未知开发者提示,请在系统隐私与安全设置中选择“仍要打开”。
tauriScript: pnpm tauri
- name: List generated bundles (debug)
if: always()
shell: bash
run: |
echo "Listing release directory:"
ls -la release/ || echo "No release directory found"
find . -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" || echo "No build files found"
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
release/*.exe
release/*.zip
release/*.AppImage
name: "CC Switch ${{ github.ref_name }}"
body: |
## CC Switch ${{ github.ref_name }}
Claude Code 供应商切换工具
### 下载
#### Windows 用户
- **安装版 (推荐)**: `CC Switch Setup ${{ github.ref_name }}.exe`
- **便携版**: `CC Switch ${{ github.ref_name }}.exe`
#### macOS 用户(推荐使用通用版本)
- **通用版本**: `CC Switch-${{ github.ref_name }}-mac.zip` - 兼容所有MacIntel + M系列
#### Linux 用户
- **AppImage**: `CC Switch-${{ github.ref_name }}.AppImage`
### macOS 安装说明
1. 下载 ZIP 文件后解压
2. 首次打开可能出现"未知开发者"警告
3. 前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
4. 或者使用命令: `xattr -cr "/path/to/CC Switch.app"`
### 注意事项
- macOS 版本使用 Intel 架构,通过 Rosetta 2 在 M 系列芯片上运行
- 兼容性和稳定性最佳性能损失minimal
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
echo "Listing bundles in src-tauri/target..."
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true

4
.gitignore vendored
View File

@@ -6,4 +6,6 @@ release/
.env
.env.local
*.tsbuildinfo
.npmrc
.npmrc
CLAUDE.md
AGENTS.md

67
CHANGELOG.md Normal file
View File

@@ -0,0 +1,67 @@
# Changelog
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.0.0] - 2025-08-27
### 🚀 Major Changes
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
- **90% reduction in bundle size** (from ~150MB to ~15MB)
- **Significantly improved startup performance**
- **Native system integration** without Chromium overhead
- **Enhanced security** with Rust backend
### ✨ New Features
- **Native window controls** with transparent title bar on macOS
- **Improved file system operations** using Rust for better performance
- **Enhanced security model** with explicit permission declarations
- **Better platform detection** using Tauri's native APIs
### 🔧 Technical Improvements
- Migrated from Electron IPC to Tauri command system
- Replaced Node.js file operations with Rust implementations
- Implemented proper CSP (Content Security Policy) for enhanced security
- Added TypeScript strict mode for better type safety
- Integrated Rust cargo fmt and clippy for code quality
### 🐛 Bug Fixes
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
- Resolved platform detection issues
- Improved error handling in configuration management
### 📦 Dependencies
- **Tauri**: 2.8.2
- **React**: 18.2.0
- **TypeScript**: 5.3.0
- **Vite**: 5.0.0
### 🔄 Migration Notes
For users upgrading from v2.x (Electron version):
- Configuration files remain compatible - no action required
- The app will automatically migrate your existing provider configurations
- Window position and size preferences have been reset to defaults
### 🛠️ Development
- Added `pnpm typecheck` command for TypeScript validation
- Added `pnpm format` and `pnpm format:check` for code formatting
- Rust code now uses cargo fmt for consistent formatting
## [2.0.0] - Previous Electron Release
### Features
- Multi-provider configuration management
- Quick provider switching
- Import/export configurations
- Preset provider templates
---
## [1.0.0] - Initial Release
### Features
- Basic provider management
- Claude Code integration
- Configuration file handling

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Jason Young
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

156
README.md
View File

@@ -1,14 +1,22 @@
# Claude Code 供应商切换器
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。
> **v3.0.0 重大更新**:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍!
## 功能特性
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
- 一键切换不同供应商
- 智谱 GLM、Qwen coder、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- 支持添加自定义供应商
- 简洁美观的图形界面
- 信息存储在本地 ~/.cc-switch/config.json无隐私风险
- 超小体积 - 仅 ~5MB 安装包
## 界面预览
@@ -22,109 +30,115 @@
## 下载安装
### 系统要求
- **Windows**: Windows 10 及以上
- **macOS**: macOS 10.15 (Catalina) 及以上
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
### Windows 用户
从 [Releases](../../releases) 页面下载
- **安装版 (推荐)**: `CC-Switch-Setup-x.x.x.exe`
- 完整系统集成,正确显示应用图标
- 自动创建桌面快捷方式和开始菜单项
- **便携版**: `CC-Switch-Portable-x.x.x.exe`
- 无需安装,直接运行
- 适合需要绿色软件的用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch_3.0.0_x64.msi``.exe` 安装包。
### macOS 用户
从 [Releases](../../releases) 页面下载
- **通用版本(推荐)**: `CC Switch-x.x.x-mac.zip` - Intel 版本,兼容所有 Mac包括 M 系列芯片)
#### macOS 安装说明
**推荐使用通用版本**,它通过 Rosetta 2 在 M 系列 Mac 上运行良好,兼容性最佳。
由于作者没有苹果开发者账号,应用使用 ad-hoc 签名(未经苹果官方认证),首次打开时可能出现"未知开发者"警告。这是正常的安全提示,处理方法:
**方法 1 - 系统设置**
1. 双击应用时选择"取消"
2. 打开"系统设置" → "隐私与安全性"
3. 在底部找到被阻止的应用,点击"仍要打开"
4. 确认后即可正常使用
**方法 2 - 自行编译**
1. Clone 代码到本地:`git clone https://github.com/farion1231/cc-switch.git`
2. 安装依赖:`pnpm install`
3. 编译代码:`pnpm run build`
4. 打包应用:`pnpm run dist`
5. 在项目 release 目录找到编译好的应用包
**安全保障**
- 应用已通过 ad-hoc 代码签名,确保文件完整性
- 源代码完全开源,可在 GitHub 审查
- 本地存储配置,无网络传输风险
**技术说明**
- 使用 Intel x64 架构,通过 Rosetta 2 在 M 系列芯片上运行
- 兼容性和稳定性最佳,性能损失 minimal
- 避免了 ARM64 原生版本的签名复杂性问题
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch_3.0.0_x64.dmg` (Intel) 或 `CC-Switch_3.0.0_aarch64.dmg` (Apple Silicon)。
### Linux 用户
- **AppImage**: `CC Switch-x.x.x.AppImage`
下载后添加执行权限:
```bash
chmod +x CC-Switch-x.x.x.AppImage
```
从 [Releases](../../releases) 页面下载最新版本的 `.AppImage``.deb` 包。
## 使用说明
1. 点击"添加供应商"添加你的 API 配置
2. 选择要使用的供应商,点击单选按钮切换
3. 配置会自动保存到 Claude Code 的配置文件中
4. 重启或者新打开 Claude Code 终端以生效
4. 重启或者新打开终端以生效
## 开发
### 环境要求
- Node.js 18+
- pnpm 8+
- Rust 1.75+
- Tauri CLI 2.0+
### 开发命令
```bash
# 安装依赖
pnpm install
# 或
npm install
# 开发模式
pnpm run dev
# 开发模式(热重载)
pnpm dev
# 类型检查
pnpm typecheck
# 代码格式化
pnpm format
# 检查代码格式
pnpm format:check
# 构建应用
pnpm run build
pnpm build
# 打包发布
pnpm run dist
# 构建调试版本
pnpm tauri build --debug
```
### Rust 后端开发
```bash
cd src-tauri
# 格式化 Rust 代码
cargo fmt
# 运行 clippy 检查
cargo clippy
# 运行测试
cargo test
```
## 技术栈
- Electron
- React
- TypeScript
- Vite
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
- **[React 18](https://react.dev/)** - 用户界面库
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
## 项目结构
```
├── src/
│ ├── main/ # 主进程代码
│ ├── renderer/ # 渲染进程代码
── shared/ # 共享类型和工具
├── build/ # 应用图标资源
── dist/ # 构建输出目录
├── src/ # 前端代码 (React + TypeScript)
│ ├── components/ # React 组件
│ ├── config/ # 预设供应商配置
── lib/ # Tauri API 封装
│ └── utils/ # 工具函数
── src-tauri/ # 后端代码 (Rust)
│ ├── src/ # Rust 源代码
│ │ ├── commands.rs # Tauri 命令定义
│ │ ├── config.rs # 配置文件管理
│ │ ├── provider.rs # 供应商管理逻辑
│ │ └── store.rs # 状态管理
│ ├── capabilities/ # 权限配置
│ └── icons/ # 应用图标资源
└── screenshots/ # 界面截图
```
## 更新日志
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
## 贡献
欢迎提交 Issue 和 Pull Request
## License
MIT
MIT © Jason Young

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

View File

@@ -1,91 +1,33 @@
{
"name": "cc-switch",
"version": "2.0.3",
"version": "3.0.0",
"description": "Claude Code 供应商切换工具",
"main": "dist/main/index.js",
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron:watch\"",
"dev:electron": "tsc -p tsconfig.main.json && electron .",
"dev:electron:watch": "tsc -p tsconfig.main.json && concurrently -k \"tsc -w -p tsconfig.main.json\" \"npm:electron\"",
"electron": "electron .",
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
"tauri": "tauri",
"dev:renderer": "vite",
"build": "npm run build:renderer && npm run build:main",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build",
"start": "electron .",
"dist": "electron-builder"
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
},
"keywords": [],
"author": "Jason Young",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.8.0",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"concurrently": "^8.2.0",
"electron": "^32.3.3",
"electron-builder": "^24.0.0",
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"build": {
"appId": "com.ccswitch.app",
"productName": "CC Switch",
"compression": "maximum",
"publish": null,
"directories": {
"output": "release"
},
"icon": "build/icon.ico",
"files": [
"dist/**/*",
"node_modules/**/*"
],
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"mac": {
"category": "public.app-category.developer-tools",
"icon": "build/icon.icns",
"identity": "-",
"hardenedRuntime": false,
"entitlements": null,
"entitlementsInherit": null,
"target": [
{
"target": "zip",
"arch": [
"x64"
]
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
}
],
"icon": "build/icon.ico"
},
"linux": {
"target": "AppImage",
"icon": "build/icon.png"
}
}
}

2386
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 194 KiB

4
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

5772
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "cc-switch"
version = "3.0.0"
description = "Claude Code MCP 服务器配置管理工具"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/jasonyoung/cc-switch"
edition = "2024"
rust-version = "1.85.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "cc_switch_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.4.0", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.8.2", features = [] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
dirs = "5.0"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

195
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,195 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
use crate::provider::Provider;
use crate::store::AppState;
/// 获取所有供应商
#[tauri::command]
pub async fn get_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, Provider>, String> {
let manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
Ok(manager.get_all_providers().clone())
}
/// 获取当前供应商ID
#[tauri::command]
pub async fn get_current_provider(state: State<'_, AppState>) -> Result<String, String> {
let manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
Ok(manager.current.clone())
}
/// 添加供应商
#[tauri::command]
pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Result<bool, String> {
let mut manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
manager.add_provider(provider)?;
// 保存配置
drop(manager); // 释放锁
state.save()?;
Ok(true)
}
/// 更新供应商
#[tauri::command]
pub async fn update_provider(
state: State<'_, AppState>,
provider: Provider,
) -> Result<bool, String> {
let mut manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
manager.update_provider(provider)?;
// 保存配置
drop(manager); // 释放锁
state.save()?;
Ok(true)
}
/// 删除供应商
#[tauri::command]
pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<bool, String> {
let mut manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
manager.delete_provider(&id)?;
// 保存配置
drop(manager); // 释放锁
state.save()?;
Ok(true)
}
/// 切换供应商
#[tauri::command]
pub async fn switch_provider(state: State<'_, AppState>, id: String) -> Result<bool, String> {
let mut manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
manager.switch_provider(&id)?;
// 保存配置
drop(manager); // 释放锁
state.save()?;
Ok(true)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub async fn import_default_config(state: State<'_, AppState>) -> Result<bool, String> {
// 若已存在 default 供应商,则直接返回,避免重复导入
{
let manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
if manager.get_all_providers().contains_key("default") {
return Ok(true);
}
}
// 导入配置
let settings_config = import_current_config_as_default()?;
// 创建默认供应商
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
// 添加到管理器
let mut manager = state
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
manager.add_provider(provider)?;
// 如果没有当前供应商,设置为 default
if manager.current.is_empty() {
manager.current = "default".to_string();
}
// 保存配置
drop(manager); // 释放锁
state.save()?;
Ok(true)
}
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(crate::config::get_claude_config_status())
}
/// 获取 Claude Code 配置文件路径
#[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(app: tauri::AppHandle) -> Result<bool, String> {
let config_dir = crate::config::get_claude_config_dir();
// 确保目录存在
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
app.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
// 规范化 URL缺少协议时默认加 https://
let url = if url.starts_with("http://") || url.starts_with("https://") {
url
} else {
format!("https://{}", url)
};
// 使用 opener 插件打开链接
app.opener()
.open_url(&url, None::<String>)
.map_err(|e| format!("打开链接失败: {}", e))?;
Ok(true)
}

141
src-tauri/src/config.rs Normal file
View File

@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")
}
/// 获取 Claude Code 主配置文件路径
pub fn get_claude_settings_path() -> PathBuf {
let dir = get_claude_config_dir();
let settings = dir.join("settings.json");
if settings.exists() {
return settings;
}
// 兼容旧版命名:若存在旧文件则继续使用
let legacy = dir.join("claude.json");
if legacy.exists() {
return legacy;
}
// 默认新建:回落到标准文件名 settings.json不再生成 claude.json
settings
}
/// 获取应用配置目录路径 (~/.cc-switch)
pub fn get_app_config_dir() -> PathBuf {
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
}
/// 获取应用配置文件路径
pub fn get_app_config_path() -> PathBuf {
get_app_config_dir().join("config.json")
}
/// 清理供应商名称,确保文件名安全
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
_ => c,
})
.collect::<String>()
.to_lowercase()
}
/// 获取供应商配置文件路径
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
let base_name = provider_name
.map(|name| sanitize_provider_name(name))
.unwrap_or_else(|| sanitize_provider_name(provider_id));
get_claude_config_dir().join(format!("settings-{}.json", base_name))
}
/// 读取 JSON 配置文件
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
if !path.exists() {
return Err(format!("文件不存在: {}", path.display()));
}
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
}
/// 写入 JSON 配置文件
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let json =
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e))
}
/// 复制文件
pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> {
fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?;
Ok(())
}
/// 删除文件
pub fn delete_file(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
}
Ok(())
}
/// 检查 Claude Code 配置状态
#[derive(Serialize, Deserialize)]
pub struct ConfigStatus {
pub exists: bool,
pub path: String,
}
/// 获取 Claude Code 配置状态
pub fn get_claude_config_status() -> ConfigStatus {
let path = get_claude_settings_path();
ConfigStatus {
exists: path.exists(),
path: path.to_string_lossy().to_string(),
}
}
/// 备份配置文件
pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> {
if from.exists() {
copy_file(from, to)?;
log::info!("已备份配置文件: {} -> {}", from.display(), to.display());
}
Ok(())
}
/// 导入当前 Claude Code 配置为默认供应商
pub fn import_current_config_as_default() -> Result<Value, String> {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
// 读取当前配置
let settings_config: Value = read_json_file(&settings_path)?;
// 保存为 default 供应商
let default_provider_path = get_provider_config_path("default", Some("default"));
write_json_file(&default_provider_path, &settings_config)?;
log::info!("已导入当前配置为默认供应商");
Ok(settings_config)
}

105
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,105 @@
mod commands;
mod config;
mod provider;
mod store;
use store::AppState;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
if let Some(window) = app.get_webview_window("main") {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
let ns_window_ptr = window.ns_window().unwrap();
let ns_window: Retained<AnyObject> =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() };
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
}
}
// 初始化日志
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage
let app_state = AppState::new();
// 如果没有供应商且存在 Claude Code 配置,自动导入
{
let manager = app_state.provider_manager.lock().unwrap();
if manager.providers.is_empty() {
drop(manager); // 释放锁
let settings_path = config::get_claude_settings_path();
if settings_path.exists() {
log::info!("检测到 Claude Code 配置,自动导入为默认供应商");
if let Ok(settings_config) = config::import_current_config_as_default() {
let mut manager = app_state.provider_manager.lock().unwrap();
let provider = provider::Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
if manager.add_provider(provider).is_ok() {
manager.current = "default".to_string();
drop(manager);
let _ = app_state.save();
log::info!("成功导入默认供应商");
}
}
}
}
}
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_providers,
commands::get_current_provider,
commands::add_provider,
commands::update_provider,
commands::delete_provider,
commands::switch_provider,
commands::import_default_config,
commands::get_claude_config_status,
commands::get_claude_code_config_path,
commands::open_config_folder,
commands::open_external,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cc_switch_lib::run();
}

179
src-tauri/src/provider.rs Normal file
View File

@@ -0,0 +1,179 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use crate::config::{
backup_config, copy_file, delete_file, get_claude_settings_path, get_provider_config_path,
read_json_file, write_json_file,
};
/// 供应商结构体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provider {
pub id: String,
pub name: String,
#[serde(rename = "settingsConfig")]
pub settings_config: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
}
impl Provider {
/// 从现有ID创建供应商
pub fn with_id(
id: String,
name: String,
settings_config: Value,
website_url: Option<String>,
) -> Self {
Self {
id,
name,
settings_config,
website_url,
}
}
}
/// 供应商管理器
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderManager {
pub providers: HashMap<String, Provider>,
pub current: String,
}
impl Default for ProviderManager {
fn default() -> Self {
Self {
providers: HashMap::new(),
current: String::new(),
}
}
}
impl ProviderManager {
/// 加载供应商列表
pub fn load_from_file(path: &Path) -> Result<Self, String> {
if !path.exists() {
log::info!("配置文件不存在,创建新的供应商管理器");
return Ok(Self::default());
}
read_json_file(path)
}
/// 保存供应商列表
pub fn save_to_file(&self, path: &Path) -> Result<(), String> {
write_json_file(path, self)
}
/// 添加供应商
pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> {
// 保存供应商配置到独立文件
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
// 添加到管理器
self.providers.insert(provider.id.clone(), provider);
Ok(())
}
/// 更新供应商
pub fn update_provider(&mut self, provider: Provider) -> Result<(), String> {
// 检查供应商是否存在
if !self.providers.contains_key(&provider.id) {
return Err(format!("供应商不存在: {}", provider.id));
}
// 如果名称改变了,需要处理配置文件
if let Some(old_provider) = self.providers.get(&provider.id) {
if old_provider.name != provider.name {
// 删除旧配置文件
let old_config_path =
get_provider_config_path(&provider.id, Some(&old_provider.name));
delete_file(&old_config_path).ok(); // 忽略删除错误
}
}
// 保存新配置文件
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
// 更新管理器
self.providers.insert(provider.id.clone(), provider);
Ok(())
}
/// 删除供应商
pub fn delete_provider(&mut self, provider_id: &str) -> Result<(), String> {
// 检查是否为当前供应商
if self.current == provider_id {
return Err("不能删除当前正在使用的供应商".to_string());
}
// 获取供应商信息
let provider = self
.providers
.get(provider_id)
.ok_or_else(|| format!("供应商不存在: {}", provider_id))?;
// 删除配置文件
let config_path = get_provider_config_path(provider_id, Some(&provider.name));
delete_file(&config_path)?;
// 从管理器删除
self.providers.remove(provider_id);
Ok(())
}
/// 切换供应商
pub fn switch_provider(&mut self, provider_id: &str) -> Result<(), String> {
// 检查供应商是否存在
let provider = self
.providers
.get(provider_id)
.ok_or_else(|| format!("供应商不存在: {}", provider_id))?;
let settings_path = get_claude_settings_path();
let provider_config_path = get_provider_config_path(provider_id, Some(&provider.name));
// 检查供应商配置文件是否存在
if !provider_config_path.exists() {
return Err(format!(
"供应商配置文件不存在: {}",
provider_config_path.display()
));
}
// 如果当前有配置,先备份到当前供应商
if settings_path.exists() && !self.current.is_empty() {
if let Some(current_provider) = self.providers.get(&self.current) {
let current_provider_path =
get_provider_config_path(&self.current, Some(&current_provider.name));
backup_config(&settings_path, &current_provider_path)?;
log::info!("已备份当前供应商配置: {}", current_provider.name);
}
}
// 确保主配置父目录存在
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 复制新供应商配置到主配置
copy_file(&provider_config_path, &settings_path)?;
// 更新当前供应商
self.current = provider_id.to_string();
log::info!("成功切换到供应商: {}", provider.name);
Ok(())
}
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
&self.providers
}
}

36
src-tauri/src/store.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::config::get_app_config_path;
use crate::provider::ProviderManager;
use std::sync::Mutex;
/// 全局应用状态
pub struct AppState {
pub provider_manager: Mutex<ProviderManager>,
}
impl AppState {
/// 创建新的应用状态
pub fn new() -> Self {
let config_path = get_app_config_path();
let provider_manager = ProviderManager::load_from_file(&config_path).unwrap_or_else(|e| {
log::warn!("加载配置失败: {}, 使用默认配置", e);
ProviderManager::default()
});
Self {
provider_manager: Mutex::new(provider_manager),
}
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), String> {
let config_path = get_app_config_path();
let manager = self
.provider_manager
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
manager.save_to_file(&config_path)
}
// 保留按需扩展:若未来需要热加载,可在此实现
}

40
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.0.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:3000",
"beforeDevCommand": "pnpm run dev:renderer",
"beforeBuildCommand": "pnpm run build:renderer"
},
"app": {
"windows": [
{
"title": "",
"width": 900,
"height": 650,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Transparent"
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:"
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg", "nsis", "appimage"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@@ -7,14 +7,13 @@
.app-header {
background: #3498db;
color: white;
padding: 1rem 2rem;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/* 允许作为 Electron 的拖拽区域macOS 隐藏标题栏时生效) */
-webkit-app-region: drag;
user-select: none;
min-height: 3rem;
}
.app-header h1 {
@@ -25,19 +24,16 @@
.header-actions {
display: flex;
gap: 1rem;
/* header 内的交互元素需要排除拖拽,否则无法点击 */
-webkit-app-region: no-drag;
}
.refresh-btn, .add-btn {
.refresh-btn,
.add-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
/* 明确按钮不可拖拽,确保可点击 */
-webkit-app-region: no-drag;
}
.refresh-btn {
@@ -128,12 +124,12 @@
left: 50%;
transform: translateX(-50%);
z-index: 100;
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
width: fit-content;
white-space: nowrap;
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { Provider } from "../shared/types";
import { Provider } from "./types";
import ProviderList from "./components/ProviderList";
import AddProviderModal from "./components/AddProviderModal";
import EditProviderModal from "./components/EditProviderModal";
@@ -10,9 +10,12 @@ function App() {
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [configPath, setConfigPath] = useState<string>("");
const [configStatus, setConfigStatus] = useState<{
exists: boolean;
path: string;
} | null>(null);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null
null,
);
const [notification, setNotification] = useState<{
message: string;
@@ -25,13 +28,13 @@ function App() {
message: string;
onConfirm: () => void;
} | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 设置通知的辅助函数
const showNotification = (
message: string,
type: "success" | "error",
duration = 3000
duration = 3000,
) => {
// 清除之前的定时器
if (timeoutRef.current) {
@@ -56,7 +59,7 @@ function App() {
// 加载供应商列表
useEffect(() => {
loadProviders();
loadConfigPath();
loadConfigStatus();
}, []);
// 清理定时器
@@ -69,20 +72,23 @@ function App() {
}, []);
const loadProviders = async () => {
const loadedProviders = await window.electronAPI.getProviders();
const currentId = await window.electronAPI.getCurrentProvider();
const loadedProviders = await window.api.getProviders();
const currentId = await window.api.getCurrentProvider();
setProviders(loadedProviders);
setCurrentProviderId(currentId);
// 如果供应商列表为空,尝试自动导入现有配置为"default"供应商
if (Object.keys(loadedProviders).length === 0) {
await handleAutoImportDefault();
}
};
const loadConfigPath = async () => {
const path = await window.electronAPI.getClaudeCodeConfigPath();
setConfigPath(path);
const loadConfigStatus = async () => {
const status = await window.api.getClaudeConfigStatus();
setConfigStatus({
exists: Boolean(status?.exists),
path: String(status?.path || ""),
});
};
// 生成唯一ID
@@ -95,14 +101,14 @@ function App() {
...provider,
id: generateId(),
};
await window.electronAPI.addProvider(newProvider);
await window.api.addProvider(newProvider);
await loadProviders();
setIsAddModalOpen(false);
};
const handleEditProvider = async (provider: Provider) => {
try {
await window.electronAPI.updateProvider(provider);
await window.api.updateProvider(provider);
await loadProviders();
setEditingProviderId(null);
// 显示编辑成功提示
@@ -121,7 +127,7 @@ function App() {
title: "删除供应商",
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
onConfirm: async () => {
await window.electronAPI.deleteProvider(id);
await window.api.deleteProvider(id);
await loadProviders();
setConfirmDialog(null);
showNotification("供应商删除成功", "success");
@@ -130,14 +136,14 @@ function App() {
};
const handleSwitchProvider = async (id: string) => {
const success = await window.electronAPI.switchProvider(id);
const success = await window.api.switchProvider(id);
if (success) {
setCurrentProviderId(id);
// 显示重启提示
showNotification(
"切换成功!请重启 Claude Code 终端以生效",
"success",
2000
2000,
);
} else {
showNotification("切换失败,请检查配置", "error");
@@ -147,21 +153,25 @@ function App() {
// 自动导入现有配置为"default"供应商
const handleAutoImportDefault = async () => {
try {
const result = await window.electronAPI.importCurrentConfigAsDefault()
const result = await window.api.importCurrentConfigAsDefault();
if (result.success) {
await loadProviders()
showNotification("已自动导入现有配置为 default 供应商", "success", 3000)
await loadProviders();
showNotification(
"已自动导入现有配置为 default 供应商",
"success",
3000,
);
}
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) {
console.error('自动导入默认配置失败:', error)
console.error("自动导入默认配置失败:", error);
// 静默处理,不影响用户体验
}
}
};
const handleOpenConfigFolder = async () => {
await window.electronAPI.openConfigFolder();
await window.api.openConfigFolder();
};
return (
@@ -199,9 +209,12 @@ function App() {
/>
</div>
{configPath && (
{configStatus && (
<div className="config-path">
<span>: {configPath}</span>
<span>
: {configStatus.path}
{!configStatus.exists ? "(未创建,切换或保存时会自动创建)" : ""}
</span>
<button
className="browse-btn"
onClick={handleOpenConfigFolder}

View File

@@ -13,20 +13,78 @@
.modal-content {
background: white;
border-radius: 8px;
padding: 2rem;
border-radius: 10px;
padding: 0;
width: 90%;
max-width: 600px;
max-width: 640px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
overflow: hidden; /* 由 body 滚动,标题栏固定 */
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1001;
display: flex; /* 纵向布局,便于底栏固定 */
flex-direction: column;
}
.modal-content h2 {
margin-bottom: 1.5rem;
color: #2c3e50;
/* 模拟窗口标题栏 */
.modal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem; /* 与主窗口标题栏一致 */
padding: 0 12px; /* 接近主头部的水平留白 */
background: #3498db; /* 与 .app-header 相同 */
color: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
.modal-spacer {
width: 32px;
flex: 0 0 32px;
}
.modal-title {
flex: 1;
text-align: center;
color: #fff;
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-close-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.modal-close-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.modal-form {
/* 表单外层包裹 body + footer */
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0; /* 允许子元素正确计算高度 */
}
.modal-body {
padding: 1.25rem 1.5rem 1.5rem;
overflow: auto; /* 仅内容区滚动 */
flex: 1 1 auto;
min-height: 0;
}
.error-message {
@@ -75,6 +133,18 @@
color: white;
}
/* 官方按钮橙色主题Anthropic 风格) */
.preset-btn.official {
border: 1px solid #d97706;
color: #d97706;
}
.preset-btn.official:hover,
.preset-btn.official.selected {
background: #d97706;
color: white;
}
.form-group {
margin-bottom: 1.25rem;
}
@@ -86,6 +156,18 @@
font-weight: 500;
}
/* API Key 输入框容器 - 预留空间避免抖动 */
.form-group.api-key-group {
min-height: 88px; /* 固定高度label + input + 间距 */
transition: opacity 0.2s ease;
}
.form-group.api-key-group.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.form-group input,
.form-group textarea {
width: 100%;
@@ -109,11 +191,14 @@
border-color: #3498db;
}
.form-actions {
.modal-footer {
/* 固定在弹窗底部(非滚动区) */
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid #ecf0f1;
background: #fff;
}
.cancel-btn,
@@ -180,4 +265,4 @@
margin: 2px;
cursor: pointer;
transform: translateY(2px);
}
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Provider } from "../../shared/types";
import { Provider } from "../types";
import ProviderForm from "./ProviderForm";
interface AddProviderModalProps {

View File

@@ -68,7 +68,9 @@
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s, transform 0.1s;
transition:
background-color 0.2s,
transform 0.1s;
min-width: 70px;
}
@@ -102,4 +104,4 @@
.confirm-btn:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import './ConfirmDialog.css';
import React from "react";
import "./ConfirmDialog.css";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -15,10 +15,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = '确定',
cancelText = '取消',
confirmText = "确定",
cancelText = "取消",
onConfirm,
onCancel
onCancel,
}) => {
if (!isOpen) return null;
@@ -32,15 +32,15 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
<p>{message}</p>
</div>
<div className="confirm-actions">
<button
className="confirm-btn cancel-btn"
<button
className="confirm-btn cancel-btn"
onClick={onCancel}
autoFocus
>
{cancelText}
</button>
<button
className="confirm-btn confirm-btn-primary"
<button
className="confirm-btn confirm-btn-primary"
onClick={onConfirm}
>
{confirmText}
@@ -49,4 +49,4 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</div>
</div>
);
};
};

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Provider } from "../types";
import ProviderForm from "./ProviderForm";
interface EditProviderModalProps {
provider: Provider;
onSave: (provider: Provider) => void;
onClose: () => void;
}
const EditProviderModal: React.FC<EditProviderModalProps> = ({
provider,
onSave,
onClose,
}) => {
const handleSubmit = (data: Omit<Provider, "id">) => {
onSave({
...provider,
...data,
});
};
return (
<ProviderForm
title="编辑供应商"
submitText="保存"
initialData={provider}
showPresets={false}
onSubmit={handleSubmit}
onClose={onClose}
/>
);
};
export default EditProviderModal;

View File

@@ -0,0 +1,355 @@
import React, { useState, useEffect } from "react";
import { Provider } from "../types";
import {
updateCoAuthoredSetting,
checkCoAuthoredSetting,
extractWebsiteUrl,
getApiKeyFromConfig,
hasApiKeyField,
setApiKeyInConfig,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import "./AddProviderModal.css";
interface ProviderFormProps {
title: string;
submitText: string;
initialData?: Provider;
showPresets?: boolean;
onSubmit: (data: Omit<Provider, "id">) => void;
onClose: () => void;
}
const ProviderForm: React.FC<ProviderFormProps> = ({
title,
submitText,
initialData,
showPresets = false,
onSubmit,
onClose,
}) => {
const [formData, setFormData] = useState({
name: initialData?.name || "",
websiteUrl: initialData?.websiteUrl || "",
settingsConfig: initialData
? JSON.stringify(initialData.settingsConfig, null, 2)
: "",
});
const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
const [apiKey, setApiKey] = useState("");
// 初始化时检查禁用签名状态
useEffect(() => {
if (initialData) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
}
}, [initialData]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!formData.name) {
setError("请填写供应商名称");
return;
}
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
return;
}
let settingsConfig: Record<string, any>;
try {
settingsConfig = JSON.parse(formData.settingsConfig);
} catch (err) {
setError("配置JSON格式错误请检查语法");
return;
}
onSubmit({
name: formData.name,
websiteUrl: formData.websiteUrl,
settingsConfig,
});
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
if (name === "settingsConfig") {
// 当用户修改配置时,尝试自动提取官网地址
const extractedWebsiteUrl = extractWebsiteUrl(value);
// 同时检查并同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 同步 API Key 输入框显示与值
const parsedKey = getApiKeyFromConfig(value);
setApiKey(parsedKey);
setFormData({
...formData,
[name]: value,
// 只有在官网地址为空时才自动填入
websiteUrl: formData.websiteUrl || extractedWebsiteUrl,
});
} else {
setFormData({
...formData,
[name]: value,
});
}
};
// 处理选择框变化
const handleCoAuthoredToggle = (checked: boolean) => {
setDisableCoAuthored(checked);
// 更新JSON配置
const updatedConfig = updateCoAuthoredSetting(
formData.settingsConfig,
checked,
);
setFormData({
...formData,
settingsConfig: updatedConfig,
});
};
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
const configString = JSON.stringify(preset.settingsConfig, null, 2);
setFormData({
name: preset.name,
websiteUrl: preset.websiteUrl,
settingsConfig: configString,
});
// 设置选中的预设
setSelectedPreset(index);
// 清空 API Key 输入框,让用户重新输入
setApiKey("");
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
};
// 处理 API Key 输入并自动更新配置
const handleApiKeyChange = (key: string) => {
setApiKey(key);
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null },
);
// 更新表单配置
setFormData((prev) => ({
...prev,
settingsConfig: configString,
}));
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
};
// 根据当前配置决定是否展示 API Key 输入框
const showApiKey =
selectedPreset !== null || hasApiKeyField(formData.settingsConfig);
// 判断当前选中的预设是否是官方
const isOfficialPreset =
selectedPreset !== null &&
providerPresets[selectedPreset]?.isOfficial === true;
// 初始时从配置中同步 API Key编辑模式
useEffect(() => {
if (initialData) {
const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig),
);
if (parsedKey) setApiKey(parsedKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div
className="modal-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-content">
<div className="modal-titlebar">
<div className="modal-spacer" />
<div className="modal-title" title={title}>
{title}
</div>
<button
type="button"
className="modal-close-btn"
aria-label="关闭"
onClick={onClose}
title="关闭"
>
×
</button>
</div>
<form onSubmit={handleSubmit} className="modal-form">
<div className="modal-body">
{error && <div className="error-message">{error}</div>}
{showPresets && (
<div className="presets">
<label> key</label>
<div className="preset-buttons">
{providerPresets.map((preset, index) => {
return (
<button
key={index}
type="button"
className={`preset-btn ${
selectedPreset === index ? "selected" : ""
} ${preset.isOfficial ? "official" : ""}`}
onClick={() => applyPreset(preset, index)}
>
{preset.name}
</button>
);
})}
</div>
</div>
)}
<div className="form-group">
<label htmlFor="name"> *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="例如Anthropic 官方"
required
autoComplete="off"
/>
</div>
<div
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
>
<label htmlFor="apiKey">API Key *</label>
<input
type="text"
id="apiKey"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder={
isOfficialPreset
? "官方登录无需填写 API Key直接保存即可"
: "只需要填这里,下方配置会自动填充"
}
disabled={isOfficialPreset}
autoComplete="off"
style={
isOfficialPreset
? {
backgroundColor: "#f5f5f5",
cursor: "not-allowed",
color: "#999",
}
: {}
}
/>
</div>
<div className="form-group">
<label htmlFor="websiteUrl"></label>
<input
type="url"
id="websiteUrl"
name="websiteUrl"
value={formData.websiteUrl}
onChange={handleChange}
placeholder="https://example.com可选"
autoComplete="off"
/>
</div>
<div className="form-group">
<div className="label-with-checkbox">
<label htmlFor="settingsConfig">
Claude Code (JSON) *
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
/>
Claude Code
</label>
</div>
<textarea
id="settingsConfig"
name="settingsConfig"
value={formData.settingsConfig}
onChange={handleChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
}
}`}
rows={12}
style={{ fontFamily: "monospace", fontSize: "14px" }}
required
/>
<small className="field-hint">
Claude Code settings.json
</small>
</div>
</div>
<div className="modal-footer">
<button type="button" className="cancel-btn" onClick={onClose}>
</button>
<button type="submit" className="submit-btn">
{submitText}
</button>
</div>
</form>
</div>
</div>
);
};
export default ProviderForm;

View File

@@ -203,4 +203,4 @@
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@@ -1,13 +1,13 @@
import React from 'react'
import { Provider } from '../../shared/types'
import './ProviderList.css'
import React from "react";
import { Provider } from "../types";
import "./ProviderList.css";
interface ProviderListProps {
providers: Record<string, Provider>
currentProviderId: string
onSwitch: (id: string) => void
onDelete: (id: string) => void
onEdit: (id: string) => void
providers: Record<string, Provider>;
currentProviderId: string;
onSwitch: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string) => void;
}
const ProviderList: React.FC<ProviderListProps> = ({
@@ -15,28 +15,28 @@ const ProviderList: React.FC<ProviderListProps> = ({
currentProviderId,
onSwitch,
onDelete,
onEdit
onEdit,
}) => {
// 提取API地址
const getApiUrl = (provider: Provider): string => {
try {
const config = provider.settingsConfig
const config = provider.settingsConfig;
if (config?.env?.ANTHROPIC_BASE_URL) {
return config.env.ANTHROPIC_BASE_URL
return config.env.ANTHROPIC_BASE_URL;
}
return '未设置'
return "未设置";
} catch {
return '配置错误'
return "配置错误";
}
}
};
const handleUrlClick = async (url: string) => {
try {
await window.electronAPI.openExternal(url)
await window.api.openExternal(url);
} catch (error) {
console.error('打开链接失败:', error)
console.error("打开链接失败:", error);
}
}
};
return (
<div className="provider-list">
@@ -48,25 +48,27 @@ const ProviderList: React.FC<ProviderListProps> = ({
) : (
<div className="provider-items">
{Object.values(providers).map((provider) => {
const isCurrent = provider.id === currentProviderId
const isCurrent = provider.id === currentProviderId;
return (
<div
key={provider.id}
className={`provider-item ${isCurrent ? 'current' : ''}`}
<div
key={provider.id}
className={`provider-item ${isCurrent ? "current" : ""}`}
>
<div className="provider-info">
<div className="provider-name">
<span>{provider.name}</span>
{isCurrent && <span className="current-badge">使</span>}
{isCurrent && (
<span className="current-badge">使</span>
)}
</div>
<div className="provider-url">
{provider.websiteUrl ? (
<a
href="#"
<a
href="#"
onClick={(e) => {
e.preventDefault()
handleUrlClick(provider.websiteUrl!)
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="url-link"
title={`访问 ${provider.websiteUrl}`}
@@ -80,23 +82,23 @@ const ProviderList: React.FC<ProviderListProps> = ({
)}
</div>
</div>
<div className="provider-actions">
<button
<button
className="enable-btn"
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
>
</button>
<button
<button
className="edit-btn"
onClick={() => onEdit(provider.id)}
disabled={isCurrent}
>
</button>
<button
<button
className="delete-btn"
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
@@ -105,12 +107,12 @@ const ProviderList: React.FC<ProviderListProps> = ({
</button>
</div>
</div>
)
);
})}
</div>
)}
</div>
)
}
);
};
export default ProviderList
export default ProviderList;

View File

@@ -5,12 +5,21 @@ export interface ProviderPreset {
name: string;
websiteUrl: string;
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
}
export const providerPresets: ProviderPreset[] = [
{
name: "Claude官方登录",
websiteUrl: "https://www.anthropic.com/claude-code",
settingsConfig: {
env: {},
},
isOfficial: true, // 明确标识为官方预设
},
{
name: "DeepSeek v3.1",
websiteUrl: "https://platform.deepseek.com/",
websiteUrl: "https://platform.deepseek.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
@@ -41,6 +50,18 @@ export const providerPresets: ProviderPreset[] = [
},
},
},
{
name: "Kimi k2",
websiteUrl: "https://platform.moonshot.cn/console",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
},
},
},
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
@@ -51,14 +72,4 @@ export const providerPresets: ProviderPreset[] = [
},
},
},
{
name: "AnyRouter",
websiteUrl: "https://anyrouter.top",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://anyrouter.top",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
},
},
},
];

View File

@@ -5,9 +5,9 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;

152
src/lib/tauri-api.ts Normal file
View File

@@ -0,0 +1,152 @@
import { invoke } from "@tauri-apps/api/core";
import { Provider } from "../types";
// 定义配置状态类型
interface ConfigStatus {
exists: boolean;
path: string;
error?: string;
}
// 定义导入结果类型
interface ImportResult {
success: boolean;
message?: string;
}
// Tauri API 封装,提供统一的全局 API 接口
export const tauriAPI = {
// 获取所有供应商
getProviders: async (): Promise<Record<string, Provider>> => {
try {
return await invoke("get_providers");
} catch (error) {
console.error("获取供应商列表失败:", error);
return {};
}
},
// 获取当前供应商ID
getCurrentProvider: async (): Promise<string> => {
try {
return await invoke("get_current_provider");
} catch (error) {
console.error("获取当前供应商失败:", error);
return "";
}
},
// 添加供应商
addProvider: async (provider: Provider): Promise<boolean> => {
try {
return await invoke("add_provider", { provider });
} catch (error) {
console.error("添加供应商失败:", error);
throw error;
}
},
// 更新供应商
updateProvider: async (provider: Provider): Promise<boolean> => {
try {
return await invoke("update_provider", { provider });
} catch (error) {
console.error("更新供应商失败:", error);
throw error;
}
},
// 删除供应商
deleteProvider: async (id: string): Promise<boolean> => {
try {
return await invoke("delete_provider", { id });
} catch (error) {
console.error("删除供应商失败:", error);
throw error;
}
},
// 切换供应商
switchProvider: async (providerId: string): Promise<boolean> => {
try {
return await invoke("switch_provider", { id: providerId });
} catch (error) {
console.error("切换供应商失败:", error);
return false;
}
},
// 导入当前配置为默认供应商
importCurrentConfigAsDefault: async (): Promise<ImportResult> => {
try {
const success = await invoke<boolean>("import_default_config");
return {
success,
message: success ? "成功导入默认配置" : "导入失败",
};
} catch (error) {
console.error("导入默认配置失败:", error);
return {
success: false,
message: String(error),
};
}
},
// 获取 Claude Code 配置文件路径
getClaudeCodeConfigPath: async (): Promise<string> => {
try {
return await invoke("get_claude_code_config_path");
} catch (error) {
console.error("获取配置路径失败:", error);
return "";
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
try {
return await invoke("get_claude_config_status");
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 打开配置文件夹
openConfigFolder: async (): Promise<void> => {
try {
await invoke("open_config_folder");
} catch (error) {
console.error("打开配置文件夹失败:", error);
}
},
// 打开外部链接
openExternal: async (url: string): Promise<void> => {
try {
await invoke("open_external", { url });
} catch (error) {
console.error("打开外部链接失败:", error);
}
},
// 选择配置文件Tauri 暂不实现,保留接口兼容性)
selectConfigFile: async (): Promise<string | null> => {
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
return null;
},
};
// 创建全局 API 对象,兼容现有代码
if (typeof window !== "undefined") {
// 绑定到 window.api避免 Electron 命名造成误解
// API 内部已做 try/catch非 Tauri 环境下也会安全返回默认值
(window as any).api = tauriAPI;
}
export default tauriAPI;

24
src/main.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
// 导入 Tauri API自动绑定到 window.api
import "./lib/tauri-api";
// 根据平台添加 body class便于平台特定样式
try {
const ua = navigator.userAgent || "";
const plat = (navigator.platform || "").toLowerCase();
const isMac = /mac/i.test(ua) || plat.includes("mac");
if (isMac) {
document.body.classList.add("is-mac");
}
} catch {
// 忽略平台检测失败
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -1,277 +0,0 @@
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
import path from "path";
import fs from "fs/promises";
import { Provider } from "../shared/types";
import {
switchProvider,
getClaudeCodeConfig,
saveProviderConfig,
deleteProviderConfig,
sanitizeProviderName,
importCurrentConfigAsDefault,
getProviderConfigPath,
fileExists,
} from "./services";
import { store } from "./store";
let mainWindow: BrowserWindow | null = null;
const isMac = process.platform === "darwin";
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
minWidth: 600,
minHeight: 400,
webPreferences: {
preload: path.join(__dirname, "../main/preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
// 使用 macOS 隐藏式标题栏,仅在 macOS 启用
...(isMac ? { titleBarStyle: "hiddenInset" as const } : {}),
autoHideMenuBar: true,
});
if (app.isPackaged) {
mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
} else {
mainWindow.loadURL("http://localhost:3000");
mainWindow.webContents.openDevTools();
}
mainWindow.on("closed", () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
// IPC handlers
ipcMain.handle("getProviders", async () => {
return await store.get("providers", {} as Record<string, Provider>);
});
ipcMain.handle("getCurrentProvider", async () => {
return await store.get("current", "");
});
ipcMain.handle("addProvider", async (_, provider: Provider) => {
try {
// 1. 保存供应商配置到独立文件
const saveSuccess = await saveProviderConfig(provider);
if (!saveSuccess) {
return false;
}
// 2. 更新应用配置
const providers = await store.get("providers", {} as Record<string, Provider>);
providers[provider.id] = provider;
await store.set("providers", providers);
return true;
} catch (error) {
console.error("添加供应商失败:", error);
return false;
}
});
ipcMain.handle("deleteProvider", async (_, id: string) => {
try {
const providers = await store.get("providers", {} as Record<string, Provider>);
const provider = providers[id];
// 1. 删除供应商配置文件
const deleteSuccess = await deleteProviderConfig(id, provider?.name);
if (!deleteSuccess) {
console.error("删除供应商配置文件失败");
// 仍然继续删除应用配置,避免配置不同步
}
// 2. 更新应用配置
delete providers[id];
await store.set("providers", providers);
// 3. 如果删除的是当前供应商,清空当前选择
const currentProviderId = await store.get("current", "");
if (currentProviderId === id) {
await store.set("current", "");
}
return true;
} catch (error) {
console.error("删除供应商失败:", error);
return false;
}
});
ipcMain.handle("updateProvider", async (_, provider: Provider) => {
try {
const providers = await store.get("providers", {} as Record<string, Provider>);
const currentProviderId = await store.get("current", "");
const oldProvider = providers[provider.id];
// 1. 如果名字发生变化,需要重命名配置文件
if (oldProvider && oldProvider.name !== provider.name) {
const oldConfigPath = getProviderConfigPath(
provider.id,
oldProvider.name
);
const newConfigPath = getProviderConfigPath(provider.id, provider.name);
// 如果旧配置文件存在且路径不同,需要重命名
if (
(await fileExists(oldConfigPath)) &&
oldConfigPath !== newConfigPath
) {
// 如果新路径已存在文件,先删除避免冲突
if (await fileExists(newConfigPath)) {
await fs.unlink(newConfigPath);
}
await fs.rename(oldConfigPath, newConfigPath);
console.log(
`已重命名配置文件: ${oldProvider.name} -> ${provider.name}`
);
}
}
// 2. 保存更新后的配置到文件
const saveSuccess = await saveProviderConfig(provider);
if (!saveSuccess) {
return false;
}
// 3. 更新应用配置
providers[provider.id] = provider;
await store.set("providers", providers);
// 4. 如果编辑的是当前激活的供应商,需要重新切换以应用更改
if (provider.id === currentProviderId) {
const switchSuccess = await switchProvider(
provider,
currentProviderId,
providers
);
if (!switchSuccess) {
console.error("更新当前供应商的Claude Code配置失败");
return false;
}
}
return true;
} catch (error) {
console.error("更新供应商失败:", error);
return false;
}
});
ipcMain.handle("switchProvider", async (_, providerId: string) => {
try {
const providers = await store.get("providers", {} as Record<string, Provider>);
const provider = providers[providerId];
const currentProviderId = await store.get("current", "");
if (!provider) {
console.error(`供应商不存在: ${providerId}`);
return false;
}
// 执行切换
const success = await switchProvider(
provider,
currentProviderId,
providers
);
if (success) {
await store.set("current", providerId);
console.log(`成功切换到供应商: ${provider.name}`);
}
return success;
} catch (error) {
console.error("切换供应商失败:", error);
return false;
}
});
ipcMain.handle("importCurrentConfigAsDefault", async () => {
try {
const result = await importCurrentConfigAsDefault();
if (result.success && result.provider) {
// 将默认供应商添加到store中
const providers = await store.get("providers", {} as Record<string, Provider>);
providers[result.provider.id] = result.provider;
await store.set("providers", providers);
// 设置为当前选中的供应商
await store.set("current", result.provider.id);
return { success: true, providerId: result.provider.id };
}
return result;
} catch (error) {
console.error("导入默认配置失败:", error);
return { success: false };
}
});
ipcMain.handle("getClaudeCodeConfigPath", () => {
return getClaudeCodeConfig().path;
});
ipcMain.handle("selectConfigFile", async () => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
title: "选择 Claude Code 配置文件",
filters: [
{ name: "JSON 文件", extensions: ["json"] },
{ name: "所有文件", extensions: ["*"] },
],
defaultPath: "settings.json",
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
});
ipcMain.handle("openConfigFolder", async () => {
try {
const { dir } = getClaudeCodeConfig();
await shell.openPath(dir);
return true;
} catch (error) {
console.error("打开配置文件夹失败:", error);
return false;
}
});
ipcMain.handle("openExternal", async (_, url: string) => {
try {
await shell.openExternal(url);
return true;
} catch (error) {
console.error("打开外部链接失败:", error);
return false;
}
});

View File

@@ -1,21 +0,0 @@
import { contextBridge, ipcRenderer } from 'electron'
import { Provider } from '../shared/types'
contextBridge.exposeInMainWorld('electronAPI', {
getProviders: () => ipcRenderer.invoke('getProviders'),
getCurrentProvider: () => ipcRenderer.invoke('getCurrentProvider'),
addProvider: (provider: Provider) => ipcRenderer.invoke('addProvider', provider),
deleteProvider: (id: string) => ipcRenderer.invoke('deleteProvider', id),
updateProvider: (provider: Provider) => ipcRenderer.invoke('updateProvider', provider),
switchProvider: (providerId: string) => ipcRenderer.invoke('switchProvider', providerId),
importCurrentConfigAsDefault: () => ipcRenderer.invoke('importCurrentConfigAsDefault'),
getClaudeCodeConfigPath: () => ipcRenderer.invoke('getClaudeCodeConfigPath'),
selectConfigFile: () => ipcRenderer.invoke('selectConfigFile'),
openConfigFolder: () => ipcRenderer.invoke('openConfigFolder'),
openExternal: (url: string) => ipcRenderer.invoke('openExternal', url)
})
// 暴露平台信息给渲染进程,用于平台特定样式控制
contextBridge.exposeInMainWorld('platform', {
isMac: process.platform === 'darwin'
})

View File

@@ -1,181 +0,0 @@
import fs from "fs/promises";
import path from "path";
import os from "os";
import { Provider } from "../shared/types";
/**
* 清理供应商名称,确保文件名安全
*/
export function sanitizeProviderName(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, "-").toLowerCase();
}
export function getClaudeCodeConfig() {
// Claude Code 配置文件路径
const configDir = path.join(os.homedir(), ".claude");
const configPath = path.join(configDir, "settings.json");
return { path: configPath, dir: configDir };
}
/**
* 获取供应商配置文件路径(基于供应商名称)
*/
export function getProviderConfigPath(
providerId: string,
providerName?: string
): string {
const { dir } = getClaudeCodeConfig();
// 如果提供了名称使用名称否则使用ID向后兼容
const baseName = providerName
? sanitizeProviderName(providerName)
: sanitizeProviderName(providerId);
return path.join(dir, `settings-${baseName}.json`);
}
/**
* 保存供应商配置到独立文件
*/
export async function saveProviderConfig(provider: Provider): Promise<boolean> {
try {
const { dir } = getClaudeCodeConfig();
const providerConfigPath = getProviderConfigPath(
provider.id,
provider.name
);
// 确保目录存在
await fs.mkdir(dir, { recursive: true });
// 保存配置到供应商专用文件
await fs.writeFile(
providerConfigPath,
JSON.stringify(provider.settingsConfig, null, 2),
"utf-8"
);
return true;
} catch (error) {
console.error("保存供应商配置失败:", error);
return false;
}
}
/**
* 检查文件是否存在
*/
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* 切换供应商配置(基于文件复制)
*/
export async function switchProvider(
provider: Provider,
currentProviderId?: string,
providers?: Record<string, Provider>
): Promise<boolean> {
try {
const { path: settingsPath, dir: configDir } = getClaudeCodeConfig();
// 确保目录存在
await fs.mkdir(configDir, { recursive: true });
const newSettingsPath = getProviderConfigPath(provider.id, provider.name);
// 检查目标配置文件是否存在
if (!(await fileExists(newSettingsPath))) {
console.error(`供应商配置文件不存在: ${newSettingsPath}`);
return false;
}
// 1. 如果当前存在settings.json先备份到当前供应商的配置文件
if (await fileExists(settingsPath)) {
if (currentProviderId && providers && providers[currentProviderId]) {
const currentProvider = providers[currentProviderId];
const currentProviderPath = getProviderConfigPath(
currentProviderId,
currentProvider.name
);
// 使用复制而不是重命名,避免删除原文件
await fs.copyFile(settingsPath, currentProviderPath);
console.log(`已备份当前供应商配置: ${currentProvider.name}`);
}
}
// 2. 复制新配置到settings.json保留原文件
await fs.copyFile(newSettingsPath, settingsPath);
console.log(`成功切换到供应商: ${provider.name}`);
return true;
} catch (error) {
console.error("切换供应商失败:", error);
return false;
}
}
/**
* 导入当前配置为默认供应商
*/
export async function importCurrentConfigAsDefault(): Promise<{ success: boolean; provider?: Provider }> {
try {
const { path: settingsPath } = getClaudeCodeConfig();
// 检查当前配置是否存在
if (!(await fileExists(settingsPath))) {
return { success: false };
}
// 读取当前配置
const configContent = await fs.readFile(settingsPath, "utf-8");
const settingsConfig = JSON.parse(configContent);
// 创建默认供应商对象
const provider: Provider = {
id: "default",
name: "default",
settingsConfig: settingsConfig,
};
// 保存默认供应商的配置到独立文件
const saveSuccess = await saveProviderConfig(provider);
if (!saveSuccess) {
return { success: false };
}
console.log(`已导入当前配置为默认供应商配置文件settings-default.json`);
return { success: true, provider };
} catch (error) {
console.error("导入默认配置失败:", error);
return { success: false };
}
}
/**
* 删除供应商配置文件
*/
export async function deleteProviderConfig(
providerId: string,
providerName?: string
): Promise<boolean> {
try {
const providerConfigPath = getProviderConfigPath(providerId, providerName);
if (await fileExists(providerConfigPath)) {
await fs.unlink(providerConfigPath);
console.log(`已删除供应商配置文件: ${providerConfigPath}`);
}
return true;
} catch (error) {
console.error("删除供应商配置失败:", error);
return false;
}
}

View File

@@ -1,60 +0,0 @@
import fs from 'fs/promises'
import path from 'path'
import os from 'os'
import { AppConfig } from '../shared/types'
export class SimpleStore {
private configPath: string
private configDir: string
private data: AppConfig = { providers: {}, current: '' }
private initPromise: Promise<void>
constructor() {
this.configDir = path.join(os.homedir(), '.cc-switch')
this.configPath = path.join(this.configDir, 'config.json')
// 立即开始加载,但不阻塞构造函数
this.initPromise = this.loadData()
}
private async loadData(): Promise<void> {
try {
const content = await fs.readFile(this.configPath, 'utf-8')
this.data = JSON.parse(content)
} catch (error) {
// 文件不存在或格式错误,使用默认数据
this.data = { providers: {}, current: '' }
await this.saveData()
}
}
private async saveData(): Promise<void> {
try {
// 确保目录存在
await fs.mkdir(this.configDir, { recursive: true })
// 写入配置文件
await fs.writeFile(this.configPath, JSON.stringify(this.data, null, 2), 'utf-8')
} catch (error) {
console.error('保存配置失败:', error)
}
}
async get<T>(key: keyof AppConfig, defaultValue?: T): Promise<T> {
await this.initPromise // 等待初始化完成
const value = this.data[key] as T
return value !== undefined ? value : (defaultValue as T)
}
async set<K extends keyof AppConfig>(key: K, value: AppConfig[K]): Promise<void> {
await this.initPromise // 等待初始化完成
this.data[key] = value
await this.saveData()
}
// 获取配置文件路径,用于调试
getConfigPath(): string {
return this.configPath
}
}
// 创建单例
export const store = new SimpleStore()

View File

@@ -1,31 +0,0 @@
import React from 'react'
import { Provider } from '../../shared/types'
import ProviderForm from './ProviderForm'
interface EditProviderModalProps {
provider: Provider
onSave: (provider: Provider) => void
onClose: () => void
}
const EditProviderModal: React.FC<EditProviderModalProps> = ({ provider, onSave, onClose }) => {
const handleSubmit = (data: Omit<Provider, 'id'>) => {
onSave({
...provider,
...data
})
}
return (
<ProviderForm
title="编辑供应商"
submitText="保存"
initialData={provider}
showPresets={false}
onSubmit={handleSubmit}
onClose={onClose}
/>
)
}
export default EditProviderModal

View File

@@ -1,284 +0,0 @@
import React, { useState, useEffect } from "react";
import { Provider } from "../../shared/types";
import {
updateCoAuthoredSetting,
checkCoAuthoredSetting,
extractWebsiteUrl,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import "./AddProviderModal.css";
interface ProviderFormProps {
title: string;
submitText: string;
initialData?: Provider;
showPresets?: boolean;
onSubmit: (data: Omit<Provider, "id">) => void;
onClose: () => void;
}
const ProviderForm: React.FC<ProviderFormProps> = ({
title,
submitText,
initialData,
showPresets = false,
onSubmit,
onClose,
}) => {
const [formData, setFormData] = useState({
name: initialData?.name || "",
websiteUrl: initialData?.websiteUrl || "",
settingsConfig: initialData
? JSON.stringify(initialData.settingsConfig, null, 2)
: "",
});
const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
const [apiKey, setApiKey] = useState("");
// 初始化时检查禁用签名状态
useEffect(() => {
if (initialData) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
}
}, [initialData]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!formData.name) {
setError("请填写供应商名称");
return;
}
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
return;
}
let settingsConfig: Record<string, any>;
try {
settingsConfig = JSON.parse(formData.settingsConfig);
} catch (err) {
setError("配置JSON格式错误请检查语法");
return;
}
onSubmit({
name: formData.name,
websiteUrl: formData.websiteUrl,
settingsConfig,
});
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
if (name === "settingsConfig") {
// 当用户修改配置时,尝试自动提取官网地址
const extractedWebsiteUrl = extractWebsiteUrl(value);
// 同时检查并同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
setDisableCoAuthored(hasCoAuthoredDisabled);
setFormData({
...formData,
[name]: value,
// 只有在官网地址为空时才自动填入
websiteUrl: formData.websiteUrl || extractedWebsiteUrl,
});
} else {
setFormData({
...formData,
[name]: value,
});
}
};
// 处理选择框变化
const handleCoAuthoredToggle = (checked: boolean) => {
setDisableCoAuthored(checked);
// 更新JSON配置
const updatedConfig = updateCoAuthoredSetting(
formData.settingsConfig,
checked
);
setFormData({
...formData,
settingsConfig: updatedConfig,
});
};
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
const configString = JSON.stringify(preset.settingsConfig, null, 2);
setFormData({
name: preset.name,
websiteUrl: preset.websiteUrl,
settingsConfig: configString,
});
// 设置选中的预设
setSelectedPreset(index);
// 清空 API Key 输入框,让用户重新输入
setApiKey("");
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
};
// 处理 API Key 输入并自动更新配置
const handleApiKeyChange = (key: string) => {
setApiKey(key);
if (selectedPreset !== null && key.trim()) {
// 获取当前选中的预设配置
const preset = providerPresets[selectedPreset];
const updatedConfig = JSON.parse(JSON.stringify(preset.settingsConfig));
// 替换配置中的 API Key
if (updatedConfig.env && updatedConfig.env.ANTHROPIC_AUTH_TOKEN) {
updatedConfig.env.ANTHROPIC_AUTH_TOKEN = key.trim();
}
const configString = JSON.stringify(updatedConfig, null, 2);
// 更新表单配置
setFormData((prev) => ({
...prev,
settingsConfig: configString,
}));
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
}
};
return (
<div className="modal-overlay">
<div className="modal-content">
<h2>{title}</h2>
{error && <div className="error-message">{error}</div>}
{showPresets && (
<div className="presets">
<label></label>
<div className="preset-buttons">
{providerPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`preset-btn ${
selectedPreset === index ? "selected" : ""
}`}
onClick={() => applyPreset(preset, index)}
>
{preset.name}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name"> *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="例如Anthropic 官方"
required
autoComplete="off"
/>
</div>
{selectedPreset !== null && (
<div className="form-group">
<label htmlFor="apiKey">API Key *</label>
<input
type="text"
id="apiKey"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="只需要填这里,下方配置会自动填充"
autoComplete="off"
/>
</div>
)}
<div className="form-group">
<label htmlFor="websiteUrl"></label>
<input
type="url"
id="websiteUrl"
name="websiteUrl"
value={formData.websiteUrl}
onChange={handleChange}
placeholder="https://example.com可选"
autoComplete="off"
/>
</div>
<div className="form-group">
<div className="label-with-checkbox">
<label htmlFor="settingsConfig">Claude Code (JSON) *</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
/>
Claude Code
</label>
</div>
<textarea
id="settingsConfig"
name="settingsConfig"
value={formData.settingsConfig}
onChange={handleChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
}
}`}
rows={12}
style={{ fontFamily: "monospace", fontSize: "14px" }}
required
/>
<small className="field-hint">
Claude Code settings.json
</small>
</div>
<div className="form-actions">
<button type="button" className="cancel-btn" onClick={onClose}>
</button>
<button type="submit" className="submit-btn">
{submitText}
</button>
</div>
</form>
</div>
</div>
);
};
export default ProviderForm;

View File

@@ -1,21 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
declare global {
interface Window {
platform?: { isMac?: boolean }
}
}
// 根据平台添加 body class便于平台特定样式
if (window.platform?.isMac) {
document.body.classList.add('is-mac')
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -1,47 +0,0 @@
// 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除
export const updateCoAuthoredSetting = (jsonString: string, disable: boolean): string => {
try {
const config = JSON.parse(jsonString)
if (disable) {
// 添加或更新includeCoAuthoredBy字段
config.includeCoAuthoredBy = false
} else {
// 删除includeCoAuthoredBy字段
delete config.includeCoAuthoredBy
}
return JSON.stringify(config, null, 2)
} catch (err) {
// 如果JSON解析失败返回原始字符串
return jsonString
}
}
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
try {
const config = JSON.parse(jsonString)
return config.includeCoAuthoredBy === false
} catch (err) {
return false
}
}
// 从JSON配置中提取并处理官网地址
export const extractWebsiteUrl = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString)
const baseUrl = config?.env?.ANTHROPIC_BASE_URL
if (baseUrl && typeof baseUrl === 'string') {
// 去掉 "api." 前缀
return baseUrl.replace(/^https?:\/\/api\./, 'https://')
}
} catch (err) {
// 忽略JSON解析错误
}
return ''
}

View File

@@ -1,29 +0,0 @@
export interface Provider {
id: string
name: string
settingsConfig: Record<string, any> // 完整的Claude Code settings.json配置
websiteUrl?: string
}
export interface AppConfig {
providers: Record<string, Provider>
current: string
}
declare global {
interface Window {
electronAPI: {
getProviders: () => Promise<Record<string, Provider>>
getCurrentProvider: () => Promise<string>
addProvider: (provider: Provider) => Promise<boolean>
deleteProvider: (id: string) => Promise<boolean>
updateProvider: (provider: Provider) => Promise<boolean>
switchProvider: (providerId: string) => Promise<boolean>
importCurrentConfigAsDefault: () => Promise<{ success: boolean; providerId?: string }>
getClaudeCodeConfigPath: () => Promise<string>
selectConfigFile: () => Promise<string | null>
openConfigFolder: () => Promise<boolean>
openExternal: (url: string) => Promise<boolean>
}
}
}

11
src/types.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Provider {
id: string;
name: string;
settingsConfig: Record<string, any>; // 完整的 Claude Code settings.json 配置
websiteUrl?: string;
}
export interface AppConfig {
providers: Record<string, Provider>;
current: string;
}

View File

@@ -0,0 +1,97 @@
// 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除
export const updateCoAuthoredSetting = (
jsonString: string,
disable: boolean,
): string => {
try {
const config = JSON.parse(jsonString);
if (disable) {
// 添加或更新includeCoAuthoredBy字段
config.includeCoAuthoredBy = false;
} else {
// 删除includeCoAuthoredBy字段
delete config.includeCoAuthoredBy;
}
return JSON.stringify(config, null, 2);
} catch (err) {
// 如果JSON解析失败返回原始字符串
return jsonString;
}
};
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
try {
const config = JSON.parse(jsonString);
return config.includeCoAuthoredBy === false;
} catch (err) {
return false;
}
};
// 从JSON配置中提取并处理官网地址
export const extractWebsiteUrl = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString);
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
if (baseUrl && typeof baseUrl === "string") {
// 去掉 "api." 前缀
return baseUrl.replace(/^https?:\/\/api\./, "https://");
}
} catch (err) {
// 忽略JSON解析错误
}
return "";
};
// 读取配置中的 API Keyenv.ANTHROPIC_AUTH_TOKEN
export const getApiKeyFromConfig = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString);
const key = config?.env?.ANTHROPIC_AUTH_TOKEN;
return typeof key === "string" ? key : "";
} catch (err) {
return "";
}
};
// 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (jsonString: string): boolean => {
try {
const config = JSON.parse(jsonString);
return Object.prototype.hasOwnProperty.call(
config?.env ?? {},
"ANTHROPIC_AUTH_TOKEN",
);
} catch (err) {
return false;
}
};
// 写入/更新配置中的 API Key默认不新增缺失字段
export const setApiKeyInConfig = (
jsonString: string,
apiKey: string,
options: { createIfMissing?: boolean } = {},
): string => {
const { createIfMissing = false } = options;
try {
const config = JSON.parse(jsonString);
if (!config.env) {
if (!createIfMissing) return jsonString;
config.env = {};
}
if (!("ANTHROPIC_AUTH_TOKEN" in config.env) && !createIfMissing) {
return jsonString;
}
config.env.ANTHROPIC_AUTH_TOKEN = apiKey;
return JSON.stringify(config, null, 2);
} catch (err) {
return jsonString;
}
};

Some files were not shown because too many files have changed in this diff Show More