29 Commits

Author SHA1 Message Date
Jason
14964667ac chore(ci): Linux runner 切换至 ubuntu-22.04(遵循 GitHub 支持范围) 2025-09-11 08:37:01 +08:00
Jason
e143ef30e3 - chore(ci): 固定 runner 版本,移除 macOS-13;升级 actions/cache@v4 与 action-gh-release@v2
- fix(linux): 使用 ubuntu-20.04 构建并可选上传 AppImage,降低 glibc/内核基线
- fix(windows): 安装器启用 WebView2 下载引导(silent),默认不开启 --disable-gpu
- fix(macos): ARM64 构建通用包(zip),覆盖 Intel 与 Apple Silicon
- chore(version): bump to v3.1.2(兼容性热修复)
2025-09-10 22:53:09 +08:00
farion1231
3665a79e50 chore: bump version to v3.1.1
- Update version in package.json, Cargo.toml, and tauri.conf.json
- Add CHANGELOG entries for v3.1.0 and v3.1.1
2025-09-03 16:43:29 +08:00
farion1231
4dce31aff7 Fix the default codex config.toml to match the latest modifications. 2025-09-03 16:33:12 +08:00
Jason
451ca949ec feat(ui): improve provider configuration UX with custom option
- Add explicit "Custom" button in preset selection
- Set "Custom" as default selection when adding new provider
- Update label from "One-click import" to "Choose configuration type"
- Add contextual hints for different configuration modes:
  - Custom mode: "Manually configure provider, complete configuration required"
  - Official preset: "Official login, no API Key required"
  - Other presets: "Use preset configuration, only API Key required"
- Remove redundant "(optional)" text from Codex config.toml hint
- Improve clarity for users who were confused about adding custom providers
2025-09-03 15:58:02 +08:00
Jason
a9ff8ce01c update readme 2025-09-01 15:33:24 +08:00
Jason Young
7848248df7 Merge pull request #3 from farion1231/codex-adaptation
feat(codex): 支持 Codex 供应商管理与一键切换;迁移前自动备份
2025-09-01 11:41:31 +08:00
Jason
b00e8de26f feat(config): backup v1 file before v2 migration
- Add timestamped backup at `~/.cc-switch/config.v1.backup.<ts>.json`
- Keep provider files untouched; only cc-switch metadata is backed up
- Remove UI notification plan; backup only as requested
- Update CHANGELOG with migration backup notes
2025-09-01 10:49:31 +08:00
Jason
47b06b7773 feat(ui): elevate title above controls for better visual hierarchy
Move title to separate row above switcher and action buttons for cleaner layout.
2025-08-31 23:13:27 +08:00
Jason
4e66f0c105 feat(ui): center title and balance header layout
Unify title to "CC Switch" to prevent text length jumping during app switching.
Reorganize header as three-column grid with centered title.
2025-08-31 21:49:28 +08:00
Jason
84c7726940 feat(ui): implement pills-style AppSwitcher with consistent button widths
Replace segmented control with pills-style switcher for better visual consistency.
2025-08-31 21:27:58 +08:00
Jason
b8f59a4740 chore: silence non_snake_case warnings in commands.rs for legacy app/appType compatibility
- Add crate-level allow(non_snake_case) in src-tauri/src/commands.rs
- Keeps compatibility while avoiding compiler warnings
2025-08-31 19:00:09 +08:00
Jason
06a19519c5 revert: restore app/appType param compatibility and revert segmented-thumb pointer-events change
- Restore backend commands to accept app_type/app/appType with priority app_type
- Frontend invoke() now passes both { app_type, app } again
- Revert CSS change that set pointer-events: none on segmented-thumb
- Keep minor fix: open_config_folder signature uses handle + respects both names

Note: warnings for non_snake_case (appType) are expected for compatibility.
2025-08-31 18:14:31 +08:00
Jason
b4ebb7c9e5 docs(codex): document Codex config directory, fields (OPENAI_API_KEY), empty config.toml behavior, and switching strategy in README 2025-08-31 17:17:22 +08:00
Jason
5edc3e07a4 feat(codex): validate non-empty config.toml with toml crate (syntax check in save/import) 2025-08-31 17:13:25 +08:00
Jason
417dcc1d37 feat(codex): require OPENAI_API_KEY when non-official preset selected; keep config.toml optional 2025-08-31 17:07:35 +08:00
Jason
72f6068e86 Revert "feat(ui): enhance Codex provider list display by extracting base_url/model_provider from config.toml; plumb appType into ProviderList"
This reverts commit 97e7f34260.
2025-08-31 17:02:15 +08:00
Jason
97e7f34260 feat(ui): enhance Codex provider list display by extracting base_url/model_provider from config.toml; plumb appType into ProviderList 2025-08-31 16:55:55 +08:00
Jason
74babf9730 refactor(api): unify Tauri command app param as app_type with backward-compatible app/appType; update front-end invocations accordingly 2025-08-31 16:43:33 +08:00
Jason
30fe800ebe fix(codex): correct config path reporting and folder opening; allow empty config.toml; unify API key field as OPENAI_API_KEY; front-end invoke uses app_type/app fallback for Tauri commands 2025-08-31 16:39:38 +08:00
Jason
c98a724935 feat(ui): 优化首页切换为分段控件;精简 Banner 间距;标题在上切换在下 2025-08-31 00:03:22 +08:00
Jason
0cb89c8f67 chore(codex): 调整 Codex 预设模板与占位符(auth.json/config.toml 与表单占位) 2025-08-30 23:02:49 +08:00
Jason
7b5d5c6ce1 refactor(codex): 选择 Codex 预设时清空 API Key 输入,避免误保存占位符 2025-08-30 22:09:19 +08:00
Jason
eea5e4123b feat(codex): 增加 Codex 预设供应商(官方、PackyCode);在添加供应商时支持一键预设与 API Key 自动写入 auth.json;UI 同步 Codex 预设按钮与字段 2025-08-30 22:08:41 +08:00
Jason
c10ace7a84 - feat(codex): 引入 Codex 应用与供应商切换(管理 auth.json/config.toml,支持备份与恢复)
- feat(core): 多应用配置 v2(claude/codex)与 ProviderManager;支持 v1→v2 自动迁移
- feat(ui): 新增 Codex 页签与双编辑器表单;统一 window.api 支持 app 参数
- feat(tauri): 新增 get_config_status/open_config_folder/open_external 命令并适配 Codex
- fix(codex): 主配置缺失时不执行默认导入(对齐 Claude 行为)
- chore: 配置目录展示与重启提示等细节优化
2025-08-30 21:54:52 +08:00
farion1231
0e803b53d8 update readme 2025-08-29 15:37:26 +08:00
Jason
49d8787ab9 ci(release): generate macOS zip, Windows installer + portable, Linux deb; split per-OS build and asset steps 2025-08-29 14:57:33 +08:00
Jason
a05fefb54c feat: optimize release workflow for better distribution
- Configure GitHub Actions to generate platform-specific releases:
  - macOS: zip package only (avoids signing issues)
  - Windows: installer (NSIS) and portable version
  - Linux: AppImage and deb packages
- Update Tauri config to build all available targets
- Add documentation for macOS signature workarounds
2025-08-29 14:40:40 +08:00
Jason
3574fa07cb ci(workflows): restore tag-only release; keep Linux deps 2025-08-29 12:11:16 +08:00
25 changed files with 1642 additions and 326 deletions

View File

