Compare commits
18 Commits
feat/add-p
...
fix/third-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a6c08673 | ||
|
|
74969ae968 | ||
|
|
1f3627add3 | ||
|
|
14ee122b27 | ||
|
|
7aecba14fe | ||
|
|
99b5f881e8 | ||
|
|
286bafbd67 | ||
|
|
6046cf8767 | ||
|
|
c88afa365f | ||
|
|
93fa5fe29a | ||
|
|
3d31ad64af | ||
|
|
bb0951552d | ||
|
|
00e3e6fa70 | ||
|
|
1ce007622e | ||
|
|
436f0e8e42 | ||
|
|
3d69da5b66 | ||
|
|
0ae9ed5a17 | ||
|
|
5ff689af82 |
222
CHANGELOG.md
222
CHANGELOG.md
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -1,8 +1,8 @@
|
||||
<div align="center">
|
||||
|
||||
# Claude Code & Codex Provider Switcher
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](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.
|
||||
|
||||
14
README_ZH.md
14
README_ZH.md
@@ -1,8 +1,8 @@
|
||||
<div align="center">
|
||||
|
||||
# Claude Code & Codex 供应商管理器
|
||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](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
551
deplink.html
Normal 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¬es=%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¬es=%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¬es=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¬es=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¬es=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¬es=%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>
|
||||
|
||||
439
docs/release-note-v3.7.0-en.md
Normal file
439
docs/release-note-v3.7.0-en.md
Normal 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!**
|
||||
435
docs/release-note-v3.7.0-zh.md
Normal file
435
docs/release-note-v3.7.0-zh.md
Normal 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、Table(shadcn/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 行)
|
||||
- **前端**:PromptPanel(177)、PromptFormModal(160)、MarkdownEditor(159)
|
||||
- **Hooks**:usePromptActions(152 行)
|
||||
- **国际化**: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.52(Windows)
|
||||
|
||||
---
|
||||
|
||||
## 技术统计
|
||||
|
||||
```
|
||||
总体变更:
|
||||
- 提交数: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.15(Catalina)+
|
||||
- **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`
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```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 预览**(暂定):
|
||||
|
||||
- 本地代理功能
|
||||
|
||||
敬请期待更多更新!
|
||||
@@ -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
113
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
19
src-tauri/Info.plist
Normal 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
src-tauri/src/commands/deeplink.rs
Normal file
29
src-tauri/src/commands/deeplink.rs
Normal 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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
457
src-tauri/src/deeplink.rs
Normal 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¬es=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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t_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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
// 删除旧版本
|
||||
|
||||
@@ -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": [
|
||||
|
||||
121
src-tauri/tests/deeplink_import.rs
Normal file
121
src-tauri/tests/deeplink_import.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
204
src/components/DeepLinkImportDialog.tsx
Normal file
204
src/components/DeepLinkImportDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/env/EnvWarningBanner.tsx
vendored
7
src/components/env/EnvWarningBanner.tsx
vendored
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Key 和 Base URL
|
||||
// 提取 API Key、Base 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 Key 和 Base URL
|
||||
// 从 geminiEnv 中提取并同步 API Key、Base 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 Key 和 Base URL
|
||||
// 提取 API Key、Base 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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -220,6 +220,7 @@ export function SettingsDialog({
|
||||
onResetAppConfig={resetAppConfigDir}
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
onDirectoryChange={updateDirectory}
|
||||
onBrowseDirectory={browseDirectory}
|
||||
onResetDirectory={resetDirectory}
|
||||
|
||||
@@ -59,6 +59,10 @@ const DialogContent = React.forwardRef<
|
||||
zIndexMap[zIndex],
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
// 防止点击遮罩层关闭对话框
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
35
src/lib/api/deeplink.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export interface Skill {
|
||||
repoOwner?: string;
|
||||
repoName?: string;
|
||||
repoBranch?: string;
|
||||
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
|
||||
}
|
||||
|
||||
export interface SkillRepo {
|
||||
|
||||
@@ -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, "请填写配置内容")
|
||||
|
||||
@@ -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 自定义端点列表
|
||||
|
||||
@@ -35,7 +35,7 @@ export function parseSmartMcpJson(jsonText: string): {
|
||||
}
|
||||
|
||||
// 如果是键值对片段("key": {...}),包装成完整对象
|
||||
if (trimmed.startsWith('"') && !trimmed.startsWith('{')) {
|
||||
if (trimmed.startsWith('"') && !trimmed.startsWith("{")) {
|
||||
trimmed = `{${trimmed}}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}`;
|
||||
};
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
Reference in New Issue
Block a user