18 Commits

Author SHA1 Message Date
YoVinchen
81a6c08673 fix(skills): resolve third-party skills installation failure
- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories
2025-11-21 12:33:12 +08:00
Jason
74969ae968 fix(dialog): prevent dialogs from closing on overlay click
Add onInteractOutside handler to DialogContent to prevent accidental
dialog closure when users click on the overlay/backdrop. This prevents
data loss in forms and improves user experience across all 11 dialog
components in the application.

Users can still close dialogs using:
- Close button (X) in the top-right corner
- Cancel/Close buttons within the dialog
- ESC key
2025-11-20 23:29:57 +08:00
Jason
1f3627add3 fix(gemini): persist settings json edits 2025-11-20 20:23:22 +08:00
YoVinchen
14ee122b27 feat(settings): add Gemini configuration directory support (#255)
* style: apply code formatting across backend and frontend

Apply cargo fmt and prettier formatting to improve code readability.
No functional changes.

Changes:
- Rust: multi-line assertion formatting (gemini_config, env_checker)
- Rust: simplify chained method calls (provider)
- TypeScript: add trailing commas to function parameters (codexProviderPresets)

* feat(settings): add Gemini configuration directory support

Add custom configuration directory support for Gemini:
- Add geminiConfigDir field to Settings type
- Extend DirectorySettings component with Gemini input
- Update useDirectorySettings hook for Gemini directory management
- Add i18n translations for Gemini directory settings
2025-11-19 21:14:43 +08:00
wugeer
7aecba14fe docs: Add installation instructions for Arch Linux (#259) 2025-11-19 19:12:00 +08:00
Jason
99b5f881e8 docs: add v3.7.0 release documentation
- Update CHANGELOG.md with v3.7.0 entry covering six major features
- Add English release notes (docs/release-note-v3.7.0-en.md)
- Add Chinese release notes (docs/release-note-v3.7.0-zh.md)

Major features documented:
- Gemini CLI integration (third app support)
- MCP v3.7.0 unified architecture
- Claude Skills management system (~2,000 lines)
- Prompts management system (~1,300 lines)
- Deep link protocol (ccswitch://)
- Environment variable conflict detection
2025-11-19 12:39:45 +08:00
Jason
286bafbd67 test: simplify boolean assertions in import_export_sync tests
Replace verbose assert_eq!(value, true) with idiomatic assert!(value) for improved readability and adherence to Rust best practices
2025-11-19 11:45:07 +08:00
Jason
6046cf8767 update screen shots 2025-11-19 11:29:19 +08:00
YoVinchen
c88afa365f style: apply code formatting across backend and frontend (#252)
Apply cargo fmt and prettier formatting to improve code readability.
No functional changes.

Changes:
- Rust: multi-line assertion formatting (gemini_config, env_checker)
- Rust: simplify chained method calls (provider)
- TypeScript: add trailing commas to function parameters (codexProviderPresets)
2025-11-19 11:26:31 +08:00
farion1231
93fa5fe29a chore(release): bump version to v3.7.0 and rebrand to include Gemini CLI
- Update version from 3.6.2 to 3.7.0 across all config files
- Update project description to "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
- Update Chinese description to "Claude Code / Codex / Gemini CLI 全方位辅助工具"
- Sync version in package.json, Cargo.toml, tauri.conf.json
- Update branding in README.md, README_ZH.md and i18n locale files
2025-11-19 11:20:55 +08:00
farion1231
3d31ad64af feat: add DouBaoSeed provider and remove AnyRouter preset
- Add DouBaoSeed code preview provider preset with Volcengine ARK API configuration
- Remove AnyRouter provider from both Claude and Codex preset configurations
- Clean up trailing commas in codex provider presets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:04:44 +08:00
farion1231
bb0951552d feat: update Gemini default model and remove Google Official preset model
Updated default model from gemini-2.5-pro to gemini-3-pro-preview across:
- Provider presets (PackyCode, Custom)
- Form field placeholders
- Default configurations
- Test cases

Google Official preset now has empty env config, allowing users to choose
their own model or use application defaults, which is more appropriate for
OAuth-based authentication.

Changes:
- geminiProviderPresets.ts: updated model to gemini-3-pro-preview, removed model from Google Official
- GeminiFormFields.tsx: updated placeholder to gemini-3-pro-preview
- GeminiConfigSections.tsx: updated placeholder to gemini-3-pro-preview
- ProviderForm.tsx: updated default config to gemini-3-pro-preview
- gemini_config.rs: updated test examples to gemini-3-pro-preview
2025-11-19 10:53:33 +08:00
farion1231
00e3e6fa70 fix: read both .env and settings.json for Gemini live config
Previously, when editing a Gemini provider, only the .env file was read,
missing the settings.json file that contains MCP configuration and other
settings. This caused the config field in the edit form to be empty or
show outdated data.

This fix ensures both files are read and merged into the complete
structure { "env": {...}, "config": {...} }, matching the behavior
of Codex provider.

Fixed in:
- read_live_settings(): now reads both .env and settings.json
- import_default_config(): now reads both files when importing
2025-11-19 10:45:45 +08:00
farion1231
1ce007622e fix: resolve winreg API compatibility issue on Windows
- Update RegValue.to_string() usage to match current winreg API
  which returns String directly instead of Result
- Add conditional compilation for std::fs import (Unix only)
2025-11-19 10:06:52 +08:00
Jason
436f0e8e42 fix: sync Gemini form fields with env editor
The Gemini API key, base URL, and model inputs were not syncing with
the env editor below due to data source mismatch. The form was using
generic hooks (useApiKeyState, useBaseUrlState) that only updated
settingsConfig, while the env editor relied on geminiEnv from
useGeminiConfigState.

Changes:
- Use geminiApiKey/geminiBaseUrl from useGeminiConfigState instead of
  generic hooks
- Wrap handlers to maintain bidirectional sync between geminiEnv and
  settingsConfig
- Remove unused handleGeminiBaseUrlChange from useBaseUrlState to
  avoid naming conflicts

Now all Gemini form fields properly sync with the env editor in both
directions.
2025-11-19 09:28:53 +08:00
YoVinchen
3d69da5b66 feat: add model configuration support and fix Gemini deeplink bug (#251)
* feat(providers): add notes field for provider management

- Add notes field to Provider model (backend and frontend)
- Display notes with higher priority than URL in provider card
- Style notes as non-clickable text to differentiate from URLs
- Add notes input field in provider form
- Add i18n support (zh/en) for notes field

* chore: format code and clean up unused props

- Run cargo fmt on Rust backend code
- Format TypeScript imports and code style
- Remove unused appId prop from ProviderPresetSelector
- Clean up unused variables in tests
- Integrate notes field handling in provider dialogs

* feat(deeplink): implement ccswitch:// protocol for provider import

Add deep link support to enable one-click provider configuration import via ccswitch:// URLs.

Backend:
- Implement URL parsing and validation (src-tauri/src/deeplink.rs)
- Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs)
- Register ccswitch:// protocol in macOS Info.plist
- Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs)

Frontend:
- Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx)
- Add API wrapper (src/lib/api/deeplink.ts)
- Integrate event listeners in App.tsx

Configuration:
- Update Tauri config for deep link handling
- Add i18n support for Chinese and English
- Include test page for deep link validation (deeplink-test.html)

Files: 15 changed, 1312 insertions(+)

* chore(deeplink): integrate deep link handling into app lifecycle

Wire up deep link infrastructure with app initialization and event handling.

Backend Integration:
- Register deep link module and commands in mod.rs
- Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url)
- Handle deep links from single instance callback (Windows/Linux CLI)
- Handle deep links from macOS system events
- Add tauri-plugin-deep-link dependency (Cargo.toml)

Frontend Integration:
- Listen for deeplink-import/deeplink-error events in App.tsx
- Update DeepLinkImportDialog component imports

Configuration:
- Enable deep link plugin in tauri.conf.json
- Update Cargo.lock for new dependencies

Localization:
- Add Chinese translations for deep link UI (zh.json)
- Add English translations for deep link UI (en.json)

Files: 9 changed, 359 insertions(+), 18 deletions(-)

* refactor(deeplink): enhance Codex provider template generation

Align deep link import with UI preset generation logic by:
- Adding complete config.toml template matching frontend defaults
- Generating safe provider name from sanitized input
- Including model_provider, reasoning_effort, and wire_api settings
- Removing minimal template that only contained base_url
- Cleaning up deprecated test file deeplink-test.html

* style: fix clippy uninlined_format_args warnings

Apply clippy --fix to use inline format arguments in:
- src/mcp.rs (8 fixes)
- src/services/env_manager.rs (10 fixes)

* style: apply code formatting and cleanup

- Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts)
- Organize Rust imports and module order alphabetically
- Add newline at end of JSON files (en.json, zh.json)
- Update Cargo.lock for dependency changes

* feat: add model name configuration support for Codex and fix Gemini model handling

- Add visual model name input field for Codex providers
  - Add model name extraction and update utilities in providerConfigUtils
  - Implement model name state management in useCodexConfigState hook
  - Add conditional model field rendering in CodexFormFields (non-official only)
  - Integrate model name sync with TOML config in ProviderForm

- Fix Gemini deeplink model injection bug
  - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
  - Add test cases for Gemini model injection (with/without model)
  - All tests passing (9/9)

- Fix Gemini model field binding in edit mode
  - Add geminiModel state to useGeminiConfigState hook
  - Extract model value during initialization and reset
  - Sync model field with geminiEnv state to prevent data loss on submit
  - Fix missing model value display when editing Gemini providers

Changes:
  - 6 files changed, 245 insertions(+), 13 deletions(-)