@@ -3,7 +3,7 @@ name: Release
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
permissions: permissions:
contents: write contents: write
@@ -14,9 +14,10 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- os: windows-latest # 固定 runner 版本,避免 latest 漂移导致 ABI 升级
- os: ubuntu-latest - os: windows-2022
- os: macos-latest - os: ubuntu-22.04
- os: macos-14
steps: steps:
- name: Checkout - name: Checkout
@@ -25,11 +26,43 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Add macOS targets (ARM64 only for universal)
if: runner.os == 'macOS' && runner.arch == 'ARM64'
run: |
rustup target add aarch64-apple-darwin x86_64-apple-darwin
- name: Install Linux system deps
if: runner.os == 'Linux'
shell: bash
run: |
set -euxo pipefail
sudo apt-get update
# Core build tools and pkg-config
sudo apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
curl \
wget \
file \
patchelf \
libssl-dev
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
librsvg2-dev \
libayatana-appindicator3-dev
# WebKit2GTK (version differs across Ubuntu images; try 4.1 then 4.0)
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
|| sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
# libsoup also changed major version; prefer 3.0 with fallback to 2.4
sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
@@ -42,7 +75,7 @@ jobs:
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ${{ steps.pnpm-store.outputs.path }} path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -51,24 +84,133 @@ jobs:
- name: Install frontend deps - name: Install frontend deps
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build and Release (Tauri) - name: Build Tauri App (macOS, universal on ARM64)
uses: tauri-apps/tauri-action@v0 if: runner.os == 'macOS' && runner.arch == 'ARM64'
env: run: pnpm tauri build --target universal-apple-darwin
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS 仅保留通用包(在 macOS-14 / ARM64 运行器上构建)
- name: Build Tauri App (Windows)
if: runner.os == 'Windows'
run: pnpm tauri build
- name: Build Tauri App (Linux)
if: runner.os == 'Linux'
run: pnpm tauri build
- name: Prepare macOS Assets
if: runner.os == 'macOS'
shell: bash
run: |
set -euxo pipefail
mkdir -p release-assets
echo "Looking for .app bundle..."
APP_PATH=""
for path in \
"src-tauri/target/release/bundle/macos" \
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
if [ -d "$path" ]; then
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
[ -n "$APP_PATH" ] && break
fi
done
if [ -z "$APP_PATH" ]; then
echo "No .app found" >&2
exit 1
fi
APP_DIR=$(dirname "$APP_PATH")
APP_NAME=$(basename "$APP_PATH")
cd "$APP_DIR"
# 使用 ditto 打包更兼容资源分叉
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready"
- name: Prepare Windows Assets
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
# 安装器(优先 NSIS其次 MSI
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
Select-Object -First 1
if ($null -ne $installer) {
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
Copy-Item $installer.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
} else {
Write-Warning 'No Windows installer found'
}
# 绿色版portable仅可执行文件
$exeCandidates = @(
'src-tauri/target/release/cc-switch.exe',
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
)
$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($null -ne $exePath) {
$portableDir = 'release-assets/CC-Switch-Portable'
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath $portableDir
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
Remove-Item -Recurse -Force $portableDir
Write-Host 'Windows portable zip created'
} else {
Write-Warning 'Portable exe not found'
}
- name: Prepare Linux Assets
if: runner.os == 'Linux'
shell: bash
run: |
set -euxo pipefail
mkdir -p release-assets
# 优先 DEB同时若存在 AppImage 一并上传
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
cp "$DEB" release-assets/
echo "Deb package copied"
else
echo "No .deb found" >&2
exit 1
fi
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
cp "$APPIMAGE" release-assets/
echo "AppImage copied"
else
echo "No AppImage found (optional)"
fi
- name: List prepared assets
shell: bash
run: |
ls -la release-assets || true
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
with: with:
tagName: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
releaseName: CC Switch ${{ github.ref_name }} name: CC Switch ${{ github.ref_name }}
releaseBody: | body: |
## CC Switch ${{ github.ref_name }} ## CC Switch ${{ github.ref_name }}
Claude Code 供应商切换工具Tauri 构建) Claude Code 供应商切换工具
- Windows: .msi / NSIS 安装包 ### 下载
- macOS: .dmg / .app 压缩包
- Linux: AppImage / deb / rpm
如遇未知开发者提示,请在系统隐私与安全设置中选择“仍要打开”。 - macOS: `CC-Switch-macOS.zip`(解压即用)
tauriScript: pnpm tauri - Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
- Linux: `*.deb`Debian/Ubuntu 安装包)
---
提示macOS 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: List generated bundles (debug) - name: List generated bundles (debug)
if: always() if: always()

View File

@@ -5,6 +5,44 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.1.1] - 2025-09-03
### 🐛 Bug Fixes
- Fixed the default codex config.toml to match the latest modifications
- Improved provider configuration UX with custom option
### 📝 Documentation
- Updated README with latest information
## [3.1.0] - 2025-09-01
### ✨ New Features
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
- Manage auth.json and config.toml for Codex
- Support for backup and restore operations
- Preset providers for Codex (Official, PackyCode)
- API Key auto-write to auth.json when using presets
- **New UI components**
- App switcher with segmented control design
- Dual editor form for Codex configuration
- Pills-style app switcher with consistent button widths
- **Enhanced configuration management**
- Multi-app config v2 structure (claude/codex)
- Automatic v1→v2 migration with backup
- OPENAI_API_KEY validation for non-official presets
- TOML syntax validation for config.toml
### 🔧 Technical Improvements
- Unified Tauri command API with app_type parameter
- Backward compatibility for app/appType parameters
- Added get_config_status/open_config_folder/open_external commands
- Improved error handling for empty config.toml
### 🐛 Bug Fixes
- Fixed config path reporting and folder opening for Codex
- Corrected default import behavior when main config is missing
- Fixed non_snake_case warnings in commands.rs
## [3.0.0] - 2025-08-27 ## [3.0.0] - 2025-08-27
### 🚀 Major Changes ### 🚀 Major Changes
@@ -44,6 +82,11 @@ For users upgrading from v2.x (Electron version):
- The app will automatically migrate your existing provider configurations - The app will automatically migrate your existing provider configurations
- Window position and size preferences have been reset to defaults - Window position and size preferences have been reset to defaults
#### Backup on v1→v2 Migration (cc-switch internal config)
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
### 🛠️ Development ### 🛠️ Development
- Added `pnpm typecheck` command for TypeScript validation - Added `pnpm typecheck` command for TypeScript validation
- Added `pnpm format` and `pnpm format:check` for code formatting - Added `pnpm format` and `pnpm format:check` for code formatting
@@ -64,4 +107,4 @@ For users upgrading from v2.x (Electron version):
### Features ### Features
- Basic provider management - Basic provider management
- Claude Code integration - Claude Code integration
- Configuration file handling - Configuration file handling

View File

