2 Commits
main ... i18n

Author SHA1 Message Date
Jason
ea81c3d839 feat: improve i18n implementation with better translations and accessibility
- Add proper i18n keys for language switcher tooltips and aria-labels
- Replace hardcoded Chinese console error messages with i18n keys
- Add missing translation keys for new UI elements
- Improve accessibility with proper aria-label attributes
2025-09-28 20:35:14 +08:00
TinsFox
aa05a8475f feat: integrate i18next for internationalization support
- Added i18next and react-i18next dependencies for localization.
- Updated various components to utilize translation functions for user-facing text.
- Enhanced user experience by providing multilingual support across the application.
2025-09-28 18:23:23 +08:00
334 changed files with 6835 additions and 55437 deletions

View File

@@ -161,7 +161,6 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
TAR_GZ=""; APP_PATH=""
for path in \
@@ -178,18 +177,15 @@ jobs:
echo "No macOS .tar.gz updater artifact found" >&2
exit 1
fi
# 重命名 tar.gz 为统一格式
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $NEW_TAR_GZ"
cp "$TAR_GZ" release-assets/
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
if [ -n "$APP_PATH" ]; then
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
cd "$APP_DIR"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: $NEW_ZIP"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: CC-Switch-macOS.zip"
else
echo "No .app found to zip (optional)" >&2
fi
@@ -200,7 +196,6 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
# 仅打包 MSI 安装器 + .sig用于 Updater
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $msi) {
@@ -208,7 +203,7 @@ jobs:
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
}
if ($null -ne $msi) {
$dest = "CC-Switch-$VERSION-Windows.msi"
$dest = 'CC-Switch-Setup.msi'
Copy-Item $msi.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
$sigPath = "$($msi.FullName).sig"
@@ -237,10 +232,9 @@ jobs:
'portable=true'
)
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
Remove-Item -Recurse -Force $portableDir
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
Write-Host 'Windows portable zip created'
} else {
Write-Warning 'Portable exe not found'
}
@@ -251,23 +245,20 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
# Updater artifact: AppImage含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
echo "AppImage copied: $NEW_APPIMAGE"
cp "$APPIMAGE" release-assets/
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
echo "AppImage copied"
else
echo "No AppImage found under target/release/bundle" >&2
fi
# 额外上传 .deb用于手动安装不参与 Updater
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
cp "$DEB" "release-assets/$NEW_DEB"
echo "Deb package copied: $NEW_DEB"
cp "$DEB" release-assets/
echo "Deb package copied"
else
echo "No .deb found (optional)"
fi
@@ -297,12 +288,12 @@ jobs:
### 下载
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)`CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu
- macOS: `CC-Switch-macOS.zip`(解压即用
- Windows: `CC-Switch-Setup.msi`(安装版)`CC-Switch-Windows-Portable.zip`(绿色版)
- Linux: `*.deb`Debian/Ubuntu 安装包
---
提示macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
提示macOS 如遇已损坏提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@@ -9,11 +9,3 @@ release/
.npmrc
CLAUDE.md
AGENTS.md
GEMINI.md
/.claude
/.codex
/.gemini
/.cc-switch
/.idea
/.vscode
vitest-report.json

View File

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

View File

@@ -5,453 +5,21 @@ 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.7.1] - 2025-11-22
### Fixed
- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)
- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers
- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)
### Added
- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings
- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`
### Improved
- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions
- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives
- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards
### Reverted
- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization
---
## [3.7.0] - 2025-11-19
### Major Features
#### Gemini CLI Integration
- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex
- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats
- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **MCP management** - Full MCP configuration capabilities for Gemini
- **Provider presets**
- Google Official (OAuth authentication)
- PackyCode (partner integration)
- Custom endpoint support
- **Deep link support** - Import Gemini providers via `ccswitch://` protocol
- **System tray integration** - Quick-switch Gemini providers from tray menu
- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`
#### MCP v3.7.0 Unified Architecture
- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport type** - New Server-Sent Events support alongside stdio/http
- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats
- **Extended field support** - Preserve custom fields in Codex TOML conversion
- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)
- **Import/export system** - Unified import from Claude/Codex/Gemini live configs
- **UX improvements**
- Default app selection in forms
- JSON formatter for config validation
- Improved layout and visual hierarchy
- Better validation error messages
#### Claude Skills Management System
- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos
- **Pre-configured repositories**
- `ComposioHQ/awesome-claude-skills` (curated collection)
- `anthropics/skills` (official Anthropic skills)
- `cexll/myclaude` (community, with subdirectory scanning)
- **Lifecycle management**
- One-click install to `~/.claude/skills/`
- Safe uninstall with state tracking
- Update checking (infrastructure ready)
- **Custom repository support** - Add any GitHub repo as a skill source
- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories
- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration
- **Frontend interface**
- SkillsPage: Browse and manage skills
- SkillCard: Visual skill presentation
- RepoManager: Repository management dialog
- **State persistence** - Installation state stored in `skills.json`
- **Full i18n support** - Complete Chinese/English translations (47+ keys)
#### Prompts (System Prompts) Management
- **Multi-preset management** - Create, edit, and switch between multiple system prompts
- **Cross-app support**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting
- **Smart synchronization**
- Auto-write to live files on enable
- Content backfill protection (save current before switching)
- First-launch auto-import from live files
- **Single-active enforcement** - Only one prompt can be active at a time
- **Delete protection** - Cannot delete active prompts
- **Backend service** - `PromptService` (213 lines) with CRUD operations
- **Frontend components**
- PromptPanel: Main management interface (177 lines)
- PromptFormModal: Edit dialog with validation (160 lines)
- MarkdownEditor: CodeMirror integration (159 lines)
- usePromptActions: Business logic hook (152 lines)
- **Full i18n support** - Complete Chinese/English translations (41+ keys)
#### Deep Link Protocol (ccswitch://)
- **Protocol registration** - `ccswitch://` URL scheme for one-click imports
- **Provider import** - Import provider configurations from URLs or shared links
- **Lifecycle integration** - Deep link handling integrated into app startup
- **Cross-platform support** - Works on Windows, macOS, and Linux
#### Environment Variable Conflict Detection
- **Claude & Codex detection** - Identify conflicting environment variables
- **Gemini auto-detection** - Automatic environment variable discovery
- **Conflict management** - UI for resolving configuration conflicts
- **Prevention system** - Warn before overwriting existing configurations
### New Features
#### Provider Management
- **DouBaoSeed preset** - Added ByteDance's DouBao provider
- **Kimi For Coding** - Moonshot AI coding assistant
- **BaiLing preset** - BaiLing AI integration
- **Removed AnyRouter preset** - Discontinued provider
- **Model configuration** - Support for custom model names in Codex and Gemini
- **Provider notes field** - Add custom notes to providers for better organization
#### Configuration Management
- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`
- **Unified persistence** - Common config snippets now shared across all apps
- **Auto-import on first launch** - Automatically import configs from live files on first run
- **Backfill priority fix** - Correct priority handling when enabling prompts
#### UI/UX Improvements
- **macOS native design** - Migrated color scheme to macOS native design system
- **Window centering** - Default window position centered on screen
- **Password input fixes** - Disabled Edge/IE reveal and clear buttons
- **URL overflow prevention** - Fixed overflow in provider cards
- **Error notification enhancement** - Copy-to-clipboard for error messages
- **Tray menu sync** - Real-time sync after drag-and-drop sorting
### Improvements
#### Architecture
- **MCP v3.7.0 cleanup** - Removed legacy code and warnings
- **Unified structure** - Default initialization with v3.7.0 unified structure
- **Backward compatibility** - Compilation fixes for older configs
- **Code formatting** - Applied consistent formatting across backend and frontend
#### Platform Compatibility
- **Windows fix** - Resolved winreg API compatibility issue (v0.52)
- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu
#### Configuration
- **MCP sync on switch** - Sync MCP configs for all apps when switching providers
- **Gemini form sync** - Fixed form fields syncing with environment editor
- **Gemini config reading** - Read from both `.env` and `settings.json`
- **Validation improvements** - Enhanced input validation and boundary checks
#### Internationalization
- **JSON syntax fixes** - Resolved syntax errors in locale files
- **App name i18n** - Added internationalization support for app names
- **Deduplicated labels** - Reused providerForm keys to reduce duplication
- **Gemini MCP title** - Added missing Gemini MCP panel title
### Bug Fixes
#### Critical Fixes
- **Usage script validation** - Added input validation and boundary checks
- **Gemini validation** - Relaxed validation when adding providers
- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors
- **MCP field preservation** - Preserve custom fields in Codex TOML editor
- **Password input** - Fixed white screen crash (FormLabel → Label)
#### Stability
- **Tray menu safety** - Replaced unwrap with safe pattern matching
- **Error isolation** - Tray menu update failures don't block main operations
- **Import classification** - Set category to custom for imported default configs
#### UI Fixes
- **Model placeholders** - Removed misleading model input placeholders
- **Base URL population** - Auto-fill base URL for non-official providers
- **Drag sort sync** - Fixed tray menu order after drag-and-drop
### Technical Improvements
#### Code Quality
- **Type safety** - Complete TypeScript type coverage across codebase
- **Test improvements** - Simplified boolean assertions in tests
- **Clippy warnings** - Fixed `uninlined_format_args` warnings
- **Code refactoring** - Extracted templates, optimized logic flows
#### Dependencies
- **Tauri** - Updated to 2.8.x series
- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills
- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor
- **winreg** - Updated to v0.52 (Windows compatibility)
#### Performance
- **Startup optimization** - Removed legacy migration scanning
- **Lock management** - Improved RwLock usage to prevent deadlocks
- **Background query** - Enabled background mode for usage polling
### Statistics
- **Total commits**: 85 commits from v3.6.0 to v3.7.0
- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)
- **New modules**:
- Skills: 2,034 lines (21 files)
- Prompts: 1,302 lines (20 files)
- Gemini: ~1,000 lines (multiple files)
- MCP refactor: ~3,000 lines (refactored)
### Strategic Positioning
v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI CLI Management Platform"**:
1. **Capability Extension** - Skills provide external ability integration
2. **Behavior Customization** - Prompts enable AI personality presets
3. **Configuration Unification** - MCP v3.7.0 eliminates app silos
4. **Ecosystem Openness** - Deep links enable community sharing
5. **Multi-AI Support** - Claude/Codex/Gemini trinity
6. **Intelligent Detection** - Auto-discovery of environment conflicts
### Notes
- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration
- Skills and Prompts management are new features requiring no migration
- Gemini CLI support requires Gemini CLI to be installed separately
- MCP v3.7.0 unified structure is backward compatible with previous configs
## [3.6.0] - 2025-11-07
### ✨ New Features
- **Provider Duplicate** - Quick duplicate existing provider configurations for easy variant creation
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
- **Custom Endpoint Management** - Support multi-endpoint configuration for aggregator providers
- **Usage Query Enhancements**
- Auto-refresh interval: Support periodic automatic usage query
- Test Script API: Validate JavaScript scripts before execution
- Template system expansion: Custom blank template, support for access token and user ID parameters
- **Configuration Editor Improvements**
- Add JSON format button
- Real-time TOML syntax validation for Codex configuration
- **Auto-sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to new directory without manual operation
- **Load Live Config When Editing Active Provider** - When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications
- **New Provider Presets** - DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
- **Partner Promotion Mechanism** - Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)
### 🔧 Improvements
- **Configuration Directory Switching**
- Introduced unified post-change sync utility (`postChangeSync.ts`)
- Auto-sync current providers to new directory when changing Claude/Codex config directories
- Perfect support for WSL environment switching
- Auto-sync after config import to ensure immediate effectiveness
- Use Result pattern for graceful error handling without blocking main flow
- Distinguish "fully successful" and "partially successful" states for precise user feedback
- **UI/UX Enhancements**
- Provider cards: Unique icons and color identification
- Unified border design system across all components
- Drag interaction optimization: Push effect animation, improved handle icons
- Enhanced current provider visual feedback
- Dialog size standardization and layout consistency
- Form experience: Optimized model placeholders, simplified provider hints, category-specific hints
- **Complete Internationalization Coverage**
- Error messages internationalization
- Tray menu internationalization
- All UI components internationalization
- **Usage Display Moved Inline** - Usage display moved next to enable button
### 🐛 Bug Fixes
- **Configuration Sync**
- Fixed `apiKeyUrl` priority issue
- Fixed MCP sync-to-other-side functionality failure
- Fixed sync issues after config import
- Prevent silent fallback and data loss on config error
- **Usage Query**
- Fixed auto-query interval timing issue
- Ensure refresh button shows loading animation on click
- **UI Issues**
- Fixed name collision error (`get_init_error` command)
- Fixed language setting rollback after successful save
- Fixed language switch state reset (dependency cycle)
- Fixed edit mode button alignment
- **Configuration Management**
- Fixed Codex API Key auto-sync
- Fixed endpoint speed test functionality
- Fixed provider duplicate insertion position (next to original provider)
- Fixed custom endpoint preservation in edit mode
- **Startup Issues**
- Force exit on config error (no silent fallback)
- Eliminate code duplication causing initialization errors
### 🏗️ Technical Improvements (For Developers)
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
- **Stage 3**: Component splitting and business logic extraction
- **Stage 4**: Code cleanup and formatting unification
**Testing System**:
- Hooks unit tests 100% coverage
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
- MSW mocking backend API to ensure test independence
**Code Quality**:
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
- `AppType` renamed to `AppId`: Semantically clearer
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
- Eliminate code duplication: DRY violations cleanup
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
**Internal Optimizations**:
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
-**Impact**: Improved startup performance, cleaner code
-**Compatibility**: v2 format configs fully compatible, no action required
- ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6
- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)
-**Impact**: More standardized code, friendlier error prompts
-**Compatibility**: Frontend fully adapted, users don't need to care about this change
### 📦 Dependencies
- Updated to Tauri 2.8.x
- Updated to TailwindCSS 4.x
- Updated to TanStack Query v5.90.x
- Maintained React 18.2.x and TypeScript 5.3.x
## [3.5.0] - 2025-01-15
### ⚠ Breaking Changes
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`
### ✨ New Features
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
- Add, edit, delete, and toggle MCP servers in `~/.claude.json`
- Support for stdio and http server types with command validation
- Built-in templates for popular MCP servers (mcp-fetch, etc.)
- Real-time enable/disable toggle for MCP servers
- Atomic file writing to prevent configuration corruption
- **Configuration Import/Export** - Backup and restore your provider configurations
- Export all configurations to JSON file with one click
- Import configurations with validation and automatic backup
- Automatic backup rotation (keeps 10 most recent backups)
- Progress modal with detailed status feedback
- **Endpoint Speed Testing** - Test API endpoint response times
- Measure latency to different provider endpoints
- Visual indicators for connection quality
- Help users choose the fastest provider
### 🔧 Improvements
- Complete internationalization (i18n) coverage for all UI components
- Enhanced error handling and user feedback throughout the application
- Improved configuration file management with better validation
- Added new provider presets: Longcat, kat-coder
- Updated GLM provider configurations with latest models
- Refined UI/UX with better spacing, icons, and visual feedback
- Enhanced tray menu functionality and responsiveness
- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:
- macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`
- Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`
- Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`
### 🐛 Bug Fixes
- Fixed layout shifts during provider switching
- Improved config file path handling across different platforms
- Better error messages for configuration validation failures
- Fixed various edge cases in configuration import/export
### 📦 Technical Details
- Enhanced `import_export.rs` module with backup management
- New `claude_mcp.rs` module for MCP configuration handling
- Improved state management and lock handling in Rust backend
- Better TypeScript type safety across the codebase
## [3.4.0] - 2025-10-01
### ✨ Features
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
- Support portable mode launches and enforce a single running instance to avoid conflicts
### 🔧 Improvements
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
### 🐛 Fixes
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
- Fix layout shifts while switching app types with Claude plugin sync enabled
- Align Enable/In Use button states to avoid visual jank across app views
## [3.3.0] - 2025-09-22
### ✨ Features
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants _(Removed in 3.4.x)_
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
### 🔧 Improvements
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
- Add a `created_at` timestamp to provider records for future sorting and analytics
### 🐛 Fixes
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
- Bundle `@codemirror/lint` to reinstate live linting in config editors
@@ -459,13 +27,11 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
## [3.2.0] - 2025-09-13
### ✨ New Features
- System tray provider switching with dynamic menu for Claude/Codex
- Frontend receives `provider-switched` events and refreshes active app
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
### 🔧 Improvements
- Single source of truth for provider configs; no duplicate copy files
- One-time migration imports existing copies into `config.json` and archives originals
- Duplicate provider de-duplication by name + API key at startup
@@ -474,35 +40,29 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
- Tailwind v4 integration and refined dark mode handling
### 🐛 Fixes
- Remove/minimize debug console logs in production builds
- Fix CSS minifier warnings for scrollbar pseudo-elements
- Prettier formatting across codebase for consistent style
### 📦 Dependencies
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
### 🔄 Notes
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
## [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
@@ -519,14 +79,12 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
- 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
@@ -534,7 +92,6 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
## [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**
@@ -542,14 +99,12 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
- **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
@@ -557,34 +112,28 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
- 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
#### 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
- 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
@@ -592,7 +141,6 @@ For users upgrading from v2.x (Electron version):
## [2.0.0] - Previous Electron Release
### Features
- Multi-provider configuration management
- Quick provider switching
- Import/export configurations
@@ -603,44 +151,6 @@ For users upgrading from v2.x (Electron version):
## [1.0.0] - Initial Release
### Features
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes
- **Runtime auto-migration from v1 to v2 config format has been removed**
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
- When a v1 config is detected, the app now returns a clear error with migration instructions
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
- **Legacy v1 copy file migration logic has been removed**
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
- No longer archives copy files or performs automatic deduplication
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
- **Tauri commands now only accept `app` parameter**
- Removed legacy `app_type`/`appType` compatibility paths
- Explicit error with available values when unknown `app` is provided
### 🔧 Improvements
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
- Simplified startup logic: Only ensures config structure exists, no migration overhead
### 🧪 Tests
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
- Added comprehensive config loading tests:
- `load_v1_config_returns_error_and_does_not_write`
- `load_v1_with_extra_version_still_treated_as_v1`
- `load_invalid_json_returns_parse_error_and_does_not_write`
- `load_valid_v2_config_succeeds`

468
README.md
View File

@@ -1,437 +1,199 @@
<div align="center">
# Claude Code & Codex 供应商切换器
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。
**From Provider Switcher to All-in-One AI CLI Management Platform**
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
</div>
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## Sponsor
## 功能特性v3.3.0
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
- **VS Code Codex 插件一键配置**:供应商卡片支持「应用到 VS Code / 从 VS Code 移除」,默认开启自动同步,并可跨 Code / Insiders / VSCodium 写入 `settings.json`
- **通用配置片段**Claude 与 Codex 共用 JSON/TOML 片段,提供编辑器 lint、内容校验、统一错误提示与本地持久化
- **Codex 配置向导**:新增显示名称、专用 API Key URL、HTML5 校验与预设模板,方便快速配置第三方服务
- **系统托盘与快捷操作**:窗口隐藏时仍可通过托盘切换供应商,并在自动同步开启时触发 VS Code 写入
- **平台适配**:新增 Windows WSL 环境支持、Linux 自动禁用模态背景模糊解决白屏问题、macOS Dock 点击即可恢复窗口
- **UI优化**:多处 UI 和使用体验优化
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
## 界面预览
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
### 主界面
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
![主界面](screenshots/main.png)
---
### 添加供应商
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
![添加供应商](screenshots/add.png)
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr>
## 下载安装
</table>
### 系统要求
## Screenshots
- **Windows**: Windows 10 及以上
- **macOS**: macOS 10.15 (Catalina) 及以上
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
| Main Interface | Add Provider |
| :-----------------------------------------------: | :--------------------------------------------: |
| ![Main Interface](assets/screenshots/main-en.png) | ![Add Provider](assets/screenshots/add-en.png) |
### Windows 用户
## Features
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
### macOS 用户
**v3.7.0 Major Update (2025-11-19)**
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
**Six Core Features, 18,000+ Lines of New Code**
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
- **Gemini CLI Integration**
- Third supported AI CLI (Claude Code / Codex / Gemini)
- Dual-file configuration support (`.env` + `settings.json`)
- Complete MCP server management
- Presets: Google Official (OAuth) / PackyCode / Custom
### Linux 用户
- **Claude Skills Management System**
- Auto-scan skills from GitHub repositories (3 pre-configured curated repos)
- One-click install/uninstall to `~/.claude/skills/`
- Custom repository support + subdirectory scanning
- Complete lifecycle management (discover/install/update)
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包或者 `AppImage`安装包。
- **Prompts Management System**
- Multi-preset system prompt management (unlimited presets, quick switching)
- Cross-app support (Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
- Markdown editor (CodeMirror 6 + real-time preview)
- Smart backfill protection, preserves manual modifications
## 使用说明
- **MCP v3.7.0 Unified Architecture**
- Single panel manages MCP servers across three applications
- New SSE (Server-Sent Events) transport type
- Smart JSON parser + Codex TOML format auto-correction
- Unified import/export + bidirectional sync
1. 点击"添加供应商"添加你的 API 配置
2. 切换方式:
- 在主界面选择供应商后点击切换
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
3. 切换会写入对应应用的“live 配置文件”Claude`settings.json`Codex`auth.json` + `config.toml`
4. 重启或新开终端以确保生效
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
- **Deep Link Protocol**
- `ccswitch://` protocol registration (all platforms)
- One-click import provider configs via shared links
- Security validation + lifecycle integration
### 检查更新
- **Environment Variable Conflict Detection**
- Auto-detect cross-app configuration conflicts (Claude/Codex/Gemini/MCP)
- Visual conflict indicators + resolution suggestions
- Override warnings + backup before changes
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
**Core Capabilities**
### Codex 说明SSOT
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
- **Claude Plugin Sync**: One-click apply/restore Claude plugin configurations
- 配置目录:`~/.codex/`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
**v3.6 Highlights**
### Claude Code 说明SSOT
- Provider duplication & drag-and-drop sorting
- Multi-endpoint management & custom config directory (cloud sync ready)
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
- WSL environment support with auto-sync on directory change
- 100% hooks test coverage & complete architecture refactoring
- 配置目录:`~/.claude/`
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
**System Features**
### 迁移与归档(自 v3.2.0 起)
- System tray with quick switching
- Single instance daemon
- Built-in auto-updater
- Atomic writes with rollback protection
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重若当前为空将 live 合并项设为当前
- 归档与清理:
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
- 归档成功后删除原副本;失败则保留原文件(保守策略)
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
## Download & Installation
## 开发
### System Requirements
- **Windows**: Windows 10 and above
- **macOS**: macOS 10.15 (Catalina) and above
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
### Windows Users
Download the latest `CC-Switch-v{version}-Windows.msi` installer or `CC-Switch-v{version}-Windows-Portable.zip` portable version from the [Releases](../../releases) page.
### macOS Users
**Method 1: Install via Homebrew (Recommended)**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
**Method 2: Manual Download**
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
### ArchLinux 用户
**Install via paru (Recommended)**
```bash
paru -S cc-switch-bin
```
### Linux Users
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
## Quick Start
### Basic Usage
1. **Add Provider**: Click "Add Provider" → Choose preset or create custom configuration
2. **Switch Provider**:
- Main UI: Select provider → Click "Enable"
- System Tray: Click provider name directly (instant effect)
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
### MCP Management
- **Location**: Click "MCP" button in top-right corner
- **Add Server**:
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
- Support stdio / http / sse transport types
- Configure independent MCP servers for different apps
- **Enable/Disable**: Toggle switches to control which servers sync to live config
- **Sync**: Enabled servers auto-sync to each app's live files
- **Import/Export**: Import existing MCP servers from Claude/Codex/Gemini config files
### Skills Management (v3.7.0 New)
- **Location**: Click "Skills" button in top-right corner
- **Discover Skills**:
- Auto-scan pre-configured GitHub repositories (Anthropic official, ComposioHQ, community, etc.)
- Add custom repositories (supports subdirectory scanning)
- **Install Skills**: Click "Install" to one-click install to `~/.claude/skills/`
- **Uninstall Skills**: Click "Uninstall" to safely remove and clean up state
- **Manage Repositories**: Add/remove custom GitHub repositories
### Prompts Management (v3.7.0 New)
- **Location**: Click "Prompts" button in top-right corner
- **Create Presets**:
- Create unlimited system prompt presets
- Use Markdown editor to write prompts (syntax highlighting + real-time preview)
- **Switch Presets**: Select preset → Click "Activate" to apply immediately
- **Sync Mechanism**:
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Protection Mechanism**: Auto-save current prompt content before switching, preserves manual modifications
### Configuration Files
**Claude Code**
- Live config: `~/.claude/settings.json` (or `claude.json`)
- API key field: `env.ANTHROPIC_AUTH_TOKEN` or `env.ANTHROPIC_API_KEY`
- MCP servers: `~/.claude.json``mcpServers`
**Codex**
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
- API key field: `OPENAI_API_KEY` in `auth.json`
- MCP servers: `~/.codex/config.toml``[mcp_servers]` tables
**Gemini**
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
- API key field: `GEMINI_API_KEY` or `GOOGLE_GEMINI_API_KEY` in `.env`
- Environment variables: Support `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- MCP servers: `~/.gemini/settings.json``mcpServers`
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
**CC Switch Storage**
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
- Settings: `~/.cc-switch/settings.json`
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
### Cloud Sync Setup
1. Go to Settings → "Custom Configuration Directory"
2. Choose your cloud sync folder (Dropbox, OneDrive, iCloud, etc.)
3. Restart app to apply
4. Repeat on other devices to enable cross-device sync
> **Note**: First launch auto-imports existing Claude/Codex configs as default provider.
## Architecture Overview
### Design Principles
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ Backend (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**Core Design Patterns**
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
- **Layered Architecture**: Clear separation (Commands → Services → Models)
**Key Components**
- **ProviderService**: Provider CRUD, switching, backfill, sorting
- **McpService**: MCP server management, import/export, live file sync
- **ConfigService**: Config import/export, backup rotation
- **SpeedtestService**: API endpoint latency measurement
**v3.6 Refactoring**
- Backend: 5-phase refactoring (error handling → command split → tests → services → concurrency)
- Frontend: 4-stage refactoring (test infra → hooks → components → cleanup)
- Testing: 100% hooks coverage + integration tests (vitest + MSW)
## Development
### Environment Requirements
### 环境要求
- Node.js 18+
- pnpm 8+
- Rust 1.85+
- Tauri CLI 2.8+
- Rust 1.75+
- Tauri CLI 2.0+
### Development Commands
### 开发命令
```bash
# Install dependencies
# 安装依赖
pnpm install
# Dev mode (hot reload)
# 开发模式(热重载)
pnpm dev
# Type check
# 类型检查
pnpm typecheck
# Format code
# 代码格式化
pnpm format
# Check code format
# 检查代码格式
pnpm format:check
# Run frontend unit tests
pnpm test:unit
# Run tests in watch mode (recommended for development)
pnpm test:unit:watch
# Build application
# 构建应用
pnpm build
# Build debug version
# 构建调试版本
pnpm tauri build --debug
```
### Rust Backend Development
### Rust 后端开发
```bash
cd src-tauri
# Format Rust code
# 格式化 Rust 代码
cargo fmt
# Run clippy checks
# 运行 clippy 检查
cargo clippy
# Run backend tests
# 运行测试
cargo test
# Run specific tests
cargo test test_name
# Run tests with test-hooks feature
cargo test --features test-hooks
```
### Testing Guide (v3.6 New)
## 技术栈
**Frontend Testing**:
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon
- **[React 18](https://react.dev/)** - 用户界面库
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
- Uses **vitest** as test framework
- Uses **MSW (Mock Service Worker)** to mock Tauri API calls
- Uses **@testing-library/react** for component testing
**Test Coverage**:
- Hooks unit tests (100% coverage)
- `useProviderActions` - Provider operations
- `useMcpActions` - MCP management
- `useSettings` series - Settings management
- `useImportExport` - Import/export
- Integration tests
- App main application flow
- SettingsDialog complete interaction
- MCP panel functionality
**Running Tests**:
```bash
# Run all tests
pnpm test:unit
# Watch mode (auto re-run)
pnpm test:unit:watch
# With coverage report
pnpm test:unit --coverage
```
## Tech Stack
**Frontend**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
**Backend**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
**Testing**: vitest · MSW · @testing-library/react
## Project Structure
## 项目结构
```
├── src/ # Frontend (React + TypeScript)
│ ├── components/ # UI components (providers/settings/mcp/ui)
│ ├── hooks/ # Custom hooks (business logic)
│ ├── lib/
│ ├── api/ # Tauri API wrapper (type-safe)
│ │ └── query/ # TanStack Query config
│ ├── i18n/locales/ # Translations (zh/en)
│ ├── config/ # Presets (providers/mcp)
└── types/ # TypeScript definitions
├── src-tauri/ # Backend (Rust)
│ └── src/
├── commands/ # Tauri command layer (by domain)
├── services/ # Business logic layer
│ ├── app_config.rs # Config data models
│ ├── provider.rs # Provider domain models
│ ├── mcp.rs # MCP sync & validation
│ └── lib.rs # App entry & tray menu
├── tests/ # Frontend tests
│ ├── hooks/ # Unit tests
│ └── components/ # Integration tests
└── assets/ # Screenshots & partner resources
├── 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
## 更新日志
See [CHANGELOG.md](CHANGELOG.md) for version update details.
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
## Legacy Electron Version
## Electron 旧版
[Releases](../../releases) retains v2.0.3 legacy Electron version
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
If you need legacy Electron code, you can pull the electron-legacy branch
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
## Contributing
## 贡献
Issues and suggestions are welcome!
Before submitting PRs, please ensure:
- Pass type check: `pnpm typecheck`
- Pass format check: `pnpm format:check`
- Pass unit tests: `pnpm test:unit`
- 💡 For new features, please open an issue for discussion before submitting a PR
欢迎提交 Issue 和 Pull Request
## Star History

View File

@@ -1,442 +0,0 @@
<div align="center">
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
</div>
## ❤️赞助商
![智谱 GLM](assets/partners/banners/glm-zh.jpg)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
---
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目PackyCode 是一家稳定、高效的API中转服务商提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码可以享受9折优惠。</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
</table>
## 界面预览
| 主界面 | 添加供应商 |
| :---------------------------------------: | :------------------------------------------: |
| ![主界面](assets/screenshots/main-zh.png) | ![添加供应商](assets/screenshots/add-zh.png) |
## 功能特性
### 当前版本v3.7.0 | [完整更新日志](CHANGELOG.md)
**v3.7.0 重大更新2025-11-19**
**六大核心功能18,000+ 行新增代码**
- **Gemini CLI 集成**
- 第三个支持的 AI CLIClaude Code / Codex / Gemini
- 双文件配置支持(`.env` + `settings.json`
- 完整 MCP 服务器管理
- 预设Google Official (OAuth) / PackyCode / 自定义
- **Claude Skills 管理系统**
- 从 GitHub 仓库自动扫描技能(预配置 3 个精选仓库)
- 一键安装/卸载到 `~/.claude/skills/`
- 自定义仓库支持 + 子目录扫描
- 完整生命周期管理(发现/安装/更新)
- **Prompts 管理系统**
- 多预设系统提示词管理(无限数量,快速切换)
- 跨应用支持Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`
- Markdown 编辑器CodeMirror 6 + 实时预览)
- 智能回填保护,保留手动修改
- **MCP v3.7.0 统一架构**
- 单一面板管理三个应用的 MCP 服务器
- 新增 SSE (Server-Sent Events) 传输类型
- 智能 JSON 解析器 + Codex TOML 格式自动修正
- 统一导入/导出 + 双向同步
- **深度链接协议**
- `ccswitch://` 协议注册(全平台)
- 通过共享链接一键导入供应商配置
- 安全验证 + 生命周期集成
- **环境变量冲突检测**
- 自动检测跨应用配置冲突Claude/Codex/Gemini/MCP
- 可视化冲突指示器 + 解决建议
- 覆盖警告 + 更改前备份
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**完整的中英文本地化UI、错误、托盘
- **Claude 插件同步**:一键应用或恢复 Claude 插件配置
**v3.6 亮点**
- 供应商复制 & 拖拽排序
- 多端点管理 & 自定义配置目录(支持云同步)
- 细粒度模型配置四层Haiku/Sonnet/Opus/自定义)
- WSL 环境支持,配置目录切换自动同步
- 100% hooks 测试覆盖 & 完整架构重构
**系统功能**
- 系统托盘快速切换
- 单实例守护
- 内置自动更新器
- 原子写入与回滚保护
## 下载安装
### 系统要求
- **Windows**: Windows 10 及以上
- **macOS**: macOS 10.15 (Catalina) 及以上
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
### Windows 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
### macOS 用户
**方式一:通过 Homebrew 安装(推荐)**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
**方式二:手动下载**
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### ArchLinux 用户
**通过 paru 安装(推荐)**
```bash
paru -S cc-switch-bin
```
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
## 快速开始
### 基本使用
1. **添加供应商**:点击"添加供应商" → 选择预设或创建自定义配置
2. **切换供应商**
- 主界面:选择供应商 → 点击"启用"
- 系统托盘:直接点击供应商名称(立即生效)
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
4. **恢复官方登录**:选择"官方登录"预设Claude/Codex或"Google 官方"预设Gemini重启对应客户端后按照其登录/OAuth 流程操作
### MCP 管理
- **位置**:点击右上角"MCP"按钮
- **添加服务器**
- 使用内置模板mcp-fetch、mcp-filesystem 等)
- 支持 stdio / http / sse 三种传输类型
- 为不同应用配置独立的 MCP 服务器
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
- **同步**:启用的服务器自动同步到各应用的 live 文件
- **导入/导出**:支持从 Claude/Codex/Gemini 配置文件导入现有 MCP 服务器
### Skills 管理v3.7.0 新增)
- **位置**:点击右上角"Skills"按钮
- **发现技能**
- 自动扫描预配置的 GitHub 仓库Anthropic 官方、ComposioHQ、社区等
- 添加自定义仓库(支持子目录扫描)
- **安装技能**:点击"安装"一键安装到 `~/.claude/skills/`
- **卸载技能**:点击"卸载"安全移除并清理状态
- **管理仓库**:添加/删除自定义 GitHub 仓库
### Prompts 管理v3.7.0 新增)
- **位置**:点击右上角"Prompts"按钮
- **创建预设**
- 创建无限数量的系统提示词预设
- 使用 Markdown 编辑器编写提示词(语法高亮 + 实时预览)
- **切换预设**:选择预设 → 点击"激活"立即应用
- **同步机制**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
### 配置文件
**Claude Code**
- Live 配置:`~/.claude/settings.json`(或 `claude.json`
- API key 字段:`env.ANTHROPIC_AUTH_TOKEN``env.ANTHROPIC_API_KEY`
- MCP 服务器:`~/.claude.json``mcpServers`
**Codex**
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
- MCP 服务器:`~/.codex/config.toml``[mcp_servers]`
**Gemini**
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式)
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY``GOOGLE_GEMINI_API_KEY`
- 环境变量:支持 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等自定义变量
- MCP 服务器:`~/.gemini/settings.json``mcpServers`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
**CC Switch 存储**
- 主配置SSOT`~/.cc-switch/config.json`包含供应商、MCP、Prompts 预设等)
- 设置:`~/.cc-switch/settings.json`
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
### 云同步设置
1. 前往设置 → "自定义配置目录"
2. 选择您的云同步文件夹Dropbox、OneDrive、iCloud、坚果云等
3. 重启应用以应用
4. 在其他设备上重复操作以启用跨设备同步
> **注意**:首次启动会自动导入现有 Claude/Codex 配置作为默认供应商。
## 架构总览
### 设计原则
```
┌─────────────────────────────────────────────────────────────┐
│ 前端 (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ UI │──│ (业务逻辑) │──│ (缓存/同步) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ 后端 (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ API 层) │──│ (业务层) │──│ (数据) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**核心设计模式**
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
- **原子写入**:临时文件 + 重命名模式防止配置损坏
- **并发安全**RwLock 与作用域守卫避免死锁
- **分层架构**清晰分离Commands → Services → Models
**核心组件**
- **ProviderService**:供应商增删改查、切换、回填、排序
- **McpService**MCP 服务器管理、导入导出、live 文件同步
- **ConfigService**:配置导入导出、备份轮换
- **SpeedtestService**API 端点延迟测量
**v3.6 重构**
- 后端5 阶段重构(错误处理 → 命令拆分 → 测试 → 服务 → 并发)
- 前端4 阶段重构(测试基础 → hooks → 组件 → 清理)
- 测试100% hooks 覆盖 + 集成测试vitest + MSW
## 开发
### 环境要求
- Node.js 18+
- pnpm 8+
- Rust 1.85+
- Tauri CLI 2.8+
### 开发命令
```bash
# 安装依赖
pnpm install
# 开发模式(热重载)
pnpm dev
# 类型检查
pnpm typecheck
# 代码格式化
pnpm format
# 检查代码格式
pnpm format:check
# 运行前端单元测试
pnpm test:unit
# 监听模式运行测试(推荐开发时使用)
pnpm test:unit:watch
# 构建应用
pnpm build
# 构建调试版本
pnpm tauri build --debug
```
### Rust 后端开发
```bash
cd src-tauri
# 格式化 Rust 代码
cargo fmt
# 运行 clippy 检查
cargo clippy
# 运行后端测试
cargo test
# 运行特定测试
cargo test test_name
# 运行带测试 hooks 的测试
cargo test --features test-hooks
```
### 测试说明v3.6 新增)
**前端测试**
- 使用 **vitest** 作为测试框架
- 使用 **MSW (Mock Service Worker)** 模拟 Tauri API 调用
- 使用 **@testing-library/react** 进行组件测试
**测试覆盖**
- Hooks 单元测试100% 覆盖)
- `useProviderActions` - 供应商操作
- `useMcpActions` - MCP 管理
- `useSettings` 系列 - 设置管理
- `useImportExport` - 导入导出
- 集成测试
- App 主应用流程
- SettingsDialog 完整交互
- MCP 面板功能
**运行测试**
```bash
# 运行所有测试
pnpm test:unit
# 监听模式(自动重跑)
pnpm test:unit:watch
# 带覆盖率报告
pnpm test:unit --coverage
```
## 技术栈
**前端**React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
**后端**Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
**测试**vitest · MSW · @testing-library/react
## 项目结构
```
├── src/ # 前端 (React + TypeScript)
│ ├── components/ # UI 组件 (providers/settings/mcp/ui)
│ ├── hooks/ # 自定义 hooks (业务逻辑)
│ ├── lib/
│ │ ├── api/ # Tauri API 封装(类型安全)
│ │ └── query/ # TanStack Query 配置
│ ├── i18n/locales/ # 翻译 (zh/en)
│ ├── config/ # 预设 (providers/mcp)
│ └── types/ # TypeScript 类型定义
├── src-tauri/ # 后端 (Rust)
│ └── src/
│ ├── commands/ # Tauri 命令层(按领域)
│ ├── services/ # 业务逻辑层
│ ├── app_config.rs # 配置数据模型
│ ├── provider.rs # 供应商领域模型
│ ├── mcp.rs # MCP 同步与校验
│ └── lib.rs # 应用入口 & 托盘菜单
├── tests/ # 前端测试
│ ├── hooks/ # 单元测试
│ └── components/ # 集成测试
└── assets/ # 截图 & 合作商资源
```
## 更新日志
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
## Electron 旧版
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
## 贡献
欢迎提交 Issue 反馈问题和建议!
提交 PR 前请确保:
- 通过类型检查:`pnpm typecheck`
- 通过格式检查:`pnpm format:check`
- 通过单元测试:`pnpm test:unit`
- 💡 新功能开发前,欢迎先开 issue 讨论实现方案
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)
## License
MIT © Jason Young

View File

@@ -67,7 +67,7 @@ src/
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
- ✅ ProviderList.tsx - 供应商列表
- ✅ LanguageSwitcher.tsx - 语言切换器
- ✅ settings/SettingsDialog.tsx - 设置对话框
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
## 注意事项

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +0,0 @@
# CC Switch Rust 后端重构方案
## 目录
- [背景与现状](#背景与现状)
- [问题确认](#问题确认)
- [方案评估](#方案评估)
- [渐进式重构路线](#渐进式重构路线)
- [测试策略](#测试策略)
- [风险与对策](#风险与对策)
- [总结](#总结)
## 背景与现状
- 前端已完成重构,后端 (Tauri + Rust) 仍维持历史结构。
- 核心文件集中在 `src-tauri/src/commands.rs``lib.rs` 等超大文件中,业务逻辑与界面事件耦合严重。
- 测试覆盖率低,只有零散单元测试,缺乏集成验证。
## 问题确认
| 提案问题 | 实际情况 | 严重程度 |
| --- | --- | --- |
| `commands.rs` 过长 | ✅ 1526 行,包含 32 个命令,职责混杂 | 🔴 高 |
| `lib.rs` 缺少服务层 | ✅ 541 行,托盘/事件/业务逻辑耦合 | 🟡 中 |
| `Result<T, String>` 泛滥 | ✅ 118 处,错误上下文丢失 | 🟡 中 |
| 全局 `Mutex` 阻塞 | ✅ 31 处 `.lock()` 调用,读写不分离 | 🟡 中 |
| 配置逻辑分散 | ✅ 分布在 5 个文件 (`config`/`app_config`/`app_store`/`settings`/`codex_config`) | 🟢 低 |
代码规模分布(约 5.4k SLOC
- `commands.rs`: 1526 行28%)→ 第一优先级 🎯
- `lib.rs`: 541 行10%)→ 托盘逻辑与业务耦合
- `mcp.rs`: 732 行14%)→ 相对清晰
- `migration.rs`: 431 行8%)→ 一次性逻辑
- 其他文件合计2156 行40%
## 方案评估
### ✅ 优点
1. **分层架构清晰**
- `commands/`Tauri 命令薄层
- `services/`业务流程如供应商切换、MCP 同步
- `infrastructure/`:配置读写、外设交互
- `domain/`:数据模型 (`Provider`, `AppType` 等)
→ 提升可测试性、降低耦合度、方便团队协作。
2. **统一错误处理**
- 引入 `AppError``thiserror`),保留错误链和上下文。
- Tauri 命令仍返回 `Result<T, String>`,通过 `From<AppError>` 自动转换。
- 改善日志可读性,利于排查。
3. **并发优化**
- `AppState` 切换为 `RwLock<MultiAppConfig>`
- 读多写少的场景提升吞吐(如频繁查询供应商列表)。
### ⚠️ 风险
1. **过度设计**
- 完整 DDD 四层在 5k 行项目中会增加 30-50% 维护成本。
- Rust trait + repository 样板较多,收益不足。
- 推荐“轻量分层”而非正统 DDD。
2. **迁移成本高**
- `commands.rs` 拆分、错误统一、锁改造触及多文件。
- 测试缺失导致重构风险高,需先补测试。
- 估算完整改造需 5-6 周;建议分阶段输出可落地价值。
3. **技术选型需谨慎**
- `parking_lot` 相比标准库 `RwLock` 提升有限,不必引入。
- `spawn_blocking` 仅用于 >100ms 的阻塞任务,避免滥用。
- 以现有依赖为主,控制复杂度。
## 实施进度
- **阶段 1统一错误处理 ✅**
- 引入 `thiserror` 并在 `src-tauri/src/error.rs` 定义 `AppError`,提供常用构造函数和 `From<AppError> for String`,保留错误链路。
- 配置、存储、同步等核心模块(`config.rs``app_config.rs``app_store.rs``store.rs``codex_config.rs``claude_mcp.rs``claude_plugin.rs``import_export.rs``mcp.rs``migration.rs``speedtest.rs``usage_script.rs``settings.rs``lib.rs` 等)已统一返回 `Result<_, AppError>`,避免字符串错误丢失上下文。
- Tauri 命令层继续返回 `Result<_, String>`,通过 `?` + `Into<String>` 统一转换,前端无需调整。
- `cargo check` 通过,`rg "Result<[^>]+, String"` 巡检确认除命令层外已无字符串错误返回。
- **阶段 2拆分命令层 ✅**
- 已将单一 `src-tauri/src/commands.rs` 拆分为 `commands/{provider,mcp,config,settings,misc,plugin}.rs` 并通过 `commands/mod.rs` 统一导出,保持对外 API 不变。
- 每个文件聚焦单一功能域供应商、MCP、配置、设置、杂项、插件命令函数平均 150-250 行,可读性与后续维护性显著提升。
- 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。
- **阶段 3补充测试 ✅**
- `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。
- 扩展 `lib.rs` re-export暴露 `AppType``MultiAppConfig``AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。
- 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
- 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。
- 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。
- 补充 Claude 切换集成测试,验证 live `settings.json` 覆写、新旧供应商快照回填以及 `.cc-switch/config.json` 持久化结果,确保阶段四提取服务层时拥有可回归的用例。
- 增加 Codex 缺失 `auth` 场景测试,确认 `switch_provider_internal` 在关键字段缺失时返回带上下文的 `AppError`,同时保持内存状态未被污染。
- 为配置导入命令抽取复用逻辑 `import_config_from_path` 并补充成功/失败集成测试校验备份生成、状态同步、JSON 解析与文件缺失等错误回退路径;`export_config_to_file` 亦具备成功/缺失源文件的命令级回归。
- 新增 `tests/mcp_commands.rs`,通过测试钩子覆盖 `import_default_config``import_mcp_from_claude``set_mcp_enabled` 等命令层行为,验证缺失文件/非法 JSON 的错误回滚以及成功路径落盘效果;阶段三目标达成,命令层关键边界已具备回归保障。
- **阶段 4服务层抽象 🚧(进行中)**
- 新增 `services/provider.rs` 并实现 `ProviderService::switch` / `delete`集中处理供应商切换、回填、MCP 同步等核心业务;命令层改为薄封装并在 `tests/provider_service.rs``tests/provider_commands.rs` 中完成成功与失败路径的集成验证。
- 新增 `services/mcp.rs` 提供 `McpService`,封装 MCP 服务器的查询、增删改、启用同步与导入流程;命令层改为参数解析 + 调用服务,`tests/mcp_commands.rs` 直接使用 `McpService` 验证成功与失败路径,阶段三测试继续适配。
- `McpService` 在内部先复制内存快照、释放写锁,再执行文件同步,避免阶段五升级后的 `RwLock` 在 I/O 场景被长时间占用;`upsert/delete/set_enabled/sync_enabled` 均已修正。
- 新增 `services/config.rs` 提供 `ConfigService`,统一处理配置导入导出、备份与 live 同步;命令层迁移至 `commands/import_export.rs`,在落盘操作前释放锁并复用现有集成测试。
- 新增 `services/speedtest.rs` 并实现 `SpeedtestService::test_endpoints`,将 URL 校验、超时裁剪与网络请求封装在服务层,命令改为薄封装;补充单元测试覆盖空列表与非法 URL 分支。
- 后续可选应用设置Store命令仍较薄可按需评估是否抽象当前阶段四核心服务已基本齐备。
- **阶段 5锁与阻塞优化 ✅(首轮)**
- `AppState` 已由 `Mutex<MultiAppConfig>` 切换为 `RwLock<MultiAppConfig>`,托盘、命令与测试均按读写语义区分 `read()` / `write()``cargo test` 全量通过验证并未破坏现有流程。
- 针对高开销 IO 的配置导入/导出命令提取 `load_config_for_import`,并通过 `tauri::async_runtime::spawn_blocking` 将文件读写与备份迁至阻塞线程,保持命令处理线程轻量。
- 其余命令梳理后确认仍属轻量同步操作,暂不额外引入 `spawn_blocking`;若后续出现新的长耗时流程,再按同一模式扩展。
## 渐进式重构路线
### 阶段 1统一错误处理高收益 / 低风险)
- 新增 `src-tauri/src/error.rs`,定义 `AppError`
- 底层文件 IO、配置解析等函数返回 `Result<T, AppError>`
- 命令层通过 `?` 自动传播,最终 `.map_err(Into::into)`
- 预估 3-5 天,立即启动。
### 阶段 2拆分 `commands.rs`(高收益 / 中风险)
- 按业务拆分为 `commands/provider.rs``commands/mcp.rs``commands/config.rs``commands/settings.rs``commands/misc.rs`
- `commands/mod.rs` 统一导出和注册。
- 文件行数降低到 200-300 行/文件,职责单一。
- 预估 5-7 天,可并行进行部分重构。
### 阶段 3补充测试中收益 / 中风险)
- 引入 `tests/``src-tauri/tests/` 集成测试覆盖供应商切换、MCP 同步、配置迁移。
- 使用 `tempfile`/`tempdir` 隔离文件系统,组合少量回归脚本。
- 预估 5-7 天,为后续重构提供安全网。
### 阶段 4提取轻量服务层中收益 / 中风险)
- 新增 `services/provider_service.rs``services/mcp_service.rs`
- 不强制使用 trait直接以自由函数/结构体实现业务流程。
```rust
pub struct ProviderService;
impl ProviderService {
pub fn switch(config: &mut MultiAppConfig, app: AppType, id: &str) -> Result<(), AppError> {
// 业务流程:验证、回填、落盘、更新 current、触发事件
}
}
```
- 命令层负责参数解析,服务层处理业务逻辑,托盘逻辑重用同一接口。
- 预估 7-10 天,可在测试补齐后执行。
### 阶段 5锁与阻塞优化低收益 / 低风险)
- ✅ `AppState` 已从 `Mutex` 切换为 `RwLock`,命令与托盘读写按需区分,现有测试全部通过。
- ✅ 配置导入/导出命令通过 `spawn_blocking` 处理高开销文件 IO其他命令维持同步执行以避免不必要调度。
- 🔄 持续监控:若后续引入新的批量迁移或耗时任务,再按相同模式扩展到阻塞线程;观察运行时锁竞争情况,必要时考虑进一步拆分状态或引入缓存。
## 测试策略
- **优先覆盖场景**
- 供应商切换:状态更新 + live 配置同步
- MCP 同步enabled 服务器快照与落盘
- 配置迁移:归档、备份与版本升级
- **推荐结构**
```rust
#[cfg(test)]
mod integration {
use super::*;
#[test]
fn switch_provider_updates_live_config() { /* ... */ }
#[test]
fn sync_mcp_to_codex_updates_claude_config() { /* ... */ }
#[test]
fn migration_preserves_backup() { /* ... */ }
}
```
- 目标覆盖率:关键路径 >80%,文件 IO/迁移 >70%。
## 风险与对策
- **测试不足** → 阶段 3 强制补齐,建立基础集成测试。
- **重构跨度大** → 按阶段在独立分支推进(如 `refactor/backend-step1` 等)。
- **回滚困难** → 每阶段结束打 tag如 `v3.6.0-backend-step1`),保留回滚点。
- **功能回归** → 重构后执行手动冒烟流程供应商切换、托盘操作、MCP 同步、配置导入导出。
## 总结
- 当前规模下不建议整体引入完整 DDD/四层架构,避免过度设计。
- 建议遵循“错误统一 → 命令拆分 → 补测试 → 服务层抽象 → 锁优化”的渐进式策略。
- 完成阶段 1-3 后即可显著提升可维护性与可靠性;阶段 4-5 可根据资源灵活安排。
- 重构过程中同步维护文档与测试,确保团队成员对架构演进保持一致认知。

File diff suppressed because it is too large Load Diff

View File

@@ -1,490 +0,0 @@
# CC Switch 重构实施清单
> 用于跟踪重构进度的详细检查清单
**开始日期**: ___________
**预计完成**: ___________
**当前阶段**: ___________
---
## 📋 阶段 0: 准备阶段 (预计 1 天)
### 环境准备
- [ ] 创建新分支 `refactor/modernization`
- [ ] 创建备份标签 `git tag backup-before-refactor`
- [ ] 备份用户配置文件 `~/.cc-switch/config.json`
- [ ] 通知团队成员重构开始
### 依赖安装
```bash
pnpm add @tanstack/react-query
pnpm add react-hook-form @hookform/resolvers
pnpm add zod
pnpm add sonner
pnpm add next-themes
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu
pnpm add @radix-ui/react-label @radix-ui/react-select
pnpm add @radix-ui/react-slot @radix-ui/react-switch @radix-ui/react-tabs
pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate
```
- [ ] 安装核心依赖 (上述命令)
- [ ] 验证依赖安装成功 `pnpm install`
- [ ] 验证编译通过 `pnpm typecheck`
### 配置文件
- [ ] 创建 `components.json`
- [ ] 更新 `tsconfig.json` 添加路径别名
- [ ] 更新 `vite.config.mts` 添加路径解析
- [ ] 验证开发服务器启动 `pnpm dev`
**完成时间**: ___________
**遇到的问题**: ___________
---
## 📋 阶段 1: 基础设施 (预计 2-3 天)
### 1.1 工具函数和基础组件
- [ ] 创建 `src/lib/utils.ts` (cn 函数)
- [ ] 创建 `src/components/ui/button.tsx`
- [ ] 创建 `src/components/ui/dialog.tsx`
- [ ] 创建 `src/components/ui/input.tsx`
- [ ] 创建 `src/components/ui/label.tsx`
- [ ] 创建 `src/components/ui/textarea.tsx`
- [ ] 创建 `src/components/ui/select.tsx`
- [ ] 创建 `src/components/ui/switch.tsx`
- [ ] 创建 `src/components/ui/tabs.tsx`
- [ ] 创建 `src/components/ui/sonner.tsx`
- [ ] 创建 `src/components/ui/form.tsx`
**测试**:
- [ ] 验证所有 UI 组件可以正常导入
- [ ] 创建一个测试页面验证组件样式
### 1.2 Query Client 设置
- [ ] 创建 `src/lib/query/queryClient.ts`
- [ ] 配置默认选项 (retry, staleTime 等)
- [ ] 导出 queryClient 实例
### 1.3 API 层
- [ ] 创建 `src/lib/api/providers.ts`
- [ ] getAll
- [ ] getCurrent
- [ ] add
- [ ] update
- [ ] delete
- [ ] switch
- [ ] importDefault
- [ ] updateTrayMenu
- [ ] 创建 `src/lib/api/settings.ts`
- [ ] get
- [ ] save
- [ ] 创建 `src/lib/api/mcp.ts`
- [ ] getConfig
- [ ] upsertServer
- [ ] deleteServer
- [ ] 创建 `src/lib/api/index.ts` (聚合导出)
**测试**:
- [ ] 验证 API 调用不会出现运行时错误
- [ ] 确认类型定义正确
### 1.4 Query Hooks
- [ ] 创建 `src/lib/query/queries.ts`
- [ ] useProvidersQuery
- [ ] useSettingsQuery
- [ ] useMcpConfigQuery
- [ ] 创建 `src/lib/query/mutations.ts`
- [ ] useAddProviderMutation
- [ ] useSwitchProviderMutation
- [ ] useDeleteProviderMutation
- [ ] useUpdateProviderMutation
- [ ] useSaveSettingsMutation
- [ ] 创建 `src/lib/query/index.ts` (聚合导出)
**测试**:
- [ ] 在临时组件中测试每个 hook
- [ ] 验证 loading/error 状态正确
- [ ] 验证缓存和自动刷新工作
**完成时间**: ___________
**遇到的问题**: ___________
---
## 📋 阶段 2: 核心功能重构 (预计 3-4 天)
### 2.1 主题系统
- [ ] 创建 `src/components/theme-provider.tsx`
- [ ] 创建 `src/components/mode-toggle.tsx`
- [ ] 更新 `src/index.css` 添加主题变量
- [ ] 删除 `src/hooks/useDarkMode.ts`
- [ ] 更新所有组件使用新的主题系统
**测试**:
- [ ] 验证主题切换正常工作
- [ ] 验证系统主题跟随功能
- [ ] 验证主题持久化
### 2.2 更新 main.tsx
- [ ] 引入 QueryClientProvider
- [ ] 引入 ThemeProvider
- [ ] 添加 Toaster 组件
- [ ] 移除旧的 API 导入
**测试**:
- [ ] 验证应用可以正常启动
- [ ] 验证 Context 正确传递
### 2.3 重构 App.tsx
- [ ] 使用 useProvidersQuery 替代手动状态管理
- [ ] 移除所有 loadProviders 相关代码
- [ ] 移除手动 notification 状态
- [ ] 简化事件监听逻辑
- [ ] 更新对话框为新的 Dialog 组件
**目标**: 将 412 行代码减少到 ~100 行
**测试**:
- [ ] 验证供应商列表正常加载
- [ ] 验证切换 Claude/Codex 正常工作
- [ ] 验证事件监听正常工作
### 2.4 重构 ProviderList
- [ ] 创建 `src/components/providers/ProviderList.tsx`
- [ ] 使用 mutation hooks 处理操作
- [ ] 移除 onNotify prop
- [ ] 移除手动状态管理
**测试**:
- [ ] 验证供应商列表渲染
- [ ] 验证切换操作
- [ ] 验证删除操作
### 2.5 重构表单系统
- [ ] 创建 `src/lib/schemas/provider.ts` (Zod schema)
- [ ] 创建 `src/components/providers/ProviderForm.tsx`
- [ ] 使用 react-hook-form
- [ ] 使用 zodResolver
- [ ] 字段级验证
- [ ] 创建 `src/components/providers/AddProviderDialog.tsx`
- [ ] 使用新的 Dialog 组件
- [ ] 集成 ProviderForm
- [ ] 使用 useAddProviderMutation
- [ ] 创建 `src/components/providers/EditProviderDialog.tsx`
- [ ] 使用新的 Dialog 组件
- [ ] 集成 ProviderForm
- [ ] 使用 useUpdateProviderMutation
**测试**:
- [ ] 验证表单验证正常工作
- [ ] 验证错误提示显示正确
- [ ] 验证提交操作成功
- [ ] 验证表单重置功能
### 2.6 清理旧组件
- [x] 删除 `src/components/AddProviderModal.tsx`
- [x] 删除 `src/components/EditProviderModal.tsx`
- [x] 更新所有引用这些组件的地方
- [x] 删除 `src/components/ProviderForm.tsx``src/components/ProviderForm/`
**完成时间**: ___________
**遇到的问题**: ___________
---
## 📋 阶段 3: 设置和辅助功能 (预计 2-3 天)
### 3.1 重构 SettingsDialog
- [ ] 创建 `src/components/settings/SettingsDialog.tsx`
- [ ] 使用 Tabs 组件
- [ ] 集成各个设置子组件
- [ ] 创建 `src/components/settings/GeneralSettings.tsx`
- [ ] 语言设置
- [ ] 配置目录设置
- [ ] 其他通用设置
- [ ] 创建 `src/components/settings/AboutSection.tsx`
- [ ] 版本信息
- [ ] 更新检查
- [ ] 链接
- [ ] 创建 `src/components/settings/ImportExportSection.tsx`
- [ ] 导入功能
- [ ] 导出功能
**目标**: 将 643 行拆分为 4-5 个小组件,每个 100-150 行
**测试**:
- [ ] 验证设置保存功能
- [ ] 验证导入导出功能
- [ ] 验证更新检查功能
### 3.2 重构通知系统
- [ ] 在所有 mutations 中使用 `toast` 替代 `showNotification`
- [ ] 移除 App.tsx 中的 notification 状态
- [ ] 移除自定义通知组件
**测试**:
- [ ] 验证成功通知显示
- [ ] 验证错误通知显示
- [ ] 验证通知自动消失
### 3.3 重构确认对话框
- [ ] 更新 `src/components/ConfirmDialog.tsx` 使用新的 Dialog
- [ ] 或者直接使用 shadcn/ui 的 AlertDialog
**测试**:
- [ ] 验证删除确认对话框
- [ ] 验证其他确认场景
**完成时间**: ___________
**遇到的问题**: ___________
---
## 📋 阶段 4: 清理和优化 (预计 1-2 天)
### 4.1 移除旧代码
- [x] 删除 `src/lib/styles.ts`
- [x]`src/lib/tauri-api.ts` 移除 `window.api` 绑定
- [x] 精简 `src/lib/tauri-api.ts`,只保留事件监听相关
- [x] 删除或更新 `src/vite-env.d.ts` 中的过时类型
### 4.2 代码审查
- [ ] 检查所有 TODO 注释
- [x] 检查是否还有 `window.api` 调用
- [ ] 检查是否还有手动状态管理
- [x] 统一代码风格
### 4.3 类型检查
- [x] 运行 `pnpm typecheck` 确保无错误
- [x] 修复所有类型错误
- [x] 更新类型定义
### 4.4 性能优化
- [ ] 检查是否有不必要的重渲染
- [ ] 添加必要的 React.memo
- [ ] 优化 Query 缓存配置
**完成时间**: ___________
**遇到的问题**: ___________
---
## 📋 阶段 5: 测试和修复 (预计 2-3 天)
### 5.1 功能测试
#### 供应商管理
- [ ] 添加供应商 (Claude)
- [ ] 添加供应商 (Codex)
- [ ] 编辑供应商
- [ ] 删除供应商
- [ ] 切换供应商
- [ ] 导入默认配置
#### 应用切换
- [ ] Claude <-> Codex 切换
- [ ] 切换后数据正确加载
- [ ] 切换后托盘菜单更新
#### 设置
- [ ] 保存通用设置
- [ ] 切换语言
- [ ] 配置目录选择
- [ ] 导入配置
- [ ] 导出配置
#### UI 交互
- [ ] 主题切换 (亮色/暗色)
- [ ] 对话框打开/关闭
- [ ] 表单验证
- [ ] Toast 通知
#### MCP 管理
- [ ] 列表显示
- [ ] 添加 MCP
- [ ] 编辑 MCP
- [ ] 删除 MCP
- [ ] 启用/禁用 MCP
### 5.2 边界情况测试
- [ ] 空供应商列表
- [ ] 无效配置文件
- [ ] 网络错误
- [ ] 后端错误响应
- [ ] 并发操作
- [ ] 表单输入边界值
### 5.3 兼容性测试
- [ ] Windows 测试
- [ ] macOS 测试
- [ ] Linux 测试
### 5.4 性能测试
- [ ] 100+ 供应商加载速度
- [ ] 快速切换供应商
- [ ] 内存使用情况
- [ ] CPU 使用情况
### 5.5 Bug 修复
**Bug 列表** (发现后记录):
1. ___________
- [ ] 已修复
- [ ] 已验证
2. ___________
- [ ] 已修复
- [ ] 已验证
**完成时间**: ___________
**遇到的问题**: ___________
---
## 📋 最终检查
### 代码质量
- [ ] 所有 TypeScript 错误已修复
- [ ] 运行 `pnpm format` 格式化代码
- [ ] 运行 `pnpm typecheck` 通过
- [ ] 代码审查完成
### 文档更新
- [ ] 更新 `CLAUDE.md` 反映新架构
- [ ] 更新 `README.md` (如有必要)
- [ ] 添加 Migration Guide (可选)
### 性能基准
记录性能数据:
**旧版本**:
- 启动时间: _____ms
- 供应商加载: _____ms
- 内存占用: _____MB
**新版本**:
- 启动时间: _____ms
- 供应商加载: _____ms
- 内存占用: _____MB
### 代码统计
**代码行数对比**:
| 文件 | 旧版本 | 新版本 | 减少 |
|------|--------|--------|------|
| App.tsx | 412 | ~100 | -76% |
| tauri-api.ts | 712 | ~50 | -93% |
| ProviderForm.tsx | 271 | ~150 | -45% |
| settings 模块 | 1046 | ~470 (拆分) | -55% |
| **总计** | 2038 | ~700 | **-66%** |
---
## 📦 发布准备
### Pre-release 测试
- [ ] 创建 beta 版本 `v4.0.0-beta.1`
- [ ] 在测试环境验证
- [ ] 收集用户反馈
### 正式发布
- [ ] 合并到 main 分支
- [ ] 创建 Release Tag `v4.0.0`
- [ ] 更新 Changelog
- [ ] 发布 GitHub Release
- [ ] 通知用户更新
---
## 🚨 回滚触发条件
如果出现以下情况,考虑回滚:
- [ ] 重大功能无法使用
- [ ] 用户数据丢失
- [ ] 严重性能问题
- [ ] 无法修复的兼容性问题
**回滚命令**:
```bash
git reset --hard backup-before-refactor
# 或
git revert <commit-range>
```
---
## 📝 总结报告
### 成功指标
- [ ] 所有现有功能正常工作
- [ ] 代码量减少 40%+
- [ ] 无用户数据丢失
- [ ] 性能未下降
### 经验教训
**遇到的主要挑战**:
1. ___________
2. ___________
3. ___________
**解决方案**:
1. ___________
2. ___________
3. ___________
**未来改进**:
1. ___________
2. ___________
3. ___________
---
**重构完成日期**: ___________
**总耗时**: _____
**参与人员**: ___________

File diff suppressed because it is too large Load Diff

View File

@@ -1,834 +0,0 @@
# 重构快速参考指南
> 常见模式和代码示例的速查表
---
## 📑 目录
1. [React Query 使用](#react-query-使用)
2. [react-hook-form 使用](#react-hook-form-使用)
3. [shadcn/ui 组件使用](#shadcnui-组件使用)
4. [代码迁移示例](#代码迁移示例)
---
## React Query 使用
### 基础查询
```typescript
// 定义查询 Hook
export const useProvidersQuery = (appId: AppId) => {
return useQuery({
queryKey: ['providers', appId],
queryFn: async () => {
const data = await providersApi.getAll(appId)
return data
},
})
}
// 在组件中使用
function MyComponent() {
const { data, isLoading, error } = useProvidersQuery('claude')
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <div>{/* 使用 data */}</div>
}
```
### Mutation (变更操作)
```typescript
// 定义 Mutation Hook
export const useAddProviderMutation = (appId: AppId) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (provider: Provider) => {
return await providersApi.add(provider, appId)
},
onSuccess: () => {
// 重新获取数据
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
toast.success('添加成功')
},
onError: (error: Error) => {
toast.error(`添加失败: ${error.message}`)
},
})
}
// 在组件中使用
function AddProviderDialog() {
const mutation = useAddProviderMutation('claude')
const handleSubmit = (data: Provider) => {
mutation.mutate(data)
}
return (
<button
onClick={() => handleSubmit(formData)}
disabled={mutation.isPending}
>
{mutation.isPending ? '添加中...' : '添加'}
</button>
)
}
```
### 乐观更新
```typescript
export const useSwitchProviderMutation = (appId: AppId) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (providerId: string) => {
return await providersApi.switch(providerId, appId)
},
// 乐观更新: 在请求发送前立即更新 UI
onMutate: async (providerId) => {
// 取消正在进行的查询
await queryClient.cancelQueries({ queryKey: ['providers', appId] })
// 保存当前数据(以便回滚)
const previousData = queryClient.getQueryData(['providers', appId])
// 乐观更新
queryClient.setQueryData(['providers', appId], (old: any) => ({
...old,
currentProviderId: providerId,
}))
return { previousData }
},
// 如果失败,回滚
onError: (err, providerId, context) => {
queryClient.setQueryData(['providers', appId], context?.previousData)
toast.error('切换失败')
},
// 无论成功失败,都重新获取数据
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
},
})
}
```
### 依赖查询
```typescript
// 第二个查询依赖第一个查询的结果
const { data: providers } = useProvidersQuery(appId)
const currentProviderId = providers?.currentProviderId
const { data: currentProvider } = useQuery({
queryKey: ['provider', currentProviderId],
queryFn: () => providersApi.getById(currentProviderId!),
enabled: !!currentProviderId, // 只有当 ID 存在时才执行
})
```
---
## react-hook-form 使用
### 基础表单
```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 定义验证 schema
const schema = z.object({
name: z.string().min(1, '请输入名称'),
email: z.string().email('邮箱格式不正确'),
age: z.number().min(18, '年龄必须大于18'),
})
type FormData = z.infer<typeof schema>
function MyForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
email: '',
age: 0,
},
})
const onSubmit = (data: FormData) => {
console.log(data)
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')} />
{form.formState.errors.name && (
<span>{form.formState.errors.name.message}</span>
)}
<button type="submit"></button>
</form>
)
}
```
### 使用 shadcn/ui Form 组件
```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
function MyForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit"></Button>
</form>
</Form>
)
}
```
### 动态表单验证
```typescript
// 根据条件动态验证
const schema = z.object({
type: z.enum(['official', 'custom']),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
}).refine(
(data) => {
// 如果是自定义供应商,必须填写 baseUrl
if (data.type === 'custom') {
return !!data.baseUrl
}
return true
},
{
message: '自定义供应商必须填写 Base URL',
path: ['baseUrl'],
}
)
```
### 手动触发验证
```typescript
function MyForm() {
const form = useForm<FormData>()
const handleBlur = async () => {
// 验证单个字段
await form.trigger('name')
// 验证多个字段
await form.trigger(['name', 'email'])
// 验证所有字段
const isValid = await form.trigger()
}
return <form>...</form>
}
```
---
## shadcn/ui 组件使用
### Dialog (对话框)
```typescript
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
function MyDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{/* 内容 */}
<div></div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={handleConfirm}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
```
### Select (选择器)
```typescript
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
function MySelect() {
const [value, setValue] = useState('')
return (
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">1</SelectItem>
<SelectItem value="option2">2</SelectItem>
<SelectItem value="option3">3</SelectItem>
</SelectContent>
</Select>
)
}
```
### Tabs (标签页)
```typescript
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
function MyTabs() {
return (
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">1</TabsTrigger>
<TabsTrigger value="tab2">2</TabsTrigger>
<TabsTrigger value="tab3">3</TabsTrigger>
</TabsList>
<TabsContent value="tab1">
<div>1</div>
</TabsContent>
<TabsContent value="tab2">
<div>2</div>
</TabsContent>
<TabsContent value="tab3">
<div>3</div>
</TabsContent>
</Tabs>
)
}
```
### Toast 通知 (Sonner)
```typescript
import { toast } from 'sonner'
// 成功通知
toast.success('操作成功')
// 错误通知
toast.error('操作失败')
// 加载中
const toastId = toast.loading('处理中...')
// 完成后更新
toast.success('处理完成', { id: toastId })
// 或
toast.dismiss(toastId)
// 自定义持续时间
toast.success('消息', { duration: 5000 })
// 带操作按钮
toast('确认删除?', {
action: {
label: '删除',
onClick: () => handleDelete(),
},
})
```
---
## 代码迁移示例
### 示例 1: 状态管理迁移
**旧代码** (手动状态管理):
```typescript
const [providers, setProviders] = useState<Record<string, Provider>>({})
const [currentProviderId, setCurrentProviderId] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const load = async () => {
setLoading(true)
setError(null)
try {
const data = await window.api.getProviders(appType)
const currentId = await window.api.getCurrentProvider(appType)
setProviders(data)
setCurrentProviderId(currentId)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}
load()
}, [appId])
```
**新代码** (React Query):
```typescript
const { data, isLoading, error } = useProvidersQuery(appId)
const providers = data?.providers || {}
const currentProviderId = data?.currentProviderId || ''
```
**减少**: 从 20+ 行到 3 行
---
### 示例 2: 表单验证迁移
**旧代码** (手动验证):
```typescript
const [name, setName] = useState('')
const [nameError, setNameError] = useState('')
const [apiKey, setApiKey] = useState('')
const [apiKeyError, setApiKeyError] = useState('')
const validate = () => {
let valid = true
if (!name.trim()) {
setNameError('请输入名称')
valid = false
} else {
setNameError('')
}
if (!apiKey.trim()) {
setApiKeyError('请输入 API Key')
valid = false
} else if (apiKey.length < 10) {
setApiKeyError('API Key 长度不足')
valid = false
} else {
setApiKeyError('')
}
return valid
}
const handleSubmit = () => {
if (validate()) {
// 提交
}
}
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
{nameError && <span>{nameError}</span>}
<input value={apiKey} onChange={e => setApiKey(e.target.value)} />
{apiKeyError && <span>{apiKeyError}</span>}
<button onClick={handleSubmit}></button>
</form>
)
```
**新代码** (react-hook-form + zod):
```typescript
const schema = z.object({
name: z.string().min(1, '请输入名称'),
apiKey: z.string().min(10, 'API Key 长度不足'),
})
const form = useForm({
resolver: zodResolver(schema),
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit"></Button>
</form>
</Form>
)
```
**减少**: 从 40+ 行到 30 行,且更健壮
---
### 示例 3: 通知系统迁移
**旧代码** (自定义通知):
```typescript
const [notification, setNotification] = useState<{
message: string
type: 'success' | 'error'
} | null>(null)
const [isVisible, setIsVisible] = useState(false)
const showNotification = (message: string, type: 'success' | 'error') => {
setNotification({ message, type })
setIsVisible(true)
setTimeout(() => {
setIsVisible(false)
setTimeout(() => setNotification(null), 300)
}, 3000)
}
return (
<>
{notification && (
<div className={`notification ${isVisible ? 'visible' : ''} ${notification.type}`}>
{notification.message}
</div>
)}
{/* 其他内容 */}
</>
)
```
**新代码** (Sonner):
```typescript
import { toast } from 'sonner'
// 在需要的地方直接调用
toast.success('操作成功')
toast.error('操作失败')
// 在 main.tsx 中只需添加一次
import { Toaster } from '@/components/ui/sonner'
<Toaster />
```
**减少**: 从 20+ 行到 1 行调用
---
### 示例 4: 对话框迁移
**旧代码** (自定义 Modal):
```typescript
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}></button>
{isOpen && (
<div className="modal-backdrop" onClick={() => setIsOpen(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2></h2>
<button onClick={() => setIsOpen(false)}>×</button>
</div>
<div className="modal-body">
{/* 内容 */}
</div>
<div className="modal-footer">
<button onClick={() => setIsOpen(false)}></button>
<button onClick={handleConfirm}></button>
</div>
</div>
</div>
)}
</>
)
```
**新代码** (shadcn/ui Dialog):
```typescript
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}></Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{/* 内容 */}
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}></Button>
<Button onClick={handleConfirm}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
```
**优势**:
- 无需自定义样式
- 内置无障碍支持
- 自动管理焦点和 ESC 键
---
### 示例 5: API 调用迁移
**旧代码** (window.api):
```typescript
// 添加供应商
const handleAdd = async (provider: Provider) => {
try {
await window.api.addProvider(provider, appType)
await loadProviders()
showNotification('添加成功', 'success')
} catch (error) {
showNotification('添加失败', 'error')
}
}
```
**新代码** (React Query Mutation):
```typescript
// 在组件中
const addMutation = useAddProviderMutation(appId)
const handleAdd = (provider: Provider) => {
addMutation.mutate(provider)
// 成功和错误处理已在 mutation 定义中处理
}
```
**优势**:
- 自动处理 loading 状态
- 统一的错误处理
- 自动刷新数据
- 更少的样板代码
---
## 常见问题
### Q: 如何在 mutation 成功后关闭对话框?
```typescript
const mutation = useAddProviderMutation(appId)
const handleSubmit = (data: Provider) => {
mutation.mutate(data, {
onSuccess: () => {
setIsOpen(false) // 关闭对话框
},
})
}
```
### Q: 如何在表单中使用异步验证?
```typescript
const schema = z.object({
name: z.string().refine(
async (name) => {
// 检查名称是否已存在
const exists = await checkNameExists(name)
return !exists
},
{ message: '名称已存在' }
),
})
```
### Q: 如何手动刷新 Query 数据?
```typescript
const queryClient = useQueryClient()
// 方式1: 使缓存失效,触发重新获取
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
// 方式2: 直接刷新
queryClient.refetchQueries({ queryKey: ['providers', appId] })
// 方式3: 更新缓存数据
queryClient.setQueryData(['providers', appId], newData)
```
### Q: 如何在组件外部使用 toast?
```typescript
// 直接导入并使用即可
import { toast } from 'sonner'
export const someUtil = () => {
toast.success('工具函数中的通知')
}
```
---
## 调试技巧
### React Query DevTools
```typescript
// 在 main.tsx 中添加
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
```
### 查看表单状态
```typescript
const form = useForm()
// 在开发模式下打印表单状态
console.log('Form values:', form.watch())
console.log('Form errors:', form.formState.errors)
console.log('Is valid:', form.formState.isValid)
```
---
## 性能优化建议
### 1. 避免不必要的重渲染
```typescript
// 使用 React.memo
export const ProviderCard = React.memo(({ provider, onEdit }: Props) => {
// ...
})
// 或使用 useMemo
const sortedProviders = useMemo(
() => Object.values(providers).sort(...),
[providers]
)
```
### 2. Query 配置优化
```typescript
const { data } = useQuery({
queryKey: ['providers', appId],
queryFn: fetchProviders,
staleTime: 1000 * 60 * 5, // 5分钟内不重新获取
gcTime: 1000 * 60 * 10, // 10分钟后清除缓存
})
```
### 3. 表单性能优化
```typescript
// 使用 mode 控制验证时机
const form = useForm({
mode: 'onBlur', // 失去焦点时验证
// mode: 'onChange', // 每次输入都验证(较慢)
// mode: 'onSubmit', // 提交时验证(最快)
})
```
---
**提示**: 将此文档保存在浏览器书签或编辑器中,方便随时查阅!

View File

@@ -1,73 +0,0 @@
# 前端测试开发计划
## 1. 背景与目标
- **背景**v3.5.0 起前端功能快速扩张供应商管理、MCP、导入导出、端点测速、国际化缺失系统化测试导致回归风险与人工验证成本攀升。
- **目标**:在 3 个迭代内建立覆盖关键业务的自动化测试体系,形成稳定的手动冒烟流程,并将测试执行纳入 CI/CD。
## 2. 范围与优先级
| 范围 | 内容 | 优先级 |
| --- | --- | --- |
| 供应商管理 | 列表、排序、预设/自定义表单、切换、复制、删除 | P0 |
| 配置导入导出 | JSON 校验、备份、进度反馈、失败回滚 | P0 |
| MCP 管理 | 列表、启停、模板、命令校验 | P1 |
| 设置面板 | 主题/语言切换、目录设置、关于、更新检查 | P1 |
| 端点速度测试 & 使用脚本 | 启动测试、状态指示、脚本保存 | P2 |
| 国际化 | 中英切换、缺省文案回退 | P2 |
## 3. 测试分层策略
- **单元测试Vitest**:纯函数与 Hook`useProviderActions``useSettingsForm``useDragSort``useImportExport` 等)验证数据处理、错误分支、排序逻辑。
- **组件测试React Testing Library**:关键组件(`ProviderList``AddProviderDialog``SettingsDialog``McpPanel`)模拟交互、校验、提示;结合 MSW 模拟 API。
- **集成测试App 级别)**:挂载 `App.tsx`,覆盖应用切换、编辑模式、导入导出回调、语言切换,验证状态同步与 toast 提示。
- **端到端测试Playwright**:依赖 `pnpm dev:renderer`,串联供应商 CRUD、排序拖拽、MCP 启停、语言切换即时刷新、更新检查跳转。
- **手动冒烟**Tauri 桌面包 + dev server 双通道,验证托盘、系统权限、真实文件写入。
## 4. 环境与工具
- 依赖Node 18+、pnpm 8+、Vitest、React Testing Library、MSW、Playwright、Testing Library User Event、Playwright Trace Viewer。
- 配置要点:
-`tsconfig` 中共享别名Vitest 配合 `vite.config.mts`
- `setupTests.ts` 统一注册 MSW/RTL、自定义 matcher。
- Playwright 使用多浏览器矩阵Chromium 必选WebKit 可选),并共享 `.env.test`
- Mock `@tauri-apps/api``providersApi`/`settingsApi`,隔离 Rust 层。
## 5. 自动化建设里程碑
| 周期 | 目标 | 交付 |
| --- | --- | --- |
| Sprint 1 | Vitest 基础设施、核心 Hook 单测P0 | `pnpm test:unit`、覆盖率报告、10+ 用例 |
| Sprint 2 | 组件/集成测试、MSW Mock 层 | `pnpm test:component`、App 主流程用例 |
| Sprint 3 | Playwright E2E、CI 接入 | `pnpm test:e2e`、CI job、冒烟脚本 |
| 持续 | 回归用例补齐、视觉比对探索 | Playwright Trace、截图基线 |
## 6. 用例规划概览
- **供应商管理**:新增(预设+自定义)、编辑校验、复制排序、切换失败回退、删除确认、使用脚本保存。
- **导入导出**:成功、重复导入、校验失败、备份失败提示、导入后托盘刷新。
- **MCP**模板应用、协议切换stdio/http、命令校验、启停状态持久化。
- **设置**:主题/语言即时生效、目录路径更新、更新检查按钮外链、关于信息渲染。
- **端点速度测试**触发测试、loading/成功/失败状态、指示器颜色、测速数据排序。
- **国际化**:默认中文、切换英文后主界面/对话框文案变化、缺失 key fallback。
## 7. 数据与 Mock 策略
-`tests/fixtures/` 维护标准供应商、MCP、设置数据集。
- 使用 MSW 拦截 `providersApi``settingsApi``providersApi.onSwitched` 等调用;提供延迟/错误注入接口以覆盖异常分支。
- Playwright 端提供临时用户目录(`TMP_CC_SWITCH_HOME`+ 伪配置文件,以验证真实文件交互路径。
## 8. 质量门禁与指标
- 覆盖率目标:单元 ≥75%,分支 ≥70%,逐步提升至 80%+。
- CI 阶段:`pnpm typecheck``pnpm format:check``pnpm test:unit``pnpm test:component``pnpm test:e2e`(可在 nightly 执行)。
- 缺陷处理修复前补充最小复现测试E2E 冒烟必须陪跑重大功能发布。
## 9. 工作流与职责
- **测试负责人**前端工程师轮值负责测试计划维护、PR 流水线健康。
- **开发者职责**:提交功能需附新增/更新测试、列出手动验证步骤、如涉及 UI 提交截图。
- **Code Review 检查**测试覆盖说明、mock 合理性、易读性。
## 10. 风险与缓解
| 风险 | 影响 | 缓解 |
| --- | --- | --- |
| Tauri API Mock 难度高 | 单测无法稳定 | 抽象 API 适配层 + MSW 统一模拟 |
| Playwright 运行时间长 | CI 变慢 | 拆分冒烟/完整版,冒烟只跑关键路径 |
| 国际化文案频繁变化 | 用例脆弱 | 优先断言 data-testid/结构,文案使用翻译 key |
## 11. 输出与维护
- 文档维护者:前端团队;每个版本更新后检查测试覆盖清单。
- 交付物测试报告CI artifact、Playwright Trace、覆盖率摘要。
- 复盘:每次发布后召开 30 分钟测试复盘,记录缺陷、补齐用例。

View File

@@ -1,249 +0,0 @@
## Major architecture refactoring with enhanced config sync and data protection
**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-note-v3.6.0-zh.md)**
---
## What's New
### Edit Mode & Provider Management
- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click
- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
### Custom Endpoint Management
- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints
- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically
### Usage Query Enhancements
- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals
- **Test Script API** - Validate JavaScript usage query scripts before execution
- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support
Thanks to @Sirhexs
### Custom Configuration Directory (Cloud Sync)
- **Customizable Storage Location** - Customize CC Switch's configuration storage directory
- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices
- **Independent Management** - Managed via Tauri Store for better isolation and reliability
Thanks to @ZyphrZero
### Configuration Directory Switching (WSL Support)
- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation
- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow
- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness
- **Smart Conflict Resolution** - Distinguishes "fully successful" and "partially successful" states for precise user feedback
### Configuration Editor Improvements
- **JSON Format Button** - One-click JSON formatting in configuration editors
- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting
### Load Live Config When Editing
- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files
- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones
### Claude Configuration Data Structure Enhancements
- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation
- New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`
- Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration
- Backend normalizes old configs on first read/write with smart fallback chain
- UI expanded from 2 to 4 model input fields with intelligent defaults
- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`
- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)
- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management
- **Visual Theme Configuration** - Custom icons and colors for provider cards
### Updated Provider Models
- **Kimi k2** - Updated to latest `kimi-k2-thinking` model
### New Provider Presets
Added 5 new provider presets:
- **DMXAPI** - Multi-model aggregation service
- **Azure Codex** - Microsoft Azure OpenAI endpoint
- **AnyRouter** - None-profit routing service
- **AiHubMix** - Multi-model aggregation service
- **MiniMax** - Open source AI model provider
### Partner Promotion Mechanism
- Support for ecosystem partner promotion (Zhipu GLM Z.ai)
- Sponsored banner integration in README
---
## Improvements
### Configuration & Sync
- **Unified Error Handling** - AppError with internationalized error messages throughout backend
- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution
- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures
- **Import Config Sync** - Fixed sync issues after configuration import
- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss
### UI/UX Enhancements
- **Unique Provider Icons** - Each provider card now has unique icons and color identification
- **Unified Border System** - Consistent border design across all components
- **Drag Interaction** - Push effect animation and improved drag handle icons
- **Enhanced Visual Feedback** - Better current provider visual indication
- **Dialog Standardization** - Unified dialog sizes and layout consistency
- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints
- **Usage Display Inline** - Usage info moved next to enable button for better space utilization
### Complete Internationalization
- **Error Messages i18n** - All backend error messages support Chinese/English
- **Tray Menu i18n** - System tray menu fully internationalized
- **UI Components i18n** - 100% coverage across all user-facing components
---
## Bug Fixes
### Configuration Management
- Fixed `apiKeyUrl` priority issue
- Fixed MCP sync-to-other-side functionality failure
- Fixed sync issues after config import
- Fixed Codex API Key auto-sync
- Fixed endpoint speed test functionality
- Fixed provider duplicate insertion position (now inserts next to original)
- Fixed custom endpoint preservation in edit mode
- Prevent silent fallback and data loss on config error
### Usage Query
- Fixed auto-query interval timing issue
- Ensured refresh button shows loading animation on click
### UI Issues
- Fixed name collision error (`get_init_error` command)
- Fixed language setting rollback after successful save
- Fixed language switch state reset (dependency cycle)
- Fixed edit mode button alignment
### Startup Issues
- Force exit on config error (no silent fallback)
- Eliminated code duplication causing initialization errors
---
## Architecture Refactoring
### Backend (Rust) - 5 Phase Refactoring
1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)
2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
### Frontend (React + TypeScript) - 4 Stage Refactoring
1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
3. **Stage 3**: Component splitting and business logic extraction
4. **Stage 4**: Code cleanup and formatting unification
### Testing System
- **Hooks Unit Tests** - 100% coverage for all custom hooks
- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)
- **MSW Mocking** - Backend API mocking to ensure test independence
- **Test Infrastructure** - vitest + MSW + @testing-library/react
### Code Quality
- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)
- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics
- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait
- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase
- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`
---
## Internal Optimizations (User Transparent)
### Removed Legacy Migration Logic
v3.6.0 removed v1 config auto-migration and copy file scanning logic:
- **Impact**: Improved startup performance, cleaner codebase
- **Compatibility**: v2 format configs fully compatible, no action required
- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0
### Command Parameter Standardization
Backend unified to use `app` parameter (values: `claude` or `codex`):
- **Impact**: More standardized code, friendlier error prompts
- **Compatibility**: Frontend fully adapted, users don't need to care about this change
---
## Dependencies
- Updated to **Tauri 2.8.x**
- Updated to **TailwindCSS 4.x**
- Updated to **TanStack Query v5.90.x**
- Maintained **React 18.2.x** and **TypeScript 5.3.x**
---
## Installation
### macOS
**Via Homebrew (Recommended):**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
**Manual Download:**
- Download `CC-Switch-v3.6.0-macOS.zip` from [Assets](#assets) below
> **Note**: Due to lack of Apple Developer account, you may see "unidentified developer" warning. Go to System Settings → Privacy & Security → Click "Open Anyway"
### Windows
- **Installer**: `CC-Switch-v3.6.0-Windows.msi`
- **Portable**: `CC-Switch-v3.6.0-Windows-Portable.zip`
### Linux
- **AppImage**: `CC-Switch-v3.6.0-Linux.AppImage`
- **Debian**: `CC-Switch-v3.6.0-Linux.deb`
---
## Documentation
- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
---
## Acknowledgments
Special thanks to **Zhipu AI** for sponsoring this project with their GLM CODING PLAN!
---
**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0

View File

@@ -1,249 +0,0 @@
# CC Switch v3.6.0
> 全栈架构重构,增强配置同步与数据保护
**[English Version →](../release-note-v3.6.0.md)**
---
## 新增功能
### 编辑模式与供应商管理
- **供应商复制功能** - 一键快速复制现有供应商配置,轻松创建变体配置
- **手动排序功能** - 通过拖拽对供应商进行重新排序,带有视觉推送效果动画
- **编辑模式切换** - 显示/隐藏拖拽手柄,优化编辑体验
### 自定义端点管理
- **多端点配置** - 支持聚合类供应商的多 API 端点配置
- **端点输入可见性** - 为所有非官方供应商自动显示端点字段
### 自定义配置目录(云同步)
- **自定义存储位置** - 自定义 CC Switch 的配置存储目录
- **云同步支持** - 指定到云同步文件夹Dropbox、OneDrive、iCloud Drive、坚果云等即可实现跨设备配置自动同步
- **独立管理** - 通过 Tauri Store 管理,更好的隔离性和可靠性
### 使用量查询增强
- **自动刷新间隔** - 配置定时自动使用量查询,支持自定义间隔时间
- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本
- **增强模板系统** - 自定义空白模板,支持 access token 和 user ID 参数
### 配置目录切换WSL 支持)
- **目录变更自动同步** - 切换 Claude/Codex 配置目录(如 WSL 环境)时,自动同步当前供应商到新目录,无需手动操作
- **后置同步工具** - 统一的 `postChangeSync.ts` 工具,优雅处理错误而不阻塞主流程
- **导入配置自动同步** - 配置导入后自动同步,确保立即生效
- **智能冲突解决** - 区分"完全成功"和"部分成功"状态,提供精确的用户反馈
### 配置编辑器改进
- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化
- **实时 TOML 验证** - Codex 配置的实时语法验证,带有错误高亮
### 编辑时加载 Live 配置
- **保护手动修改** - 编辑当前激活的供应商时,优先显示来自 live 文件的实际生效配置
- **双源策略** - 活动供应商自动从 live 配置加载,非活动供应商从 SSOT 加载
### Claude 配置数据结构增强
- **细粒度模型配置** - 从双键系统升级到四键系统,以匹配官方最新数据结构
- 新增字段:`ANTHROPIC_DEFAULT_HAIKU_MODEL``ANTHROPIC_DEFAULT_SONNET_MODEL``ANTHROPIC_DEFAULT_OPUS_MODEL``ANTHROPIC_MODEL`
- 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`,支持自动迁移
- 后端在首次读写时自动规范化旧配置,带有智能回退链
- UI 从 2 个模型输入字段扩展到 4 个,具有智能默认值
- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段(除 `ANTHROPIC_AUTH_TOKEN` 外)
- **模板变量系统** - 支持动态配置替换(如 KAT-Coder 的 `ENDPOINT_ID` 参数)
- **端点候选列表** - 预定义端点列表,用于速度测试和端点管理
- **视觉主题配置** - 供应商卡片自定义图标和颜色
### 供应商模型更新
- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型
### 新增供应商预设
新增 5 个供应商预设:
- **DMXAPI** - 多模型聚合服务
- **Azure Codex** - 微软 Azure OpenAI 端点
- **AnyRouter** - API 路由服务
- **AiHubMix** - AI 模型集合
- **MiniMax** - 国产 AI 模型提供商
### 合作伙伴推广机制
- 支持生态合作伙伴推广(智谱 GLM Z.ai
- README 中集成赞助商横幅
---
## 改进优化
### 配置与同步
- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息
- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序
- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题
- **导入配置同步** - 修复配置导入后的同步问题
- **配置错误处理** - 配置错误时强制退出,防止静默回退和数据丢失
### UI/UX 增强
- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别
- **统一边框系统** - 所有组件采用一致的边框设计
- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标
- **增强视觉反馈** - 更好的当前供应商视觉指示
- **对话框标准化** - 统一的对话框尺寸和布局一致性
- **表单改进** - 优化模型占位符,简化供应商提示,分类特定提示
- **使用量内联显示** - 使用量信息移至启用按钮旁边,更好地利用空间
### 完整国际化
- **错误消息国际化** - 所有后端错误消息支持中英文
- **托盘菜单国际化** - 系统托盘菜单完全国际化
- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖
---
## Bug 修复
### 配置管理
- 修复 `apiKeyUrl` 优先级问题
- 修复 MCP 同步到另一端功能失效
- 修复配置导入后的同步问题
- 修复 Codex API Key 自动同步
- 修复端点速度测试功能
- 修复供应商复制插入位置(现在插入到原供应商旁边)
- 修复编辑模式下自定义端点保留问题
- 防止配置错误时的静默回退和数据丢失
### 使用量查询
- 修复自动查询间隔时间问题
- 确保刷新按钮点击时显示加载动画
### UI 问题
- 修复名称冲突错误(`get_init_error` 命令)
- 修复保存成功后语言设置回滚
- 修复语言切换状态重置(依赖循环)
- 修复编辑模式按钮对齐
### 启动问题
- 配置错误时强制退出(不再静默回退)
- 消除导致初始化错误的代码重复
---
## 架构重构
### 后端Rust- 5 阶段重构
1. **阶段 1**:统一错误处理(`AppError` + 国际化错误消息)
2. **阶段 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`
3. **阶段 3**:集成测试和事务机制(配置快照 + 失败回滚)
4. **阶段 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`
5. **阶段 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
### 前端React + TypeScript- 4 阶段重构
1. **阶段 1**测试基础设施vitest + MSW + @testing-library/react
2. **阶段 2**:提取自定义 hooks`useProviderActions``useMcpActions``useSettings``useImportExport` 等)
3. **阶段 3**:组件拆分和业务逻辑提取
4. **阶段 4**:代码清理和格式化统一
### 测试体系
- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖
- **集成测试** - 关键流程覆盖App、SettingsDialog、MCP 面板)
- **MSW 模拟** - 后端 API 模拟确保测试独立性
- **测试基础设施** - vitest + MSW + @testing-library/react
### 代码质量
- **统一参数格式** - 所有 Tauri 命令迁移到 camelCaseTauri 2 规范)
- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义
- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析
- **DRY 违规清理** - 消除整个代码库中的代码重复
- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`
---
## 内部优化(用户无感知)
### 移除遗留迁移逻辑
v3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑:
- **影响**:提升启动性能,代码更简洁
- **兼容性**v2 格式配置完全兼容,无需任何操作
- **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移,然后再升级到 v3.6.0
### 命令参数标准化
后端统一使用 `app` 参数(取值:`claude``codex`
- **影响**:代码更规范,错误提示更友好
- **兼容性**:前端已完全适配,用户无需关心此变更
---
## 依赖更新
- 更新到 **Tauri 2.8.x**
- 更新到 **TailwindCSS 4.x**
- 更新到 **TanStack Query v5.90.x**
- 保持 **React 18.2.x****TypeScript 5.3.x**
---
## 安装方式
### macOS
**通过 Homebrew 安装(推荐):**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
**手动下载:**
- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.0-macOS.zip`
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告。请前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
### Windows
- **安装包**`CC-Switch-v3.6.0-Windows.msi`
- **便携版**`CC-Switch-v3.6.0-Windows-Portable.zip`
### Linux
- **AppImage**`CC-Switch-v3.6.0-Linux.AppImage`
- **Debian**`CC-Switch-v3.6.0-Linux.deb`
---
## 文档
- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
---
## 致谢
特别感谢**智谱 AI** 通过 GLM CODING PLAN 赞助本项目!
---
**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0

View File

@@ -1,391 +0,0 @@
# CC Switch v3.6.1
> Stability improvements and user experience optimization (based on v3.6.0)
**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-note-v3.6.1-zh.md)**
---
## 📦 What's New in v3.6.1 (2025-11-10)
This release focuses on **user experience optimization** and **configuration parsing robustness**, fixing several critical bugs and enhancing the usage query system.
### ✨ New Features
#### Usage Query System Enhancements
- **Credential Decoupling** - Usage queries can now use independent API Key and Base URL, no longer dependent on provider configuration
- Support for different query endpoints and authentication methods
- Automatically displays credential input fields based on template type
- General template: API Key + Base URL
- NewAPI template: Base URL + Access Token + User ID
- Custom template: Fully customizable
- **UI Component Upgrade** - Replaced native checkbox with shadcn/ui Switch component for modern experience
- **Form Unification** - Unified use of shadcn/ui Input components, consistent styling with the application
- **Password Visibility Toggle** - Added show/hide password functionality (API Key, Access Token)
#### Form Validation Infrastructure
- **Common Schema Library** - New JSON/TOML generic validators to reduce code duplication
- `jsonConfigSchema`: Generic JSON object validator
- `tomlConfigSchema`: Generic TOML format validator
- `mcpJsonConfigSchema`: MCP-specific JSON validator
- **MCP Conditional Field Validation** - Strict type checking
- stdio type requires `command` field
- http type requires `url` field
#### Partner Integration
- **PackyCode** - New official partner
- Added to Claude and Codex provider presets
- 10% discount promotion support
- New logo and partner identification
---
### 🔧 Improvements
#### User Experience
- **Drag Sort Sync** - Tray menu order now syncs with drag-and-drop sorting in real-time
- **Enhanced Error Notifications** - Provider switch failures now display copyable error messages
- **Removed Misleading Placeholders** - Deleted example text from model input fields to avoid user confusion
- **Auto-fill Base URL** - All non-official provider categories automatically populate the Base URL input field
#### Configuration Parsing
- **CJK Quote Normalization** - Automatically handles IME-input fullwidth quotes to prevent TOML parsing errors
- Supports automatic conversion of Chinese quotes (" " ' ') to ASCII quotes
- Applied in TOML input handlers
- Disabled browser auto-correction in Textarea component
- **Preserve Custom Fields** - Editing Codex MCP TOML configuration now preserves unknown fields
- Supports extension fields like timeout_ms, retry_count
- Forward compatibility with future MCP protocol extensions
---
### 🐛 Bug Fixes
#### Critical Fixes
- **Fixed usage script panel white screen crash** - FormLabel component missing FormField context caused entire app to crash
- Replaced with standalone Label component
- Root cause: FormLabel internally calls useFormField() hook which requires FormFieldContext
- **Fixed CJK input quote parsing failure** - IME-input fullwidth quotes caused TOML parsing errors
- Added textNormalization utility function
- Automatically normalizes quotes before parsing
- **Fixed drag sort tray desync** (#179) - Tray menu order not updated after drag-and-drop sorting
- Automatically calls updateTrayMenu after sorting completes
- Ensures UI and tray menu stay consistent
- **Fixed MCP custom field loss** - Custom fields silently dropped when editing Codex MCP configuration
- Uses spread operator to retain all fields
- Preserves unknown fields in normalizeServerConfig
#### Stability Improvements
- **Error Isolation** - Tray menu update failures no longer affect main operations
- Decoupled tray update errors from main operations
- Provides warning when main operation succeeds but tray update fails
- **Safe Pattern Matching** - Replaced `unwrap()` with safe pattern matching
- Avoids panic-induced app crashes
- Tray menu event handling uses match patterns
- **Import Config Classification** - Importing from default config now automatically sets category to `custom`
- Avoids imported configs being mistaken for official presets
- Provides clearer configuration source identification
---
### 📊 Technical Statistics
```
Commits: 17 commits
Code Changes: 31 files
- Additions: 1,163 lines
- Deletions: 811 lines
- Net Growth: +352 lines
Contributors: Jason (16), ZyphrZero (1)
```
**By Module**:
- UI/User Interface: 3 commits
- Usage Query System: 3 commits
- Configuration Parsing: 2 commits
- Form Validation: 1 commit
- Other Improvements: 8 commits
---
### 📥 Installation
#### macOS
**Via Homebrew (Recommended):**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
**Manual Download:**
- Download `CC-Switch-v3.6.1-macOS.zip` from [Assets](#assets) below
> **Note**: Due to lack of Apple Developer account, you may see "unidentified developer" warning. Go to System Settings → Privacy & Security → Click "Open Anyway"
#### Windows
- **Installer**: `CC-Switch-v3.6.1-Windows.msi`
- **Portable**: `CC-Switch-v3.6.1-Windows-Portable.zip`
#### Linux
- **AppImage**: `CC-Switch-v3.6.1-Linux.AppImage`
- **Debian**: `CC-Switch-v3.6.1-Linux.deb`
---
### 📚 Documentation
- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
---
### 🙏 Acknowledgments
Special thanks to:
- **Zhipu AI** - For sponsoring this project with GLM CODING PLAN
- **PackyCode** - New official partner
- **ZyphrZero** - For contributing tray menu sync fix (#179)
---
**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1
---
---
## 📜 v3.6.0 Complete Feature Review
> Content below is from v3.6.0 (2025-11-07), helping you understand the complete feature set
<details>
<summary><b>Click to expand v3.6.0 detailed content →</b></summary>
## What's New
### Edit Mode & Provider Management
- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click
- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
### Custom Endpoint Management
- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints
- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically
### Usage Query Enhancements
- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals
- **Test Script API** - Validate JavaScript usage query scripts before execution
- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support
Thanks to @Sirhexs
### Custom Configuration Directory (Cloud Sync)
- **Customizable Storage Location** - Customize CC Switch's configuration storage directory
- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices
- **Independent Management** - Managed via Tauri Store for better isolation and reliability
Thanks to @ZyphrZero
### Configuration Directory Switching (WSL Support)
- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation
- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow
- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness
- **Smart Conflict Resolution** - Distinguishes "fully successful" and "partially successful" states for precise user feedback
### Configuration Editor Improvements
- **JSON Format Button** - One-click JSON formatting in configuration editors
- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting
### Load Live Config When Editing
- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files
- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones
### Claude Configuration Data Structure Enhancements
- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation
- New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`
- Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration
- Backend normalizes old configs on first read/write with smart fallback chain
- UI expanded from 2 to 4 model input fields with intelligent defaults
- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`
- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)
- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management
- **Visual Theme Configuration** - Custom icons and colors for provider cards
### Updated Provider Models
- **Kimi k2** - Updated to latest `kimi-k2-thinking` model
### New Provider Presets
Added 5 new provider presets:
- **DMXAPI** - Multi-model aggregation service
- **Azure Codex** - Microsoft Azure OpenAI endpoint
- **AnyRouter** - None-profit routing service
- **AiHubMix** - Multi-model aggregation service
- **MiniMax** - Open source AI model provider
### Partner Promotion Mechanism
- Support for ecosystem partner promotion (Zhipu GLM Z.ai)
- Sponsored banner integration in README
---
## Improvements
### Configuration & Sync
- **Unified Error Handling** - AppError with internationalized error messages throughout backend
- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution
- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures
- **Import Config Sync** - Fixed sync issues after configuration import
- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss
### UI/UX Enhancements
- **Unique Provider Icons** - Each provider card now has unique icons and color identification
- **Unified Border System** - Consistent border design across all components
- **Drag Interaction** - Push effect animation and improved drag handle icons
- **Enhanced Visual Feedback** - Better current provider visual indication
- **Dialog Standardization** - Unified dialog sizes and layout consistency
- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints
- **Usage Display Inline** - Usage info moved next to enable button for better space utilization
### Complete Internationalization
- **Error Messages i18n** - All backend error messages support Chinese/English
- **Tray Menu i18n** - System tray menu fully internationalized
- **UI Components i18n** - 100% coverage across all user-facing components
---
## Bug Fixes
### Configuration Management
- Fixed `apiKeyUrl` priority issue
- Fixed MCP sync-to-other-side functionality failure
- Fixed sync issues after config import
- Fixed Codex API Key auto-sync
- Fixed endpoint speed test functionality
- Fixed provider duplicate insertion position (now inserts next to original)
- Fixed custom endpoint preservation in edit mode
- Prevent silent fallback and data loss on config error
### Usage Query
- Fixed auto-query interval timing issue
- Ensured refresh button shows loading animation on click
### UI Issues
- Fixed name collision error (`get_init_error` command)
- Fixed language setting rollback after successful save
- Fixed language switch state reset (dependency cycle)
- Fixed edit mode button alignment
### Startup Issues
- Force exit on config error (no silent fallback)
- Eliminated code duplication causing initialization errors
---
## Architecture Refactoring
### Backend (Rust) - 5 Phase Refactoring
1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)
2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
### Frontend (React + TypeScript) - 4 Stage Refactoring
1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
3. **Stage 3**: Component splitting and business logic extraction
4. **Stage 4**: Code cleanup and formatting unification
### Testing System
- **Hooks Unit Tests** - 100% coverage for all custom hooks
- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)
- **MSW Mocking** - Backend API mocking to ensure test independence
- **Test Infrastructure** - vitest + MSW + @testing-library/react
### Code Quality
- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)
- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics
- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait
- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase
- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`
---
## Internal Optimizations (User Transparent)
### Removed Legacy Migration Logic
v3.6.0 removed v1 config auto-migration and copy file scanning logic:
- **Impact**: Improved startup performance, cleaner codebase
- **Compatibility**: v2 format configs fully compatible, no action required
- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0
### Command Parameter Standardization
Backend unified to use `app` parameter (values: `claude` or `codex`):
- **Impact**: More standardized code, friendlier error prompts
- **Compatibility**: Frontend fully adapted, users don't need to care about this change
---
## Dependencies
- Updated to **Tauri 2.8.x**
- Updated to **TailwindCSS 4.x**
- Updated to **TanStack Query v5.90.x**
- Maintained **React 18.2.x** and **TypeScript 5.3.x**
</details>
---
## 🌟 About CC Switch
CC Switch is a cross-platform desktop application for managing and switching between different provider configurations for Claude Code and Codex. Built with Tauri 2.0 + React 18 + TypeScript, supporting Windows, macOS, and Linux.
**Core Features**:
- 🔄 One-click switching between multiple AI providers
- 📦 Support for both Claude Code and Codex applications
- 🎨 Modern UI with complete Chinese/English internationalization
- 🔐 Local storage, secure and reliable data
- ☁️ Support for cloud sync configurations
- 🧩 Unified MCP server management
---
**Project Repository**: https://github.com/farion1231/cc-switch

View File

@@ -1,389 +0,0 @@
# CC Switch v3.6.1
> 稳定性提升与用户体验优化(基于 v3.6.0
**[English Version →](../release-note-v3.6.1.md)**
---
## 📦 v3.6.1 新增内容 (2025-11-10)
本次更新主要聚焦于**用户体验优化**和**配置解析健壮性**,修复了多个关键 Bug并增强了用量查询系统。
### ✨ 新增功能
#### 用量查询系统增强
- **凭证解耦** - 用量查询可使用独立的 API Key 和 Base URL不再依赖供应商配置
- 支持不同的查询端点和认证方式
- 根据模板类型自动显示对应的凭证输入框
- General 模板API Key + Base URL
- NewAPI 模板Base URL + Access Token + User ID
- Custom 模板:完全自定义
- **UI 组件升级** - 使用 shadcn/ui Switch 替代原生 checkbox体验更现代
- **表单统一化** - 统一使用 shadcn/ui 输入组件,样式与应用保持一致
- **密码显示切换** - 添加查看/隐藏密码功能API Key、Access Token
#### 表单验证基础设施
- **通用 Schema 库** - 新增 JSON/TOML 通用验证器,减少重复代码
- `jsonConfigSchema`:通用 JSON 对象验证器
- `tomlConfigSchema`:通用 TOML 格式验证器
- `mcpJsonConfigSchema`MCP 专用 JSON 验证器
- **MCP 条件字段验证** - 严格的类型检查
- stdio 类型强制要求 `command` 字段
- http 类型强制要求 `url` 字段
#### 合作伙伴集成
- **PackyCode** - 新增官方合作伙伴
- 添加到 Claude 和 Codex 供应商预设
- 支持 10% 折扣优惠(促销信息集成)
- 新增 Logo 和合作伙伴标识
---
### 🔧 改进优化
#### 用户体验
- **拖拽排序同步** - 托盘菜单顺序实时同步拖拽排序结果
- **错误通知增强** - 切换供应商失败时显示可复制的错误信息
- **移除误导性占位符** - 删除模型输入框的示例文本,避免用户混淆
- **Base URL 自动填充** - 所有非官方供应商类别自动填充 Base URL 输入框
#### 配置解析
- **中文引号规范化** - 自动处理 IME 输入的全角引号,防止 TOML 解析错误
- 支持中文引号(" " ' ')自动转换为 ASCII 引号
- 在 TOML 输入处理器中应用
- Textarea 组件禁用浏览器自动纠正
- **自定义字段保留** - 编辑 Codex MCP TOML 配置时保留未知字段
- 支持 timeout_ms、retry_count 等扩展字段
- 向前兼容未来的 MCP 协议扩展
---
### 🐛 Bug 修复
#### 关键修复
- **修复用量脚本面板白屏崩溃** - FormLabel 组件缺少 FormField context 导致整个应用崩溃
- 替换为独立的 Label 组件
- 根本原因FormLabel 内部调用 useFormField() hook 需要 FormFieldContext
- **修复中文输入法引号解析失败** - IME 输入的全角引号导致 TOML 解析错误
- 新增 textNormalization 工具函数
- 在解析前自动规范化引号
- **修复拖拽排序托盘不同步** (#179) - 拖拽排序后托盘菜单顺序未更新
- 在排序完成后自动调用 updateTrayMenu
- 确保 UI 和托盘菜单保持一致
- **修复 MCP 自定义字段丢失** - 编辑 Codex MCP 配置时自定义字段被静默丢弃
- 使用 spread 操作符保留所有字段
- normalizeServerConfig 中保留未知字段
#### 稳定性改进
- **错误隔离** - 托盘菜单更新失败不再影响主操作流程
- 将托盘更新错误与主操作解耦
- 主操作成功但托盘更新失败时给出警告
- **安全模式匹配** - 替换 `unwrap()` 为安全的 pattern matching
- 避免 panic 导致应用崩溃
- 托盘菜单事件处理使用 match 模式
- **导入配置分类** - 从默认配置导入时自动设置 category 为 `custom`
- 避免导入的配置被误认为官方预设
- 提供更清晰的配置来源标识
---
### 📊 技术统计
```
提交数: 17 commits
代码变更: 31 个文件
- 新增: 1,163 行
- 删除: 811 行
- 净增长: +352 行
贡献者: Jason (16), ZyphrZero (1)
```
**按模块分类**
- UI/用户界面3 commits
- 用量查询系统3 commits
- 配置解析2 commits
- 表单验证1 commit
- 其他改进8 commits
---
### 📥 安装方式
#### macOS
**通过 Homebrew 安装(推荐):**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
**手动下载:**
- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.1-macOS.zip`
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告。请前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
#### Windows
- **安装包**`CC-Switch-v3.6.1-Windows.msi`
- **便携版**`CC-Switch-v3.6.1-Windows-Portable.zip`
#### Linux
- **AppImage**`CC-Switch-v3.6.1-Linux.AppImage`
- **Debian**`CC-Switch-v3.6.1-Linux.deb`
---
### 📚 文档
- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
---
### 🙏 致谢
特别感谢:
- **智谱 AI** - 通过 GLM CODING PLAN 赞助本项目
- **PackyCode** - 新加入的官方合作伙伴
- **ZyphrZero** - 贡献托盘菜单同步修复 (#179)
---
**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1
---
---
## 📜 v3.6.0 完整功能回顾
> 以下内容来自 v3.6.0 (2025-11-07),帮助您了解完整的功能集
<details>
<summary><b>点击展开 v3.6.0 的详细内容 →</b></summary>
## 新增功能
### 编辑模式与供应商管理
- **供应商复制功能** - 一键快速复制现有供应商配置,轻松创建变体配置
- **手动排序功能** - 通过拖拽对供应商进行重新排序,带有视觉推送效果动画
- **编辑模式切换** - 显示/隐藏拖拽手柄,优化编辑体验
### 自定义端点管理
- **多端点配置** - 支持聚合类供应商的多 API 端点配置
- **端点输入可见性** - 为所有非官方供应商自动显示端点字段
### 自定义配置目录(云同步)
- **自定义存储位置** - 自定义 CC Switch 的配置存储目录
- **云同步支持** - 指定到云同步文件夹Dropbox、OneDrive、iCloud Drive、坚果云等即可实现跨设备配置自动同步
- **独立管理** - 通过 Tauri Store 管理,更好的隔离性和可靠性
### 使用量查询增强
- **自动刷新间隔** - 配置定时自动使用量查询,支持自定义间隔时间
- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本
- **增强模板系统** - 自定义空白模板,支持 access token 和 user ID 参数
### 配置目录切换WSL 支持)
- **目录变更自动同步** - 切换 Claude/Codex 配置目录(如 WSL 环境)时,自动同步当前供应商到新目录,无需手动操作
- **后置同步工具** - 统一的 `postChangeSync.ts` 工具,优雅处理错误而不阻塞主流程
- **导入配置自动同步** - 配置导入后自动同步,确保立即生效
- **智能冲突解决** - 区分"完全成功"和"部分成功"状态,提供精确的用户反馈
### 配置编辑器改进
- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化
- **实时 TOML 验证** - Codex 配置的实时语法验证,带有错误高亮
### 编辑时加载 Live 配置
- **保护手动修改** - 编辑当前激活的供应商时,优先显示来自 live 文件的实际生效配置
- **双源策略** - 活动供应商自动从 live 配置加载,非活动供应商从 SSOT 加载
### Claude 配置数据结构增强
- **细粒度模型配置** - 从双键系统升级到四键系统,以匹配官方最新数据结构
- 新增字段:`ANTHROPIC_DEFAULT_HAIKU_MODEL``ANTHROPIC_DEFAULT_SONNET_MODEL``ANTHROPIC_DEFAULT_OPUS_MODEL``ANTHROPIC_MODEL`
- 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`,支持自动迁移
- 后端在首次读写时自动规范化旧配置,带有智能回退链
- UI 从 2 个模型输入字段扩展到 4 个,具有智能默认值
- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段(除 `ANTHROPIC_AUTH_TOKEN` 外)
- **模板变量系统** - 支持动态配置替换(如 KAT-Coder 的 `ENDPOINT_ID` 参数)
- **端点候选列表** - 预定义端点列表,用于速度测试和端点管理
- **视觉主题配置** - 供应商卡片自定义图标和颜色
### 供应商模型更新
- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型
### 新增供应商预设
新增 5 个供应商预设:
- **DMXAPI** - 多模型聚合服务
- **Azure Codex** - 微软 Azure OpenAI 端点
- **AnyRouter** - API 路由服务
- **AiHubMix** - AI 模型集合
- **MiniMax** - 国产 AI 模型提供商
### 合作伙伴推广机制
- 支持生态合作伙伴推广(智谱 GLM Z.ai
- README 中集成赞助商横幅
---
## 改进优化
### 配置与同步
- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息
- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序
- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题
- **导入配置同步** - 修复配置导入后的同步问题
- **配置错误处理** - 配置错误时强制退出,防止静默回退和数据丢失
### UI/UX 增强
- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别
- **统一边框系统** - 所有组件采用一致的边框设计
- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标
- **增强视觉反馈** - 更好的当前供应商视觉指示
- **对话框标准化** - 统一的对话框尺寸和布局一致性
- **表单改进** - 优化模型占位符,简化供应商提示,分类特定提示
- **使用量内联显示** - 使用量信息移至启用按钮旁边,更好地利用空间
### 完整国际化
- **错误消息国际化** - 所有后端错误消息支持中英文
- **托盘菜单国际化** - 系统托盘菜单完全国际化
- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖
---
## Bug 修复
### 配置管理
- 修复 `apiKeyUrl` 优先级问题
- 修复 MCP 同步到另一端功能失效
- 修复配置导入后的同步问题
- 修复 Codex API Key 自动同步
- 修复端点速度测试功能
- 修复供应商复制插入位置(现在插入到原供应商旁边)
- 修复编辑模式下自定义端点保留问题
- 防止配置错误时的静默回退和数据丢失
### 使用量查询
- 修复自动查询间隔时间问题
- 确保刷新按钮点击时显示加载动画
### UI 问题
- 修复名称冲突错误(`get_init_error` 命令)
- 修复保存成功后语言设置回滚
- 修复语言切换状态重置(依赖循环)
- 修复编辑模式按钮对齐
### 启动问题
- 配置错误时强制退出(不再静默回退)
- 消除导致初始化错误的代码重复
---
## 架构重构
### 后端Rust- 5 阶段重构
1. **阶段 1**:统一错误处理(`AppError` + 国际化错误消息)
2. **阶段 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`
3. **阶段 3**:集成测试和事务机制(配置快照 + 失败回滚)
4. **阶段 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`
5. **阶段 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
### 前端React + TypeScript- 4 阶段重构
1. **阶段 1**测试基础设施vitest + MSW + @testing-library/react
2. **阶段 2**:提取自定义 hooks`useProviderActions``useMcpActions``useSettings``useImportExport` 等)
3. **阶段 3**:组件拆分和业务逻辑提取
4. **阶段 4**:代码清理和格式化统一
### 测试体系
- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖
- **集成测试** - 关键流程覆盖App、SettingsDialog、MCP 面板)
- **MSW 模拟** - 后端 API 模拟确保测试独立性
- **测试基础设施** - vitest + MSW + @testing-library/react
### 代码质量
- **统一参数格式** - 所有 Tauri 命令迁移到 camelCaseTauri 2 规范)
- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义
- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析
- **DRY 违规清理** - 消除整个代码库中的代码重复
- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`
---
## 内部优化(用户无感知)
### 移除遗留迁移逻辑
v3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑:
- **影响**:提升启动性能,代码更简洁
- **兼容性**v2 格式配置完全兼容,无需任何操作
- **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移,然后再升级到 v3.6.0
### 命令参数标准化
后端统一使用 `app` 参数(取值:`claude``codex`
- **影响**:代码更规范,错误提示更友好
- **兼容性**:前端已完全适配,用户无需关心此变更
---
## 依赖更新
- 更新到 **Tauri 2.8.x**
- 更新到 **TailwindCSS 4.x**
- 更新到 **TanStack Query v5.90.x**
- 保持 **React 18.2.x****TypeScript 5.3.x**
</details>
---
## 🌟 关于 CC Switch
CC Switch 是一个跨平台桌面应用,用于管理和切换 Claude Code 与 Codex 的不同供应商配置。基于 Tauri 2.0 + React 18 + TypeScript 构建,支持 Windows、macOS、Linux。
**核心特性**
- 🔄 一键切换多个 AI 供应商
- 📦 支持 Claude Code 和 Codex 双应用
- 🎨 现代化 UI完整的中英文国际化
- 🔐 本地存储,数据安全可靠
- ☁️ 支持云同步配置
- 🧩 MCP 服务器统一管理
---
**项目地址**: https://github.com/farion1231/cc-switch

View File

@@ -1,439 +0,0 @@
# CC Switch v3.7.0
> From Provider Switcher to All-in-One AI CLI Management Platform
**[中文更新说明 Chinese Documentation →](release-note-v3.7.0-zh.md)**
---
## Overview
CC Switch v3.7.0 introduces six major features with over 18,000 lines of new code.
**Release Date**: 2025-11-19
**Commits**: 85 from v3.6.0
**Code Changes**: 152 files, +18,104 / -3,732 lines
---
## New Features
### Gemini CLI Integration
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
**Core Capabilities**:
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **Full MCP support** - Complete MCP server management for Gemini
- **Deep link integration** - Import via `ccswitch://` protocol
- **System tray** - Quick-switch from tray menu
**Provider Presets**:
- **Google Official** - OAuth authentication support
- **PackyCode** - Partner integration
- **Custom** - Full customization support
**Technical Implementation**:
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
- Form synchronization with environment editor
- Dual-file atomic writes
---
### MCP v3.7.0 Unified Architecture
Complete refactoring of MCP management system for cross-application unification.
**Architecture Improvements**:
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport** - New Server-Sent Events support
- **Smart parser** - Fault-tolerant JSON parsing
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
- **Extended fields** - Preserve custom TOML fields
**User Experience**:
- Default app selection in forms
- JSON formatter for validation
- Improved visual hierarchy
- Better error messages
**Import/Export**:
- Unified import from all three apps
- Bidirectional synchronization
- State preservation
---
### Claude Skills Management System
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
**GitHub Integration**:
- Auto-scan skills from GitHub repositories
- Pre-configured repos:
- `ComposioHQ/awesome-claude-skills` - Curated collection
- `anthropics/skills` - Official Anthropic skills
- `cexll/myclaude` - Community contributions
- Add custom repositories
- Subdirectory scanning support (`skillsPath`)
**Lifecycle Management**:
- **Discover** - Auto-detect `SKILL.md` files
- **Install** - One-click to `~/.claude/skills/`
- **Uninstall** - Safe removal with tracking
- **Update** - Check for updates (infrastructure ready)
**Technical Architecture**:
- **Backend**: `SkillService` (526 lines) with GitHub API integration
- **Frontend**: SkillsPage, SkillCard, RepoManager
- **UI Components**: Badge, Card, Table (shadcn/ui)
- **State**: Persistent storage in `skills.json`
- **i18n**: 47+ translation keys
---
### Prompts Management System
**Approximately 1,300 lines of code** - Complete system prompt management.
**Multi-Preset Management**:
- Create unlimited prompt presets
- Quick switch between presets
- One active prompt at a time
- Delete protection for active prompts
**Cross-App Support**:
- **Claude**: `~/.claude/CLAUDE.md`
- **Codex**: `~/.codex/AGENTS.md`
- **Gemini**: `~/.gemini/GEMINI.md`
**Markdown Editor**:
- Full-featured CodeMirror 6 integration
- Syntax highlighting
- Dark theme (One Dark)
- Real-time preview
**Smart Synchronization**:
- **Auto-write** - Immediately write to live files
- **Backfill protection** - Save current content before switching
- **Auto-import** - Import from live files on first launch
- **Modification protection** - Preserve manual modifications
**Technical Implementation**:
- **Backend**: `PromptService` (213 lines)
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
- **Hooks**: usePromptActions (152 lines)
- **i18n**: 41+ translation keys
---
### Deep Link Protocol (ccswitch://)
One-click provider configuration import via URL scheme.
**Features**:
- Protocol registration on all platforms
- Import from shared links
- Lifecycle integration
- Security validation
---
### Environment Variable Conflict Detection
Intelligent detection and management of configuration conflicts.
**Detection Scope**:
- **Claude & Codex** - Cross-app conflicts
- **Gemini** - Auto-discovery
- **MCP** - Server configuration conflicts
**Management Features**:
- Visual conflict indicators
- Resolution suggestions
- Override warnings
- Backup before changes
---
## Improvements
### Provider Management
**New Presets**:
- **DouBaoSeed** - ByteDance's DouBao
- **Kimi For Coding** - Moonshot AI
- **BaiLing** - BaiLing AI
- **Removed AnyRouter** - To avoid confusion
**Enhancements**:
- Model name configuration for Codex and Gemini
- Provider notes field for organization
- Enhanced preset metadata
### Configuration Management
- **Common config migration** - From localStorage to `config.json`
- **Unified persistence** - Shared across all apps
- **Auto-import** - First launch configuration import
- **Backfill priority** - Correct handling of live files
### UI/UX Improvements
**Design System**:
- **macOS native** - System-aligned color scheme
- **Window centering** - Default centered position
- **Visual polish** - Improved spacing and hierarchy
**Interactions**:
- **Password input** - Fixed Edge/IE reveal buttons
- **URL overflow** - Fixed card overflow
- **Error copying** - Copy-to-clipboard errors
- **Tray sync** - Real-time drag-and-drop sync
---
## Bug Fixes
### Critical Fixes
- **Usage script validation** - Boundary checks
- **Gemini validation** - Relaxed constraints
- **TOML parsing** - CJK quote handling
- **MCP fields** - Custom field preservation
- **White screen** - FormLabel crash fix
### Stability
- **Tray safety** - Pattern matching instead of unwrap
- **Error isolation** - Tray failures don't block operations
- **Import classification** - Correct category assignment
### UI Fixes
- **Model placeholders** - Removed misleading hints
- **Base URL** - Auto-fill for third-party providers
- **Drag sort** - Tray menu synchronization
---
## Technical Improvements
### Architecture
**MCP v3.7.0**:
- Removed legacy code (~1,000 lines)
- Unified initialization structure
- Backward compatibility maintained
- Comprehensive code formatting
**Platform Compatibility**:
- Windows winreg API fix (v0.52)
- Safe pattern matching (no `unwrap()`)
- Cross-platform tray handling
### Configuration
**Synchronization**:
- MCP sync across all apps
- Gemini form-editor sync
- Dual-file reading (.env + settings.json)
**Validation**:
- Input boundary checks
- TOML quote normalization (CJK)
- Custom field preservation
- Enhanced error messages
### Code Quality
**Type Safety**:
- Complete TypeScript coverage
- Rust type refinements
- API contract validation
**Testing**:
- Simplified assertions
- Better test coverage
- Integration test updates
**Dependencies**:
- Tauri 2.8.x
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
- Frontend: CodeMirror 6 packages
- winreg 0.52 (Windows)
---
## Technical Statistics
```
Total Changes:
- Commits: 85
- Files: 152 changed
- Additions: +18,104 lines
- Deletions: -3,732 lines
New Modules:
- Skills Management: 2,034 lines (21 files)
- Prompts Management: 1,302 lines (20 files)
- Gemini Integration: ~1,000 lines
- MCP Refactor: ~3,000 lines refactored
Code Distribution:
- Backend (Rust): ~4,500 lines new
- Frontend (React): ~3,000 lines new
- Configuration: ~1,500 lines refactored
- Tests: ~500 lines
```
---
## Strategic Positioning
### From Tool to Platform
v3.7.0 represents a shift in CC Switch's positioning:
| Aspect | v3.6 | v3.7.0 |
| ----------------- | ------------------------ | ---------------------------- |
| **Identity** | Provider Switcher | AI CLI Management Platform |
| **Scope** | Configuration Management | Ecosystem Management |
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
| **Customization** | Manual editing | Visual management (Prompts) |
| **Integration** | Isolated apps | Unified management (MCP) |
### Six Pillars of AI CLI Management
1. **Configuration Management** - Provider switching and management
2. **Capability Extension** - Skills installation and lifecycle
3. **Behavior Customization** - System prompt presets
4. **Ecosystem Integration** - Deep links and sharing
5. **Multi-AI Support** - Claude/Codex/Gemini
6. **Intelligent Detection** - Conflict prevention
---
## Download & Installation
### System Requirements
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-v3.7.0-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-v3.7.0-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-v3.7.0-Linux.AppImage` or `.deb`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
---
## Migration Notes
### From v3.6.x
**Automatic migration** - No action required, configs are fully compatible
### From v3.1.x or Earlier
**Two-step migration required**:
1. First upgrade to v3.2.x (performs one-time migration)
2. Then upgrade to v3.7.0
### New Features
- **Skills**: No migration needed, start fresh
- **Prompts**: Auto-import from live files on first launch
- **Gemini**: Install Gemini CLI separately if needed
- **MCP v3.7.0**: Backward compatible with previous configs
---
## Acknowledgments
### Contributors
Thanks to all contributors who made this release possible:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
- Community members for testing and feedback
### Sponsors
**Z.ai** - GLM CODING PLAN sponsor
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API relay service partner
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
---
## Feedback & Support
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **Documentation**: [README](../README.md)
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
---
## What's Next
**v3.8.0 Preview** (Tentative):
- Local proxy functionality
Stay tuned for more updates!
---
**Happy Coding!**

View File

@@ -1,435 +0,0 @@
# CC Switch v3.7.0
> 从供应商切换器到 AI CLI 一体化管理平台
**[English Version →](release-note-v3.7.0-en.md)**
---
## 概览
CC Switch v3.7.0 新增六大核心功能,新增超过 18,000 行代码。
**发布日期**2025-11-19
**提交数量**:从 v3.6.0 开始 85 个提交
**代码变更**152 个文件,+18,104 / -3,732 行
---
## 新增功能
### Gemini CLI 集成
完整支持 Google Gemini CLI成为第三个支持的应用Claude Code、Codex、Gemini
**核心能力**
- **双文件配置** - 同时支持 `.env``settings.json` 格式
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等环境变量
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
- **系统托盘** - 从托盘菜单快速切换
**供应商预设**
- **Google Official** - 支持 OAuth 认证
- **PackyCode** - 合作伙伴集成
- **自定义** - 完全自定义支持
**技术实现**
- 新增后端模块:`gemini_config.rs`20KB`gemini_mcp.rs`
- 表单与环境编辑器同步
- 双文件原子写入
---
### MCP v3.7.0 统一架构
MCP 管理系统完整重构,实现跨应用统一管理。
**架构改进**
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
- **SSE 传输类型** - 新增 Server-Sent Events 支持
- **智能解析器** - 容错性 JSON 解析
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
- **扩展字段** - 保留自定义 TOML 字段
**用户体验**
- 表单中的默认应用选择
- JSON 格式化器用于验证
- 改进的视觉层次
- 更好的错误消息
**导入/导出**
- 统一从三个应用导入
- 双向同步
- 状态保持
---
### Claude Skills 管理系统
**约 2,000 行代码** - 完整的技能生态平台。
**GitHub 集成**
- 从 GitHub 仓库自动扫描技能
- 预配置仓库:
- `ComposioHQ/awesome-claude-skills` - 精选集合
- `anthropics/skills` - Anthropic 官方技能
- `cexll/myclaude` - 社区贡献
- 添加自定义仓库
- 子目录扫描支持(`skillsPath`
**生命周期管理**
- **发现** - 自动检测 `SKILL.md` 文件
- **安装** - 一键安装到 `~/.claude/skills/`
- **卸载** - 安全移除并跟踪状态
- **更新** - 检查更新(基础设施已就绪)
**技术架构**
- **后端**`SkillService`526 行)集成 GitHub API
- **前端**SkillsPage、SkillCard、RepoManager
- **UI 组件**Badge、Card、Tableshadcn/ui
- **状态**:持久化存储在 `skills.json`
- **国际化**47+ 个翻译键
---
### Prompts 管理系统
**约 1,300 行代码** - 完整的系统提示词管理。
**多预设管理**
- 创建无限数量的提示词预设
- 快速在预设间切换
- 同时只能激活一个提示词
- 活动提示词删除保护
**跨应用支持**
- **Claude**`~/.claude/CLAUDE.md`
- **Codex**`~/.codex/AGENTS.md`
- **Gemini**`~/.gemini/GEMINI.md`
**Markdown 编辑器**
- 完整的 CodeMirror 6 集成
- 语法高亮
- 暗色主题One Dark
- 实时预览
**智能同步**
- **自动写入** - 立即写入 live 文件
- **回填保护** - 切换前保存当前内容
- **自动导入** - 首次启动从 live 文件导入
- **修改保护** - 保留手动修改
**技术实现**
- **后端**`PromptService`213 行)
- **前端**PromptPanel177、PromptFormModal160、MarkdownEditor159
- **Hooks**usePromptActions152 行)
- **国际化**41+ 个翻译键
---
### 深度链接协议ccswitch://
通过 URL 方案一键导入供应商配置。
**功能特性**
- 所有平台的协议注册
- 从共享链接导入
- 生命周期集成
- 安全验证
---
### 环境变量冲突检测
智能检测和管理配置冲突。
**检测范围**
- **Claude & Codex** - 跨应用冲突
- **Gemini** - 自动发现
- **MCP** - 服务器配置冲突
**管理功能**
- 可视化冲突指示器
- 解决建议
- 覆盖警告
- 更改前备份
---
## 改进优化
### 供应商管理
**新增预设**
- **DouBaoSeed** - 字节跳动的豆包
- **Kimi For Coding** - 月之暗面
- **BaiLing** - 百灵 AI
- **移除 AnyRouter** - 避免误导
**增强功能**
- Codex 和 Gemini 的模型名称配置
- 供应商备注字段用于组织
- 增强的预设元数据
### 配置管理
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
- **统一持久化** - 跨所有应用共享
- **自动导入** - 首次启动配置导入
- **回填优先级** - 正确处理 live 文件
### UI/UX 改进
**设计系统**
- **macOS 原生** - 与系统对齐的配色方案
- **窗口居中** - 默认居中位置
- **视觉优化** - 改进的间距和层次
**交互优化**
- **密码输入** - 修复 Edge/IE 显示按钮
- **URL 溢出** - 修复卡片溢出
- **错误复制** - 可复制到剪贴板的错误
- **托盘同步** - 实时拖放同步
---
## Bug 修复
### 关键修复
- **用量脚本验证** - 边界检查
- **Gemini 验证** - 放宽约束
- **TOML 解析** - CJK 引号处理
- **MCP 字段** - 自定义字段保留
- **白屏** - FormLabel 崩溃修复
### 稳定性
- **托盘安全** - 模式匹配替代 unwrap
- **错误隔离** - 托盘失败不阻塞操作
- **导入分类** - 正确的类别分配
### UI 修复
- **模型占位符** - 移除误导性提示
- **Base URL** - 第三方供应商自动填充
- **拖拽排序** - 托盘菜单同步
---
## 技术改进
### 架构
**MCP v3.7.0**
- 移除遗留代码(约 1,000 行)
- 统一初始化结构
- 保持向后兼容性
- 全面的代码格式化
**平台兼容性**
- Windows winreg API 修复v0.52
- 安全模式匹配(无 `unwrap()`
- 跨平台托盘处理
### 配置
**同步机制**
- 跨所有应用的 MCP 同步
- Gemini 表单-编辑器同步
- 双文件读取(.env + settings.json
**验证增强**
- 输入边界检查
- TOML 引号规范化CJK
- 自定义字段保留
- 增强的错误消息
### 代码质量
**类型安全**
- 完整的 TypeScript 覆盖
- Rust 类型改进
- API 契约验证
**测试**
- 简化的断言
- 更好的测试覆盖
- 集成测试更新
**依赖项**
- Tauri 2.8.x
- Rust`anyhow``zip``serde_yaml``tempfile`
- 前端CodeMirror 6 包
- winreg 0.52Windows
---
## 技术统计
```
总体变更:
- 提交数85
- 文件数152 个文件变更
- 新增:+18,104 行
- 删除:-3,732 行
新增模块:
- Skills 管理2,034 行21 个文件)
- Prompts 管理1,302 行20 个文件)
- Gemini 集成:约 1,000 行
- MCP 重构:约 3,000 行重构
代码分布:
- 后端Rust约 4,500 行新增
- 前端React约 3,000 行新增
- 配置:约 1,500 行重构
- 测试:约 500 行
```
---
## 战略定位
### 从工具到平台
v3.7.0 代表了 CC Switch 定位的转变:
| 方面 | v3.6 | v3.7.0 |
| -------- | -------------- | ----------------------- |
| **身份** | 供应商切换器 | AI CLI 管理平台 |
| **范围** | 配置管理 | 生态系统管理 |
| **应用** | Claude + Codex | Claude + Codex + Gemini |
| **能力** | 切换配置 | 扩展能力Skills |
| **定制** | 手动编辑 | 可视化管理Prompts |
| **集成** | 孤立应用 | 统一管理MCP |
### AI CLI 管理六大支柱
1. **配置管理** - 供应商切换和管理
2. **能力扩展** - Skills 安装和生命周期
3. **行为定制** - 系统提示词预设
4. **生态集成** - 深度链接和共享
5. **多 AI 支持** - Claude/Codex/Gemini
6. **智能检测** - 冲突预防
---
## 下载与安装
### 系统要求
- **Windows**Windows 10+
- **macOS**macOS 10.15Catalina+
- **Linux**Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### 下载链接
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
- **Windows**`CC-Switch-v3.7.0-Windows.msi``-Portable.zip`
- **macOS**`CC-Switch-v3.7.0-macOS.tar.gz``.zip`
- **Linux**`CC-Switch-v3.7.0-Linux.AppImage``.deb`
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
---
## 迁移说明
### 从 v3.6.x 升级
**自动迁移** - 无需任何操作,配置完全兼容
### 从 v3.1.x 或更早版本升级
**需要两步迁移**
1. 首先升级到 v3.2.x执行一次性迁移
2. 然后升级到 v3.7.0
### 新功能
- **Skills**:无需迁移,全新开始
- **Prompts**:首次启动时从 live 文件自动导入
- **Gemini**:需要单独安装 Gemini CLI
- **MCP v3.7.0**:与之前的配置向后兼容
---
## 致谢
### 贡献者
感谢所有让这个版本成为可能的贡献者:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Geimini 集成实现
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
- 社区成员的测试和反馈
### 赞助商
**Z.ai** - GLM CODING PLAN 赞助商
[通过此链接获得 10% 折扣](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API 中继服务合作伙伴
[使用 "cc-switch" 代码注册可享受 10% 折扣](https://www.packyapi.com/register?aff=cc-switch)
---
## 反馈与支持
- **问题反馈**[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **讨论**[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **文档**[README](../README_ZH.md)
- **更新日志**[CHANGELOG.md](../CHANGELOG.md)
---
## 未来展望
**v3.8.0 预览**(暂定):
- 本地代理功能
敬请期待更多更新!

View File

@@ -1,481 +0,0 @@
# CC Switch v3.7.1
> Stability Enhancements and User Experience Improvements
**[中文更新说明 Chinese Documentation →](release-note-v3.7.1-zh.md)**
---
## v3.7.1 Updates
**Release Date**: 2025-11-22
**Code Changes**: 17 files, +524 / -81 lines
### Bug Fixes
- **Fix Third-Party Skills Installation Failure** (#268)
Fixed installation issues for skills repositories with custom subdirectories, now supports repos like `ComposioHQ/awesome-claude-skills` with subdirectories
- **Fix Gemini Configuration Persistence Issue**
Resolved the issue where settings.json edits in Gemini form were lost when switching providers
- **Prevent Dialogs from Closing on Overlay Click**
Added protection against clicking overlay/backdrop, preventing accidental form data loss across all 11 dialog components
### New Features
- **Gemini Configuration Directory Support** (#255)
Added Gemini configuration directory option in settings, supports customizing `~/.gemini/` path
- **ArchLinux Installation Support** (#259)
Added AUR installation method: `paru -S cc-switch-bin`
### Improvements
- **Skills Error Message i18n Enhancement**
Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions, extended download timeout from 15s to 60s
- **Code Formatting**
Applied unified Rust and TypeScript code formatting standards
### Download
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the latest version
---
## v3.7.0 Complete Release Notes
> From Provider Switcher to All-in-One AI CLI Management Platform
**Release Date**: 2025-11-19
**Commits**: 85 from v3.6.0
**Code Changes**: 152 files, +18,104 / -3,732 lines
---
## New Features
### Gemini CLI Integration
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
**Core Capabilities**:
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **Full MCP support** - Complete MCP server management for Gemini
- **Deep link integration** - Import via `ccswitch://` protocol
- **System tray** - Quick-switch from tray menu
**Provider Presets**:
- **Google Official** - OAuth authentication support
- **PackyCode** - Partner integration
- **Custom** - Full customization support
**Technical Implementation**:
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
- Form synchronization with environment editor
- Dual-file atomic writes
---
### MCP v3.7.0 Unified Architecture
Complete refactoring of MCP management system for cross-application unification.
**Architecture Improvements**:
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport** - New Server-Sent Events support
- **Smart parser** - Fault-tolerant JSON parsing
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
- **Extended fields** - Preserve custom TOML fields
**User Experience**:
- Default app selection in forms
- JSON formatter for validation
- Improved visual hierarchy
- Better error messages
**Import/Export**:
- Unified import from all three apps
- Bidirectional synchronization
- State preservation
---
### Claude Skills Management System
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
**GitHub Integration**:
- Auto-scan skills from GitHub repositories
- Pre-configured repos:
- `ComposioHQ/awesome-claude-skills` - Curated collection
- `anthropics/skills` - Official Anthropic skills
- `cexll/myclaude` - Community contributions
- Add custom repositories
- Subdirectory scanning support (`skillsPath`)
**Lifecycle Management**:
- **Discover** - Auto-detect `SKILL.md` files
- **Install** - One-click to `~/.claude/skills/`
- **Uninstall** - Safe removal with tracking
- **Update** - Check for updates (infrastructure ready)
**Technical Architecture**:
- **Backend**: `SkillService` (526 lines) with GitHub API integration
- **Frontend**: SkillsPage, SkillCard, RepoManager
- **UI Components**: Badge, Card, Table (shadcn/ui)
- **State**: Persistent storage in `config.json`
- **i18n**: 47+ translation keys
---
### Prompts Management System
**Approximately 1,300 lines of code** - Complete system prompt management.
**Multi-Preset Management**:
- Create unlimited prompt presets
- Quick switch between presets
- One active prompt at a time
- Delete protection for active prompts
**Cross-App Support**:
- **Claude**: `~/.claude/CLAUDE.md`
- **Codex**: `~/.codex/AGENTS.md`
- **Gemini**: `~/.gemini/GEMINI.md`
**Markdown Editor**:
- Full-featured CodeMirror 6 integration
- Syntax highlighting
- Dark theme (One Dark)
- Real-time preview
**Smart Synchronization**:
- **Auto-write** - Immediately write to live files
- **Backfill protection** - Save current content before switching
- **Auto-import** - Import from live files on first launch
- **Modification protection** - Preserve manual modifications
**Technical Implementation**:
- **Backend**: `PromptService` (213 lines)
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
- **Hooks**: usePromptActions (152 lines)
- **i18n**: 41+ translation keys
---
### Deep Link Protocol (ccswitch://)
One-click provider configuration import via URL scheme.
**Features**:
- Protocol registration on all platforms
- Import from shared links
- Lifecycle integration
- Security validation
---
### Environment Variable Conflict Detection
Intelligent detection and management of configuration conflicts.
**Detection Scope**:
- **Claude & Codex** - Cross-app conflicts
- **Gemini** - Auto-discovery
- **MCP** - Server configuration conflicts
**Management Features**:
- Visual conflict indicators
- Resolution suggestions
- Override warnings
- Backup before changes
---
## Improvements
### Provider Management
**New Presets**:
- **DouBaoSeed** - ByteDance's DouBao
- **Kimi For Coding** - Moonshot AI
- **BaiLing** - BaiLing AI
- **Removed AnyRouter** - To avoid confusion
**Enhancements**:
- Model name configuration for Codex and Gemini
- Provider notes field for organization
- Enhanced preset metadata
### Configuration Management
- **Common config migration** - From localStorage to `config.json`
- **Unified persistence** - Shared across all apps
- **Auto-import** - First launch configuration import
- **Backfill priority** - Correct handling of live files
### UI/UX Improvements
**Design System**:
- **macOS native** - System-aligned color scheme
- **Window centering** - Default centered position
- **Visual polish** - Improved spacing and hierarchy
**Interactions**:
- **Password input** - Fixed Edge/IE reveal buttons
- **URL overflow** - Fixed card overflow
- **Error copying** - Copy-to-clipboard errors
- **Tray sync** - Real-time drag-and-drop sync
---
## Bug Fixes
### Critical Fixes
- **Usage script validation** - Boundary checks
- **Gemini validation** - Relaxed constraints
- **TOML parsing** - CJK quote handling
- **MCP fields** - Custom field preservation
- **White screen** - FormLabel crash fix
### Stability
- **Tray safety** - Pattern matching instead of unwrap
- **Error isolation** - Tray failures don't block operations
- **Import classification** - Correct category assignment
### UI Fixes
- **Model placeholders** - Removed misleading hints
- **Base URL** - Auto-fill for third-party providers
- **Drag sort** - Tray menu synchronization
---
## Technical Improvements
### Architecture
**MCP v3.7.0**:
- Removed legacy code (~1,000 lines)
- Unified initialization structure
- Backward compatibility maintained
- Comprehensive code formatting
**Platform Compatibility**:
- Windows winreg API fix (v0.52)
- Safe pattern matching (no `unwrap()`)
- Cross-platform tray handling
### Configuration
**Synchronization**:
- MCP sync across all apps
- Gemini form-editor sync
- Dual-file reading (.env + settings.json)
**Validation**:
- Input boundary checks
- TOML quote normalization (CJK)
- Custom field preservation
- Enhanced error messages
### Code Quality
**Type Safety**:
- Complete TypeScript coverage
- Rust type refinements
- API contract validation
**Testing**:
- Simplified assertions
- Better test coverage
- Integration test updates
**Dependencies**:
- Tauri 2.8.x
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
- Frontend: CodeMirror 6 packages
- winreg 0.52 (Windows)
---
## Technical Statistics
```
Total Changes:
- Commits: 85
- Files: 152 changed
- Additions: +18,104 lines
- Deletions: -3,732 lines
New Modules:
- Skills Management: 2,034 lines (21 files)
- Prompts Management: 1,302 lines (20 files)
- Gemini Integration: ~1,000 lines
- MCP Refactor: ~3,000 lines refactored
Code Distribution:
- Backend (Rust): ~4,500 lines new
- Frontend (React): ~3,000 lines new
- Configuration: ~1,500 lines refactored
- Tests: ~500 lines
```
---
## Strategic Positioning
### From Tool to Platform
v3.7.0 represents a shift in CC Switch's positioning:
| Aspect | v3.6 | v3.7.0 |
| ----------------- | ------------------------ | ---------------------------- |
| **Identity** | Provider Switcher | AI CLI Management Platform |
| **Scope** | Configuration Management | Ecosystem Management |
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
| **Customization** | Manual editing | Visual management (Prompts) |
| **Integration** | Isolated apps | Unified management (MCP) |
### Six Pillars of AI CLI Management
1. **Configuration Management** - Provider switching and management
2. **Capability Extension** - Skills installation and lifecycle
3. **Behavior Customization** - System prompt presets
4. **Ecosystem Integration** - Deep links and sharing
5. **Multi-AI Support** - Claude/Codex/Gemini
6. **Intelligent Detection** - Conflict prevention
---
## Download & Installation
### System Requirements
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-Linux.AppImage` or `.deb`
- **ArchLinux**: `paru -S cc-switch-bin`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
---
## Migration Notes
### From v3.6.x
**Automatic migration** - No action required, configs are fully compatible
### From v3.1.x or Earlier
**Two-step migration required**:
1. First upgrade to v3.2.x (performs one-time migration)
2. Then upgrade to v3.7.0
### New Features
- **Skills**: No migration needed, start fresh
- **Prompts**: Auto-import from live files on first launch
- **Gemini**: Install Gemini CLI separately if needed
- **MCP v3.7.0**: Backward compatible with previous configs
---
## Acknowledgments
### Contributors
Thanks to all contributors who made this release possible:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
- Community members for testing and feedback
### Sponsors
**Z.ai** - GLM CODING PLAN sponsor
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API relay service partner
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
**ShanDianShuo** - Local-first AI voice input
[Free download](https://shandianshuo.cn) for Mac/Win
---
## Feedback & Support
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **Documentation**: [README](../README.md)
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
---
## What's Next
**v3.8.0 Preview** (Tentative):
- Local proxy functionality
Stay tuned for more updates!
---
**Happy Coding!**

View File

@@ -1,481 +0,0 @@
# CC Switch v3.7.1
> 稳定性增强与用户体验改进
**[English Version →](release-note-v3.7.1-en.md)**
---
## v3.7.1 更新内容
**发布日期**2025-11-22
**代码变更**17 个文件,+524 / -81 行
### Bug 修复
- **修复 Skills 第三方仓库安装失败** (#268)
修复使用自定义子目录的 skills 仓库无法安装的问题,支持类似 `ComposioHQ/awesome-claude-skills` 这样带子目录的仓库
- **修复 Gemini 配置持久化问题**
解决在 Gemini 表单中编辑 settings.json 后,切换供应商时修改丢失的问题
- **防止对话框意外关闭**
添加点击遮罩时的保护,避免误操作导致表单数据丢失,影响所有 11 个对话框组件
### 新增功能
- **Gemini 配置目录支持** (#255)
在设置中添加 Gemini 配置目录选项,支持自定义 `~/.gemini/` 路径
- **ArchLinux 安装支持** (#259)
添加 AUR 安装方式:`paru -S cc-switch-bin`
### 改进
- **Skills 错误消息国际化增强**
新增 28+ 条详细错误消息(中英文),提供具体的解决建议,下载超时从 15 秒延长到 60 秒
- **代码格式化**
应用统一的 Rust 和 TypeScript 代码格式化标准
### 下载
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载最新版本
---
## v3.7.0 完整更新说明
> 从供应商切换器到 AI CLI 一体化管理平台
**发布日期**2025-11-19
**提交数量**:从 v3.6.0 开始 85 个提交
**代码变更**152 个文件,+18,104 / -3,732 行
---
## 新增功能
### Gemini CLI 集成
完整支持 Google Gemini CLI成为第三个支持的应用Claude Code、Codex、Gemini
**核心能力**
- **双文件配置** - 同时支持 `.env``settings.json` 格式
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等环境变量
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
- **系统托盘** - 从托盘菜单快速切换
**供应商预设**
- **Google Official** - 支持 OAuth 认证
- **PackyCode** - 合作伙伴集成
- **自定义** - 完全自定义支持
**技术实现**
- 新增后端模块:`gemini_config.rs`20KB`gemini_mcp.rs`
- 表单与环境编辑器同步
- 双文件原子写入
---
### MCP v3.7.0 统一架构
MCP 管理系统完整重构,实现跨应用统一管理。
**架构改进**
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
- **SSE 传输类型** - 新增 Server-Sent Events 支持
- **智能解析器** - 容错性 JSON 解析
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
- **扩展字段** - 保留自定义 TOML 字段
**用户体验**
- 表单中的默认应用选择
- JSON 格式化器用于验证
- 改进的视觉层次
- 更好的错误消息
**导入/导出**
- 统一从三个应用导入
- 双向同步
- 状态保持
---
### Claude Skills 管理系统
**约 2,000 行代码** - 完整的技能生态平台。
**GitHub 集成**
- 从 GitHub 仓库自动扫描技能
- 预配置仓库:
- `ComposioHQ/awesome-claude-skills` - 精选集合
- `anthropics/skills` - Anthropic 官方技能
- `cexll/myclaude` - 社区贡献
- 添加自定义仓库
- 子目录扫描支持(`skillsPath`
**生命周期管理**
- **发现** - 自动检测 `SKILL.md` 文件
- **安装** - 一键安装到 `~/.claude/skills/`
- **卸载** - 安全移除并跟踪状态
- **更新** - 检查更新(基础设施已就绪)
**技术架构**
- **后端**`SkillService`526 行)集成 GitHub API
- **前端**SkillsPage、SkillCard、RepoManager
- **UI 组件**Badge、Card、Tableshadcn/ui
- **状态**:持久化存储在 `config.json`
- **国际化**47+ 个翻译键
---
### Prompts 管理系统
**约 1,300 行代码** - 完整的系统提示词管理。
**多预设管理**
- 创建无限数量的提示词预设
- 快速在预设间切换
- 同时只能激活一个提示词
- 活动提示词删除保护
**跨应用支持**
- **Claude**`~/.claude/CLAUDE.md`
- **Codex**`~/.codex/AGENTS.md`
- **Gemini**`~/.gemini/GEMINI.md`
**Markdown 编辑器**
- 完整的 CodeMirror 6 集成
- 语法高亮
- 暗色主题One Dark
- 实时预览
**智能同步**
- **自动写入** - 立即写入 live 文件
- **回填保护** - 切换前保存当前内容
- **自动导入** - 首次启动从 live 文件导入
- **修改保护** - 保留手动修改
**技术实现**
- **后端**`PromptService`213 行)
- **前端**PromptPanel177、PromptFormModal160、MarkdownEditor159
- **Hooks**usePromptActions152 行)
- **国际化**41+ 个翻译键
---
### 深度链接协议ccswitch://
通过 URL 方案一键导入供应商配置。
**功能特性**
- 所有平台的协议注册
- 从共享链接导入
- 生命周期集成
- 安全验证
---
### 环境变量冲突检测
智能检测和管理配置冲突。
**检测范围**
- **Claude & Codex** - 跨应用冲突
- **Gemini** - 自动发现
- **MCP** - 服务器配置冲突
**管理功能**
- 可视化冲突指示器
- 解决建议
- 覆盖警告
- 更改前备份
---
## 改进优化
### 供应商管理
**新增预设**
- **DouBaoSeed** - 字节跳动的豆包
- **Kimi For Coding** - 月之暗面
- **BaiLing** - 百灵 AI
- **移除 AnyRouter** - 避免误导
**增强功能**
- Codex 和 Gemini 的模型名称配置
- 供应商备注字段用于组织
- 增强的预设元数据
### 配置管理
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
- **统一持久化** - 跨所有应用共享
- **自动导入** - 首次启动配置导入
- **回填优先级** - 正确处理 live 文件
### UI/UX 改进
**设计系统**
- **macOS 原生** - 与系统对齐的配色方案
- **窗口居中** - 默认居中位置
- **视觉优化** - 改进的间距和层次
**交互优化**
- **密码输入** - 修复 Edge/IE 显示按钮
- **URL 溢出** - 修复卡片溢出
- **错误复制** - 可复制到剪贴板的错误
- **托盘同步** - 实时拖放同步
---
## Bug 修复
### 关键修复
- **用量脚本验证** - 边界检查
- **Gemini 验证** - 放宽约束
- **TOML 解析** - CJK 引号处理
- **MCP 字段** - 自定义字段保留
- **白屏** - FormLabel 崩溃修复
### 稳定性
- **托盘安全** - 模式匹配替代 unwrap
- **错误隔离** - 托盘失败不阻塞操作
- **导入分类** - 正确的类别分配
### UI 修复
- **模型占位符** - 移除误导性提示
- **Base URL** - 第三方供应商自动填充
- **拖拽排序** - 托盘菜单同步
---
## 技术改进
### 架构
**MCP v3.7.0**
- 移除遗留代码(约 1,000 行)
- 统一初始化结构
- 保持向后兼容性
- 全面的代码格式化
**平台兼容性**
- Windows winreg API 修复v0.52
- 安全模式匹配(无 `unwrap()`
- 跨平台托盘处理
### 配置
**同步机制**
- 跨所有应用的 MCP 同步
- Gemini 表单-编辑器同步
- 双文件读取(.env + settings.json
**验证增强**
- 输入边界检查
- TOML 引号规范化CJK
- 自定义字段保留
- 增强的错误消息
### 代码质量
**类型安全**
- 完整的 TypeScript 覆盖
- Rust 类型改进
- API 契约验证
**测试**
- 简化的断言
- 更好的测试覆盖
- 集成测试更新
**依赖项**
- Tauri 2.8.x
- Rust`anyhow``zip``serde_yaml``tempfile`
- 前端CodeMirror 6 包
- winreg 0.52Windows
---
## 技术统计
```
总体变更:
- 提交数85
- 文件数152 个文件变更
- 新增:+18,104 行
- 删除:-3,732 行
新增模块:
- Skills 管理2,034 行21 个文件)
- Prompts 管理1,302 行20 个文件)
- Gemini 集成:约 1,000 行
- MCP 重构:约 3,000 行重构
代码分布:
- 后端Rust约 4,500 行新增
- 前端React约 3,000 行新增
- 配置:约 1,500 行重构
- 测试:约 500 行
```
---
## 战略定位
### 从工具到平台
v3.7.0 代表了 CC Switch 定位的转变:
| 方面 | v3.6 | v3.7.0 |
| -------- | -------------- | ----------------------- |
| **身份** | 供应商切换器 | AI CLI 管理平台 |
| **范围** | 配置管理 | 生态系统管理 |
| **应用** | Claude + Codex | Claude + Codex + Gemini |
| **能力** | 切换配置 | 扩展能力Skills |
| **定制** | 手动编辑 | 可视化管理Prompts |
| **集成** | 孤立应用 | 统一管理MCP |
### AI CLI 管理六大支柱
1. **配置管理** - 供应商切换和管理
2. **能力扩展** - Skills 安装和生命周期
3. **行为定制** - 系统提示词预设
4. **生态集成** - 深度链接和共享
5. **多 AI 支持** - Claude/Codex/Gemini
6. **智能检测** - 冲突预防
---
## 下载与安装
### 系统要求
- **Windows**Windows 10+
- **macOS**macOS 10.15Catalina+
- **Linux**Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
### 下载链接
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
- **Windows**`CC-Switch-Windows.msi``-Portable.zip`
- **macOS**`CC-Switch-macOS.tar.gz``.zip`
- **Linux**`CC-Switch-Linux.AppImage``.deb`
- **ArchLinux**`paru -S cc-switch-bin`
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
---
## 迁移说明
### 从 v3.6.x 升级
**自动迁移** - 无需任何操作,配置完全兼容
### 从 v3.1.x 或更早版本升级
**需要两步迁移**
1. 首先升级到 v3.2.x执行一次性迁移
2. 然后升级到 v3.7.0
### 新功能
- **Skills**:无需迁移,全新开始
- **Prompts**:首次启动时从 live 文件自动导入
- **Gemini**:需要单独安装 Gemini CLI
- **MCP v3.7.0**:与之前的配置向后兼容
---
## 致谢
### 贡献者
感谢所有让这个版本成为可能的贡献者:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini 集成实现
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
- 社区成员的测试和反馈
### 赞助商
**智谱AI** - GLM CODING PLAN 赞助商
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
**PackyCode** - API 中转服务合作伙伴
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
**闪电说** - 本地优先的 AI 语音输入法
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
---
## 反馈与支持
- **问题反馈**[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **讨论**[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **文档**[README](../README_ZH.md)
- **更新日志**[CHANGELOG.md](../CHANGELOG.md)
---
## 未来展望
**v3.8.0 预览**(暂定):
- 本地代理功能
敬请期待更多更新!
---
**Happy Coding!**

View File

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

View File

@@ -1,863 +0,0 @@
# v3.7.0 统一 MCP 管理重构计划
## 📋 项目概述
**目标**:将原有的按应用分离的 MCP 管理Claude/Codex/Gemini 各自独立管理)重构为统一管理面板,每个 MCP 服务器通过多选框控制应用到哪些客户端。
**版本**v3.6.2 → v3.7.0
**开始时间**2025-11-14
---
## 🎯 核心需求
### 原有架构v3.6.x
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Claude面板 │ │ Codex面板 │ │ Gemini面板 │
│ MCP管理 │ │ MCP管理 │ │ MCP管理 │
└─────────────┘ └─────────────┘ └─────────────┘
↓ ↓ ↓
mcp.claude mcp.codex mcp.gemini
{servers} {servers} {servers}
```
### 新架构v3.7.0
```
┌───────────────────────────────────────┐
│ 统一 MCP 管理面板 │
│ ┌────────┬────────┬────────┬────┐ │
│ │ 服务器 │ Claude │ Codex │Gem │ │
│ ├────────┼────────┼────────┼────┤ │
│ │ mcp-1 │ ✓ │ ✓ │ │ │
│ │ mcp-2 │ ✓ │ │ ✓ │ │
│ └────────┴────────┴────────┴────┘ │
└───────────────────────────────────────┘
mcp.servers
{
"mcp-1": {
apps: {claude: true, codex: true, gemini: false}
}
}
```
---
## 📐 技术架构
### 数据结构设计
#### 新增McpApps应用启用状态
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct McpApps {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
}
```
#### 更新McpServer统一服务器定义
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
pub name: String,
pub server: serde_json::Value, // 连接配置stdio/http
pub apps: McpApps, // 新增:标记应用到哪些客户端
pub description: Option<String>,
pub homepage: Option<String>,
pub docs: Option<String>,
pub tags: Vec<String>,
}
```
#### 更新McpRoot新旧结构并存
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpRoot {
// v3.7.0 新结构
#[serde(skip_serializing_if = "Option::is_none")]
pub servers: Option<HashMap<String, McpServer>>,
// v3.6.x 旧结构(保留用于迁移)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub claude: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
}
```
### 迁移策略
```
旧配置 (v3.6.x) 新配置 (v3.7.0)
───────────────── ─────────────────
mcp: mcp:
claude: servers:
servers: mcp-fetch:
mcp-fetch: {...} → id: "mcp-fetch"
codex: server: {...}
servers: apps:
mcp-filesystem: {...} claude: true
codex: true
gemini: false
```
**迁移逻辑**
1. 检测 `mcp.servers` 是否存在
2. 若不存在,从 `mcp.claude/codex/gemini.servers` 收集所有服务器
3. 合并同 id 服务器的 apps 字段
4. 清空旧结构字段
5. 保存配置(自动触发)
---
## ✅ 开发进度
### Phase 1: 后端数据结构与迁移 ✅ 已完成
#### 1.1 修改数据结构app_config.rs
**文件**`src-tauri/src/app_config.rs`
**变更**
- ✅ 新增 `McpApps` 结构体lines 30-62
- ✅ 新增 `McpServer` 结构体lines 64-79
- ✅ 更新 `McpRoot` 支持新旧结构lines 81-96
- ✅ 添加辅助方法:`is_enabled_for`, `set_enabled_for`, `enabled_apps`
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
#### 1.2 实现迁移逻辑 ✅
**文件**`src-tauri/src/app_config.rs`
**实现**
-`migrate_mcp_to_unified()` 方法lines 380-509
- 从旧结构收集所有服务器
- 按 id 合并重复服务器
- 处理冲突(合并 apps 字段)
- 清空旧结构
- ✅ 集成到 `MultiAppConfig::load()` 方法lines 252-257
- 自动检测并执行迁移
- 迁移后保存配置
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
---
### Phase 2: 后端服务层重构 ✅ 已完成
#### 2.1 重写 McpService ✅
**文件**`src-tauri/src/services/mcp.rs`
**新增方法**
-`get_all_servers()` - 获取所有服务器lines 13-27
-`upsert_server()` - 添加/更新服务器lines 30-52
-`delete_server()` - 删除服务器lines 55-75
-`toggle_app()` - 切换应用启用状态lines 78-111
-`sync_all_enabled()` - 同步所有启用的服务器lines 180-188
**兼容层方法**(已废弃):
-`get_servers()` - 按应用过滤服务器lines 196-210
-`set_enabled()` - 委托到 toggle_applines 213-222
-`sync_enabled()` - 同步特定应用lines 225-236
-`import_from_claude/codex/gemini()` - 导入包装lines 239-266
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
#### 2.2 新增同步函数mcp.rs
**文件**`src-tauri/src/mcp.rs`
**新增函数**lines 800-965
-`json_server_to_toml_table()` - JSON → TOML 转换助手lines 828-889
-`sync_single_server_to_claude()` - 同步单个服务器到 Claudelines 800-814
-`remove_server_from_claude()` - 从 Claude 移除服务器lines 817-826
-`sync_single_server_to_codex()` - 同步单个服务器到 Codexlines 891-936
-`remove_server_from_codex()` - 从 Codex 移除服务器lines 939-965
-`sync_single_server_to_gemini()` - 同步单个服务器到 Geminilines 967-977
-`remove_server_from_gemini()` - 从 Gemini 移除服务器lines 980-989
**关键修复**
- ✅ 修复 toml_edit 类型转换(使用手动构建而非 serde 转换)
- ✅ 修复 get_codex_config_path() 调用(返回 PathBuf 而非 Result
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
**修复提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
#### 2.3 新增 Tauri Commands ✅
**文件**`src-tauri/src/commands/mcp.rs`
**新增命令**lines 147-196
-`get_mcp_servers()` - 获取所有服务器lines 154-159
-`upsert_mcp_server()` - 添加/更新服务器lines 162-168
-`delete_mcp_server()` - 删除服务器lines 171-177
-`toggle_mcp_app()` - 切换应用状态lines 180-189
-`sync_all_mcp_servers()` - 同步所有服务器lines 192-195
**更新旧命令**(兼容层):
-`upsert_mcp_server_in_config()` - 转换为统一结构lines 68-131
-`delete_mcp_server_in_config()` - 忽略 app 参数lines 134-141
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
**修复提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
#### 2.4 注册新命令lib.rs
**文件**`src-tauri/src/lib.rs`
**变更**
- ✅ 导出 `McpServer` 类型line 21
- ✅ 导出新增的 mcp 同步函数lines 26-31
- ✅ 注册 5 个新命令到 invoke_handlerlines 550-555
**提交**`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
#### 2.5 添加缺失的函数claude_mcp.rs & gemini_mcp.rs
**文件**
- `src-tauri/src/claude_mcp.rs` (lines 234-253)
- `src-tauri/src/gemini_mcp.rs` (lines 160-179)
**新增**
-`read_mcp_servers_map()` - 读取现有 MCP 服务器映射
**提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
#### 2.6 编译验证 ✅
**状态**:✅ 编译成功
- ⚠️ 16 个警告8 个废弃警告 + 8 个未使用函数警告 - 预期内)
- ✅ 0 个错误
**提交**`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
---
### Phase 3: 前端开发 ⚠️ 部分完成
#### 3.1 TypeScript 类型定义 ✅
**文件**`src/types.ts`
**变更**
- ✅ 新增 `McpApps` 接口lines 129-133
- ✅ 更新 `McpServer` 接口lines 136-149
- 新增 `apps: McpApps` 字段
- `name` 改为必填
- 标记 `enabled` 为废弃
- ✅ 新增 `McpServersMap` 类型别名line 152
- ✅ 保持向后兼容(保留 `enabled`, `source` 等旧字段)
**提交**`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
#### 3.2 API 层更新 ✅
**文件**`src/lib/api/mcp.ts`
**新增方法**lines 99-141
-`getAllServers()` - 获取所有服务器lines 106-108
-`upsertUnifiedServer()` - 添加/更新服务器lines 113-115
-`deleteUnifiedServer()` - 删除服务器lines 120-122
-`toggleApp()` - 切换应用状态lines 127-133
-`syncAllServers()` - 同步所有服务器lines 138-140
**导入更新**
- ✅ 导入 `McpServersMap` 类型line 6
**提交**`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
#### 3.3 React Query Hooks 📝 待开发
**计划文件**`src/hooks/useMcp.ts`
**需要实现的 Hooks**
```typescript
// 查询 hooks
export function useAllMcpServers() {
return useQuery({
queryKey: ['mcp', 'all'],
queryFn: () => mcpApi.getAllServers(),
});
}
// 变更 hooks
export function useUpsertMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
},
});
}
export function useToggleMcpApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ serverId, app, enabled }: {
serverId: string;
app: AppId;
enabled: boolean;
}) => mcpApi.toggleApp(serverId, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
},
});
}
export function useDeleteMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
},
});
}
export function useSyncAllMcpServers() {
return useMutation({
mutationFn: () => mcpApi.syncAllServers(),
});
}
```
**依赖**
- `@tanstack/react-query` (已安装)
- `src/lib/api/mcp.ts` (✅ 已完成)
- `src/types.ts` (✅ 已完成)
#### 3.4 统一 MCP 面板组件 📝 待开发
**计划文件**`src/components/mcp/UnifiedMcpPanel.tsx`
**组件结构**
```typescript
interface UnifiedMcpPanelProps {
className?: string;
}
export function UnifiedMcpPanel({ className }: UnifiedMcpPanelProps) {
const { t } = useTranslation();
const { data: servers, isLoading } = useAllMcpServers();
const toggleApp = useToggleMcpApp();
const deleteServer = useDeleteMcpServer();
const syncAll = useSyncAllMcpServers();
// 组件实现...
}
```
**UI 设计**
```
┌─────────────────────────────────────────────────────┐
│ MCP 服务器管理 ┌──────────┐ │
│ │ 添加服务器 │ │
│ ┌─────┐ ┌──────────────┐ ┌─────────┐ └──────────┘ │
│ │ 搜索 │ │ 导入自...▼ │ │ 同步全部 │ │
│ └─────┘ └──────────────┘ └─────────┘ │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 名称 │ Claude │ Codex │ Gemini │操作│ │
│ ├─────────────────────────────────────────────┤ │
│ │ mcp-fetch │ ✓ │ ✓ │ │ ⚙️ │ │
│ │ filesystem │ ✓ │ │ ✓ │ ⚙️ │ │
│ │ brave-search │ │ ✓ │ ✓ │ ⚙️ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
**功能特性**
- 📋 服务器列表展示(名称、描述、标签)
- ☑️ 三个复选框控制应用启用状态Claude/Codex/Gemini
- 添加新服务器(表单模态框)
- ✏️ 编辑服务器(表单模态框)
- 🗑️ 删除服务器(确认对话框)
- 📥 导入功能(从 Claude/Codex/Gemini 导入)
- 🔄 同步全部(手动触发同步到 live 配置)
- 🔍 搜索过滤
- 🏷️ 标签过滤
**子组件**
1. **McpServerTable** (`McpServerTable.tsx`)
- 服务器列表表格
- 应用复选框
- 操作按钮(编辑、删除)
2. **McpServerFormModal** (`McpServerFormModal.tsx`)
- 添加/编辑表单
- stdio/http 类型切换
- 应用选择(多选)
- 元信息编辑(描述、标签、链接)
3. **McpImportDialog** (`McpImportDialog.tsx`)
- 选择导入来源Claude/Codex/Gemini
- 服务器预览
- 批量导入
**依赖组件**(来自 shadcn/ui
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
- `Checkbox`
- `Button`
- `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`
- `Input`, `Textarea`, `Label`
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
- `Badge`
- `Tooltip`
#### 3.5 主界面集成 📝 待开发
**文件**`src/App.tsx`
**变更计划**
```typescript
// 原有代码v3.6.x
{currentApp === 'claude' && <ClaudeMcpPanel />}
{currentApp === 'codex' && <CodexMcpPanel />}
{currentApp === 'gemini' && <GeminiMcpPanel />}
// 新代码v3.7.0
<UnifiedMcpPanel />
```
**移除的组件**
- `ClaudeMcpPanel.tsx`
- `CodexMcpPanel.tsx`
- `GeminiMcpPanel.tsx`
**注意**:保留旧组件文件备份,以便回滚
#### 3.6 国际化文本更新 📝 待开发
**文件**
- `src/locales/zh/translation.json`
- `src/locales/en/translation.json`
**需要添加的翻译键**
```json
{
"mcp": {
"unifiedPanel": {
"title": "MCP 服务器管理 / MCP Server Management",
"addServer": "添加服务器 / Add Server",
"editServer": "编辑服务器 / Edit Server",
"deleteServer": "删除服务器 / Delete Server",
"deleteConfirm": "确定要删除此服务器吗?/ Are you sure to delete this server?",
"syncAll": "同步全部 / Sync All",
"syncAllSuccess": "已同步所有启用的服务器 / All enabled servers synced",
"importFrom": "导入自... / Import from...",
"search": "搜索服务器... / Search servers...",
"noServers": "暂无服务器 / No servers yet",
"enabledApps": "启用的应用 / Enabled Apps",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
},
"form": {
"id": "服务器 ID / Server ID",
"name": "显示名称 / Display Name",
"type": "类型 / Type",
"stdio": "本地进程 / Local Process",
"http": "远程服务 / Remote Service",
"command": "命令 / Command",
"args": "参数 / Arguments",
"env": "环境变量 / Environment Variables",
"cwd": "工作目录 / Working Directory",
"url": "URL",
"headers": "请求头 / Headers",
"description": "描述 / Description",
"tags": "标签 / Tags",
"homepage": "主页 / Homepage",
"docs": "文档 / Documentation",
"selectApps": "选择应用 / Select Apps",
"selectAppsHint": "勾选此服务器要应用到哪些客户端 / Check which clients this server applies to"
},
"table": {
"name": "名称 / Name",
"type": "类型 / Type",
"apps": "应用 / Apps",
"actions": "操作 / Actions",
"edit": "编辑 / Edit",
"delete": "删除 / Delete"
},
"import": {
"title": "导入 MCP 服务器 / Import MCP Servers",
"fromClaude": "从 Claude 导入 / Import from Claude",
"fromCodex": "从 Codex 导入 / Import from Codex",
"fromGemini": "从 Gemini 导入 / Import from Gemini",
"success": "成功导入 {{count}} 个服务器 / Successfully imported {{count}} server(s)",
"noServersFound": "未找到可导入的服务器 / No servers found to import"
}
}
}
}
```
---
## 🔄 迁移流程
### 用户体验
```
1. 用户升级到 v3.7.0
2. 首次启动应用
3. 后端自动执行迁移
- 检测旧结构 (mcp.claude/codex/gemini.servers)
- 合并到统一结构 (mcp.servers)
- 保存迁移后的配置
- 日志记录迁移详情
4. 前端加载新面板
- 显示所有服务器
- 三个复选框显示各应用启用状态
5. 用户无缝使用
```
### 数据完整性保证
1. **迁移前验证**
- ✅ 校验旧结构合法性
- ✅ 记录迁移前状态
2. **迁移中处理**
- ✅ 合并同 id 服务器的 apps 字段
- ✅ 处理 id 冲突(保留第一个,记录警告)
- ✅ 保留所有元信息(描述、标签、链接)
3. **迁移后清理**
- ✅ 清空旧结构claude/codex/gemini
- ✅ 自动保存新配置
- ✅ 日志记录迁移完成
4. **回滚机制**
- 配置文件有备份(`config.v1.backup.<timestamp>.json`
- 迁移失败时可手动回滚
---
## 🧪 测试计划
### 后端测试 ✅ 已验证
- [x] 编译测试cargo check
- [x] 数据结构序列化/反序列化
- [ ] 迁移逻辑单元测试
- [ ] 服务层方法测试
- [ ] 同步函数测试
### 前端测试 ⏳ 待进行
- [ ] TypeScript 类型检查
- [ ] API 调用测试
- [ ] 组件渲染测试
- [ ] 用户交互测试
- [ ] 国际化文本检查
### 集成测试 ⏳ 待进行
- [ ] 完整迁移流程测试
- [ ] 从空配置启动
- [ ] 从 v3.6.x 配置升级
- [ ] 多服务器合并场景
- [ ] 冲突处理验证
- [ ] 多应用同步测试
- [ ] 启用单个应用
- [ ] 启用多个应用
- [ ] 动态切换应用
- [ ] 同步到 live 配置验证
- [ ] 边界情况测试
- [ ] 空服务器列表
- [ ] 超长服务器名称
- [ ] 特殊字符处理
- [ ] 并发操作
---
## 📦 交付清单
### 代码文件
#### 后端Rust✅ 已完成
- [x] `src-tauri/src/app_config.rs` - 数据结构定义与迁移
- [x] `src-tauri/src/services/mcp.rs` - 服务层重构
- [x] `src-tauri/src/mcp.rs` - 同步函数实现
- [x] `src-tauri/src/commands/mcp.rs` - Tauri 命令
- [x] `src-tauri/src/lib.rs` - 命令注册
- [x] `src-tauri/src/claude_mcp.rs` - Claude MCP 操作
- [x] `src-tauri/src/gemini_mcp.rs` - Gemini MCP 操作
#### 前端TypeScript/React 部分完成
- [x] `src/types.ts` - 类型定义更新
- [x] `src/lib/api/mcp.ts` - API 层更新
- [ ] `src/hooks/useMcp.ts` - React Query Hooks
- [ ] `src/components/mcp/UnifiedMcpPanel.tsx` - 统一面板组件
- [ ] `src/components/mcp/McpServerTable.tsx` - 服务器表格
- [ ] `src/components/mcp/McpServerFormModal.tsx` - 表单模态框
- [ ] `src/components/mcp/McpImportDialog.tsx` - 导入对话框
- [ ] `src/App.tsx` - 主界面集成
- [ ] `src/locales/zh/translation.json` - 中文翻译
- [ ] `src/locales/en/translation.json` - 英文翻译
### 文档
- [x] 本重构计划文档 (`docs/v3.7.0-unified-mcp-refactor.md`)
- [ ] 用户升级指南 (`docs/upgrade-to-v3.7.0.md`)
- [ ] API 变更说明 (`docs/api-changes-v3.7.0.md`)
### Git 提交记录 ✅
- [x] `c7b235b` - feat(mcp): implement unified MCP management for v3.7.0
- [x] `7ae2a9f` - fix(mcp): resolve compilation errors and add backward compatibility
- [x] `ac09551` - feat(frontend): add unified MCP types and API layer for v3.7.0
---
## 🎯 下一步行动
### 立即任务(优先级 P0
1.**实现 useMcp Hook**
- 文件:`src/hooks/useMcp.ts`
- 估时1-2 小时
- 依赖API 层(已完成)
2.**创建 UnifiedMcpPanel 核心组件**
- 文件:`src/components/mcp/UnifiedMcpPanel.tsx`
- 估时3-4 小时
- 依赖useMcp Hook
3.**添加国际化文本**
- 文件:`src/locales/{zh,en}/translation.json`
- 估时30 分钟
4.**集成到主界面**
- 文件:`src/App.tsx`
- 估时30 分钟
- 依赖UnifiedMcpPanel 组件
### 次要任务(优先级 P1
5.**实现子组件**
- McpServerTable
- McpServerFormModal
- McpImportDialog
- 估时4-6 小时
6.**编写测试用例**
- 后端单元测试
- 前端组件测试
- 集成测试
- 估时6-8 小时
7.**编写用户文档**
- 升级指南
- API 变更说明
- 估时2-3 小时
### 优化任务(优先级 P2
8.**性能优化**
- 服务器列表虚拟滚动
- 批量操作优化
- 估时2-3 小时
9.**用户体验增强**
- 添加加载状态
- 添加错误提示
- 添加操作确认
- 估时2-3 小时
10.**代码清理**
- 移除旧的分应用面板组件
- 清理废弃代码
- 代码格式化
- 估时1-2 小时
---
## 💡 技术亮点
### 1. 平滑迁移机制
- ✅ 自动检测旧配置并迁移
- ✅ 新旧结构并存(过渡期)
- ✅ 无需用户手动操作
- ✅ 保留所有历史数据
### 2. 向后兼容
- ✅ 旧命令继续可用(带废弃警告)
- ✅ 前端可增量更新
- ✅ 渐进式重构策略
### 3. 类型安全
- ✅ Rust 强类型保证数据完整性
- ✅ TypeScript 类型定义与后端一致
- ✅ serde 序列化/反序列化自动处理
### 4. 清晰的架构分层
```
Frontend (React)
↓ (Tauri IPC)
Commands Layer
Services Layer
Data Layer (Config + Live Sync)
```
### 5. SSOT 原则
- 单一配置源:`~/.cc-switch/config.json`
- 统一管理:`mcp.servers` 字段
- 按需同步:写入各应用 live 配置
---
## 📚 参考资源
### 内部文档
- [项目 README](../README.md)
- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
- [架构文档](../CLAUDE.md#架构概述)
### 相关 Issues/PRs
- 无(新功能开发)
### 技术栈文档
- [Tauri 2.0](https://tauri.app/v1/guides/)
- [React 18](https://react.dev/)
- [TanStack Query](https://tanstack.com/query/latest)
- [shadcn/ui](https://ui.shadcn.com/)
- [serde](https://serde.rs/)
---
## 📝 变更日志
### 2025-11-14
- ✅ 完成后端 Phase 1 & 2数据结构、服务层、命令层
- ✅ 修复所有编译错误
- ✅ 完成前端类型定义和 API 层
- ✅ 创建本重构计划文档
### 待更新...
---
## 👥 团队协作
**开发者**Claude Code (AI Assistant) + User
**审查者**User
**测试者**User
---
## ⚠️ 风险与对策
### 风险 1迁移数据丢失
**概率**:低
**影响**:高
**对策**
- ✅ 迁移前自动备份配置
- ✅ 详细日志记录
- ✅ 测试各种边界情况
### 风险 2性能问题大量服务器
**概率**:中
**影响**:中
**对策**
- ⬜ 实现虚拟滚动
- ⬜ 分页或懒加载
- ⬜ 性能测试
### 风险 3兼容性问题
**概率**:中
**影响**:中
**对策**
- ✅ 保留旧命令兼容层
- ✅ 前端增量更新
- ⬜ 多版本测试
### 风险 4用户学习成本
**概率**:低
**影响**:低
**对策**
- ⬜ 清晰的 UI 设计
- ⬜ 详细的升级指南
- ⬜ 操作提示和引导
---
## 🎉 预期收益
### 用户体验提升
-**简化操作**:不再需要在不同应用面板切换
-**统一视图**:一目了然看到所有 MCP 配置
-**灵活配置**:轻松控制每个 MCP 应用到哪些客户端
### 代码质量提升
-**架构优化**:统一数据源,消除冗余
-**维护性**:单一面板组件,代码更简洁
-**扩展性**:未来添加新应用(如 Cursor更容易
### 性能提升
-**减少重复加载**:统一管理减少配置文件读写
-**更快同步**:批量操作更高效
---
## 📞 联系方式
**问题反馈**[GitHub Issues](https://github.com/jasonyoungyang/cc-switch/issues)
**功能建议**[GitHub Discussions](https://github.com/jasonyoungyang/cc-switch/discussions)
---
**文档版本**v1.0
**最后更新**2025-11-14
**状态**:🟡 开发中(后端完成 ✅,前端进行中 ⚠️)

View File

@@ -1,7 +1,7 @@
{
"name": "cc-switch",
"version": "3.7.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"version": "3.3.1",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -10,74 +10,39 @@
"build:renderer": "vite build",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
"test:unit": "vitest run",
"test:unit:watch": "vitest watch"
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
},
"keywords": [],
"author": "Jason Young",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.8.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5"
"vite": "^5.0.0"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.65.0",
"react-i18next": "^16.0.0",
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
"tailwindcss": "^4.1.13"
}
}

2605
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,2 @@
packages: []
onlyBuiltDependencies:
- '@tailwindcss/oxide'

BIN
screenshots/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
screenshots/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -1,208 +0,0 @@
const fs = require('fs');
const path = require('path');
// 要提取的图标列表(按分类组织)
const ICONS_TO_EXTRACT = {
// AI 服务商(必需)
aiProviders: [
'openai', 'anthropic', 'claude', 'google', 'gemini',
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
'cohere', 'perplexity', 'mistral', 'huggingface'
],
// 云平台
cloudPlatforms: [
'aws', 'azure', 'huawei', 'cloudflare'
],
// 开发工具
devTools: [
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
],
// 其他
others: [
'settings', 'folder', 'file', 'link'
]
};
// 合并所有图标
const ALL_ICONS = [
...ICONS_TO_EXTRACT.aiProviders,
...ICONS_TO_EXTRACT.cloudPlatforms,
...ICONS_TO_EXTRACT.devTools,
...ICONS_TO_EXTRACT.others
];
// 提取逻辑
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('🎨 CC-Switch Icon Extractor\n');
console.log('========================================');
console.log('📦 Extracting icons...\n');
let extracted = 0;
let notFound = [];
// 提取图标
ALL_ICONS.forEach(iconName => {
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
if (fs.existsSync(sourceFile)) {
fs.copyFileSync(sourceFile, targetFile);
console.log(`${iconName}.svg`);
extracted++;
} else {
console.log(`${iconName}.svg (not found)`);
notFound.push(iconName);
}
});
// 生成索引文件
console.log('\n📝 Generating index file...\n');
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${ALL_ICONS.filter(name => !notFound.includes(name))
.map(name => {
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
})
.join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
console.log('✓ Generated: src/icons/extracted/index.ts');
// 生成图标元数据
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
// AI Providers
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
// Cloud Platforms
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
// Dev Tools
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
// Others
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
console.log('✓ Generated: src/icons/extracted/metadata.ts');
// 生成 README
const readmeContent = `# Extracted Icons
This directory contains extracted icons from @lobehub/icons-static-svg.
## Statistics
- Total extracted: ${extracted} icons
- Not found: ${notFound.length} icons
## Extracted Icons
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
## Usage
\`\`\`typescript
import { getIcon, hasIcon, iconList } from './extracted';
// Get icon SVG
const svg = getIcon('openai');
// Check if icon exists
if (hasIcon('openai')) {
// ...
}
// Get all available icons
console.log(iconList);
\`\`\`
---
Last updated: ${new Date().toISOString()}
Generated by: scripts/extract-icons.js
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
console.log('✓ Generated: src/icons/extracted/README.md');
console.log('\n========================================');
console.log('✅ Extraction complete!\n');
console.log(` ✓ Extracted: ${extracted} icons`);
console.log(` ✗ Not found: ${notFound.length} icons`);
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
console.log('========================================\n');

View File

@@ -1,95 +0,0 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
// List of "Famous" icons to keep
// Based on common AI providers and tools
const KEEP_LIST = [
// AI Providers
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
'microsoft', 'azure', 'copilot', 'meta', 'llama',
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
'perplexity', 'huggingface', 'midjourney', 'stability',
'xai', 'grok', 'yi', 'zeroone', 'ollama',
// Cloud/Tools
'aws', 'googlecloud', 'huawei', 'cloudflare',
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
];
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Scanning ${files.length} files...`);
let keptCount = 0;
let deletedCount = 0;
let renamedCount = 0;
// First pass: Identify files to keep and prefer color versions
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
files.forEach(file => {
const isColor = file.endsWith('-color.svg');
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
if (!fileMap[baseName]) {
fileMap[baseName] = { hasColor: false, hasMono: false };
}
if (isColor) {
fileMap[baseName].hasColor = true;
} else {
fileMap[baseName].hasMono = true;
}
});
// Second pass: Process files
Object.keys(fileMap).forEach(baseName => {
const info = fileMap[baseName];
const shouldKeep = KEEP_LIST.includes(baseName);
if (!shouldKeep) {
// Delete both versions if not in keep list
if (info.hasColor) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
deletedCount++;
}
if (info.hasMono) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
deletedCount++;
}
return;
}
// If keeping, prefer color
if (info.hasColor) {
// Rename color version to base version (overwrite mono if exists)
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
try {
// If mono exists, it will be overwritten/replaced
fs.renameSync(colorPath, targetPath);
renamedCount++;
keptCount++;
} catch (e) {
console.error(`Error renaming ${baseName}:`, e);
}
} else if (info.hasMono) {
// Keep mono if no color version
keptCount++;
}
});
console.log(`\nCleanup complete:`);
console.log(`- Kept: ${keptCount}`);
console.log(`- Deleted: ${deletedCount}`);
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
// Regenerate index and metadata
require('./generate-icon-index.js');

View File

@@ -1,113 +0,0 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
// Known metadata from previous configuration
const KNOWN_METADATA = {
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Found ${files.length} SVG files.`);
// Generate index.ts
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${files.map(file => {
const name = path.basename(file, '.svg');
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
}).join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(INDEX_FILE, indexContent);
console.log(`Generated ${INDEX_FILE}`);
// Generate metadata.ts
const metadataEntries = files.map(file => {
const name = path.basename(file, '.svg').toLowerCase();
const known = KNOWN_METADATA[name];
if (known) {
return ` ${name}: ${JSON.stringify(known)},`;
}
// Default metadata for unknown icons
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
});
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
${metadataEntries.join('\n')}
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(METADATA_FILE, metadataContent);
console.log(`Generated ${METADATA_FILE}`);

838
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package]
name = "cc-switch"
version = "3.7.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
version = "3.3.1"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/farion1231/cc-switch"
@@ -14,10 +14,6 @@ rust-version = "1.85.0"
name = "cc_switch_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = []
test-hooks = []
[build-dependencies]
tauri-build = { version = "2.4.0", features = [] }
@@ -25,39 +21,18 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
tauri = { version = "2.8.2", features = ["tray-icon"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
futures = "0.3"
regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "1.0"
anyhow = "1.0"
zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
@@ -69,7 +44,3 @@ lto = "thin"
opt-level = "s"
panic = "abort"
strip = "symbols"
[dev-dependencies]
serial_test = "3"
tempfile = "3"

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>CC Switch Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ccswitch</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -10,8 +10,7 @@
"opener:default",
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
"process:allow-restart",
"dialog:default"
]
}
}

View File

@@ -1,151 +1,15 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use crate::services::skill::SkillStore;
/// MCP 服务器应用状态(标记应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct McpApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
impl McpApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<AppType> {
let mut apps = Vec::new();
if self.claude {
apps.push(AppType::Claude);
}
if self.codex {
apps.push(AppType::Codex);
}
if self.gemini {
apps.push(AppType::Gemini);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
}
}
/// MCP 服务器定义v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
pub name: String,
pub server: serde_json::Value,
pub apps: McpApps,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docs: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
/// MCP 配置单客户端维度v3.6.x 及以前,保留用于向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpConfig {
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
#[serde(default)]
pub servers: HashMap<String, serde_json::Value>,
}
impl McpConfig {
/// 检查配置是否为空
pub fn is_empty(&self) -> bool {
self.servers.is_empty()
}
}
/// MCP 根配置v3.7.0 新旧结构并存)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpRoot {
/// 统一的 MCP 服务器存储v3.7.0+
#[serde(skip_serializing_if = "Option::is_none")]
pub servers: Option<HashMap<String, McpServer>>,
/// 旧的分应用存储v3.6.x 及以前,保留用于迁移)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub claude: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
}
impl Default for McpRoot {
fn default() -> Self {
Self {
// v3.7.0+ 默认使用新的统一结构(空 HashMap
servers: Some(HashMap::new()),
// 旧结构保持空,仅用于反序列化旧配置时的迁移
claude: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
}
}
}
/// Prompt 配置:单客户端维度
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromptConfig {
#[serde(default)]
pub prompts: HashMap<String, crate::prompt::Prompt>,
}
/// Prompt 根:按客户端分开维护
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromptRoot {
#[serde(default)]
pub claude: PromptConfig,
#[serde(default)]
pub codex: PromptConfig,
#[serde(default)]
pub gemini: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
use crate::error::AppError;
use crate::prompt_files::prompt_file_path;
use crate::provider::ProviderManager;
/// 应用类型
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
Codex,
Gemini, // 新增
}
impl AppType {
@@ -153,58 +17,15 @@ impl AppType {
match self {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini", // 新增
}
}
}
impl FromStr for AppType {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim().to_lowercase();
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini), // 新增
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
)),
}
}
}
/// 通用配置片段(按应用分治)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
}
impl CommonConfigSnippets {
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
}
}
/// 设置指定应用的通用配置片段
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
impl From<&str> for AppType {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"codex" => AppType::Codex,
_ => AppType::Claude, // 默认为 Claude
}
}
}
@@ -214,24 +35,8 @@ impl CommonConfigSnippets {
pub struct MultiAppConfig {
#[serde(default = "default_version")]
pub version: u32,
/// 应用管理器claude/codex
#[serde(flatten)]
pub apps: HashMap<String, ProviderManager>,
/// MCP 配置(按客户端分治)
#[serde(default)]
pub mcp: McpRoot,
/// Prompt 配置(按客户端分治)
#[serde(default)]
pub prompts: PromptRoot,
/// Claude Skills 配置
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_common_config_snippet: Option<String>,
}
fn default_version() -> u32 {
@@ -243,134 +48,70 @@ impl Default for MultiAppConfig {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
Self {
version: 2,
apps,
mcp: McpRoot::default(),
prompts: PromptRoot::default(),
skills: SkillStore::default(),
common_config_snippets: CommonConfigSnippets::default(),
claude_common_config_snippet: None,
}
Self { version: 2, apps }
}
}
impl MultiAppConfig {
/// 从文件加载配置(仅支持 v2 结构
pub fn load() -> Result<Self, AppError> {
/// 从文件加载配置(处理v1到v2的迁移
pub fn load() -> Result<Self, String> {
let config_path = get_app_config_path();
if !config_path.exists() {
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
// 使用新的方法,支持自动导入提示词
let config = Self::default_with_auto_import()?;
// 立即保存到磁盘
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);
}
// 尝试读取文件
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 先解析为 Value以便严格判定是否为 v1 结构;
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
let value: serde_json::Value =
serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
let is_v1 = value.as_object().is_some_and(|map| {
let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
// v1 的充分必要条件:有 providers 和 current且 apps 不存在version/mcp 可能存在但不作为 v2 判据)
let has_apps = map.contains_key("apps");
has_providers && has_current && !has_apps
});
if is_v1 {
return Err(AppError::localized(
"config.unsupported_v1",
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json将顶层结构调整为\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
"Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
));
}
let has_skills_in_config = value
.as_object()
.is_some_and(|map| map.contains_key("skills"));
// 解析 v2 结构
let mut config: Self =
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
let mut updated = false;
if !has_skills_in_config {
let skills_path = get_app_config_dir().join("skills.json");
if skills_path.exists() {
match std::fs::read_to_string(&skills_path) {
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
Ok(store) => {
config.skills = store;
updated = true;
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
}
Err(e) => {
log::warn!("解析旧版 skills.json 失败: {e}");
}
},
Err(e) => {
log::warn!("读取旧版 skills.json 失败: {e}");
}
}
}
}
// 确保 gemini 应用存在(兼容旧配置文件)
if !config.apps.contains_key("gemini") {
config
.apps
.insert("gemini".to_string(), ProviderManager::default());
updated = true;
}
// 执行 MCP 迁移v3.6.x → v3.7.0
let migrated = config.migrate_mcp_to_unified()?;
if migrated {
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
updated = true;
}
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
if imported_prompts {
updated = true;
}
// 迁移通用配置片段claude_common_config_snippet → common_config_snippets.claude
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
log::info!(
"迁移通用配置claude_common_config_snippet → common_config_snippets.claude"
);
config.common_config_snippets.claude = Some(old_claude_snippet);
updated = true;
}
if updated {
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
config.save()?;
}
Ok(config)
// 尝试读取v2格式
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), AppError> {
pub fn save(&self) -> Result<(), String> {
let config_path = get_app_config_path();
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak再写入新内容
if config_path.exists() {
let backup_path = get_app_config_dir().join("config.json.bak");
if let Err(e) = copy_file(&config_path, &backup_path) {
log::warn!("备份 config.json 到 .bak 失败: {e}");
log::warn!("备份 config.json 到 .bak 失败: {}", e);
}
}
@@ -395,466 +136,4 @@ impl MultiAppConfig {
.insert(app.as_str().to_string(), ProviderManager::default());
}
}
/// 获取指定客户端的 MCP 配置(不可变引用)
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
}
}
/// 获取指定客户端的 MCP 配置(可变引用)
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
}
}
/// 创建默认配置并自动导入已存在的提示词文件
fn default_with_auto_import() -> Result<Self, AppError> {
log::info!("首次启动,创建默认配置并检测提示词文件");
let mut config = Self::default();
// 为每个应用尝试自动导入提示词
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Ok(config)
}
/// 已存在配置文件时的 Prompt 自动导入逻辑
///
/// 适用于「老版本已经生成过 config.json但当时还没有 Prompt 功能」的升级场景。
/// 判定规则:
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md
///
/// 返回值:
/// - Ok(true) 表示至少有一个应用成功导入了提示词
/// - Ok(false) 表示无需导入或未导入任何内容
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
{
return Ok(false);
}
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false;
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
}
}
Ok(imported)
}
/// 检查并自动导入单个应用的提示词文件
///
/// 返回值:
/// - Ok(true) 表示成功导入了非空文件
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
let file_path = prompt_file_path(&app)?;
// 检查文件是否存在
if !file_path.exists() {
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
return Ok(false);
}
// 读取文件内容
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(e) => {
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
return Ok(false); // 失败时不中断,继续处理其他应用
}
};
// 检查内容是否为空
if content.trim().is_empty() {
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
return Ok(false);
}
log::info!("发现提示词文件,自动导入: {file_path:?}");
// 创建提示词对象
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let id = format!("auto-imported-{timestamp}");
let prompt = crate::prompt::Prompt {
id: id.clone(),
name: format!(
"Auto-imported Prompt {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("Automatically imported on first launch".to_string()),
enabled: true, // 自动启用
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
// 插入到对应的应用配置中
let prompts = match app {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
};
prompts.insert(id, prompt);
log::info!("自动导入完成: {}", app.as_str());
Ok(true)
}
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
///
/// 迁移策略:
/// 1. 检查是否已经迁移mcp.servers 是否存在)
/// 2. 收集所有应用的 MCP按 ID 去重合并
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
/// 4. 清空旧的分应用配置
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
// 检查是否已经是新结构
if self.mcp.servers.is_some() {
log::debug!("MCP 配置已是统一结构,跳过迁移");
return Ok(false);
}
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
let mut conflicts = Vec::new();
// 收集所有应用的 MCP
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
};
for (id, entry) in old_servers {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if let Some(existing) = unified_servers.get_mut(id) {
// 该 ID 已存在,合并 apps 字段
existing.apps.set_enabled_for(&app, enabled);
// 检测配置冲突(同 ID 但配置不同)
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
conflicts.push(format!(
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
app.as_str()
));
}
} else {
// 首次遇到该 MCP创建新条目
let name = entry
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(id)
.to_string();
let server = entry
.get("server")
.cloned()
.unwrap_or(serde_json::json!({}));
let description = entry
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let homepage = entry
.get("homepage")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let docs = entry
.get("docs")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tags = entry
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let mut apps = McpApps::default();
apps.set_enabled_for(&app, enabled);
unified_servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name,
server,
apps,
description,
homepage,
docs,
tags,
},
);
}
}
}
// 记录冲突警告
if !conflicts.is_empty() {
log::warn!("MCP 迁移过程中检测到配置冲突:");
for conflict in &conflicts {
log::warn!(" - {conflict}");
}
}
log::info!(
"MCP 迁移完成,共迁移 {} 个服务器{}",
unified_servers.len(),
if !conflicts.is_empty() {
format!("(存在 {} 个冲突)", conflicts.len())
} else {
String::new()
}
);
// 替换为新结构
self.mcp.servers = Some(unified_servers);
// 清空旧的分应用配置
self.mcp.claude = McpConfig::default();
self.mcp.codex = McpConfig::default();
self.mcp.gemini = McpConfig::default();
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::fs;
use tempfile::TempDir;
struct TempHome {
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
dir: TempDir,
original_home: Option<String>,
original_userprofile: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = TempDir::new().expect("failed to create temp home");
let original_home = env::var("HOME").ok();
let original_userprofile = env::var("USERPROFILE").ok();
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
Self {
dir,
original_home,
original_userprofile,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match &self.original_userprofile {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
}
}
fn write_prompt_file(app: AppType, content: &str) {
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, content).expect("write prompt");
}
#[test]
#[serial]
fn auto_imports_existing_prompt_when_config_missing() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, "# hello");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.claude.prompts.len(), 1);
let prompt = config
.prompts
.claude
.prompts
.values()
.next()
.expect("prompt exists");
assert!(prompt.enabled);
assert_eq!(prompt.content, "# hello");
let config_path = crate::config::get_app_config_path();
assert!(
config_path.exists(),
"auto import should persist config to disk"
);
}
#[test]
#[serial]
fn skips_empty_prompt_files_during_import() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, " \n ");
let config = MultiAppConfig::load().expect("load config");
assert!(
config.prompts.claude.prompts.is_empty(),
"empty files must be ignored"
);
}
#[test]
#[serial]
fn auto_import_happens_only_once() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, "first version");
let first = MultiAppConfig::load().expect("load config");
assert_eq!(first.prompts.claude.prompts.len(), 1);
let claude_prompt = first
.prompts
.claude
.prompts
.values()
.next()
.expect("prompt exists")
.content
.clone();
assert_eq!(claude_prompt, "first version");
// 覆盖文件内容,但保留 config.json
write_prompt_file(AppType::Claude, "second version");
let second = MultiAppConfig::load().expect("load config again");
assert_eq!(second.prompts.claude.prompts.len(), 1);
let prompt = second
.prompts
.claude
.prompts
.values()
.next()
.expect("prompt exists");
assert_eq!(
prompt.content, "first version",
"should not re-import when config already exists"
);
}
#[test]
#[serial]
fn auto_imports_gemini_prompt_on_first_launch() {
let _home = TempHome::new();
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.gemini.prompts.len(), 1);
let prompt = config
.prompts
.gemini
.prompts
.values()
.next()
.expect("gemini prompt exists");
assert!(prompt.enabled, "gemini prompt should be enabled");
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
assert_eq!(
prompt.description,
Some("Automatically imported on first launch".to_string())
);
}
#[test]
#[serial]
fn auto_imports_all_three_apps_prompts() {
let _home = TempHome::new();
write_prompt_file(AppType::Claude, "# Claude prompt");
write_prompt_file(AppType::Codex, "# Codex prompt");
write_prompt_file(AppType::Gemini, "# Gemini prompt");
let config = MultiAppConfig::load().expect("load config");
// 验证所有三个应用的提示词都被导入
assert_eq!(config.prompts.claude.prompts.len(), 1);
assert_eq!(config.prompts.codex.prompts.len(), 1);
assert_eq!(config.prompts.gemini.prompts.len(), 1);
// 验证所有提示词都被启用
assert!(
config
.prompts
.claude
.prompts
.values()
.next()
.unwrap()
.enabled
);
assert!(
config
.prompts
.codex
.prompts
.values()
.next()
.unwrap()
.enabled
);
assert!(
config
.prompts
.gemini
.prompts
.values()
.next()
.unwrap()
.enabled
);
}
}

View File

@@ -1,135 +0,0 @@
use serde_json::Value;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use tauri_plugin_store::StoreExt;
use crate::error::AppError;
/// Store 中的键名
const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override";
/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle
static APP_CONFIG_DIR_OVERRIDE: OnceLock<RwLock<Option<PathBuf>>> = OnceLock::new();
fn override_cache() -> &'static RwLock<Option<PathBuf>> {
APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None))
}
fn update_cached_override(value: Option<PathBuf>) {
if let Ok(mut guard) = override_cache().write() {
*guard = value;
}
}
/// 获取缓存中的 app_config_dir 覆盖路径
pub fn get_app_config_dir_override() -> Option<PathBuf> {
override_cache().read().ok()?.clone()
}
fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
let store = match app.store_builder("app_paths.json").build() {
Ok(store) => store,
Err(e) => {
log::warn!("无法创建 Store: {e}");
return None;
}
};
match store.get(STORE_KEY_APP_CONFIG_DIR) {
Some(Value::String(path_str)) => {
let path_str = path_str.trim();
if path_str.is_empty() {
return None;
}
let path = resolve_path(path_str);
if !path.exists() {
log::warn!(
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
将使用默认路径。"
);
return None;
}
log::info!("使用 Store 中的 app_config_dir: {path:?}");
Some(path)
}
Some(_) => {
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
None
}
None => None,
}
}
/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存
pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option<PathBuf> {
let value = read_override_from_store(app);
update_cached_override(value.clone());
value
}
/// 写入 app_config_dir 到 Tauri Store
pub fn set_app_config_dir_to_store(
app: &tauri::AppHandle,
path: Option<&str>,
) -> Result<(), AppError> {
let store = app
.store_builder("app_paths.json")
.build()
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
match path {
Some(p) => {
let trimmed = p.trim();
if !trimmed.is_empty() {
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
} else {
store.delete(STORE_KEY_APP_CONFIG_DIR);
log::info!("已从 Store 中删除 app_config_dir 配置");
}
}
None => {
store.delete(STORE_KEY_APP_CONFIG_DIR);
log::info!("已从 Store 中删除 app_config_dir 配置");
}
}
store
.save()
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
refresh_app_config_dir_override(app);
Ok(())
}
/// 解析路径,支持 ~ 开头的相对路径
fn resolve_path(raw: &str) -> PathBuf {
if raw == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
} else if let Some(stripped) = raw.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
} else if let Some(stripped) = raw.strip_prefix("~\\") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
PathBuf::from(raw)
}
/// 从旧的 settings.json 迁移 app_config_dir 到 Store
pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {
// app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移
// 如果用户在旧版本设置过 app_config_dir需要在 Store 中手动配置
log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置");
let _ = refresh_app_config_dir_override(app);
Ok(())
}

View File

@@ -1,40 +0,0 @@
use crate::error::AppError;
use auto_launch::AutoLaunch;
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch";
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
Ok(auto_launch)
}
/// 启用开机自启
pub fn enable_auto_launch() -> Result<(), AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.enable()
.map_err(|e| AppError::Message(format!("启用开机自启失败: {e}")))?;
log::info!("已启用开机自启");
Ok(())
}
/// 禁用开机自启
pub fn disable_auto_launch() -> Result<(), AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.disable()
.map_err(|e| AppError::Message(format!("禁用开机自启失败: {e}")))?;
log::info!("已禁用开机自启");
Ok(())
}
/// 检查是否已启用开机自启
pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
}

View File

@@ -1,305 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::error::AppError;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
fn user_config_path() -> PathBuf {
ensure_mcp_override_migrated();
get_claude_mcp_path()
}
fn ensure_mcp_override_migrated() {
if crate::settings::get_claude_override_dir().is_none() {
return;
}
let new_path = get_claude_mcp_path();
if new_path.exists() {
return;
}
let legacy_path = get_default_claude_mcp_path();
if !legacy_path.exists() {
return;
}
if let Some(parent) = new_path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
log::warn!("创建 MCP 目录失败: {err}");
return;
}
}
match fs::copy(&legacy_path, &new_path) {
Ok(_) => {
log::info!(
"已根据覆盖目录复制 MCP 配置: {} -> {}",
legacy_path.display(),
new_path.display()
);
}
Err(err) => {
log::warn!(
"复制 MCP 配置失败: {} -> {}: {}",
legacy_path.display(),
new_path.display(),
err
);
}
}
}
fn read_json_value(path: &Path) -> Result<Value, AppError> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
Ok(value)
}
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json =
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
pub fn get_mcp_status() -> Result<McpStatus, AppError> {
let path = user_config_path();
let (exists, count) = if path.exists() {
let v = read_json_value(&path)?;
let servers = v.get("mcpServers").and_then(|x| x.as_object());
(true, servers.map(|m| m.len()).unwrap_or(0))
} else {
(false, 0)
};
Ok(McpStatus {
user_config_path: path.to_string_lossy().to_string(),
user_config_exists: exists,
server_count: count,
})
}
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(Some(content))
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
// 基础字段校验(尽量宽松)
if !spec.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器定义必须为 JSON 对象".into(),
));
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio".into(),
));
}
// stdio 类型必须有 command
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.is_empty() {
return Err(AppError::McpValidation(
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
));
}
}
// http/sse 类型必须有 url
if is_http || is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err(AppError::McpValidation(if is_http {
"http 类型的 MCP 服务器缺少 url 字段".into()
} else {
"sse 类型的 MCP 服务器缺少 url 字段".into()
}));
}
}
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 确保 mcpServers 对象存在
{
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?;
if !obj.contains_key("mcpServers") {
obj.insert("mcpServers".into(), serde_json::json!({}));
}
}
let before = root.clone();
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
servers.insert(id.to_string(), spec);
}
if before == root && path.exists() {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
return Ok(false);
};
let existed = servers.remove(id).is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
if cmd.trim().is_empty() {
return Ok(false);
}
// 如果包含路径分隔符,直接判断是否存在可执行文件
if cmd.contains('/') || cmd.contains('\\') {
return Ok(Path::new(cmd).exists());
}
let path_var = env::var_os("PATH").unwrap_or_default();
let paths = env::split_paths(&path_var);
#[cfg(windows)]
let exts: Vec<String> = env::var("PATHEXT")
.unwrap_or(".COM;.EXE;.BAT;.CMD".into())
.split(';')
.map(|s| s.trim().to_uppercase())
.collect();
for p in paths {
let candidate = p.join(cmd);
if candidate.is_file() {
return Ok(true);
}
#[cfg(windows)]
{
for ext in &exts {
let cand = p.join(format!("{}{}", cmd, ext));
if cand.is_file() {
return Ok(true);
}
}
}
}
Ok(false)
}
/// 读取 ~/.claude.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(std::collections::HashMap::new());
}
let root = read_json_value(&path)?;
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)
}
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
servers: &std::collections::HashMap<String, Value>,
) -> Result<(), AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
map.clone()
} else {
return Err(AppError::McpValidation(format!(
"MCP 服务器 '{id}' 不是对象"
)));
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
})?;
obj = server_obj;
}
obj.remove("enabled");
obj.remove("source");
obj.remove("id");
obj.remove("name");
obj.remove("description");
obj.remove("tags");
obj.remove("homepage");
obj.remove("docs");
out.insert(id.clone(), Value::Object(obj));
}
{
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
obj.insert("mcpServers".into(), Value::Object(out));
}
write_json_value(&path, &root)?;
Ok(())
}

View File

@@ -1,131 +0,0 @@
use std::fs;
use std::path::PathBuf;
use crate::error::AppError;
const CLAUDE_DIR: &str = ".claude";
const CLAUDE_CONFIG_FILE: &str = "config.json";
fn claude_dir() -> Result<PathBuf, AppError> {
// 优先使用设置中的覆盖目录
if let Some(dir) = crate::settings::get_claude_override_dir() {
return Ok(dir);
}
let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
Ok(home.join(CLAUDE_DIR))
}
pub fn claude_config_path() -> Result<PathBuf, AppError> {
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
}
pub fn ensure_claude_dir_exists() -> Result<PathBuf, AppError> {
let dir = claude_dir()?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;
}
Ok(dir)
}
pub fn read_claude_config() -> Result<Option<String>, AppError> {
let path = claude_config_path()?;
if path.exists() {
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(Some(content))
} else {
Ok(None)
}
}
fn is_managed_config(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(value) => value
.get("primaryApiKey")
.and_then(|v| v.as_str())
.map(|val| val == "any")
.unwrap_or(false),
Err(_) => false,
}
}
pub fn write_claude_config() -> Result<bool, AppError> {
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
let path = claude_config_path()?;
ensure_claude_dir_exists()?;
// 尝试读取并解析为对象
let mut obj = match read_claude_config()? {
Some(existing) => match serde_json::from_str::<serde_json::Value>(&existing) {
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
_ => serde_json::json!({}),
},
None => serde_json::json!({}),
};
let mut changed = false;
if let Some(map) = obj.as_object_mut() {
let cur = map
.get("primaryApiKey")
.and_then(|v| v.as_str())
.unwrap_or("");
if cur != "any" {
map.insert(
"primaryApiKey".to_string(),
serde_json::Value::String("any".to_string()),
);
changed = true;
}
}
if changed || !path.exists() {
let serialized = serde_json::to_string_pretty(&obj)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
Ok(true)
} else {
Ok(false)
}
}
pub fn clear_claude_config() -> Result<bool, AppError> {
let path = claude_config_path()?;
if !path.exists() {
return Ok(false);
}
let content = match read_claude_config()? {
Some(content) => content,
None => return Ok(false),
};
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(_) => return Ok(false),
};
let obj = match value.as_object_mut() {
Some(obj) => obj,
None => return Ok(false),
};
if obj.remove("primaryApiKey").is_none() {
return Ok(false);
}
let serialized =
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
Ok(true)
}
pub fn claude_config_status() -> Result<(bool, PathBuf), AppError> {
let path = claude_config_path()?;
Ok((path.exists(), path))
}
pub fn is_claude_config_applied() -> Result<bool, AppError> {
match read_claude_config()? {
Some(content) => Ok(is_managed_config(&content)),
None => Ok(false),
}
}

View File

@@ -4,7 +4,6 @@ use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
@@ -34,20 +33,17 @@ pub fn get_codex_provider_paths(
provider_name: Option<&str>,
) -> (PathBuf, PathBuf) {
let base_name = provider_name
.map(sanitize_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-{base_name}.json"));
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
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 delete_codex_provider_config(
provider_id: &str,
provider_name: &str,
) -> Result<(), AppError> {
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();
@@ -56,26 +52,25 @@ pub fn delete_codex_provider_config(
Ok(())
}
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
pub fn write_codex_live_atomic(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
if let Some(parent) = auth_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
}
// 读取旧内容用于回滚
let old_auth = if auth_path.exists() {
Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
} else {
None
};
let _old_config = if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
} else {
None
};
@@ -86,7 +81,8 @@ pub fn write_codex_live_atomic(
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
toml::from_str::<toml::Table>(&cfg_text)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
// 第一步:写 auth.json
@@ -107,28 +103,44 @@ pub fn write_codex_live_atomic(
}
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
pub fn read_codex_config_text() -> Result<String, AppError> {
pub fn read_codex_config_text() -> Result<String, String> {
let path = get_codex_config_path();
if path.exists() {
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e))
} else {
Ok(String::new())
}
}
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
pub fn read_config_text_from_path(path: &Path) -> Result<String, String> {
if path.exists() {
std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e))
} else {
Ok(String::new())
}
}
/// 对非空的 TOML 文本进行语法校验
pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
pub fn validate_config_toml(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|e| AppError::toml(Path::new("config.toml"), e))
.map_err(|e| format!("config.toml 语法错误: {}", e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
pub fn read_and_validate_codex_config_text() -> Result<String, String> {
let s = read_codex_config_text()?;
validate_config_toml(&s)?;
Ok(s)
}
/// 从指定路径读取并校验 config.toml返回文本可能为空
pub fn read_and_validate_config_from_path(path: &Path) -> Result<String, String> {
let s = read_config_text_from_path(path)?;
validate_config_toml(&s)?;
Ok(s)
}

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

@@ -0,0 +1,732 @@
#![allow(non_snake_case)]
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::store::AppState;
use crate::vscode;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
AppType::Claude => {
if !provider.settings_config.is_object() {
return Err("Claude 配置必须是 JSON 对象".to_string());
}
}
AppType::Codex => {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
let auth = settings
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
if !auth.is_object() {
return Err("Codex auth 配置必须是 JSON 对象".to_string());
}
if let Some(config_value) = settings.get("config") {
if !(config_value.is_string() || config_value.is_null()) {
return Err("Codex config 字段必须是字符串".to_string());
}
if let Some(cfg_text) = config_value.as_str() {
codex_config::validate_config_toml(cfg_text)?;
}
}
}
}
Ok(())
}
/// 获取所有供应商
#[tauri::command]
pub async fn get_providers(
state: State<'_, AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<HashMap<String, Provider>, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
Ok(manager.get_all_providers().clone())
}
/// 获取当前供应商ID
#[tauri::command]
pub async fn get_current_provider(
state: State<'_, AppState>,
app_type: Option<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()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
Ok(manager.current.clone())
}
/// 添加供应商
#[tauri::command]
pub async fn add_provider(
state: State<'_, AppState>,
app_type: Option<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);
validate_provider_settings(&app_type, &provider)?;
// 读取当前是否是激活供应商(短锁)
let is_current = {
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.current == provider.id
};
// 若目标为当前供应商,则先写 live成功后再落盘配置
if is_current {
match app_type {
AppType::Claude => {
let settings_path = crate::config::get_claude_settings_path();
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
}
AppType::Codex => {
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
let cfg_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str());
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
}
}
}
// 更新内存并保存配置
{
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
Ok(true)
}
/// 更新供应商
#[tauri::command]
pub async fn update_provider(
state: State<'_, AppState>,
app_type: Option<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);
validate_provider_settings(&app_type, &provider)?;
// 读取校验 & 是否当前(短锁)
let (exists, is_current) = {
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
(
manager.providers.contains_key(&provider.id),
manager.current == provider.id,
)
};
if !exists {
return Err(format!("供应商不存在: {}", provider.id));
}
// 若更新的是当前供应商,先写 live 成功再保存
if is_current {
match app_type {
AppType::Claude => {
let settings_path = crate::config::get_claude_settings_path();
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
}
AppType::Codex => {
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
let cfg_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str());
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
}
}
}
// 更新内存并保存
{
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
Ok(true)
}
/// 删除供应商
#[tauri::command]
pub async fn delete_provider(
state: State<'_, AppState>,
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()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查是否为当前供应商
if manager.current == id {
return Err("不能删除当前正在使用的供应商".to_string());
}
// 获取供应商信息
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// 删除配置文件
match app_type {
AppType::Codex => {
codex_config::delete_codex_provider_config(&id, &provider.name)?;
}
AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path};
// 兼容历史两种命名settings-{name}.json 与 settings-{id}.json
let by_name = get_provider_config_path(&id, Some(&provider.name));
let by_id = get_provider_config_path(&id, None);
delete_file(&by_name)?;
delete_file(&by_id)?;
}
}
// 从管理器删除
manager.providers.remove(&id);
// 保存配置
drop(config); // 释放锁
state.save()?;
Ok(true)
}
/// 切换供应商
#[tauri::command]
pub async fn switch_provider(
state: State<'_, AppState>,
app_type: Option<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()
.map_err(|e| format!("获取锁失败: {}", e))?;
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();
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
match app_type {
AppType::Codex => {
use serde_json::Value;
// 回填:读取 liveauth.json + config.toml写回当前供应商 settings_config
if !manager.current.is_empty() {
let auth_path = codex_config::get_codex_auth_path();
let config_path = codex_config::get_codex_config_path();
if auth_path.exists() {
let auth: Value = crate::config::read_json_file(&auth_path)?;
let config_str = if config_path.exists() {
std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
} else {
String::new()
};
let live = serde_json::json!({
"auth": auth,
"config": config_str,
});
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
}
}
}
// 切换:从目标供应商 settings_config 写入主配置Codex 双文件原子+回滚)
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
let cfg_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str());
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
}
AppType::Claude => {
use crate::config::{read_json_file, write_json_file};
let settings_path = get_claude_settings_path();
// 回填:读取 live settings.json 写回当前供应商 settings_config
if settings_path.exists() && !manager.current.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
}
}
}
// 切换:从目标供应商 settings_config 写入主配置
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 不做归档,直接写入
write_json_file(&settings_path, &provider.settings_config)?;
}
}
// 更新当前供应商
manager.current = id;
log::info!("成功切换到供应商: {}", provider.name);
// 保存配置
drop(config); // 释放锁
state.save()?;
Ok(true)
}
/// 导入当前配置为默认供应商
#[tauri::command]
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);
// 仅当 providers 为空时才从 live 导入一条默认项
{
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
if let Some(manager) = config.get_manager(&app_type) {
if !manager.get_all_providers().is_empty() {
return Ok(true);
}
}
}
// 根据应用类型导入配置
// 读取当前主配置为默认供应商(不再写入副本文件)
let settings_config = match app_type {
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
let auth: serde_json::Value =
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let config_str = match crate::codex_config::read_and_validate_codex_config_text() {
Ok(s) => s,
Err(e) => return Err(e),
};
serde_json::json!({ "auth": auth, "config": config_str })
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
crate::config::read_json_file::<serde_json::Value>(&settings_path)?
}
};
// 创建默认供应商(仅首次初始化)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
// 添加到管理器
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider);
// 设置当前供应商为默认项
manager.current = "default".to_string();
// 保存配置
drop(config); // 释放锁
state.save()?;
Ok(true)
}
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
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 配置文件路径
#[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 get_config_dir(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<String, 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);
let dir = match app {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
};
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command]
pub async fn open_config_folder(
handle: tauri::AppHandle,
app_type: Option<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() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: tauri::AppHandle,
default_path: Option<String>,
) -> Result<Option<String>, String> {
let initial = default_path
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let result = tauri::async_runtime::spawn_blocking(move || {
let mut builder = app.dialog().file();
if let Some(path) = initial {
builder = builder.set_directory(path);
}
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
}
}
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<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)
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
use crate::config::get_app_config_path;
let config_path = get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, String> {
use crate::config::get_app_config_dir;
let config_dir = get_app_config_dir();
// 确保目录存在
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)?;
Ok(true)
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String> {
// 打开 GitHub releases 页面
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// VS Code: 获取用户 settings.json 状态
#[tauri::command]
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
if let Some(p) = vscode::find_existing_settings() {
Ok(ConfigStatus {
exists: true,
path: p.to_string_lossy().to_string(),
})
} else {
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
let preferred = vscode::candidate_settings_paths().into_iter().next();
Ok(ConfigStatus {
exists: false,
path: preferred.unwrap_or_default().to_string_lossy().to_string(),
})
}
}
/// VS Code: 读取 settings.json 文本(仅当文件存在)
#[tauri::command]
pub async fn read_vscode_settings() -> Result<String, String> {
if let Some(p) = vscode::find_existing_settings() {
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
#[tauri::command]
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
if let Some(p) = vscode::find_existing_settings() {
config::write_text_file(&p, &content)?;
Ok(true)
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}

View File

@@ -1,240 +0,0 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(config::get_claude_config_status())
}
use std::str::FromStr;
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => Ok(config::get_claude_config_status()),
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
let exists = auth_path.exists();
let path = codex_config::get_codex_config_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
AppType::Gemini => {
let env_path = crate::gemini_config::get_gemini_env_path();
let exists = env_path.exists();
let path = crate::gemini_config::get_gemini_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
/// 获取 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 get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
};
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
};
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: AppHandle,
#[allow(non_snake_case)] defaultPath: Option<String>,
) -> Result<Option<String>, String> {
let initial = defaultPath
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let result = tauri::async_runtime::spawn_blocking(move || {
let mut builder = app.dialog().file();
if let Some(path) = initial {
builder = builder.set_directory(path);
}
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
}
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
let config_path = config::get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.claude.clone())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证是否为有效的 JSON如果不为空
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
guard.common_config_snippets.claude = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
guard.save().map_err(|e| e.to_string())?;
Ok(())
}
/// 获取通用配置片段(统一接口)
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.get(&app).cloned())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app {
AppType::Claude | AppType::Gemini => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
AppType::Codex => {
// TOML 格式暂不验证(或可使用 toml crate
// 注意TOML 验证较为复杂,暂时跳过
}
}
}
guard.common_config_snippets.set(
&app,
if snippet.trim().is_empty() {
None
} else {
Some(snippet)
},
);
guard.save().map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -1,39 +0,0 @@
use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
use crate::store::AppState;
use tauri::State;
/// Parse a deep link URL and return the parsed request for frontend confirmation
#[tauri::command]
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
log::info!("Parsing deep link URL: {url}");
parse_deeplink_url(&url).map_err(|e| e.to_string())
}
/// Merge configuration from Base64/URL into a deep link request
/// This is used by the frontend to show the complete configuration in the confirmation dialog
#[tauri::command]
pub fn merge_deeplink_config(
request: DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, String> {
log::info!("Merging config for deep link request: {}", request.name);
crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())
}
/// Import a provider from a deep link request (after user confirmation)
#[tauri::command]
pub fn import_from_deeplink(
state: State<AppState>,
request: DeepLinkImportRequest,
) -> Result<String, String> {
log::info!(
"Importing provider from deep link: {} for app {}",
request.name,
request.app
);
let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
log::info!("Successfully imported provider with ID: {provider_id}");
Ok(provider_id)
}

View File

@@ -1,22 +0,0 @@
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
use crate::services::env_manager::{
delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
};
/// Check environment variable conflicts for a specific app
#[tauri::command]
pub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {
check_conflicts(&app)
}
/// Delete environment variables with backup
#[tauri::command]
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
delete_vars(conflicts)
}
/// Restore environment variables from backup file
#[tauri::command]
pub fn restore_env_backup(backup_path: String) -> Result<(), String> {
restore_from_backup(backup_path)
}

View File

@@ -1,106 +0,0 @@
#![allow(non_snake_case)]
use serde_json::{json, Value};
use std::path::PathBuf;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use crate::error::AppError;
use crate::services::ConfigService;
use crate::store::AppState;
/// 导出配置文件
#[tauri::command]
pub async fn export_config_to_file(
#[allow(non_snake_case)] filePath: String,
) -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(move || {
let target_path = PathBuf::from(&filePath);
ConfigService::export_config_to_path(&target_path)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Configuration exported successfully",
"filePath": filePath
}))
})
.await
.map_err(|e| format!("导出配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// 从文件导入配置
#[tauri::command]
pub async fn import_config_from_file(
#[allow(non_snake_case)] filePath: String,
state: State<'_, AppState>,
) -> Result<Value, String> {
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
ConfigService::load_config_for_import(&path_buf)
})
.await
.map_err(|e| format!("导入配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())?;
{
let mut guard = state
.config
.write()
.map_err(|e| AppError::from(e).to_string())?;
*guard = new_config;
}
Ok(json!({
"success": true,
"message": "Configuration imported successfully",
"backupId": backup_id
}))
}
/// 同步当前供应商配置到对应的 live 文件
#[tauri::command]
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
{
let mut config_state = state
.config
.write()
.map_err(|e| AppError::from(e).to_string())?;
ConfigService::sync_current_providers_to_live(&mut config_state)
.map_err(|e| e.to_string())?;
}
Ok(json!({
"success": true,
"message": "Live configuration synchronized"
}))
}
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
#[allow(non_snake_case)] defaultName: String,
) -> Result<Option<String>, String> {
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.set_file_name(&defaultName)
.blocking_save_file();
Ok(result.map(|p| p.to_string()))
}
/// 打开文件对话框
#[tauri::command]
pub async fn open_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}

View File

@@ -1,197 +0,0 @@
#![allow(non_snake_case)]
use std::collections::HashMap;
use serde::Serialize;
use tauri::State;
use crate::app_config::AppType;
use crate::claude_mcp;
use crate::services::McpService;
use crate::store::AppState;
/// 获取 Claude MCP 状态
#[tauri::command]
pub async fn get_claude_mcp_status() -> Result<claude_mcp::McpStatus, String> {
claude_mcp::get_mcp_status().map_err(|e| e.to_string())
}
/// 读取 mcp.json 文本内容
#[tauri::command]
pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
claude_mcp::read_mcp_json().map_err(|e| e.to_string())
}
/// 新增或更新一个 MCP 服务器条目
#[tauri::command]
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
claude_mcp::upsert_mcp_server(&id, spec).map_err(|e| e.to_string())
}
/// 删除一个 MCP 服务器条目
#[tauri::command]
pub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {
claude_mcp::delete_mcp_server(&id).map_err(|e| e.to_string())
}
/// 校验命令是否在 PATH 中可用(不执行)
#[tauri::command]
pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
claude_mcp::validate_command_in_path(&cmd).map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct McpConfigResponse {
pub config_path: String,
pub servers: HashMap<String, serde_json::Value>,
}
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json
use std::str::FromStr;
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn get_mcp_config(
state: State<'_, AppState>,
app: String,
) -> Result<McpConfigResponse, String> {
let config_path = crate::config::get_app_config_path()
.to_string_lossy()
.to_string();
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
let servers = McpService::get_servers(&state, app_ty).map_err(|e| e.to_string())?;
Ok(McpConfigResponse {
config_path,
servers,
})
}
/// 在 config.json 中新增或更新一个 MCP 服务器定义
/// [已废弃] 该命令仍然使用旧的分应用API会转换为统一结构
#[tauri::command]
pub async fn upsert_mcp_server_in_config(
state: State<'_, AppState>,
app: String,
id: String,
spec: serde_json::Value,
sync_other_side: Option<bool>,
) -> Result<bool, String> {
use crate::app_config::McpServer;
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 读取现有的服务器(如果存在)
let existing_server = {
let cfg = state.config.read().map_err(|e| e.to_string())?;
if let Some(servers) = &cfg.mcp.servers {
servers.get(&id).cloned()
} else {
None
}
};
// 构建新的统一服务器结构
let mut new_server = if let Some(mut existing) = existing_server {
// 更新现有服务器
existing.server = spec.clone();
existing.apps.set_enabled_for(&app_ty, true);
existing
} else {
// 创建新服务器
let mut apps = crate::app_config::McpApps::default();
apps.set_enabled_for(&app_ty, true);
// 尝试从 spec 中提取 name否则使用 id
let name = spec
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
McpServer {
id: id.clone(),
name,
server: spec,
apps,
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
}
};
// 如果 sync_other_side 为 true也启用其他应用
if sync_other_side.unwrap_or(false) {
new_server.apps.claude = true;
new_server.apps.codex = true;
new_server.apps.gemini = true;
}
McpService::upsert_server(&state, new_server)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 在 config.json 中删除一个 MCP 服务器定义
#[tauri::command]
pub async fn delete_mcp_server_in_config(
state: State<'_, AppState>,
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
id: String,
) -> Result<bool, String> {
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
/// 设置启用状态并同步到客户端配置
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: String,
id: String,
enabled: bool,
) -> Result<bool, String> {
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
}
// ============================================================================
// v3.7.0 新增:统一 MCP 管理命令
// ============================================================================
use crate::app_config::McpServer;
/// 获取所有 MCP 服务器(统一结构)
#[tauri::command]
pub async fn get_mcp_servers(
state: State<'_, AppState>,
) -> Result<HashMap<String, McpServer>, String> {
McpService::get_all_servers(&state).map_err(|e| e.to_string())
}
/// 添加或更新 MCP 服务器
#[tauri::command]
pub async fn upsert_mcp_server(
state: State<'_, AppState>,
server: McpServer,
) -> Result<(), String> {
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
}
/// 删除 MCP 服务器
#[tauri::command]
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
/// 切换 MCP 服务器在指定应用的启用状态
#[tauri::command]
pub async fn toggle_mcp_app(
state: State<'_, AppState>,
server_id: String,
app: String,
enabled: bool,
) -> Result<(), String> {
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}

View File

@@ -1,53 +0,0 @@
#![allow(non_snake_case)]
use crate::init_status::InitErrorPayload;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
let url = if url.starts_with("http://") || url.starts_with("https://") {
url
} else {
format!("https://{url}")
};
app.opener()
.open_url(&url, None::<String>)
.map_err(|e| format!("打开链接失败: {e}"))?;
Ok(true)
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {e}"))?;
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// 获取应用启动阶段的初始化错误(若有)。
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
#[tauri::command]
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
Ok(crate::init_status::get_init_error())
}

View File

@@ -1,25 +0,0 @@
#![allow(non_snake_case)]
mod config;
mod deeplink;
mod env;
mod import_export;
mod mcp;
mod misc;
mod plugin;
mod prompt;
mod provider;
mod settings;
pub mod skill;
pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use settings::*;
pub use skill::*;

View File

@@ -1,36 +0,0 @@
#![allow(non_snake_case)]
use crate::config::ConfigStatus;
/// Claude 插件:获取 ~/.claude/config.json 状态
#[tauri::command]
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
crate::claude_plugin::claude_config_status()
.map(|(exists, path)| ConfigStatus {
exists,
path: path.to_string_lossy().to_string(),
})
.map_err(|e| e.to_string())
}
/// Claude 插件:读取配置内容(若不存在返回 Ok(None)
#[tauri::command]
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
crate::claude_plugin::read_claude_config().map_err(|e| e.to_string())
}
/// Claude 插件:写入/清除固定配置
#[tauri::command]
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
if official {
crate::claude_plugin::clear_claude_config().map_err(|e| e.to_string())
} else {
crate::claude_plugin::write_claude_config().map_err(|e| e.to_string())
}
}
/// Claude 插件:检测是否已写入目标配置
#[tauri::command]
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
}

View File

@@ -1,64 +0,0 @@
use std::collections::HashMap;
use std::str::FromStr;
use tauri::State;
use crate::app_config::AppType;
use crate::prompt::Prompt;
use crate::services::PromptService;
use crate::store::AppState;
#[tauri::command]
pub async fn get_prompts(
app: String,
state: State<'_, AppState>,
) -> Result<HashMap<String, Prompt>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn upsert_prompt(
app: String,
id: String,
prompt: Prompt,
state: State<'_, AppState>,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_prompt(
app: String,
id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn enable_prompt(
app: String,
id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_prompt_from_file(
app: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
}

View File

@@ -1,233 +0,0 @@
use std::collections::HashMap;
use tauri::State;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
use crate::store::AppState;
use std::str::FromStr;
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
app: String,
) -> Result<HashMap<String, Provider>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 获取当前供应商ID
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 添加供应商
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 更新供应商
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 删除供应商
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::delete(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
ProviderService::switch(state, app_type, id)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub fn switch_provider_test_hook(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
switch_provider_internal(state, app_type, id)
}
#[tauri::command]
pub fn switch_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
switch_provider_internal(&state, app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
ProviderService::import_default_config(state, app_type)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
import_default_config_internal(state, app_type)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type)
.map(|_| true)
.map_err(Into::into)
}
/// 查询供应商用量
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::query_usage(state.inner(), app_type, &providerId)
.await
.map_err(|e| e.to_string())
}
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn testUsageScript(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerId: String,
app: String,
#[allow(non_snake_case)] scriptCode: String,
timeout: Option<u64>,
#[allow(non_snake_case)] apiKey: Option<String>,
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
state.inner(),
app_type,
&providerId,
&scriptCode,
timeout.unwrap_or(10),
apiKey.as_deref(),
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
/// 读取当前生效的配置内容
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
) -> Result<Vec<EndpointLatency>, String> {
SpeedtestService::test_endpoints(urls, timeoutSecs)
.await
.map_err(|e| e.to_string())
}
/// 获取自定义端点列表
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
.map_err(|e| e.to_string())
}
/// 添加自定义端点
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
/// 删除自定义端点
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
/// 更新端点最后使用时间
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
/// 更新多个供应商的排序
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
app: String,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}

View File

@@ -1,56 +0,0 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
Ok(true)
}
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
app.restart();
}
/// 获取 app_config_dir 覆盖配置 (从 Store)
#[tauri::command]
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
Ok(crate::app_store::refresh_app_config_dir_override(&app)
.map(|p| p.to_string_lossy().to_string()))
}
/// 设置 app_config_dir 覆盖配置 (到 Store)
#[tauri::command]
pub async fn set_app_config_dir_override(
app: AppHandle,
path: Option<String>,
) -> Result<bool, String> {
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
Ok(true)
}
/// 设置开机自启
#[tauri::command]
pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
if enabled {
crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?;
} else {
crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?;
}
Ok(true)
}
/// 获取开机自启状态
#[tauri::command]
pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
}

View File

@@ -1,176 +0,0 @@
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
pub struct SkillServiceState(pub Arc<SkillService>);
#[tauri::command]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let repos = {
let config = app_state.config.read().map_err(|e| e.to_string())?;
config.skills.repos.clone()
};
service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn install_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
// 先在不持有写锁的情况下收集仓库与技能信息
let repos = {
let config = app_state.config.read().map_err(|e| e.to_string())?;
config.skills.repos.clone()
};
let skills = service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
&[("directory", &directory)],
Some("checkRepoUrl"),
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service
.0
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
config.skills.skills.insert(
directory.clone(),
SkillState {
installed: true,
installed_at: Utc::now(),
},
);
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
service
.0
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
config.skills.skills.remove(&directory);
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
let config = app_state.config.read().map_err(|e| e.to_string())?;
Ok(config.skills.repos.clone())
}
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
service
.0
.add_repo(&mut config.skills, repo)
.map_err(|e| e.to_string())?;
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
service
.0
.remove_repo(&mut config.skills, owner, name)
.map_err(|e| e.to_string())?;
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}

View File

@@ -1,10 +1,9 @@
use serde::{Deserialize, Serialize};
// unused import removed
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
@@ -16,36 +15,6 @@ pub fn get_claude_config_dir() -> PathBuf {
.join(".claude")
}
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf {
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
let file_name = dir
.file_name()
.map(|name| name.to_string_lossy().to_string())?
.trim()
.to_string();
if file_name.is_empty() {
return None;
}
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
Some(parent.join(format!("{file_name}.json")))
}
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
pub fn get_claude_mcp_path() -> PathBuf {
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
return path;
}
}
get_default_claude_mcp_path()
}
/// 获取 Claude Code 主配置文件路径
pub fn get_claude_settings_path() -> PathBuf {
let dir = get_claude_config_dir();
@@ -64,10 +33,6 @@ pub fn get_claude_settings_path() -> PathBuf {
/// 获取应用配置目录路径 (~/.cc-switch)
pub fn get_app_config_dir() -> PathBuf {
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
@@ -78,6 +43,55 @@ pub fn get_app_config_path() -> PathBuf {
get_app_config_dir().join("config.json")
}
/// 归档根目录 ~/.cc-switch/archive
pub fn get_archive_root() -> PathBuf {
get_app_config_dir().join("archive")
}
fn ensure_unique_path(dest: PathBuf) -> PathBuf {
if !dest.exists() {
return dest;
}
let file_name = dest
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "file".into());
let ext = dest
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let parent = dest.parent().map(|p| p.to_path_buf()).unwrap_or_default();
for i in 2..1000 {
let mut candidate = parent.clone();
candidate.push(format!("{}-{}{}", file_name, i, ext));
if !candidate.exists() {
return candidate;
}
}
dest
}
/// 将现有文件归档到 `~/.cc-switch/archive/<ts>/<category>/` 下,返回归档路径
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, String> {
if !src.exists() {
return Ok(None);
}
let mut dest_dir = get_archive_root();
dest_dir.push(ts.to_string());
dest_dir.push(category);
fs::create_dir_all(&dest_dir).map_err(|e| format!("创建归档目录失败: {}", e))?;
let file_name = src
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "file".into());
let mut dest = dest_dir.join(file_name);
dest = ensure_unique_path(dest);
copy_file(src, &dest)?;
Ok(Some(dest))
}
/// 清理供应商名称,确保文件名安全
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -92,69 +106,68 @@ pub fn sanitize_provider_name(name: &str) -> String {
/// 获取供应商配置文件路径
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
let base_name = provider_name
.map(sanitize_provider_name)
.map(|name| sanitize_provider_name(name))
.unwrap_or_else(|| sanitize_provider_name(provider_id));
get_claude_config_dir().join(format!("settings-{base_name}.json"))
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, AppError> {
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
if !path.exists() {
return Err(AppError::Config(format!("文件不存在: {}", path.display())));
return Err(format!("文件不存在: {}", path.display()));
}
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
serde_json::from_str(&content).map_err(|e| AppError::json(path, 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<(), AppError> {
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| AppError::io(parent, e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let json =
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
atomic_write(path, json.as_bytes())
}
/// 原子写入文本文件(用于 TOML/纯文本)
pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
atomic_write(path, data.as_bytes())
}
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let parent = path
.parent()
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
let mut tmp = parent.to_path_buf();
let file_name = path
.file_name()
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
.ok_or_else(|| "无效的文件名".to_string())?
.to_string_lossy()
.to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{file_name}.tmp.{ts}"));
tmp.push(format!("{}.tmp.{}", file_name, ts));
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
f.flush().map_err(|e| AppError::io(&tmp, e))?;
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
f.write_all(data)
.map_err(|e| format!("写入临时文件失败: {}", e))?;
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
}
#[cfg(unix)]
@@ -172,70 +185,26 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
if path.exists() {
let _ = fs::remove_file(path);
}
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_mcp_path_from_override_preserves_folder_name() {
let override_dir = PathBuf::from("/tmp/profile/.claude");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for nested dir");
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
}
#[test]
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
let override_dir = PathBuf::from("/data/claude-config");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for standard dir");
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
}
#[test]
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
let override_dir = PathBuf::from("claude");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for single segment");
assert_eq!(derived, PathBuf::from("claude.json"));
}
#[test]
fn derive_mcp_path_from_root_like_dir_returns_none() {
let override_dir = PathBuf::from("/");
assert!(derive_mcp_path_from_override(&override_dir).is_none());
}
}
/// 复制文件
pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
fs::copy(from, to).map_err(|e| AppError::IoContext {
context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
source: 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<(), AppError> {
pub fn delete_file(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
}
Ok(())
}
@@ -255,3 +224,5 @@ pub fn get_claude_config_status() -> ConfigStatus {
path: path.to_string_lossy().to_string(),
}
}
//(移除未使用的备份/导入函数,避免 dead_code 告警)

View File

@@ -1,866 +0,0 @@
/// Deep link import functionality for CC Switch
///
/// This module implements the ccswitch:// protocol for importing provider configurations
/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design.
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use url::Url;
/// Deep link import request model
/// Represents a parsed ccswitch:// URL ready for processing
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepLinkImportRequest {
/// Protocol version (e.g., "v1")
pub version: String,
/// Resource type to import (e.g., "provider")
pub resource: String,
/// Target application (claude/codex/gemini)
pub app: String,
/// Provider name
pub name: String,
/// Provider homepage URL
pub homepage: String,
/// API endpoint/base URL
pub endpoint: String,
/// API key
pub api_key: String,
/// Optional model name
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// Optional Haiku model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub haiku_model: Option<String>,
/// Optional Sonnet model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub sonnet_model: Option<String>,
/// Optional Opus model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub opus_model: Option<String>,
/// Optional Base64 encoded config content (v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<String>,
/// Optional config format (json/toml, v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_format: Option<String>,
/// Optional remote config URL (v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>,
}
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
///
/// Expected format:
/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=...
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
// Parse URL
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
// Validate scheme
let scheme = url.scheme();
if scheme != "ccswitch" {
return Err(AppError::InvalidInput(format!(
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
)));
}
// Extract version from host
let version = url
.host_str()
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
.to_string();
// Validate version
if version != "v1" {
return Err(AppError::InvalidInput(format!(
"Unsupported protocol version: {version}"
)));
}
// Extract path (should be "/import")
let path = url.path();
if path != "/import" {
return Err(AppError::InvalidInput(format!(
"Invalid path: expected '/import', got '{path}'"
)));
}
// Parse query parameters
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
// Extract and validate resource type
let resource = params
.get("resource")
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
.clone();
if resource != "provider" {
return Err(AppError::InvalidInput(format!(
"Unsupported resource type: {resource}"
)));
}
// Extract required fields
let app = params
.get("app")
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
.clone();
// Validate app type
if app != "claude" && app != "codex" && app != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
)));
}
let name = params
.get("name")
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
.clone();
// Make these optional for config file auto-fill (v3.8+)
let homepage = params.get("homepage").cloned().unwrap_or_default();
let endpoint = params.get("endpoint").cloned().unwrap_or_default();
let api_key = params.get("apiKey").cloned().unwrap_or_default();
// Validate URLs only if provided
if !homepage.is_empty() {
validate_url(&homepage, "homepage")?;
}
if !endpoint.is_empty() {
validate_url(&endpoint, "endpoint")?;
}
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
// Extract Claude-specific optional model fields (v3.7.1+)
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
let opus_model = params.get("opusModel").cloned();
// Extract optional config fields (v3.8+)
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
Ok(DeepLinkImportRequest {
version,
resource,
app,
name,
homepage,
endpoint,
api_key,
model,
notes,
haiku_model,
sonnet_model,
opus_model,
config,
config_format,
config_url,
})
}
/// Validate that a string is a valid HTTP(S) URL
fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?;
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return Err(AppError::InvalidInput(format!(
"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'"
)));
}
Ok(())
}
/// Import a provider from a deep link request
///
/// This function:
/// 1. Validates the request
/// 2. Merges config file if provided (v3.8+)
/// 3. Converts it to a Provider structure
/// 4. Delegates to ProviderService for actual import
pub fn import_provider_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?;
// Step 2: Validate required fields after merge
if merged_request.api_key.is_empty() {
return Err(AppError::InvalidInput(
"API key is required (either in URL or config file)".to_string(),
));
}
if merged_request.endpoint.is_empty() {
return Err(AppError::InvalidInput(
"Endpoint is required (either in URL or config file)".to_string(),
));
}
if merged_request.homepage.is_empty() {
return Err(AppError::InvalidInput(
"Homepage is required (either in URL or config file)".to_string(),
));
}
// Parse app type
let app_type = AppType::from_str(&merged_request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?;
// Build provider configuration based on app type
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
// Generate a unique ID for the provider using timestamp + sanitized name
// This is similar to how frontend generates IDs
let timestamp = chrono::Utc::now().timestamp_millis();
let sanitized_name = merged_request
.name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_lowercase();
provider.id = format!("{sanitized_name}-{timestamp}");
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type, provider)?;
Ok(provider_id)
}
/// Build a Provider structure from a deep link request
fn build_provider_from_request(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
use serde_json::json;
let settings_config = match app_type {
AppType::Claude => {
// Claude configuration structure
let mut env = serde_json::Map::new();
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
// Add default model if provided
if let Some(model) = &request.model {
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
}
// Add Claude-specific model fields (v3.7.1+)
if let Some(haiku_model) = &request.haiku_model {
env.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
json!(haiku_model),
);
}
if let Some(sonnet_model) = &request.sonnet_model {
env.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
json!(sonnet_model),
);
}
if let Some(opus_model) = &request.opus_model {
env.insert(
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
json!(opus_model),
);
}
json!({ "env": env })
}
AppType::Codex => {
// Codex configuration structure
// For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。
//
// 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致,
// 再根据深链接参数注入 base_url / model避免出现“只有 base_url 行”的极简配置,
// 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。
// 1. 生成一个适合作为 model_provider 名的安全标识
// 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致:
// - 转小写
// - 非 [a-z0-9_] 统一替换为下划线
// - 去掉首尾下划线
// - 若结果为空,则使用 "custom"
let clean_provider_name = {
let raw: String = request.name.chars().filter(|c| !c.is_control()).collect();
let lower = raw.to_lowercase();
let mut key: String = lower
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' | '_' => c,
_ => '_',
})
.collect();
// 去掉首尾下划线
while key.starts_with('_') {
key.remove(0);
}
while key.ends_with('_') {
key.pop();
}
if key.is_empty() {
"custom".to_string()
} else {
key
}
};
// 2. 模型名称:优先使用 deeplink 中的 model否则退回到 Codex 默认模型
let model_name = request
.model
.as_deref()
.unwrap_or("gpt-5-codex")
.to_string();
// 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠
let endpoint = request.endpoint.trim().trim_end_matches('/').to_string();
// 4. 组装 config.toml 内容
// 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告
let config_toml = format!(
r#"model_provider = "{clean_provider_name}"
model = "{model_name}"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.{clean_provider_name}]
name = "{clean_provider_name}"
base_url = "{endpoint}"
wire_api = "responses"
requires_openai_auth = true
"#
);
json!({
"auth": {
"OPENAI_API_KEY": request.api_key,
},
"config": config_toml
})
}
AppType::Gemini => {
// Gemini configuration structure (.env format)
let mut env = serde_json::Map::new();
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(),
json!(request.endpoint),
);
// Add model if provided
if let Some(model) = &request.model {
env.insert("GEMINI_MODEL".to_string(), json!(model));
}
json!({ "env": env })
}
};
let provider = Provider {
id: String::new(), // Will be generated by ProviderService
name: request.name.clone(),
settings_config,
website_url: Some(request.homepage.clone()),
category: None,
created_at: None,
sort_index: None,
notes: request.notes.clone(),
meta: None,
icon: None,
icon_color: None,
};
Ok(provider)
}
/// Parse and merge configuration from Base64 encoded config or remote URL
///
/// Priority: URL params > inline config > remote config
pub fn parse_and_merge_config(
request: &DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, AppError> {
use base64::prelude::*;
// If no config provided, return original request
if request.config.is_none() && request.config_url.is_none() {
return Ok(request.clone());
}
// Step 1: Get config content
let config_content = if let Some(config_b64) = &request.config {
// Decode Base64 inline config
let decoded = BASE64_STANDARD
.decode(config_b64)
.map_err(|e| AppError::InvalidInput(format!("Invalid Base64 encoding: {e}")))?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
} else if let Some(_config_url) = &request.config_url {
// Fetch remote config (TODO: implement remote fetching in next phase)
return Err(AppError::InvalidInput(
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
));
} else {
return Ok(request.clone());
};
// Step 2: Parse config based on format
let format = request.config_format.as_deref().unwrap_or("json");
let config_value: serde_json::Value = match format {
"json" => serde_json::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
"toml" => {
let toml_value: toml::Value = toml::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
// Convert TOML to JSON for uniform processing
serde_json::to_value(toml_value)
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
}
_ => {
return Err(AppError::InvalidInput(format!(
"Unsupported config format: {format}"
)))
}
};
// Step 3: Extract values from config based on app type and merge with URL params
let mut merged = request.clone();
match request.app.as_str() {
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
_ => {
return Err(AppError::InvalidInput(format!(
"Invalid app type: {}",
request.app
)))
}
}
Ok(merged)
}
/// Merge Claude configuration from config file
///
/// Priority: URL params override config file values
fn merge_claude_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let env = config
.get("env")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::InvalidInput("Claude config must have 'env' object".to_string())
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_empty() {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = token.to_string();
}
}
// Auto-fill endpoint if not provided in URL
if request.endpoint.is_empty() {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = base_url.to_string();
}
}
// Auto-fill homepage from endpoint if not provided
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://anthropic.com".to_string());
}
// Auto-fill model fields (URL params take priority)
if request.model.is_none() {
request.model = env
.get("ANTHROPIC_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.haiku_model.is_none() {
request.haiku_model = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.sonnet_model.is_none() {
request.sonnet_model = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.opus_model.is_none() {
request.opus_model = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
Ok(())
}
/// Merge Codex configuration from config file
fn merge_codex_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_empty() {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
{
request.api_key = api_key.to_string();
}
}
// Auto-fill endpoint and model from config string
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_empty() {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = base_url;
}
}
// Extract model
if request.model.is_none() {
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
request.model = Some(model.to_string());
}
}
}
}
// Auto-fill homepage from endpoint
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://openai.com".to_string());
}
Ok(())
}
/// Merge Gemini configuration from config file
fn merge_gemini_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_empty() {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = api_key.to_string();
}
}
if request.endpoint.is_empty() {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = base_url.to_string();
}
}
if request.model.is_none() {
request.model = config
.get("GEMINI_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
// Auto-fill homepage from endpoint
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://ai.google.dev".to_string());
}
Ok(())
}
/// Extract base_url from Codex TOML config
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
// Try to find base_url in model_providers section
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
for (_key, provider) in providers.iter() {
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
return Some(base_url.to_string());
}
}
}
None
}
/// Infer homepage URL from API endpoint
///
/// Examples:
/// - https://api.anthropic.com/v1 → https://anthropic.com
/// - https://api.openai.com/v1 → https://openai.com
/// - https://api-test.company.com/v1 → https://company.com
fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {
let url = Url::parse(endpoint).ok()?;
let host = url.host_str()?;
// Remove common API prefixes
let clean_host = host
.strip_prefix("api.")
.or_else(|| host.strip_prefix("api-"))
.unwrap_or(host);
Some(format!("https://{clean_host}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_claude_deeplink() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.version, "v1");
assert_eq!(request.resource, "provider");
assert_eq!(request.app, "claude");
assert_eq!(request.name, "Test Provider");
assert_eq!(request.homepage, "https://example.com");
assert_eq!(request.endpoint, "https://api.example.com");
assert_eq!(request.api_key, "sk-test-123");
}
#[test]
fn test_parse_deeplink_with_notes() {
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123&notes=Test%20notes";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
}
#[test]
fn test_parse_unsupported_version() {
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported protocol version"));
}
#[test]
fn test_parse_missing_required_field() {
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
let url = "ccswitch://v1/import?resource=provider&app=claude";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing 'name' parameter"));
}
#[test]
fn test_validate_invalid_url() {
let result = validate_url("not-a-url", "test");
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_scheme() {
let result = validate_url("ftp://example.com", "test");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must be http or https"));
}
#[test]
fn test_build_gemini_provider_with_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
api_key: "test-api-key".to_string(),
model: Some("gemini-2.0-flash".to_string()),
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// Verify provider basic info
assert_eq!(provider.name, "Test Gemini");
assert_eq!(
provider.website_url,
Some("https://example.com".to_string())
);
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
}
#[test]
fn test_build_gemini_provider_without_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
api_key: "test-api-key".to_string(),
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
#[test]
fn test_infer_homepage() {
assert_eq!(
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
Some("https://anthropic.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
Some("https://test.company.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://example.com"),
Some("https://example.com".to_string())
);
}
#[test]
fn test_parse_and_merge_config_claude() {
use base64::prelude::*;
// Prepare Base64 encoded Claude config
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "claude".to_string(),
name: "Test".to_string(),
homepage: String::new(),
endpoint: String::new(),
api_key: String::new(),
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// Should auto-fill from config
assert_eq!(merged.api_key, "sk-ant-xxx");
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
assert_eq!(merged.homepage, "https://anthropic.com");
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
}
#[test]
fn test_parse_and_merge_config_url_override() {
use base64::prelude::*;
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "claude".to_string(),
name: "Test".to_string(),
homepage: String::new(),
endpoint: String::new(),
api_key: "sk-new".to_string(), // URL param should override
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// URL param should take priority
assert_eq!(merged.api_key, "sk-new");
// Config file value should be used
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
}
}

View File

@@ -1,121 +0,0 @@
use std::path::Path;
use std::sync::PoisonError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("配置错误: {0}")]
Config(String),
#[error("无效输入: {0}")]
InvalidInput(String),
#[error("IO 错误: {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("{context}: {source}")]
IoContext {
context: String,
#[source]
source: std::io::Error,
},
#[error("JSON 解析错误: {path}: {source}")]
Json {
path: String,
#[source]
source: serde_json::Error,
},
#[error("JSON 序列化失败: {source}")]
JsonSerialize {
#[source]
source: serde_json::Error,
},
#[error("TOML 解析错误: {path}: {source}")]
Toml {
path: String,
#[source]
source: toml::de::Error,
},
#[error("锁获取失败: {0}")]
Lock(String),
#[error("MCP 校验失败: {0}")]
McpValidation(String),
#[error("{0}")]
Message(String),
#[error("{zh} ({en})")]
Localized {
key: &'static str,
zh: String,
en: String,
},
}
impl AppError {
pub fn io(path: impl AsRef<Path>, source: std::io::Error) -> Self {
Self::Io {
path: path.as_ref().display().to_string(),
source,
}
}
pub fn json(path: impl AsRef<Path>, source: serde_json::Error) -> Self {
Self::Json {
path: path.as_ref().display().to_string(),
source,
}
}
pub fn toml(path: impl AsRef<Path>, source: toml::de::Error) -> Self {
Self::Toml {
path: path.as_ref().display().to_string(),
source,
}
}
pub fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> Self {
Self::Localized {
key,
zh: zh.into(),
en: en.into(),
}
}
}
impl<T> From<PoisonError<T>> for AppError {
fn from(err: PoisonError<T>) -> Self {
Self::Lock(err.to_string())
}
}
impl From<AppError> for String {
fn from(err: AppError) -> Self {
err.to_string()
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
context: &[(&str, &str)],
suggestion: Option<&str>,
) -> String {
use serde_json::json;
let mut ctx_map = serde_json::Map::new();
for (key, value) in context {
ctx_map.insert(key.to_string(), json!(value));
}
let error_obj = json!({
"code": code,
"context": ctx_map,
"suggestion": suggestion,
});
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
// 如果 JSON 序列化失败,返回简单格式
format!("ERROR:{code}")
})
}

View File

@@ -1,656 +0,0 @@
use crate::config::write_text_file;
use crate::error::AppError;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".gemini")
}
/// 获取 Gemini .env 文件路径
pub fn get_gemini_env_path() -> PathBuf {
get_gemini_dir().join(".env")
}
/// 解析 .env 文件内容为键值对
///
/// 此函数宽松地解析 .env 文件,跳过无效行。
/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。
pub fn parse_env_file(content: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') {
continue;
}
// 解析 KEY=VALUE
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
// 验证 key 是否有效(不为空,只包含字母、数字和下划线)
if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
map.insert(key, value);
}
}
}
map
}
/// 严格解析 .env 文件内容,返回详细的错误信息
///
/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误,
/// 包含行号和详细的错误信息。
///
/// # 错误
///
/// 返回 `AppError` 如果遇到以下情况:
/// - 行不包含 `=` 分隔符
/// - Key 为空或包含无效字符
/// - Key 不符合环境变量命名规范
///
/// # 使用场景
///
/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。
/// 可用于:
/// - 配置导入验证
/// - CLI 工具的严格模式
/// - 配置文件错误诊断
///
/// 已有完整的测试覆盖,可直接使用。
#[allow(dead_code)]
pub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {
let mut map = HashMap::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
let line_number = line_num + 1; // 行号从 1 开始
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') {
continue;
}
// 检查是否包含 =
if !line.contains('=') {
return Err(AppError::localized(
"gemini.env.parse_error.no_equals",
format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"),
format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"),
));
}
// 解析 KEY=VALUE
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
// 验证 key 不为空
if key.is_empty() {
return Err(AppError::localized(
"gemini.env.parse_error.empty_key",
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"),
format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"),
));
}
// 验证 key 只包含字母、数字和下划线
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(AppError::localized(
"gemini.env.parse_error.invalid_key",
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"),
format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"),
));
}
map.insert(key.to_string(), value.to_string());
}
}
Ok(map)
}
/// 将键值对序列化为 .env 格式
pub fn serialize_env_file(map: &HashMap<String, String>) -> String {
let mut lines = Vec::new();
// 按键排序以保证输出稳定
let mut keys: Vec<_> = map.keys().collect();
keys.sort();
for key in keys {
if let Some(value) = map.get(key) {
lines.push(format!("{key}={value}"));
}
}
lines.join("\n")
}
/// 读取 Gemini .env 文件
pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
let path = get_gemini_env_path();
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(parse_env_file(&content))
}
/// 写入 Gemini .env 文件(原子操作)
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
let path = get_gemini_env_path();
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
// 设置目录权限为 700仅所有者可读写执行
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)
.map_err(|e| AppError::io(parent, e))?
.permissions();
perms.set_mode(0o700);
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
}
}
let content = serialize_env_file(map);
write_text_file(&path, &content)?;
// 设置文件权限为 600仅所有者可读写
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path)
.map_err(|e| AppError::io(&path, e))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
}
Ok(())
}
/// 从 .env 格式转换为 Provider.settings_config (JSON Value)
pub fn env_to_json(env_map: &HashMap<String, String>) -> Value {
let mut json_map = serde_json::Map::new();
for (key, value) in env_map {
json_map.insert(key.clone(), Value::String(value.clone()));
}
serde_json::json!({ "env": json_map })
}
/// 从 Provider.settings_config (JSON Value) 提取 .env 格式
pub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {
let mut env_map = HashMap::new();
if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) {
for (key, value) in env_obj {
if let Some(val_str) = value.as_str() {
env_map.insert(key.clone(), val_str.to_string());
}
}
}
Ok(env_map)
}
/// 验证 Gemini 配置的基本结构
///
/// 此函数只验证配置的基本格式,不强制要求 GEMINI_API_KEY。
/// 这允许用户先创建供应商配置,稍后再填写 API Key。
///
/// API Key 的验证会在切换供应商时进行(通过 `validate_gemini_settings_strict`)。
pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
// 只验证基本结构,不强制要求 GEMINI_API_KEY
// 如果有 env 字段,验证它是一个对象
if let Some(env) = settings.get("env") {
if !env.is_object() {
return Err(AppError::localized(
"gemini.validation.invalid_env",
"Gemini 配置格式错误: env 必须是对象",
"Gemini config invalid: env must be an object",
));
}
}
// 如果有 config 字段,验证它是对象或 null
if let Some(config) = settings.get("config") {
if !(config.is_object() || config.is_null()) {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象",
"Gemini config invalid: config must be an object",
));
}
}
Ok(())
}
/// 严格验证 Gemini 配置(要求必需字段)
///
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
/// 对于需要 API Key 的供应商(如 PackyCode会验证 GEMINI_API_KEY 字段。
pub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {
// 先做基础格式验证(包含 env/config 类型)
validate_gemini_settings(settings)?;
let env_map = json_to_env(settings)?;
// 如果 env 为空,表示使用 OAuth如 Google 官方),跳过验证
if env_map.is_empty() {
return Ok(());
}
// 如果 env 不为空,检查必需字段 GEMINI_API_KEY
if !env_map.contains_key("GEMINI_API_KEY") {
return Err(AppError::localized(
"gemini.validation.missing_api_key",
"Gemini 配置缺少必需字段: GEMINI_API_KEY",
"Gemini config missing required field: GEMINI_API_KEY",
));
}
Ok(())
}
/// 获取 Gemini settings.json 文件路径
///
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
pub fn get_gemini_settings_path() -> PathBuf {
get_gemini_dir().join("settings.json")
}
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
///
/// 此函数会:
/// 1. 读取现有的 settings.json如果存在
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
/// 3. 原子性写入文件
///
/// # 参数
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal"
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
let settings_path = get_gemini_settings_path();
// 确保目录存在
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
// 读取现有的 settings.json如果存在
let mut settings_content = if settings_path.exists() {
let content =
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
// 只更新 security.auth.selectedType 字段
if let Some(obj) = settings_content.as_object_mut() {
let security = obj
.entry("security")
.or_insert_with(|| serde_json::json!({}));
if let Some(security_obj) = security.as_object_mut() {
let auth = security_obj
.entry("auth")
.or_insert_with(|| serde_json::json!({}));
if let Some(auth_obj) = auth.as_object_mut() {
auth_obj.insert(
"selectedType".to_string(),
Value::String(selected_type.to_string()),
);
}
}
}
// 写入文件
crate::config::write_json_file(&settings_path, &settings_content)?;
Ok(())
}
/// 为 Packycode Gemini 供应商写入 settings.json
///
/// 设置 `~/.gemini/settings.json` 中的:
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "gemini-api-key"
/// }
/// }
/// }
/// ```
///
/// 保留文件中的其他所有字段。
pub fn write_packycode_settings() -> Result<(), AppError> {
update_selected_type("gemini-api-key")
}
/// 为 Google 官方 Gemini 供应商写入 settings.jsonOAuth 模式)
///
/// 设置 `~/.gemini/settings.json` 中的:
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "oauth-personal"
/// }
/// }
/// }
/// ```
///
/// 保留文件中的其他所有字段。
pub fn write_google_oauth_settings() -> Result<(), AppError> {
update_selected_type("oauth-personal")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_env_file() {
let content = r#"
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-3-pro-preview
# Another comment
"#;
let map = parse_env_file(content);
assert_eq!(map.len(), 3);
assert_eq!(
map.get("GOOGLE_GEMINI_BASE_URL"),
Some(&"https://example.com".to_string())
);
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
assert_eq!(
map.get("GEMINI_MODEL"),
Some(&"gemini-3-pro-preview".to_string())
);
}
#[test]
fn test_serialize_env_file() {
let mut map = HashMap::new();
map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string());
map.insert(
"GEMINI_MODEL".to_string(),
"gemini-3-pro-preview".to_string(),
);
let content = serialize_env_file(&map);
assert!(content.contains("GEMINI_API_KEY=sk-test"));
assert!(content.contains("GEMINI_MODEL=gemini-3-pro-preview"));
}
#[test]
fn test_env_json_conversion() {
let mut env_map = HashMap::new();
env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string());
let json = env_to_json(&env_map);
let converted = json_to_env(&json).unwrap();
assert_eq!(
converted.get("GEMINI_API_KEY"),
Some(&"test-key".to_string())
);
}
#[test]
fn test_parse_env_file_strict_success() {
// 测试严格模式下正常解析
let content = r#"
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-3-pro-preview
# Another comment
"#;
let result = parse_env_file_strict(content);
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.len(), 3);
assert_eq!(
map.get("GOOGLE_GEMINI_BASE_URL"),
Some(&"https://example.com".to_string())
);
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
assert_eq!(
map.get("GEMINI_MODEL"),
Some(&"gemini-3-pro-preview".to_string())
);
}
#[test]
fn test_parse_env_file_strict_missing_equals() {
// 测试严格模式下检测缺少 = 的行
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
INVALID_LINE_WITHOUT_EQUALS
GEMINI_API_KEY=sk-test123";
let result = parse_env_file_strict(content);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err:?}");
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS"));
}
#[test]
fn test_parse_env_file_strict_empty_key() {
// 测试严格模式下检测空 key
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
=value_without_key
GEMINI_API_KEY=sk-test123";
let result = parse_env_file_strict(content);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err:?}");
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
assert!(err_msg.contains("empty") || err_msg.contains(""));
}
#[test]
fn test_parse_env_file_strict_invalid_key_characters() {
// 测试严格模式下检测无效字符(如空格、特殊符号)
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
INVALID KEY WITH SPACES=value
GEMINI_API_KEY=sk-test123";
let result = parse_env_file_strict(content);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{err:?}");
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
assert!(err_msg.contains("INVALID KEY WITH SPACES"));
}
#[test]
fn test_parse_env_file_lax_vs_strict() {
// 测试宽松模式和严格模式的差异
let content = "VALID_KEY=value
INVALID LINE
KEY_WITH-DASH=value";
// 宽松模式:跳过无效行,继续解析
let lax_result = parse_env_file(content);
assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY
assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string()));
// 严格模式:遇到无效行立即返回错误
let strict_result = parse_env_file_strict(content);
assert!(strict_result.is_err());
}
#[test]
fn test_packycode_settings_structure() {
// 验证 Packycode settings.json 的结构正确
let settings_content = serde_json::json!({
"security": {
"auth": {
"selectedType": "gemini-api-key"
}
}
});
assert_eq!(
settings_content["security"]["auth"]["selectedType"],
"gemini-api-key"
);
}
#[test]
fn test_packycode_settings_merge() {
// 测试合并逻辑:应该保留其他字段
let mut existing_settings = serde_json::json!({
"otherField": "should-be-kept",
"security": {
"otherSetting": "also-kept",
"auth": {
"otherAuth": "preserved"
}
}
});
// 模拟更新 selectedType
if let Some(obj) = existing_settings.as_object_mut() {
let security = obj
.entry("security")
.or_insert_with(|| serde_json::json!({}));
if let Some(security_obj) = security.as_object_mut() {
let auth = security_obj
.entry("auth")
.or_insert_with(|| serde_json::json!({}));
if let Some(auth_obj) = auth.as_object_mut() {
auth_obj.insert(
"selectedType".to_string(),
Value::String("gemini-api-key".to_string()),
);
}
}
}
// 验证所有字段都被保留
assert_eq!(existing_settings["otherField"], "should-be-kept");
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
assert_eq!(
existing_settings["security"]["auth"]["otherAuth"],
"preserved"
);
assert_eq!(
existing_settings["security"]["auth"]["selectedType"],
"gemini-api-key"
);
}
#[test]
fn test_google_oauth_settings_structure() {
// 验证 Google OAuth settings.json 的结构正确
let settings_content = serde_json::json!({
"security": {
"auth": {
"selectedType": "oauth-personal"
}
}
});
assert_eq!(
settings_content["security"]["auth"]["selectedType"],
"oauth-personal"
);
}
#[test]
fn test_validate_empty_env_for_oauth() {
// 测试空 envGoogle 官方 OAuth可以通过基本验证
let settings = serde_json::json!({
"env": {}
});
assert!(validate_gemini_settings(&settings).is_ok());
// 严格验证也应该通过(空 env 表示 OAuth
assert!(validate_gemini_settings_strict(&settings).is_ok());
}
#[test]
fn test_validate_env_with_api_key() {
// 测试有 API Key 的配置可以通过验证
let settings = serde_json::json!({
"env": {
"GEMINI_API_KEY": "sk-test123",
"GEMINI_MODEL": "gemini-3-pro-preview"
}
});
assert!(validate_gemini_settings(&settings).is_ok());
assert!(validate_gemini_settings_strict(&settings).is_ok());
}
#[test]
fn test_validate_env_without_api_key_relaxed() {
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
let settings = serde_json::json!({
"env": {
"GEMINI_MODEL": "gemini-3-pro-preview"
}
});
// 基本验证应该通过(允许稍后填写 API Key
assert!(validate_gemini_settings(&settings).is_ok());
// 严格验证应该失败(切换时要求完整配置)
assert!(validate_gemini_settings_strict(&settings).is_err());
}
#[test]
fn test_validate_invalid_env_type() {
// 测试 env 不是对象时会失败
let settings = serde_json::json!({
"env": "invalid_string"
});
assert!(validate_gemini_settings(&settings).is_err());
}
}

View File

@@ -1,134 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::atomic_write;
use crate::error::AppError;
use crate::gemini_config::get_gemini_settings_path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json
fn user_config_path() -> PathBuf {
get_gemini_settings_path()
}
fn read_json_value(path: &Path) -> Result<Value, AppError> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
Ok(value)
}
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json =
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(Some(content))
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(std::collections::HashMap::new());
}
let root = read_json_value(&path)?;
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)
}
/// 将给定的启用 MCP 服务器映射写入到 Gemini settings.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
servers: &std::collections::HashMap<String, Value>,
) -> Result<(), AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
map.clone()
} else {
return Err(AppError::McpValidation(format!(
"MCP 服务器 '{id}' 不是对象"
)));
};
// 提取 server 字段(如果存在)
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
})?;
obj = server_obj;
}
// Gemini CLI 格式转换:
// - Gemini 不使用 "type" 字段(从字段名推断传输类型)
// - HTTP 使用 "httpUrl" 字段SSE 使用 "url" 字段
let transport_type = obj.get("type").and_then(|v| v.as_str());
if transport_type == Some("http") {
// HTTP streaming: 将 "url" 重命名为 "httpUrl"
if let Some(url_value) = obj.remove("url") {
obj.insert("httpUrl".to_string(), url_value);
}
}
// SSE 保持 "url" 字段不变
// 移除 UI 辅助字段和 type 字段Gemini 不需要)
obj.remove("type");
obj.remove("enabled");
obj.remove("source");
obj.remove("id");
obj.remove("name");
obj.remove("description");
obj.remove("tags");
obj.remove("homepage");
obj.remove("docs");
out.insert(id.clone(), Value::Object(obj));
}
{
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.gemini/settings.json 根必须是对象".into()))?;
obj.insert("mcpServers".into(), Value::Object(out));
}
write_json_value(&path, &root)?;
Ok(())
}

View File

@@ -1,41 +0,0 @@
use serde::Serialize;
use std::sync::{OnceLock, RwLock};
#[derive(Debug, Clone, Serialize)]
pub struct InitErrorPayload {
pub path: String,
pub error: String,
}
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
INIT_ERROR.get_or_init(|| RwLock::new(None))
}
pub fn set_init_error(payload: InitErrorPayload) {
if let Ok(mut guard) = cell().write() {
*guard = Some(payload);
}
}
pub fn get_init_error() -> Option<InitErrorPayload> {
cell().read().ok()?.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_error_roundtrip() {
let payload = InitErrorPayload {
path: "/tmp/config.json".into(),
error: "broken json".into(),
};
set_init_error(payload.clone());
let got = get_init_error().expect("should get payload back");
assert_eq!(got.path, payload.path);
assert_eq!(got.error, payload.error);
}
}

View File

@@ -1,263 +1,124 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod deeplink;
mod error;
mod gemini_config; // 新增
mod gemini_mcp;
mod init_status;
mod mcp;
mod prompt;
mod prompt_files;
mod migration;
mod provider;
mod provider_defaults;
mod services;
mod settings;
mod store;
mod usage_script;
mod vscode;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
};
pub use provider::{Provider, ProviderMeta};
pub use services::{
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
SpeedtestService,
};
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use std::sync::Arc;
use store::AppState;
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::{Emitter, Manager};
#[derive(Clone, Copy)]
struct TrayTexts {
show_main: &'static str,
no_provider_hint: &'static str,
quit: &'static str,
}
impl TrayTexts {
fn from_language(language: &str) -> Self {
match language {
"en" => Self {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
},
}
}
}
struct TrayAppSection {
app_type: AppType,
prefix: &'static str,
header_id: &'static str,
empty_id: &'static str,
header_label: &'static str,
log_name: &'static str,
}
const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
app_type: AppType::Codex,
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
app_type: AppType::Gemini,
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
};
let header = MenuItem::with_id(
app,
section.header_id,
section.header_label,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&header);
if manager.providers.is_empty() {
let empty_hint = MenuItem::with_id(
app,
section.empty_id,
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
return Ok(menu_builder.item(&empty_hint));
}
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&item);
}
Ok(menu_builder)
}
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
return true;
}
}
false
}
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
let config = app_state.config.read().map_err(AppError::from)?;
) -> Result<Menu<tauri::Wry>, String> {
let config = app_state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
for section in TRAY_SECTIONS.iter() {
menu_builder = append_provider_section(
app,
menu_builder,
config.get_manager(&section.app_type),
section,
&tray_texts,
)?;
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识
let claude_header =
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
.map_err(|e| format!("创建Claude标题失败: {}", e))?;
menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() {
for (id, provider) in &claude_manager.providers {
let is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("claude_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"claude_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Claude空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
// 添加Codex标题禁用状态仅作为分组标识
let codex_header =
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
.map_err(|e| format!("创建Codex标题失败: {}", e))?;
menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() {
for (id, provider) in &codex_manager.providers {
let is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("codex_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"codex_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Codex空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
.map_err(|e| format!("创建退出菜单失败: {}", e))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {err}");
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {err}");
}
.map_err(|e| format!("构建菜单失败: {}", e))
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {event_id}");
log::info!("处理托盘菜单事件: {}", event_id);
match event_id {
"show_main" => {
@@ -269,105 +130,83 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
}
_ => {
if handle_provider_tray_event(app, event_id) {
return;
}
log::warn!("未处理的菜单事件: {event_id}");
}
}
}
id if id.starts_with("claude_") => {
let provider_id = id.strip_prefix("claude_").unwrap();
log::info!("切换到Claude供应商: {}", provider_id);
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件
/// - 可选:在成功时聚焦主窗口
fn handle_deeplink_url(
app: &tauri::AppHandle,
url_str: &str,
focus_main_window: bool,
source: &str,
) -> bool {
if !url_str.starts_with("ccswitch://") {
return false;
}
log::info!("✓ Deep link URL detected from {source}: {url_str}");
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
log::info!(
"✓ Successfully parsed deep link: resource={}, app={}, name={}",
request.resource,
request.app,
request.name
);
if let Err(e) = app.emit("deeplink-import", &request) {
log::error!("✗ Failed to emit deeplink-import event: {e}");
} else {
log::info!("✓ Emitted deeplink-import event to frontend");
}
if focus_main_window {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
log::info!("✓ Window shown and focused");
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Claude,
provider_id,
)
.await
{
log::error!("切换Claude供应商失败: {}", e);
}
}
});
}
Err(e) => {
log::error!("✗ Failed to parse deep link URL: {e}");
id if id.starts_with("codex_") => {
let provider_id = id.strip_prefix("codex_").unwrap();
log::info!("切换到Codex供应商: {}", provider_id);
if let Err(emit_err) = app.emit(
"deeplink-error",
serde_json::json!({
"url": url_str,
"error": e.to_string()
}),
) {
log::error!("✗ Failed to emit deeplink-error event: {emit_err}");
}
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Codex,
provider_id,
)
.await
{
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
log::warn!("未处理的菜单事件: {}", event_id);
}
}
true
}
//
/// 内部切换供应商函数
fn switch_provider_internal(
async fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: crate::app_config::AppType,
provider_id: String,
) -> Result<(), AppError> {
) -> Result<(), String> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
.map_err(AppError::Message)?;
crate::commands::switch_provider(
app_state.clone().into(),
Some(app_type),
None,
None,
provider_id,
)
.await?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {e}");
log::error!("更新托盘菜单失败: {}", e);
}
}
}
@@ -378,7 +217,7 @@ fn switch_provider_internal(
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {e}");
log::error!("发射供应商切换事件失败: {}", e);
}
}
Ok(())
@@ -390,20 +229,14 @@ async fn update_tray_menu(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
match create_tray_menu(&app, state.inner()) {
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
return Ok(true);
}
Ok(false)
}
Err(err) => {
log::error!("创建托盘菜单失败: {err}");
Ok(false)
if let Ok(new_menu) = create_tray_menu(&app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
return Ok(true);
}
}
Ok(false)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -412,27 +245,7 @@ pub fn run() {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
log::info!("=== Single Instance Callback Triggered ===");
log::info!("Args count: {}", args.len());
for (i, arg) in args.iter().enumerate() {
log::info!(" arg[{i}]: {arg}");
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
let mut found_deeplink = false;
for arg in &args {
if handle_deeplink_url(app, arg, false, "single_instance args") {
found_deeplink = true;
break;
}
}
if !found_deeplink {
log::info!(" No deep link URL found in args (this is expected on macOS when launched via system)");
}
// Show and focus window regardless
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
@@ -442,11 +255,9 @@ pub fn run() {
}
let builder = builder
// 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接)
.plugin(tauri_plugin_deep_link::init())
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let settings = crate::settings::get_settings();
if settings.minimize_to_tray_on_close {
@@ -456,19 +267,15 @@ pub fn run() {
{
let _ = window.set_skip_taskbar(true);
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
}
}
_ => {}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build())
.setup(|app| {
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
@@ -478,7 +285,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
}
}
#[cfg(target_os = "macos")]
@@ -520,82 +327,26 @@ pub fn run() {
)?;
}
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
app_store::refresh_app_config_dir_override(app.handle());
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage
// 如果配置解析失败,则向前端发送错误事件并提前结束 setup不落盘、不覆盖配置
let app_state = match AppState::try_new() {
Ok(state) => state,
Err(err) => {
let path = crate::config::get_app_config_path();
let payload_json = serde_json::json!({
"path": path.display().to_string(),
"error": err.to_string(),
});
// 事件通知(可能早于前端订阅,不保证送达)
if let Err(e) = app.emit("configLoadError", payload_json) {
log::error!("发射配置加载错误事件失败: {e}");
}
// 同时缓存错误,供前端启动阶段主动拉取
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
path: path.display().to_string(),
error: err.to_string(),
});
// 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。
return Ok(());
}
};
let app_state = AppState::new();
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {e}");
}
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
// 首次启动迁移:扫描副本文件,合并到 config.json并归档副本旧 config.json 先归档
{
let mut config_guard = app_state.config.write().unwrap();
let mut config_guard = app_state.config.lock().unwrap();
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
if migrated {
log::info!("已将副本文件导入到 config.json并完成归档");
}
// 确保两个 App 条目存在
config_guard.ensure_app(&app_config::AppType::Claude);
config_guard.ensure_app(&app_config::AppType::Codex);
}
// 启动阶段不再无条件保存,避免意外覆盖用户配置
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API
log::info!("=== Registering deep-link URL handler ===");
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux/Windows)");
}
}
// 注册 URL 处理回调(所有平台通用)
app.deep_link().on_open_url({
let app_handle = app.handle().clone();
move |event| {
log::info!("=== Deep Link Event Received (on_open_url) ===");
let urls = event.urls();
log::info!("Received {} URL(s)", urls.len());
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::info!(" URL[{i}]: {url_str}");
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
break; // Process only first ccswitch:// URL
}
}
}
});
log::info!("✓ Deep-link URL handler registered");
// 保存配置
let _ = app_state.save();
// 创建动态托盘菜单
let menu = create_tray_menu(app.handle(), &app_state)?;
let menu = create_tray_menu(&app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
@@ -616,17 +367,6 @@ pub fn run() {
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -644,85 +384,16 @@ pub fn run() {
commands::open_config_folder,
commands::pick_directory,
commands::open_external,
commands::get_init_error,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::restart_app,
commands::check_for_updates,
commands::is_portable_mode,
commands::get_claude_plugin_status,
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
// Claude MCP management
commands::get_claude_mcp_status,
commands::read_claude_mcp_config,
commands::upsert_claude_mcp_server,
commands::delete_claude_mcp_server,
commands::validate_mcp_command,
// usage query
commands::queryProviderUsage,
commands::testUsageScript,
// New MCP via config.json (SSOT)
commands::get_mcp_config,
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
// v3.7.0: Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
commands::delete_prompt,
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
commands::add_custom_endpoint,
commands::remove_custom_endpoint,
commands::update_endpoint_last_used,
// app_config_dir override via Store
commands::get_app_config_dir_override,
commands::set_app_config_dir_override,
// provider sort order management
commands::update_providers_sort_order,
// theirs: config import/export and dialogs
commands::export_config_to_file,
commands::import_config_from_file,
commands::save_file_dialog,
commands::open_file_dialog,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
commands::merge_deeplink_config,
commands::import_from_deeplink,
commands::get_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
update_tray_menu,
// Environment variable management
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
commands::get_skills,
commands::install_skill,
commands::uninstall_skill,
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
]);
let app = builder
@@ -731,75 +402,20 @@ pub fn run() {
app.run(|app_handle, event| {
#[cfg(target_os = "macos")]
{
match event {
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
match event {
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
RunEvent::Opened { urls } => {
if let Some(url) = urls.first() {
let url_str = url.to_string();
log::info!("RunEvent::Opened with URL: {url_str}");
if url_str.starts_with("ccswitch://") {
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => {
log::info!(
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={}",
request.resource,
request.app
);
if let Err(e) =
app_handle.emit("deeplink-import", &request)
{
log::error!(
"Failed to emit deep link event from RunEvent::Opened: {e}"
);
}
}
Err(e) => {
log::error!(
"Failed to parse deep link URL from RunEvent::Opened: {e}"
);
if let Err(emit_err) = app_handle.emit(
"deeplink-error",
serde_json::json!({
"url": url_str,
"error": e.to_string()
}),
) {
log::error!(
"Failed to emit deep link error event from RunEvent::Opened: {emit_err}"
);
}
}
}
// 确保主窗口可见
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
}
}
_ => {}
}
_ => {}
}
#[cfg(not(target_os = "macos"))]

File diff suppressed because it is too large Load Diff

437
src-tauri/src/migration.rs Normal file
View File

@@ -0,0 +1,437 @@
use crate::app_config::{AppType, MultiAppConfig};
use crate::config::{
archive_file, delete_file, get_app_config_dir, get_app_config_path, get_claude_config_dir,
};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
fn now_ts() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn get_marker_path() -> PathBuf {
get_app_config_dir().join("migrated.copies.v1")
}
fn sanitized_id(base: &str) -> String {
crate::config::sanitize_provider_name(base)
}
fn next_unique_id(existing: &HashSet<String>, base: &str) -> String {
let base = sanitized_id(base);
if !existing.contains(&base) {
return base;
}
for i in 2..1000 {
let candidate = format!("{}-{}", base, i);
if !existing.contains(&candidate) {
return candidate;
}
}
format!("{}-dup", base)
}
fn extract_claude_api_key(value: &Value) -> Option<String> {
value
.get("env")
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_codex_api_key(value: &Value) -> Option<String> {
value
.get("auth")
.and_then(|auth| {
auth.get("OPENAI_API_KEY")
.or_else(|| auth.get("openai_api_key"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn norm_name(s: &str) -> String {
s.trim().to_lowercase()
}
// 去重策略name + 原始 key 直接比较(不做哈希)
fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> {
let mut items = Vec::new();
let dir = get_claude_config_dir();
if !dir.exists() {
return items;
}
if let Ok(rd) = fs::read_dir(&dir) {
for e in rd.flatten() {
let p = e.path();
let fname = match p.file_name().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
if fname == "settings.json" || fname == "claude.json" {
continue;
}
if !fname.starts_with("settings-") || !fname.ends_with(".json") {
continue;
}
let name = fname
.trim_start_matches("settings-")
.trim_end_matches(".json");
if let Ok(val) = crate::config::read_json_file::<Value>(&p) {
items.push((name.to_string(), p, val));
}
}
}
items
}
fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)> {
let mut by_name: HashMap<String, (Option<PathBuf>, Option<PathBuf>)> = HashMap::new();
let dir = crate::codex_config::get_codex_config_dir();
if !dir.exists() {
return Vec::new();
}
if let Ok(rd) = fs::read_dir(&dir) {
for e in rd.flatten() {
let p = e.path();
let fname = match p.file_name().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
if fname.starts_with("auth-") && fname.ends_with(".json") {
let name = fname.trim_start_matches("auth-").trim_end_matches(".json");
let entry = by_name.entry(name.to_string()).or_default();
entry.0 = Some(p);
} else if fname.starts_with("config-") && fname.ends_with(".toml") {
let name = fname
.trim_start_matches("config-")
.trim_end_matches(".toml");
let entry = by_name.entry(name.to_string()).or_default();
entry.1 = Some(p);
}
}
}
let mut items = Vec::new();
for (name, (auth_path, config_path)) in by_name {
if let Some(authp) = auth_path {
if let Ok(auth) = crate::config::read_json_file::<Value>(&authp) {
let config_str = if let Some(cfgp) = &config_path {
match crate::codex_config::read_and_validate_config_from_path(cfgp) {
Ok(s) => s,
Err(e) => {
log::warn!("跳过无效 Codex config-{}.toml: {}", name, e);
String::new()
}
}
} else {
String::new()
};
let settings = serde_json::json!({
"auth": auth,
"config": config_str,
});
items.push((name, Some(authp), config_path, settings));
}
}
}
items
}
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
let marker = get_marker_path();
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
}
if marker.exists() {
return Ok(false);
}
let claude_items = scan_claude_copies();
let codex_items = scan_codex_copies();
if claude_items.is_empty() && codex_items.is_empty() {
// 即便没有可迁移项,也写入标记避免每次扫描
fs::write(&marker, b"no-copies").map_err(|e| format!("写入迁移标记失败: {}", e))?;
return Ok(false);
}
// 备份旧的 config.json
let ts = now_ts();
let app_cfg_path = get_app_config_path();
if app_cfg_path.exists() {
let _ = archive_file(ts, "cc-switch", &app_cfg_path);
}
// 读取 liveClaudesettings.json / claude.json
let live_claude: Option<(String, Value)> = {
let settings_path = crate::config::get_claude_settings_path();
if settings_path.exists() {
match crate::config::read_json_file::<Value>(&settings_path) {
Ok(val) => Some(("default".to_string(), val)),
Err(e) => {
log::warn!("读取 Claude live 配置失败: {}", e);
None
}
}
} else {
None
}
};
// 合并Claude优先 live然后副本 - 去重键: name + apiKey直接比较
config.ensure_app(&AppType::Claude);
let manager = config.get_manager_mut(&AppType::Claude).unwrap();
let mut ids: HashSet<String> = manager.providers.keys().cloned().collect();
let mut live_claude_id: Option<String> = None;
if let Some((name, value)) = &live_claude {
let cand_key = extract_claude_api_key(value);
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name);
prov.settings_config = value.clone();
live_claude_id = Some(exist_id);
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_claude_id = Some(id);
}
}
for (name, path, value) in claude_items.iter() {
let cand_key = extract_claude_api_key(value);
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!(
"覆盖 Claude 供应商 '{}' 来自 {} (by name+key)",
name,
path.display()
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
// 读取 liveCodexauth.json 必需config.toml 可空)
let live_codex: Option<(String, Value)> = {
let auth_path = crate::codex_config::get_codex_auth_path();
if auth_path.exists() {
match crate::config::read_json_file::<Value>(&auth_path) {
Ok(auth) => {
let cfg = match crate::codex_config::read_and_validate_codex_config_text() {
Ok(s) => s,
Err(e) => {
log::warn!("读取/校验 Codex live config.toml 失败: {}", e);
String::new()
}
};
Some((
"default".to_string(),
serde_json::json!({"auth": auth, "config": cfg}),
))
}
Err(e) => {
log::warn!("读取 Codex live auth.json 失败: {}", e);
None
}
}
} else {
None
}
};
// 合并Codex优先 live然后副本 - 去重键: name + OPENAI_API_KEY直接比较
config.ensure_app(&AppType::Codex);
let manager = config.get_manager_mut(&AppType::Codex).unwrap();
let mut ids: HashSet<String> = manager.providers.keys().cloned().collect();
let mut live_codex_id: Option<String> = None;
if let Some((name, value)) = &live_codex {
let cand_key = extract_codex_api_key(value);
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name);
prov.settings_config = value.clone();
live_codex_id = Some(exist_id);
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_codex_id = Some(id);
}
}
for (name, authp, cfgp, value) in codex_items.iter() {
let cand_key = extract_codex_api_key(value);
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!(
"覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)",
name,
authp,
cfgp
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
// 若当前为空,将 live 导入项设为当前
{
let manager = config.get_manager_mut(&AppType::Claude).unwrap();
if manager.current.is_empty() {
if let Some(id) = live_claude_id {
manager.current = id;
}
}
}
{
let manager = config.get_manager_mut(&AppType::Codex).unwrap();
if manager.current.is_empty() {
if let Some(id) = live_codex_id {
manager.current = id;
}
}
}
// 归档副本文件
for (_, p, _) in claude_items.into_iter() {
match archive_file(ts, "claude", &p) {
Ok(Some(_)) => {
let _ = delete_file(&p);
}
_ => {
// 归档失败则不要删除原文件,保守处理
}
}
}
for (_, ap, cp, _) in codex_items.into_iter() {
if let Some(ap) = ap {
match archive_file(ts, "codex", &ap) {
Ok(Some(_)) => {
let _ = delete_file(&ap);
}
_ => {}
}
}
if let Some(cp) = cp {
match archive_file(ts, "codex", &cp) {
Ok(Some(_)) => {
let _ = delete_file(&cp);
}
_ => {}
}
}
}
// 标记完成
// 仅在迁移阶段执行一次全量去重(忽略大小写的名称 + API Key
let removed = dedupe_config(config);
if removed > 0 {
log::info!("迁移阶段已去重重复供应商 {} 个", removed);
}
fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?;
Ok(true)
}
/// 启动时对现有配置做一次去重:按名称(忽略大小写)+API Key
pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
use std::collections::HashMap as Map;
fn dedupe_one(
mgr: &mut crate::provider::ProviderManager,
extract_key: &dyn Fn(&Value) -> Option<String>,
) -> usize {
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
let mut remove: Vec<String> = Vec::new();
for (id, p) in mgr.providers.iter() {
let k = format!(
"{}|{}",
norm_name(&p.name),
extract_key(&p.settings_config).unwrap_or_default()
);
if let Some(exist_id) = keep.get(&k) {
// 若当前是正在使用的,则用当前替换之前的,反之丢弃当前
if *id == mgr.current {
// 替换:把原先的标记为删除,改保留为当前
remove.push(exist_id.clone());
keep.insert(k, id.clone());
} else {
remove.push(id.clone());
}
} else {
keep.insert(k, id.clone());
}
}
for id in remove.iter() {
mgr.providers.remove(id);
}
remove.len()
}
let mut removed = 0;
if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Claude) {
removed += dedupe_one(mgr, &extract_claude_api_key);
}
if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Codex) {
removed += dedupe_one(mgr, &extract_codex_api_key);
}
removed
}

View File

@@ -1,16 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prompt {
pub id: String,
pub name: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}

View File

@@ -1,41 +0,0 @@
use std::path::PathBuf;
use crate::app_config::AppType;
use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
let base_dir: PathBuf = match app {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
};
Ok(base_dir.join(filename))
}
fn get_base_dir_with_fallback(
primary_path: PathBuf,
fallback_dir: &str,
) -> Result<PathBuf, AppError> {
primary_path
.parent()
.map(|p| p.to_path_buf())
.or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))
.ok_or_else(|| {
AppError::localized(
"home_dir_not_found",
format!("无法确定 {fallback_dir} 配置目录:用户主目录不存在"),
format!("Cannot determine {fallback_dir} config directory: user home not found"),
)
})
}

View File

@@ -19,22 +19,6 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 图标名称(如 "openai", "anthropic"
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色Hex 格式,如 "#00A67E"
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
}
impl Provider {
@@ -52,104 +36,24 @@ impl Provider {
website_url,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
}
}
}
/// 供应商管理器
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderManager {
pub providers: HashMap<String, Provider>,
pub current: String,
}
/// 用量查询脚本配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageScript {
pub enabled: bool,
pub language: String,
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
/// 用量查询专用的 API Key通用模板使用
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// 用量查询专用的 Base URL通用和 NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "baseUrl")]
pub base_url: Option<String>,
/// 访问令牌用于需要登录的接口NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "accessToken")]
pub access_token: Option<String>,
/// 用户ID用于需要用户标识的接口NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
/// 自动查询间隔单位分钟0 表示禁用自动查询)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
pub auto_query_interval: Option<u64>,
}
/// 用量数据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageData {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "planName")]
pub plan_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "isValid")]
pub is_valid: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "invalidMessage")]
pub invalid_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub used: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remaining: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
/// 用量查询结果(支持多套餐)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Vec<UsageData>>, // 支持返回多个套餐
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
/// 自定义端点列表(按 URL 去重存储)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 合作伙伴标记(前端使用 isPartner保持字段名一致
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
/// 合作伙伴促销 key用于识别 PackyCode 等特殊供应商
#[serde(
rename = "partnerPromotionKey",
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
impl Default for ProviderManager {
fn default() -> Self {
Self {
providers: HashMap::new(),
current: String::new(),
}
}
}
impl ProviderManager {

View File

@@ -1,238 +0,0 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
/// 供应商图标信息
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProviderIcon {
pub name: &'static str,
pub color: &'static str,
}
/// 供应商名称到图标的默认映射
#[allow(dead_code)]
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
let mut m = HashMap::new();
// AI 服务商
m.insert(
"openai",
ProviderIcon {
name: "openai",
color: "#00A67E",
},
);
m.insert(
"anthropic",
ProviderIcon {
name: "anthropic",
color: "#D4915D",
},
);
m.insert(
"claude",
ProviderIcon {
name: "claude",
color: "#D4915D",
},
);
m.insert(
"google",
ProviderIcon {
name: "google",
color: "#4285F4",
},
);
m.insert(
"gemini",
ProviderIcon {
name: "gemini",
color: "#4285F4",
},
);
m.insert(
"deepseek",
ProviderIcon {
name: "deepseek",
color: "#1E88E5",
},
);
m.insert(
"kimi",
ProviderIcon {
name: "kimi",
color: "#6366F1",
},
);
m.insert(
"moonshot",
ProviderIcon {
name: "moonshot",
color: "#6366F1",
},
);
m.insert(
"zhipu",
ProviderIcon {
name: "zhipu",
color: "#0F62FE",
},
);
m.insert(
"minimax",
ProviderIcon {
name: "minimax",
color: "#FF6B6B",
},
);
m.insert(
"baidu",
ProviderIcon {
name: "baidu",
color: "#2932E1",
},
);
m.insert(
"alibaba",
ProviderIcon {
name: "alibaba",
color: "#FF6A00",
},
);
m.insert(
"tencent",
ProviderIcon {
name: "tencent",
color: "#00A4FF",
},
);
m.insert(
"meta",
ProviderIcon {
name: "meta",
color: "#0081FB",
},
);
m.insert(
"microsoft",
ProviderIcon {
name: "microsoft",
color: "#00A4EF",
},
);
m.insert(
"cohere",
ProviderIcon {
name: "cohere",
color: "#39594D",
},
);
m.insert(
"perplexity",
ProviderIcon {
name: "perplexity",
color: "#20808D",
},
);
m.insert(
"mistral",
ProviderIcon {
name: "mistral",
color: "#FF7000",
},
);
m.insert(
"huggingface",
ProviderIcon {
name: "huggingface",
color: "#FFD21E",
},
);
// 云平台
m.insert(
"aws",
ProviderIcon {
name: "aws",
color: "#FF9900",
},
);
m.insert(
"azure",
ProviderIcon {
name: "azure",
color: "#0078D4",
},
);
m.insert(
"huawei",
ProviderIcon {
name: "huawei",
color: "#FF0000",
},
);
m.insert(
"cloudflare",
ProviderIcon {
name: "cloudflare",
color: "#F38020",
},
);
m
});
/// 根据供应商名称智能推断图标
#[allow(dead_code)]
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
let name_lower = provider_name.to_lowercase();
// 精确匹配
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
return Some(icon.clone());
}
// 模糊匹配(包含关键词)
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
if name_lower.contains(key) {
return Some(icon.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let icon = infer_provider_icon("openai");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
assert_eq!(icon.color, "#00A67E");
}
#[test]
fn test_fuzzy_match() {
let icon = infer_provider_icon("OpenAI Official");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
}
#[test]
fn test_case_insensitive() {
let icon = infer_provider_icon("ANTHROPIC");
assert!(icon.is_some());
assert_eq!(icon.unwrap().name, "anthropic");
}
#[test]
fn test_no_match() {
let icon = infer_provider_icon("unknown provider");
assert!(icon.is_none());
}
}

View File

@@ -1,257 +0,0 @@
use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
use crate::store::AppState;
use chrono::Utc;
use serde_json::Value;
use std::fs;
use std::path::Path;
const MAX_BACKUPS: usize = 10;
/// 配置导入导出相关业务逻辑
pub struct ConfigService;
impl ConfigService {
/// 为当前 config.json 创建备份,返回备份 ID若文件不存在则返回空字符串
pub fn create_backup(config_path: &Path) -> Result<String, AppError> {
if !config_path.exists() {
return Ok(String::new());
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_id = format!("backup_{timestamp}");
let backup_dir = config_path
.parent()
.ok_or_else(|| AppError::Config("Invalid config path".into()))?
.join("backups");
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_path = backup_dir.join(format!("{backup_id}.json"));
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
Ok(backup_id)
}
fn cleanup_old_backups(backup_dir: &Path, retain: usize) -> Result<(), AppError> {
if retain == 0 {
return Ok(());
}
let entries = match fs::read_dir(backup_dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect::<Vec<_>>(),
Err(_) => return Ok(()),
};
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(retain);
let mut sorted = entries;
sorted.sort_by(|a, b| {
let a_time = a.metadata().and_then(|m| m.modified()).ok();
let b_time = b.metadata().and_then(|m| m.modified()).ok();
a_time.cmp(&b_time)
});
for entry in sorted.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!(
"Failed to remove old backup {}: {}",
entry.path().display(),
err
);
}
}
Ok(())
}
/// 将当前 config.json 拷贝到目标路径。
pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> {
let config_path = crate::config::get_app_config_path();
let config_content =
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e))
}
/// 从磁盘文件加载配置并写回 config.json返回备份 ID 及新配置。
pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> {
let import_content =
fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?;
let new_config: MultiAppConfig =
serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?;
let config_path = crate::config::get_app_config_path();
let backup_id = Self::create_backup(&config_path)?;
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
Ok((new_config, backup_id))
}
/// 将外部配置文件内容加载并写入应用状态。
pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result<String, AppError> {
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
{
let mut guard = state.config.write().map_err(AppError::from)?;
*guard = new_config;
}
Ok(backup_id)
}
/// 同步当前供应商到对应的 live 配置。
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Ok(())
}
fn sync_current_provider_for_app(
config: &mut MultiAppConfig,
app_type: &AppType,
) -> Result<(), AppError> {
let (current_id, provider) = {
let manager = match config.get_manager(app_type) {
Some(manager) => manager,
None => return Ok(()),
};
if manager.current.is_empty() {
return Ok(());
}
let current_id = manager.current.clone();
let provider = match manager.providers.get(&current_id) {
Some(provider) => provider.clone(),
None => {
log::warn!(
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
);
return Ok(());
}
};
(current_id, provider)
};
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
}
Ok(())
}
fn sync_codex_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
})?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
crate::mcp::sync_enabled_to_codex(config)?;
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
if let Some(target) = manager.providers.get_mut(provider_id) {
if let Some(obj) = target.settings_config.as_object_mut() {
obj.insert(
"config".to_string(),
serde_json::Value::String(cfg_text_after),
);
}
}
}
Ok(())
}
fn sync_claude_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::config::{read_json_file, write_json_file};
let settings_path = crate::config::get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&settings_path, &provider.settings_config)?;
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
fn sync_gemini_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::gemini_config::{env_to_json, read_gemini_env};
ProviderService::write_gemini_live(provider)?;
// 读回实际写入的内容并更新到配置中(包含 settings.json
let live_after_env = read_gemini_env()?;
let settings_path = crate::gemini_config::get_gemini_settings_path();
let live_after_config = if settings_path.exists() {
crate::config::read_json_file(&settings_path)?
} else {
serde_json::json!({})
};
let mut live_after = env_to_json(&live_after_env);
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), live_after_config);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
}

View File

@@ -1,168 +0,0 @@
use serde::{Deserialize, Serialize};
#[cfg(not(target_os = "windows"))]
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvConflict {
pub var_name: String,
pub var_value: String,
pub source_type: String, // "system" | "file"
pub source_path: String, // Registry path or file path
}
#[cfg(target_os = "windows")]
use winreg::enums::*;
#[cfg(target_os = "windows")]
use winreg::RegKey;
/// Check environment variables for conflicts
pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
let keywords = get_keywords_for_app(app);
let mut conflicts = Vec::new();
// Check system environment variables
conflicts.extend(check_system_env(&keywords)?);
// Check shell configuration files (Unix only)
#[cfg(not(target_os = "windows"))]
conflicts.extend(check_shell_configs(&keywords)?);
Ok(conflicts)
}
/// Get relevant keywords for each app
fn get_keywords_for_app(app: &str) -> Vec<&str> {
match app.to_lowercase().as_str() {
"claude" => vec!["ANTHROPIC"],
"codex" => vec!["OPENAI"],
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
_ => vec![],
}
}
/// Check system environment variables (Windows Registry or Unix env)
#[cfg(target_os = "windows")]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check HKEY_CURRENT_USER\Environment
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
source_type: "system".to_string(),
source_path: "HKEY_CURRENT_USER\\Environment".to_string(),
});
}
}
}
// Check HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
if let Ok(hklm) = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
{
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
source_type: "system".to_string(),
source_path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment".to_string(),
});
}
}
}
Ok(conflicts)
}
#[cfg(not(target_os = "windows"))]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check current process environment
for (key, value) in std::env::vars() {
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: key,
var_value: value,
source_type: "system".to_string(),
source_path: "Process Environment".to_string(),
});
}
}
Ok(conflicts)
}
/// Check shell configuration files for environment variable exports (Unix only)
#[cfg(not(target_os = "windows"))]
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let config_files = vec![
format!("{}/.bashrc", home),
format!("{}/.bash_profile", home),
format!("{}/.zshrc", home),
format!("{}/.zprofile", home),
format!("{}/.profile", home),
"/etc/profile".to_string(),
"/etc/bashrc".to_string(),
];
for file_path in config_files {
if let Ok(content) = fs::read_to_string(&file_path) {
// Parse lines for export statements
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Match patterns like: export VAR=value or VAR=value
if trimmed.starts_with("export ")
|| (!trimmed.starts_with('#') && trimmed.contains('='))
{
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
if let Some(eq_pos) = export_line.find('=') {
let var_name = export_line[..eq_pos].trim();
let var_value = export_line[eq_pos + 1..].trim();
// Check if variable name contains any keyword
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: var_name.to_string(),
var_value: var_value
.trim_matches('"')
.trim_matches('\'')
.to_string(),
source_type: "file".to_string(),
source_path: format!("{}:{}", file_path, line_num + 1),
});
}
}
}
}
}
}
Ok(conflicts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_keywords() {
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
assert_eq!(
get_keywords_for_app("gemini"),
vec!["GEMINI", "GOOGLE_GEMINI"]
);
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
}
}

View File

@@ -1,240 +0,0 @@
use super::env_checker::EnvConflict;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use winreg::enums::*;
#[cfg(target_os = "windows")]
use winreg::RegKey;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupInfo {
pub backup_path: String,
pub timestamp: String,
pub conflicts: Vec<EnvConflict>,
}
/// Delete environment variables with automatic backup
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
// Step 1: Create backup
let backup_info = create_backup(&conflicts)?;
// Step 2: Delete variables
for conflict in &conflicts {
match delete_single_env(conflict) {
Ok(_) => {}
Err(e) => {
// If deletion fails, we keep the backup but return error
return Err(format!(
"删除环境变量失败: {}. 备份已保存到: {}",
e, backup_info.backup_path
));
}
}
}
Ok(backup_info)
}
/// Create backup file before deletion
fn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {
// Get backup directory
let backup_dir = get_backup_dir()?;
fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?;
// Generate backup file name with timestamp
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json"));
// Create backup data
let backup_info = BackupInfo {
backup_path: backup_file.to_string_lossy().to_string(),
timestamp: timestamp.clone(),
conflicts: conflicts.to_vec(),
};
// Write backup file
let json = serde_json::to_string_pretty(&backup_info)
.map_err(|e| format!("序列化备份数据失败: {e}"))?;
fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?;
Ok(backup_info)
}
/// Get backup directory path
fn get_backup_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("无法获取用户主目录")?;
Ok(home.join(".cc-switch").join("backups"))
}
/// Delete a single environment variable
#[cfg(target_os = "windows")]
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"system" => {
if conflict.source_path.contains("HKEY_CURRENT_USER") {
let hkcu = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey_with_flags("Environment", KEY_ALL_ACCESS)
.map_err(|e| format!("打开注册表失败: {}", e))?;
hkcu.delete_value(&conflict.var_name)
.map_err(|e| format!("删除注册表项失败: {}", e))?;
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey_with_flags(
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
KEY_ALL_ACCESS,
)
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
hklm.delete_value(&conflict.var_name)
.map_err(|e| format!("删除系统注册表项失败: {}", e))?;
}
Ok(())
}
"file" => Err("Windows 系统不应该有文件类型的环境变量".to_string()),
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
}
}
#[cfg(not(target_os = "windows"))]
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"file" => {
// Parse file path and line number from source_path (format: "path:line")
let parts: Vec<&str> = conflict.source_path.split(':').collect();
if parts.len() < 2 {
return Err("无效的文件路径格式".to_string());
}
let file_path = parts[0];
// Read file content
let content = fs::read_to_string(file_path)
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Filter out the line containing the environment variable
let new_content: Vec<String> = content
.lines()
.filter(|line| {
let trimmed = line.trim();
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
// Check if this line sets the target variable
if let Some(eq_pos) = export_line.find('=') {
let var_name = export_line[..eq_pos].trim();
var_name != conflict.var_name
} else {
true
}
})
.map(|s| s.to_string())
.collect();
// Write back to file
fs::write(file_path, new_content.join("\n"))
.map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
"system" => {
// On Unix, we can't directly delete process environment variables
Ok(())
}
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
}
}
/// Restore environment variables from backup
pub fn restore_from_backup(backup_path: String) -> Result<(), String> {
// Read backup file
let content = fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {e}"))?;
let backup_info: BackupInfo =
serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?;
// Restore each variable
for conflict in &backup_info.conflicts {
restore_single_env(conflict)?;
}
Ok(())
}
/// Restore a single environment variable
#[cfg(target_os = "windows")]
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"system" => {
if conflict.source_path.contains("HKEY_CURRENT_USER") {
let (hkcu, _) = RegKey::predef(HKEY_CURRENT_USER)
.create_subkey("Environment")
.map_err(|e| format!("打开注册表失败: {}", e))?;
hkcu.set_value(&conflict.var_name, &conflict.var_value)
.map_err(|e| format!("恢复注册表项失败: {}", e))?;
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
let (hklm, _) = RegKey::predef(HKEY_LOCAL_MACHINE)
.create_subkey(
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
)
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
hklm.set_value(&conflict.var_name, &conflict.var_value)
.map_err(|e| format!("恢复系统注册表项失败: {}", e))?;
}
Ok(())
}
_ => Err(format!(
"无法恢复类型为 {} 的环境变量",
conflict.source_type
)),
}
}
#[cfg(not(target_os = "windows"))]
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
match conflict.source_type.as_str() {
"file" => {
// Parse file path from source_path
let parts: Vec<&str> = conflict.source_path.split(':').collect();
if parts.is_empty() {
return Err("无效的文件路径格式".to_string());
}
let file_path = parts[0];
// Read file content
let mut content = fs::read_to_string(file_path)
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Append the environment variable line
let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value);
content.push_str(&export_line);
// Write back to file
fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
_ => Err(format!(
"无法恢复类型为 {} 的环境变量",
conflict.source_type
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backup_dir_creation() {
let backup_dir = get_backup_dir();
assert!(backup_dir.is_ok());
}
}

View File

@@ -1,260 +0,0 @@
use std::collections::HashMap;
use crate::app_config::{AppType, McpServer, MultiAppConfig};
use crate::error::AppError;
use crate::mcp;
use crate::store::AppState;
/// MCP 相关业务逻辑v3.7.0 统一结构)
pub struct McpService;
impl McpService {
/// 获取所有 MCP 服务器(统一结构)
pub fn get_all_servers(state: &AppState) -> Result<HashMap<String, McpServer>, AppError> {
let cfg = state.config.read()?;
// 如果是新结构,直接返回
if let Some(servers) = &cfg.mcp.servers {
return Ok(servers.clone());
}
// 理论上不应该走到这里,因为 load 时会自动迁移
Err(AppError::localized(
"mcp.old_structure",
"检测到旧版 MCP 结构,请重启应用完成迁移",
"Old MCP structure detected, please restart app to complete migration",
))
}
/// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
{
let mut cfg = state.config.write()?;
// 确保 servers 字段存在
if cfg.mcp.servers.is_none() {
cfg.mcp.servers = Some(HashMap::new());
}
let servers = cfg.mcp.servers.as_mut().unwrap();
let id = server.id.clone();
// 插入或更新
servers.insert(id, server.clone());
}
state.save()?;
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
Ok(())
}
/// 删除 MCP 服务器
pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {
let server = {
let mut cfg = state.config.write()?;
if let Some(servers) = &mut cfg.mcp.servers {
servers.remove(id)
} else {
None
}
};
if let Some(server) = server {
state.save()?;
// 从所有应用的 live 配置中移除
Self::remove_server_from_all_apps(state, id, &server)?;
Ok(true)
} else {
Ok(false)
}
}
/// 切换指定应用的启用状态
pub fn toggle_app(
state: &AppState,
server_id: &str,
app: AppType,
enabled: bool,
) -> Result<(), AppError> {
let server = {
let mut cfg = state.config.write()?;
if let Some(servers) = &mut cfg.mcp.servers {
if let Some(server) = servers.get_mut(server_id) {
server.apps.set_enabled_for(&app, enabled);
Some(server.clone())
} else {
None
}
} else {
None
}
};
if let Some(server) = server {
state.save()?;
// 同步到对应应用
if enabled {
Self::sync_server_to_app(state, &server, &app)?;
} else {
Self::remove_server_from_app(state, server_id, &app)?;
}
}
Ok(())
}
/// 将 MCP 服务器同步到所有启用的应用
fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> {
let cfg = state.config.read()?;
for app in server.apps.enabled_apps() {
Self::sync_server_to_app_internal(&cfg, server, &app)?;
}
Ok(())
}
/// 将 MCP 服务器同步到指定应用
fn sync_server_to_app(
state: &AppState,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
let cfg = state.config.read()?;
Self::sync_server_to_app_internal(&cfg, server, app)
}
fn sync_server_to_app_internal(
cfg: &MultiAppConfig,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
match app {
AppType::Claude => {
mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?;
}
AppType::Codex => {
mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?;
}
AppType::Gemini => {
mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?;
}
}
Ok(())
}
/// 从所有曾启用过该服务器的应用中移除
fn remove_server_from_all_apps(
state: &AppState,
id: &str,
server: &McpServer,
) -> Result<(), AppError> {
// 从所有曾启用的应用中移除
for app in server.apps.enabled_apps() {
Self::remove_server_from_app(state, id, &app)?;
}
Ok(())
}
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
}
Ok(())
}
/// 手动同步所有启用的 MCP 服务器到对应的应用
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
Self::sync_server_to_apps(state, server)?;
}
Ok(())
}
// ========================================================================
// 兼容层:支持旧的 v3.6.x 命令(已废弃,将在 v4.0 移除)
// ========================================================================
/// [已废弃] 获取指定应用的 MCP 服务器(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use get_all_servers instead")]
pub fn get_servers(
state: &AppState,
app: AppType,
) -> Result<HashMap<String, serde_json::Value>, AppError> {
let all_servers = Self::get_all_servers(state)?;
let mut result = HashMap::new();
for (id, server) in all_servers {
if server.apps.is_enabled_for(&app) {
result.insert(id, server.server);
}
}
Ok(result)
}
/// [已废弃] 设置 MCP 服务器在指定应用的启用状态(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use toggle_app instead")]
pub fn set_enabled(
state: &AppState,
app: AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
Self::toggle_app(state, id, app, enabled)?;
Ok(true)
}
/// [已废弃] 同步启用的 MCP 到指定应用(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use sync_all_enabled instead")]
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
if server.apps.is_enabled_for(&app) {
Self::sync_server_to_app(state, server, &app)?;
}
}
Ok(())
}
/// 从 Claude 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_claude(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
}
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_codex(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_gemini(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
}
}

View File

@@ -1,15 +0,0 @@
pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod prompt;
pub mod provider;
pub mod skill;
pub mod speedtest;
pub use config::ConfigService;
pub use mcp::McpService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use skill::{Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};

View File

@@ -1,203 +0,0 @@
use std::collections::HashMap;
use crate::app_config::AppType;
use crate::config::write_text_file;
use crate::error::AppError;
use crate::prompt::Prompt;
use crate::prompt_files::prompt_file_path;
use crate::store::AppState;
pub struct PromptService;
impl PromptService {
pub fn get_prompts(
state: &AppState,
app: AppType,
) -> Result<HashMap<String, Prompt>, AppError> {
let cfg = state.config.read()?;
let prompts = match app {
AppType::Claude => &cfg.prompts.claude.prompts,
AppType::Codex => &cfg.prompts.codex.prompts,
AppType::Gemini => &cfg.prompts.gemini.prompts,
};
Ok(prompts.clone())
}
pub fn upsert_prompt(
state: &AppState,
app: AppType,
id: &str,
prompt: Prompt,
) -> Result<(), AppError> {
// 检查是否为已启用的提示词
let is_enabled = prompt.enabled;
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
prompts.insert(id.to_string(), prompt.clone());
drop(cfg);
state.save()?;
// 如果是已启用的提示词,同步更新到对应的文件
if is_enabled {
let target_path = prompt_file_path(&app)?;
write_text_file(&target_path, &prompt.content)?;
}
Ok(())
}
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
if let Some(prompt) = prompts.get(id) {
if prompt.enabled {
return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string()));
}
}
prompts.remove(id);
drop(cfg);
state.save()?;
Ok(())
}
pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
// 回填当前 live 文件内容到已启用的提示词,或创建备份
let target_path = prompt_file_path(&app)?;
if target_path.exists() {
if let Ok(live_content) = std::fs::read_to_string(&target_path) {
if !live_content.trim().is_empty() {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
// 尝试回填到当前已启用的提示词
if let Some((enabled_id, enabled_prompt)) = prompts
.iter_mut()
.find(|(_, p)| p.enabled)
.map(|(id, p)| (id.clone(), p))
{
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
enabled_prompt.content = live_content.clone();
enabled_prompt.updated_at = Some(timestamp);
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
drop(cfg); // 释放锁后保存,避免死锁
state.save()?; // 第一次保存:回填后立即持久化
} else {
// 没有已启用的提示词,则创建一次备份(避免重复备份)
let content_exists = prompts
.values()
.any(|p| p.content.trim() == live_content.trim());
if !content_exists {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt {
id: backup_id.clone(),
name: format!(
"原始提示词 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content: live_content,
description: Some("自动备份的原始提示词".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
prompts.insert(backup_id.clone(), backup_prompt);
log::info!("回填 live 提示词内容,创建备份: {backup_id}");
drop(cfg); // 释放锁后保存
state.save()?; // 第一次保存:回填后立即持久化
} else {
// 即使内容已存在,也无需重复备份;但不需要保存任何更改
drop(cfg);
}
}
}
}
}
// 启用目标提示词并写入文件
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
for prompt in prompts.values_mut() {
prompt.enabled = false;
}
if let Some(prompt) = prompts.get_mut(id) {
prompt.enabled = true;
write_text_file(&target_path, &prompt.content)?; // 原子写入
} else {
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
}
drop(cfg);
state.save()?; // 第二次保存:启用目标提示词并写入文件后
Ok(())
}
pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
let file_path = prompt_file_path(&app)?;
if !file_path.exists() {
return Err(AppError::Message("提示词文件不存在".to_string()));
}
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let id = format!("imported-{timestamp}");
let prompt = Prompt {
id: id.clone(),
name: format!(
"导入的提示词 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("从现有配置文件导入".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
Self::upsert_prompt(state, app, &id, prompt)?;
Ok(id)
}
pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
let file_path = prompt_file_path(&app)?;
if !file_path.exists() {
return Ok(None);
}
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
Ok(Some(content))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,583 +0,0 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::time::timeout;
use crate::error::format_skill_error;
/// 技能对象
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
/// 唯一标识: "owner/name:directory" 或 "local:directory"
pub key: String,
/// 显示名称 (从 SKILL.md 解析)
pub name: String,
/// 技能描述
pub description: String,
/// 目录名称 (安装路径的最后一段)
pub directory: String,
/// GitHub README URL
#[serde(rename = "readmeUrl")]
pub readme_url: Option<String>,
/// 是否已安装
pub installed: bool,
/// 仓库所有者
#[serde(rename = "repoOwner")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(rename = "repoName")]
pub repo_name: Option<String>,
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillRepo {
/// GitHub 用户/组织名
pub owner: String,
/// 仓库名称
pub name: String,
/// 分支 (默认 "main")
pub branch: String,
/// 是否启用
pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 技能安装状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillState {
/// 是否已安装
pub installed: bool,
/// 安装时间
#[serde(rename = "installedAt")]
pub installed_at: DateTime<Utc>,
}
/// 持久化存储结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillStore {
/// directory -> 安装状态
pub skills: HashMap<String, SkillState>,
/// 仓库列表
pub repos: Vec<SkillRepo>,
}
impl Default for SkillStore {
fn default() -> Self {
SkillStore {
skills: HashMap::new(),
repos: vec![
SkillRepo {
owner: "ComposioHQ".to_string(),
name: "awesome-claude-skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
},
],
}
}
}
/// 技能元数据 (从 SKILL.md 解析)
#[derive(Debug, Clone, Deserialize)]
pub struct SkillMetadata {
pub name: Option<String>,
pub description: Option<String>,
}
pub struct SkillService {
http_client: Client,
install_dir: PathBuf,
}
impl SkillService {
pub fn new() -> Result<Self> {
let install_dir = Self::get_install_dir()?;
// 确保目录存在
fs::create_dir_all(&install_dir)?;
Ok(Self {
http_client: Client::builder()
.user_agent("cc-switch")
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
.timeout(std::time::Duration::from_secs(10))
.build()?,
install_dir,
})
}
fn get_install_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
Ok(home.join(".claude").join("skills"))
}
}
// 核心方法实现
impl SkillService {
/// 列出所有技能
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
let mut skills = Vec::new();
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
let fetch_tasks = enabled_repos
.iter()
.map(|repo| self.fetch_repo_skills(repo));
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
match result {
Ok(repo_skills) => skills.extend(repo_skills),
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
}
}
// 合并本地技能
self.merge_local_skills(&mut skills)?;
// 去重并排序
Self::deduplicate_skills(&mut skills);
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(skills)
}
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skillsPath则扫描该子目录
let subdir = temp_dir.join(skills_path.trim_matches('/'));
if !subdir.exists() {
log::warn!(
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
repo.owner,
repo.name,
skills_path
);
let _ = fs::remove_dir_all(&temp_dir);
return Ok(skills);
}
subdir
} else {
// 否则扫描仓库根目录
temp_dir.clone()
};
// 遍历目标目录
for entry in fs::read_dir(&scan_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
let directory = path.file_name().unwrap().to_string_lossy().to_string();
// 构建 README URL考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.clone()
};
skills.push(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory,
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
});
}
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
}
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(skills)
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
// 移除 BOM
let content = content.trim_start_matches('\u{feff}');
// 提取 YAML front matter
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Ok(SkillMetadata {
name: None,
description: None,
});
}
let front_matter = parts[1].trim();
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
name: None,
description: None,
});
Ok(meta)
}
/// 合并本地技能
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
if !self.install_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let directory = path.file_name().unwrap().to_string_lossy().to_string();
// 更新已安装状态
let mut found = false;
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(&directory) {
skill.installed = true;
found = true;
break;
}
}
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
}
}
}
Ok(())
}
/// 去重技能列表
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
true
} else {
false
}
});
}
/// 下载仓库
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理
// 尝试多个分支
let branches = if repo.branch.is_empty() {
vec!["main", "master"]
} else {
vec![repo.branch.as_str(), "main", "master"]
};
let mut last_error = None;
for branch in branches {
let url = format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
repo.owner, repo.name, branch
);
match self.download_and_extract(&url, &temp_path).await {
Ok(_) => {
return Ok(temp_path);
}
Err(e) => {
last_error = Some(e);
continue;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
}
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
// 下载 ZIP
let response = self.http_client.get(url).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
"DOWNLOAD_FAILED",
&[("status", &status)],
match status.as_str() {
"403" => Some("http403"),
"404" => Some("http404"),
"429" => Some("http429"),
_ => Some("checkNetwork"),
},
)));
}
let bytes = response.bytes().await?;
// 解压
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
let root_name = if !archive.is_empty() {
let first_file = archive.by_index(0)?;
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow::anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkRepoUrl"),
)));
};
// 解压所有文件
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name();
// 跳过根目录,直接提取内容
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
stripped
} else {
continue;
};
if relative_path.is_empty() {
continue;
}
let outpath = dest.join(relative_path);
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
fs::create_dir_all(parent)?;
}
let mut outfile = fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
Ok(())
}
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
let dest = self.install_dir.join(&directory);
// 若目标目录已存在,则视为已安装,避免重复下载
if dest.exists() {
return Ok(());
}
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
let temp_dir = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
};
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow::anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
// 删除旧版本
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
// 递归复制
Self::copy_dir_recursive(&source, &dest)?;
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(())
}
/// 递归复制目录
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest_path = dest.join(entry.file_name());
if path.is_dir() {
Self::copy_dir_recursive(&path, &dest_path)?;
} else {
fs::copy(&path, &dest_path)?;
}
}
Ok(())
}
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
let dest = self.install_dir.join(&directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
Ok(())
}
/// 列出仓库
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
store.repos.clone()
}
/// 添加仓库
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
// 检查重复
if let Some(pos) = store
.repos
.iter()
.position(|r| r.owner == repo.owner && r.name == repo.name)
{
store.repos[pos] = repo;
} else {
store.repos.push(repo);
}
Ok(())
}
/// 删除仓库
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
store
.repos
.retain(|r| !(r.owner == owner && r.name == name));
Ok(())
}
}

View File

@@ -1,174 +0,0 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
use crate::error::AppError;
const DEFAULT_TIMEOUT_SECS: u64 = 8;
const MAX_TIMEOUT_SECS: u64 = 30;
const MIN_TIMEOUT_SECS: u64 = 2;
/// 端点测速结果
#[derive(Debug, Clone, Serialize)]
pub struct EndpointLatency {
pub url: String,
pub latency: Option<u128>,
pub status: Option<u16>,
pub error: Option<String>,
}
/// 网络测速相关业务
pub struct SpeedtestService;
impl SpeedtestService {
/// 测试一组端点的响应延迟。
pub async fn test_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<EndpointLatency>, AppError> {
if urls.is_empty() {
return Ok(vec![]);
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let tasks = urls.into_iter().map(|raw_url| {
let client = client.clone();
async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now();
match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
status: Some(resp.status().as_u16()),
error: None,
},
Err(err) => {
let status = err.status().map(|s| s.as_u16());
let error_message = if err.is_timeout() {
"请求超时".to_string()
} else if err.is_connect() {
"连接失败".to_string()
} else {
err.to_string()
};
EndpointLatency {
url: trimmed,
latency: None,
status,
error: Some(error_message),
}
}
}
}
});
Ok(join_all(tasks).await)
}
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| {
AppError::localized(
"speedtest.client_create_failed",
format!("创建 HTTP 客户端失败: {e}"),
format!("Failed to create HTTP client: {e}"),
)
})
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_timeout_clamps_values() {
assert_eq!(
SpeedtestService::sanitize_timeout(Some(1)),
MIN_TIMEOUT_SECS
);
assert_eq!(
SpeedtestService::sanitize_timeout(Some(999)),
MAX_TIMEOUT_SECS
);
assert_eq!(
SpeedtestService::sanitize_timeout(Some(10)),
10.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
);
assert_eq!(
SpeedtestService::sanitize_timeout(None),
DEFAULT_TIMEOUT_SECS
);
}
#[test]
fn test_endpoints_handles_empty_list() {
let result =
tauri::async_runtime::block_on(SpeedtestService::test_endpoints(Vec::new(), Some(5)))
.expect("empty list should succeed");
assert!(result.is_empty());
}
#[test]
fn test_endpoints_reports_invalid_url() {
let result = tauri::async_runtime::block_on(SpeedtestService::test_endpoints(
vec!["not a url".into(), "".into()],
None,
))
.expect("invalid inputs should still succeed");
assert_eq!(result.len(), 2);
assert!(
result[0]
.error
.as_deref()
.unwrap_or_default()
.starts_with("URL 无效"),
"invalid url should yield parse error"
);
assert_eq!(
result[1].error.as_deref(),
Some("URL 不能为空"),
"empty url should report validation error"
);
}
}

View File

@@ -1,35 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use crate::error::AppError;
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
pub url: String,
pub added_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecurityAuthSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<SecurityAuthSettings>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -38,28 +11,10 @@ pub struct AppSettings {
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
@@ -75,27 +30,15 @@ impl Default for AppSettings {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
impl AppSettings {
fn settings_path() -> PathBuf {
// settings.json 必须使用固定路径,不能被 app_config_dir 覆盖
// 否则会造成循环依赖:读取 settings 需要知道路径,但路径在 settings 中
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
.join("settings.json")
crate::config::get_app_config_dir().join("settings.json")
}
fn normalize_paths(&mut self) {
@@ -112,20 +55,6 @@ impl AppSettings {
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.gemini_config_dir = self
.gemini_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh"))
.map(|s| s.to_string());
}
pub fn load() -> Self {
@@ -150,18 +79,18 @@ impl AppSettings {
}
}
pub fn save(&self) -> Result<(), AppError> {
pub fn save(&self) -> Result<(), String> {
let mut normalized = self.clone();
normalized.normalize_paths();
let path = Self::settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
}
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
.map_err(|e| format!("序列化设置失败: {}", e))?;
fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?;
Ok(())
}
}
@@ -193,7 +122,7 @@ pub fn get_settings() -> AppSettings {
settings_store().read().expect("读取设置锁失败").clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
new_settings.normalize_paths();
new_settings.save()?;
@@ -202,27 +131,6 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
Ok(())
}
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings();
let current = settings
.security
.as_ref()
.and_then(|sec| sec.auth.as_ref())
.and_then(|auth| auth.selected_type.as_deref());
if current == Some(selected_type) {
return Ok(());
}
let mut security = settings.security.unwrap_or_default();
let mut auth = security.auth.unwrap_or_default();
auth.selected_type = Some(selected_type.to_string());
security.auth = Some(auth);
settings.security = Some(security);
update_settings(settings)
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -238,11 +146,3 @@ pub fn get_codex_override_dir() -> Option<PathBuf> {
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_gemini_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.gemini_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}

View File

@@ -1,25 +1,30 @@
use crate::app_config::MultiAppConfig;
use crate::error::AppError;
use std::sync::RwLock;
use std::sync::Mutex;
/// 全局应用状态
pub struct AppState {
pub config: RwLock<MultiAppConfig>,
pub config: Mutex<MultiAppConfig>,
}
impl AppState {
/// 创建新的应用状态
/// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。
pub fn try_new() -> Result<Self, AppError> {
let config = MultiAppConfig::load()?;
Ok(Self {
config: RwLock::new(config),
})
pub fn new() -> Self {
let config = MultiAppConfig::load().unwrap_or_else(|e| {
log::warn!("加载配置失败: {}, 使用默认配置", e);
MultiAppConfig::default()
});
Self {
config: Mutex::new(config),
}
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), AppError> {
let config = self.config.read().map_err(AppError::from)?;
pub fn save(&self) -> Result<(), String> {
let config = self
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
config.save()
}

View File

@@ -1,396 +0,0 @@
use reqwest::Client;
use rquickjs::{Context, Function, Runtime};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use crate::error::AppError;
/// 执行用量查询脚本
pub async fn execute_usage_script(
script_code: &str,
api_key: &str,
base_url: &str,
timeout_secs: u64,
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<Value, AppError> {
// 1. 替换变量
let mut replaced = script_code
.replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
// 替换 accessToken 和 userId
if let Some(token) = access_token {
replaced = replaced.replace("{{accessToken}}", token);
}
if let Some(uid) = user_id {
replaced = replaced.replace("{{userId}}", uid);
}
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
"usage_script.runtime_create_failed",
format!("创建 JS 运行时失败: {e}"),
format!("Failed to create JS runtime: {e}"),
)
})?;
let context = Context::full(&runtime).map_err(|e| {
AppError::localized(
"usage_script.context_create_failed",
format!("创建 JS 上下文失败: {e}"),
format!("Failed to create JS context: {e}"),
)
})?;
context.with(|ctx| {
// 执行用户代码,获取配置对象
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized(
"usage_script.config_parse_failed",
format!("解析配置失败: {e}"),
format!("Failed to parse config: {e}"),
)
})?;
// 提取 request 配置
let request: rquickjs::Object = config.get("request").map_err(|e| {
AppError::localized(
"usage_script.request_missing",
format!("缺少 request 配置: {e}"),
format!("Missing request config: {e}"),
)
})?;
// 将 request 转换为 JSON 字符串
let request_json: String = ctx
.json_stringify(request)
.map_err(|e| {
AppError::localized(
"usage_script.request_serialize_failed",
format!("序列化 request 失败: {e}"),
format!("Failed to serialize request: {e}"),
)
})?
.ok_or_else(|| {
AppError::localized(
"usage_script.serialize_none",
"序列化返回 None",
"Serialization returned None",
)
})?
.get()
.map_err(|e| {
AppError::localized(
"usage_script.get_string_failed",
format!("获取字符串失败: {e}"),
format!("Failed to get string: {e}"),
)
})?;
Ok::<_, AppError>(request_json)
})?
}; // Runtime 和 Context 在这里被 drop
// 3. 解析 request 配置
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
AppError::localized(
"usage_script.request_format_invalid",
format!("request 配置格式错误: {e}"),
format!("Invalid request config format: {e}"),
)
})?;
// 4. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?;
// 5. 在独立作用域中执行 extractor确保 Runtime/Context 在函数结束前释放)
let result: Value = {
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
"usage_script.runtime_create_failed",
format!("创建 JS 运行时失败: {e}"),
format!("Failed to create JS runtime: {e}"),
)
})?;
let context = Context::full(&runtime).map_err(|e| {
AppError::localized(
"usage_script.context_create_failed",
format!("创建 JS 上下文失败: {e}"),
format!("Failed to create JS context: {e}"),
)
})?;
context.with(|ctx| {
// 重新 eval 获取配置对象
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized(
"usage_script.config_reparse_failed",
format!("重新解析配置失败: {e}"),
format!("Failed to re-parse config: {e}"),
)
})?;
// 提取 extractor 函数
let extractor: Function = config.get("extractor").map_err(|e| {
AppError::localized(
"usage_script.extractor_missing",
format!("缺少 extractor 函数: {e}"),
format!("Missing extractor function: {e}"),
)
})?;
// 将响应数据转换为 JS 值
let response_js: rquickjs::Value =
ctx.json_parse(response_data.as_str()).map_err(|e| {
AppError::localized(
"usage_script.response_parse_failed",
format!("解析响应 JSON 失败: {e}"),
format!("Failed to parse response JSON: {e}"),
)
})?;
// 调用 extractor(response)
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
AppError::localized(
"usage_script.extractor_exec_failed",
format!("执行 extractor 失败: {e}"),
format!("Failed to execute extractor: {e}"),
)
})?;
// 转换为 JSON 字符串
let result_json: String = ctx
.json_stringify(result_js)
.map_err(|e| {
AppError::localized(
"usage_script.result_serialize_failed",
format!("序列化结果失败: {e}"),
format!("Failed to serialize result: {e}"),
)
})?
.ok_or_else(|| {
AppError::localized(
"usage_script.serialize_none",
"序列化返回 None",
"Serialization returned None",
)
})?
.get()
.map_err(|e| {
AppError::localized(
"usage_script.get_string_failed",
format!("获取字符串失败: {e}"),
format!("Failed to get string: {e}"),
)
})?;
// 解析为 serde_json::Value
serde_json::from_str(&result_json).map_err(|e| {
AppError::localized(
"usage_script.json_parse_failed",
format!("JSON 解析失败: {e}"),
format!("JSON parse failed: {e}"),
)
})
})?
}; // Runtime 和 Context 在这里被 drop
// 6. 验证返回值格式
validate_result(&result)?;
Ok(result)
}
/// 请求配置结构
#[derive(Debug, serde::Deserialize)]
struct RequestConfig {
url: String,
method: String,
#[serde(default)]
headers: HashMap<String, String>,
#[serde(default)]
body: Option<String>,
}
/// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 约束超时范围,防止异常配置导致长时间阻塞
let timeout = timeout_secs.clamp(2, 30);
let client = Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
// 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| {
AppError::localized(
"usage_script.invalid_http_method",
format!("不支持的 HTTP 方法: {}", config.method),
format!("Unsupported HTTP method: {}", config.method),
)
})?;
let mut req = client.request(method.clone(), &config.url);
// 添加请求头
for (k, v) in &config.headers {
req = req.header(k, v);
}
// 添加请求体
if let Some(body) = &config.body {
req = req.body(body.clone());
}
// 发送请求
let resp = req.send().await.map_err(|e| {
AppError::localized(
"usage_script.request_failed",
format!("请求失败: {e}"),
format!("Request failed: {e}"),
)
})?;
let status = resp.status();
let text = resp.text().await.map_err(|e| {
AppError::localized(
"usage_script.read_response_failed",
format!("读取响应失败: {e}"),
format!("Failed to read response: {e}"),
)
})?;
if !status.is_success() {
let preview = if text.len() > 200 {
format!("{}...", &text[..200])
} else {
text.clone()
};
return Err(AppError::localized(
"usage_script.http_error",
format!("HTTP {status} : {preview}"),
format!("HTTP {status} : {preview}"),
));
}
Ok(text)
}
/// 验证脚本返回值(支持单对象或数组)
fn validate_result(result: &Value) -> Result<(), AppError> {
// 如果是数组,验证每个元素
if let Some(arr) = result.as_array() {
if arr.is_empty() {
return Err(AppError::localized(
"usage_script.empty_array",
"脚本返回的数组不能为空",
"Script returned empty array",
));
}
for (idx, item) in arr.iter().enumerate() {
validate_single_usage(item).map_err(|e| {
AppError::localized(
"usage_script.array_validation_failed",
format!("数组索引[{idx}]验证失败: {e}"),
format!("Validation failed at index [{idx}]: {e}"),
)
})?;
}
return Ok(());
}
// 如果是单对象,直接验证(向后兼容)
validate_single_usage(result)
}
/// 验证单个用量数据对象
fn validate_single_usage(result: &Value) -> Result<(), AppError> {
let obj = result.as_object().ok_or_else(|| {
AppError::localized(
"usage_script.must_return_object",
"脚本必须返回对象或对象数组",
"Script must return object or array of objects",
)
})?;
// 所有字段均为可选,只进行类型检查
if obj.contains_key("isValid")
&& !result["isValid"].is_null()
&& !result["isValid"].is_boolean()
{
return Err(AppError::localized(
"usage_script.isvalid_type_error",
"isValid 必须是布尔值或 null",
"isValid must be boolean or null",
));
}
if obj.contains_key("invalidMessage")
&& !result["invalidMessage"].is_null()
&& !result["invalidMessage"].is_string()
{
return Err(AppError::localized(
"usage_script.invalidmessage_type_error",
"invalidMessage 必须是字符串或 null",
"invalidMessage must be string or null",
));
}
if obj.contains_key("remaining")
&& !result["remaining"].is_null()
&& !result["remaining"].is_number()
{
return Err(AppError::localized(
"usage_script.remaining_type_error",
"remaining 必须是数字或 null",
"remaining must be number or null",
));
}
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
return Err(AppError::localized(
"usage_script.unit_type_error",
"unit 必须是字符串或 null",
"unit must be string or null",
));
}
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
return Err(AppError::localized(
"usage_script.total_type_error",
"total 必须是数字或 null",
"total must be number or null",
));
}
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
return Err(AppError::localized(
"usage_script.used_type_error",
"used 必须是数字或 null",
"used must be number or null",
));
}
if obj.contains_key("planName")
&& !result["planName"].is_null()
&& !result["planName"].is_string()
{
return Err(AppError::localized(
"usage_script.planname_type_error",
"planName 必须是字符串或 null",
"planName must be string or null",
));
}
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
return Err(AppError::localized(
"usage_script.extra_type_error",
"extra 必须是字符串或 null",
"extra must be string or null",
));
}
Ok(())
}

65
src-tauri/src/vscode.rs Normal file
View File

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

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.7.1",
"version": "3.3.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -14,22 +14,17 @@
{
"label": "main",
"title": "",
"titleBarStyle": "Overlay",
"width": 1000,
"width": 900,
"height": 650,
"minWidth": 900,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
"titleBarStyle": "Transparent"
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:",
"assetProtocol": {
"enable": true,
"scope": []
}
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
}
},
"bundle": {
@@ -47,17 +42,9 @@
"wix": {
"template": "wix/per-user-main.wxs"
}
},
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["ccswitch"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [

View File

@@ -1,107 +0,0 @@
use std::fs;
use std::path::PathBuf;
use cc_switch_lib::{AppError, MultiAppConfig};
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
fn cfg_path() -> PathBuf {
let home = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
PathBuf::from(home).join(".cc-switch").join("config.json")
}
#[test]
fn load_v1_config_returns_error_and_does_not_write() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let path = cfg_path();
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
// 最小 v1 形状providers + current且不含 version/apps/mcp
let v1_json = r#"{"providers":{},"current":""}"#;
fs::write(&path, v1_json).expect("seed v1 json");
let before = fs::read_to_string(&path).expect("read before");
let err = MultiAppConfig::load().expect_err("v1 should not be auto-migrated");
match err {
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
other => panic!("expected Localized v1 error, got {other:?}"),
}
// 文件不应有任何变化,且不应生成 .bak
let after = fs::read_to_string(&path).expect("read after");
assert_eq!(before, after, "config.json should not be modified");
let bak = home.join(".cc-switch").join("config.json.bak");
assert!(!bak.exists(), ".bak should not be created on load error");
}
#[test]
fn load_v1_with_extra_version_still_treated_as_v1() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let path = cfg_path();
std::fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
// 畸形:包含 providers + current + version但没有 apps应按 v1 处理
let v1_like = r#"{"providers":{},"current":"","version":2}"#;
std::fs::write(&path, v1_like).expect("seed v1-like json");
let before = std::fs::read_to_string(&path).expect("read before");
let err = MultiAppConfig::load().expect_err("v1-like should not be parsed as v2");
match err {
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
other => panic!("expected Localized v1 error, got {other:?}"),
}
let after = std::fs::read_to_string(&path).expect("read after");
assert_eq!(before, after, "config.json should not be modified");
let bak = home.join(".cc-switch").join("config.json.bak");
assert!(!bak.exists(), ".bak should not be created on v1-like error");
}
#[test]
fn load_invalid_json_returns_parse_error_and_does_not_write() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let path = cfg_path();
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
fs::write(&path, "{not json").expect("seed invalid json");
let before = fs::read_to_string(&path).expect("read before");
let err = MultiAppConfig::load().expect_err("invalid json should error");
match err {
AppError::Json { .. } => {}
other => panic!("expected Json error, got {other:?}"),
}
let after = fs::read_to_string(&path).expect("read after");
assert_eq!(before, after, "config.json should remain unchanged");
let bak = home.join(".cc-switch").join("config.json.bak");
assert!(!bak.exists(), ".bak should not be created on parse error");
}
#[test]
fn load_valid_v2_config_succeeds() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let path = cfg_path();
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
// 使用默认结构序列化为 v2
let default_cfg = MultiAppConfig::default();
let json = serde_json::to_string_pretty(&default_cfg).expect("serialize default cfg");
fs::write(&path, json).expect("write v2 json");
let loaded = MultiAppConfig::load().expect("v2 should load successfully");
assert_eq!(loaded.version, 2);
assert!(loaded
.get_manager(&cc_switch_lib::AppType::Claude)
.is_some());
assert!(loaded.get_manager(&cc_switch_lib::AppType::Codex).is_some());
}

View File

@@ -1,22 +0,0 @@
use std::str::FromStr;
use cc_switch_lib::AppType;
#[test]
fn parse_known_apps_case_insensitive_and_trim() {
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
assert!(matches!(
AppType::from_str(" ClAuDe \n"),
Ok(AppType::Claude)
));
assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex)));
}
#[test]
fn parse_unknown_app_returns_localized_error_message() {
let err = AppType::from_str("unknown").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("可选值") || msg.contains("Allowed"));
assert!(msg.contains("unknown"));
}

View File

@@ -1,121 +0,0 @@
use std::sync::RwLock;
use cc_switch_lib::{
import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig,
};
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn deeplink_import_claude_provider_persists_to_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4";
let request = parse_deeplink_url(url).expect("parse deeplink url");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Claude);
let state = AppState {
config: RwLock::new(config),
};
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
// 验证内存状态
let guard = state.config.read().expect("read config");
let manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager should exist");
let provider = manager
.providers
.get(&provider_id)
.expect("provider created via deeplink");
assert_eq!(provider.name, request.name);
assert_eq!(
provider.website_url.as_deref(),
Some(request.homepage.as_str())
);
let auth_token = provider
.settings_config
.pointer("/env/ANTHROPIC_AUTH_TOKEN")
.and_then(|v| v.as_str());
let base_url = provider
.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str());
assert_eq!(auth_token, Some(request.api_key.as_str()));
assert_eq!(base_url, Some(request.endpoint.as_str()));
drop(guard);
// 验证配置已持久化
let config_path = home.join(".cc-switch").join("config.json");
assert!(
config_path.exists(),
"importing provider from deeplink should persist config.json"
);
}
#[test]
fn deeplink_import_codex_provider_builds_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o";
let request = parse_deeplink_url(url).expect("parse deeplink url");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
let state = AppState {
config: RwLock::new(config),
};
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
let guard = state.config.read().expect("read config");
let manager = guard
.get_manager(&AppType::Codex)
.expect("codex manager should exist");
let provider = manager
.providers
.get(&provider_id)
.expect("provider created via deeplink");
assert_eq!(provider.name, request.name);
assert_eq!(
provider.website_url.as_deref(),
Some(request.homepage.as_str())
);
let auth_value = provider
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str());
let config_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(auth_value, Some(request.api_key.as_str()));
assert!(
config_text.contains(request.endpoint.as_str()),
"config.toml content should contain endpoint"
);
assert!(
config_text.contains("model = \"gpt-4o\""),
"config.toml content should contain model setting"
);
drop(guard);
let config_path = home.join(".cc-switch").join("config.json");
assert!(
config_path.exists(),
"importing provider from deeplink should persist config.json"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,256 +0,0 @@
use std::{collections::HashMap, fs, sync::RwLock};
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn import_default_config_claude_persists_provider() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).expect("create claude settings dir");
}
let settings = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_BASE_URL": "https://api.test"
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&settings).expect("serialize settings"),
)
.expect("seed claude settings.json");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Claude);
let state = AppState {
config: RwLock::new(config),
};
import_default_config_test_hook(&state, AppType::Claude)
.expect("import default config succeeds");
// 验证内存状态
let guard = state.config.read().expect("lock config");
let manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager present");
assert_eq!(manager.current, "default");
let default_provider = manager.providers.get("default").expect("default provider");
assert_eq!(
default_provider.settings_config, settings,
"default provider should capture live settings"
);
drop(guard);
// 验证配置已持久化
let config_path = home.join(".cc-switch").join("config.json");
assert!(
config_path.exists(),
"importing default config should persist config.json"
);
}
#[test]
fn import_default_config_without_live_file_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = import_default_config_test_hook(&state, AppType::Claude)
.expect_err("missing live file should error");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("Claude Code 配置文件不存在"),
"unexpected error message: {zh}"
),
AppError::Message(msg) => assert!(
msg.contains("Claude Code 配置文件不存在"),
"unexpected error message: {msg}"
),
other => panic!("unexpected error variant: {other:?}"),
}
let config_path = home.join(".cc-switch").join("config.json");
assert!(
!config_path.exists(),
"failed import should not create config.json"
);
}
#[test]
fn import_mcp_from_claude_creates_config_and_enables_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"echo": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
assert!(
changed > 0,
"import should report inserted or normalized entries"
);
let guard = state.config.read().expect("lock config");
// v3.7.0: 检查统一结构
let servers = guard
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
assert!(
entry.apps.claude,
"imported server should have Claude app enabled"
);
drop(guard);
let config_path = home.join(".cc-switch").join("config.json");
assert!(
config_path.exists(),
"state.save should persist config.json when changes detected"
);
}
#[test]
fn import_mcp_from_claude_invalid_json_preserves_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mcp_path = get_claude_mcp_path();
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
.expect("seed invalid ~/.claude.json");
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err =
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
match err {
AppError::McpValidation(msg) => assert!(
msg.contains("解析 ~/.claude.json 失败"),
"unexpected error message: {msg}"
),
other => panic!("unexpected error variant: {other:?}"),
}
let config_path = home.join(".cc-switch").join("config.json");
assert!(
!config_path.exists(),
"failed import should not persist config.json"
);
}
#[test]
fn set_mcp_enabled_for_codex_writes_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 创建 Codex 配置目录和文件
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("auth.json"),
r#"{"OPENAI_API_KEY":"test-key"}"#,
)
.expect("create auth.json");
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
// v3.7.0: 使用统一结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"codex-server".into(),
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = AppState {
config: RwLock::new(config),
};
// v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let guard = state.config.read().expect("lock config");
let entry = guard
.mcp
.servers
.as_ref()
.unwrap()
.get("codex-server")
.expect("codex server exists");
assert!(
entry.apps.codex,
"server should have Codex app enabled after toggle"
);
drop(guard);
let toml_path = cc_switch_lib::get_codex_config_path();
assert!(
toml_path.exists(),
"enabling server should trigger sync to ~/.codex/config.toml"
);
let toml_text = fs::read_to_string(&toml_path).expect("read codex config");
assert!(
toml_text.contains("codex-server"),
"codex config should include the enabled server definition"
);
}

View File

@@ -1,327 +0,0 @@
use serde_json::json;
use std::sync::RwLock;
use cc_switch_lib::{
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider,
};
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn switch_provider_updates_codex_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"});
let legacy_config = r#"[mcp_servers.legacy]
type = "stdio"
command = "echo"
"#;
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing codex live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": "stale-config"
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Latest".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"[mcp_servers.latest]
type = "stdio"
command = "say"
"#
}),
None,
),
);
}
config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo"
}
}),
);
let app_state = AppState {
config: RwLock::new(config),
};
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.unwrap_or(""),
"fresh-key",
"live auth.json should reflect new provider"
);
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
);
let locked = app_state.config.read().expect("lock config after switch");
let manager = locked
.get_manager(&AppType::Codex)
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(
new_config_text, config_text,
"provider config snapshot should match live file"
);
let legacy = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
let legacy_auth_value = legacy
.settings_config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
legacy_auth_value, "legacy-key",
"previous provider should be backfilled with live auth"
);
}
#[test]
fn switch_provider_missing_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
config
.get_manager_mut(&AppType::Claude)
.expect("claude manager")
.current = "does-not-exist".to_string();
let app_state = AppState {
config: RwLock::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
.expect_err("switching to a missing provider should fail");
assert!(
err.to_string().contains("供应商不存在"),
"error message should mention missing provider"
);
}
#[test]
fn switch_provider_updates_claude_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let settings_path = cc_switch_lib::get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).expect("create claude settings dir");
}
let legacy_live = json!({
"env": {
"ANTHROPIC_API_KEY": "legacy-key"
},
"workspace": {
"path": "/tmp/workspace"
}
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy Claude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "stale-key" }
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Fresh Claude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
"workspace": { "path": "/tmp/new-workspace" }
}),
None,
),
);
}
let app_state = AppState {
config: RwLock::new(config),
};
switch_provider_test_hook(&app_state, AppType::Claude, "new-provider")
.expect("switch provider should succeed");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read claude live settings");
assert_eq!(
live_after
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("fresh-key"),
"live settings.json should reflect new provider auth"
);
let locked = app_state.config.read().expect("lock config after switch");
let manager = locked
.get_manager(&AppType::Claude)
.expect("claude manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let legacy_provider = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should receive backfilled live config"
);
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
assert_eq!(
new_provider
.settings_config
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("fresh-key"),
"new provider snapshot should retain fresh auth"
);
drop(locked);
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
let config_path = std::path::Path::new(&home_dir)
.join(".cc-switch")
.join("config.json");
assert!(
config_path.exists(),
"switching provider should persist config.json"
);
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config"))
.expect("parse saved config");
assert_eq!(
persisted
.get("claude")
.and_then(|claude| claude.get("current"))
.and_then(|current| current.as_str()),
Some("new-provider"),
"saved config.json should record the new current provider"
);
}
#[test]
fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"invalid".to_string(),
Provider::with_id(
"invalid".to_string(),
"Broken Codex".to_string(),
json!({
"config": "[mcp_servers.test]\ncommand = \"noop\""
}),
None,
),
);
}
let app_state = AppState {
config: RwLock::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
.expect_err("switching should fail when auth missing");
match err {
AppError::Config(msg) => assert!(
msg.contains("auth"),
"expected auth missing error message, got {msg}"
),
other => panic!("expected config error, got {other:?}"),
}
let locked = app_state.config.read().expect("lock config after failure");
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
assert!(
manager.current.is_empty(),
"current provider should remain empty on failure"
);
}

View File

@@ -1,632 +0,0 @@
use serde_json::json;
use std::sync::RwLock;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
fn sanitize_provider_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
_ => c,
})
.collect::<String>()
.to_lowercase()
}
#[test]
fn provider_service_switch_codex_updates_live_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" });
let legacy_config = r#"[mcp_servers.legacy]
type = "stdio"
command = "echo"
"#;
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing codex live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": "stale-config"
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Latest".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"[mcp_servers.latest]
type = "stdio"
command = "say"
"#
}),
None,
),
);
}
initial_config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo"
}
}),
);
let state = AppState {
config: RwLock::new(initial_config),
};
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("fresh-key"),
"live auth.json should reflect new provider"
);
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
);
let guard = state.config.read().expect("read config after switch");
let manager = guard
.get_manager(&AppType::Codex)
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(
new_config_text, config_text,
"provider config snapshot should match live file"
);
let legacy = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
let legacy_auth_value = legacy
.settings_config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
legacy_auth_value, "legacy-key",
"previous provider should be backfilled with live auth"
);
}
#[test]
fn switch_packycode_gemini_updates_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-gemini".to_string();
manager.providers.insert(
"packy-gemini".to_string(),
Provider::with_id(
"packy-gemini".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"PackyCode Gemini should set security.auth.selectedType"
);
}
#[test]
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-meta".to_string();
let mut provider = Provider::with_id(
"packy-meta".to_string(),
"Generic Gemini".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-meta",
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
}
}),
Some("https://example.com".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("packycode".to_string()),
..ProviderMeta::default()
});
manager.providers.insert("packy-meta".to_string(), provider);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"Partner meta should set security.auth.selectedType even without packy keywords"
);
}
#[test]
fn switch_google_official_gemini_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should also reflect oauth-personal"
);
}
#[test]
fn provider_service_switch_claude_updates_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).expect("create claude settings dir");
}
let legacy_live = json!({
"env": {
"ANTHROPIC_API_KEY": "legacy-key"
},
"workspace": {
"path": "/tmp/workspace"
}
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy Claude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "stale-key" }
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Fresh Claude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
"workspace": { "path": "/tmp/new-workspace" }
}),
None,
),
);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Claude, "new-provider")
.expect("switch provider should succeed");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read claude live settings");
assert_eq!(
live_after
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("fresh-key"),
"live settings.json should reflect new provider auth"
);
let guard = state
.config
.read()
.expect("read claude config after switch");
let manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let legacy_provider = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should receive backfilled live config"
);
}
#[test]
fn provider_service_switch_missing_provider_returns_error() {
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = ProviderService::switch(&state, AppType::Claude, "missing")
.expect_err("switching missing provider should fail");
match err {
AppError::Localized { key, .. } => assert_eq!(key, "provider.not_found"),
other => panic!("expected Localized error for provider not found, got {other:?}"),
}
}
#[test]
fn provider_service_switch_codex_missing_auth_returns_error() {
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"invalid".to_string(),
Provider::with_id(
"invalid".to_string(),
"Broken Codex".to_string(),
json!({
"config": "[mcp_servers.test]\ncommand = \"noop\""
}),
None,
),
);
}
let state = AppState {
config: RwLock::new(config),
};
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
.expect_err("switching should fail without auth");
match err {
AppError::Config(msg) => assert!(
msg.contains("auth"),
"expected auth related message, got {msg}"
),
other => panic!("expected config error, got {other:?}"),
}
}
#[test]
fn provider_service_delete_codex_removes_provider_and_files() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "keep".to_string();
manager.providers.insert(
"keep".to_string(),
Provider::with_id(
"keep".to_string(),
"Keep".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "keep-key"},
"config": ""
}),
None,
),
);
manager.providers.insert(
"to-delete".to_string(),
Provider::with_id(
"to-delete".to_string(),
"DeleteCodex".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "delete-key"},
"config": ""
}),
None,
),
);
}
let sanitized = sanitize_provider_name("DeleteCodex");
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
std::fs::write(&auth_path, "{}").expect("seed auth file");
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
let app_state = AppState {
config: RwLock::new(config),
};
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed");
let locked = app_state.config.read().expect("lock config after delete");
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
assert!(
!manager.providers.contains_key("to-delete"),
"provider entry should be removed"
);
assert!(
!auth_path.exists() && !cfg_path.exists(),
"provider-specific files should be deleted"
);
}
#[test]
fn provider_service_delete_claude_removes_provider_files() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "keep".to_string();
manager.providers.insert(
"keep".to_string(),
Provider::with_id(
"keep".to_string(),
"Keep".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "keep-key" }
}),
None,
),
);
manager.providers.insert(
"delete".to_string(),
Provider::with_id(
"delete".to_string(),
"DeleteClaude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "delete-key" }
}),
None,
),
);
}
let sanitized = sanitize_provider_name("DeleteClaude");
let claude_dir = home.join(".claude");
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
let by_id = claude_dir.join("settings-delete.json");
std::fs::write(&by_name, "{}").expect("seed settings by name");
std::fs::write(&by_id, "{}").expect("seed settings by id");
let app_state = AppState {
config: RwLock::new(config),
};
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let locked = app_state.config.read().expect("lock config after delete");
let manager = locked
.get_manager(&AppType::Claude)
.expect("claude manager");
assert!(
!manager.providers.contains_key("delete"),
"claude provider should be removed"
);
assert!(
!by_name.exists() && !by_id.exists(),
"provider config files should be deleted"
);
}
#[test]
fn provider_service_delete_current_provider_returns_error() {
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "keep".to_string();
manager.providers.insert(
"keep".to_string(),
Provider::with_id(
"keep".to_string(),
"Keep".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "keep-key" }
}),
None,
),
);
}
let app_state = AppState {
config: RwLock::new(config),
};
let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
.expect_err("deleting current provider should fail");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商"),
"unexpected message: {zh}"
),
AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config error, got {other:?}"),
}
}

View File

@@ -1,47 +0,0 @@
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use cc_switch_lib::{update_settings, AppSettings};
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path {
static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let base = std::env::temp_dir().join("cc-switch-test-home");
if base.exists() {
let _ = std::fs::remove_dir_all(&base);
}
std::fs::create_dir_all(&base).expect("create test home");
std::env::set_var("HOME", &base);
#[cfg(windows)]
std::env::set_var("USERPROFILE", &base);
base
})
.as_path()
}
/// 清理测试目录中生成的配置文件与缓存。
pub fn reset_test_fs() {
let home = ensure_test_home();
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
let path = home.join(sub);
if path.exists() {
if let Err(err) = std::fs::remove_dir_all(&path) {
eprintln!("failed to clean {}: {}", path.display(), err);
}
}
}
let claude_json = home.join(".claude.json");
if claude_json.exists() {
let _ = std::fs::remove_file(&claude_json);
}
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
let _ = update_settings(AppSettings::default());
}
/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。
pub fn test_mutex() -> &'static Mutex<()> {
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
MUTEX.get_or_init(|| Mutex::new(()))
}

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