2025-11-19 09:03:18 +08:00
Jason
0ae9ed5a17 fix: resolve JSON syntax error in i18n locale files
Fixed missing closing braces in the error object of both en.json and zh.json locale files that caused Vite parse errors. The envManager.error object was not properly closed, causing the subsequent skills object to be parsed incorrectly.
2025-11-19 08:52:26 +08:00
冰子
5ff689af82 Add Gemini environment variable detection (#250)
* feat(env): add environment variable conflict detection and management

实现了系统环境变量冲突检测与管理功能:

核心功能:
- 自动检测会影响 Claude/Codex 的系统环境变量
- 支持 Windows 注册表和 Unix shell 配置文件检测
- 提供可视化的环境变量冲突警告横幅
- 支持批量选择和删除环境变量
- 删除前自动备份,支持后续恢复

技术实现:
- Rust 后端: 跨平台环境变量检测与管理
- React 前端: EnvWarningBanner 组件交互界面
- 国际化支持: 中英文界面
- 类型安全: 完整的 TypeScript 类型定义

* refactor(env): remove unused imports and function

Remove unused HashMap and PathBuf imports, and delete the unused get_source_description function to clean up the code.

* feat: Add Gemini environment variable detection
2025-11-19 08:33:02 +08:00
64 changed files with 3602 additions and 263 deletions

View File

@@ -5,6 +5,222 @@ 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.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
@@ -73,6 +289,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 🏗️ 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)
@@ -80,17 +297,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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
@@ -98,6 +318,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
@@ -361,6 +582,7 @@ For users upgrading from v2.x (Electron version):
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes

View File

@@ -1,8 +1,8 @@
<div align="center">
# Claude Code & Codex Provider Switcher
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.6.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.7.0-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/)
@@ -43,7 +43,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.6.2 | [Full Changelog](CHANGELOG.md)
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md)
**Core Capabilities**
@@ -103,6 +103,14 @@ Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) pa
> **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.

View File

@@ -1,8 +1,8 @@
<div align="center">
# Claude Code & Codex 供应商管理器
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.6.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.7.0-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/)
@@ -43,7 +43,7 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
## 功能特性
### 当前版本v3.6.2 | [完整更新日志](CHANGELOG.md)
### 当前版本v3.7.0 | [完整更新日志](CHANGELOG.md)
**核心功能**
@@ -103,6 +103,14 @@ brew upgrade --cask cc-switch
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### ArchLinux 用户
**通过 paru 安装(推荐)**
```bash
paru -S cc-switch-bin
```
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 227 KiB

551
deplink.html Normal file
View File