@@ -1,19 +1,23 @@
# Claude Code 供应商切换器 # Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases) [![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) [![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/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。 一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> **v3.0.0 重大更新**:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍! > v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍!
## 功能特性 ## 功能特性
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用 - **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
- 一键切换不同供应商 - 一键切换不同供应商
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置 - Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- 支持添加自定义供应商 - 支持添加自定义供应商
- 随时切换官方登录
- 简洁美观的图形界面 - 简洁美观的图形界面
- 信息存储在本地 ~/.cc-switch/config.json无隐私风险 - 信息存储在本地 ~/.cc-switch/config.json无隐私风险
- 超小体积 - 仅 ~5MB 安装包 - 超小体积 - 仅 ~5MB 安装包
@@ -38,22 +42,50 @@
### Windows 用户 ### Windows 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch_3.0.0_x64.msi``.exe` 安装包 从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版
### macOS 用户 ### macOS 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch_3.0.0_x64.dmg` (Intel) 或 `CC-Switch_3.0.0_aarch64.dmg` (Apple Silicon) 从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### Linux 用户 ### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `.AppImage``.deb` 包。 从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
## 使用说明 ## 使用说明
1. 点击"添加供应商"添加你的 API 配置 1. 点击"添加供应商"添加你的 API 配置
2. 选择要使用的供应商,点击单选按钮切换 2. 选择要使用的供应商,点击单选按钮切换
3. 配置会自动保存到 Claude Code 的配置文件中 3. 配置会自动保存到对应应用的配置文件中
4. 重启或者新打开终端以生效 4. 重启或者新打开终端以生效
5. 如果需要切回 Claude 官方登录可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
### Codex 说明
- 配置目录:`~/.codex/`
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
- 供应商副本:`auth-<name>.json``config-<name>.toml`
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json``config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`
- 导入默认:若 `~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商;`config.toml` 不存在时按空处理。
- 官方登录可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
### Claude Code 说明
- 配置目录:`~/.claude/`
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
- 供应商副本:`settings-<name>.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
- 导入默认:若 `~/.claude/settings.json``~/.claude/claude.json` 存在,会将当前主配置导入为 `default` 供应商副本。
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
### 迁移与备份
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`
- 实际生效的应用配置文件(如 `~/.claude/settings.json``~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
## 开发 ## 开发
@@ -135,6 +167,12 @@ cargo test
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。 查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
## Electron 旧版
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
## 贡献 ## 贡献
欢迎提交 Issue 和 Pull Request 欢迎提交 Issue 和 Pull Request

View File

@@ -1,7 +1,7 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.0.0", "version": "3.1.2",
"description": "Claude Code 供应商切换工具", "description": "Claude Code & Codex 供应商切换工具",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
"build": "pnpm tauri build", "build": "pnpm tauri build",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 247 KiB

1
src-tauri/Cargo.lock generated
View File

@@ -562,6 +562,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-opener", "tauri-plugin-opener",
"toml 0.8.2",
] ]
[[package]] [[package]]

View File

@@ -1,10 +1,10 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.0.0" version = "3.1.2"
description = "Claude Code MCP 服务器配置管理工具" description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
repository = "https://github.com/jasonyoung/cc-switch" repository = "https://github.com/farion1231/cc-switch"
edition = "2024" edition = "2024"
rust-version = "1.85.0" rust-version = "1.85.0"
@@ -25,6 +25,7 @@ tauri = { version = "2.8.2", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
dirs = "5.0" dirs = "5.0"
toml = "0.8"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5" objc2 = "0.5"

130
src-tauri/src/app_config.rs Normal file
View File

@@ -0,0 +1,130 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
use crate::provider::ProviderManager;
/// 应用类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
Codex,
}
impl AppType {
pub fn as_str(&self) -> &str {
match self {
AppType::Claude => "claude",
AppType::Codex => "codex",
}
}
}
impl From<&str> for AppType {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"codex" => AppType::Codex,
_ => AppType::Claude, // 默认为 Claude
}
}
}
/// 多应用配置结构(向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiAppConfig {
#[serde(default = "default_version")]
pub version: u32,
#[serde(flatten)]
pub apps: HashMap<String, ProviderManager>,
}
fn default_version() -> u32 {
2
}
impl Default for MultiAppConfig {
fn default() -> Self {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
Self { version: 2, apps }
}
}
impl MultiAppConfig {
/// 从文件加载配置处理v1到v2的迁移
pub fn load() -> Result<Self, String> {
let config_path = get_app_config_path();
if !config_path.exists() {
log::info!("配置文件不存在,创建新的多应用配置");
return Ok(Self::default());
}
// 尝试读取文件
let content = std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取配置文件失败: {}", e))?;
// 检查是否是旧版本格式v1
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
log::info!("检测到v1配置自动迁移到v2");
// 迁移到新格式
let mut apps = HashMap::new();
apps.insert("claude".to_string(), v1_config);
apps.insert("codex".to_string(), ProviderManager::default());
let config = Self { version: 2, apps };
// 迁移前备份旧版(v1)配置文件
let backup_dir = get_app_config_dir();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts));
match copy_file(&config_path, &backup_path) {
Ok(()) => log::info!(
"已备份旧版配置文件: {} -> {}",
config_path.display(),
backup_path.display()
),
Err(e) => log::warn!("备份旧版配置文件失败: {}", e),
}
// 保存迁移后的配置
config.save()?;
return Ok(config);
}
// 尝试读取v2格式
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), String> {
let config_path = get_app_config_path();
write_json_file(&config_path, self)
}
/// 获取指定应用的管理器
pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
self.apps.get(app.as_str())
}
/// 获取指定应用的管理器(可变引用)
pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
self.apps.get_mut(app.as_str())
}
/// 确保应用存在
pub fn ensure_app(&mut self, app: &AppType) {
if !self.apps.contains_key(app.as_str()) {
self.apps
.insert(app.as_str().to_string(), ProviderManager::default());
}
}
}

View File

@@ -0,0 +1,172 @@
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use crate::config::{
copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file,
};
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
}
/// 获取 Codex auth.json 路径
pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
/// 获取 Codex config.toml 路径
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
}
/// 获取 Codex 供应商配置文件路径
pub fn get_codex_provider_paths(
provider_id: &str,
provider_name: Option<&str>,
) -> (PathBuf, PathBuf) {
let base_name = provider_name
.map(|name| sanitize_provider_name(name))
.unwrap_or_else(|| sanitize_provider_name(provider_id));
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
(auth_path, config_path)
}
/// 备份 Codex 当前配置
pub fn backup_codex_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
let (backup_auth_path, backup_config_path) =
get_codex_provider_paths(provider_id, Some(provider_name));
// 备份 auth.json
if auth_path.exists() {
copy_file(&auth_path, &backup_auth_path)?;
log::info!("已备份 Codex auth.json: {}", backup_auth_path.display());
}
// 备份 config.toml
if config_path.exists() {
copy_file(&config_path, &backup_config_path)?;
log::info!("已备份 Codex config.toml: {}", backup_config_path.display());
}
Ok(())
}
/// 保存 Codex 供应商配置副本
pub fn save_codex_provider_config(
provider_id: &str,
provider_name: &str,
settings_config: &Value,
) -> Result<(), String> {
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
// 保存 auth.json
if let Some(auth) = settings_config.get("auth") {
write_json_file(&auth_path, auth)?;
}
// 保存 config.toml
if let Some(config) = settings_config.get("config") {
if let Some(config_str) = config.as_str() {
// 若非空则进行 TOML 语法校验
if !config_str.trim().is_empty() {
toml::from_str::<toml::Table>(config_str)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
fs::write(&config_path, config_str)
.map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?;
}
}
Ok(())
}
/// 删除 Codex 供应商配置文件
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
delete_file(&auth_path).ok();
delete_file(&config_path).ok();
Ok(())
}
/// 从 Codex 供应商配置副本恢复到主配置
pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let (provider_auth_path, provider_config_path) =
get_codex_provider_paths(provider_id, Some(provider_name));
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
// 确保目录存在
if let Some(parent) = auth_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
}
// 复制 auth.json必需
if provider_auth_path.exists() {
copy_file(&provider_auth_path, &auth_path)?;
log::info!("已恢复 Codex auth.json");
} else {
return Err(format!(
"供应商 auth.json 不存在: {}",
provider_auth_path.display()
));
}
// 复制 config.toml可选允许为空不存在则创建空文件以保持一致性
if provider_config_path.exists() {
copy_file(&provider_config_path, &config_path)?;
log::info!("已恢复 Codex config.toml");
} else {
// 写入空文件
fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?;
log::info!("供应商 config.toml 缺失,已创建空文件");
}
Ok(())
}
/// 导入当前 Codex 配置为默认供应商
pub fn import_current_codex_config() -> Result<Value, String> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
// 行为放宽:仅要求 auth.json 存在config.toml 可缺失
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
// 读取 auth.json
let auth = read_json_file::<Value>(&auth_path)?;
// 读取 config.toml允许不存在或读取失败时为空
let config_str = if config_path.exists() {
let s = fs::read_to_string(&config_path)
.map_err(|e| format!("读取 config.toml 失败: {}", e))?;
if !s.trim().is_empty() {
toml::from_str::<toml::Table>(&s)
.map_err(|e| format!("config.toml 语法错误: {}", e))?;
}
s
} else {
String::new()
};
// 组合成完整配置
let settings_config = serde_json::json!({
"auth": auth,
"config": config_str
});
// 保存为默认供应商副本
save_codex_provider_config("default", "default", &settings_config)?;
Ok(settings_config)
}

View File

@@ -1,7 +1,11 @@
#![allow(non_snake_case)]
use std::collections::HashMap; use std::collections::HashMap;
use tauri::State; use tauri::State;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default}; use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
use crate::provider::Provider; use crate::provider::Provider;
use crate::store::AppState; use crate::store::AppState;
@@ -10,38 +14,97 @@ use crate::store::AppState;
#[tauri::command] #[tauri::command]
pub async fn get_providers( pub async fn get_providers(
state: State<'_, AppState>, state: State<'_, AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<HashMap<String, Provider>, String> { ) -> Result<HashMap<String, Provider>, String> {
let manager = state let app_type = app_type
.provider_manager .or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config = state
.config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
Ok(manager.get_all_providers().clone()) Ok(manager.get_all_providers().clone())
} }
/// 获取当前供应商ID /// 获取当前供应商ID
#[tauri::command] #[tauri::command]
pub async fn get_current_provider(state: State<'_, AppState>) -> Result<String, String> { pub async fn get_current_provider(
let manager = state state: State<'_, AppState>,
.provider_manager app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<String, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config = state
.config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
Ok(manager.current.clone()) Ok(manager.current.clone())
} }
/// 添加供应商 /// 添加供应商
#[tauri::command] #[tauri::command]
pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Result<bool, String> { pub async fn add_provider(
let mut manager = state state: State<'_, AppState>,
.provider_manager app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider: Provider,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
manager.add_provider(provider)?; let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 根据应用类型保存配置文件
match app_type {
AppType::Codex => {
// Codex: 保存两个文件
codex_config::save_codex_provider_config(
&provider.id,
&provider.name,
&provider.settings_config,
)?;
}
AppType::Claude => {
// Claude: 使用原有逻辑
use crate::config::{get_provider_config_path, write_json_file};
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
}
}
manager.providers.insert(provider.id.clone(), provider);
// 保存配置 // 保存配置
drop(manager); // 释放锁 drop(config); // 释放锁
state.save()?; state.save()?;
Ok(true) Ok(true)
@@ -51,17 +114,69 @@ pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Res
#[tauri::command] #[tauri::command]
pub async fn update_provider( pub async fn update_provider(
state: State<'_, AppState>, state: State<'_, AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider: Provider, provider: Provider,
) -> Result<bool, String> { ) -> Result<bool, String> {
let mut manager = state let app_type = app_type
.provider_manager .or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
manager.update_provider(provider)?; let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在
if !manager.providers.contains_key(&provider.id) {
return Err(format!("供应商不存在: {}", provider.id));
}
// 如果名称改变了,需要处理配置文件
if let Some(old_provider) = manager.providers.get(&provider.id) {
if old_provider.name != provider.name {
// 删除旧配置文件
match app_type {
AppType::Codex => {
codex_config::delete_codex_provider_config(&provider.id, &old_provider.name)
.ok();
}
AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path};
let old_config_path =
get_provider_config_path(&provider.id, Some(&old_provider.name));
delete_file(&old_config_path).ok();
}
}
}
}
// 保存新配置文件
match app_type {
AppType::Codex => {
codex_config::save_codex_provider_config(
&provider.id,
&provider.name,
&provider.settings_config,
)?;
}
AppType::Claude => {
use crate::config::{get_provider_config_path, write_json_file};
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
}
}
manager.providers.insert(provider.id.clone(), provider);
// 保存配置 // 保存配置
drop(manager); // 释放锁 drop(config); // 释放锁
state.save()?; state.save()?;
Ok(true) Ok(true)
@@ -69,16 +184,56 @@ pub async fn update_provider(
/// 删除供应商 /// 删除供应商
#[tauri::command] #[tauri::command]
pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<bool, String> { pub async fn delete_provider(
let mut manager = state state: State<'_, AppState>,
.provider_manager app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
id: String,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
manager.delete_provider(&id)?; let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查是否为当前供应商
if manager.current == id {
return Err("不能删除当前正在使用的供应商".to_string());
}
// 获取供应商信息
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// 删除配置文件
match app_type {
AppType::Codex => {
codex_config::delete_codex_provider_config(&id, &provider.name)?;
}
AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path};
let config_path = get_provider_config_path(&id, Some(&provider.name));
delete_file(&config_path)?;
}
}
// 从管理器删除
manager.providers.remove(&id);
// 保存配置 // 保存配置
drop(manager); // 释放锁 drop(config); // 释放锁
state.save()?; state.save()?;
Ok(true) Ok(true)
@@ -86,16 +241,92 @@ pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<b
/// 切换供应商 /// 切换供应商
#[tauri::command] #[tauri::command]
pub async fn switch_provider(state: State<'_, AppState>, id: String) -> Result<bool, String> { pub async fn switch_provider(
let mut manager = state state: State<'_, AppState>,
.provider_manager app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
id: String,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
manager.switch_provider(&id)?; let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// 根据应用类型执行切换
match app_type {
AppType::Codex => {
// 备份当前配置(如果存在)
if !manager.current.is_empty() {
if let Some(current_provider) = manager.providers.get(&manager.current) {
codex_config::backup_codex_config(&manager.current, &current_provider.name)?;
log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name);
}
}
// 恢复目标供应商配置
codex_config::restore_codex_provider_config(&id, &provider.name)?;
}
AppType::Claude => {
// 使用原有的 Claude 切换逻辑
use crate::config::{
backup_config, copy_file, get_claude_settings_path, get_provider_config_path,
};
let settings_path = get_claude_settings_path();
let provider_config_path = get_provider_config_path(&id, Some(&provider.name));
// 检查供应商配置文件是否存在
if !provider_config_path.exists() {
return Err(format!(
"供应商配置文件不存在: {}",
provider_config_path.display()
));
}
// 如果当前有配置,先备份到当前供应商
if settings_path.exists() && !manager.current.is_empty() {
if let Some(current_provider) = manager.providers.get(&manager.current) {
let current_provider_path =
get_provider_config_path(&manager.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)?;
}
}
// 更新当前供应商
manager.current = id;
log::info!("成功切换到供应商: {}", provider.name);
// 保存配置 // 保存配置
drop(manager); // 释放锁 drop(config); // 释放锁
state.save()?; state.save()?;
Ok(true) Ok(true)
@@ -103,20 +334,36 @@ pub async fn switch_provider(state: State<'_, AppState>, id: String) -> Result<b
/// 导入当前配置为默认供应商 /// 导入当前配置为默认供应商
#[tauri::command] #[tauri::command]
pub async fn import_default_config(state: State<'_, AppState>) -> Result<bool, String> { pub async fn import_default_config(
state: State<'_, AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
// 若已存在 default 供应商,则直接返回,避免重复导入 // 若已存在 default 供应商,则直接返回,避免重复导入
{ {
let manager = state let config = state
.provider_manager .config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
if manager.get_all_providers().contains_key("default") {
return Ok(true); if let Some(manager) = config.get_manager(&app_type) {
if manager.get_all_providers().contains_key("default") {
return Ok(true);
}
} }
} }
// 导入配置 // 根据应用类型导入配置
let settings_config = import_current_config_as_default()?; let settings_config = match app_type {
AppType::Codex => codex_config::import_current_codex_config()?,
AppType::Claude => import_current_config_as_default()?,
};
// 创建默认供应商 // 创建默认供应商
let provider = Provider::with_id( let provider = Provider::with_id(
@@ -127,12 +374,32 @@ pub async fn import_default_config(state: State<'_, AppState>) -> Result<bool, S
); );
// 添加到管理器 // 添加到管理器
let mut manager = state let mut config = state
.provider_manager .config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
manager.add_provider(provider)?; let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 根据应用类型保存配置文件
match app_type {
AppType::Codex => {
codex_config::save_codex_provider_config(
&provider.id,
&provider.name,
&provider.settings_config,
)?;
}
AppType::Claude => {
use crate::config::{get_provider_config_path, write_json_file};
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
}
}
manager.providers.insert(provider.id.clone(), provider);
// 如果没有当前供应商,设置为 default // 如果没有当前供应商,设置为 default
if manager.current.is_empty() { if manager.current.is_empty() {
@@ -140,7 +407,7 @@ pub async fn import_default_config(state: State<'_, AppState>) -> Result<bool, S
} }
// 保存配置 // 保存配置
drop(manager); // 释放锁 drop(config); // 释放锁
state.save()?; state.save()?;
Ok(true) Ok(true)
@@ -152,6 +419,34 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(crate::config::get_claude_config_status()) Ok(crate::config::get_claude_config_status())
} }
/// 获取应用配置状态(通用)
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command]
pub async fn get_config_status(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<ConfigStatus, String> {
let app = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
match app {
AppType::Claude => Ok(crate::config::get_claude_config_status()),
AppType::Codex => {
use crate::codex_config::{get_codex_auth_path, get_codex_config_dir};
let auth_path = get_codex_auth_path();
// 放宽:只要 auth.json 存在即可认为已配置config.toml 允许为空
let exists = auth_path.exists();
let path = get_codex_config_dir().to_string_lossy().to_string();
Ok(ConfigStatus { exists, path })
}
}
}
/// 获取 Claude Code 配置文件路径 /// 获取 Claude Code 配置文件路径
#[tauri::command] #[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> { pub async fn get_claude_code_config_path() -> Result<String, String> {
@@ -159,9 +454,23 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
} }
/// 打开配置文件夹 /// 打开配置文件夹
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command] #[tauri::command]
pub async fn open_config_folder(app: tauri::AppHandle) -> Result<bool, String> { pub async fn open_config_folder(
let config_dir = crate::config::get_claude_config_dir(); handle: tauri::AppHandle,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config_dir = match app_type {
AppType::Claude => crate::config::get_claude_config_dir(),
AppType::Codex => crate::codex_config::get_codex_config_dir(),
};
// 确保目录存在 // 确保目录存在
if !config_dir.exists() { if !config_dir.exists() {
@@ -169,7 +478,7 @@ pub async fn open_config_folder(app: tauri::AppHandle) -> Result<bool, String> {
} }
// 使用 opener 插件打开文件夹 // 使用 opener 插件打开文件夹
app.opener() handle.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>) .open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?; .map_err(|e| format!("打开文件夹失败: {}", e))?;

View File

@@ -1,3 +1,5 @@
mod app_config;
mod codex_config;
mod commands; mod commands;
mod config; mod config;
mod provider; mod provider;
@@ -55,34 +57,51 @@ pub fn run() {
// 如果没有供应商且存在 Claude Code 配置,自动导入 // 如果没有供应商且存在 Claude Code 配置,自动导入
{ {
let manager = app_state.provider_manager.lock().unwrap(); let mut config = app_state.config.lock().unwrap();
if manager.providers.is_empty() {
drop(manager); // 释放锁
// 检查 Claude 供应商
let need_import = if let Some(claude_manager) =
config.get_manager(&app_config::AppType::Claude)
{
claude_manager.providers.is_empty()
} else {
// 确保 Claude 应用存在
config.ensure_app(&app_config::AppType::Claude);
true
};
if need_import {
let settings_path = config::get_claude_settings_path(); let settings_path = config::get_claude_settings_path();
if settings_path.exists() { if settings_path.exists() {
log::info!("检测到 Claude Code 配置,自动导入为默认供应商"); log::info!("检测到 Claude Code 配置,自动导入为默认供应商");
if let Ok(settings_config) = config::import_current_config_as_default() { if let Ok(settings_config) = config::import_current_config_as_default() {
let mut manager = app_state.provider_manager.lock().unwrap(); if let Some(manager) =
let provider = provider::Provider::with_id( config.get_manager_mut(&app_config::AppType::Claude)
"default".to_string(), {
"default".to_string(), let provider = provider::Provider::with_id(
settings_config, "default".to_string(),
None, "default".to_string(),
); settings_config,
None,
);
if manager.add_provider(provider).is_ok() { if manager.add_provider(provider).is_ok() {
manager.current = "default".to_string(); manager.current = "default".to_string();
drop(manager); log::info!("成功导入默认供应商");
let _ = app_state.save(); }
log::info!("成功导入默认供应商");
} }
} }
} }
} }
// 确保 Codex 应用存在
config.ensure_app(&app_config::AppType::Codex);
} }
// 保存配置
let _ = app_state.save();
// 将同一个实例注入到全局状态,避免重复创建导致的不一致 // 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state); app.manage(app_state);
Ok(()) Ok(())
@@ -96,6 +115,7 @@ pub fn run() {
commands::switch_provider, commands::switch_provider,
commands::import_default_config, commands::import_default_config,
commands::get_claude_config_status, commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path, commands::get_claude_code_config_path,
commands::open_config_folder, commands::open_config_folder,
commands::open_external, commands::open_external,

View File

@@ -1,12 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path;
use crate::config::{ use crate::config::{get_provider_config_path, write_json_file};
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -54,21 +50,6 @@ impl Default for ProviderManager {
} }
impl ProviderManager { impl ProviderManager {
/// 加载供应商列表
pub fn load_from_file(path: &Path) -> Result<Self, String> {
if !path.exists() {
log::info!("配置文件不存在,创建新的供应商管理器");
return Ok(Self::default());
}
read_json_file(path)
}
/// 保存供应商列表
pub fn save_to_file(&self, path: &Path) -> Result<(), String> {
write_json_file(path, self)
}
/// 添加供应商 /// 添加供应商
pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> { pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> {
// 保存供应商配置到独立文件 // 保存供应商配置到独立文件
@@ -80,98 +61,6 @@ impl ProviderManager {
Ok(()) 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> { pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
&self.providers &self.providers

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.0.0", "version": "3.1.2",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -28,13 +28,19 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app", "dmg", "nsis", "appimage"], "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper",
"silent": true
}
}
} }
} }

View File

@@ -5,25 +5,94 @@
} }
.app-header { .app-header {
background: #3498db; background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
color: white; color: white;
padding: 0.75rem 2rem; padding: 0.35rem 2rem 0.45rem;
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto auto;
grid-template-areas:
". title ."
"tabs . actions";
align-items: center; align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); row-gap: 0.6rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
user-select: none; user-select: none;
min-height: 3rem; }
.app-tabs {
grid-area: tabs;
}
/* Segmented control */
.segmented {
--seg-bg: rgba(255, 255, 255, 0.16);
--seg-thumb: #ffffff;
--seg-color: rgba(255, 255, 255, 0.85);
--seg-active: #2d89c7;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 280px;
background: var(--seg-bg);
border-radius: 999px;
padding: 4px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
backdrop-filter: saturate(140%) blur(2px);
}
.segmented-thumb {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--seg-thumb);
border-radius: 999px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition:
transform 220ms ease,
width 220ms ease;
will-change: transform;
}
.segmented-item {
position: relative;
z-index: 1;
background: transparent;
border: none;
border-radius: 999px;
padding: 6px 16px; /* 更紧凑的高度 */
color: var(--seg-color);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.2px;
cursor: pointer;
transition: color 200ms ease;
}
.segmented-item.active {
color: var(--seg-active);
}
.segmented-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.8);
outline-offset: 2px;
} }
.app-header h1 { .app-header h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 500; font-weight: 500;
margin: 0;
grid-area: title;
text-align: center;
} }
.header-actions { .header-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
grid-area: actions;
justify-self: end;
} }
.refresh-btn, .refresh-btn,

View File

@@ -1,12 +1,15 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Provider } from "./types"; import { Provider } from "./types";
import { AppType } from "./lib/tauri-api";
import ProviderList from "./components/ProviderList"; import ProviderList from "./components/ProviderList";
import AddProviderModal from "./components/AddProviderModal"; import AddProviderModal from "./components/AddProviderModal";
import EditProviderModal from "./components/EditProviderModal"; import EditProviderModal from "./components/EditProviderModal";
import { ConfirmDialog } from "./components/ConfirmDialog"; import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher";
import "./App.css"; import "./App.css";
function App() { function App() {
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({}); const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>(""); const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@@ -60,7 +63,7 @@ function App() {
useEffect(() => { useEffect(() => {
loadProviders(); loadProviders();
loadConfigStatus(); loadConfigStatus();
}, []); }, [activeApp]); // 当切换应用时重新加载
// 清理定时器 // 清理定时器
useEffect(() => { useEffect(() => {
@@ -72,8 +75,8 @@ function App() {
}, []); }, []);
const loadProviders = async () => { const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(); const loadedProviders = await window.api.getProviders(activeApp);
const currentId = await window.api.getCurrentProvider(); const currentId = await window.api.getCurrentProvider(activeApp);
setProviders(loadedProviders); setProviders(loadedProviders);
setCurrentProviderId(currentId); setCurrentProviderId(currentId);
@@ -84,7 +87,7 @@ function App() {
}; };
const loadConfigStatus = async () => { const loadConfigStatus = async () => {
const status = await window.api.getClaudeConfigStatus(); const status = await window.api.getConfigStatus(activeApp);
setConfigStatus({ setConfigStatus({
exists: Boolean(status?.exists), exists: Boolean(status?.exists),
path: String(status?.path || ""), path: String(status?.path || ""),
@@ -101,14 +104,14 @@ function App() {
...provider, ...provider,
id: generateId(), id: generateId(),
}; };
await window.api.addProvider(newProvider); await window.api.addProvider(newProvider, activeApp);
await loadProviders(); await loadProviders();
setIsAddModalOpen(false); setIsAddModalOpen(false);
}; };
const handleEditProvider = async (provider: Provider) => { const handleEditProvider = async (provider: Provider) => {
try { try {
await window.api.updateProvider(provider); await window.api.updateProvider(provider, activeApp);
await loadProviders(); await loadProviders();
setEditingProviderId(null); setEditingProviderId(null);
// 显示编辑成功提示 // 显示编辑成功提示
@@ -127,7 +130,7 @@ function App() {
title: "删除供应商", title: "删除供应商",
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`, message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
onConfirm: async () => { onConfirm: async () => {
await window.api.deleteProvider(id); await window.api.deleteProvider(id, activeApp);
await loadProviders(); await loadProviders();
setConfirmDialog(null); setConfirmDialog(null);
showNotification("供应商删除成功", "success"); showNotification("供应商删除成功", "success");
@@ -136,12 +139,13 @@ function App() {
}; };
const handleSwitchProvider = async (id: string) => { const handleSwitchProvider = async (id: string) => {
const success = await window.api.switchProvider(id); const success = await window.api.switchProvider(id, activeApp);
if (success) { if (success) {
setCurrentProviderId(id); setCurrentProviderId(id);
// 显示重启提示 // 显示重启提示
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
showNotification( showNotification(
"切换成功!请重启 Claude Code 终端以生效", `切换成功!请重启 ${appName} 终端以生效`,
"success", "success",
2000, 2000,
); );
@@ -153,7 +157,7 @@ function App() {
// 自动导入现有配置为"default"供应商 // 自动导入现有配置为"default"供应商
const handleAutoImportDefault = async () => { const handleAutoImportDefault = async () => {
try { try {
const result = await window.api.importCurrentConfigAsDefault(); const result = await window.api.importCurrentConfigAsDefault(activeApp);
if (result.success) { if (result.success) {
await loadProviders(); await loadProviders();
@@ -171,13 +175,19 @@ function App() {
}; };
const handleOpenConfigFolder = async () => { const handleOpenConfigFolder = async () => {
await window.api.openConfigFolder(); await window.api.openConfigFolder(activeApp);
}; };
return ( return (
<div className="app"> <div className="app">
<header className="app-header"> <header className="app-header">
<h1>Claude Code </h1> <h1>CC Switch</h1>
<div className="app-tabs">
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
/>
</div>
<div className="header-actions"> <div className="header-actions">
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}> <button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
@@ -228,6 +238,7 @@ function App() {
{isAddModalOpen && ( {isAddModalOpen && (
<AddProviderModal <AddProviderModal
appType={activeApp}
onAdd={handleAddProvider} onAdd={handleAddProvider}
onClose={() => setIsAddModalOpen(false)} onClose={() => setIsAddModalOpen(false)}
/> />
@@ -235,6 +246,7 @@ function App() {
{editingProviderId && providers[editingProviderId] && ( {editingProviderId && providers[editingProviderId] && (
<EditProviderModal <EditProviderModal
appType={activeApp}
provider={providers[editingProviderId]} provider={providers[editingProviderId]}
onSave={handleEditProvider} onSave={handleEditProvider}
onClose={() => setEditingProviderId(null)} onClose={() => setEditingProviderId(null)}

View File

@@ -1,18 +1,22 @@
import React from "react"; import React from "react";
import { Provider } from "../types"; import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm"; import ProviderForm from "./ProviderForm";
interface AddProviderModalProps { interface AddProviderModalProps {
appType: AppType;
onAdd: (provider: Omit<Provider, "id">) => void; onAdd: (provider: Omit<Provider, "id">) => void;
onClose: () => void; onClose: () => void;
} }
const AddProviderModal: React.FC<AddProviderModalProps> = ({ const AddProviderModal: React.FC<AddProviderModalProps> = ({
appType,
onAdd, onAdd,
onClose, onClose,
}) => { }) => {
return ( return (
<ProviderForm <ProviderForm
appType={appType}
title="添加新供应商" title="添加新供应商"
submitText="添加" submitText="添加"
showPresets={true} showPresets={true}

View File

@@ -0,0 +1,66 @@
/* 药丸式切换按钮 */
.switcher-pills {
display: inline-flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.08);
padding: 6px 8px;
border-radius: 50px;
backdrop-filter: blur(10px);
}
.switcher-pill {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 50px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 200ms ease;
min-width: 120px;
}
.switcher-pill:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
.switcher-pill.active {
background: rgba(255, 255, 255, 0.15);
color: white;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.1);
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
transition: all 200ms ease;
}
.switcher-pill.active .pill-dot {
opacity: 1;
box-shadow: 0 0 8px currentColor;
animation: pulse 2s infinite;
}
.pills-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
}

View File

@@ -0,0 +1,36 @@
import { AppType } from "../lib/tauri-api";
import "./AppSwitcher.css";
interface AppSwitcherProps {
activeApp: AppType;
onSwitch: (app: AppType) => void;
}
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
const handleSwitch = (app: AppType) => {
if (app === activeApp) return;
onSwitch(app);
};
return (
<div className="switcher-pills">
<button
type="button"
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
onClick={() => handleSwitch("claude")}
>
<span className="pill-dot" />
<span>Claude Code</span>
</button>
<div className="pills-divider" />
<button
type="button"
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
onClick={() => handleSwitch("codex")}
>
<span className="pill-dot" />
<span>Codex</span>
</button>
</div>
);
}

View File

@@ -1,14 +1,17 @@
import React from "react"; import React from "react";
import { Provider } from "../types"; import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm"; import ProviderForm from "./ProviderForm";
interface EditProviderModalProps { interface EditProviderModalProps {
appType: AppType;
provider: Provider; provider: Provider;
onSave: (provider: Provider) => void; onSave: (provider: Provider) => void;
onClose: () => void; onClose: () => void;
} }
const EditProviderModal: React.FC<EditProviderModalProps> = ({ const EditProviderModal: React.FC<EditProviderModalProps> = ({
appType,
provider, provider,
onSave, onSave,
onClose, onClose,
@@ -22,6 +25,7 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
return ( return (
<ProviderForm <ProviderForm
appType={appType}
title="编辑供应商" title="编辑供应商"
submitText="保存" submitText="保存"
initialData={provider} initialData={provider}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Provider } from "../types"; import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import { import {
updateCoAuthoredSetting, updateCoAuthoredSetting,
checkCoAuthoredSetting, checkCoAuthoredSetting,
@@ -9,9 +10,11 @@ import {
setApiKeyInConfig, setApiKeyInConfig,
} from "../utils/providerConfigUtils"; } from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets"; import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets";
import "./AddProviderModal.css"; import "./AddProviderModal.css";
interface ProviderFormProps { interface ProviderFormProps {
appType?: AppType;
title: string; title: string;
submitText: string; submitText: string;
initialData?: Provider; initialData?: Provider;
@@ -21,6 +24,7 @@ interface ProviderFormProps {
} }
const ProviderForm: React.FC<ProviderFormProps> = ({ const ProviderForm: React.FC<ProviderFormProps> = ({
appType = "claude",
title, title,
submitText, submitText,
initialData, initialData,
@@ -28,6 +32,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
onSubmit, onSubmit,
onClose, onClose,
}) => { }) => {
// 对于 Codex需要分离 auth 和 config
const isCodex = appType === "codex";
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: initialData?.name || "", name: initialData?.name || "",
websiteUrl: initialData?.websiteUrl || "", websiteUrl: initialData?.websiteUrl || "",
@@ -35,9 +42,40 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
? JSON.stringify(initialData.settingsConfig, null, 2) ? JSON.stringify(initialData.settingsConfig, null, 2)
: "", : "",
}); });
// Codex 特有的状态
const [codexAuth, setCodexAuth] = useState("");
const [codexConfig, setCodexConfig] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null,
);
// 初始化 Codex 配置
useEffect(() => {
if (isCodex && initialData) {
const config = initialData.settingsConfig;
if (typeof config === "object" && config !== null) {
setCodexAuth(JSON.stringify(config.auth || {}, null, 2));
setCodexConfig(config.config || "");
try {
const auth = config.auth || {};
if (auth && typeof auth.OPENAI_API_KEY === "string") {
setCodexApiKey(auth.OPENAI_API_KEY);
}
} catch {
// ignore
}
}
}
}, [isCodex, initialData]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false); const [disableCoAuthored, setDisableCoAuthored] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<number | null>(null); // -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null
);
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
// 初始化时检查禁用签名状态 // 初始化时检查禁用签名状态
@@ -58,18 +96,55 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return; return;
} }
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
return;
}
let settingsConfig: Record<string, any>; let settingsConfig: Record<string, any>;
try { if (isCodex) {
settingsConfig = JSON.parse(formData.settingsConfig); // Codex: 仅要求 auth.json 必填config.toml 可为空
} catch (err) { if (!codexAuth.trim()) {
setError("配置JSON格式错误请检查语法"); setError("请填写 auth.json 配置");
return; return;
}
try {
const authJson = JSON.parse(codexAuth);
// 非官方预设强制要求 OPENAI_API_KEY
if (selectedCodexPreset !== null) {
const preset = codexProviderPresets[selectedCodexPreset];
const isOfficial = Boolean(preset?.isOfficial);
if (!isOfficial) {
const key =
typeof authJson.OPENAI_API_KEY === "string"
? authJson.OPENAI_API_KEY.trim()
: "";
if (!key) {
setError("请填写 OPENAI_API_KEY");
return;
}
}
}
settingsConfig = {
auth: authJson,
config: codexConfig ?? "",
};
} catch (err) {
setError("auth.json 格式错误请检查JSON语法");
return;
}
} else {
// Claude: 原有逻辑
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
return;
}
try {
settingsConfig = JSON.parse(formData.settingsConfig);
} catch (err) {
setError("配置JSON格式错误请检查语法");
return;
}
} }
onSubmit({ onSubmit({
@@ -145,6 +220,52 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setDisableCoAuthored(hasCoAuthoredDisabled); setDisableCoAuthored(hasCoAuthoredDisabled);
}; };
// 处理点击自定义按钮
const handleCustomClick = () => {
setSelectedPreset(-1);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setApiKey("");
setDisableCoAuthored(false);
};
// Codex: 应用预设
const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0],
index: number,
) => {
const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString);
setCodexConfig(preset.config || "");
setFormData({
name: preset.name,
websiteUrl: preset.websiteUrl,
settingsConfig: formData.settingsConfig,
});
setSelectedCodexPreset(index);
// 清空 API Key让用户重新输入
setCodexApiKey("");
};
// Codex: 处理点击自定义按钮
const handleCodexCustomClick = () => {
setSelectedCodexPreset(-1);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setCodexAuth("");
setCodexConfig("");
setCodexApiKey("");
};
// 处理 API Key 输入并自动更新配置 // 处理 API Key 输入并自动更新配置
const handleApiKeyChange = (key: string) => { const handleApiKeyChange = (key: string) => {
setApiKey(key); setApiKey(key);
@@ -152,7 +273,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = setApiKeyInConfig( const configString = setApiKeyInConfig(
formData.settingsConfig, formData.settingsConfig,
key.trim(), key.trim(),
{ createIfMissing: selectedPreset !== null }, { createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
); );
// 更新表单配置 // 更新表单配置
@@ -166,15 +287,48 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setDisableCoAuthored(hasCoAuthoredDisabled); setDisableCoAuthored(hasCoAuthoredDisabled);
}; };
// Codex: 处理 API Key 输入并写回 auth.json
const handleCodexApiKeyChange = (key: string) => {
setCodexApiKey(key);
try {
const auth = JSON.parse(codexAuth || "{}");
auth.OPENAI_API_KEY = key.trim();
setCodexAuth(JSON.stringify(auth, null, 2));
} catch {
// ignore
}
};
// 根据当前配置决定是否展示 API Key 输入框 // 根据当前配置决定是否展示 API Key 输入框
// 自定义模式(-1)不显示独立的 API Key 输入框
const showApiKey = const showApiKey =
selectedPreset !== null || hasApiKeyField(formData.settingsConfig); (selectedPreset !== null && selectedPreset !== -1) ||
(!showPresets && hasApiKeyField(formData.settingsConfig));
// 判断当前选中的预设是否是官方 // 判断当前选中的预设是否是官方
const isOfficialPreset = const isOfficialPreset =
selectedPreset !== null && selectedPreset !== null &&
selectedPreset >= 0 &&
providerPresets[selectedPreset]?.isOfficial === true; providerPresets[selectedPreset]?.isOfficial === true;
// Codex: 控制显示 API Key 与官方标记
const getCodexAuthApiKey = (authString: string): string => {
try {
const auth = JSON.parse(authString || "{}");
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
} catch {
return "";
}
};
// 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
const isCodexOfficialPreset =
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
codexProviderPresets[selectedCodexPreset]?.isOfficial === true;
// 初始时从配置中同步 API Key编辑模式 // 初始时从配置中同步 API Key编辑模式
useEffect(() => { useEffect(() => {
if (initialData) { if (initialData) {
@@ -226,10 +380,19 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
<div className="modal-body"> <div className="modal-body">
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{showPresets && ( {showPresets && !isCodex && (
<div className="presets"> <div className="presets">
<label> key</label> <label></label>
<div className="preset-buttons"> <div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedPreset === -1 ? "selected" : ""
}`}
onClick={handleCustomClick}
>
</button>
{providerPresets.map((preset, index) => { {providerPresets.map((preset, index) => {
return ( return (
<button <button
@@ -245,6 +408,59 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
); );
})} })}
</div> </div>
{selectedPreset === -1 && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
</small>
)}
{selectedPreset !== -1 && selectedPreset !== null && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
{isOfficialPreset
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
)}
</div>
)}
{showPresets && isCodex && (
<div className="presets">
<label></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedCodexPreset === -1 ? "selected" : ""
}`}
onClick={handleCodexCustomClick}
>
</button>
{codexProviderPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`preset-btn ${
selectedCodexPreset === index ? "selected" : ""
} ${preset.isOfficial ? "official" : ""}`}
onClick={() => applyCodexPreset(preset, index)}
>
{preset.name}
</button>
))}
</div>
{selectedCodexPreset === -1 && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
</small>
)}
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
{isCodexOfficialPreset
? "Codex 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
)}
</div> </div>
)} )}
@@ -262,33 +478,68 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
/> />
</div> </div>
<div {!isCodex && (
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`} <div
> className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
<label htmlFor="apiKey">API Key *</label> >
<input <label htmlFor="apiKey">API Key *</label>
type="text" <input
id="apiKey" type="text"
value={apiKey} id="apiKey"
onChange={(e) => handleApiKeyChange(e.target.value)} value={apiKey}
placeholder={ onChange={(e) => handleApiKeyChange(e.target.value)}
isOfficialPreset placeholder={
? "官方登录无需填写 API Key直接保存即可" isOfficialPreset
: "只需要填这里,下方配置会自动填充" ? "官方登录无需填写 API Key直接保存即可"
} : "只需要填这里,下方配置会自动填充"
disabled={isOfficialPreset} }
autoComplete="off" disabled={isOfficialPreset}
style={ autoComplete="off"
isOfficialPreset style={
? { isOfficialPreset
backgroundColor: "#f5f5f5", ? {
cursor: "not-allowed", backgroundColor: "#f5f5f5",
color: "#999", cursor: "not-allowed",
} color: "#999",
: {} }
} : {}
/> }
</div> />
</div>
)}
{isCodex && (
<div
className={`form-group api-key-group ${!showCodexApiKey ? "hidden" : ""}`}
>
<label htmlFor="codexApiKey">API Key *</label>
<input
type="text"
id="codexApiKey"
value={codexApiKey}
onChange={(e) => handleCodexApiKeyChange(e.target.value)}
placeholder={
isCodexOfficialPreset
? "官方无需填写 API Key直接保存即可"
: "只需要填这里,下方 auth.json 会自动填充"
}
disabled={isCodexOfficialPreset}
required={
selectedCodexPreset !== null && selectedCodexPreset >= 0 && !isCodexOfficialPreset
}
autoComplete="off"
style={
isCodexOfficialPreset
? {
backgroundColor: "#f5f5f5",
cursor: "not-allowed",
color: "#999",
}
: {}
}
/>
</div>
)}
<div className="form-group"> <div className="form-group">
<label htmlFor="websiteUrl"></label> <label htmlFor="websiteUrl"></label>
@@ -303,39 +554,90 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
/> />
</div> </div>
<div className="form-group"> {/* Claude 或 Codex 的配置部分 */}
<div className="label-with-checkbox"> {isCodex ? (
<label htmlFor="settingsConfig"> // Codex: 双编辑器
Claude Code (JSON) * <>
</label> <div className="form-group">
<label className="checkbox-label"> <label htmlFor="codexAuth">auth.json (JSON) *</label>
<input <textarea
type="checkbox" id="codexAuth"
checked={disableCoAuthored} value={codexAuth}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)} onChange={(e) => {
const value = e.target.value;
setCodexAuth(value);
try {
const auth = JSON.parse(value || "{}");
const key =
typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY
: "";
setCodexApiKey(key);
} catch {
// ignore
}
}}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
rows={6}
style={{ fontFamily: "monospace", fontSize: "14px" }}
required
/> />
Claude Code <small className="field-hint">Codex auth.json </small>
</label> </div>
</div>
<textarea <div className="form-group">
id="settingsConfig" <label htmlFor="codexConfig">config.toml (TOML)</label>
name="settingsConfig" <textarea
value={formData.settingsConfig} id="codexConfig"
onChange={handleChange} value={codexConfig}
placeholder={`{ onChange={(e) => setCodexConfig(e.target.value)}
placeholder={``}
rows={8}
style={{ fontFamily: "monospace", fontSize: "14px" }}
/>
<small className="field-hint">
Codex config.toml
</small>
</div>
</>
) : (
// Claude: 原有的单编辑器
<div className="form-group">
<div className="label-with-checkbox">
<label htmlFor="settingsConfig">
Claude Code (JSON) *
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
/>
Claude Code
</label>
</div>
<textarea
id="settingsConfig"
name="settingsConfig"
value={formData.settingsConfig}
onChange={handleChange}
placeholder={`{
"env": { "env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here" "ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
} }
}`} }`}
rows={12} rows={12}
style={{ fontFamily: "monospace", fontSize: "14px" }} style={{ fontFamily: "monospace", fontSize: "14px" }}
required required
/> />
<small className="field-hint"> <small className="field-hint">
Claude Code settings.json Claude Code settings.json
</small> </small>
</div> </div>
)}
</div> </div>
<div className="modal-footer"> <div className="modal-footer">

View File

@@ -0,0 +1,41 @@
/**
* Codex 预设供应商配置模板
*/
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
}
export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "Codex官方",
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
// 官方的 key 为null
auth: {
OPENAI_API_KEY: null,
},
config: ``,
},
{
name: "PackyCode",
websiteUrl: "https://codex.packycode.com/",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: {
OPENAI_API_KEY: "sk-your-api-key-here",
},
config: `model_provider = "packycode"
model = "gpt-5"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"
env_key = "packycode"`,
},
];

View File

@@ -1,6 +1,9 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Provider } from "../types"; import { Provider } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
// 定义配置状态类型 // 定义配置状态类型
interface ConfigStatus { interface ConfigStatus {
exists: boolean; exists: boolean;
@@ -17,9 +20,9 @@ interface ImportResult {
// Tauri API 封装,提供统一的全局 API 接口 // Tauri API 封装,提供统一的全局 API 接口
export const tauriAPI = { export const tauriAPI = {
// 获取所有供应商 // 获取所有供应商
getProviders: async (): Promise<Record<string, Provider>> => { getProviders: async (app?: AppType): Promise<Record<string, Provider>> => {
try { try {
return await invoke("get_providers"); return await invoke("get_providers", { app_type: app, app });
} catch (error) { } catch (error) {
console.error("获取供应商列表失败:", error); console.error("获取供应商列表失败:", error);
return {}; return {};
@@ -27,9 +30,9 @@ export const tauriAPI = {
}, },
// 获取当前供应商ID // 获取当前供应商ID
getCurrentProvider: async (): Promise<string> => { getCurrentProvider: async (app?: AppType): Promise<string> => {
try { try {
return await invoke("get_current_provider"); return await invoke("get_current_provider", { app_type: app, app });
} catch (error) { } catch (error) {
console.error("获取当前供应商失败:", error); console.error("获取当前供应商失败:", error);
return ""; return "";
@@ -37,9 +40,9 @@ export const tauriAPI = {
}, },
// 添加供应商 // 添加供应商
addProvider: async (provider: Provider): Promise<boolean> => { addProvider: async (provider: Provider, app?: AppType): Promise<boolean> => {
try { try {
return await invoke("add_provider", { provider }); return await invoke("add_provider", { provider, app_type: app, app });
} catch (error) { } catch (error) {
console.error("添加供应商失败:", error); console.error("添加供应商失败:", error);
throw error; throw error;
@@ -47,9 +50,12 @@ export const tauriAPI = {
}, },
// 更新供应商 // 更新供应商
updateProvider: async (provider: Provider): Promise<boolean> => { updateProvider: async (
provider: Provider,
app?: AppType,
): Promise<boolean> => {
try { try {
return await invoke("update_provider", { provider }); return await invoke("update_provider", { provider, app_type: app, app });
} catch (error) { } catch (error) {
console.error("更新供应商失败:", error); console.error("更新供应商失败:", error);
throw error; throw error;
@@ -57,9 +63,9 @@ export const tauriAPI = {
}, },
// 删除供应商 // 删除供应商
deleteProvider: async (id: string): Promise<boolean> => { deleteProvider: async (id: string, app?: AppType): Promise<boolean> => {
try { try {
return await invoke("delete_provider", { id }); return await invoke("delete_provider", { id, app_type: app, app });
} catch (error) { } catch (error) {
console.error("删除供应商失败:", error); console.error("删除供应商失败:", error);
throw error; throw error;
@@ -67,9 +73,16 @@ export const tauriAPI = {
}, },
// 切换供应商 // 切换供应商
switchProvider: async (providerId: string): Promise<boolean> => { switchProvider: async (
providerId: string,
app?: AppType,
): Promise<boolean> => {
try { try {
return await invoke("switch_provider", { id: providerId }); return await invoke("switch_provider", {
id: providerId,
app_type: app,
app,
});
} catch (error) { } catch (error) {
console.error("切换供应商失败:", error); console.error("切换供应商失败:", error);
return false; return false;
@@ -77,9 +90,14 @@ export const tauriAPI = {
}, },
// 导入当前配置为默认供应商 // 导入当前配置为默认供应商
importCurrentConfigAsDefault: async (): Promise<ImportResult> => { importCurrentConfigAsDefault: async (
app?: AppType,
): Promise<ImportResult> => {
try { try {
const success = await invoke<boolean>("import_default_config"); const success = await invoke<boolean>("import_default_config", {
app_type: app,
app,
});
return { return {
success, success,
message: success ? "成功导入默认配置" : "导入失败", message: success ? "成功导入默认配置" : "导入失败",
@@ -117,10 +135,24 @@ export const tauriAPI = {
} }
}, },
// 打开配置文件夹 // 获取应用配置状态(通用)
openConfigFolder: async (): Promise<void> => { getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
try { try {
await invoke("open_config_folder"); return await invoke("get_config_status", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 打开配置文件夹
openConfigFolder: async (app?: AppType): Promise<void> => {
try {
await invoke("open_config_folder", { app_type: app, app });
} catch (error) { } catch (error) {
console.error("打开配置文件夹失败:", error); console.error("打开配置文件夹失败:", error);
} }
@@ -135,6 +167,8 @@ export const tauriAPI = {
} }
}, },
// (保留空位,取消迁移提示)
// 选择配置文件Tauri 暂不实现,保留接口兼容性) // 选择配置文件Tauri 暂不实现,保留接口兼容性)
selectConfigFile: async (): Promise<string | null> => { selectConfigFile: async (): Promise<string | null> => {
console.warn("selectConfigFile 在 Tauri 版本中暂不支持"); console.warn("selectConfigFile 在 Tauri 版本中暂不支持");

View File

@@ -1,7 +1,7 @@
export interface Provider { export interface Provider {
id: string; id: string;
name: string; name: string;
settingsConfig: Record<string, any>; // 完整的 Claude Code settings.json 配置 settingsConfig: Record<string, any>; // 应用配置对象:Claude settings.jsonCodex 为 { auth, config }
websiteUrl?: string; websiteUrl?: string;
} }

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

@@ -1,6 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import { Provider } from "./types"; import { Provider } from "./types";
import { AppType } from "./lib/tauri-api";
interface ImportResult { interface ImportResult {
success: boolean; success: boolean;
@@ -16,17 +17,18 @@ interface ConfigStatus {
declare global { declare global {
interface Window { interface Window {
api: { api: {
getProviders: () => Promise<Record<string, Provider>>; getProviders: (app?: AppType) => Promise<Record<string, Provider>>;
getCurrentProvider: () => Promise<string>; getCurrentProvider: (app?: AppType) => Promise<string>;
addProvider: (provider: Provider) => Promise<boolean>; addProvider: (provider: Provider, app?: AppType) => Promise<boolean>;
deleteProvider: (id: string) => Promise<boolean>; deleteProvider: (id: string, app?: AppType) => Promise<boolean>;
updateProvider: (provider: Provider) => Promise<boolean>; updateProvider: (provider: Provider, app?: AppType) => Promise<boolean>;
switchProvider: (providerId: string) => Promise<boolean>; switchProvider: (providerId: string, app?: AppType) => Promise<boolean>;
importCurrentConfigAsDefault: () => Promise<ImportResult>; importCurrentConfigAsDefault: (app?: AppType) => Promise<ImportResult>;
getClaudeCodeConfigPath: () => Promise<string>; getClaudeCodeConfigPath: () => Promise<string>;
getClaudeConfigStatus: () => Promise<ConfigStatus>; getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
selectConfigFile: () => Promise<string | null>; selectConfigFile: () => Promise<string | null>;
openConfigFolder: () => Promise<void>; openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
}; };
platform: { platform: {