@@ -0,0 +1,551 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CC Switch 深链接测试</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
color: white;
padding: 40px;
text-align: center;
}
.header h1 {
font-size: 32px;
margin-bottom: 10px;
}
.header p {
font-size: 16px;
opacity: 0.9;
}
.content {
padding: 40px;
}
.section {
margin-bottom: 40px;
}
.section h2 {
color: #2c3e50;
font-size: 24px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ecf0f1;
}
.link-card {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.link-card:hover {
border-color: #3498db;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.15);
transform: translateY(-2px);
}
.link-card h3 {
color: #2c3e50;
font-size: 20px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.link-card .description {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 16px;
line-height: 1.6;
}
.deep-link {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);
}
.deep-link:hover {
background: linear-gradient(135deg, #2980b9 0%, #1f6391 100%);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
transform: translateY(-1px);
}
.deep-link:active {
transform: translateY(0);
}
.info-box {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
}
.info-box h4 {
color: #856404;
margin-bottom: 8px;
font-size: 16px;
}
.info-box ul {
list-style: disc;
margin-left: 20px;
color: #856404;
font-size: 14px;
line-height: 1.8;
padding-left: 20px;
}
.generator-section {
background: #e8f4f8;
border-radius: 12px;
padding: 30px;
margin-top: 40px;
}
.generator-section h2 {
color: #2c3e50;
margin-bottom: 24px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #2c3e50;
font-weight: 500;
font-size: 14px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 2px solid #dee2e6;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.btn {
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
color: white;
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
}
.btn:hover {
background: linear-gradient(135deg, #229954 0%, #1e8449 100%);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.result-box {
background: white;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
border: 2px solid #3498db;
}
.result-box strong {
color: #2c3e50;
font-size: 16px;
}
.result-text {
background: #f8f9fa;
padding: 12px;
border-radius: 6px;
margin: 12px 0;
word-break: break-all;
font-family: monospace;
font-size: 13px;
color: #2c3e50;
border: 1px solid #dee2e6;
}
.btn-copy {
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
margin-right: 10px;
}
.btn-copy:hover {
background: linear-gradient(135deg, #8e44ad 0%, #7d3c98 100%);
}
.app-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.badge-claude {
background: #e8f4f8;
color: #3498db;
}
.badge-codex {
background: #fef5e7;
color: #f39c12;
}
.badge-gemini {
background: #fdeef4;
color: #e91e63;
}
@media (max-width: 768px) {
.header h1 {
font-size: 24px;
}
.content {
padding: 20px;
}
.generator-section {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔗 CC Switch 深链接测试</h1>
<p>点击下方链接测试深链接导入功能</p>
</div>
<div class="content">
<!-- Claude 示例 -->
<div class="section">
<h2>Claude Code 供应商</h2>
<div class="link-card">
<h3>
<span class="app-badge badge-claude">Claude</span>
Claude Official (官方)
</h3>
<p class="description">
导入 Claude 官方 API 配置。使用官方端点 api.anthropic.com默认模型 claude-haiku-4.1。
</p>
<a href="ccswitch://v1/import?resource=provider&app=claude&name=Claude%20Official&homepage=https%3A%2F%2Fclaude.ai&endpoint=https%3A%2F%2Fapi.anthropic.com%2Fv1&apiKey=sk-ant-test-demo-key-12345&model=claude-haiku-4.1&notes=%E5%AE%98%E6%96%B9%E6%B5%8B%E8%AF%95%E9%85%8D%E7%BD%AE"
class="deep-link">
📥 导入 Claude Official
</a>
</div>
<div class="link-card">
<h3>
<span class="app-badge badge-claude">Claude</span>
Claude 测试环境
</h3>
<p class="description">
公司内部测试环境配置示例。包含备注信息,方便区分不同环境。默认模型 claude-haiku-4.1。
</p>
<a href="ccswitch://v1/import?resource=provider&app=claude&name=%E5%85%AC%E5%8F%B8%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83&homepage=https%3A%2F%2Ftest.company.com&endpoint=https%3A%2F%2Fapi-test.company.com%2Fv1&apiKey=sk-ant-test-company-key&model=claude-haiku-4.1&notes=%E5%85%AC%E5%8F%B8%E5%86%85%E9%83%A8%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83%EF%BC%8C%E4%BB%85%E4%BE%9B%E5%BC%80%E5%8F%91%E4%BD%BF%E7%94%A8"
class="deep-link">
📥 导入测试环境
</a>
</div>
</div>
<!-- Codex 示例 -->
<div class="section">
<h2>Codex 供应商</h2>
<div class="link-card">
<h3>
<span class="app-badge badge-codex">Codex</span>
OpenAI Official (官方)
</h3>
<p class="description">
导入 OpenAI 官方 API 配置。使用官方端点 api.openai.com默认模型 gpt-5.1。
</p>
<a href="ccswitch://v1/import?resource=provider&app=codex&name=OpenAI%20Official&homepage=https%3A%2F%2Fopenai.com&endpoint=https%3A%2F%2Fapi.openai.com%2Fv1&apiKey=sk-test-demo-openai-key-67890&model=gpt-5.1&notes=OpenAI%20%E5%AE%98%E6%96%B9%E6%9C%8D%E5%8A%A1"
class="deep-link">
📥 导入 OpenAI Official
</a>
</div>
<div class="link-card">
<h3>
<span class="app-badge badge-codex">Codex</span>
Azure OpenAI
</h3>
<p class="description">
Azure 部署的 OpenAI 服务示例。适合企业用户使用 Azure 云服务。默认模型 gpt-5.1。
</p>
<a href="ccswitch://v1/import?resource=provider&app=codex&name=Azure%20OpenAI&homepage=https%3A%2F%2Fazure.microsoft.com%2Fopenai&endpoint=https%3A%2F%2Fyour-resource.openai.azure.com%2F&apiKey=azure-test-api-key-xyz&model=gpt-5.1&notes=Azure%20%E4%BC%81%E4%B8%9A%E7%89%88%E6%9C%AC"
class="deep-link">
📥 导入 Azure OpenAI
</a>
</div>
</div>
<!-- Gemini 示例 -->
<div class="section">
<h2>Gemini 供应商</h2>
<div class="link-card">
<h3>
<span class="app-badge badge-gemini">Gemini</span>
Google Gemini Official
</h3>
<p class="description">
导入 Google Gemini 官方 API 配置。默认模型 gemini-3-pro-preview。
</p>
<a href="ccswitch://v1/import?resource=provider&app=gemini&name=Google%20Gemini&homepage=https%3A%2F%2Fai.google.dev&endpoint=https%3A%2F%2Fgenerativelanguage.googleapis.com%2Fv1beta&apiKey=AIzaSy-test-demo-key-abc123&model=gemini-3-pro-preview&notes=Google%20AI%20%E5%AE%98%E6%96%B9%E6%9C%8D%E5%8A%A1"
class="deep-link">
📥 导入 Google Gemini
</a>
</div>
<div class="link-card">
<h3>
<span class="app-badge badge-gemini">Gemini</span>
Gemini 测试环境
</h3>
<p class="description">
公司内部 Gemini 测试环境配置示例。用于验证 Gemini 相关深链接导入流程请求地址为https://api-gemini-test.company.com/v1beta。默认模型 gemini-3-pro-preview。
</p>
<a href="ccswitch://v1/import?resource=provider&app=gemini&name=%E5%85%AC%E5%8F%B8%20Gemini%20%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83&homepage=https%3A%2F%2Fgemini-test.company.com&endpoint=https%3A%2F%2Fapi-gemini-test.company.com%2Fv1beta&apiKey=sk-gemini-test-company-key&model=gemini-3-pro-preview&notes=%E5%85%AC%E5%8F%B8%E5%86%85%E9%83%A8%20Gemini%20%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83%EF%BC%8C%E4%BB%85%E4%BE%9B%E5%BC%80%E5%8F%91%E4%BD%BF%E7%94%A8"
class="deep-link">
📥 导入 Gemini 测试环境
</a>
</div>
</div>
<!-- 注意事项 -->
<div class="info-box">
<h4>⚠️ 使用注意事项</h4>
<ul>
<li><strong>首次点击</strong>:浏览器会询问是否允许打开 CC Switch请点击"允许"或"打开"</li>
<li><strong>macOS 用户</strong>:可能需要在"系统设置" → "隐私与安全性"中允许应用</li>
<li><strong>测试 API Key</strong>:示例中的 API Key 仅用于测试格式,无法实际使用</li>
<li><strong>导入确认</strong>点击链接后会弹出确认对话框API Key 会被掩码显示前4位+****</li>
<li><strong>编辑配置</strong>:导入后可以在 CC Switch 中随时编辑或删除配置</li>
</ul>
</div>
<!-- 深链接生成器 -->
<div class="generator-section">
<h2>🛠️ 深链接生成器</h2>
<p style="color: #7f8c8d; margin-bottom: 24px;">填写下方表单,生成您自己的深链接</p>
<div class="form-group">
<label>应用类型 *</label>
<select id="app">
<option value="claude">Claude Code</option>
<option value="codex">Codex</option>
<option value="gemini">Gemini</option>
</select>
</div>
<div class="form-group">
<label>供应商名称 *</label>
<input type="text" id="name" placeholder="例如: Claude Official">
</div>
<div class="form-group">
<label>官网地址 *</label>
<input type="url" id="homepage" placeholder="https://example.com">
</div>
<div class="form-group">
<label>API 端点 *</label>
<input type="url" id="endpoint" placeholder="https://api.example.com/v1">
</div>
<div class="form-group">
<label>API Key *</label>
<input type="text" id="apiKey" placeholder="sk-xxxxx 或 AIzaSyXXXXX">
</div>
<div class="form-group">
<label>模型(可选)</label>
<input type="text" id="model" placeholder="例如: claude-haiku-4.1, gpt-5.1, gemini-3-pro-preview">
</div>
<div class="form-group">
<label>备注(可选)</label>
<textarea id="notes" rows="2" placeholder="例如: 公司专用账号"></textarea>
</div>
<button class="btn" onclick="generateLink()">🚀 生成深链接</button>
<div id="result" style="display: none;">
<div class="result-box">
<strong>✅ 生成的深链接:</strong>
<div class="result-text" id="linkText"></div>
<button class="btn btn-copy" onclick="copyLink()">📋 复制链接</button>
<a id="testLink" class="deep-link" style="text-decoration: none;">
🧪 测试链接
</a>
</div>
</div>
</div>
</div>
</div>
<script>
function generateLink() {
const app = document.getElementById('app').value;
const name = document.getElementById('name').value.trim();
const homepage = document.getElementById('homepage').value.trim();
const endpoint = document.getElementById('endpoint').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
const model = document.getElementById('model').value.trim();
const notes = document.getElementById('notes').value.trim();
// 验证必填字段
if (!name || !homepage || !endpoint || !apiKey) {
alert('❌ 请填写所有必填字段(标记 * 的字段)!');
return;
}
// 验证 URL 格式
try {
new URL(homepage);
new URL(endpoint);
} catch (e) {
alert('❌ 请输入有效的 URL 格式(需包含 http:// 或 https://');
return;
}
// 构建参数
const params = new URLSearchParams({
resource: 'provider',
app: app,
name: name,
homepage: homepage,
endpoint: endpoint,
apiKey: apiKey
});
if (model) {
params.append('model', model);
}
if (notes) {
params.append('notes', notes);
}
const deepLink = `ccswitch://v1/import?${params.toString()}`;
// 显示结果
document.getElementById('linkText').textContent = deepLink;
document.getElementById('testLink').href = deepLink;
document.getElementById('result').style.display = 'block';
// 滚动到结果区域
document.getElementById('result').scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
function copyLink() {
const linkText = document.getElementById('linkText').textContent;
navigator.clipboard.writeText(linkText).then(() => {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '✅ 已复制!';
btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 2000);
}).catch(err => {
console.error('复制失败:', err);
alert('❌ 复制失败,请手动复制链接');
});
}
// 阻止表单默认提交行为
document.addEventListener('DOMContentLoaded', function() {
const inputs = document.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault();
generateLink();
}
});
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,439 @@
# 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

@@ -0,0 +1,435 @@
# 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,7 +1,7 @@
{
"name": "cc-switch",
"version": "3.6.2",
"description": "Claude Code & Codex 供应商切换工具",
"version": "3.7.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",

113
src-tauri/Cargo.lock generated
View File

@@ -595,7 +595,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.6.2"
version = "3.7.0"
dependencies = [
"anyhow",
"chrono",
@@ -613,6 +613,7 @@ dependencies = [
"serial_test",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
@@ -625,6 +626,7 @@ dependencies = [
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"url",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -711,6 +713,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -821,6 +843,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -1057,6 +1085,15 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1745,6 +1782,12 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -1830,6 +1873,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2901,6 +2950,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3756,6 +3815,16 @@ dependencies = [
"cc",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.38.0"
@@ -4519,6 +4588,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",
@@ -4634,6 +4704,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.0"
@@ -4988,6 +5079,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -5917,6 +6017,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View File

@@ -1,7 +1,7 @@
[package]
name = "cc-switch"
version = "3.6.2"
description = "Claude Code & Codex 供应商配置管理工具"
version = "3.7.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/farion1231/cc-switch"
@@ -26,13 +26,14 @@ 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"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
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"
@@ -46,6 +47,7 @@ anyhow = "1.0"
zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

19
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,19 @@
<?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

@@ -184,12 +184,12 @@ pub async fn get_common_config_snippet(
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {}", e))?;
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.get(&app).cloned())
}
@@ -204,12 +204,12 @@ pub async fn set_common_config_snippet(
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {}", e))?;
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
@@ -217,7 +217,7 @@ pub async fn set_common_config_snippet(
AppType::Claude | AppType::Gemini => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {}", e))?;
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
AppType::Codex => {
// TOML 格式暂不验证(或可使用 toml crate

View File

@@ -0,0 +1,29 @@
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())
}
/// 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,5 +1,7 @@
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};
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]

View File

@@ -1,6 +1,7 @@
#![allow(non_snake_case)]
mod config;
mod deeplink;
mod env;
mod import_export;
mod mcp;
@@ -12,6 +13,7 @@ mod settings;
pub mod skill;
pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use import_export::*;
pub use mcp::*;

View File

@@ -62,7 +62,7 @@ pub async fn install_skill(
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: None, // 安装时使用默认路径
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service

457
src-tauri/src/deeplink.rs Normal file
View File

@@ -0,0 +1,457 @@
/// 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>,
}
/// 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();
let homepage = params
.get("homepage")
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
.clone();
let endpoint = params
.get("endpoint")
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
.clone();
let api_key = params
.get("apiKey")
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
.clone();
// Validate URLs
validate_url(&homepage, "homepage")?;
validate_url(&endpoint, "endpoint")?;
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
Ok(DeepLinkImportRequest {
version,
resource,
app,
name,
homepage,
endpoint,
api_key,
model,
notes,
})
}
/// 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. Converts it to a Provider structure
/// 3. Delegates to ProviderService for actual import
pub fn import_provider_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Parse app type
let app_type = AppType::from_str(&request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
// Build provider configuration based on app type
let mut provider = build_provider_from_request(&app_type, &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 = 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 model if provided (use as default model)
if let Some(model) = &request.model {
env.insert("ANTHROPIC_MODEL".to_string(), json!(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,
};
Ok(provider)
}
#[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() {
let url = "ccswitch://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("Missing 'homepage' 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,
};
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,
};
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());
}
}

View File

@@ -236,6 +236,17 @@ pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
}
}
// 如果有 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(())
}
@@ -244,6 +255,9 @@ pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
/// 对于需要 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 官方),跳过验证
@@ -368,7 +382,7 @@ mod tests {
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-2.5-pro
GEMINI_MODEL=gemini-3-pro-preview
# Another comment
"#;
@@ -381,19 +395,25 @@ GEMINI_MODEL=gemini-2.5-pro
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-2.5-pro".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-2.5-pro".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-2.5-pro"));
assert!(content.contains("GEMINI_MODEL=gemini-3-pro-preview"));
}
#[test]
@@ -417,7 +437,7 @@ GEMINI_MODEL=gemini-2.5-pro
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-2.5-pro
GEMINI_MODEL=gemini-3-pro-preview
# Another comment
"#;
@@ -432,7 +452,10 @@ GEMINI_MODEL=gemini-2.5-pro
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-2.5-pro".to_string()));
assert_eq!(
map.get("GEMINI_MODEL"),
Some(&"gemini-3-pro-preview".to_string())
);
}
#[test]
@@ -598,7 +621,7 @@ KEY_WITH-DASH=value";
let settings = serde_json::json!({
"env": {
"GEMINI_API_KEY": "sk-test123",
"GEMINI_MODEL": "gemini-2.5-pro"
"GEMINI_MODEL": "gemini-3-pro-preview"
}
});
@@ -611,7 +634,7 @@ KEY_WITH-DASH=value";
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
let settings = serde_json::json!({
"env": {
"GEMINI_MODEL": "gemini-2.5-pro"
"GEMINI_MODEL": "gemini-3-pro-preview"
}
});

View File

@@ -5,6 +5,7 @@ mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod deeplink;
mod error;
mod gemini_config; // 新增
mod gemini_mcp;
@@ -22,6 +23,7 @@ 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,
@@ -36,6 +38,7 @@ pub use services::{
};
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use std::sync::Arc;
use tauri::{
@@ -283,6 +286,65 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
/// 统一处理 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");
}
}
}
Err(e) => {
log::error!("✗ Failed to parse deep link URL: {e}");
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}");
}
}
}
true
}
//
/// 内部切换供应商函数
@@ -348,7 +410,27 @@ 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| {
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
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
@@ -358,6 +440,8 @@ 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 {
@@ -473,7 +557,40 @@ pub fn run() {
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 menu = create_tray_menu(app.handle(), &app_state)?;
@@ -585,6 +702,9 @@ pub fn run() {
commands::save_file_dialog,
commands::open_file_dialog,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
commands::import_from_deeplink,
update_tray_menu,
// Environment variable management
commands::check_env_conflicts,
@@ -605,17 +725,74 @@ pub fn run() {
app.run(|app_handle, event| {
#[cfg(target_os = "macos")]
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
if let RunEvent::Reopen { .. } = event {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
{
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);
}
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
// 处理通过自定义 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();
}
}
}
}
_ => {}
}
}

View File

@@ -536,7 +536,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
if !json_arr.is_empty() {
Some(serde_json::Value::Array(json_arr))
} else {
log::debug!("跳过复杂数组字段 '{}' (TOML → JSON)", key);
log::debug!("跳过复杂数组字段 '{key}' (TOML → JSON)");
None
}
}
@@ -551,19 +551,19 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
if !json_obj.is_empty() {
Some(serde_json::Value::Object(json_obj))
} else {
log::debug!("跳过复杂对象字段 '{}' (TOML → JSON)", key);
log::debug!("跳过复杂对象字段 '{key}' (TOML → JSON)");
None
}
}
toml::Value::Datetime(_) => {
log::debug!("跳过日期时间字段 '{}' (TOML → JSON)", key);
log::debug!("跳过日期时间字段 '{key}' (TOML → JSON)");
None
}
};
if let Some(val) = json_val {
spec.insert(key.clone(), val);
log::debug!("导入扩展字段 '{}' = {:?}", key, toml_val);
log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
}
}
@@ -831,7 +831,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
} else if let Some(f) = n.as_f64() {
Some(toml_edit::value(f))
} else {
log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {}", n);
log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {n}");
None
}
}
@@ -1009,9 +1009,9 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
// 记录扩展字段的处理
if extended_fields.contains(&key.as_str()) {
log::debug!("已转换扩展字段 '{}' = {:?}", key, value);
log::debug!("已转换扩展字段 '{key}' = {value:?}");
} else {
log::info!("已转换自定义字段 '{}' = {:?}", key, value);
log::info!("已转换自定义字段 '{key}' = {value:?}");
}
}
}
@@ -1094,7 +1094,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{}'", id);
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}

View File

@@ -22,6 +22,9 @@ pub struct Provider {
#[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>,
@@ -43,6 +46,7 @@ impl Provider {
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
}
}

View File

@@ -229,42 +229,22 @@ impl ConfigService {
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::gemini_config::{
env_to_json, json_to_env, read_gemini_env, write_gemini_env_atomic,
};
use crate::gemini_config::{env_to_json, read_gemini_env};
let env_path = crate::gemini_config::get_gemini_env_path();
if let Some(parent) = env_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
ProviderService::write_gemini_live(provider)?;
// 转换 JSON 配置为 .env 格式
let env_map = json_to_env(&provider.settings_config)?;
// Google 官方OAuth: env 为空,写入空文件并设置安全标志后返回
if env_map.is_empty() {
write_gemini_env_atomic(&env_map)?;
ProviderService::ensure_google_oauth_security_flag(provider)?;
let live_after_env = read_gemini_env()?;
let live_after = env_to_json(&live_after_env);
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;
}
}
return Ok(());
}
// 非 OAuth按常规写入并在必要时设置 Packycode 安全标志
write_gemini_env_atomic(&env_map)?;
ProviderService::ensure_packycode_security_flag(provider)?;
// 读回实际写入的内容并更新到配置中
// 读回实际写入的内容并更新到配置中(包含 settings.json
let live_after_env = read_gemini_env()?;
let live_after = env_to_json(&live_after_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) {

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
#[cfg(not(target_os = "windows"))]
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -35,6 +36,7 @@ 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![],
}
}
@@ -48,14 +50,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
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)) {
if let Ok(val) = value.to_string() {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: val,
source_type: "system".to_string(),
source_path: "HKEY_CURRENT_USER\\Environment".to_string(),
});
}
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(),
});
}
}
}
@@ -66,14 +66,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
{
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
if let Ok(val) = value.to_string() {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: val,
source_type: "system".to_string(),
source_path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment".to_string(),
});
}
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(),
});
}
}
}
@@ -123,7 +121,9 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let trimmed = line.trim();
// Match patterns like: export VAR=value or VAR=value
if trimmed.starts_with("export ") || (!trimmed.starts_with('#') && trimmed.contains('=')) {
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('=') {
@@ -134,7 +134,10 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
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(),
var_value: var_value
.trim_matches('"')
.trim_matches('\'')
.to_string(),
source_type: "file".to_string(),
source_path: format!("{}:{}", file_path, line_num + 1),
});
@@ -156,6 +159,10 @@ mod tests {
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

@@ -43,11 +43,11 @@ pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String
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))?;
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-{}.json", timestamp));
let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json"));
// Create backup data
let backup_info = BackupInfo {
@@ -58,9 +58,9 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {
// Write backup file
let json = serde_json::to_string_pretty(&backup_info)
.map_err(|e| format!("序列化备份数据失败: {}", e))?;
.map_err(|e| format!("序列化备份数据失败: {e}"))?;
fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {}", e))?;
fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?;
Ok(backup_info)
}
@@ -115,7 +115,7 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
// Read file content
let content = fs::read_to_string(file_path)
.map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?;
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Filter out the line containing the environment variable
let new_content: Vec<String> = content
@@ -137,7 +137,7 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
// Write back to file
fs::write(file_path, new_content.join("\n"))
.map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?;
.map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
@@ -152,11 +152,10 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
/// 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 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))?;
let backup_info: BackupInfo =
serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?;
// Restore each variable
for conflict in &backup_info.conflicts {
@@ -190,7 +189,10 @@ fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
}
Ok(())
}
_ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)),
_ => Err(format!(
"无法恢复类型为 {} 的环境变量",
conflict.source_type
)),
}
}
@@ -208,19 +210,21 @@ fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
// Read file content
let mut content = fs::read_to_string(file_path)
.map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?;
.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))?;
fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
_ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)),
_ => Err(format!(
"无法恢复类型为 {} 的环境变量",
conflict.source_type
)),
}
}

View File

@@ -30,6 +30,7 @@ enum LiveSnapshot {
},
Gemini {
env: Option<HashMap<String, String>>, // 新增
config: Option<Value>, // 新增settings.json 内容
},
}
@@ -68,15 +69,30 @@ impl LiveSnapshot {
delete_file(&config_path)?;
}
}
LiveSnapshot::Gemini { env } => {
LiveSnapshot::Gemini { env, .. } => {
// 新增
use crate::gemini_config::{get_gemini_env_path, write_gemini_env_atomic};
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic,
};
let path = get_gemini_env_path();
if let Some(env_map) = env {
write_gemini_env_atomic(env_map)?;
} else if path.exists() {
delete_file(&path)?;
}
let settings_path = get_gemini_settings_path();
match self {
LiveSnapshot::Gemini {
config: Some(cfg), ..
} => {
write_json_file(&settings_path, cfg)?;
}
LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => {
delete_file(&settings_path)?;
}
_ => {}
}
}
}
Ok(())
@@ -612,7 +628,9 @@ impl ProviderService {
state.save()?;
}
AppType::Gemini => {
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let env_path = get_gemini_env_path();
if !env_path.exists() {
@@ -623,7 +641,18 @@ impl ProviderService {
));
}
let env_map = read_gemini_env()?;
let live_after = env_to_json(&env_map);
let mut live_after = env_to_json(&env_map);
let settings_path = get_gemini_settings_path();
let config_value = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), config_value);
}
{
let mut guard = state.config.write().map_err(AppError::from)?;
@@ -670,14 +699,22 @@ impl ProviderService {
}
AppType::Gemini => {
// 新增
use crate::gemini_config::{get_gemini_env_path, read_gemini_env};
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let path = get_gemini_env_path();
let env = if path.exists() {
Some(read_gemini_env()?)
} else {
None
};
Ok(LiveSnapshot::Gemini { env })
let settings_path = get_gemini_settings_path();
let config = if settings_path.exists() {
Some(read_json_file(&settings_path)?)
} else {
None
};
Ok(LiveSnapshot::Gemini { env, config })
}
}
}
@@ -847,19 +884,37 @@ impl ProviderService {
v
}
AppType::Gemini => {
// 新增
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let path = get_gemini_env_path();
if !path.exists() {
// 读取 .env 文件(环境变量)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.live.missing",
"Gemini 配置文件不存在",
"Gemini configuration file is missing",
));
}
let env_map = read_gemini_env()?;
env_to_json(&env_map)
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// 读取 settings.json 文件MCP 配置等)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// 返回完整结构:{ "env": {...}, "config": {...} }
json!({
"env": env_obj,
"config": config_obj
})
}
};
@@ -914,11 +969,13 @@ impl ProviderService {
read_json_file(&path)
}
AppType::Gemini => {
// 新增
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let path = get_gemini_env_path();
if !path.exists() {
// 读取 .env 文件(环境变量)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.env.missing",
"Gemini .env 文件不存在",
@@ -927,7 +984,22 @@ impl ProviderService {
}
let env_map = read_gemini_env()?;
Ok(env_to_json(&env_map))
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// 读取 settings.json 文件MCP 配置等)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// 返回完整结构:{ "env": {...}, "config": {...} }
Ok(json!({
"env": env_obj,
"config": config_obj
}))
}
}
}
@@ -1426,7 +1498,9 @@ impl ProviderService {
config: &mut MultiAppConfig,
next_provider: &str,
) -> Result<(), AppError> {
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let env_path = get_gemini_env_path();
if !env_path.exists() {
@@ -1442,7 +1516,18 @@ impl ProviderService {
}
let env_map = read_gemini_env()?;
let live = env_to_json(&env_map);
let mut live = env_to_json(&env_map);
let settings_path = get_gemini_settings_path();
let config_value = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
if let Some(obj) = live.as_object_mut() {
obj.insert("config".to_string(), config_value);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(current) = manager.providers.get_mut(&current_id) {
current.settings_config = live;
@@ -1460,36 +1545,71 @@ impl ProviderService {
Ok(())
}
fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{
json_to_env, validate_gemini_settings_strict, write_gemini_env_atomic,
get_gemini_settings_path, json_to_env, validate_gemini_settings_strict,
write_gemini_env_atomic,
};
// 一次性检测认证类型,避免重复检测
let auth_type = Self::detect_gemini_auth_type(provider);
let mut env_map = json_to_env(&provider.settings_config)?;
// 准备要写入 ~/.gemini/settings.json 的配置(缺省时保留现有文件内容)
let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config")
{
if config_value.is_null() {
Some(json!({}))
} else if config_value.is_object() {
Some(config_value.clone())
} else {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
} else {
None
};
if config_to_write.is_none() {
let settings_path = get_gemini_settings_path();
if settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
}
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google 官方使用 OAuth清空 env
let empty_env = std::collections::HashMap::new();
write_gemini_env_atomic(&empty_env)?;
Self::ensure_google_oauth_security_flag(provider)?;
env_map.clear();
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode => {
// PackyCode 供应商,使用 API Key切换时严格验证
validate_gemini_settings_strict(&provider.settings_config)?;
let env_map = json_to_env(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
Self::ensure_packycode_security_flag(provider)?;
}
GeminiAuthType::Generic => {
// 通用供应商,使用 API Key切换时严格验证
validate_gemini_settings_strict(&provider.settings_config)?;
let env_map = json_to_env(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
}
if let Some(config_value) = config_to_write {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, &config_value)?;
}
match auth_type {
GeminiAuthType::GoogleOfficial => Self::ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode => Self::ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
Ok(())
}

View File

@@ -32,6 +32,9 @@ pub struct Skill {
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
@@ -234,6 +237,7 @@ impl SkillService {
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),
@@ -312,6 +316,7 @@ impl SkillService {
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
}
@@ -442,12 +447,21 @@ impl SkillService {
.await
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
// 复制到安装目录
let source = temp_dir.join(&directory);
// 根据 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!("技能目录不存在"));
return Err(anyhow::anyhow!(
"技能目录不存在: {}",
source.display()
));
}
// 删除旧版本

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.6.2",
"version": "3.7.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -24,7 +24,11 @@
}
],
"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:"
"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": []
}
}
},
"bundle": {
@@ -42,9 +46,17 @@
"wix": {
"template": "wix/per-user-main.wxs"
}
},
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["ccswitch"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [

View File

@@ -0,0 +1,121 @@
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"
);
}

View File

@@ -498,8 +498,8 @@ url = "https://example.com"
.expect("unified servers should exist");
let echo = servers.get("echo_server").expect("echo server");
assert_eq!(
echo.apps.codex, true,
assert!(
echo.apps.codex,
"Codex app should be enabled for echo_server"
);
let server_spec = echo.server.as_object().expect("server spec");
@@ -512,8 +512,8 @@ url = "https://example.com"
);
let http = servers.get("http_server").expect("http server");
assert_eq!(
http.apps.codex, true,
assert!(
http.apps.codex,
"Codex app should be enabled for http_server"
);
let http_spec = http.server.as_object().expect("http spec");
@@ -577,10 +577,7 @@ command = "echo"
.expect("existing entry");
// 验证 Codex 应用已启用
assert_eq!(
entry.apps.codex, true,
"Codex app should be enabled after import"
);
assert!(entry.apps.codex, "Codex app should be enabled after import");
// 验证现有配置被保留server 不应被覆盖)
let spec = entry.server.as_object().expect("server spec");
@@ -702,8 +699,8 @@ fn import_from_claude_merges_into_config() {
.expect("entry exists");
// 验证 Claude 应用已启用
assert_eq!(
entry.apps.claude, true,
assert!(
entry.apps.claude,
"Claude app should be enabled after import"
);

View File

@@ -26,6 +26,7 @@ import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -100,7 +101,10 @@ function App() {
setShowEnvBanner(true);
}
} catch (error) {
console.error("[App] Failed to check environment conflicts on startup:", error);
console.error(
"[App] Failed to check environment conflicts on startup:",
error,
);
}
};
@@ -117,17 +121,20 @@ function App() {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`)
prev.map((c) => `${c.varName}:${c.sourcePath}`),
);
const newConflicts = conflicts.filter(
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`)
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
);
return [...prev, ...newConflicts];
});
setShowEnvBanner(true);
}
} catch (error) {
console.error("[App] Failed to check environment conflicts on app switch:", error);
console.error(
"[App] Failed to check environment conflicts on app switch:",
error,
);
}
};
@@ -239,7 +246,10 @@ function App() {
setShowEnvBanner(false);
}
} catch (error) {
console.error("[App] Failed to re-check conflicts after deletion:", error);
console.error(
"[App] Failed to re-check conflicts after deletion:",
error,
);
}
}}
/>
@@ -402,6 +412,7 @@ function App() {
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
</DialogContent>
</Dialog>
<DeepLinkImportDialog />
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { useState, useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
interface DeeplinkError {
url: string;
error: string;
}
export function DeepLinkImportDialog() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Listen for deep link import events
const unlistenImport = listen<DeepLinkImportRequest>(
"deeplink-import",
(event) => {
console.log("Deep link import event received:", event.payload);
setRequest(event.payload);
setIsOpen(true);
},
);
// Listen for deep link error events
const unlistenError = listen<DeeplinkError>("deeplink-error", (event) => {
console.error("Deep link error:", event.payload);
toast.error(t("deeplink.parseError"), {
description: event.payload.error,
});
});
return () => {
unlistenImport.then((fn) => fn());
unlistenError.then((fn) => fn());
};
}, [t]);
const handleImport = async () => {
if (!request) return;
setIsImporting(true);
try {
await deeplinkApi.importFromDeeplink(request);
// Invalidate provider queries to refresh the list
await queryClient.invalidateQueries({
queryKey: ["providers", request.app],
});
toast.success(t("deeplink.importSuccess"), {
description: t("deeplink.importSuccessDescription", {
name: request.name,
}),
});
setIsOpen(false);
setRequest(null);
} catch (error) {
console.error("Failed to import provider from deep link:", error);
toast.error(t("deeplink.importError"), {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setIsImporting(false);
}
};
const handleCancel = () => {
setIsOpen(false);
setRequest(null);
};
if (!request) return null;
// Mask API key for display (show first 4 chars + ***)
const maskedApiKey =
request.apiKey.length > 4
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
: "****";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[500px]">
{/* 标题显式左对齐,避免默认居中样式影响 */}
<DialogHeader className="text-left sm:text-left">
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
<DialogDescription>
{t("deeplink.confirmImportDescription")}
</DialogDescription>
</DialogHeader>
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
<div className="space-y-4 px-8 py-4">
{/* App Type */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.app")}
</div>
<div className="col-span-2 text-sm font-medium capitalize">
{request.app}
</div>
</div>
{/* Provider Name */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.providerName")}
</div>
<div className="col-span-2 text-sm font-medium">{request.name}</div>
</div>
{/* Homepage */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.homepage")}
</div>
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
{request.homepage}
</div>
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all">
{request.endpoint}
</div>
</div>
{/* API Key (masked) */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.apiKey")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{maskedApiKey}
</div>
</div>
{/* Model (if present) */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
)}
{/* Notes (if present) */}
{request.notes && (
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.notes")}
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{request.notes}
</div>
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isImporting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -198,7 +198,8 @@ export function EnvWarningBanner({
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{t("env.field.source")}: {getSourceDescription(conflict)}
{t("env.field.source")}:{" "}
{getSourceDescription(conflict)}
</p>
</div>
</div>
@@ -247,7 +248,9 @@ export function EnvWarningBanner({
{t("env.confirm.title")}
</DialogTitle>
<DialogDescription className="space-y-2">
<p>{t("env.confirm.message", { count: selectedConflicts.size })}</p>
<p>
{t("env.confirm.message", { count: selectedConflicts.size })}
</p>
<p className="text-sm text-muted-foreground">
{t("env.confirm.backupNotice")}
</p>

View File

@@ -1,7 +1,14 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp, Wand2 } from "lucide-react";
import {
Save,
Plus,
AlertCircle,
ChevronDown,
ChevronUp,
Wand2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {

View File

@@ -80,7 +80,9 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
initialServer,
}) => {
const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio");
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">(
"stdio",
);
const [wizardTitle, setWizardTitle] = useState("");
// stdio 字段
const [wizardCommand, setWizardCommand] = useState("");

View File

@@ -76,10 +76,7 @@ export function useMcpValidation() {
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
return t("mcp.error.commandRequired");
}
if (
(typ === "http" || typ === "sse") &&
!(obj as any)?.url?.trim()
) {
if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) {
return t("mcp.wizard.urlRequired");
}
}

View File

@@ -45,6 +45,7 @@ export function AddProviderDialog({
// 构造基础提交数据
const providerData: Omit<Provider, "id"> = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
...(values.presetCategory ? { category: values.presetCategory } : {}),

View File

@@ -93,6 +93,7 @@ export function EditProviderDialog({
const updatedProvider: Provider = {
...provider,
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
...(values.presetCategory ? { category: values.presetCategory } : {}),
@@ -129,6 +130,7 @@ export function EditProviderDialog({
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,

View File

@@ -33,10 +33,17 @@ interface ProviderCardProps {
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
// 优先级 1: 备注
if (provider.notes?.trim()) {
return provider.notes.trim();
}
// 优先级 2: 官网地址
if (provider.websiteUrl) {
return provider.websiteUrl;
}
// 优先级 3: 从配置中提取请求地址
const config = provider.settingsConfig;
if (config && typeof config === "object") {
@@ -83,10 +90,24 @@ export function ProviderCard({
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
// 判断是否为可点击的 URL备注不可点击
const isClickableUrl = useMemo(() => {
// 如果有备注,则不可点击
if (provider.notes?.trim()) {
return false;
}
// 如果显示的是回退文本,也不可点击
if (displayUrl === fallbackUrlText) {
return false;
}
// 其他情况(官网地址或请求地址)可点击
return true;
}, [provider.notes, displayUrl, fallbackUrlText]);
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const handleOpenWebsite = () => {
if (!displayUrl || displayUrl === fallbackUrlText) {
if (!isClickableUrl) {
return;
}
onOpenWebsite(displayUrl);
@@ -174,8 +195,14 @@ export function ProviderCard({
<button
type="button"
onClick={handleOpenWebsite}
className="inline-flex items-center text-sm text-blue-500 transition-colors hover:underline dark:text-blue-400 max-w-[280px]"
className={cn(
"inline-flex items-center text-sm max-w-[280px]",
isClickableUrl
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
: "text-muted-foreground cursor-default",
)}
title={displayUrl}
disabled={!isClickableUrl}
>
<span className="truncate">{displayUrl}</span>
</button>

View File

@@ -46,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.notes")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View File

@@ -26,6 +26,11 @@ interface CodexFormFieldsProps {
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
// Model Name
shouldShowModelField?: boolean;
modelName?: string;
onModelNameChange?: (model: string) => void;
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
}
@@ -45,6 +50,9 @@ export function CodexFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
shouldShowModelField = true,
modelName = "",
onModelNameChange,
speedTestEndpoints,
}: CodexFormFieldsProps) {
const { t } = useTranslation();
@@ -85,6 +93,33 @@ export function CodexFormFields({
/>
)}
{/* Codex Model Name 输入框 */}
{shouldShowModelField && onModelNameChange && (
<div className="space-y-2">
<label
htmlFor="codexModelName"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
</label>
<input
id="codexModelName"
type="text"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
placeholder={t("codexConfig.modelNamePlaceholder", {
defaultValue: "例如: gpt-5-codex",
})}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.modelNameHint", {
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
})}
</p>
</div>
)}
{/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest

View File

@@ -61,7 +61,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
onBlur={onBlur}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-2.5-pro`}
GEMINI_MODEL=gemini-3-pro-preview`}
rows={6}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
autoComplete="off"

View File

@@ -127,7 +127,7 @@ export function GeminiFormFields({
id="gemini-model"
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="gemini-2.5-pro"
placeholder="gemini-3-pro-preview"
/>
</div>
)}

View File

@@ -53,7 +53,7 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
env: {
GOOGLE_GEMINI_BASE_URL: "",
GEMINI_API_KEY: "",
GEMINI_MODEL: "gemini-2.5-pro",
GEMINI_MODEL: "gemini-3-pro-preview",
},
},
null,
@@ -74,6 +74,7 @@ interface ProviderFormProps {
initialData?: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
@@ -138,6 +139,7 @@ export function ProviderForm({
() => ({
name: initialData?.name ?? "",
websiteUrl: initialData?.websiteUrl ?? "",
notes: initialData?.notes ?? "",
settingsConfig: initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig, null, 2)
: appId === "codex"
@@ -169,18 +171,16 @@ export function ProviderForm({
});
// 使用 Base URL hook (Claude, Codex, Gemini)
const { baseUrl, handleClaudeBaseUrlChange, handleGeminiBaseUrlChange } =
useBaseUrlState({
appType: appId,
category,
settingsConfig: form.watch("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: (config) =>
form.setValue("settingsConfig", config),
onCodexConfigChange: () => {
/* noop */
},
});
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
appType: appId,
category,
settingsConfig: form.watch("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
onCodexConfigChange: () => {
/* noop */
},
});
// 使用 Model hook主模型 + Haiku/Sonnet/Opus 默认模型)
const {
@@ -200,10 +200,12 @@ export function ProviderForm({
codexConfig,
codexApiKey,
codexBaseUrl,
codexModelName,
codexAuthError,
setCodexAuth,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelNameChange,
handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig,
} = useCodexConfigState({ initialData });
@@ -313,16 +315,55 @@ export function ProviderForm({
const {
geminiEnv,
geminiConfig,
geminiApiKey,
geminiBaseUrl,
geminiModel,
envError,
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
// 包装 Gemini handlers 以同步 settingsConfig
const handleGeminiApiKeyChange = useCallback(
(key: string) => {
originalHandleGeminiApiKeyChange(key);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_API_KEY = key.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[originalHandleGeminiApiKeyChange, form],
);
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
originalHandleGeminiBaseUrlChange(url);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[originalHandleGeminiBaseUrlChange, form],
);
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
const {
useCommonConfig: useGeminiCommonConfigFlag,
@@ -621,7 +662,6 @@ export function ProviderForm({
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
category={category}
appId={appId}
/>
)}
@@ -684,6 +724,9 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -696,31 +739,33 @@ export function ProviderForm({
form.watch("settingsConfig"),
isEditMode,
)}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
apiKey={geminiApiKey}
onApiKeyChange={handleGeminiApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowGeminiApiKeyLink}
websiteUrl={geminiWebsiteUrl}
isPartner={isGeminiPartner}
partnerPromotionKey={geminiPartnerPromotionKey}
shouldShowSpeedTest={shouldShowSpeedTest}
baseUrl={baseUrl}
baseUrl={geminiBaseUrl}
onBaseUrlChange={handleGeminiBaseUrlChange}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
model={
form.watch("settingsConfig")
? JSON.parse(form.watch("settingsConfig") || "{}")?.env
?.GEMINI_MODEL || ""
: ""
}
model={geminiModel}
onModelChange={(model) => {
// 同时更新 form.settingsConfig 和 geminiEnv
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
// 同步更新 geminiEnv确保提交时不丢失
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = model.trim();
const newEnv = envObjToString(envObj);
handleGeminiEnvChange(newEnv);
}}
speedTestEndpoints={speedTestEndpoints}
/>

View File

@@ -6,7 +6,6 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
import type { ProviderCategory } from "@/types";
import type { AppId } from "@/lib/api";
type PresetEntry = {
id: string;
@@ -20,7 +19,6 @@ interface ProviderPresetSelectorProps {
presetCategoryLabels: Record<string, string>;
onPresetChange: (value: string) => void;
category?: ProviderCategory; // 当前选中的分类
appId?: AppId;
}
export function ProviderPresetSelector({
@@ -30,7 +28,6 @@ export function ProviderPresetSelector({
presetCategoryLabels,
onPresetChange,
category,
appId,
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();

View File

@@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
import {
extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig,
extractCodexModelName,
setCodexModelName as setCodexModelNameInConfig,
} from "@/utils/providerConfigUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
@@ -20,9 +22,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [codexModelName, setCodexModelName] = useState("");
const [codexAuthError, setCodexAuthError] = useState("");
const isUpdatingCodexBaseUrlRef = useRef(false);
const isUpdatingCodexModelNameRef = useRef(false);
// 初始化 Codex 配置(编辑模式)
useEffect(() => {
@@ -47,6 +51,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(initialBaseUrl);
}
// 提取 Model Name
const initialModelName = extractCodexModelName(configStr);
if (initialModelName) {
setCodexModelName(initialModelName);
}
// 提取 API Key
try {
if (auth && typeof auth.OPENAI_API_KEY === "string") {
@@ -69,6 +79,17 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
}
}, [codexConfig, codexBaseUrl]);
// 与 TOML 配置保持模型名称同步
useEffect(() => {
if (isUpdatingCodexModelNameRef.current) {
return;
}
const extracted = extractCodexModelName(codexConfig) || "";
if (extracted !== codexModelName) {
setCodexModelName(extracted);
}
}, [codexConfig, codexModelName]);
// 获取 API Key从 auth JSON
const getCodexAuthApiKey = useCallback((authString: string): string => {
try {
@@ -157,7 +178,26 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
[setCodexConfig],
);
// 处理 config 变化(同步 Base URL
// 处理 Codex Model Name 变化
const handleCodexModelNameChange = useCallback(
(modelName: string) => {
const trimmed = modelName.trim();
setCodexModelName(trimmed);
if (!trimmed) {
return;
}
isUpdatingCodexModelNameRef.current = true;
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
setTimeout(() => {
isUpdatingCodexModelNameRef.current = false;
}, 0);
},
[setCodexConfig],
);
// 处理 config 变化(同步 Base URL 和 Model Name
const handleCodexConfigChange = useCallback(
(value: string) => {
// 归一化中文/全角/弯引号,避免 TOML 解析报错
@@ -170,8 +210,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(extracted);
}
}
if (!isUpdatingCodexModelNameRef.current) {
const extractedModel = extractCodexModelName(normalized) || "";
if (extractedModel !== codexModelName) {
setCodexModelName(extractedModel);
}
}
},
[setCodexConfig, codexBaseUrl],
[setCodexConfig, codexBaseUrl, codexModelName],
);
// 重置配置(用于预设切换)
@@ -186,6 +233,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(baseUrl);
}
const modelName = extractCodexModelName(config);
if (modelName) {
setCodexModelName(modelName);
} else {
setCodexModelName("");
}
// 提取 API Key
try {
if (auth && typeof auth.OPENAI_API_KEY === "string") {
@@ -205,11 +259,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
codexConfig,
codexApiKey,
codexBaseUrl,
codexModelName,
codexAuthError,
setCodexAuth,
setCodexConfig,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelNameChange,
handleCodexConfigChange,
resetCodexConfig,
getCodexAuthApiKey,

View File

@@ -17,6 +17,7 @@ export function useGeminiConfigState({
const [geminiConfig, setGeminiConfigState] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const [geminiModel, setGeminiModel] = useState("");
const [envError, setEnvError] = useState("");
const [configError, setConfigError] = useState("");
@@ -72,21 +73,25 @@ export function useGeminiConfigState({
const configObj = (config as any).config || {};
setGeminiConfigState(JSON.stringify(configObj, null, 2));
// 提取 API KeyBase URL
// 提取 API KeyBase URL 和 Model
if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY);
}
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
}
if (typeof env.GEMINI_MODEL === "string") {
setGeminiModel(env.GEMINI_MODEL);
}
}
}, [initialData, envObjToString]);
// 从 geminiEnv 中提取并同步 API KeyBase URL
// 从 geminiEnv 中提取并同步 API KeyBase URL 和 Model
useEffect(() => {
const envObj = envStringToObj(geminiEnv);
const extractedKey = envObj.GEMINI_API_KEY || "";
const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || "";
const extractedModel = envObj.GEMINI_MODEL || "";
if (extractedKey !== geminiApiKey) {
setGeminiApiKey(extractedKey);
@@ -94,7 +99,10 @@ export function useGeminiConfigState({
if (extractedBaseUrl !== geminiBaseUrl) {
setGeminiBaseUrl(extractedBaseUrl);
}
}, [geminiEnv, envStringToObj]);
if (extractedModel !== geminiModel) {
setGeminiModel(extractedModel);
}
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
// 验证 Gemini Config JSON
const validateGeminiConfig = useCallback((value: string): string => {
@@ -181,7 +189,7 @@ export function useGeminiConfigState({
setGeminiEnv(envString);
setGeminiConfig(configString);
// 提取 API KeyBase URL
// 提取 API KeyBase URL 和 Model
if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY);
} else {
@@ -193,6 +201,12 @@ export function useGeminiConfigState({
} else {
setGeminiBaseUrl("");
}
if (typeof env.GEMINI_MODEL === "string") {
setGeminiModel(env.GEMINI_MODEL);
} else {
setGeminiModel("");
}
},
[envObjToString, setGeminiEnv, setGeminiConfig],
);
@@ -202,6 +216,7 @@ export function useGeminiConfigState({
geminiConfig,
geminiApiKey,
geminiBaseUrl,
geminiModel,
envError,
configError,
setGeminiEnv,

View File

@@ -14,6 +14,7 @@ interface DirectorySettingsProps {
onResetAppConfig: () => Promise<void>;
claudeDir?: string;
codexDir?: string;
geminiDir?: string;
onDirectoryChange: (app: AppId, value?: string) => void;
onBrowseDirectory: (app: AppId) => Promise<void>;
onResetDirectory: (app: AppId) => Promise<void>;
@@ -27,6 +28,7 @@ export function DirectorySettings({
onResetAppConfig,
claudeDir,
codexDir,
geminiDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
@@ -104,6 +106,17 @@ export function DirectorySettings({
onBrowse={() => onBrowseDirectory("codex")}
onReset={() => onResetDirectory("codex")}
/>
<DirectoryInput
label={t("settings.geminiConfigDir")}
description={undefined}
value={geminiDir}
resolvedValue={resolvedDirs.gemini}
placeholder={t("settings.browsePlaceholderGemini")}
onChange={(val) => onDirectoryChange("gemini", val)}
onBrowse={() => onBrowseDirectory("gemini")}
onReset={() => onResetDirectory("gemini")}
/>
</section>
</>
);

View File

@@ -220,6 +220,7 @@ export function SettingsDialog({
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}

View File

@@ -59,6 +59,10 @@ const DialogContent = React.forwardRef<
zIndexMap[zIndex],
className,
)}
onInteractOutside={(e) => {
// 防止点击遮罩层关闭对话框
e.preventDefault();
}}
{...props}
>
{children}

View File

@@ -230,6 +230,23 @@ export const providerPresets: ProviderPreset[] = [
},
category: "cn_official",
},
{
name: "DouBaoSeed",
websiteUrl: "https://www.volcengine.com/product/doubao",
apiKeyUrl: "https://www.volcengine.com/product/doubao",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
ANTHROPIC_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-code-preview-latest",
},
},
category: "cn_official",
},
{
name: "BaiLing",
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
@@ -294,22 +311,4 @@ export const providerPresets: ProviderPreset[] = [
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
},
{
name: "AnyRouter",
websiteUrl: "https://anyrouter.top",
apiKeyUrl: "https://anyrouter.top/register?aff=PCel",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://anyrouter.top",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://q.quuvv.cn",
"https://pmpjfbhq.cn-nb1.rainapp.top",
"https://anyrouter.top",
],
category: "third_party",
},
];

View File

@@ -143,20 +143,4 @@ requires_openai_auth = true`,
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
},
{
name: "AnyRouter",
websiteUrl: "https://anyrouter.top",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"anyrouter",
"https://anyrouter.top/v1",
"gpt-5-codex",
),
endpointCandidates: [
"https://anyrouter.top/v1",
"https://q.quuvv.cn/v1",
"https://pmpjfbhq.cn-nb1.rainapp.top/v1",
],
},
];

View File

@@ -33,14 +33,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
websiteUrl: "https://ai.google.dev/",
apiKeyUrl: "https://aistudio.google.com/apikey",
settingsConfig: {
env: {
GEMINI_MODEL: "gemini-2.5-pro",
},
env: {},
},
description: "Google 官方 Gemini API (OAuth)",
category: "official",
partnerPromotionKey: "google-official",
model: "gemini-2.5-pro",
theme: {
icon: "gemini",
backgroundColor: "#4285F4",
@@ -54,11 +51,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.com",
GEMINI_MODEL: "gemini-2.5-pro",
GEMINI_MODEL: "gemini-3-pro-preview",
},
},
baseURL: "https://www.packyapi.com",
model: "gemini-2.5-pro",
model: "gemini-3-pro-preview",
description: "PackyCode",
category: "third_party",
isPartner: true,
@@ -74,10 +71,10 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "",
GEMINI_MODEL: "gemini-2.5-pro",
GEMINI_MODEL: "gemini-3-pro-preview",
},
},
model: "gemini-2.5-pro",
model: "gemini-3-pro-preview",
description: "自定义 Gemini API 端点",
category: "custom",
},

View File

@@ -5,12 +5,13 @@ import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
type DirectoryKey = "appConfig" | "claude" | "codex";
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini";
export interface ResolvedDirectories {
appConfig: string;
claude: string;
codex: string;
gemini: string;
}
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -37,7 +38,8 @@ const computeDefaultConfigDir = async (
): Promise<string | undefined> => {
try {
const home = await homeDir();
const folder = app === "claude" ? ".claude" : ".codex";
const folder =
app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini";
return await join(home, folder);
} catch (error) {
console.error(
@@ -64,7 +66,11 @@ export interface UseDirectorySettingsResult {
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
resetAllDirectories: (claudeDir?: string, codexDir?: string) => void;
resetAllDirectories: (
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
) => void;
}
/**
@@ -89,6 +95,7 @@ export function useDirectorySettings({
appConfig: "",
claude: "",
codex: "",
gemini: "",
});
const [isLoading, setIsLoading] = useState(true);
@@ -96,6 +103,7 @@ export function useDirectorySettings({
appConfig: "",
claude: "",
codex: "",
gemini: "",
});
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
@@ -110,16 +118,20 @@ export function useDirectorySettings({
overrideRaw,
claudeDir,
codexDir,
geminiDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
defaultGeminiDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.getConfigDir("gemini"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"),
]);
if (!active) return;
@@ -130,6 +142,7 @@ export function useDirectorySettings({
appConfig: defaultAppConfig ?? "",
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
gemini: defaultGeminiDir ?? "",
};
setAppConfigDir(normalizedOverride);
@@ -139,6 +152,7 @@ export function useDirectorySettings({
appConfig: normalizedOverride ?? defaultsRef.current.appConfig,
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
gemini: geminiDir || defaultsRef.current.gemini,
});
} catch (error) {
console.error(
@@ -167,7 +181,9 @@ export function useDirectorySettings({
onUpdateSettings(
key === "claude"
? { claudeConfigDir: sanitized }
: { codexConfigDir: sanitized },
: key === "codex"
? { codexConfigDir: sanitized }
: { geminiConfigDir: sanitized },
);
}
@@ -188,18 +204,24 @@ export function useDirectorySettings({
const updateDirectory = useCallback(
(app: AppId, value?: string) => {
updateDirectoryState(app === "claude" ? "claude" : "codex", value);
updateDirectoryState(
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini",
value,
);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
const key: DirectoryKey =
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
const currentValue =
key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
: (settings?.codexConfigDir ?? resolvedDirs.codex);
: key === "codex"
? (settings?.codexConfigDir ?? resolvedDirs.codex)
: (settings?.geminiConfigDir ?? resolvedDirs.gemini);
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
@@ -240,7 +262,8 @@ export function useDirectorySettings({
const resetDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
const key: DirectoryKey =
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
@@ -269,13 +292,14 @@ export function useDirectorySettings({
}, [updateDirectoryState]);
const resetAllDirectories = useCallback(
(claudeDir?: string, codexDir?: string) => {
(claudeDir?: string, codexDir?: string, geminiDir?: string) => {
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig:
initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,
claude: claudeDir ?? defaultsRef.current.claude,
codex: codexDir ?? defaultsRef.current.codex,
gemini: geminiDir ?? defaultsRef.current.gemini,
});
},
[],

View File

@@ -102,6 +102,7 @@ export function useSettings(): UseSettingsResult {
resetAllDirectories(
sanitizeDir(data?.claudeConfigDir),
sanitizeDir(data?.codexConfigDir),
sanitizeDir(data?.geminiConfigDir),
);
setRequiresRestart(false);
}, [
@@ -120,14 +121,17 @@ export function useSettings(): UseSettingsResult {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(settings.geminiConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const payload: Settings = {
...settings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: settings.language,
};
@@ -170,10 +174,11 @@ export function useSettings(): UseSettingsResult {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
if (claudeDirChanged || codexDirChanged) {
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
console.warn(

View File

@@ -1,7 +1,7 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex Provider Switching Tool"
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
},
"common": {
"add": "Add",
@@ -84,6 +84,8 @@
"name": "Provider Name",
"namePlaceholder": "e.g., Claude Official",
"websiteUrl": "Website URL",
"notes": "Notes",
"notesPlaceholder": "e.g., Company dedicated account",
"configJson": "Config JSON",
"writeCommonConfig": "Write common config",
"editCommonConfigButton": "Edit common config",
@@ -177,8 +179,11 @@
"claudeConfigDirDescription": "Override Claude configuration directory (settings.json) and keep claude.json (MCP) alongside it.",
"codexConfigDir": "Codex Configuration Directory",
"codexConfigDirDescription": "Override Codex configuration directory.",
"geminiConfigDir": "Gemini Configuration Directory",
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
@@ -408,7 +413,6 @@
"errors": {
"usage_query_failed": "Usage query failed"
},
"presetSelector": {
"title": "Select Configuration Type",
"custom": "Custom",
@@ -645,6 +649,8 @@
},
"error": {
"noSelection": "Please select environment variables to delete"
}
},
"skills": {
"manage": "Skills",
"title": "Claude Skills Management",
@@ -688,5 +694,23 @@
"removeFailed": "Failed to remove",
"skillCount": "{{count}} skills detected"
}
},
"deeplink": {
"confirmImport": "Confirm Import Provider",
"confirmImportDescription": "The following configuration will be imported from deep link into CC Switch",
"app": "App Type",
"providerName": "Provider Name",
"homepage": "Homepage",
"endpoint": "API Endpoint",
"apiKey": "API Key",
"model": "Model",
"notes": "Notes",
"import": "Import",
"importing": "Importing...",
"warning": "Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.",
"parseError": "Failed to parse deep link",
"importSuccess": "Import successful",
"importSuccessDescription": "Provider \"{{name}}\" has been successfully imported",
"importError": "Failed to import"
}
}

View File

@@ -1,7 +1,7 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex 供应商切换工具"
"description": "Claude Code / Codex / Gemini CLI 全方位辅助工具"
},
"common": {
"add": "添加",
@@ -84,6 +84,8 @@
"name": "供应商名称",
"namePlaceholder": "例如Claude 官方",
"websiteUrl": "官网链接",
"notes": "备注",
"notesPlaceholder": "例如:公司专用账号",
"configJson": "配置 JSON",
"writeCommonConfig": "写入通用配置",
"editCommonConfigButton": "编辑通用配置",
@@ -177,8 +179,11 @@
"claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json),同时会在同级存放 Claude MCP 的 claude.json。",
"codexConfigDir": "Codex 配置目录",
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
"geminiConfigDir": "Gemini 配置目录",
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
@@ -408,7 +413,6 @@
"errors": {
"usage_query_failed": "用量查询失败"
},
"presetSelector": {
"title": "选择配置类型",
"custom": "自定义",
@@ -645,6 +649,8 @@
},
"error": {
"noSelection": "请选择要删除的环境变量"
}
},
"skills": {
"manage": "Skills",
"title": "Claude Skills 管理",
@@ -688,5 +694,23 @@
"removeFailed": "删除失败",
"skillCount": "识别到 {{count}} 个技能"
}
},
"deeplink": {
"confirmImport": "确认导入供应商配置",
"confirmImportDescription": "以下配置将导入到 CC Switch",
"app": "应用类型",
"providerName": "供应商名称",
"homepage": "官网地址",
"endpoint": "API 端点",
"apiKey": "API 密钥",
"model": "模型",
"notes": "备注",
"import": "导入",
"importing": "导入中...",
"warning": "请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。",
"parseError": "深链接解析失败",
"importSuccess": "导入成功",
"importSuccessDescription": "供应商 \"{{name}}\" 已成功导入",
"importError": "导入失败"
}
}

35
src/lib/api/deeplink.ts Normal file
View File

@@ -0,0 +1,35 @@
import { invoke } from "@tauri-apps/api/core";
export interface DeepLinkImportRequest {
version: string;
resource: string;
app: "claude" | "codex" | "gemini";
name: string;
homepage: string;
endpoint: string;
apiKey: string;
model?: string;
notes?: string;
}
export const deeplinkApi = {
/**
* Parse a deep link URL
* @param url The ccswitch:// URL to parse
* @returns Parsed deep link request
*/
parseDeeplink: async (url: string): Promise<DeepLinkImportRequest> => {
return invoke("parse_deeplink", { url });
},
/**
* Import a provider from a deep link request
* @param request The deep link import request
* @returns The ID of the imported provider
*/
importFromDeeplink: async (
request: DeepLinkImportRequest,
): Promise<string> => {
return invoke("import_from_deeplink", { request });
},
};

View File

@@ -10,6 +10,7 @@ export interface Skill {
repoOwner?: string;
repoName?: string;
repoBranch?: string;
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
}
export interface SkillRepo {

View File

@@ -38,6 +38,7 @@ function parseJsonError(error: unknown): string {
export const providerSchema = z.object({
name: z.string().min(1, "请填写供应商名称"),
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
notes: z.string().optional(),
settingsConfig: z
.string()
.min(1, "请填写配置内容")

View File

@@ -14,6 +14,8 @@ export interface Provider {
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
// 备注信息
notes?: string;
// 新增:是否为商业合作伙伴
isPartner?: boolean;
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
@@ -95,6 +97,8 @@ export interface Settings {
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// Claude 自定义端点列表

View File

@@ -35,7 +35,7 @@ export function parseSmartMcpJson(jsonText: string): {
}
// 如果是键值对片段("key": {...}),包装成完整对象
if (trimmed.startsWith('"') && !trimmed.startsWith('{')) {
if (trimmed.startsWith('"') && !trimmed.startsWith("{")) {
trimmed = `{${trimmed}}`;
}

View File

@@ -467,3 +467,66 @@ export const setCodexBaseUrl = (
: normalizedText;
return `${prefix}${replacementLine}\n`;
};
// ========== Codex model name utils ==========
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)
export const extractCodexModelName = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
if (!text) return undefined;
// 匹配 model = "xxx" 或 model = 'xxx'
const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m);
return m && m[2] ? m[2] : undefined;
} catch {
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 model 字段
export const setCodexModelName = (
configText: string,
modelName: string,
): string => {
const trimmed = modelName.trim();
if (!trimmed) {
return configText;
}
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const replacementLine = `model = "${trimmed}"`;
const pattern = /^model\s*=\s*["']([^"']+)["']/m;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
}
// 如果不存在 model 字段,尝试在 model_provider 之后插入
// 如果 model_provider 也不存在,则插入到开头
const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m;
const match = normalizedText.match(providerPattern);
if (match && match.index !== undefined) {
// 在 model_provider 行之后插入
const endOfLine = normalizedText.indexOf("\n", match.index);
if (endOfLine !== -1) {
return (
normalizedText.slice(0, endOfLine + 1) +
replacementLine +
"\n" +
normalizedText.slice(endOfLine + 1)
);
}
}
// 在文件开头插入
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
};

View File

@@ -220,7 +220,7 @@ describe("McpFormModal", () => {
});
it("缺少配置命令时阻止提交并提示错误", async () => {
const { onSave } = renderForm();
renderForm();
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
target: { value: "no-command" },
@@ -288,7 +288,7 @@ command = "run"
});
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
const { onSave } = renderForm({ defaultFormat: "toml" });
renderForm({ defaultFormat: "toml" });
// 填写 ID 字段
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {