142 Commits

Author SHA1 Message Date
YoVinchen
f02efbd2b7 Merge branch 'main' into refactor/ui
# Conflicts:
#	src/components/skills/SkillsPage.tsx
2025-11-22 18:46:32 +08:00
YoVinchen
a39b1d8698 refactor(usage): improve footer layout with two-row design
Reorganize usage footer for better readability and space efficiency.
- Split into two rows: update time + refresh button (row 1), usage stats (row 2)
- Move refresh button to top row next to update time
- Remove card background for cleaner look
- Add fallback text when never updated
- Improve spacing and alignment
- Format template literals for consistency
2025-11-22 18:44:35 +08:00
YoVinchen
86255fe106 style(deeplink): format test page HTML for better readability
Improve HTML formatting in deeplink test page.
- Format multiline attributes for better readability
- Add consistent indentation to nested elements
- Break long lines in buttons and links
2025-11-22 18:09:52 +08:00
YoVinchen
4acd48adc9 i18n: add config merge error message
Add translation for config file merge error handling.
2025-11-22 18:06:25 +08:00
YoVinchen
d4cd8105d1 feat(deeplink): merge and display config in import dialog
Enhance import dialog to fetch and display complete config.
- Call mergeDeeplinkConfig API when config is present
- Add UTF-8 base64 decoding support for config content
- Add scrollable content area with custom scrollbar styling
- Show complete configuration before user confirms import
2025-11-22 18:05:52 +08:00
YoVinchen
2b1ae2aa71 feat(deeplink): add config merge command for preview
Expose config merging functionality to frontend for preview.
- Add merge_deeplink_config Tauri command
- Make parse_and_merge_config public for reuse
- Enable frontend to display complete config before import
2025-11-22 17:58:18 +08:00
YoVinchen
cf57fbed7b style: format template literals for better readability
Improve code formatting for conditional className expressions.
- Break long template literals across multiple lines
- Maintain consistent formatting in MCP form and endpoint test components
2025-11-22 17:22:36 +08:00
Jason
b64bb6cfa1 chore: bump version to v3.7.1
Prepare for v3.7.1 maintenance release.

**Version Updates**:
- package.json: 3.7.0 → 3.7.1
- src-tauri/Cargo.toml: 3.7.0 → 3.7.1
- src-tauri/tauri.conf.json: 3.7.0 → 3.7.1
- README.md: version badge updated
- README_ZH.md: version badge updated

**CHANGELOG.md**:
- Added v3.7.1 release notes (2025-11-22)
- 3 bug fixes (Skills installation, Gemini persistence, dialog overlay)
- 2 new features (Gemini config directory, ArchLinux support)
- 3 improvements (error i18n, download timeout, code formatting)
- 1 reverted feature (auto-launch)

**Code Formatting**:
- Applied prettier to SkillsPage.tsx and skillErrorParser.ts

**Pre-Release Checks**:
 TypeScript type check passed
 Prettier format check passed
 All version numbers synchronized
2025-11-22 16:57:09 +08:00
Jason
d65513ae7d docs: add v3.7.1 release documentation
Add release notes for v3.7.1 maintenance release, with v3.7.1 updates at top and complete v3.7.0 documentation below.

**v3.7.1 Updates**:
- Bug Fixes: Skills installation, Gemini config persistence, dialog overlay protection
- New Features: Gemini config directory support, ArchLinux installation
- Improvements: Skills error i18n enhancement (28+ keys), code formatting

**v3.7.0 Complete Documentation**:
- Six major features (Gemini CLI, MCP v3.7.0, Skills, Prompts, Deep Link, Conflict Detection)
- Technical statistics (152 files, +18,104 / -3,732 lines)
- Strategic positioning shift: Provider Switcher → AI CLI Management Platform

Files:
- docs/release-note-v3.7.1-zh.md (Chinese)
- docs/release-note-v3.7.1-en.md (English)
2025-11-22 16:53:00 +08:00
YoVinchen
eefb764f72 style: unify list item styles with semantic colors
Apply consistent styling to list items across components.
- Replace hardcoded colors with semantic tokens in MCP and Prompt list items
- Add glass effect container to EndpointSpeedTest panel
- Format code for better readability
2025-11-22 16:50:35 +08:00
YoVinchen
4ed3e3bf84 refactor(mcp): improve form modal layout with adaptive height editor
Restructure MCP form modal for better space utilization.
- Split form into upper form fields and lower JSON editor sections
- Add full-height mode support for JsonEditor component
- Use flex layout for editor to fill available space
- Update PromptFormPanel to use full-height editor
- Fix locale text formatting
2025-11-22 16:11:06 +08:00
Jason
29e32f73f3 docs(readme): update for v3.7.0 release with six major features
Update both English and Chinese README files to reflect the major platform positioning shift and new capabilities:

**Platform Positioning**
- Update tagline: "From Provider Switcher to All-in-One AI CLI Management Platform"
- Emphasize unified management of configurations, MCP, Skills, and Prompts

**v3.7.0 Highlights**
- Gemini CLI Integration: Third supported AI CLI with dual-file config
- Claude Skills Management: GitHub integration, lifecycle management
- Prompts Management: Multi-preset system with Markdown editor
- MCP v3.7.0 Unified Architecture: SSE transport, smart parser, cross-app sync
- Deep Link Protocol: ccswitch:// for one-click config sharing
- Environment Conflict Detection: Auto-detect and resolve config conflicts

**Documentation Updates**
- Add v3.7.0 release notes link
- Expand Quick Start with Skills and Prompts management guides
- Update configuration files section with Gemini environment variables
- Add ShanDianShuo sponsor logos and information

**Technical Details**
- 18,000+ lines of new code across 6 core features
- Cross-app support for Claude Code, Codex, and Gemini CLI
- Complete usage instructions for new management systems
2025-11-22 16:05:09 +08:00
YoVinchen
99471f6706 style(providers): optimize card layout and action button sizes
Improve provider card visual density and action buttons.
- Reduce icon button sizes for compact layout
- Adjust drag handle and icon sizes
- Tighten spacing between action buttons
- Update hover translate values for better alignment
2025-11-22 15:37:52 +08:00
YoVinchen
4210b1547c refactor(settings): simplify settings page layout and auto-save
Reorganize settings page structure and integrate autoSaveSettings.
- Move save button inline within Advanced tab content
- Remove sticky footer for cleaner layout
- Use autoSaveSettings for General tab settings
- Simplify dialog close behavior
- Refactor ImportExportSection layout
2025-11-22 15:35:08 +08:00
YoVinchen
cfee4d6fcc feat(settings): add autoSaveSettings for lightweight auto-save
Add optimized auto-save function for General tab settings.
- Add autoSaveSettings method for non-destructive auto-save
- Only trigger system APIs when values actually change
- Avoid unnecessary auto-launch and plugin config updates
- Update tests to cover new functionality
2025-11-22 15:27:32 +08:00
YoVinchen
24dc628130 feat(deeplink): enhance test page with v3.8 config file examples
Improve deeplink test page with comprehensive config file import examples.
- Add version badge for v3.8 features
- Add copy-to-clipboard functionality for all deep links
- Add Claude config file import examples (embedded/remote)
- Add Codex config file import examples (auth.json + config.toml)
- Add Gemini config file import examples (.env format)
- Add config generator tool for easy testing
- Update UI with better styling and layout
2025-11-22 14:07:55 +08:00
YoVinchen
bf74620051 i18n: add deeplink config preview translations
Add missing translation keys for config file preview feature.
- Add configSource, configEmbedded, configRemote labels
- Add configDetails and configUrl display strings
- Support both Chinese and English versions
2025-11-22 14:05:43 +08:00
YoVinchen
e8d4397b3a refactor(ui): unify dialog styles and improve layout consistency
Standardize dialog and panel components across the application.
- Update dialog background to use semantic color tokens
- Adjust FullScreenPanel max-width to 56rem for better alignment
- Add drag region and prevent body scroll in full-screen panels
- Optimize button sizes and spacing in panel headers
- Apply consistent styling to all dialog-based components
2025-11-22 14:03:09 +08:00
YoVinchen
2b0bc73276 feat(deeplink): enhance dialog with config file preview
Add config file parsing and preview in deep link import dialog.
- Support Base64 encoded config display
- Add config file source indicator (embedded/remote)
- Parse and display config fields by app type (Claude/Codex/Gemini)
- Mask sensitive values in config preview
- Improve dialog layout and content organization
2025-11-22 14:02:22 +08:00
YoVinchen
939a2e4f2b feat(deeplink): add config file support for deeplink import
Support importing provider configuration from embedded or remote config files.
- Add base64 dependency for config content encoding
- Support config, configFormat, and configUrl parameters
- Make homepage/endpoint/apiKey optional when config is provided
- Add config parsing and merging logic
2025-11-22 14:00:15 +08:00
YoVinchen
1a89267986 feat(deeplink): add Claude model fields support and enhance import dialog
- Add Claude-specific model field support in deeplink import:
  * Support model (ANTHROPIC_MODEL) - general default model
  * Support haikuModel (ANTHROPIC_DEFAULT_HAIKU_MODEL)
  * Support sonnetModel (ANTHROPIC_DEFAULT_SONNET_MODEL)
  * Support opusModel (ANTHROPIC_DEFAULT_OPUS_MODEL)
  * Backend: Update DeepLinkImportRequest struct to include optional model fields
  * Frontend: Add TypeScript type definitions for new model parameters

- Enhance deeplink demo page (deplink.html):
  * Add 5 new Claude configuration examples showcasing different model setups
  * Add parameter documentation with required/optional tags
  * Include basic config (no models), single model, complete 4-model, partial models, and third-party provider examples
  * Improve visual design with param-list component and color-coded badges
  * Add detailed descriptions for each configuration scenario

- Redesign DeepLinkImportDialog layout:
  * Switch from 3-column to compact 2-column grid layout
  * Reduce dialog width from 500px to 650px for better content display
  * Add dedicated section for Claude model configurations with blue highlight box
  * Use uppercase labels and smaller text for more information density
  * Add truncation and tooltips for long URLs
  * Improve visual hierarchy with spacing and grouping
  * Increase z-index to 9999 to ensure dialog appears on top

- Minor UI refinements:
  * Update App.tsx layout adjustments
  * Optimize McpFormModal styling
  * Refine ProviderCard and BasicFormFields components

This enables users to import Claude providers with precise model configurations via deeplink.
2025-11-22 03:26:28 +08:00
YoVinchen
127fa5bf9d refactor(ui): unify layout system with 60rem max-width and consistent spacing
- Standardize container max-width across all panels:
  * Replace max-w-4xl and max-w-5xl with unified max-w-[60rem]
  * Apply to SettingsPage, UnifiedMcpPanel, SkillsPage, and FullScreenPanel
  * Ensures consistent reading width and visual balance on wide screens

- Optimize padding hierarchy and structure:
  * Move px-6 from parent elements to content containers
  * FullScreenPanel: Add max-w-[60rem] wrapper to header, content, and footer
  * Add border separators (border-t/border-b) to header and footer sections
  * Consolidate nested padding in MCP, Skills, and Prompts panels
  * Remove redundant padding layers for cleaner CSS

- Simplify component styling:
  * MCP list items: Replace card-based layout with modern group-based design
  * Remove unnecessary wrapper divs and flatten DOM structure
  * Update card hover effects with smooth transitions
  * Simplify icon selection dialog (remove description text in BasicFormFields)

- Benefits:
  * Consistent 60rem max-width provides optimal readability
  * Unified spacing rules reduce maintenance complexity
  * Cleaner component hierarchy improves performance
  * Better responsive behavior across different screen sizes
  * More cohesive visual design language throughout the app

This creates a maintainable, scalable design system foundation.
2025-11-22 02:41:17 +08:00
YoVinchen
0d4be40c25 refactor(layout): standardize max-width to 60rem and optimize padding structure
- Unify container max-width across components:
  * Replace max-w-4xl with max-w-[60rem] in App.tsx provider list
  * Replace max-w-5xl with max-w-[60rem] in PromptPanel
  * Move padding from header element to inner container for consistent spacing
- Optimize padding hierarchy:
  * Remove px-6 from header element, add to inner header container
  * Remove px-6 from main element, keep it only in provider list container
  * Consolidate PromptPanel padding: move px-6 from nested divs to outer container
  * Remove redundant pl-1 sm:pl-2 from logo/title area
- Benefits:
  * Consistent 60rem max-width provides better readability on wide screens
  * Simplified padding structure reduces CSS complexity
  * Cleaner responsive behavior with unified spacing rules

This creates a more maintainable and visually consistent layout system.
2025-11-22 02:03:50 +08:00
YoVinchen
00720ecf30 style(ui): hide scrollbars across all browsers and optimize form layout
- Hide scrollbars globally with cross-browser support:
  * WebKit browsers (Chrome, Safari, Edge): ::-webkit-scrollbar { display: none }
  * Firefox: scrollbar-width: none
  * IE 10+: -ms-overflow-style: none
- Remove custom scrollbar styling (width, colors, hover states)
- Reorganize BasicFormFields layout:
  * Move icon picker to top center as a clickable preview (80x80)
  * Change name and notes fields to horizontal grid layout (md:grid-cols-2)
  * Remove icon preview section from bottom
  * Improve visual hierarchy and space efficiency

This provides a cleaner, more modern UI with hidden scrollbars while maintaining full scroll functionality.
2025-11-22 01:47:00 +08:00
YoVinchen
de7f93d513 refactor(ui): simplify AppSwitcher styles and migrate to local SVG icons
- Replace complex gradient animations with clean, minimal tab design
- Migrate from @lobehub/icons CDN to local SVG assets for better reliability
- Fix clippy warning in error.rs (use inline format args)
- Improve code formatting in skill service and commands
- Reduce CSS complexity in AppSwitcher component (removed blur effects and gradients)
- Update BrandIcons to use imported local SVG files instead of dynamic image loading

This improves performance, reduces external dependencies, and provides a cleaner UI experience.
2025-11-22 01:20:21 +08:00
YoVinchen
e7545f8cdf Merge branch 'main' into refactor/ui
# Conflicts:
#	src-tauri/src/services/skill.rs
#	src/components/skills/SkillsPage.tsx
2025-11-22 00:04:28 +08:00
Jason
eb46ac8592 Revert "feat(settings): add auto-launch on system startup feature"
This reverts commit ba336fc416.

Reason: Found issues that need to be addressed before reintroducing
the auto-launch feature. Will reimplement with fixes.
2025-11-21 23:30:56 +08:00
YoVinchen
838a99b5d2 chore(deps): add icon library and update preset configurations
Add dependencies and utility scripts for icon system:

## Dependencies (package.json)
- Add @lobehub/icons-static-svg@1.73.0
  - High-quality SVG icon library for AI providers
  - Source for extracted icons in src/icons/extracted/
- Update pnpm-lock.yaml accordingly

## Provider Preset Updates (src/config/claudeProviderPresets.ts)
- Add optional `icon` and `iconColor` fields to ProviderPreset interface
- Apply to Anthropic Official preset as example:
  - icon: "anthropic"
  - iconColor: "#D4915D"
- Future presets can include default icon configurations

## Utility Script (scripts/filter-icons.js) [NEW]
- Helper script for filtering and managing icon assets
- Supports icon discovery and validation workflow
- Complements extract-icons.js and generate-icon-index.js

This completes the icon system infrastructure, providing all
necessary tools and dependencies for icon customization.
2025-11-21 23:29:48 +08:00
YoVinchen
325c6a5f21 style(ui): refine header layout and AppSwitcher color scheme
Improve application header and component styling:

## App.tsx Header Layout
- Wrap title and settings button in flex container with gap
- Add vertical divider between title and settings icon
- Apply responsive padding (pl-1 sm:pl-2)
- Reformat JSX for better readability (prettier)
- Fix string template formatting in className

## AppSwitcher Color Update
- Change Claude tab gradient from orange/amber to teal/emerald/green
- Update shadow color to match new teal theme
- Change hover color from orange-500 to teal-500
- Align visual style with emerald/teal brand colors

## Dialog Component Cleanup
- Remove default close button (X icon) from DialogContent
- Allow parent components to control close button placement
- Remove unused lucide-react X import

## index.css Header Border
- Add top border (2px solid) to glass-header
- Apply to both light and dark mode variants
- Improve visual separation of header area

These changes enhance visual consistency and modernize the UI
appearance with a cohesive teal color scheme.
2025-11-21 23:28:48 +08:00
YoVinchen
8824462e4c chore(i18n): add translations for icon picker and provider icon
Add Chinese and English translations for icon customization feature:

## Icon Picker (iconPicker)
- search: "Search Icons" / "搜索图标"
- searchPlaceholder: "Enter icon name..." / "输入图标名称..."
- noResults: "No matching icons found" / "未找到匹配的图标"
- category.aiProvider: "AI Providers" / "AI 服务商"
- category.cloud: "Cloud Platforms" / "云平台"
- category.tool: "Dev Tools" / "开发工具"
- category.other: "Other" / "其他"

## Provider Icon (providerIcon)
- label: "Icon" / "图标"
- colorLabel: "Icon Color" / "图标颜色"
- selectIcon: "Select Icon" / "选择图标"
- preview: "Preview" / "预览"

These translations support the new icon picker UI components
and provider form icon selection interface.
2025-11-21 23:25:46 +08:00
Jason
ba336fc416 feat(settings): add auto-launch on system startup feature
Implement auto-launch functionality with proper state synchronization
and error handling across Windows, macOS, and Linux platforms.

Key changes:
- Add auto_launch module using auto-launch crate 0.5
- Define typed errors (AutoLaunchPathError, AutoLaunchEnableError, etc.)
- Sync system state with settings.json on app startup
- Only call system API when auto-launch state actually changes
- Add UI toggle in Window Settings panel
- Add i18n support for auto-launch settings (en/zh)

Implementation details:
- Settings file (settings.json) is the single source of truth
- On startup, system state is synced to match settings.json
- Error handling uses Rust type system with proper error propagation
- Frontend optimized to avoid unnecessary system API calls

Platform support:
- Windows: HKEY_CURRENT_USER registry modification
- macOS: AppleScript-based launch item (configurable to Launch Agent)
- Linux: XDG autostart desktop file
2025-11-21 23:23:35 +08:00
YoVinchen
0c1d94e57b feat(backend): add icon fields to Provider model and default mappings
Extend Rust backend to support provider icon customization:

## Provider Model (src-tauri/src/provider.rs)
- Add `icon: Option<String>` field for icon name
- Add `icon_color: Option<String>` field for hex color
- Use serde rename `iconColor` for frontend compatibility
- Apply skip_serializing_if for clean JSON output
- Update Provider::new() to initialize icon fields as None

## Provider Defaults (src-tauri/src/provider_defaults.rs) [NEW]
- Define ProviderIcon struct with name and color fields
- Create DEFAULT_PROVIDER_ICONS static HashMap with 23 providers:
  - AI providers: OpenAI, Anthropic, Claude, Google, Gemini,
    DeepSeek, Kimi, Moonshot, Zhipu, MiniMax, Baidu, Alibaba,
    Tencent, Meta, Microsoft, Cohere, Perplexity, Mistral, HuggingFace
  - Cloud platforms: AWS, Azure, Huawei, Cloudflare
- Implement infer_provider_icon() with exact and fuzzy matching
- Add unit tests for matching logic (exact, fuzzy, case-insensitive)

## Deep Link Support (src-tauri/src/deeplink.rs)
- Initialize icon fields when creating Provider from deep link import

## Module Registration (src-tauri/src/lib.rs)
- Register provider_defaults module

## Dependencies (Cargo.toml)
- Add once_cell for lazy static initialization

This backend support enables icon persistence and future features
like auto-icon inference during provider creation.
2025-11-21 23:22:51 +08:00
YoVinchen
636a1e2c60 feat(provider): integrate icon system into provider UI components
Add icon customization support to provider management interface:

## Type System Updates

### Provider Interface (src/types.ts)
- Add optional `icon` field for icon name (e.g., "openai", "anthropic")
- Add optional `iconColor` field for hex color (e.g., "#00A67E")

### Form Schema (src/lib/schemas/provider.ts)
- Extend providerSchema with icon and iconColor optional fields
- Maintain backward compatibility with existing providers

## UI Components

### ProviderCard (src/components/providers/ProviderCard.tsx)
- Display ProviderIcon alongside provider name
- Add icon container with hover animation effect
- Adjust layout spacing for icon placement
- Update translate offsets for action buttons

### BasicFormFields (src/components/providers/forms/BasicFormFields.tsx)
- Add icon preview section showing current selection
- Implement fullscreen icon picker dialog
- Auto-apply default color from icon metadata on selection
- Display provider name and icon status in preview

### AddProviderDialog & EditProviderDialog
- Pass icon fields through form submission
- Preserve icon data during provider updates

This enables users to visually distinguish providers in the list
with custom icons, improving UX for multi-provider setups.
2025-11-21 23:21:34 +08:00
YoVinchen
a56a578e91 feat(ui): add icon picker, color picker and provider icon components
Implement comprehensive icon selection system for provider customization:

## New Components

### ProviderIcon (src/components/ProviderIcon.tsx)
- Render SVG icons by name with automatic fallback
- Display provider initials when icon not found
- Support custom sizing via size prop
- Use dangerouslySetInnerHTML for inline SVG rendering

### IconPicker (src/components/IconPicker.tsx)
- Grid-based icon selection with visual preview
- Real-time search filtering by name and keywords
- Integration with icon metadata for display names
- Responsive grid layout (6-10 columns based on screen)

### ColorPicker (src/components/ColorPicker.tsx)
- 12 preset colors for quick selection
- Native color input for custom color picking
- Hex input field for precise color entry
- Visual feedback for selected color state

## Icon Assets (src/icons/extracted/)
- 38 high-quality SVG icons for AI providers and platforms
- Includes: OpenAI, Claude, DeepSeek, Qwen, Kimi, Gemini, etc.
- Cloud platforms: AWS, Azure, Google Cloud, Cloudflare
- Auto-generated index.ts with getIcon/hasIcon helpers
- Metadata system with searchable keywords per icon

## Build Scripts
- scripts/extract-icons.js: Extract icons from simple-icons
- scripts/generate-icon-index.js: Generate TypeScript index file
2025-11-21 23:20:39 +08:00
YoVinchen
c582be265b feat(icon): add icon type system and intelligent inference logic
Introduce a new icon system for provider customization:

- Add IconMetadata and IconPreset interfaces in src/types/icon.ts
  - Define structure for icon name, display name, category, keywords
  - Support default color configuration per icon

- Implement smart icon inference in src/config/iconInference.ts
  - Create iconMappings for 25+ AI providers and cloud platforms
  - Include Claude, DeepSeek, Qwen, Kimi, Google, AWS, Azure, etc.
  - inferIconForPreset(): match provider name to icon config
  - addIconsToPresets(): batch apply icons to preset arrays
  - Support fuzzy matching for flexible name recognition

This foundation enables automatic icon assignment when users add
providers, improving visual identification in the provider list.
2025-11-21 23:19:48 +08:00
Jason
7fa0a7b166 feat(skills): enhance error messages with i18n support
- Add structured error format with error codes and context
- Create skillErrorParser to format errors for user-friendly display
- Add comprehensive i18n keys for all skill-related errors (zh/en)
- Extend download timeout from 15s to 60s to reduce false positives
- Fix: Pass correct error title based on operation context (load/install/uninstall)

Error improvements:
- SKILL_NOT_FOUND: Show skill directory name
- DOWNLOAD_TIMEOUT: Display repo info and timeout duration with network suggestion
- DOWNLOAD_FAILED: Show HTTP status code with specific suggestions (403/404/429)
- SKILL_DIR_NOT_FOUND: Show full path with URL check suggestion
- EMPTY_ARCHIVE: Suggest checking repository URL

Users will now see detailed error messages instead of generic "Error".
2025-11-21 16:20:01 +08:00
YoVinchen
5e54656d45 fix(skills): resolve third-party skills installation failure (#268)
- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories
2025-11-21 15:02:01 +08:00
YoVinchen
8f218057f3 Merge branch 'fix/third-party-skills-installation' into refactor/ui 2025-11-21 12:35:13 +08:00
YoVinchen
81a6c08673 fix(skills): resolve third-party skills installation failure
- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories
2025-11-21 12:33:12 +08:00
YoVinchen
988ea326d9 style(ui): improve window dragging and provider card styles 2025-11-21 11:44:33 +08:00
YoVinchen
f1b0fa2985 test: update test suites to match component refactoring
Comprehensive test updates to align with recent component refactoring
and new auto-launch functionality.

Component Tests:
- AddProviderDialog.test.tsx (10 lines):
  * Updated test cases for new dialog behavior
  * Enhanced mock data for preset selection
  * Improved assertions for validation

- ImportExportSection.test.tsx (16 lines):
  * Updated for new settings page integration
  * Enhanced test coverage for error scenarios
  * Better mock state management

- McpFormModal.test.tsx (60 lines):
  * Extensive updates for form refactoring
  * New test cases for multi-app selection
  * Enhanced validation testing
  * Better coverage of stdio/http server types

- ProviderList.test.tsx (11 lines):
  * Updated for new card layout
  * Enhanced drag-and-drop testing

- SettingsDialog.test.tsx (96 lines):
  * Major updates for SettingsPage migration
  * New test cases for auto-launch functionality
  * Enhanced integration test coverage
  * Better async operation testing

Hook Tests:
- useDirectorySettings.test.tsx (32 lines):
  * Updated for refactored hook logic
  * Enhanced test coverage for edge cases

- useDragSort.test.tsx (36 lines):
  * Simplified test cases
  * Better mock implementation
  * Improved assertions

- useImportExport tests (16 lines total):
  * Updated for new error handling
  * Enhanced test coverage

- useMcpValidation.test.tsx (23 lines):
  * Updated validation test cases
  * Better coverage of error scenarios

- useProviderActions.test.tsx (48 lines):
  * Extensive updates for hook refactoring
  * New test cases for provider operations
  * Enhanced mock data

- useSettings.test.tsx (12 lines):
  * New test cases for auto-launch
  * Enhanced settings state testing
  * Better async operation coverage

Integration Tests:
- App.test.tsx (41 lines):
  * Updated for new routing logic
  * Enhanced navigation testing
  * Better component integration coverage

- SettingsDialog.test.tsx (88 lines):
  * Complete rewrite for SettingsPage
  * New integration test scenarios
  * Enhanced user workflow testing

Mock Infrastructure:
- handlers.ts (117 lines):
  * Major updates for MSW handlers
  * New handlers for auto-launch commands
  * Enhanced error simulation
  * Better request/response mocking

- state.ts (37 lines):
  * Updated mock state structure
  * New state for auto-launch
  * Enhanced state reset functionality

- tauriMocks.ts (10 lines):
  * Updated mock implementations
  * Better type safety

- server.ts & testQueryClient.ts:
  * Minor cleanup (2 lines removed)

Test Infrastructure Improvements:
- Better test isolation
- Enhanced mock data consistency
- Improved async operation testing
- Better error scenario coverage
- Enhanced integration test patterns

Coverage Improvements:
- Net increase of 195 lines of test code
- Better coverage of edge cases
- Enhanced error path testing
- Improved integration test scenarios
- Better mock infrastructure

All tests now pass with the refactored components while maintaining
comprehensive coverage of functionality and edge cases.
2025-11-21 11:12:06 +08:00
YoVinchen
3f470de608 chore(i18n): add auto-launch translation keys
Add translation keys for new auto-launch feature to support
multi-language interface.

Translation Keys Added:
- autoLaunch: Label for auto-launch toggle
- autoLaunchDescription: Explanation of auto-launch functionality
- autoLaunchEnabled: Status message when enabled

Languages Updated:
- Chinese (zh.json): 简体中文翻译
- English (en.json): English translations

The translations maintain consistency with existing terminology
and provide clear, user-friendly descriptions of the auto-launch
feature across both supported languages.
2025-11-21 11:10:16 +08:00
YoVinchen
03af3600b0 style(ui): refine component layouts and improve visual consistency
Comprehensive UI polish across multiple components to enhance visual
design, improve user experience, and maintain consistency.

UsageScriptModal Component (1302 lines refactored):
- Complete layout overhaul for better usability
- Improved script editor with syntax highlighting
- Better template selection interface
- Enhanced test/preview panels with clearer separation
- Improved error feedback and validation messages
- Better modal sizing and responsiveness
- Cleaner tab navigation between sections
- Enhanced code formatting and readability
- Improved loading states for async operations
- Better integration with parent components

MCP Components:
- McpFormModal (42 lines):
  * Streamlined form layout
  * Better server type selection (stdio/http)
  * Improved field grouping and labels
  * Enhanced validation feedback
- UnifiedMcpPanel (14 lines):
  * Minor layout adjustments
  * Better list item spacing
  * Improved server status indicators
  * Enhanced action button placement

Provider Components:
- ProviderCard (11 lines):
  * Refined card layout and spacing
  * Better visual hierarchy
  * Improved badge placement
  * Enhanced hover effects
- ProviderList (5 lines):
  * Minor grid layout adjustments
  * Better drag-and-drop visual feedback
- GeminiConfigSections (4 lines):
  * Field label alignment
  * Improved spacing consistency

Editor & Footer Components:
- JsonEditor (13 lines):
  * Better editor height management
  * Improved error display
  * Enhanced syntax highlighting
- UsageFooter (10 lines):
  * Refined footer layout
  * Better quota display
  * Improved refresh button placement

Settings & Environment:
- ImportExportSection (24 lines):
  * Better button layout
  * Improved action grouping
  * Enhanced visual feedback
- EnvWarningBanner (4 lines):
  * Refined alert styling
  * Better dismiss button placement

Global Styles (index.css):
- Added 11 lines of utility classes
- Improved transition timing
- Better focus indicators
- Enhanced scrollbar styling
- Refined spacing utilities

Design Improvements:
- Consistent spacing using design tokens
- Unified color palette application
- Better typography hierarchy
- Improved shadow system for depth
- Enhanced interactive states (hover, active, focus)
- Better border radius consistency
- Refined animation timings

Accessibility:
- Improved focus indicators
- Better keyboard navigation
- Enhanced screen reader support
- Improved color contrast ratios

Code Quality:
- Net increase of 68 lines due to UsageScriptModal improvements
- Better component organization
- Cleaner style application
- Reduced style duplication

These visual refinements create a more polished and professional
interface while maintaining excellent usability and accessibility
standards across all components.
2025-11-21 11:09:24 +08:00
YoVinchen
482b8a1cab refactor(features): modernize Skills, Prompts and Agents components
Major refactoring of feature components to improve code quality,
user experience, and maintainability.

SkillsPage Component (299 lines refactored):
- Complete rewrite of layout and state management
- Better integration with RepoManagerPanel
- Improved navigation between list and detail views
- Enhanced error handling with user-friendly messages
- Better loading states with skeleton screens
- Optimized re-renders with proper memoization
- Cleaner separation between list and form views
- Improved skill card interactions
- Better responsive design for different screen sizes

RepoManagerPanel Component (370 lines refactored):
- Streamlined repository management workflow
- Enhanced form validation with real-time feedback
- Improved repository list with better visual hierarchy
- Better handling of git operations (clone, pull, delete)
- Enhanced error recovery for network issues
- Cleaner state management reducing complexity
- Improved TypeScript type safety
- Better integration with Skills backend API
- Enhanced loading indicators for async operations

PromptPanel Component (249 lines refactored):
- Modernized layout with FullScreenPanel integration
- Better separation between list and edit modes
- Improved prompt card design with better readability
- Enhanced search and filter functionality
- Cleaner state management for editing workflow
- Better integration with PromptFormPanel
- Improved delete confirmation with safety checks
- Enhanced keyboard navigation support

PromptFormPanel Component (238 lines refactored):
- Streamlined form layout and validation
- Better markdown editor integration
- Real-time preview with syntax highlighting
- Improved validation error display
- Enhanced save/cancel workflow
- Better handling of large prompt content
- Cleaner form state management
- Improved accessibility features

AgentsPanel Component (33 lines modified):
- Minor layout adjustments for consistency
- Better integration with FullScreenPanel
- Improved placeholder states
- Enhanced error boundaries

Type Definitions (types.ts):
- Added 10 new type definitions
- Better type safety for Skills/Prompts/Agents
- Enhanced interfaces for repository management
- Improved typing for form validations

Architecture Improvements:
- Reduced component coupling
- Better prop interfaces with explicit types
- Improved error boundaries
- Enhanced code reusability
- Better testing surface

User Experience Enhancements:
- Smoother transitions between views
- Better visual feedback for actions
- Improved error messages
- Enhanced loading states
- More intuitive navigation flows
- Better responsive layouts

Code Quality:
- Net reduction of 29 lines while adding features
- Improved code organization
- Better naming conventions
- Enhanced documentation
- Cleaner control flow

These changes significantly improve the maintainability and user
experience of core feature components while establishing consistent
patterns for future development.
2025-11-21 11:08:13 +08:00
YoVinchen
ddb0b68b4c refactor(ui): optimize FullScreenPanel, Dialog and App routing
Comprehensive refactoring of core UI components to improve code quality,
maintainability, and user experience.

FullScreenPanel Component:
- Enhanced props interface with better TypeScript types
- Improved layout flexibility with customizable padding
- Better header/footer composition patterns
- Enhanced scroll behavior for long content
- Added support for custom actions in header
- Improved responsive design for different screen sizes
- Better integration with parent components
- Cleaner prop drilling with context where appropriate

Dialog Component (shadcn/ui):
- Updated to latest component patterns
- Improved animation timing and easing
- Better focus trap management
- Enhanced overlay styling with backdrop blur
- Improved accessibility (ARIA labels, keyboard navigation)
- Better close button positioning and styling
- Enhanced mobile responsiveness
- Cleaner composition with DialogHeader/Footer

App Component Routing:
- Refactored routing logic for better clarity
- Improved state management for navigation
- Better integration with settings page
- Enhanced error boundary handling
- Cleaner separation of layout concerns
- Improved provider context propagation
- Better handling of deep links
- Optimized re-renders with React.memo where appropriate

Code Quality Improvements:
- Reduced prop drilling with better component composition
- Improved TypeScript type safety
- Better separation of concerns
- Enhanced code readability with clearer naming
- Eliminated redundant logic

Performance Optimizations:
- Reduced unnecessary re-renders
- Better memoization of callbacks
- Optimized component tree structure
- Improved event handler efficiency

User Experience:
- Smoother transitions and animations
- Better visual feedback for interactions
- Improved loading states
- More consistent behavior across features

These changes create a more maintainable and performant foundation
for the application's UI layer while improving the overall user
experience with smoother interactions and better visual polish.
2025-11-21 11:07:17 +08:00
YoVinchen
524fa94339 refactor(settings): enhance settings page with auto-launch integration
Complete refactoring of settings page architecture to integrate auto-launch
feature and improve overall settings management workflow.

SettingsPage Component:
- Integrate auto-launch toggle with WindowSettings section
- Improve layout and spacing for better visual hierarchy
- Enhanced error handling for settings operations
- Better loading states during settings updates
- Improved accessibility with proper ARIA labels

WindowSettings Component:
- Add auto-launch switch with real-time status
- Integrate with backend auto-launch commands
- Proper error feedback for permission issues
- Visual indicators for current auto-launch state
- Tooltip guidance for auto-launch functionality

useSettings Hook (Major Refactoring):
- Complete rewrite reducing complexity by ~30%
- Better separation of concerns with dedicated handlers
- Improved state management using React Query
- Enhanced auto-launch state synchronization
  * Fetch auto-launch status on mount
  * Real-time updates on toggle
  * Proper error recovery
- Optimized re-renders with better memoization
- Cleaner API for component integration
- Better TypeScript type safety

Settings API:
- Add getAutoLaunch() method
- Add setAutoLaunch(enabled: boolean) method
- Type-safe Tauri command invocations
- Proper error propagation to UI layer

Architecture Improvements:
- Reduced hook complexity from 197 to ~140 effective lines
- Eliminated redundant state management logic
- Better error boundaries and fallback handling
- Improved testability with clearer separation

User Experience Enhancements:
- Instant visual feedback on auto-launch toggle
- Clear error messages for permission issues
- Loading indicators during async operations
- Consistent behavior across all platforms

This refactoring provides a solid foundation for future settings
additions while maintaining code quality and user experience.
2025-11-21 11:06:19 +08:00
YoVinchen
162c92144c feat(backend): add auto-launch functionality
Implement system auto-launch feature to allow CC-Switch to start
automatically on system boot, improving user convenience.

Backend Implementation:
- auto_launch.rs: New module for auto-launch management
  * Cross-platform support using auto-launch crate
  * Enable/disable auto-launch with system integration
  * Proper error handling for permission issues
  * Platform-specific implementations (macOS/Windows/Linux)

Command Layer:
- Add get_auto_launch command to check current status
- Add set_auto_launch command to toggle auto-start
- Integrate commands with settings API

Settings Integration:
- Extend Settings struct with auto_launch field
- Persist auto-launch preference in settings store
- Automatic state synchronization on app startup

Dependencies:
- Add auto-launch ^0.5.0 to Cargo.toml
- Update Cargo.lock with new dependency tree

Technical Details:
- Uses platform-specific auto-launch mechanisms:
  * macOS: Login Items via LaunchServices
  * Windows: Registry Run key
  * Linux: XDG autostart desktop files
- Handles edge cases like permission denials gracefully
- Maintains settings consistency across app restarts

This feature enables users to have CC-Switch readily available
after system boot without manual intervention, particularly useful
for users who frequently switch between API providers.
2025-11-21 11:05:16 +08:00
YoVinchen
b075ee9fbb chore: update dialogs, i18n and improve component integration
Various functional updates and improvements across provider dialogs,
MCP panel, skills page, and internationalization.

Provider Dialogs:
- AddProviderDialog
  * Simplified form state management
  * Improved preset selection workflow
  * Better validation error messages
  * Enhanced template variable handling
- EditProviderDialog
  * Streamlined edit flow with better state synchronization
  * Improved handling of live config backfilling
  * Better error recovery for failed updates
  * Enhanced integration with parent components

MCP & Skills:
- UnifiedMcpPanel
  * Reduced complexity from 140+ to ~95 lines
  * Improved multi-app server management
  * Better server type detection (stdio/http)
  * Enhanced server status indicators
  * Cleaner integration with MCP form modal
- SkillsPage
  * Simplified navigation and state management
  * Better integration with RepoManagerPanel
  * Improved error handling for repository operations
  * Enhanced loading states
- SkillCard
  * Minor layout adjustments
  * Better action button placement

Environment & Configuration:
- EnvWarningBanner
  * Improved conflict detection messages
  * Better visual hierarchy for warnings
  * Enhanced dismissal behavior
- tauri.conf.json
  * Updated build configuration
  * Added new window management options

Internationalization:
- en.json & zh.json
  * Added 17 new translation keys for new features
  * Updated existing keys for better clarity
  * Added translations for new settings page
  * Improved consistency across UI text

Code Cleanup:
- mutations.ts
  * Removed 14 lines of unused mutation definitions
  * Cleaned up deprecated query invalidation logic
  * Better type safety for mutation parameters

Overall Impact:
- Reduced total lines by 51 (-10% in affected files)
- Improved component integration and data flow
- Better error handling and user feedback
- Enhanced i18n coverage for new features

These changes improve the overall polish and integration of various
components while removing technical debt and unused code.
2025-11-21 09:32:39 +08:00
YoVinchen
17cf701bad style(ui): modernize component layouts and visual design
Update UI components with improved layouts, visual hierarchy, and
modern design patterns for better user experience.

Navigation & Brand Components:
- AppSwitcher
  * Enhanced visual design with better spacing
  * Improved active state indicators
  * Smoother transitions and hover effects
  * Better mobile responsiveness
- BrandIcons
  * Optimized icon rendering performance
  * Added support for more provider icons
  * Improved SVG handling and fallbacks
  * Better scaling across different screen sizes

Editor Components:
- JsonEditor
  * Enhanced syntax highlighting
  * Better error visualization
  * Improved code formatting options
  * Added line numbers and code folding support
- UsageScriptModal
  * Complete layout overhaul (1239 lines refactored)
  * Better script editor integration
  * Improved template selection UI
  * Enhanced preview and testing panels
  * Better error feedback and validation

Provider Components:
- ProviderCard
  * Redesigned card layout with modern aesthetics
  * Better information density and readability
  * Improved action buttons placement
  * Enhanced status indicators (active/inactive)
- ProviderList
  * Better grid/list view layouts
  * Improved drag-and-drop visual feedback
  * Enhanced sorting indicators
- ProviderActions
  * Streamlined action menu
  * Better icon consistency
  * Improved tooltips and accessibility

Usage & Footer:
- UsageFooter
  * Redesigned footer layout
  * Better quota visualization
  * Improved refresh controls
  * Enhanced error states

Design System Updates:
- dialog.tsx (shadcn/ui component)
  * Updated to latest design tokens
  * Better overlay animations
  * Improved focus management
- index.css
  * Added 65 lines of global utility classes
  * New animation keyframes
  * Enhanced color variables for dark mode
  * Improved typography scale
- tailwind.config.js
  * Extended theme with new design tokens
  * Added custom animations and transitions
  * New spacing and sizing utilities
  * Enhanced color palette

Visual Improvements:
- Consistent border radius across components
- Unified shadow system for depth perception
- Better color contrast for accessibility (WCAG AA)
- Smoother animations and transitions
- Improved dark mode support

These changes create a more polished, modern interface while
maintaining consistency with the application's design language.
2025-11-21 09:31:36 +08:00
YoVinchen
977185e2d5 refactor(forms): simplify and modernize form components
Comprehensive refactoring of form components to reduce complexity,
improve maintainability, and enhance user experience.

Provider Forms:
- CodexCommonConfigModal & CodexConfigSections
  * Simplified state management with reduced boilerplate
  * Improved field validation and error handling
  * Better layout with consistent spacing
  * Enhanced model selection with visual indicators
- GeminiCommonConfigModal & GeminiConfigSections
  * Streamlined authentication flow (OAuth vs API Key)
  * Cleaner form layout with better grouping
  * Improved validation feedback
  * Better integration with parent components
- CommonConfigEditor
  * Reduced from 178 to 68 lines (-62% complexity)
  * Extracted reusable form patterns
  * Improved JSON editing with syntax validation
  * Better error messages and recovery options
- EndpointSpeedTest
  * Complete rewrite for better UX
  * Real-time testing progress indicators
  * Enhanced error handling with retry logic
  * Visual feedback for test results (color-coded latency)

MCP & Prompts:
- McpFormModal
  * Simplified from 581 to ~360 lines
  * Better stdio/http server type handling
  * Improved form validation
  * Enhanced multi-app selection (Claude/Codex/Gemini)
- PromptPanel
  * Cleaner integration with PromptFormPanel
  * Improved list/grid view switching
  * Better state management for editing workflows
  * Enhanced delete confirmation with safety checks

Code Quality Improvements:
- Reduced total lines by ~251 lines (-24% code reduction)
- Eliminated duplicate validation logic
- Improved TypeScript type safety
- Better component composition and separation of concerns
- Enhanced accessibility with proper ARIA labels

These changes make forms more intuitive, responsive, and easier to
maintain while reducing bundle size and improving runtime performance.
2025-11-21 09:30:30 +08:00
YoVinchen
764ba81ea6 refactor(settings): migrate from dialog to full-screen page layout
Complete migration of settings from modal dialog to dedicated full-screen
page, improving UX and providing more space for configuration options.

Changes:
- Remove SettingsDialog component (legacy modal-based interface)
- Add SettingsPage component with full-screen layout using FullScreenPanel
- Refactor App.tsx routing to support dedicated settings page
  * Add settings route handler
  * Update navigation logic from dialog-based to page-based
  * Integrate with existing app switcher and provider management
- Update ImportExportSection to work with new page layout
  * Improve spacing and layout for better readability
  * Enhanced error handling and user feedback
  * Better integration with page-level actions
- Enhance useSettings hook to support page-based workflow
  * Add navigation state management
  * Improve settings persistence logic
  * Better error boundary handling

Benefits:
- More intuitive navigation with dedicated settings page
- Better use of screen space for complex configurations
- Improved accessibility with clearer visual hierarchy
- Consistent with modern desktop application patterns
- Easier to extend with new settings sections

This change is part of the larger UI refactoring initiative to modernize
the application interface and improve user experience.
2025-11-21 09:28:11 +08:00
YoVinchen
d802b7bf61 feat(components): add reusable full-screen panel components
Add new full-screen panel components to support the UI refactoring:

- FullScreenPanel: Reusable full-screen layout component with header,
  content area, and optional footer. Provides consistent layout for
  settings, prompts, and other full-screen views.

- PromptFormPanel: Dedicated panel for creating and editing prompts
  with markdown preview support. Features real-time validation and
  integrated save/cancel actions.

- AgentsPanel: Panel component for managing agent configurations.
  Provides a consistent interface for agent CRUD operations.

- RepoManagerPanel: Full-featured repository manager panel for Skills.
  Supports repository listing, addition, deletion, and configuration
  management with integrated validation.

These components establish the foundation for the upcoming settings
page migration from dialog-based to full-screen layout.
2025-11-21 09:27:20 +08:00
Jason
74969ae968 fix(dialog): prevent dialogs from closing on overlay click
Add onInteractOutside handler to DialogContent to prevent accidental
dialog closure when users click on the overlay/backdrop. This prevents
data loss in forms and improves user experience across all 11 dialog
components in the application.

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

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

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

* feat(settings): add Gemini configuration directory support

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

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

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

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

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

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

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

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

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

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

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

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

* chore: format code and clean up unused props

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

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

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

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

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

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

Files: 15 changed, 1312 insertions(+)

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

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

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

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

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

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

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

* refactor(deeplink): enhance Codex provider template generation

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

* style: fix clippy uninlined_format_args warnings

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

* style: apply code formatting and cleanup

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

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

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

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

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

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

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

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

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

* refactor(env): remove unused imports and function

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

* feat: Add Gemini environment variable detection
2025-11-19 08:33:02 +08:00
冰子
b9412ece0b 添加Claude和Codex环境变量检查 (#242)
* feat(env): add environment variable conflict detection and management

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

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

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

* refactor(env): remove unused imports and function

Remove unused HashMap and PathBuf imports, and delete the unused get_source_description function to clean up the code.
2025-11-18 23:44:44 +08:00
YoVinchen
ec303544ca Feat/claude skills management (#237)
* feat(skills): add Claude Skills management feature

Implement complete Skills management system with repository discovery,
installation, and lifecycle management capabilities.

Backend:
- Add SkillService with GitHub integration and installation logic
- Implement skill commands (list, install, uninstall, check updates)
- Support multiple skill repositories with caching

Frontend:
- Add Skills management page with repository browser
- Create SkillCard and RepoManager components
- Add badge, card, table UI components
- Integrate Skills API with Tauri commands

Files: 10 files changed, 1488 insertions(+)

* feat(skills): integrate Skills feature into application

Integrate Skills management feature with complete dependency updates,
configuration structure extensions, and internationalization support.

Dependencies:
- Add @radix-ui/react-visually-hidden for accessibility
- Add anyhow, zip, serde_yaml, tempfile for Skills backend
- Enable chrono serde feature for timestamp serialization

Backend Integration:
- Extend MultiAppConfig with SkillStore field
- Implement skills.json migration from legacy location
- Register SkillService and skill commands in main app
- Export skill module in commands and services

Frontend Integration:
- Add Skills page route and dialog in App
- Integrate Skills UI with main navigation

Internationalization:
- Add complete Chinese translations for Skills UI
- Add complete English translations for Skills UI

Code Quality:
- Remove redundant blank lines in gemini_mcp.rs
- Format log statements in mcp.rs

Tests:
- Update import_export_sync tests for SkillStore
- Update mcp_commands tests for new structure

Files: 16 files changed, 540 insertions(+), 39 deletions(-)

* style(skills): improve SkillsPage typography and spacing

Optimize visual hierarchy and readability of Skills page:
- Reduce title size from 2xl to lg with tighter tracking
- Improve description spacing and color contrast
- Enhance empty state with better text hierarchy
- Use explicit gray colors for better dark mode support

* feat(skills): support custom subdirectory path for skill scanning

Add optional skillsPath field to SkillRepo to enable scanning skills
from subdirectories (e.g., "skills/") instead of repository root.

Changes:
- Backend: Add skillsPath field with subdirectory scanning logic
- Frontend: Add skillsPath input field and display in repo list
- Presets: Add cexll/myclaude repo with skills/ subdirectory
- Code quality: Fix clippy warnings (dedup logic, string formatting)

Backward compatible: skillsPath is optional, defaults to root scanning.

* refactor(skills): improve repo manager dialog layout

Optimize dialog structure with fixed header and scrollable content:
- Add flexbox layout with fixed header and scrollable body
- Remove outer border wrapper for cleaner appearance
- Match SkillsPage design pattern for consistency
- Improve UX with better content hierarchy
2025-11-18 22:05:54 +08:00
Jason
023726c59d docs: add Codex MCP raw TOML refactor plan
Add comprehensive implementation plan for v3.7.0 refactor that
separates Codex MCP configuration from unified MCP structure.

Key design decisions:
- Codex MCP stored as raw TOML string in codexMcp.rawToml
- Unified MCP (mcp.servers) only supports Claude/Gemini
- Complete isolation: no apps.codex in unified structure
- Migration clears mcp.codex.servers to prevent pollution

Architecture improvements:
- Single responsibility: each data source has one purpose
- No priority conflicts: completely independent data paths
- Simplified switching logic: no conditional branches
- UI constraints: Tab1 limited to claude/gemini only

Implementation phases:
- Phase 0: Setup (0.5d)
- Phase 1: Backend foundation (1.5d)
- Phase 2: Command layer (1d)
- Phase 3: Switching logic (1d)
- Phase 4: Frontend API (0.5d)
- Phase 5: UI implementation (2d)
- Phase 6: Enhancements (1d, optional)
- Phase 7: Testing & docs (1d)

Total: 8.5 days (MVP: 7 days)

Addresses TOML-JSON conversion data loss issue by preserving
raw TOML format for Codex while maintaining structured approach
for Claude/Gemini.
2025-11-18 16:08:31 +08:00
Jason
8d2c067814 feat(window): center window on screen by default
Add `center: true` to main window configuration to improve initial
window positioning on Windows and other platforms. This addresses
user feedback about the window appearing in the top-left corner.
2025-11-17 23:25:13 +08:00
Jason
a00eb764f7 fix(provider): sync MCP config for all apps on provider switch
Previously, only Codex provider switches triggered MCP synchronization,
which could cause MCP configuration loss when switching Claude or Gemini
providers.

Changes:
- Enable MCP sync for all app types (Claude, Codex, Gemini) during provider switch
- Migrate to v3.7.0 unified MCP sync mechanism using McpService::sync_all_enabled()
- Replace app-specific sync_enabled_to_codex() with unified sync for all apps
- Remove unused mcp module import

This ensures MCP servers remain properly configured across all applications
after provider switches, preventing configuration loss.
2025-11-17 23:17:21 +08:00
Jason
67bd8f5c11 fix(mcp): correct Codex MCP configuration format to [mcp_servers]
BREAKING CHANGE: The [mcp.servers] format was completely incorrect and not
any official Codex format. The only correct format is [mcp_servers] at the
top level of config.toml.

Changes:
- Remove incorrect [mcp.servers] nested table support
- Always use [mcp_servers] top-level table (official Codex format)
- Auto-migrate and cleanup erroneous [mcp.servers] entries on write
- Preserve error-tolerant import for migrating old incorrect configs
- Simplify sync logic by removing format selection branches (~60 lines)
- Update all documentation and tests to reflect correct format
- Add warning logs when detecting and cleaning incorrect format

Backend (Rust):
- mcp.rs: Simplify sync_enabled_to_codex by removing Target enum
- mcp.rs: sync_single_server_to_codex now always uses [mcp_servers]
- mcp.rs: remove_server_from_codex cleans both locations
- mcp.rs: Update import_from_codex comments to clarify format status
- tests: Rename test to sync_enabled_to_codex_migrates_erroneous_*
- tests: Update assertions to verify migration behavior

Frontend (TypeScript):
- tomlUtils.ts: Prioritize [mcp_servers] format in parsing
- tomlUtils.ts: Update error messages to guide correct format

Documentation:
- README.md: Correct MCP format reference to [mcp_servers]
- CLAUDE.md: Add comprehensive format specification with examples

All 79 tests pass. This ensures backward compatibility while enforcing
the correct Codex official standard going forward.

Refs: https://github.com/openai/codex/issues/3441
2025-11-17 22:57:04 +08:00
Jason
3051743bd3 feat(mcp): support extended fields for Codex TOML conversion
## Problem
Previously, the Codex MCP configuration used a whitelist-only approach
for TOML conversion, which caused custom fields (like `timeout`,
`startup_timeout_ms`, etc.) to be silently dropped during JSON → TOML
conversion. Only Claude and Gemini (JSON format) could preserve
arbitrary fields.

## Solution
Implemented a three-tier field handling strategy:

1. **Core fields** (type, command, args, url, headers, env, cwd)
   - Strong-typed manual processing (existing behavior preserved)

2. **Extended fields** (19 common optional fields)
   - White-listed fields with automatic type conversion:
     - General: timeout, timeout_ms, startup_timeout_ms/sec,
       connection_timeout, read_timeout, debug, log_level, disabled
     - stdio: shell, encoding, working_dir, restart_on_exit,
       max_restart_count
     - http/sse: retry_count, max_retry_attempts, retry_delay,
       cache_tools_list, verify_ssl, insecure, proxy

3. **Custom fields** (generic converter)
   - Automatic type inference for:
     - String → TOML String
     - Number (i64/f64) → TOML Integer/Float
     - Boolean → TOML Boolean
     - Simple arrays → TOML Array
     - Shallow objects (string values only) → TOML Inline Table
   - Unsupported types (null, mixed arrays, nested objects) are
     gracefully skipped with debug logging

## Changes

### Core Implementation
- **json_value_to_toml_item()** (mcp.rs:843-947)
  Generic JSON → TOML value converter with smart type inference

- **json_server_to_toml_table()** (mcp.rs:949-1072)
  Refactored to use three-tier strategy, processes all fields beyond
  core whitelist

- **build_servers_table()** (mcp.rs:616-633)
  Now reuses the generic converter for consistency

- **import_from_codex()** (mcp.rs:432-605)
  Extended with generic TOML → JSON converter for bidirectional
  field preservation

### Logging
- DEBUG: Extended field conversions
- INFO: Custom field conversions
- WARN: Skipped unsupported types (with reason)

## Testing
-  All 24 integration tests pass
-  Compilation clean (zero errors)
-  Backward compatible with existing configs

## Design Decision: No Auto Field Mapping
Explicitly NOT implementing automatic field mapping (e.g., Claude's
`timeout` → Codex's `startup_timeout_ms`) due to:
- Unit ambiguity (seconds vs milliseconds)
- Semantic ambiguity (same field name, different meanings)
- Risk of data corruption (30s → 30ms causes immediate timeout)
- Breaks user expectation of "what you see is what you get"

Recommendation: Use application-specific field names as documented.

## Example
User adds `timeout: 30` in MCP panel:

**Claude/Gemini** (~/.claude.json):
```json
{"mcpServers": {"srv": {"timeout": 30}}}
```

**Codex** (~/.codex/config.toml):
```toml
[mcp.servers.srv]
timeout = 30  #  Now preserved!
```

Fixes the whitelist limitation reported in user feedback.
2025-11-17 17:23:09 +08:00
qixing-jk
883cf0346b fix(password input): disable Edge/IE reveal and clear buttons (#232)
Hide default password reveal and clear controls in Microsoft browsers
for improved consistency and security.
2025-11-17 15:30:46 +08:00
Jason
1805ed586e fix(mcp): improve format/submit UX and fix validation errors
Fixes two critical issues with the MCP JSON input:
1. Format button failed with wrapped format like "server": {...}
2. Submit button failed despite input validation passing

Changes:
- Updated formatJSON to use smart parser (supports wrapped format)
- Simplified submit validation logic (removed redundant validateJsonConfig call)
- Improved UX: input preserves original format, cleanup happens on format/submit
  * Prevents confusing "instant disappearance" when pasting wrapped JSON
  * Auto-fills ID/Name fields while keeping input unchanged
  * Format button now strips wrapper key and formats cleanly
  * Submit button correctly extracts config regardless of format

Code quality:
- Reduced code by 5 lines (20 changes, 11 insertions, 16 deletions)
- Consistent use of parseSmartMcpJson across all JSON operations
- No type errors introduced
2025-11-16 20:40:16 +08:00
Jason
98a1305684 feat(mcp): add smart JSON parser for flexible input formats
Support multiple MCP configuration input formats:
- Pure config object: { "command": "npx", ... }
- Key-value pair fragment: "server-name": { "command": "npx", ... }
- Wrapped object: { "server-name": { "command": "npx", ... } }

The parser automatically:
- Detects and wraps JSON fragments into complete objects
- Extracts server name from single-key objects
- Auto-fills ID and Name fields when applicable
- Formats the config for display

This improves UX by allowing users to paste configs directly from
.claude.json or .codex/config.toml without manual editing.
2025-11-16 20:08:04 +08:00
Jason
f79efb86cd refactor(mcp): improve form label layout and simplify text
- Simplify config label from "Full JSON configuration or use" to "Full JSON Configuration"
- Align wizard button to the right using justify-between layout
- Apply same pattern to TOML configuration label
- Improve visual balance with cleaner left-right alignment
2025-11-16 16:59:23 +08:00
Jason
ed59420a83 feat(mcp): enhance form UX with default apps and JSON formatter
- Enable all apps (Claude, Codex, Gemini) by default when adding MCP servers
- Improve config label with clearer wording: "Full JSON configuration or use [Config Wizard]"
- Add JSON format button to beautify configuration with 2-space indentation
- Update tests to reflect new default behavior
- Clean up redundant explicit prop passing

This provides a more streamlined experience by enabling all apps out of the box
and making it easier to format JSON configurations.
2025-11-16 16:50:07 +08:00
Jason
bfc27349b3 feat(mcp): add SSE (Server-Sent Events) transport type support
Add comprehensive support for SSE transport type to MCP server configuration,
enabling real-time streaming connections alongside existing stdio and http types.

Backend Changes:
- Add SSE type validation in mcp.rs validate_server_spec()
- Extend Codex TOML import/export to handle SSE servers
- Update claude_mcp.rs legacy API for backward compatibility
- Unify http/sse handling in json_server_to_toml_table()

Frontend Changes:
- Extend McpServerSpec type definition to include "sse"
- Add SSE radio button to configuration wizard UI
- Update wizard form logic to handle SSE url and headers
- Add SSE validation in McpFormModal submission

Validation & Error Handling:
- Add SSE support in useMcpValidation hook (TOML/JSON)
- Extend tomlUtils normalizeServerConfig for SSE parsing
- Update Zod schemas (common.ts, mcp.ts) with SSE enum
- Add SSE error message mapping in errorUtils

Internationalization:
- Add "typeSse" translations (zh: "sse", en: "sse")

Tests:
- Add SSE validation test cases in useMcpValidation.test.tsx

SSE Configuration Format:
{
  "type": "sse",
  "url": "https://api.example.com/sse",
  "headers": { "Authorization": "Bearer token" }
}
2025-11-16 16:15:17 +08:00
Jason
4fc7413ffa refactor(codex): simplify custom template with minimal config
**Changes:**
- Remove all comments from custom template (align with Claude/Gemini)
- Remove base_url field from template (user fills in form instead)
- Simplify getCodexCustomTemplate() - no locale parameter needed
- Keep preset configurations unchanged with their baseUrl values

**Template now contains only:**
- model_provider, model, model_reasoning_effort
- disable_response_storage
- [model_providers.custom] section with minimal fields

**Benefits:**
-  Cleaner, more focused template
-  Consistent with other apps (no comments)
-  Forces users to fill base_url via form field
-  Reduced template size from 35+ lines to 12 lines

Net change: -74 lines (codexTemplates.ts)
2025-11-16 13:36:33 +08:00
Jason
12112e9d7d refactor(codex): extract template to config with i18n support
**Changes:**
- Create src/config/codexTemplates.ts with getCodexCustomTemplate factory
- Support both Chinese and English templates based on i18n.language
- Remove 70 lines of duplicated template strings from ProviderForm.tsx
- Update both useEffect and handlePresetChange to use template factory
- Clean up unused "Custom (Blank Template)" preset entry

**Benefits:**
-  Eliminates code duplication (35-line template repeated twice)
-  Adds internationalization support for English users
-  Follows project architecture (templates in config/ directory)
-  Improves maintainability (single source of truth)
-  Net reduction: 34 lines (81 additions, 115 deletions)

**Technical Details:**
- Template selection logic: (i18n.language || "zh").startsWith("zh") ? "zh" : "en"
- Templates are identical except for comments language
- Both auth and config are returned as a single CodexTemplate object

Addresses DRY principle violation and architectural concerns identified
in code review.
2025-11-16 13:23:53 +08:00
Jason
6a6980c82c refactor(codex): remove configuration wizard and unify provider setup experience
- Remove CodexQuickWizardModal component (~300 lines)
- Add "Custom (Blank Template)" preset with annotated TOML template
- Unify configuration experience across Claude/Codex/Gemini
- Remove wizard-related i18n keys, keep apiUrlLabel for CodexFormFields
- Simplify component integration by removing wizard state management

This change reduces code complexity by ~250 lines while providing better
user education through commented configuration templates in Chinese.

Users can now:
1. Select "Custom (Blank Template)" preset
2. See annotated TOML template with inline documentation
3. Follow step-by-step comments to configure custom providers

BREAKING CHANGE: Configuration wizard UI removed, replaced with template-based approach
2025-11-16 12:29:18 +08:00
Jason
031ea3a58f style(mcp): refine panel layout for better visual hierarchy and compactness
- Replace checkboxes with toggle switches for app selection (more intuitive for enable/disable actions)
- Change switch color from blue to emerald to match MCP button theme
- Stack app options vertically with labels on left to save horizontal space
- Reduce panel width from max-w-4xl to max-w-3xl for more compact design
- Move docs button next to server name for better information grouping
2025-11-16 11:31:27 +08:00
Jason
9d431cc7ae style(ui): migrate color scheme to macOS native design system
- Update primary blue from Linear style (#3498db) to macOS system blue (#0A84FF)
- Align gray scale with macOS dark mode palette (#1C1C1E, #2C2C2E, etc.)
- Adjust dark mode background to match macOS systemBackground (HSL 240 5% 12%)
- Refine scrollbar colors to use native macOS gray tones
- Remove transparent titleBarStyle for better stability
2025-11-16 10:44:15 +08:00
Jason
685a1138e4 refactor(mcp): complete form refactoring for unified MCP management
Complete the v3.7.0 MCP refactoring by updating the form layer to match
the unified architecture already implemented in data/service/API layers.

**Breaking Changes:**
- Remove confusing `appId` parameter from McpFormModal
- Replace with `defaultFormat` (json/toml) and `defaultEnabledApps` (array)

**Form Enhancements:**
- Add app enablement checkboxes (Claude/Codex/Gemini) directly in the form
- Smart defaults: new servers default to Claude enabled, editing preserves state
- Support "draft" mode: servers can be created without enabling any apps

**Architecture Improvements:**
- Eliminate semantic confusion: format selection separate from app targeting
- One-step workflow: configure and enable apps in single form submission
- Consistent with unified backend: `apps: { claude, codex, gemini }`

**Testing:**
- Update test mocks to use `useUpsertMcpServer` hook
- Add test case for creating servers with no apps enabled
- Fix parameter references from `appId` to `defaultFormat`

**i18n:**
- Add `mcp.form.enabledApps` translation (zh/en)
- Add `mcp.form.noAppsWarning` translation (zh/en)

This completes the MCP management refactoring, ensuring all layers
(data, service, API, UI) follow the same unified architecture pattern.
2025-11-15 23:47:35 +08:00
Jason
154ff4c819 feat(config): unify common config snippets persistence across all apps
- Add unified `common_config_snippets` structure to MultiAppConfig
- Implement `get_common_config_snippet` and `set_common_config_snippet` commands
- Replace localStorage with config.json persistence for Codex and Gemini
- Auto-migrate legacy `claude_common_config_snippet` to new unified structure
- Deprecate individual API methods in favor of unified interface
- Add automatic migration from localStorage on first load

BREAKING CHANGE: Common config snippets now stored in unified `common_config_snippets` object instead of separate fields
2025-11-15 19:52:49 +08:00
Jason
2540f6ba08 refactor(prompt): optimize auto-import logic with unified save
- Change auto_import_prompt_if_exists return type to Result<bool>
- Remove redundant save() calls by introducing updated flag
- Eliminate unnecessary clone() in app type iterations
- Remove semantic contradiction in let _ = ...? pattern
- Improve code semantics and maintainability

Performance: Reduce disk I/O by 50%+ through unified save logic
2025-11-15 16:47:08 +08:00
YoVinchen
d32ceb9b80 fix(prompt): correct live file backfill priority in enable flow (#225)
Fix backfill logic in prompt enable workflow:
- Prioritize backfilling live file content to currently enabled prompt (prevent data loss)
- Create backup only when no enabled prompt exists and content is new (avoid duplicate backups)
- Implement staged persistence (save after backfill + save after enable)
- Add explicit logging for backfill/backup operations

Also simplify string formatting in prompt_files.rs with inline format strings.
2025-11-15 16:09:00 +08:00
Jason
e11c7d84cd test(mcp): update import tests for v3.7.0 unified structure
- Fix import_from_claude_merges_into_config: check unified mcp.servers
- Fix import_from_codex_adds_servers_from_mcp_servers_table: verify apps.codex enabled
- Fix import_from_codex_merges_into_existing_entries: test smart merge preserves existing config
- Replace cc_switch_lib::app_config:: with public exports (McpServer, McpApps)

All 24 import_export_sync tests now passing.
2025-11-14 23:39:34 +08:00
Jason
ea8f2095e2 fix(mcp): migrate import functions to unified v3.7.0 structure
- Rewrite import_from_claude/codex/gemini to write directly to mcp.servers
- Implement skip-on-error strategy for fault tolerance (single invalid item no longer aborts entire batch)
- Smart merge logic: existing servers only enable corresponding app, preserve other configs
- Remove deprecated markers from service layer
- Export McpApps type for test usage
- Update mcp_commands tests to use unified structure

Fixes runtime import issue where data was written to legacy structure (mcp.claude/codex.servers)
but unified panel reads from new structure (mcp.servers), causing "imported but invisible" bug.
2025-11-14 23:33:54 +08:00
Jason
09f80d82bc fix(mcp): initialize McpRoot with v3.7.0 unified structure by default
Problem:
- On first launch, McpRoot::default() created `servers: None`
- McpService::get_all_servers() would incorrectly return error "old structure detected"
- This confused new users who had no legacy MCP data

Root Cause:
- Derived Default trait sets Option<T> fields to None
- New installations should start with v3.7.0 structure immediately

Solution:
- Explicitly implement Default for McpRoot
- Initialize `servers: Some(HashMap::new())` for v3.7.0+ structure
- Legacy fields (claude/codex/gemini) remain empty, only used for deserializing old configs

Impact:
- First-time users get correct v3.7.0 structure immediately
- migrate_mcp_to_unified() correctly detects already-migrated state
- No false "old structure" errors on fresh installs
2025-11-14 22:55:46 +08:00
Jason
2f18d6ec00 refactor(mcp): complete v3.7.0 cleanup - remove legacy code and warnings
This commit finalizes the v3.7.0 unified MCP architecture migration by
removing all deprecated code paths and eliminating compiler warnings.

Frontend Changes (~950 lines removed):
- Remove deprecated components: McpPanel, McpListItem, McpToggle
- Remove deprecated hook: useMcpActions
- Remove unused API methods: importFrom*, syncEnabledTo*, syncAllServers
- Simplify McpFormModal by removing dual-mode logic (unified/legacy)
- Remove syncOtherSide checkbox and conflict detection
- Clean up unused imports and state variables
- Delete associated test files

Backend Changes (~400 lines cleaned):
- Remove unused Tauri commands: import_mcp_from_*, sync_enabled_mcp_to_*
- Delete unused Gemini MCP functions: get_mcp_status, upsert/delete_mcp_server
- Add #[allow(deprecated)] to compatibility layer commands
- Add #[allow(dead_code)] to legacy helper functions for future migration
- Simplify boolean expression in mcp.rs per Clippy suggestion

API Deprecation:
- Mark legacy APIs with @deprecated JSDoc (getConfig, upsertServerInConfig, etc.)
- Preserve backward compatibility for v3.x, planned removal in v4.0

Verification:
-  Zero TypeScript errors (pnpm typecheck)
-  Zero Clippy warnings (cargo clippy)
-  All code formatted (prettier + cargo fmt)
-  Builds successfully

Total cleanup: ~1,350 lines of code removed/marked
Breaking changes: None (all legacy APIs still functional)
2025-11-14 22:43:25 +08:00
Jason
fafca841cb refactor(frontend): remove redundant 'Sync All' button from MCP panel
All MCP operations already auto-sync to live configs:
- upsert_server() → sync_server_to_apps()
- toggle_app() → sync_server_to_app() or remove_server_from_app()
- delete_server() → remove_server_from_all_apps()

The manual 'Sync All' button was redundant and could confuse users
into thinking they need to manually sync after each change.

Changes:
- Remove 'Sync All' button from UnifiedMcpPanel header
- Remove useSyncAllMcpServers hook
- Remove handleSyncAll function and syncAllMutation state
- Remove RefreshCw icon import
- Remove sync-related i18n translations (en/zh)

Note: Backend sync_all_mcp_servers command remains for potential
future use (e.g., recovery tool), but is no longer exposed in UI.
2025-11-14 15:52:01 +08:00
Jason
f4b8aed29a refactor(frontend): remove MCP import functionality for v3.7.0
Auto-migration at startup is sufficient for upgrading from v3.6.x.
Manual import adds unnecessary complexity since:
- Gemini MCP support launches with v3.7.0 (no legacy data)
- Existing Claude/Codex MCP configs are auto-migrated on first run
- All MCP management should happen within CC Switch

Changes:
- Remove McpImportDialog component
- Remove "Import" button from UnifiedMcpPanel
- Remove import-related i18n translations (en/zh)
- Simplify user experience with single management interface

Note: Backend import commands (import_mcp_from_*) remain for
backward compatibility but are no longer exposed in UI.
2025-11-14 15:47:04 +08:00
Jason
9663b4251e feat(frontend): add MCP import dialog for v3.7.0
Implement import functionality to migrate MCP servers from existing configs:

**New Component:**
- src/components/mcp/McpImportDialog.tsx: Import dialog with card-based source selection

**Features:**
- Import from Claude (~/.claude/claude.json or settings.json)
- Import from Codex (~/.codex/config.toml)
- Import from Gemini (config file)
- Card-based UI with icons and descriptions
- Loading states with spinner animation
- Auto-refresh after successful import

**Integration:**
- Add import button to UnifiedMcpPanel header
- Handle import completion with refetch
- Toast notifications for success/info/error cases

**I18n:**
- Add mcp.unifiedPanel.import namespace (zh/en)
- Import button, dialog title, descriptions
- Success/error messages with count interpolation

**UX:**
- Smart disable: other sources disabled during import
- Clear feedback: count of imported servers
- Friendly messages: "No servers found" when empty

TypeScript type check passes 
2025-11-14 15:29:16 +08:00
Jason
9e8abf5f26 feat(frontend): implement unified MCP panel for v3.7.0
Complete Phase 3 (P0) frontend implementation for unified MCP management:

**New Files:**
- src/hooks/useMcp.ts: React Query hooks for unified MCP operations
- src/components/mcp/UnifiedMcpPanel.tsx: Unified MCP management panel
- src/components/ui/checkbox.tsx: Checkbox component from shadcn/ui

**Features:**
- Unified panel with three-column layout: server info + app checkboxes + actions
- Multi-app control: Claude/Codex/Gemini checkboxes for each server
- Real-time stats: Show enabled server counts per app
- Full CRUD operations: Add, edit, delete, sync all servers

**Integration:**
- Replace old app-specific McpPanel with UnifiedMcpPanel in App.tsx
- Update McpFormModal to support unified mode with apps field
- Add i18n support: mcp.unifiedPanel namespace (zh/en)

**Type Safety:**
- Ensure McpServer.apps field always initialized
- Fix all test files to include apps field
- TypeScript type check passes 

**Architecture:**
- Single source of truth: mcp.servers manages all MCP configs
- Per-server app control: apps.claude/codex/gemini boolean flags
- Backward compatible: McpFormModal supports both unified and legacy modes

Next: P1 tasks (import dialogs, sub-components, tests)
2025-11-14 15:24:48 +08:00
Jason
32a6de074c docs: add comprehensive v3.7.0 unified MCP refactor plan
## Document Overview
Created detailed refactoring plan document covering:
- Project goals and architecture changes
- Data structure migration strategy (v3.6.x → v3.7.0)
- Complete development progress tracking
- Frontend remaining tasks roadmap
- Testing plan and delivery checklist

## Completed Work (Documented)
 Phase 1: Backend data structure migration
  - McpApps, McpServer, McpRoot definitions
  - Automatic migration logic
  - Integration into load() method

 Phase 2: Backend services refactor
  - Complete McpService rewrite
  - Single-server sync functions
  - New Tauri commands
  - Backward compatibility layer
  - All compilation errors fixed

 Phase 3 (Partial): Frontend types and API
  - Updated TypeScript types (McpApps, McpServer)
  - New API methods (getAllServers, toggleApp, etc.)

## Remaining Work (Documented)
📝 React Query hooks (useMcp.ts)
📝 UnifiedMcpPanel component
📝 i18n translations
📝 Main app integration
📝 Sub-components (table, form, import dialog)

## Key Highlights
- Detailed UI wireframes and component structure
- Step-by-step migration flow documentation
- Risk assessment and mitigation strategies
- Technical insights and architecture benefits

Related: v3.7.0 unified MCP management
2025-11-14 15:09:22 +08:00
Jason
ac09551563 feat(frontend): add unified MCP types and API layer for v3.7.0
## Type Definitions
- Update McpServer interface with new apps field (McpApps)
- Add McpApps interface for multi-app enable state
- Add McpServersMap type for server collections
- Mark enabled field as deprecated (use apps instead)
- Maintain backward compatibility with optional fields

## API Layer Updates
- Add unified MCP management methods to mcpApi:
  * getAllServers() - retrieve all servers with apps state
  * upsertUnifiedServer() - add/update server with apps
  * deleteUnifiedServer() - remove server
  * toggleApp() - enable/disable server for specific app
  * syncAllServers() - sync all enabled servers to live configs
- Import new McpServersMap type

## Code Organization
- Keep all types in src/types.ts (removed duplicate types/mcp.ts)
- Follow existing project structure conventions

Related: v3.7.0 unified MCP management
2025-11-14 13:01:47 +08:00
Jason
7ae2a9f556 fix(mcp): resolve compilation errors and add backward compatibility
## Compilation Fixes
- Add deprecated compatibility methods to McpService:
  * get_servers() - filters servers by app
  * set_enabled() - delegates to toggle_app()
  * sync_enabled() - syncs enabled servers for specific app
  * import_from_claude/codex/gemini() - wraps mcp:: functions
- Fix toml_edit type conversion in sync_single_server_to_codex():
  * Add json_server_to_toml_table() helper function
  * Manually construct toml_edit::Table instead of invalid serde conversion
- Fix get_codex_config_path() calls (returns PathBuf, not Result)
- Update upsert_mcp_server_in_config() to work with unified structure:
  * Converts old per-app API to unified McpServer structure
  * Preserves existing server data when updating
  * Supports sync_other_side parameter for multi-app enable
- Update delete_mcp_server_in_config() to ignore app parameter

## Backward Compatibility
- All old v3.6.x commands continue to work with deprecation warnings
- Frontend migration can be done incrementally
- Old commands transparently use new unified backend

## Status
 Backend compiles successfully (cargo check passes)
⚠️ 16 warnings (8 deprecation + 8 unused functions - expected)
2025-11-14 12:57:14 +08:00
Jason
c985db8f3d feat(mcp): implement unified MCP management for v3.7.0
BREAKING CHANGE: Migrate from app-specific MCP storage to unified structure

## Phase 1: Data Structure Migration
- Add McpApps struct with claude/codex/gemini boolean fields
- Add McpServer struct for unified server definition
- Add migration logic in MultiAppConfig::migrate_mcp_to_unified()
- Integrate automatic migration into MultiAppConfig::load()
- Support backward compatibility through Optional fields

## Phase 2: Backend Services Refactor
- Completely rewrite services/mcp.rs for unified management:
  * get_all_servers() - retrieve all MCP servers
  * upsert_server() - add/update unified server
  * delete_server() - remove server
  * toggle_app() - enable/disable server for specific app
  * sync_all_enabled() - sync to all live configs
- Add single-server sync functions to mcp.rs:
  * sync_single_server_to_claude/codex/gemini()
  * remove_server_from_claude/codex/gemini()
- Add read_mcp_servers_map() to claude_mcp.rs and gemini_mcp.rs
- Add new Tauri commands to commands/mcp.rs:
  * get_mcp_servers, upsert_mcp_server, delete_mcp_server
  * toggle_mcp_app, sync_all_mcp_servers
- Register new commands in lib.rs

## Migration Strategy
- Detects old structure (mcp.claude/codex/gemini.servers)
- Merges into unified mcp.servers with apps markers
- Handles conflicts by merging enabled apps
- Clears old structures after migration
- Saves migrated config automatically

## Known Issues
- Old commands still need compatibility layer (WIP)
- toml_edit type conversion needs fixing (WIP)
- Frontend not yet implemented (Phase 3 pending)

Related: v3.6.2 -> v3.7.0
2025-11-14 12:51:24 +08:00
Jason
c7b235bb98 fix(i18n): add missing Gemini MCP panel title and fix ternary logic
- Add mcp.geminiTitle to both zh.json and en.json
- Fix McpPanel title logic to handle all three apps (claude/codex/gemini)
- Previous logic would incorrectly display codexTitle for gemini
2025-11-14 10:35:55 +08:00
Jason
1616c63c0b feat(gemini): implement full MCP management functionality
- Add gemini_mcp.rs module for Gemini MCP file I/O operations
- Implement sync_enabled_to_gemini to export enabled MCPs to ~/.gemini/settings.json
- Implement import_from_gemini to import MCPs from Gemini config
- Add Gemini sync logic in services/mcp.rs (upsert_server, delete_server, set_enabled)
- Register Tauri commands for Gemini MCP sync and import
- Update frontend API calls and McpPanel to support Gemini

Fixes the issue where adding MCP servers in Gemini tab would not sync to ~/.gemini/settings.json
2025-11-14 10:02:27 +08:00
Jason
146b42fb68 feat(gemini): add config.json editor and common config functionality
Implements dual-editor pattern for Gemini providers, following the Codex architecture:
- Environment variables (.env format) editor
- Extended configuration (config.json) editor with common config support

New Components:
- GeminiConfigSections: Separate sections for env and config editing
- GeminiCommonConfigModal: Modal for editing common config snippets

New Hooks:
- useGeminiConfigState: Manages env/config separation and conversion
  - Converts between .env string format and JSON object
  - Validates JSON config structure
  - Extracts API Key and Base URL from env
- useGeminiCommonConfig: Handles common config snippets
  - Deep merge algorithm for combining configs
  - Remove common config logic for toggling off
  - localStorage persistence for snippets

Features:
- Format buttons for both env and config editors
- Common config toggle with deep merge/remove
- Error validation and display
- Auto-open modal on common config errors

Configuration Structure:
{
  "env": {
    "GOOGLE_GEMINI_BASE_URL": "https://...",
    "GEMINI_API_KEY": "sk-...",
    "GEMINI_MODEL": "gemini-2.5-pro"
  },
  "config": {
    "timeout": 30000,
    "maxRetries": 3
  }
}

This brings Gemini providers to feature parity with Claude and Codex.
2025-11-14 08:32:30 +08:00
Jason
0ea434a485 fix(i18n): deduplicate category labels by reusing providerForm keys
- Change ProviderForm to use providerForm.category* instead of providerPreset.category*
- Remove duplicate category keys from providerPreset namespace in both zh.json and en.json
- Fix naming inconsistency: use categoryAggregation (not categoryAggregator)
- Fixes issue where English UI would show Chinese defaultValue fallbacks

This ensures single source of truth for category labels and improves maintainability.
2025-11-14 08:31:09 +08:00
Jason
21fd7cc9fd feat: migrate Claude common config snippet from localStorage to config.json
Migrate the Claude common config snippet storage from browser localStorage
to the persistent config.json file for better cross-device sync and backup support.

**Backend Changes:**
- Add `claude_common_config_snippet` field to `MultiAppConfig` struct
- Add `get_claude_common_config_snippet` and `set_claude_common_config_snippet` Tauri commands
- Include JSON validation in the setter command

**Frontend Changes:**
- Create new `lib/api/config.ts` API module
- Refactor `useCommonConfigSnippet` hook to use config.json instead of localStorage
- Add automatic one-time migration from localStorage to config.json
- Add loading state during initialization

**Benefits:**
- Cross-device synchronization via backup/restore
- More reliable persistence than browser storage
- Centralized configuration management
- Seamless migration for existing users
2025-11-13 22:45:58 +08:00
Jason
434c64f38d fix(ui): prevent URL overflow in provider cards
Add max-width constraint to URL display to prevent long URLs from breaking card layout.
2025-11-13 17:14:49 +08:00
Jason
6d8e822f8d refactor(i18n): remove unnecessary translation for brand names
- Use hardcoded "Claude", "Codex", "Gemini" instead of i18n keys
- Brand names should not be translated across different locales
- Simplifies code by removing useTranslation hook from AppSwitcher
- Reduces maintenance overhead in translation files
2025-11-13 17:08:05 +08:00
Jason
2fae8c9275 fix(i18n): add internationalization support for app names
- Add i18n for Claude/Codex/Gemini app names in AppSwitcher
- Use useTranslation hook with existing translation keys
- Fix ASCII diagram alignment in README files
2025-11-13 16:37:58 +08:00
Jason
30c763ffe3 fix(prompt): improve i18n and error handling for auto-import
Address code review feedback for PR #214:

- Replace hardcoded Chinese strings with English in auto-imported prompts
  - Prompt name: "Auto-imported Prompt" instead of "初始提示词"
  - Description: "Automatically imported on first launch"
- Remove panic risk by replacing expect() with proper error propagation
- Use AppError::localized for bilingual error messages
- Extract get_base_dir_with_fallback() helper to eliminate code duplication
- Update test assertions to match new English strings
- Suppress false-positive dead_code warning on TempHome.dir field

All 5 tests passing with zero compiler warnings.
2025-11-13 15:43:37 +08:00
YoVinchen
e4d7999294 refactor(prompt): extract file path logic and implement auto-import on first launch (#214)
- Extract prompt file path logic to dedicated prompt_files module
- Refactor PromptService to use centralized path resolution
- Implement auto-import for existing prompt files on first startup
- Add comprehensive unit tests for auto-import functionality
2025-11-13 15:15:58 +08:00
YoVinchen
34f7139fda fix(usage-script): add input validation and boundary checks (#208)
- Backend: validate auto-query interval ≤ 1440 minutes (24 hours)
- Frontend: add number input sanitization and blur validation
- Add user-friendly error messages for invalid inputs
- Support auto-clamping to valid ranges with toast notifications
2025-11-13 11:28:48 +08:00
YoVinchen
a85f24f616 fix(gemini): relax validation when adding providers (#210)
Allow users to create Gemini provider configurations without API key
and fill it in later. Split validation into two modes:

- validate_gemini_settings: Basic structure check (used when adding)
- validate_gemini_settings_strict: Full validation (used when switching)

This fixes the error 'Gemini config missing required field: GEMINI_API_KEY'
when trying to add Gemini providers from presets like PackyCode or Google.

Changes:
- Add validate_gemini_settings_strict for switching validation
- Update write_gemini_live to use strict validation
- Add comprehensive tests for both validation modes
2025-11-13 11:28:28 +08:00
YoVinchen
b9743a463d feat(tray): add Gemini support to system tray menu (#209)
Refactor tray menu system to support three applications (Claude/Codex/Gemini):
- Introduce generic TrayAppSection structure and TRAY_SECTIONS array
- Implement append_provider_section and handle_provider_tray_event helper functions
- Enhance Gemini provider service with .env config read/write support
- Implement Gemini LiveSnapshot for atomic operations and rollback
- Update README documentation to reflect Gemini tray quick switching feature
2025-11-12 23:38:43 +08:00
YoVinchen
2f02514a14 feat(gemini): add Google Official branding with Gemini icon (#211)
Update Google Gemini preset to match Claude Official styling:
- Rename 'Google' to 'Google Official'
- Add GeminiIcon support in preset selector
- Add custom theme with Google blue (#4285F4) background
- Update PresetTheme type to support 'gemini' icon type

Changes:
- Add GeminiPresetTheme interface
- Add theme config to Google Official preset
- Import and render GeminiIcon in ProviderPresetSelector
- Update PresetTheme icon type to include 'gemini'
2025-11-12 22:41:26 +08:00
Jason
75866044bd update readme 2025-11-12 21:52:47 +08:00
YoVinchen
155532ea8c feat(prompts+i18n): add prompt management and improve prompt editor i18n (#193)
* feat(prompts): add prompt management across Tauri service and React UI

- backend: add commands/prompt.rs, services/prompt.rs, register in commands/mod.rs and lib.rs, refine app_config.rs
- frontend: add PromptPanel, PromptFormModal, PromptListItem, MarkdownEditor, usePromptActions, integrate in App.tsx
- api: add src/lib/api/prompts.ts
- i18n: update src/i18n/locales/{en,zh}.json
- build: update package.json and pnpm-lock.yaml

* feat(i18n): improve i18n for prompts and Markdown editor

- update src/i18n/locales/{en,zh}.json keys and strings
- apply i18n in PromptFormModal, PromptPanel, and MarkdownEditor
- align prompt text with src-tauri/src/services/prompt.rs

* feat(prompts): add enable/disable toggle and simplify panel UI

- Add PromptToggle component and integrate in prompt list items
- Implement toggleEnabled with optimistic update; enable via API, disable via upsert with enabled=false;
  reload after success
- Simplify PromptPanel: remove file import and current-file preview to keep CRUD flow focused
- Tweak header controls style (use mcp variant) and minor copy: rename “Prompt Management” to “Prompts”
- i18n: add disableSuccess/disableFailed messages
- Backend (Tauri): prevent duplicate backups when importing original prompt content

* style: unify code formatting with trailing commas

* feat(prompts): add Gemini filename support to PromptFormModal

Update filename mapping to use Record<AppId, string> pattern, supporting
GEMINI.md alongside CLAUDE.md and AGENTS.md.

* fix(prompts): sync enabled prompt to file when updating

When updating a prompt that is currently enabled, automatically sync
the updated content to the corresponding live file (CLAUDE.md/AGENTS.md/GEMINI.md).

This ensures the active prompt file always reflects the latest content
when editing enabled prompts.
2025-11-12 16:41:41 +08:00
YoVinchen
346f916048 refactor(endpoint): separate edit and create mode endpoint management (#192)
Optimize custom endpoint management logic to distinguish between edit and create modes:
- Edit mode: endpoints are read/written directly to backend via API
- Create mode: use draftCustomEndpoints to stage, save on submit
- Remove duplicate endpoint loading in useSpeedTestEndpoints
- Add isSaving state and initialCustomUrls tracking
2025-11-12 11:02:43 +08:00
YoVinchen
8a05e7bd3d feat(gemini): add Gemini provider integration (#202)
* feat(gemini): add Gemini provider integration

- Add gemini_config.rs module for .env file parsing
- Extend AppType enum to support Gemini
- Implement GeminiConfigEditor and GeminiFormFields components
- Add GeminiIcon with standardized 1024x1024 viewBox
- Add Gemini provider presets configuration
- Update i18n translations for Gemini support
- Extend ProviderService and McpService for Gemini

* fix(gemini): resolve TypeScript errors, add i18n support, and fix MCP logic

**Critical Fixes:**
- Fix TS2741 errors in tests/msw/state.ts by adding missing Gemini type definitions
- Fix ProviderCard.extractApiUrl to support GOOGLE_GEMINI_BASE_URL display
- Add missing apps.gemini i18n keys (zh/en) for proper app name display
- Fix MCP service Gemini cross-app duplication logic to prevent self-copy

**Technical Details:**
- tests/msw/state.ts: Add gemini default providers, current ID, and MCP config
- ProviderCard.tsx: Check both ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL
- services/mcp.rs: Skip Gemini in sync_other_side logic with unreachable!() guards
- Run pnpm format to auto-fix code style issues

**Verification:**
-  pnpm typecheck passes
-  pnpm format completed

* feat(gemini): enhance authentication and config parsing

- Add strict and lenient .env parsing modes
- Implement PackyCode partner authentication detection
- Support Google OAuth official authentication
- Auto-configure security.auth.selectedType for PackyCode
- Add comprehensive test coverage for all auth types
- Update i18n for OAuth hints and Gemini config

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-11-12 10:47:34 +08:00
Jason
32a2ba5ef6 chore(release): prepare for v3.6.2 release 2025-11-11 23:57:21 +08:00
Jason
4502b2f973 feat(presets): add Kimi For Coding and BaiLing provider presets
Add two new Chinese official provider presets:
- Kimi For Coding: AI coding assistant from Kimi
- BaiLing: Claude-compatible API from AliPay TBox
2025-11-11 23:50:52 +08:00
Jason
6cb930b4ec docs: add TypeScript Trending badge and improve contributing tone
- Add 🔥 TypeScript Trending badge celebrating daily/weekly/monthly rankings
- Refine contributing guidelines with friendlier, more welcoming language
- Replace directive tone with collaborative suggestions for feature PRs
2025-11-11 11:03:26 +08:00
Jason
9a5c8c0e57 chore(release): prepare for v3.6.1 release
- Bump version to 3.6.1 across all config files
  - package.json, Cargo.toml, tauri.conf.json
  - README.md and README_ZH.md version badges
  - Auto-updated Cargo.lock
- Add release notes documentation
  - docs/release-note-v3.6.0-en.md (archive)
  - docs/release-note-v3.6.1-zh.md (cumulative format)
  - docs/release-note-v3.6.1-en.md (cumulative format)
- Enlarge PackyCode sponsor logo by 50% (100px → 150px)
- Update roadmap checklist (homebrew support marked as done)
2025-11-10 21:21:27 +08:00
Jason
b1abdf95aa feat(sponsor): add PackyCode as official partner
- Add PackyCode to sponsor section in README (both EN/ZH)
- Add PackyCode logo to assets/partners/logos/
- Mark PackyCode as partner in provider presets (Claude & Codex)
- Add promotion message to i18n files with 10% discount info
2025-11-10 18:44:19 +08:00
Jason
be155c857e refactor(usage-script): replace native checkbox with Switch component
Upgrade the enable toggle from native checkbox to shadcn/ui Switch component
for better UX and UI consistency with settings page.

**Improvements**:
1. Use modern toggle UI (Switch) instead of traditional checkbox
2. Adopt the same layout pattern as settings page (ToggleRow style)
3. Add bordered container with proper spacing for better visual hierarchy
4. Maintain full accessibility support (aria-label)

**Layout changes**:
- Before: Simple label + checkbox horizontal layout
- After: Bordered container, label on left, Switch on right, vertically centered
2025-11-10 16:01:28 +08:00
Jason
9d6f101006 fix(usage-script): replace FormLabel with Label to fix white screen crash
FormLabel component requires FormField context and throws error when used
standalone, causing the entire component to crash with a white screen.

Root cause:
- FormLabel internally calls useFormField() hook
- useFormField() requires FormFieldContext (must be within <FormField>)
- Without context, it throws: "useFormField should be used within <FormField>"
- Uncaught error crashes React rendering tree

Solution:
- Replace FormLabel with standalone Label component
- Label component from @/components/ui/label doesn't depend on form context
- Maintains same styling (text-sm font-medium) without requiring context

This fixes the white screen issue when clicking the usage panel.
2025-11-10 15:51:18 +08:00
Jason
2a56a0d889 style(usage-script): unify form input styles with shadcn/ui components
Replace native HTML input elements with shadcn/ui Input and FormLabel
components to ensure consistent styling across the application.

Changes:
- Import Input, FormLabel, Eye, and EyeOff components
- Replace all credential input fields with Input component
- Add show/hide toggle buttons for password fields (API Key, Access Token)
- Replace label/span elements with FormLabel component
- Update timeout and auto-query interval inputs to use Input component
- Improve spacing consistency (space-y-4 for credential config)
- Add proper id attributes for accessibility
- Use muted-foreground for hint text

The form now matches the styling of provider configuration forms
throughout the application.
2025-11-10 15:42:36 +08:00
Jason
7096957b40 feat(usage-query): decouple credentials from provider config
Add independent credential fields for usage query to support different
query endpoints and authentication methods.

Changes:
- Add `apiKey` and `baseUrl` fields to UsageScript struct
- Remove dependency on provider config credentials in query_usage
- Update test_usage_script to accept independent credential parameters
- Add credential input fields in UsageScriptModal based on template:
  * General: apiKey + baseUrl
  * NewAPI: baseUrl + accessToken + userId
  * Custom: no additional fields (full freedom)
- Auto-clear irrelevant fields when switching templates
- Add i18n text for "credentialsConfig"

Benefits:
- Query API can use different endpoint/key than provider config
- Better separation of concerns
- More flexible for various usage query scenarios
2025-11-10 15:28:09 +08:00
Jason
23d06515ad fix(toml): normalize CJK quotes to prevent parsing errors
Add quote normalization to handle Chinese/fullwidth quotes automatically
converted by IME. This fixes TOML parsing failures when users input
configuration with non-ASCII quotes (" " ' ' etc.).

Changes:
- Add textNormalization utility for quote normalization
- Apply normalization in TOML input handlers (MCP form, Codex config)
- Disable browser auto-correction in Textarea component
- Add defensive normalization in TOML parsing layer
2025-11-10 14:35:55 +08:00
Jason
3210202132 fix(mcp): preserve custom fields in Codex TOML config editor
Fixed an issue where custom/extension fields (e.g., timeout_ms, retry_count)
were silently dropped when editing Codex MCP server configurations in TOML format.

Root cause: The TOML parser functions only extracted known fields (type, command,
args, env, cwd, url, headers), discarding any additional fields during normalization.

Changes:
- mcpServerToToml: Now uses spread operator to copy all fields before stringification
- normalizeServerConfig: Added logic to preserve unknown fields after processing known ones
- Both stdio and http server types now retain custom configuration fields

This fix enables forward compatibility with future MCP protocol extensions and
allows users to add custom configurations without code changes.
2025-11-10 12:03:15 +08:00
Jason
7b52c44a9d feat(schema): add common JSON/TOML validators and enforce MCP conditional fields
- Add src/lib/schemas/common.ts with jsonConfigSchema and tomlConfigSchema
- Enhance src/lib/schemas/mcp.ts to require command for stdio and url for http via superRefine
- Keep ProviderForm as-is; future steps will wire new schemas into RHF flows
- Verified: pnpm typecheck passes
2025-11-09 20:42:43 +08:00
Jason
772081312e feat(ui): enhance provider switch error notification with copy action
- Split error message into title and description for better UX
- Add one-click copy button to easily share error details
- Extend toast duration to 6s for improved readability
- Use extractErrorMessage utility for consistent error handling
- Add i18n keys: common.copy, notifications.switchFailedTitle

This improves debugging experience when provider switching fails,
allowing users to quickly copy and share error messages.
2025-11-09 17:56:02 +08:00
Jason
cfcd7b892a fix(tray): replace unwrap with safe pattern matching in menu handler
Replace unwrap() calls with safe pattern matching to prevent panics
when handling invalid tray menu item IDs. Now logs errors and returns
gracefully instead of crashing the application.
2025-11-08 22:55:53 +08:00
Jason
3da787b9af fix(provider): set category to custom for imported default config
Ensure that when importing default configuration from live files,
the created provider is properly marked with category "custom" for
correct UI display and filtering.
2025-11-08 22:45:53 +08:00
Jason
9370054911 fix(error-handling): isolate tray menu update failures from main operations
Previously, if updateTrayMenu() failed after a successful main operation
(like sorting, adding, or updating providers), the entire operation would
appear to fail with a misleading error message, even though the core
functionality had already succeeded.

This resulted in false negative feedback where:
- Backend data was successfully updated
- Frontend UI was successfully refreshed
- Tray menu failed to update
- User saw "operation failed" message (incorrect)

Changes:
- Wrap updateTrayMenu() calls in nested try-catch blocks
- Log tray menu failures separately with descriptive messages
- Ensure main operation success is reported accurately
- Prevent tray menu failures from triggering main operation error handlers

Files modified:
- src/hooks/useDragSort.ts (drag-and-drop sorting)
- src/lib/query/mutations.ts (add/delete/switch mutations)
- src/hooks/useProviderActions.ts (update provider)

This fixes the bug introduced in PR #179 and prevents similar issues
across all provider operations.
2025-11-08 22:07:12 +08:00
ZyphrZero
5b3b211c9a fix(ui): sync tray menu order after drag-and-drop sorting (#179)
The drag-and-drop sorting feature introduced in PR #126 (9eb991d) was
updating the provider sort order in the backend and frontend, but failed
to update the tray menu to reflect the new order.

Changes:
- Add updateTrayMenu() call after successful sort order update
- Ensures tray menu items are immediately reordered to match the UI

This fixes the issue where dragging providers in the main window would
not update their order in the system tray menu.

Fixes: 9eb991d (feat(ui): add drag-and-drop sorting for provider list)
2025-11-08 21:45:43 +08:00
Jason
fb02881684 fix(forms): populate base URL for all non-official provider categories
The base URL field was not populating when editing providers with
categories like cn_official or aggregator. The issue was caused by
inconsistent conditional logic: the input field was shown for all
non-official categories, but the value extraction only worked for
third_party and custom categories.

Changed the category check from allowlist (third_party, custom) to
denylist (official) to match the UI display logic. Now ANTHROPIC_BASE_URL
correctly populates for all provider categories except official.
2025-11-08 21:25:41 +08:00
Jason
34b8aa1008 fix(ui): remove misleading model placeholders from input fields
Clear placeholder values for model input fields to avoid suggesting specific model names that may not be applicable to all providers. This prevents user confusion when configuring different Claude providers.
2025-11-08 08:50:15 +08:00
Jason
52a7f9d313 feat(usage): enable background query for usage polling
- Add `refetchIntervalInBackground: true` to usage query configuration
- Allows usage queries to continue running when app window is minimized or unfocused
- Existing safety mechanisms remain in place (timeout limits, minimum interval, no retry)
- Users have full control through autoQueryInterval setting
2025-11-08 00:02:28 +08:00
Jason
b617879035 docs: add v3.6.0 release notes and update READMEs with missing features
**Release Notes**
- Create Chinese v3.6.0 release notes in docs/ folder

**README Updates**
Add documentation for three missing v3.6.0 features:

1. Claude Configuration Data Structure Enhancements
   - Granular model configuration: migrated from dual-key to quad-key system
   - New fields: ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
     ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_MODEL
   - Replaces legacy ANTHROPIC_SMALL_FAST_MODEL with automatic migration
   - Backend normalizes old configs with smart fallback chain
   - UI expanded from 2 to 4 model input fields

2. Updated Provider Models
   - Kimi: updated to latest kimi-k2-thinking model (from k1 series)
   - Removed legacy KimiModelSelector component

3. Custom Configuration Directory (Cloud Sync Support)
   - Customize CC Switch's configuration storage location
   - Point to cloud sync folders (Dropbox, OneDrive, iCloud, etc.)
     to enable automatic config synchronization across devices
   - Managed via Tauri Store for better isolation

**Files Changed**
- README.md & README_ZH.md: added feature documentation
- docs/release-note-v3.6.0-zh.md: comprehensive Chinese release notes
2025-11-07 22:05:33 +08:00
252 changed files with 26894 additions and 5426 deletions

5
.gitignore vendored
View File

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

View File

@@ -5,6 +5,247 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.7.1] - 2025-11-22
### Fixed
- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)
- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers
- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)
### Added
- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings
- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`
### Improved
- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions
- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives
- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards
### Reverted
- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization
---
## [3.7.0] - 2025-11-19
### Major Features
#### Gemini CLI Integration
- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex
- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats
- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **MCP management** - Full MCP configuration capabilities for Gemini
- **Provider presets**
- Google Official (OAuth authentication)
- PackyCode (partner integration)
- Custom endpoint support
- **Deep link support** - Import Gemini providers via `ccswitch://` protocol
- **System tray integration** - Quick-switch Gemini providers from tray menu
- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`
#### MCP v3.7.0 Unified Architecture
- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport type** - New Server-Sent Events support alongside stdio/http
- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats
- **Extended field support** - Preserve custom fields in Codex TOML conversion
- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)
- **Import/export system** - Unified import from Claude/Codex/Gemini live configs
- **UX improvements**
- Default app selection in forms
- JSON formatter for config validation
- Improved layout and visual hierarchy
- Better validation error messages
#### Claude Skills Management System
- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos
- **Pre-configured repositories**
- `ComposioHQ/awesome-claude-skills` (curated collection)
- `anthropics/skills` (official Anthropic skills)
- `cexll/myclaude` (community, with subdirectory scanning)
- **Lifecycle management**
- One-click install to `~/.claude/skills/`
- Safe uninstall with state tracking
- Update checking (infrastructure ready)
- **Custom repository support** - Add any GitHub repo as a skill source
- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories
- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration
- **Frontend interface**
- SkillsPage: Browse and manage skills
- SkillCard: Visual skill presentation
- RepoManager: Repository management dialog
- **State persistence** - Installation state stored in `skills.json`
- **Full i18n support** - Complete Chinese/English translations (47+ keys)
#### Prompts (System Prompts) Management
- **Multi-preset management** - Create, edit, and switch between multiple system prompts
- **Cross-app support**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting
- **Smart synchronization**
- Auto-write to live files on enable
- Content backfill protection (save current before switching)
- First-launch auto-import from live files
- **Single-active enforcement** - Only one prompt can be active at a time
- **Delete protection** - Cannot delete active prompts
- **Backend service** - `PromptService` (213 lines) with CRUD operations
- **Frontend components**
- PromptPanel: Main management interface (177 lines)
- PromptFormModal: Edit dialog with validation (160 lines)
- MarkdownEditor: CodeMirror integration (159 lines)
- usePromptActions: Business logic hook (152 lines)
- **Full i18n support** - Complete Chinese/English translations (41+ keys)
#### Deep Link Protocol (ccswitch://)
- **Protocol registration** - `ccswitch://` URL scheme for one-click imports
- **Provider import** - Import provider configurations from URLs or shared links
- **Lifecycle integration** - Deep link handling integrated into app startup
- **Cross-platform support** - Works on Windows, macOS, and Linux
#### Environment Variable Conflict Detection
- **Claude & Codex detection** - Identify conflicting environment variables
- **Gemini auto-detection** - Automatic environment variable discovery
- **Conflict management** - UI for resolving configuration conflicts
- **Prevention system** - Warn before overwriting existing configurations
### New Features
#### Provider Management
- **DouBaoSeed preset** - Added ByteDance's DouBao provider
- **Kimi For Coding** - Moonshot AI coding assistant
- **BaiLing preset** - BaiLing AI integration
- **Removed AnyRouter preset** - Discontinued provider
- **Model configuration** - Support for custom model names in Codex and Gemini
- **Provider notes field** - Add custom notes to providers for better organization
#### Configuration Management
- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`
- **Unified persistence** - Common config snippets now shared across all apps
- **Auto-import on first launch** - Automatically import configs from live files on first run
- **Backfill priority fix** - Correct priority handling when enabling prompts
#### UI/UX Improvements
- **macOS native design** - Migrated color scheme to macOS native design system
- **Window centering** - Default window position centered on screen
- **Password input fixes** - Disabled Edge/IE reveal and clear buttons
- **URL overflow prevention** - Fixed overflow in provider cards
- **Error notification enhancement** - Copy-to-clipboard for error messages
- **Tray menu sync** - Real-time sync after drag-and-drop sorting
### Improvements
#### Architecture
- **MCP v3.7.0 cleanup** - Removed legacy code and warnings
- **Unified structure** - Default initialization with v3.7.0 unified structure
- **Backward compatibility** - Compilation fixes for older configs
- **Code formatting** - Applied consistent formatting across backend and frontend
#### Platform Compatibility
- **Windows fix** - Resolved winreg API compatibility issue (v0.52)
- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu
#### Configuration
- **MCP sync on switch** - Sync MCP configs for all apps when switching providers
- **Gemini form sync** - Fixed form fields syncing with environment editor
- **Gemini config reading** - Read from both `.env` and `settings.json`
- **Validation improvements** - Enhanced input validation and boundary checks
#### Internationalization
- **JSON syntax fixes** - Resolved syntax errors in locale files
- **App name i18n** - Added internationalization support for app names
- **Deduplicated labels** - Reused providerForm keys to reduce duplication
- **Gemini MCP title** - Added missing Gemini MCP panel title
### Bug Fixes
#### Critical Fixes
- **Usage script validation** - Added input validation and boundary checks
- **Gemini validation** - Relaxed validation when adding providers
- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors
- **MCP field preservation** - Preserve custom fields in Codex TOML editor
- **Password input** - Fixed white screen crash (FormLabel → Label)
#### Stability
- **Tray menu safety** - Replaced unwrap with safe pattern matching
- **Error isolation** - Tray menu update failures don't block main operations
- **Import classification** - Set category to custom for imported default configs
#### UI Fixes
- **Model placeholders** - Removed misleading model input placeholders
- **Base URL population** - Auto-fill base URL for non-official providers
- **Drag sort sync** - Fixed tray menu order after drag-and-drop
### Technical Improvements
#### Code Quality
- **Type safety** - Complete TypeScript type coverage across codebase
- **Test improvements** - Simplified boolean assertions in tests
- **Clippy warnings** - Fixed `uninlined_format_args` warnings
- **Code refactoring** - Extracted templates, optimized logic flows
#### Dependencies
- **Tauri** - Updated to 2.8.x series
- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills
- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor
- **winreg** - Updated to v0.52 (Windows compatibility)
#### Performance
- **Startup optimization** - Removed legacy migration scanning
- **Lock management** - Improved RwLock usage to prevent deadlocks
- **Background query** - Enabled background mode for usage polling
### Statistics
- **Total commits**: 85 commits from v3.6.0 to v3.7.0
- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)
- **New modules**:
- Skills: 2,034 lines (21 files)
- Prompts: 1,302 lines (20 files)
- Gemini: ~1,000 lines (multiple files)
- MCP refactor: ~3,000 lines (refactored)
### Strategic Positioning
v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI CLI Management Platform"**:
1. **Capability Extension** - Skills provide external ability integration
2. **Behavior Customization** - Prompts enable AI personality presets
3. **Configuration Unification** - MCP v3.7.0 eliminates app silos
4. **Ecosystem Openness** - Deep links enable community sharing
5. **Multi-AI Support** - Claude/Codex/Gemini trinity
6. **Intelligent Detection** - Auto-discovery of environment conflicts
### Notes
- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration
- Skills and Prompts management are new features requiring no migration
- Gemini CLI support requires Gemini CLI to be installed separately
- MCP v3.7.0 unified structure is backward compatible with previous configs
## [3.6.0] - 2025-11-07
### ✨ New Features
@@ -73,6 +314,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 +322,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 +343,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 +607,7 @@ For users upgrading from v2.x (Electron version):
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes

503
README.md
View File

@@ -1,18 +1,24 @@
# Claude Code & Codex Provider Switcher
<div align="center">
[![Version](https://img.shields.io/badge/version-3.6.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
**From Provider Switcher to All-in-One AI CLI Management Platform**
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
</div>
## ❤️ Sponsor
## ❤Sponsor
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
@@ -22,78 +28,91 @@ GLM CODING PLAN is a subscription service designed for AI coding, starting at ju
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
## Release Notes
---
> **v3.6.0**: Added edit mode (provider duplication, manual sorting), custom endpoint management, usage query features. Optimized config directory switching experience (perfect WSL environment support). Added multiple provider presets (DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax). Completed full-stack architecture refactoring and testing infrastructure.
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
> v3.5.0: Added MCP management, config import/export, endpoint speed testing. Complete i18n coverage. Added Longcat and kat-coder presets. Standardized release file naming conventions.
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr>
> v3.4.0: Added i18next internationalization, support for new models (qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp), Claude plugin, single-instance daemon, tray minimize, and installer optimizations.
> v3.3.0: One-click VS Code Codex plugin configuration/removal (auto-sync by default), Codex common config snippets, enhanced custom wizard, WSL environment support, cross-platform tray and UI optimizations. (VS Code write feature deprecated in v3.4.x)
> v3.2.0: Brand new UI, macOS system tray, built-in updater, atomic write with rollback, improved dark mode, Single Source of Truth (SSOT) with one-time migration/archival.
> v3.1.0: Added Codex provider management with one-click switching. Import current Codex config as default provider. Auto-backup before internal config v1 → v2 migration (see "Migration & Archival" below).
> v3.0.0 Major Update: Complete migration from Electron to Tauri 2.0. Significantly reduced app size and greatly improved startup performance.
## Features (v3.6.0)
### Core Features
- **MCP (Model Context Protocol) Management**: Complete MCP server configuration management system
- Support for stdio and http server types with command validation
- Built-in templates for popular MCP servers (e.g., mcp-fetch)
- Real-time enable/disable MCP servers with atomic file writes to prevent configuration corruption
- **Config Import/Export**: Backup and restore your provider configurations
- One-click export all configurations to JSON file
- Import configs with automatic validation and backup, auto-rotate backups (keep 10 most recent)
- Auto-sync to live config files after import to ensure immediate effect
- **Endpoint Speed Testing**: Test API endpoint response times
- Measure latency to different provider endpoints with visual connection quality indicators
- Help users choose the fastest provider
- **Internationalization & Language Switching**: Complete i18next i18n coverage (including error messages, tray menu, all UI components)
- **Claude Plugin Sync**: Built-in button to apply or restore Claude plugin configurations with one click. Takes effect immediately after switching providers.
### v3.6 New Features
- **Provider Duplication**: Quickly duplicate existing provider configs to easily create variants
- **Manual Sorting**: Drag and drop to manually reorder providers
- **Custom Endpoint Management**: Support multi-endpoint configuration for aggregator providers
- **Usage Query Features**
- Auto-refresh interval: Supports periodic automatic usage queries
- Test Script API: Validate JavaScript scripts before execution
- Template system expansion: Custom blank templates, support for access token and user ID parameters
- **Config Editor Improvements**
- Added JSON format button
- Real-time TOML syntax validation (for Codex configs)
- **Auto-sync on Directory Change**: When switching Claude/Codex config directories (e.g., switching to WSL environment), automatically sync current provider to new directory to avoid config file conflicts
- **Load Live Config When Editing Active Provider**: When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications
- **New Provider Presets**: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
- **Partner Promotion Mechanism**: Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)
### v3.6 Architecture Improvements
- **Backend Refactoring**: Completed 5-phase refactoring (unified error handling → command layer split → integration tests → Service layer extraction → concurrency optimization)
- **Frontend Refactoring**: Completed 4-stage refactoring (test infrastructure → Hooks extraction → component splitting → code cleanup)
- **Testing System**: 100% Hooks unit test coverage, integration tests covering critical flows (vitest + MSW + @testing-library/react)
### System Features
- **System Tray & Window Behavior**: Window can minimize to tray, macOS supports hide/show Dock in tray mode, tray switching syncs Claude/Codex/plugin status.
- **Single Instance**: Ensures only one instance runs at a time to avoid multi-instance conflicts.
- **Standardized Release Naming**: All platform release files use consistent version-tagged naming (macOS: `.tar.gz` / `.zip`, Windows: `.msi` / `-Portable.zip`, Linux: `.AppImage` / `.deb`).
</table>
## Screenshots
### Main Interface
| Main Interface | Add Provider |
| :-----------------------------------------------: | :--------------------------------------------: |
| ![Main Interface](assets/screenshots/main-en.png) | ![Add Provider](assets/screenshots/add-en.png) |
![Main Interface](assets/screenshots/main-en.png)
## Features
### Add Provider
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
![Add Provider](assets/screenshots/add-en.png)
**v3.7.0 Major Update (2025-11-19)**
**Six Core Features, 18,000+ Lines of New Code**
- **Gemini CLI Integration**
- Third supported AI CLI (Claude Code / Codex / Gemini)
- Dual-file configuration support (`.env` + `settings.json`)
- Complete MCP server management
- Presets: Google Official (OAuth) / PackyCode / Custom
- **Claude Skills Management System**
- Auto-scan skills from GitHub repositories (3 pre-configured curated repos)
- One-click install/uninstall to `~/.claude/skills/`
- Custom repository support + subdirectory scanning
- Complete lifecycle management (discover/install/update)
- **Prompts Management System**
- Multi-preset system prompt management (unlimited presets, quick switching)
- Cross-app support (Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
- Markdown editor (CodeMirror 6 + real-time preview)
- Smart backfill protection, preserves manual modifications
- **MCP v3.7.0 Unified Architecture**
- Single panel manages MCP servers across three applications
- New SSE (Server-Sent Events) transport type
- Smart JSON parser + Codex TOML format auto-correction
- Unified import/export + bidirectional sync
- **Deep Link Protocol**
- `ccswitch://` protocol registration (all platforms)
- One-click import provider configs via shared links
- Security validation + lifecycle integration
- **Environment Variable Conflict Detection**
- Auto-detect cross-app configuration conflicts (Claude/Codex/Gemini/MCP)
- Visual conflict indicators + resolution suggestions
- Override warnings + backup before changes
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
- **Claude Plugin Sync**: One-click apply/restore Claude plugin configurations
**v3.6 Highlights**
- Provider duplication & drag-and-drop sorting
- Multi-endpoint management & custom config directory (cloud sync ready)
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
- WSL environment support with auto-sync on directory change
- 100% hooks test coverage & complete architecture refactoring
**System Features**
- System tray with quick switching
- Single instance daemon
- Built-in auto-updater
- Atomic writes with rollback protection
## Download & Installation
@@ -128,152 +147,142 @@ 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.
## Usage Guide
## Quick Start
1. Click "Add Provider" to add your API configuration
2. Switching methods:
- Select a provider on the main interface and click switch
- Or directly select target provider from "System Tray (Menu Bar)" for immediate effect
3. Switching will write to the corresponding app's "live config file" (Claude: `settings.json`; Codex: `auth.json` + `config.toml`)
4. Restart or open new terminal to ensure it takes effect
5. To switch back to official login, select "Official Login" from presets and switch; after restarting terminal, follow the official login process
### Basic Usage
### MCP Configuration Guide (v3.5.x)
1. **Add Provider**: Click "Add Provider" → Choose preset or create custom configuration
2. **Switch Provider**:
- Main UI: Select provider → Click "Enable"
- System Tray: Click provider name directly (instant effect)
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
- Management Location: All MCP server definitions are centrally saved in `~/.cc-switch/config.json` (categorized by client `claude` / `codex`)
- Sync Mechanism:
- Enabled Claude MCP servers are projected to `~/.claude.json` (path may vary with override directory)
- Enabled Codex MCP servers are projected to `~/.codex/config.toml`
- Validation & Normalization: Auto-validate field legality (stdio/http) when adding/importing, and auto-fix/populate keys like `id`
- Import Sources: Support importing from `~/.claude.json` and `~/.codex/config.toml`; existing entries only force `enabled=true`, don't override other fields
### MCP Management
### Check for Updates
- **Location**: Click "MCP" button in top-right corner
- **Add Server**:
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
- Support stdio / http / sse transport types
- Configure independent MCP servers for different apps
- **Enable/Disable**: Toggle switches to control which servers sync to live config
- **Sync**: Enabled servers auto-sync to each app's live files
- **Import/Export**: Import existing MCP servers from Claude/Codex/Gemini config files
- Click "Check for Updates" in Settings. If built-in Updater config is available, it will detect and download directly; otherwise, it will fall back to opening the Releases page
### Skills Management (v3.7.0 New)
### Codex Guide (SSOT)
- **Location**: Click "Skills" button in top-right corner
- **Discover Skills**:
- Auto-scan pre-configured GitHub repositories (Anthropic official, ComposioHQ, community, etc.)
- Add custom repositories (supports subdirectory scanning)
- **Install Skills**: Click "Install" to one-click install to `~/.claude/skills/`
- **Uninstall Skills**: Click "Uninstall" to safely remove and clean up state
- **Manage Repositories**: Add/remove custom GitHub repositories
- Config Directory: `~/.codex/`
- Live main config: `auth.json` (required), `config.toml` (can be empty)
- API Key Field: Uses `OPENAI_API_KEY` in `auth.json`
- Switching Behavior (no longer writes "copy files"):
- Provider configs are uniformly saved in `~/.cc-switch/config.json`
- When switching, writes target provider back to live files (`auth.json` + `config.toml`)
- Uses "atomic write + rollback on failure" to avoid half-written state; `config.toml` can be empty
- Import Default: When the app has no providers, creates a default entry from existing live main config and sets it as current
- Official Login: Can switch to preset "Codex Official Login", restart terminal and follow official login process
### Prompts Management (v3.7.0 New)
### Claude Code Guide (SSOT)
- **Location**: Click "Prompts" button in top-right corner
- **Create Presets**:
- Create unlimited system prompt presets
- Use Markdown editor to write prompts (syntax highlighting + real-time preview)
- **Switch Presets**: Select preset → Click "Activate" to apply immediately
- **Sync Mechanism**:
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Protection Mechanism**: Auto-save current prompt content before switching, preserves manual modifications
- Config Directory: `~/.claude/`
- Live main config: `settings.json` (preferred) or legacy-compatible `claude.json`
- API Key Field: `env.ANTHROPIC_AUTH_TOKEN`
- Switching Behavior (no longer writes "copy files"):
- Provider configs are uniformly saved in `~/.cc-switch/config.json`
- When switching, writes target provider JSON directly to live file (preferring `settings.json`)
- When editing current provider, writes live first successfully, then updates app main config to ensure consistency
- Import Default: When the app has no providers, creates a default entry from existing live main config and sets it as current
- Official Login: Can switch to preset "Claude Official Login", restart terminal and use `/login` to complete login
### Configuration Files
### Migration & Archival
**Claude Code**
#### v3.6 Technical Improvements
- Live config: `~/.claude/settings.json` (or `claude.json`)
- API key field: `env.ANTHROPIC_AUTH_TOKEN` or `env.ANTHROPIC_API_KEY`
- MCP servers: `~/.claude.json``mcpServers`
**Internal Optimizations (User Transparent)**:
**Codex**
- **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 are fully compatible, no action required
- ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
- API key field: `OPENAI_API_KEY` in `auth.json`
- MCP servers: `~/.codex/config.toml``[mcp_servers]` tables
- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)
-**Impact**: More standardized code, friendlier error messages
-**Compatibility**: Frontend fully adapted, users don't need to care about this change
**Gemini**
#### Startup Failure & Recovery
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
- API key field: `GEMINI_API_KEY` or `GOOGLE_GEMINI_API_KEY` in `.env`
- Environment variables: Support `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- MCP servers: `~/.gemini/settings.json``mcpServers`
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
- Trigger Conditions: Triggered when `~/.cc-switch/config.json` doesn't exist, is corrupted, or fails to parse.
- User Action: Check JSON syntax according to popup prompt, or restore from backup files.
- Backup Location & Rotation: `~/.cc-switch/backups/backup_YYYYMMDD_HHMMSS.json` (keep up to 10, see `src-tauri/src/services/config.rs`).
- Exit Strategy: To protect data safety, the app will show a popup and force exit when the above errors occur; restart after fixing.
**CC Switch Storage**
#### Migration Mechanism (v3.2.0+)
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
- Settings: `~/.cc-switch/settings.json`
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
- One-time Migration: First launch of v3.2.0+ will scan old "copy files" and merge into `~/.cc-switch/config.json`
- Claude: `~/.claude/settings-*.json` (excluding `settings.json` / legacy `claude.json`)
- Codex: `~/.codex/auth-*.json` and `config-*.toml` (merged in pairs by name)
- Deduplication & Current Item: Deduplicate by "name (case-insensitive) + API Key"; if current is empty, set live merged item as current
- Archival & Cleanup:
- Archive directory: `~/.cc-switch/archive/<timestamp>/<category>/...`
- Delete original copies after successful archival; keep original files on failure (conservative strategy)
- v1 → v2 Structure Upgrade: Additionally generates `~/.cc-switch/config.v1.backup.<timestamp>.json` for rollback
- Note: After migration, daily switch/edit operations are no longer archived; prepare your own backup solution if long-term auditing is needed
### Cloud Sync Setup
## Architecture Overview (v3.6)
1. Go to Settings → "Custom Configuration Directory"
2. Choose your cloud sync folder (Dropbox, OneDrive, iCloud, etc.)
3. Restart app to apply
4. Repeat on other devices to enable cross-device sync
### Architecture Refactoring Highlights (v3.6)
> **Note**: First launch auto-imports existing Claude/Codex configs as default provider.
**Backend Refactoring (Rust)**: Completed 5-phase refactoring
## Architecture Overview
- **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**: Introduced integration tests and transaction mechanism (config snapshot + failure rollback)
- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
### Design Principles
**Frontend Refactoring (React + TypeScript)**: Completed 4-stage refactoring
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ Backend (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
- **Stage 1**: Established 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
**Core Design Patterns**
**Test Coverage**:
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
- **Layered Architecture**: Clear separation (Commands → Services → Models)
- 100% Hooks unit test coverage
- Integration tests covering critical flows (App, SettingsDialog, MCP Panel)
- MSW mocking backend API to ensure test independence
**Key Components**
### Layered Architecture
- **ProviderService**: Provider CRUD, switching, backfill, sorting
- **McpService**: MCP server management, import/export, live file sync
- **ConfigService**: Config import/export, backup rotation
- **SpeedtestService**: API endpoint latency measurement
- **Frontend (Renderer)**
- Tech Stack: TypeScript + React 18 + Vite + TailwindCSS 4
- Data Layer: TanStack React Query unified queries and mutations (`@/lib/query`), Tauri API unified wrapper (`@/lib/api`)
- Business Logic Layer: Custom Hooks (`@/hooks`) carry domain logic, components stay simple
- Event Flow: Listen to backend `provider-switched` events, drive UI refresh and tray state consistency
- Organization: Components split by domain (`providers/settings/mcp/ui`)
**v3.6 Refactoring**
- **Backend (Tauri + Rust)**
- **Commands Layer** (Interface Layer): `src-tauri/src/commands/*` split by domain, only responsible for parameter parsing and permission validation
- **Services Layer** (Business Layer): `src-tauri/src/services/*` carry core logic, reusable and testable
- `ProviderService`: Provider CRUD, switch, backfill, sorting
- `McpService`: MCP server management, import/export, sync
- `ConfigService`: Config file import/export, backup/restore
- `SpeedtestService`: API endpoint latency testing
- **Models & State**:
- `provider.rs`: Domain models (`Provider`, `ProviderManager`, `ProviderMeta`)
- `app_config.rs`: Multi-app config (`MultiAppConfig`, `AppId`, `McpRoot`)
- `store.rs`: Global state (`AppState` + `RwLock<MultiAppConfig>`)
- **Reliability**:
- Unified error type `AppError` (with localized messages)
- Transactional changes (config snapshot + failure rollback)
- Atomic writes (temp file + rename, avoid half-writes)
- Tray menu & events: Rebuild menu after switch and emit `provider-switched` event to frontend
- **Design Points (SSOT + Dual-way Sync)**
- **Single Source of Truth**: Provider configs centrally stored in `~/.cc-switch/config.json`
- **Write on Switch**: Write target provider config to live files (Claude: `settings.json`; Codex: `auth.json` + `config.toml`)
- **Backfill Mechanism**: Immediately read back live files after switch, update SSOT to protect user manual modifications
- **Directory Switch Sync**: Auto-sync current provider to new directory when changing config directories (perfect WSL environment support)
- **Prioritize Live When Editing**: When editing current provider, prioritize loading live config to ensure display of actually effective configuration
- **Compatibility & Changes**
- Command Parameters Unified: Tauri commands only accept `app` (values: `claude` / `codex`)
- Frontend Types Unified: Use `AppId` to express app identifiers (replacing legacy `AppType` export)
- Backend: 5-phase refactoring (error handling → command split → tests → services → concurrency)
- Frontend: 4-stage refactoring (test infra → hooks → components → cleanup)
- Testing: 100% hooks coverage + integration tests (vitest + MSW)
## Development
@@ -346,12 +355,12 @@ cargo test --features test-hooks
**Test Coverage**:
- Hooks unit tests (100% coverage)
- Hooks unit tests (100% coverage)
- `useProviderActions` - Provider operations
- `useMcpActions` - MCP management
- `useSettings` series - Settings management
- `useImportExport` - Import/export
- Integration tests
- Integration tests
- App main application flow
- SettingsDialog complete interaction
- MCP panel functionality
@@ -371,120 +380,36 @@ pnpm test:unit --coverage
## Tech Stack
### Frontend
**Frontend**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
- **[React 18](https://react.dev/)** - User interface library
- **[TypeScript](https://www.typescriptlang.org/)** - Type-safe JavaScript
- **[Vite](https://vitejs.dev/)** - Lightning fast frontend build tool
- **[TailwindCSS 4](https://tailwindcss.com/)** - Utility-first CSS framework
- **[TanStack Query v5](https://tanstack.com/query/latest)** - Powerful data fetching and caching
- **[react-i18next](https://react.i18next.com/)** - React internationalization framework
- **[react-hook-form](https://react-hook-form.com/)** - High-performance forms library
- **[zod](https://zod.dev/)** - TypeScript-first schema validation
- **[shadcn/ui](https://ui.shadcn.com/)** - Reusable React components
- **[@dnd-kit](https://dndkit.com/)** - Modern drag and drop toolkit
**Backend**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
### Backend
- **[Tauri 2.8](https://tauri.app/)** - Cross-platform desktop app framework
- tauri-plugin-updater - Auto update
- tauri-plugin-process - Process management
- tauri-plugin-dialog - File dialogs
- tauri-plugin-store - Persistent storage
- tauri-plugin-log - Logging
- **[Rust](https://www.rust-lang.org/)** - Systems programming language
- **[serde](https://serde.rs/)** - Serialization/deserialization framework
- **[tokio](https://tokio.rs/)** - Async runtime
- **[thiserror](https://github.com/dtolnay/thiserror)** - Error handling derive macro
### Testing Tools
- **[vitest](https://vitest.dev/)** - Fast unit testing framework
- **[MSW](https://mswjs.io/)** - API mocking tool
- **[@testing-library/react](https://testing-library.com/react)** - React testing utilities
**Testing**: vitest · MSW · @testing-library/react
## Project Structure
```
├── src/ # Frontend code (React + TypeScript)
│ ├── components/ # React components
│ ├── providers/ # Provider management components
│ │ │ ├── forms/ # Form sub-components (Claude/Codex fields)
│ │ │ ├── ProviderList.tsx
│ │ │ ├── ProviderForm.tsx
│ │ │ ├── AddProviderDialog.tsx
│ │ │ └── EditProviderDialog.tsx
│ │ ├── settings/ # Settings related components
│ │ │ ├── SettingsDialog.tsx
│ │ │ ├── DirectorySettings.tsx
│ │ │ └── ImportExportSection.tsx
│ │ ├── mcp/ # MCP management components
│ │ │ ├── McpPanel.tsx
│ │ │ ├── McpFormModal.tsx
│ │ │ └── McpWizard.tsx
│ │ └── ui/ # shadcn/ui base components
│ ├── hooks/ # Custom Hooks (business logic layer)
│ │ ├── useProviderActions.ts # Provider operations
│ │ ├── useMcpActions.ts # MCP operations
│ │ ├── useSettings.ts # Settings management
│ │ ├── useImportExport.ts # Import/export
│ │ └── useDirectorySettings.ts # Directory config
├── src/ # Frontend (React + TypeScript)
│ ├── components/ # UI components (providers/settings/mcp/ui)
│ ├── hooks/ # Custom hooks (business logic)
│ ├── lib/
│ │ ├── api/ # Tauri API wrapper (type-safe)
│ │ │ ├── providers.ts # Provider API
│ │ │ ├── settings.ts # Settings API
│ │ │ ├── mcp.ts # MCP API
│ │ │ └── usage.ts # Usage query API
│ │ └── query/ # TanStack Query config
│ ├── queries.ts # Query definitions
│ ├── mutations.ts # Mutation definitions
│ └── queryClient.ts
│ ├── i18n/ # Internationalization resources
│ └── locales/
│ ├── zh/ # Chinese translations
── en/ # English translations
├── config/ # Config & presets
├── claudeProviderPresets.ts # Claude provider presets
├── codexProviderPresets.ts # Codex provider presets
└── mcpPresets.ts # MCP server templates
│ ├── utils/ # Utility functions
│ ├── postChangeSync.ts # Config sync utility
│ └── ...
│ └── types/ # TypeScript type definitions
├── src-tauri/ # Backend code (Rust)
│ ├── src/
│ │ ├── commands/ # Tauri command layer (split by domain)
│ │ │ ├── provider.rs # Provider commands
│ │ │ ├── mcp.rs # MCP commands
│ │ │ ├── config.rs # Config query commands
│ │ │ ├── settings.rs # Settings commands
│ │ │ ├── plugin.rs # Plugin commands
│ │ │ ├── import_export.rs # Import/export commands
│ │ │ └── misc.rs # Misc commands
│ │ ├── services/ # Service layer (business logic)
│ │ │ ├── provider.rs # ProviderService
│ │ │ ├── mcp.rs # McpService
│ │ │ ├── config.rs # ConfigService
│ │ │ └── speedtest.rs # SpeedtestService
│ │ ├── app_config.rs # Config data models
│ │ ├── provider.rs # Provider domain models
│ │ ├── store.rs # Global state management
│ │ ├── mcp.rs # MCP sync & validation
│ │ ├── error.rs # Unified error type
│ │ ├── usage_script.rs # Usage script execution
│ │ ├── claude_plugin.rs # Claude plugin management
│ │ └── lib.rs # App entry point
│ ├── capabilities/ # Tauri permission config
│ └── icons/ # App icons
├── tests/ # Frontend tests (v3.6 new)
│ ├── hooks/ # Hooks unit tests
│ ├── components/ # Component integration tests
│ └── setup.ts # Test config
└── assets/ # Static resources
├── screenshots/ # Interface screenshots
└── partners/ # Partner resources
├── logos/ # Partner logos
└── banners/ # Partner banners/promotional images
├── i18n/locales/ # Translations (zh/en)
├── config/ # Presets (providers/mcp)
└── types/ # TypeScript definitions
├── src-tauri/ # Backend (Rust)
│ └── src/
│ ├── commands/ # Tauri command layer (by domain)
── services/ # Business logic layer
├── app_config.rs # Config data models
├── provider.rs # Provider domain models
├── mcp.rs # MCP sync & validation
└── lib.rs # App entry & tray menu
├── tests/ # Frontend tests
│ ├── hooks/ # Unit tests
│ └── components/ # Integration tests
└── assets/ # Screenshots & partner resources
```
## Changelog
@@ -506,7 +431,7 @@ Before submitting PRs, please ensure:
- Pass type check: `pnpm typecheck`
- Pass format check: `pnpm format:check`
- Pass unit tests: `pnpm test:unit`
- Functional PRs should be discussed in the issue area first
- 💡 For new features, please open an issue for discussion before submitting a PR
## Star History

View File

@@ -1,99 +1,118 @@
# Claude Code & Codex 供应商管理器
<div align="center">
[![Version](https://img.shields.io/badge/version-3.6.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
</div>
## ❤️ 赞助商
## ❤️赞助商
![智谱 GLM](assets/partners/banners/glm-zh.jpg)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
GLM CODING PLAN 是专为AI编码打造的订阅套餐每月最低仅需20元即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
## 更新记录
---
> **v3.6.0** 新增编辑模式供应商复制、手动排序、自定义端点管理、使用量查询等功能优化配置目录切换体验WSL 环境完美支持新增多个供应商预设DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax完成全栈架构重构和测试体系建设。
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目PackyCode 是一家稳定、高效的API中转服务商提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码可以享受9折优惠。</td>
</tr>
> v3.5.0 :新增 MCP 管理、配置导入/导出、端点速度测试功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。
<tr>
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="shandianshuo.cn">免费下载</a></td>
</tr>
> v3.4.0 :新增 i18next 国际化、对新模型qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.6.0
### 核心功能
- **MCP (Model Context Protocol) 管理**:完整的 MCP 服务器配置管理系统
- 支持 stdio 和 http 服务器类型,并提供命令校验
- 内置常用 MCP 服务器模板(如 mcp-fetch 等)
- 实时启用/禁用 MCP 服务器,原子文件写入防止配置损坏
- **配置导入/导出**:备份和恢复你的供应商配置
- 一键导出所有配置到 JSON 文件
- 导入配置时自动验证并备份,自动轮换备份(保留最近 10 个)
- 导入后自动同步到 live 配置文件,确保立即生效
- **端点速度测试**:测试 API 端点响应时间
- 测量不同供应商端点的延迟,可视化连接质量指示器
- 帮助用户选择最快的供应商
- **国际化与语言切换**:完整的 i18next 国际化覆盖(包含错误消息、托盘菜单、所有 UI 组件)
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
### v3.6 新增功能
- **供应商复制功能**:快速复制现有供应商配置,轻松创建变体配置
- **手动排序功能**:通过拖拽来对供应商进行手动排序
- **自定义端点管理**:支持聚合类供应商的多端点配置
- **使用量查询功能**
- 自动刷新间隔:支持定时自动查询使用量
- 测试脚本 API测试 JavaScript 脚本是否正确
- 模板系统扩展:自定义空白模板、支持 access token 和 user ID 参数
- **配置编辑器改进**
- 新增 JSON 格式化按钮
- 实时 TOML 语法验证Codex 配置)
- **配置目录切换自动同步**:切换 Claude/Codex 配置目录(如切换到 WSL 环境)时,自动同步当前供应商到新目录,避免冲突导致配置文件混乱
- **编辑当前供应商时加载 live 配置**:编辑正在使用的供应商时,优先显示实际生效的配置,保护用户手动修改
- **新增供应商预设**DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
- **合作伙伴推广机制**:支持生态合作伙伴推广(如智谱 GLM Z.ai
### v3.6 架构改进
- **后端重构**:完成 5 阶段重构(统一错误处理 → 命令层拆分 → 集成测试 → Service 层提取 → 并发优化)
- **前端重构**:完成 4 阶段重构(测试基础设施 → Hooks 提取 → 组件拆分 → 代码清理)
- **测试体系**Hooks 单元测试 100% 覆盖集成测试覆盖关键流程vitest + MSW + @testing-library/react
### 系统功能
- **系统托盘与窗口行为**窗口关闭可最小化到托盘macOS 支持托盘模式下隐藏/显示 Dock托盘切换时同步 Claude/Codex/插件状态。
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
- **标准化发布命名**所有平台发布文件使用一致的版本标签命名macOS: `.tar.gz` / `.zip`Windows: `.msi` / `-Portable.zip`Linux: `.AppImage` / `.deb`)。
</table>
## 界面预览
### 主界面
| 主界面 | 添加供应商 |
| :---------------------------------------: | :------------------------------------------: |
| ![主界面](assets/screenshots/main-zh.png) | ![添加供应商](assets/screenshots/add-zh.png) |
![主界面](assets/screenshots/main-zh.png)
## 功能特性
### 添加供应商
### 当前版本v3.7.0 | [完整更新日志](CHANGELOG.md)
![添加供应商](assets/screenshots/add-zh.png)
**v3.7.0 重大更新2025-11-19**
**六大核心功能18,000+ 行新增代码**
- **Gemini CLI 集成**
- 第三个支持的 AI CLIClaude Code / Codex / Gemini
- 双文件配置支持(`.env` + `settings.json`
- 完整 MCP 服务器管理
- 预设Google Official (OAuth) / PackyCode / 自定义
- **Claude Skills 管理系统**
- 从 GitHub 仓库自动扫描技能(预配置 3 个精选仓库)
- 一键安装/卸载到 `~/.claude/skills/`
- 自定义仓库支持 + 子目录扫描
- 完整生命周期管理(发现/安装/更新)
- **Prompts 管理系统**
- 多预设系统提示词管理(无限数量,快速切换)
- 跨应用支持Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`
- Markdown 编辑器CodeMirror 6 + 实时预览)
- 智能回填保护,保留手动修改
- **MCP v3.7.0 统一架构**
- 单一面板管理三个应用的 MCP 服务器
- 新增 SSE (Server-Sent Events) 传输类型
- 智能 JSON 解析器 + Codex TOML 格式自动修正
- 统一导入/导出 + 双向同步
- **深度链接协议**
- `ccswitch://` 协议注册(全平台)
- 通过共享链接一键导入供应商配置
- 安全验证 + 生命周期集成
- **环境变量冲突检测**
- 自动检测跨应用配置冲突Claude/Codex/Gemini/MCP
- 可视化冲突指示器 + 解决建议
- 覆盖警告 + 更改前备份
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**完整的中英文本地化UI、错误、托盘
- **Claude 插件同步**:一键应用或恢复 Claude 插件配置
**v3.6 亮点**
- 供应商复制 & 拖拽排序
- 多端点管理 & 自定义配置目录(支持云同步)
- 细粒度模型配置四层Haiku/Sonnet/Opus/自定义)
- WSL 环境支持,配置目录切换自动同步
- 100% hooks 测试覆盖 & 完整架构重构
**系统功能**
- 系统托盘快速切换
- 单实例守护
- 内置自动更新器
- 原子写入与回滚保护
## 下载安装
@@ -128,152 +147,142 @@ 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` 安装包。
## 使用说明
## 快速开始
1. 点击"添加供应商"添加你的 API 配置
2. 切换方式:
- 在主界面选择供应商后点击切换
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
3. 切换会写入对应应用的“live 配置文件”Claude`settings.json`Codex`auth.json` + `config.toml`
4. 重启或新开终端以确保生效
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
### 基本使用
### MCP 配置说明v3.5.x
1. **添加供应商**:点击"添加供应商" → 选择预设或创建自定义配置
2. **切换供应商**
- 主界面:选择供应商 → 点击"启用"
- 系统托盘:直接点击供应商名称(立即生效)
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
4. **恢复官方登录**:选择"官方登录"预设Claude/Codex或"Google 官方"预设Gemini重启对应客户端后按照其登录/OAuth 流程操作
- 管理位置:所有 MCP 服务器定义集中保存在 `~/.cc-switch/config.json`(按客户端 `claude` / `codex` 分类)
- 同步机制:
- 启用的 Claude MCP 会投影到 `~/.claude.json`(路径可随覆盖目录而变化)
- 启用的 Codex MCP 会投影到 `~/.codex/config.toml`
- 校验与归一化:新增/导入时自动校验字段合法性stdio/http并自动修复/填充 `id` 等键名
- 导入来源:支持从 `~/.claude.json``~/.codex/config.toml` 导入;已存在条目只强制 `enabled=true`,不覆盖其他字段
### MCP 管理
### 检查更新
- **位置**:点击右上角"MCP"按钮
- **添加服务器**
- 使用内置模板mcp-fetch、mcp-filesystem 等)
- 支持 stdio / http / sse 三种传输类型
- 为不同应用配置独立的 MCP 服务器
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
- **同步**:启用的服务器自动同步到各应用的 live 文件
- **导入/导出**:支持从 Claude/Codex/Gemini 配置文件导入现有 MCP 服务器
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Skills 管理v3.7.0 新增)
### Codex 说明SSOT
- **位置**:点击右上角"Skills"按钮
- **发现技能**
- 自动扫描预配置的 GitHub 仓库Anthropic 官方、ComposioHQ、社区等
- 添加自定义仓库(支持子目录扫描)
- **安装技能**:点击"安装"一键安装到 `~/.claude/skills/`
- **卸载技能**:点击"卸载"安全移除并清理状态
- **管理仓库**:添加/删除自定义 GitHub 仓库
- 配置目录:`~/.codex/`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Prompts 管理v3.7.0 新增)
### Claude Code 说明SSOT
- **位置**:点击右上角"Prompts"按钮
- **创建预设**
- 创建无限数量的系统提示词预设
- 使用 Markdown 编辑器编写提示词(语法高亮 + 实时预览)
- **切换预设**:选择预设 → 点击"激活"立即应用
- **同步机制**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
- 配置目录:`~/.claude/`
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 配置文件
### 迁移与归档
**Claude Code**
#### v3.6 技术改进
- Live 配置:`~/.claude/settings.json`(或 `claude.json`
- API key 字段:`env.ANTHROPIC_AUTH_TOKEN``env.ANTHROPIC_API_KEY`
- MCP 服务器:`~/.claude.json``mcpServers`
**内部优化(用户无感知)**
**Codex**
- **移除遗留迁移逻辑**v3.6 移除了 v1 配置自动迁移和副本文件扫描逻辑
- **影响**:启动性能提升,代码更简洁
- **兼容性**v2 格式配置完全兼容,无需任何操作
- ⚠️ **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 完成一次性迁移,再升级到 v3.6
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
- MCP 服务器:`~/.codex/config.toml``[mcp_servers]`
- **命令参数标准化**:后端统一使用 `app` 参数(取值:`claude``codex`
-**影响**:代码更规范,错误提示更友好
-**兼容性**:前端已完全适配,用户无需关心此变更
**Gemini**
#### 启动失败与恢复
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式)
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY``GOOGLE_GEMINI_API_KEY`
- 环境变量:支持 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等自定义变量
- MCP 服务器:`~/.gemini/settings.json``mcpServers`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
- 触发条件:`~/.cc-switch/config.json` 不存在、损坏或解析失败时触发。
- 用户动作:根据弹窗提示检查 JSON 语法,或从备份文件恢复。
- 备份位置与轮换:`~/.cc-switch/backups/backup_YYYYMMDD_HHMMSS.json`(最多保留 10 个,参见 `src-tauri/src/services/config.rs`)。
- 退出策略:为保护数据安全,出现上述错误时应用会弹窗提示并强制退出;修复后重新启动即可。
**CC Switch 存储**
#### v3.2.0 起的迁移机制
- 主配置SSOT`~/.cc-switch/config.json`包含供应商、MCP、Prompts 预设等)
- 设置:`~/.cc-switch/settings.json`
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的"副本文件"并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按"名称(忽略大小写)+ API Key"去重;若当前为空,将 live 合并项设为当前
- 归档与清理:
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
- 归档成功后删除原副本;失败则保留原文件(保守策略)
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
### 云同步设置
## 架构总览v3.6
1. 前往设置 → "自定义配置目录"
2. 选择您的云同步文件夹Dropbox、OneDrive、iCloud、坚果云等
3. 重启应用以应用
4. 在其他设备上重复操作以启用跨设备同步
### 架构重构亮点v3.6
> **注意**:首次启动会自动导入现有 Claude/Codex 配置作为默认供应商。
**后端重构Rust**:完成 5 阶段重构
## 架构总览
- **Phase 1**:统一错误处理(`AppError` + 国际化错误消息)
- **Phase 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`
- **Phase 3**:引入集成测试和事务机制(配置快照 + 失败回滚)
- **Phase 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`
- **Phase 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
### 设计原则
**前端重构React + TypeScript**:完成 4 阶段重构
```
┌─────────────────────────────────────────────────────────────┐
│ 前端 (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ UI │──│ (业务逻辑) │──│ (缓存/同步) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ 后端 (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ API 层) │──│ (业务层) │──│ (数据) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
- **Stage 1**建立测试基础设施vitest + MSW + @testing-library/react
- **Stage 2**:提取自定义 hooks`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport` 等)
- **Stage 3**:组件拆分和业务逻辑提取
- **Stage 4**:代码清理和格式化统一
**核心设计模式**
**测试覆盖**
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
- **原子写入**:临时文件 + 重命名模式防止配置损坏
- **并发安全**RwLock 与作用域守卫避免死锁
- **分层架构**清晰分离Commands → Services → Models
- Hooks 单元测试 100% 覆盖
- 集成测试覆盖关键流程App、SettingsDialog、MCP 面板)
- MSW 模拟后端 API确保测试独立性
**核心组件**
### 分层架构
- **ProviderService**:供应商增删改查、切换、回填、排序
- **McpService**MCP 服务器管理、导入导出、live 文件同步
- **ConfigService**:配置导入导出、备份轮换
- **SpeedtestService**API 端点延迟测量
- **前端Renderer**
- 技术栈TypeScript + React 18 + Vite + TailwindCSS 4
- 数据层TanStack React Query 统一查询与变更(`@/lib/query`Tauri API 统一封装(`@/lib/api`
- 业务逻辑层:自定义 Hooks`@/hooks`)承载领域逻辑,组件保持简洁
- 事件流:监听后端 `provider-switched` 事件,驱动 UI 刷新与托盘状态一致
- 组织结构:按领域拆分组件(`providers/settings/mcp/ui`
**v3.6 重构**
- **后端Tauri + Rust**
- **Commands 层**(接口层):`src-tauri/src/commands/*` 按领域拆分,仅负责参数解析和权限校验
- **Services 层**(业务层):`src-tauri/src/services/*` 承载核心逻辑,可复用和测试
- `ProviderService`:供应商增删改查、切换、回填、排序
- `McpService`MCP 服务器管理、导入导出、同步
- `ConfigService`:配置文件导入导出、备份恢复
- `SpeedtestService`API 端点延迟测试
- **模型与状态**
- `provider.rs`:领域模型(`Provider`, `ProviderManager`, `ProviderMeta`
- `app_config.rs`:多应用配置(`MultiAppConfig`, `AppId`, `McpRoot`
- `store.rs`:全局状态(`AppState` + `RwLock<MultiAppConfig>`
- **可靠性**
- 统一错误类型 `AppError`(包含本地化消息)
- 事务式变更(配置快照 + 失败回滚)
- 原子写入(临时文件 + 重命名,避免半写入)
- 托盘菜单与事件:切换后重建菜单并向前端发射 `provider-switched` 事件
- **设计要点SSOT + 双向同步)**
- **单一事实源**:供应商配置集中存放于 `~/.cc-switch/config.json`
- **切换时写入**:将目标供应商配置写入 live 文件Claude: `settings.json`Codex: `auth.json` + `config.toml`
- **回填机制**:切换后立即读回 live 文件,更新 SSOT保护用户手动修改
- **目录切换同步**修改配置目录时自动同步当前供应商到新目录WSL 环境完美支持)
- **编辑时优先 live**:编辑当前供应商时,优先加载 live 配置,确保显示实际生效的配置
- **兼容性与变更**
- 命令参数统一Tauri 命令仅接受 `app`(值为 `claude` / `codex`
- 前端类型统一:使用 `AppId` 表达应用标识(替代历史 `AppType` 导出)
- 后端5 阶段重构(错误处理 → 命令拆分 → 测试 → 服务 → 并发)
- 前端4 阶段重构(测试基础 → hooks → 组件 → 清理)
- 测试100% hooks 覆盖 + 集成测试(vitest + MSW
## 开发
@@ -346,12 +355,12 @@ cargo test --features test-hooks
**测试覆盖**
- Hooks 单元测试100% 覆盖)
- Hooks 单元测试100% 覆盖)
- `useProviderActions` - 供应商操作
- `useMcpActions` - MCP 管理
- `useSettings` 系列 - 设置管理
- `useImportExport` - 导入导出
- 集成测试
- 集成测试
- App 主应用流程
- SettingsDialog 完整交互
- MCP 面板功能
@@ -371,120 +380,36 @@ pnpm test:unit --coverage
## 技术栈
### 前端
**前端**React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
- **[React 18](https://react.dev/)** - 用户界面库
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
- **[TailwindCSS 4](https://tailwindcss.com/)** - 实用优先的 CSS 框架
- **[TanStack Query v5](https://tanstack.com/query/latest)** - 强大的数据获取与缓存
- **[react-i18next](https://react.i18next.com/)** - React 国际化框架
- **[react-hook-form](https://react-hook-form.com/)** - 高性能表单库
- **[zod](https://zod.dev/)** - TypeScript 优先的模式验证
- **[shadcn/ui](https://ui.shadcn.com/)** - 可复用的 React 组件
- **[@dnd-kit](https://dndkit.com/)** - 现代拖拽工具包
**后端**Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
### 后端
- **[Tauri 2.8](https://tauri.app/)** - 跨平台桌面应用框架
- tauri-plugin-updater - 自动更新
- tauri-plugin-process - 进程管理
- tauri-plugin-dialog - 文件对话框
- tauri-plugin-store - 持久化存储
- tauri-plugin-log - 日志记录
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言
- **[serde](https://serde.rs/)** - 序列化/反序列化框架
- **[tokio](https://tokio.rs/)** - 异步运行时
- **[thiserror](https://github.com/dtolnay/thiserror)** - 错误处理派生宏
### 测试工具
- **[vitest](https://vitest.dev/)** - 快速的单元测试框架
- **[MSW](https://mswjs.io/)** - API mock 工具
- **[@testing-library/react](https://testing-library.com/react)** - React 测试工具
**测试**vitest · MSW · @testing-library/react
## 项目结构
```
├── src/ # 前端代码 (React + TypeScript)
│ ├── components/ # React 组件
│ ├── providers/ # 供应商管理组件
│ │ │ ├── forms/ # 表单子组件Claude/Codex 字段)
│ │ │ ├── ProviderList.tsx
│ │ │ ├── ProviderForm.tsx
│ │ │ ├── AddProviderDialog.tsx
│ │ │ └── EditProviderDialog.tsx
│ │ ├── settings/ # 设置相关组件
│ │ │ ├── SettingsDialog.tsx
│ │ │ ├── DirectorySettings.tsx
│ │ │ └── ImportExportSection.tsx
│ │ ├── mcp/ # MCP 管理组件
│ │ │ ├── McpPanel.tsx
│ │ │ ├── McpFormModal.tsx
│ │ │ └── McpWizard.tsx
│ │ └── ui/ # shadcn/ui 基础组件
│ ├── hooks/ # 自定义 Hooks业务逻辑层
│ │ ├── useProviderActions.ts # 供应商操作
│ │ ├── useMcpActions.ts # MCP 操作
│ │ ├── useSettings.ts # 设置管理
│ │ ├── useImportExport.ts # 导入导出
│ │ └── useDirectorySettings.ts # 目录配置
├── src/ # 前端 (React + TypeScript)
│ ├── components/ # UI 组件 (providers/settings/mcp/ui)
│ ├── hooks/ # 自定义 hooks (业务逻辑)
│ ├── lib/
│ │ ├── api/ # Tauri API 封装(类型安全)
│ │ │ ├── providers.ts # 供应商 API
│ │ │ ├── settings.ts # 设置 API
│ │ │ ├── mcp.ts # MCP API
│ │ │ └── usage.ts # 用量查询 API
│ │ └── query/ # TanStack Query 配置
│ ├── queries.ts # 查询定义
│ ├── mutations.ts # 变更定义
│ │ └── queryClient.ts
│ ├── i18n/ # 国际化资源
│ │ └── locales/
│ │ ├── zh/ # 中文翻译
│ │ └── en/ # 英文翻译
│ ├── config/ # 配置与预设
│ │ ├── claudeProviderPresets.ts # Claude 供应商预设
│ │ ├── codexProviderPresets.ts # Codex 供应商预设
│ │ └── mcpPresets.ts # MCP 服务器模板
│ ├── utils/ # 工具函数
│ │ ├── postChangeSync.ts # 配置同步工具
│ │ └── ...
├── i18n/locales/ # 翻译 (zh/en)
├── config/ # 预设 (providers/mcp)
│ └── types/ # TypeScript 类型定义
├── src-tauri/ # 后端代码 (Rust)
── src/
├── commands/ # Tauri 命令层(按领域拆分
│ │ ├── provider.rs # 供应商命令
│ │ ├── mcp.rs # MCP 命令
│ │ ├── config.rs # 配置查询命令
│ │ ├── settings.rs # 设置命令
│ ├── plugin.rs # 插件命令
├── import_export.rs # 导入导出命令
└── misc.rs # 杂项命令
│ ├── services/ # Service 层(业务逻辑)
├── provider.rs # ProviderService
│ │ │ ├── mcp.rs # McpService
│ │ │ ├── config.rs # ConfigService
│ │ │ └── speedtest.rs # SpeedtestService
│ │ ├── app_config.rs # 配置数据模型
│ │ ├── provider.rs # 供应商领域模型
│ │ ├── store.rs # 全局状态管理
│ │ ├── mcp.rs # MCP 同步与校验
│ │ ├── error.rs # 统一错误类型
│ │ ├── usage_script.rs # 用量脚本执行
│ │ ├── claude_plugin.rs # Claude 插件管理
│ │ └── lib.rs # 应用入口
│ ├── capabilities/ # Tauri 权限配置
│ └── icons/ # 应用图标
├── tests/ # 前端测试v3.6 新增)
│ ├── hooks/ # Hooks 单元测试
│ ├── components/ # 组件集成测试
│ └── setup.ts # 测试配置
└── assets/ # 静态资源
├── screenshots/ # 界面截图
└── partners/ # 合作商资源
├── logos/ # 合作商 Logo
└── banners/ # 合作商横幅/宣传图
├── src-tauri/ # 后端 (Rust)
── src/
├── commands/ # Tauri 命令层(按领域)
├── services/ # 业务逻辑层
├── app_config.rs # 配置数据模型
├── provider.rs # 供应商领域模型
├── mcp.rs # MCP 同步与校验
└── lib.rs # 应用入口 & 托盘菜单
├── tests/ # 前端测试
├── hooks/ # 单元测试
└── components/ # 集成测试
└── assets/ # 截图 & 合作商资源
```
## 更新日志
@@ -506,7 +431,7 @@ pnpm test:unit --coverage
- 通过类型检查:`pnpm typecheck`
- 通过格式检查:`pnpm format:check`
- 通过单元测试:`pnpm test:unit`
- 功能性 PR 请先经过 issue 讨论
- 💡 新功能开发前,欢迎先开 issue 讨论实现方案
## Star History

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

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

1674
deplink.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,8 @@
- mcp 管理器 ✅
- i18n ✅
- gemini cli
- homebrew 支持
- homebrew 支持
- memory 管理
- codex 更多预设供应商
- 云同步
- 本地代理

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "cc-switch",
"version": "3.6.0",
"description": "Claude Code & Codex 供应商切换工具",
"version": "3.7.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -37,6 +37,7 @@
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -45,6 +46,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -53,6 +55,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
@@ -75,5 +78,6 @@
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
}
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

139
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lint':
specifier: ^6.8.5
version: 6.8.5
@@ -38,6 +41,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
'@lobehub/icons-static-svg':
specifier: ^1.73.0
version: 1.73.0
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -62,6 +68,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tailwindcss/vite':
specifier: ^4.1.13
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
@@ -280,12 +289,21 @@ packages:
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
@@ -573,9 +591,15 @@ packages:
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/html@1.3.12':
resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
@@ -585,6 +609,12 @@ packages:
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@lezer/markdown@1.6.0':
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
'@lobehub/icons-static-svg@1.73.0':
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -821,6 +851,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
@@ -856,6 +899,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
@@ -967,6 +1019,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.2.4':
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -2468,6 +2533,26 @@ snapshots:
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@lezer/common': 1.2.3
'@lezer/css': 1.3.0
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@lezer/css': 1.3.0
'@lezer/html': 1.3.12
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.18.7
@@ -2483,6 +2568,16 @@ snapshots:
'@codemirror/language': 6.11.3
'@lezer/json': 1.0.3
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@lezer/markdown': 1.6.0
'@codemirror/language@6.11.3':
dependencies:
'@codemirror/state': 6.5.2
@@ -2713,10 +2808,22 @@ snapshots:
'@lezer/common@1.2.3': {}
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/html@1.3.12':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.2.3
@@ -2733,6 +2840,13 @@ snapshots:
dependencies:
'@lezer/common': 1.2.3
'@lezer/markdown@1.6.0':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lobehub/icons-static-svg@1.73.0': {}
'@marijn/find-cluster-break@1.0.2': {}
'@mswjs/interceptors@0.40.0':
@@ -2968,6 +3082,15 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -3021,6 +3144,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
'@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.23
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -3115,6 +3245,15 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/rect@1.1.1': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}

208
scripts/extract-icons.js Normal file
View File

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

95
scripts/filter-icons.js Normal file
View File

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

View File

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

468
src-tauri/Cargo.lock generated
View File

@@ -17,6 +17,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.7.8"
@@ -280,6 +291,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -484,6 +506,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -558,26 +599,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cc-switch"
version = "3.6.0"
version = "3.7.1"
dependencies = [
"anyhow",
"auto-launch",
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"once_cell",
"regex",
"reqwest",
"rquickjs",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
@@ -585,10 +635,14 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"url",
"winreg 0.52.0",
"zip 2.4.2",
]
[[package]]
@@ -644,6 +698,16 @@ dependencies = [
"windows-link 0.2.0",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -663,6 +727,32 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -728,6 +818,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -752,6 +857,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"
@@ -834,6 +945,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "deflate64"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
[[package]]
name = "deranged"
version = "0.5.4"
@@ -876,6 +993,16 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
@@ -896,6 +1023,17 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
@@ -981,6 +1119,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"
@@ -1034,7 +1181,7 @@ dependencies = [
"rustc_version",
"toml 0.9.7",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -1669,6 +1816,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"
@@ -1699,6 +1852,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1745,6 +1907,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"
@@ -1992,6 +2160,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "io-uring"
version = "0.7.10"
@@ -2089,6 +2266,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.81"
@@ -2247,6 +2434,27 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -2776,6 +2984,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"
@@ -2860,6 +3078,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3621,6 +3849,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"
@@ -3727,6 +3965,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3790,6 +4037,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "seahash"
version = "4.1.0"
@@ -3962,6 +4215,44 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.11.4",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serial_test"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
dependencies = [
"futures",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -3994,6 +4285,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4320,6 +4622,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",
@@ -4435,6 +4738,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"
@@ -4589,7 +4913,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -4789,6 +5113,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"
@@ -5162,6 +5495,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5712,6 +6051,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"
@@ -6081,6 +6431,25 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -6188,6 +6557,15 @@ dependencies = [
"rustix",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.0"
@@ -6319,6 +6697,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "zerotrie"
@@ -6353,6 +6745,36 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.3",
"hmac",
"indexmap 2.11.4",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.17",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zip"
version = "4.6.1"
@@ -6365,6 +6787,46 @@ dependencies = [
"memchr",
]
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.7.0"

View File

@@ -1,7 +1,7 @@
[package]
name = "cc-switch"
version = "3.6.0"
description = "Claude Code & Codex 供应商配置管理工具"
version = "3.7.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/farion1231/cc-switch"
@@ -25,14 +25,15 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = "0.4"
tauri = { version = "2.8.2", features = ["tray-icon"] }
chrono = { version = "0.4", features = ["serde"] }
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"
@@ -42,10 +43,21 @@ futures = "0.3"
regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "1.0"
anyhow = "1.0"
zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
@@ -57,3 +69,7 @@ lto = "thin"
opt-level = "s"
panic = "abort"
strip = "symbols"
[dev-dependencies]
serial_test = "3"
tempfile = "3"

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

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
let store = match app.store_builder("app_paths.json").build() {
Ok(store) => store,
Err(e) => {
log::warn!("无法创建 Store: {}", e);
log::warn!("无法创建 Store: {e}");
return None;
}
};
@@ -46,21 +46,17 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
if !path.exists() {
log::warn!(
"Store 中配置的 app_config_dir 不存在: {:?}\n\
将使用默认路径。",
path
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
将使用默认路径。"
);
return None;
}
log::info!("使用 Store 中的 app_config_dir: {:?}", path);
log::info!("使用 Store 中的 app_config_dir: {path:?}");
Some(path)
}
Some(_) => {
log::warn!(
"Store 中的 {} 类型不正确,应为字符串",
STORE_KEY_APP_CONFIG_DIR
);
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
None
}
None => None,
@@ -82,14 +78,14 @@ pub fn set_app_config_dir_to_store(
let store = app
.store_builder("app_paths.json")
.build()
.map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
match path {
Some(p) => {
let trimmed = p.trim();
if !trimmed.is_empty() {
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
log::info!("已将 app_config_dir 写入 Store: {}", trimmed);
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
} else {
store.delete(STORE_KEY_APP_CONFIG_DIR);
log::info!("已从 Store 中删除 app_config_dir 配置");
@@ -103,7 +99,7 @@ pub fn set_app_config_dir_to_store(
store
.save()
.map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
refresh_app_config_dir_override(app);
Ok(())

View File

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

View File

@@ -37,7 +37,7 @@ fn ensure_mcp_override_migrated() {
if let Some(parent) = new_path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
log::warn!("创建 MCP 目录失败: {}", err);
log::warn!("创建 MCP 目录失败: {err}");
return;
}
}
@@ -118,9 +118,10 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
let t_opt = spec.get("type").and_then(|x| x.as_str());
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) {
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio''http'(或省略表示 stdio".into(),
"MCP 服务器 type 必须是 'stdio''http' 或 'sse'(或省略表示 stdio".into(),
));
}
@@ -134,13 +135,15 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
}
}
// http 类型必须有 url
if is_http {
// http/sse 类型必须有 url
if is_http || is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
return Err(AppError::McpValidation(if is_http {
"http 类型的 MCP 服务器缺少 url 字段".into()
} else {
"sse 类型的 MCP 服务器缺少 url 字段".into()
}));
}
}
@@ -231,6 +234,23 @@ pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
Ok(false)
}
/// 读取 ~/.claude.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(std::collections::HashMap::new());
}
let root = read_json_value(&path)?;
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)
}
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
@@ -250,14 +270,13 @@ pub fn set_mcp_servers_map(
map.clone()
} else {
return Err(AppError::McpValidation(format!(
"MCP 服务器 '{}' 不是对象",
id
"MCP 服务器 '{id}' 不是对象"
)));
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id))
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
})?;
obj = server_obj;
}

View File

@@ -80,7 +80,7 @@ pub fn write_claude_config() -> Result<bool, AppError> {
if changed || !path.exists() {
let serialized = serde_json::to_string_pretty(&obj)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
Ok(true)
} else {
Ok(false)
@@ -114,7 +114,7 @@ pub fn clear_claude_config() -> Result<bool, AppError> {
let serialized =
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
Ok(true)
}

View File

@@ -37,8 +37,8 @@ pub fn get_codex_provider_paths(
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
(auth_path, config_path)
}

View File

@@ -29,6 +29,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::Gemini => {
let env_path = crate::gemini_config::get_gemini_env_path();
let exists = env_path.exists();
let path = crate::gemini_config::get_gemini_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -44,6 +53,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -55,16 +65,17 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
};
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
@@ -87,14 +98,14 @@ pub async fn pick_directory(
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
@@ -114,13 +125,116 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.claude.clone())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证是否为有效的 JSON如果不为空
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
guard.common_config_snippets.claude = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
guard.save().map_err(|e| e.to_string())?;
Ok(())
}
/// 获取通用配置片段(统一接口)
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.get(&app).cloned())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app {
AppType::Claude | AppType::Gemini => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
AppType::Codex => {
// TOML 格式暂不验证(或可使用 toml crate
// 注意TOML 验证较为复杂,暂时跳过
}
}
}
guard.common_config_snippets.set(
&app,
if snippet.trim().is_empty() {
None
} else {
Some(snippet)
},
);
guard.save().map_err(|e| e.to_string())?;
Ok(())
}

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ pub async fn export_config_to_file(
}))
})
.await
.map_err(|e| format!("导出配置失败: {}", e))?
.map_err(|e| format!("导出配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())
}
@@ -39,7 +39,7 @@ pub async fn import_config_from_file(
ConfigService::load_config_for_import(&path_buf)
})
.await
.map_err(|e| format!("导入配置失败: {}", e))?
.map_err(|e| format!("导入配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())?;
{

View File

@@ -50,6 +50,7 @@ pub struct McpConfigResponse {
use std::str::FromStr;
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn get_mcp_config(
state: State<'_, AppState>,
app: String,
@@ -66,6 +67,7 @@ pub async fn get_mcp_config(
}
/// 在 config.json 中新增或更新一个 MCP 服务器定义
/// [已废弃] 该命令仍然使用旧的分应用API会转换为统一结构
#[tauri::command]
pub async fn upsert_mcp_server_in_config(
state: State<'_, AppState>,
@@ -74,8 +76,59 @@ pub async fn upsert_mcp_server_in_config(
spec: serde_json::Value,
sync_other_side: Option<bool>,
) -> Result<bool, String> {
use crate::app_config::McpServer;
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::upsert_server(&state, app_ty, &id, spec, sync_other_side.unwrap_or(false))
// 读取现有的服务器(如果存在)
let existing_server = {
let cfg = state.config.read().map_err(|e| e.to_string())?;
if let Some(servers) = &cfg.mcp.servers {
servers.get(&id).cloned()
} else {
None
}
};
// 构建新的统一服务器结构
let mut new_server = if let Some(mut existing) = existing_server {
// 更新现有服务器
existing.server = spec.clone();
existing.apps.set_enabled_for(&app_ty, true);
existing
} else {
// 创建新服务器
let mut apps = crate::app_config::McpApps::default();
apps.set_enabled_for(&app_ty, true);
// 尝试从 spec 中提取 name否则使用 id
let name = spec
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
McpServer {
id: id.clone(),
name,
server: spec,
apps,
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
}
};
// 如果 sync_other_side 为 true也启用其他应用
if sync_other_side.unwrap_or(false) {
new_server.apps.claude = true;
new_server.apps.codex = true;
new_server.apps.gemini = true;
}
McpService::upsert_server(&state, new_server)
.map(|_| true)
.map_err(|e| e.to_string())
}
@@ -83,15 +136,15 @@ pub async fn upsert_mcp_server_in_config(
#[tauri::command]
pub async fn delete_mcp_server_in_config(
state: State<'_, AppState>,
app: String,
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
id: String,
) -> Result<bool, String> {
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::delete_server(&state, app_ty, &id).map_err(|e| e.to_string())
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
/// 设置启用状态并同步到客户端配置
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: String,
@@ -102,30 +155,43 @@ pub async fn set_mcp_enabled(
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
// ============================================================================
// v3.7.0 新增:统一 MCP 管理命令
// ============================================================================
use crate::app_config::McpServer;
/// 获取所有 MCP 服务器(统一结构)
#[tauri::command]
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Claude)
.map(|_| true)
.map_err(|e| e.to_string())
pub async fn get_mcp_servers(
state: State<'_, AppState>,
) -> Result<HashMap<String, McpServer>, String> {
McpService::get_all_servers(&state).map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
/// 添加或更新 MCP 服务器
#[tauri::command]
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Codex)
.map(|_| true)
.map_err(|e| e.to_string())
pub async fn upsert_mcp_server(
state: State<'_, AppState>,
server: McpServer,
) -> Result<(), String> {
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
}
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
/// 删除 MCP 服务器
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_claude(&state).map_err(|e| e.to_string())
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
/// 切换 MCP 服务器在指定应用的启用状态
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_codex(&state).map_err(|e| e.to_string())
pub async fn toggle_mcp_app(
state: State<'_, AppState>,
server_id: String,
app: String,
enabled: bool,
) -> Result<(), String> {
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}

View File

@@ -10,12 +10,12 @@ pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String>
let url = if url.starts_with("http://") || url.starts_with("https://") {
url
} else {
format!("https://{}", url)
format!("https://{url}")
};
app.opener()
.open_url(&url, None::<String>)
.map_err(|e| format!("打开链接失败: {}", e))?;
.map_err(|e| format!("打开链接失败: {e}"))?;
Ok(true)
}
@@ -29,7 +29,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
.map_err(|e| format!("打开更新页面失败: {e}"))?;
Ok(true)
}
@@ -37,7 +37,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ pub async fn queryProviderUsage(
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn testUsageScript(
state: State<'_, AppState>,
@@ -130,6 +131,8 @@ pub async fn testUsageScript(
app: String,
#[allow(non_snake_case)] scriptCode: String,
timeout: Option<u64>,
#[allow(non_snake_case)] apiKey: Option<String>,
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
@@ -140,6 +143,8 @@ pub async fn testUsageScript(
&providerId,
&scriptCode,
timeout.unwrap_or(10),
apiKey.as_deref(),
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
)

View File

@@ -37,3 +37,20 @@ pub async fn set_app_config_dir_override(
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
Ok(true)
}
/// 设置开机自启
#[tauri::command]
pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
if enabled {
crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?;
} else {
crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?;
}
Ok(true)
}
/// 获取开机自启状态
#[tauri::command]
pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
}

View File

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

View File

@@ -33,7 +33,7 @@ fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
return None;
}
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
Some(parent.join(format!("{}.json", file_name)))
Some(parent.join(format!("{file_name}.json")))
}
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
@@ -95,7 +95,7 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>)
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
get_claude_config_dir().join(format!("settings-{}.json", base_name))
get_claude_config_dir().join(format!("settings-{base_name}.json"))
}
/// 读取 JSON 配置文件
@@ -149,7 +149,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{}.tmp.{}", file_name, ts));
tmp.push(format!("{file_name}.tmp.{ts}"));
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;

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

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

View File

@@ -94,3 +94,28 @@ impl From<AppError> for String {
err.to_string()
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
context: &[(&str, &str)],
suggestion: Option<&str>,
) -> String {
use serde_json::json;
let mut ctx_map = serde_json::Map::new();
for (key, value) in context {
ctx_map.insert(key.to_string(), json!(value));
}
let error_obj = json!({
"code": code,
"context": ctx_map,
"suggestion": suggestion,
});
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
// 如果 JSON 序列化失败,返回简单格式
format!("ERROR:{code}")
})
}

View File

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

121
src-tauri/src/gemini_mcp.rs Normal file
View File

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

View File

@@ -1,32 +1,48 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod deeplink;
mod error;
mod gemini_config; // 新增
mod gemini_mcp;
mod init_status;
mod mcp;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod services;
mod settings;
mod store;
mod usage_script;
pub use app_config::{AppType, MultiAppConfig};
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, sync_enabled_to_claude, sync_enabled_to_codex,
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
};
pub use provider::{Provider, ProviderMeta};
pub use services::{
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
SpeedtestService,
};
pub use provider::Provider;
pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService};
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use std::sync::Arc;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
@@ -59,6 +75,129 @@ impl TrayTexts {
}
}
struct TrayAppSection {
app_type: AppType,
prefix: &'static str,
header_id: &'static str,
empty_id: &'static str,
header_label: &'static str,
log_name: &'static str,
}
const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
app_type: AppType::Codex,
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
app_type: AppType::Gemini,
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
};
let header = MenuItem::with_id(
app,
section.header_id,
section.header_label,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&header);
if manager.providers.is_empty() {
let empty_hint = MenuItem::with_id(
app,
section.empty_id,
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
return Ok(menu_builder.item(&empty_hint));
}
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&item);
}
Ok(menu_builder)
}
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
return true;
}
}
false
}
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
@@ -74,131 +213,29 @@ fn create_tray_menu(
// 顶部:打开主界面
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识
let claude_header =
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
.map_err(|e| AppError::Message(format!("创建Claude标题失败: {}", e)))?;
menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() {
// Sort providers by sortIndex, then by createdAt, then by name
let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
// Priority 1: sortIndex
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
// Priority 2: createdAt
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
// Priority 3: name
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("claude_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"claude_empty",
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建Claude空提示失败: {}", e)))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
// 添加Codex标题禁用状态仅作为分组标识
let codex_header =
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
.map_err(|e| AppError::Message(format!("创建Codex标题失败: {}", e)))?;
menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() {
// Sort providers by sortIndex, then by createdAt, then by name
let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
// Priority 1: sortIndex
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
// Priority 2: createdAt
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
// Priority 3: name
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("codex_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"codex_empty",
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建Codex空提示失败: {}", e)))?;
menu_builder = menu_builder.item(&empty_hint);
}
for section in TRAY_SECTIONS.iter() {
menu_builder = append_provider_section(
app,
menu_builder,
config.get_manager(&section.app_type),
section,
&tray_texts,
)?;
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {}", e)))?;
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {}", e)))
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
#[cfg(target_os = "macos")]
@@ -210,17 +247,17 @@ fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {}", err);
log::warn!("设置 Dock 显示状态失败: {err}");
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {}", err);
log::warn!("设置激活策略失败: {err}");
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {}", event_id);
log::info!("处理托盘菜单事件: {event_id}");
match event_id {
"show_main" => {
@@ -242,46 +279,74 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("退出应用");
app.exit(0);
}
id if id.starts_with("claude_") => {
let provider_id = id.strip_prefix("claude_").unwrap();
log::info!("切换到Claude供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Claude,
provider_id,
) {
log::error!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
let provider_id = id.strip_prefix("codex_").unwrap();
log::info!("切换到Codex供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Codex,
provider_id,
) {
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
log::warn!("未处理的菜单事件: {}", event_id);
if handle_provider_tray_event(app, event_id) {
return;
}
log::warn!("未处理的菜单事件: {event_id}");
}
}
}
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件
/// - 可选:在成功时聚焦主窗口
fn handle_deeplink_url(
app: &tauri::AppHandle,
url_str: &str,
focus_main_window: bool,
source: &str,
) -> bool {
if !url_str.starts_with("ccswitch://") {
return false;
}
log::info!("✓ Deep link URL detected from {source}: {url_str}");
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
log::info!(
"✓ Successfully parsed deep link: resource={}, app={}, name={}",
request.resource,
request.app,
request.name
);
if let Err(e) = app.emit("deeplink-import", &request) {
log::error!("✗ Failed to emit deeplink-import event: {e}");
} else {
log::info!("✓ Emitted deeplink-import event to frontend");
}
if focus_main_window {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
log::info!("✓ Window shown and focused");
}
}
}
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
}
//
/// 内部切换供应商函数
@@ -302,7 +367,7 @@ fn switch_provider_internal(
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {}", e);
log::error!("更新托盘菜单失败: {e}");
}
}
}
@@ -313,7 +378,7 @@ fn switch_provider_internal(
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {}", e);
log::error!("发射供应商切换事件失败: {e}");
}
}
Ok(())
@@ -329,13 +394,13 @@ async fn update_tray_menu(
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
return Ok(true);
}
Ok(false)
}
Err(err) => {
log::error!("创建托盘菜单失败: {}", err);
log::error!("创建托盘菜单失败: {err}");
Ok(false)
}
}
@@ -347,7 +412,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();
@@ -357,6 +442,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 {
@@ -391,7 +478,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
#[cfg(target_os = "macos")]
@@ -448,7 +535,7 @@ pub fn run() {
});
// 事件通知(可能早于前端订阅,不保证送达)
if let Err(e) = app.emit("configLoadError", payload_json) {
log::error!("发射配置加载错误事件失败: {}", e);
log::error!("发射配置加载错误事件失败: {e}");
}
// 同时缓存错误,供前端启动阶段主动拉取
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
@@ -462,7 +549,7 @@ pub fn run() {
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {}", e);
log::warn!("迁移 app_config_dir 失败: {e}");
}
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
@@ -472,7 +559,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)?;
@@ -496,6 +616,17 @@ pub fn run() {
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -516,6 +647,10 @@ pub fn run() {
commands::get_init_error,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
@@ -540,10 +675,18 @@ pub fn run() {
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
commands::sync_enabled_mcp_to_claude,
commands::sync_enabled_mcp_to_codex,
commands::import_mcp_from_claude,
commands::import_mcp_from_codex,
// v3.7.0: Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
commands::delete_prompt,
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
@@ -561,7 +704,25 @@ pub fn run() {
commands::save_file_dialog,
commands::open_file_dialog,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
commands::merge_deeplink_config,
commands::import_from_deeplink,
update_tray_menu,
// Environment variable management
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
commands::get_skills,
commands::install_skill,
commands::uninstall_skill,
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
]);
let app = builder
@@ -570,17 +731,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();
}
}
}
}
_ => {}
}
}

File diff suppressed because it is too large Load Diff

16
src-tauri/src/prompt.rs Normal file
View File

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

View File

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

View File

@@ -22,9 +22,19 @@ 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>,
/// 图标名称(如 "openai", "anthropic"
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色Hex 格式,如 "#00A67E"
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
}
impl Provider {
@@ -43,7 +53,10 @@ impl Provider {
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
}
}
}
@@ -63,11 +76,19 @@ pub struct UsageScript {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
/// 访问令牌(用于需要登录的接口
/// 用量查询专用的 API Key通用模板使用
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// 用量查询专用的 Base URL通用和 NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "baseUrl")]
pub base_url: Option<String>,
/// 访问令牌用于需要登录的接口NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "accessToken")]
pub access_token: Option<String>,
/// 用户ID用于需要用户标识的接口
/// 用户ID用于需要用户标识的接口NewAPI 模板使用
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
@@ -120,6 +141,15 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 合作伙伴标记(前端使用 isPartner保持字段名一致
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
/// 合作伙伴促销 key用于识别 PackyCode 等特殊供应商
#[serde(
rename = "partnerPromotionKey",
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
}
impl ProviderManager {

View File

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

View File

@@ -1,3 +1,4 @@
use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
@@ -20,7 +21,7 @@ impl ConfigService {
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_id = format!("backup_{}", timestamp);
let backup_id = format!("backup_{timestamp}");
let backup_dir = config_path
.parent()
@@ -29,7 +30,7 @@ impl ConfigService {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_path = backup_dir.join(format!("{}.json", backup_id));
let backup_path = backup_dir.join(format!("{backup_id}.json"));
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
@@ -123,6 +124,7 @@ impl ConfigService {
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Ok(())
}
@@ -145,9 +147,7 @@ impl ConfigService {
Some(provider) => provider.clone(),
None => {
log::warn!(
"当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步",
app_type,
current_id
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
);
return Ok(());
}
@@ -158,6 +158,7 @@ impl ConfigService {
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
}
Ok(())
@@ -169,18 +170,14 @@ impl ConfigService {
provider: &Provider,
) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id))
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::Config(format!(
"供应商 {} 的 Codex 配置缺少 auth 字段",
provider_id
))
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
})?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 Codex auth 配置必须是 JSON 对象",
provider_id
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
@@ -226,4 +223,35 @@ impl ConfigService {
Ok(())
}
fn sync_gemini_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::gemini_config::{env_to_json, read_gemini_env};
ProviderService::write_gemini_live(provider)?;
// 读回实际写入的内容并更新到配置中(包含 settings.json
let live_after_env = read_gemini_env()?;
let settings_path = crate::gemini_config::get_gemini_settings_path();
let live_after_config = if settings_path.exists() {
crate::config::read_json_file(&settings_path)?
} else {
serde_json::json!({})
};
let mut live_after = env_to_json(&live_after_env);
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), live_after_config);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
}

View File

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

View File

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

View File

@@ -1,191 +1,260 @@
use std::collections::HashMap;
use serde_json::Value;
use crate::app_config::{AppType, MultiAppConfig};
use crate::app_config::{AppType, McpServer, MultiAppConfig};
use crate::error::AppError;
use crate::mcp;
use crate::store::AppState;
/// MCP 相关业务逻辑
/// MCP 相关业务逻辑v3.7.0 统一结构)
pub struct McpService;
impl McpService {
/// 获取指定应用的 MCP 服务器快照,并在必要时回写归一化后的配置。
pub fn get_servers(state: &AppState, app: AppType) -> Result<HashMap<String, Value>, AppError> {
let mut cfg = state.config.write()?;
let (snapshot, normalized) = mcp::get_servers_snapshot_for(&mut cfg, &app);
drop(cfg);
if normalized > 0 {
state.save()?;
/// 获取所有 MCP 服务器(统一结构)
pub fn get_all_servers(state: &AppState) -> Result<HashMap<String, McpServer>, AppError> {
let cfg = state.config.read()?;
// 如果是新结构,直接返回
if let Some(servers) = &cfg.mcp.servers {
return Ok(servers.clone());
}
Ok(snapshot)
// 理论上不应该走到这里,因为 load 时会自动迁移
Err(AppError::localized(
"mcp.old_structure",
"检测到旧版 MCP 结构,请重启应用完成迁移",
"Old MCP structure detected, please restart app to complete migration",
))
}
/// 在 config.json 中新增或更新指定 MCP 服务器,并按需同步到对应客户端。
pub fn upsert_server(
state: &AppState,
app: AppType,
id: &str,
spec: Value,
sync_other_side: bool,
) -> Result<bool, AppError> {
let (changed, snapshot, sync_claude, sync_codex): (
bool,
Option<MultiAppConfig>,
bool,
bool,
) = {
/// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
{
let mut cfg = state.config.write()?;
let changed = mcp::upsert_in_config_for(&mut cfg, &app, id, spec)?;
// 修复默认启用unwrap_or(true)
// 新增的 MCP 如果缺少 enabled 字段,应该默认为启用状态
let enabled = cfg
.mcp_for(&app)
.servers
.get(id)
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut sync_claude = matches!(app, AppType::Claude) && enabled;
let mut sync_codex = matches!(app, AppType::Codex) && enabled;
// 修复sync_other_side=true 时,先将 MCP 复制到另一侧,然后强制同步
// 这才是"同步到另一侧"的正确语义:将 MCP 跨应用复制
if sync_other_side {
// 获取当前 MCP 条目的克隆(刚刚插入的,不可能失败)
let current_entry = cfg
.mcp_for(&app)
.servers
.get(id)
.cloned()
.expect("刚刚插入的 MCP 条目必定存在");
// 将该 MCP 复制到另一侧的 servers
let other_app = match app {
AppType::Claude => AppType::Codex,
AppType::Codex => AppType::Claude,
};
cfg.mcp_for_mut(&other_app)
.servers
.insert(id.to_string(), current_entry);
// 强制同步另一侧
match app {
AppType::Claude => sync_codex = true,
AppType::Codex => sync_claude = true,
}
// 确保 servers 字段存在
if cfg.mcp.servers.is_none() {
cfg.mcp.servers = Some(HashMap::new());
}
let snapshot = if sync_claude || sync_codex {
Some(cfg.clone())
} else {
None
};
let servers = cfg.mcp.servers.as_mut().unwrap();
let id = server.id.clone();
(changed, snapshot, sync_claude, sync_codex)
};
// 插入或更新
servers.insert(id, server.clone());
}
// 保持原有行为:始终尝试持久化,避免遗漏 normalize 带来的隐式变更
state.save()?;
if let Some(snapshot) = snapshot {
if sync_claude {
mcp::sync_enabled_to_claude(&snapshot)?;
}
if sync_codex {
mcp::sync_enabled_to_codex(&snapshot)?;
}
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
Ok(changed)
Ok(())
}
/// 删除 config.json 中的 MCP 服务器条目,并同步客户端配置。
pub fn delete_server(state: &AppState, app: AppType, id: &str) -> Result<bool, AppError> {
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
/// 删除 MCP 服务器
pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {
let server = {
let mut cfg = state.config.write()?;
let existed = mcp::delete_in_config_for(&mut cfg, &app, id)?;
let snapshot = if existed { Some(cfg.clone()) } else { None };
(existed, snapshot)
};
if existed {
state.save()?;
if let Some(snapshot) = snapshot {
match app {
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
}
if let Some(servers) = &mut cfg.mcp.servers {
servers.remove(id)
} else {
None
}
};
if let Some(server) = server {
state.save()?;
// 从所有应用的 live 配置中移除
Self::remove_server_from_all_apps(state, id, &server)?;
Ok(true)
} else {
Ok(false)
}
Ok(existed)
}
/// 设置 MCP 启用状态,并同步到客户端配置。
/// 切换指定应用的启用状态
pub fn toggle_app(
state: &AppState,
server_id: &str,
app: AppType,
enabled: bool,
) -> Result<(), AppError> {
let server = {
let mut cfg = state.config.write()?;
if let Some(servers) = &mut cfg.mcp.servers {
if let Some(server) = servers.get_mut(server_id) {
server.apps.set_enabled_for(&app, enabled);
Some(server.clone())
} else {
None
}
} else {
None
}
};
if let Some(server) = server {
state.save()?;
// 同步到对应应用
if enabled {
Self::sync_server_to_app(state, &server, &app)?;
} else {
Self::remove_server_from_app(state, server_id, &app)?;
}
}
Ok(())
}
/// 将 MCP 服务器同步到所有启用的应用
fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> {
let cfg = state.config.read()?;
for app in server.apps.enabled_apps() {
Self::sync_server_to_app_internal(&cfg, server, &app)?;
}
Ok(())
}
/// 将 MCP 服务器同步到指定应用
fn sync_server_to_app(
state: &AppState,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
let cfg = state.config.read()?;
Self::sync_server_to_app_internal(&cfg, server, app)
}
fn sync_server_to_app_internal(
cfg: &MultiAppConfig,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
match app {
AppType::Claude => {
mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?;
}
AppType::Codex => {
mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?;
}
AppType::Gemini => {
mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?;
}
}
Ok(())
}
/// 从所有曾启用过该服务器的应用中移除
fn remove_server_from_all_apps(
state: &AppState,
id: &str,
server: &McpServer,
) -> Result<(), AppError> {
// 从所有曾启用的应用中移除
for app in server.apps.enabled_apps() {
Self::remove_server_from_app(state, id, &app)?;
}
Ok(())
}
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
}
Ok(())
}
/// 手动同步所有启用的 MCP 服务器到对应的应用
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
Self::sync_server_to_apps(state, server)?;
}
Ok(())
}
// ========================================================================
// 兼容层:支持旧的 v3.6.x 命令(已废弃,将在 v4.0 移除)
// ========================================================================
/// [已废弃] 获取指定应用的 MCP 服务器(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use get_all_servers instead")]
pub fn get_servers(
state: &AppState,
app: AppType,
) -> Result<HashMap<String, serde_json::Value>, AppError> {
let all_servers = Self::get_all_servers(state)?;
let mut result = HashMap::new();
for (id, server) in all_servers {
if server.apps.is_enabled_for(&app) {
result.insert(id, server.server);
}
}
Ok(result)
}
/// [已废弃] 设置 MCP 服务器在指定应用的启用状态(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use toggle_app instead")]
pub fn set_enabled(
state: &AppState,
app: AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
let (existed, snapshot): (bool, Option<MultiAppConfig>) = {
let mut cfg = state.config.write()?;
let existed = mcp::set_enabled_flag_for(&mut cfg, &app, id, enabled)?;
let snapshot = if existed { Some(cfg.clone()) } else { None };
(existed, snapshot)
};
if existed {
state.save()?;
if let Some(snapshot) = snapshot {
match app {
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
}
}
}
Ok(existed)
Self::toggle_app(state, id, app, enabled)?;
Ok(true)
}
/// 手动同步启用的 MCP 服务器到客户端配置。
/// [已废弃] 同步启用的 MCP 到指定应用(兼容旧 API
#[deprecated(since = "3.7.0", note = "Use sync_all_enabled instead")]
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
let (snapshot, normalized): (MultiAppConfig, usize) = {
let mut cfg = state.config.write()?;
let normalized = mcp::normalize_servers_for(&mut cfg, &app);
(cfg.clone(), normalized)
};
if normalized > 0 {
state.save()?;
}
match app {
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
if server.apps.is_enabled_for(&app) {
Self::sync_server_to_app(state, server, &app)?;
}
}
Ok(())
}
/// 从 Claude 客户端配置导入 MCP 定义。
/// 从 Claude 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let changed = mcp::import_from_claude(&mut cfg)?;
let count = mcp::import_from_claude(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
state.save()?;
Ok(count)
}
/// 从 Codex 客户端配置导入 MCP 定义。
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let changed = mcp::import_from_codex(&mut cfg)?;
let count = mcp::import_from_codex(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
state.save()?;
Ok(count)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_gemini(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,6 +16,20 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecurityAuthSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<SecurityAuthSettings>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -32,7 +46,14 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
@@ -57,7 +78,10 @@ impl Default for AppSettings {
enable_claude_plugin_integration: false,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
@@ -89,6 +113,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.gemini_config_dir = self
.gemini_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
@@ -171,6 +202,27 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
Ok(())
}
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings();
let current = settings
.security
.as_ref()
.and_then(|sec| sec.auth.as_ref())
.and_then(|auth| auth.selected_type.as_deref());
if current == Some(selected_type) {
return Ok(());
}
let mut security = settings.security.unwrap_or_default();
let mut auth = security.auth.unwrap_or_default();
auth.selected_type = Some(selected_type.to_string());
security.auth = Some(auth);
settings.security = Some(security);
update_settings(settings)
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -186,3 +238,11 @@ pub fn get_codex_override_dir() -> Option<PathBuf> {
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_gemini_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.gemini_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}

View File

@@ -33,15 +33,15 @@ pub async fn execute_usage_script(
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
"usage_script.runtime_create_failed",
format!("创建 JS 运行时失败: {}", e),
format!("Failed to create JS runtime: {}", e),
format!("创建 JS 运行时失败: {e}"),
format!("Failed to create JS runtime: {e}"),
)
})?;
let context = Context::full(&runtime).map_err(|e| {
AppError::localized(
"usage_script.context_create_failed",
format!("创建 JS 上下文失败: {}", e),
format!("Failed to create JS context: {}", e),
format!("创建 JS 上下文失败: {e}"),
format!("Failed to create JS context: {e}"),
)
})?;
@@ -50,8 +50,8 @@ pub async fn execute_usage_script(
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized(
"usage_script.config_parse_failed",
format!("解析配置失败: {}", e),
format!("Failed to parse config: {}", e),
format!("解析配置失败: {e}"),
format!("Failed to parse config: {e}"),
)
})?;
@@ -59,8 +59,8 @@ pub async fn execute_usage_script(
let request: rquickjs::Object = config.get("request").map_err(|e| {
AppError::localized(
"usage_script.request_missing",
format!("缺少 request 配置: {}", e),
format!("Missing request config: {}", e),
format!("缺少 request 配置: {e}"),
format!("Missing request config: {e}"),
)
})?;
@@ -70,8 +70,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.request_serialize_failed",
format!("序列化 request 失败: {}", e),
format!("Failed to serialize request: {}", e),
format!("序列化 request 失败: {e}"),
format!("Failed to serialize request: {e}"),
)
})?
.ok_or_else(|| {
@@ -85,8 +85,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.get_string_failed",
format!("获取字符串失败: {}", e),
format!("Failed to get string: {}", e),
format!("获取字符串失败: {e}"),
format!("Failed to get string: {e}"),
)
})?;
@@ -98,8 +98,8 @@ pub async fn execute_usage_script(
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
AppError::localized(
"usage_script.request_format_invalid",
format!("request 配置格式错误: {}", e),
format!("Invalid request config format: {}", e),
format!("request 配置格式错误: {e}"),
format!("Invalid request config format: {e}"),
)
})?;
@@ -111,15 +111,15 @@ pub async fn execute_usage_script(
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
"usage_script.runtime_create_failed",
format!("创建 JS 运行时失败: {}", e),
format!("Failed to create JS runtime: {}", e),
format!("创建 JS 运行时失败: {e}"),
format!("Failed to create JS runtime: {e}"),
)
})?;
let context = Context::full(&runtime).map_err(|e| {
AppError::localized(
"usage_script.context_create_failed",
format!("创建 JS 上下文失败: {}", e),
format!("Failed to create JS context: {}", e),
format!("创建 JS 上下文失败: {e}"),
format!("Failed to create JS context: {e}"),
)
})?;
@@ -128,8 +128,8 @@ pub async fn execute_usage_script(
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized(
"usage_script.config_reparse_failed",
format!("重新解析配置失败: {}", e),
format!("Failed to re-parse config: {}", e),
format!("重新解析配置失败: {e}"),
format!("Failed to re-parse config: {e}"),
)
})?;
@@ -137,8 +137,8 @@ pub async fn execute_usage_script(
let extractor: Function = config.get("extractor").map_err(|e| {
AppError::localized(
"usage_script.extractor_missing",
format!("缺少 extractor 函数: {}", e),
format!("Missing extractor function: {}", e),
format!("缺少 extractor 函数: {e}"),
format!("Missing extractor function: {e}"),
)
})?;
@@ -147,8 +147,8 @@ pub async fn execute_usage_script(
ctx.json_parse(response_data.as_str()).map_err(|e| {
AppError::localized(
"usage_script.response_parse_failed",
format!("解析响应 JSON 失败: {}", e),
format!("Failed to parse response JSON: {}", e),
format!("解析响应 JSON 失败: {e}"),
format!("Failed to parse response JSON: {e}"),
)
})?;
@@ -156,8 +156,8 @@ pub async fn execute_usage_script(
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
AppError::localized(
"usage_script.extractor_exec_failed",
format!("执行 extractor 失败: {}", e),
format!("Failed to execute extractor: {}", e),
format!("执行 extractor 失败: {e}"),
format!("Failed to execute extractor: {e}"),
)
})?;
@@ -167,8 +167,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.result_serialize_failed",
format!("序列化结果失败: {}", e),
format!("Failed to serialize result: {}", e),
format!("序列化结果失败: {e}"),
format!("Failed to serialize result: {e}"),
)
})?
.ok_or_else(|| {
@@ -182,8 +182,8 @@ pub async fn execute_usage_script(
.map_err(|e| {
AppError::localized(
"usage_script.get_string_failed",
format!("获取字符串失败: {}", e),
format!("Failed to get string: {}", e),
format!("获取字符串失败: {e}"),
format!("Failed to get string: {e}"),
)
})?;
@@ -191,8 +191,8 @@ pub async fn execute_usage_script(
serde_json::from_str(&result_json).map_err(|e| {
AppError::localized(
"usage_script.json_parse_failed",
format!("JSON 解析失败: {}", e),
format!("JSON parse failed: {}", e),
format!("JSON 解析失败: {e}"),
format!("JSON parse failed: {e}"),
)
})
})?
@@ -225,8 +225,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {}", e),
format!("Failed to create client: {}", e),
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
@@ -255,8 +255,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
let resp = req.send().await.map_err(|e| {
AppError::localized(
"usage_script.request_failed",
format!("请求失败: {}", e),
format!("Request failed: {}", e),
format!("请求失败: {e}"),
format!("Request failed: {e}"),
)
})?;
@@ -264,8 +264,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
let text = resp.text().await.map_err(|e| {
AppError::localized(
"usage_script.read_response_failed",
format!("读取响应失败: {}", e),
format!("Failed to read response: {}", e),
format!("读取响应失败: {e}"),
format!("Failed to read response: {e}"),
)
})?;
@@ -277,8 +277,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
};
return Err(AppError::localized(
"usage_script.http_error",
format!("HTTP {} : {}", status, preview),
format!("HTTP {} : {}", status, preview),
format!("HTTP {status} : {preview}"),
format!("HTTP {status} : {preview}"),
));
}
@@ -300,8 +300,8 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
validate_single_usage(item).map_err(|e| {
AppError::localized(
"usage_script.array_validation_failed",
format!("数组索引[{}]验证失败: {}", idx, e),
format!("Validation failed at index [{}]: {}", idx, e),
format!("数组索引[{idx}]验证失败: {e}"),
format!("Validation failed at index [{idx}]: {e}"),
)
})?;
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.6.0",
"version": "3.7.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -14,17 +14,22 @@
{
"label": "main",
"title": "",
"width": 900,
"titleBarStyle": "Overlay",
"width": 1000,
"height": 650,
"minWidth": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Transparent"
"center": true
}
],
"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 +47,17 @@
"wix": {
"template": "wix/per-user-main.wxs"
}
},
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["ccswitch"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [

View File

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

View File

@@ -4,7 +4,7 @@ use tauri::async_runtime;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
MultiAppConfig, Provider,
MultiAppConfig, Provider, ProviderMeta,
};
#[path = "support.rs"]
@@ -63,9 +63,7 @@ fn sync_claude_provider_writes_live_settings() {
// 额外确认写入位置位于测试 HOME 下
assert!(
settings_path.starts_with(home),
"settings path {:?} should reside under test HOME {:?}",
settings_path,
home
"settings path {settings_path:?} should reside under test HOME {home:?}"
);
}
@@ -224,10 +222,13 @@ mode = "dev"
text.contains("[profile]"),
"non-MCP table should be preserved"
);
// 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo
assert!(
text.contains("mcp_servers") || text.contains("[mcp.servers]"),
"one server table style should be present"
text.contains("mcp_servers"),
"mcp_servers table should be present"
);
assert!(
!text.contains("[mcp.servers]"),
"invalid [mcp.servers] table should not appear"
);
assert!(
text.contains("echo") && text.contains("command = \"echo\""),
@@ -236,14 +237,14 @@ mode = "dev"
}
#[test]
fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
fn sync_enabled_to_codex_migrates_erroneous_mcp_dot_servers_to_mcp_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
// 预置 mcp.servers 风格
// 预置错误的 mcp.servers 风格(应迁移为顶层 mcp_servers
let seed = r#"[mcp]
other = "keep"
[mcp.servers]
@@ -262,14 +263,14 @@ fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let text = fs::read_to_string(&path).expect("read config.toml");
// 仍应采用 mcp.servers 风格
// 应迁移到顶层 mcp_servers并移除错误的 mcp.servers
assert!(
text.contains("[mcp.servers]"),
"should keep mcp.servers style"
text.contains("mcp_servers"),
"should migrate to mcp_servers table"
);
assert!(
!text.contains("mcp_servers"),
"should not switch to mcp_servers"
!text.contains("[mcp.servers]"),
"invalid [mcp.servers] table should be removed"
);
}
@@ -489,16 +490,19 @@ url = "https://example.com"
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
assert!(changed >= 2, "should import both servers");
let servers = &config.mcp.codex.servers;
let echo = servers
.get("echo_server")
.and_then(|v| v.as_object())
.expect("echo server");
assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true));
let server_spec = echo
.get("server")
.and_then(|v| v.as_object())
.expect("server spec");
// v3.7.0: 检查统一结构
let servers = config
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let echo = servers.get("echo_server").expect("echo server");
assert!(
echo.apps.codex,
"Codex app should be enabled for echo_server"
);
let server_spec = echo.server.as_object().expect("server spec");
assert_eq!(
server_spec
.get("command")
@@ -507,14 +511,12 @@ url = "https://example.com"
"echo"
);
let http = servers
.get("http_server")
.and_then(|v| v.as_object())
.expect("http server");
let http_spec = http
.get("server")
.and_then(|v| v.as_object())
.expect("http spec");
let http = servers.get("http_server").expect("http server");
assert!(
http.apps.codex,
"Codex app should be enabled for http_server"
);
let http_spec = http.server.as_object().expect("http spec");
assert_eq!(
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
"https://example.com"
@@ -539,36 +541,51 @@ command = "echo"
.expect("write codex config");
let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert(
"existing".into(),
json!({
"id": "existing",
"name": "existing",
"enabled": false,
"server": {
// v3.7.0: 在统一结构中创建已存在的服务器
config.mcp.servers = Some(std::collections::HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"existing".to_string(),
cc_switch_lib::McpServer {
id: "existing".to_string(),
name: "existing".to_string(),
server: json!({
"type": "stdio",
"command": "prev"
}
}),
}),
apps: cc_switch_lib::McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
assert!(changed >= 1, "should mark change for enabled flag");
// v3.7.0: 检查统一结构
let entry = config
.mcp
.codex
.servers
.as_ref()
.unwrap()
.get("existing")
.and_then(|v| v.as_object())
.expect("existing entry");
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
let spec = entry
.get("server")
.and_then(|v| v.as_object())
.expect("server spec");
// 保留原 command确保导入不会覆盖现有 server 细节
assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev"));
// 验证 Codex 应用已启用
assert!(entry.apps.codex, "Codex app should be enabled after import");
// 验证现有配置被保留server 不应被覆盖)
let spec = entry.server.as_object().expect("server spec");
assert_eq!(
spec.get("command").and_then(|v| v.as_str()),
Some("prev"),
"existing server config should be preserved, not overwritten by import"
);
}
#[test]
@@ -646,34 +663,49 @@ fn import_from_claude_merges_into_config() {
.expect("write claude json");
let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert(
"stdio-enabled".into(),
json!({
"id": "stdio-enabled",
"name": "stdio-enabled",
"enabled": false,
"server": {
// v3.7.0: 在统一结构中创建已存在的服务器
config.mcp.servers = Some(std::collections::HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"stdio-enabled".to_string(),
cc_switch_lib::McpServer {
id: "stdio-enabled".to_string(),
name: "stdio-enabled".to_string(),
server: json!({
"type": "stdio",
"command": "prev"
}
}),
}),
apps: cc_switch_lib::McpApps {
claude: false, // 初始未启用
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
assert!(changed >= 1, "should mark at least one change");
// v3.7.0: 检查统一结构
let entry = config
.mcp
.claude
.servers
.as_ref()
.unwrap()
.get("stdio-enabled")
.and_then(|v| v.as_object())
.expect("entry exists");
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
let server = entry
.get("server")
.and_then(|v| v.as_object())
.expect("server obj");
// 验证 Claude 应用已启用
assert!(
entry.apps.claude,
"Claude app should be enabled after import"
);
// 验证现有配置被保留server 不应被覆盖)
let server = entry.server.as_object().expect("server obj");
assert_eq!(
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
"prev",
@@ -909,6 +941,121 @@ fn import_config_from_path_missing_file_produces_io_error() {
}
}
#[test]
fn sync_gemini_packycode_sets_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-1".to_string();
manager.providers.insert(
"packy-1".to_string(),
Provider::with_id(
"packy-1".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType"
);
}
#[test]
fn sync_gemini_google_official_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
let cc_settings = home.join(".cc-switch").join("settings.json");
assert!(
cc_settings.exists(),
"app settings should exist at {}",
cc_settings.display()
);
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
assert_eq!(
cc_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"syncing Google official should set oauth-personal in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should also record oauth-personal"
);
}
#[test]
fn export_config_to_file_writes_target_path() {
let _guard = test_mutex().lock().expect("acquire test mutex");

View File

@@ -1,10 +1,10 @@
use std::{fs, sync::RwLock};
use std::{collections::HashMap, fs, sync::RwLock};
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppState, AppType, McpService, MultiAppConfig,
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
@@ -126,16 +126,18 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
);
let guard = state.config.read().expect("lock config");
let claude_servers = &guard.mcp.claude.servers;
let entry = claude_servers
// v3.7.0: 检查统一结构
let servers = guard
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let entry = servers
.get("echo")
.expect("server imported into config.json");
.expect("server imported into unified structure");
assert!(
entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
"imported server should be marked enabled"
entry.apps.claude,
"imported server should have Claude app enabled"
);
drop(guard);
@@ -181,43 +183,63 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
fn set_mcp_enabled_for_codex_writes_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
ensure_test_home();
let home = ensure_test_home();
// 创建 Codex 配置目录和文件
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("auth.json"),
r#"{"OPENAI_API_KEY":"test-key"}"#,
)
.expect("create auth.json");
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
config.mcp.codex.servers.insert(
// v3.7.0: 使用统一结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"codex-server".into(),
json!({
"id": "codex-server",
"name": "Codex Server",
"server": {
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
},
"enabled": false
}),
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = AppState {
config: RwLock::new(config),
};
McpService::set_enabled(&state, AppType::Codex, "codex-server", true)
.expect("set enabled should succeed");
// v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let guard = state.config.read().expect("lock config");
let entry = guard
.mcp
.codex
.servers
.as_ref()
.unwrap()
.get("codex-server")
.expect("codex server exists");
assert!(
entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
"server should be marked enabled after command"
entry.apps.codex,
"server should have Codex app enabled after toggle"
);
drop(guard);

View File

@@ -3,7 +3,7 @@ use std::sync::RwLock;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
MultiAppConfig, Provider, ProviderService,
MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
@@ -139,6 +139,188 @@ command = "say"
);
}
#[test]
fn switch_packycode_gemini_updates_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-gemini".to_string();
manager.providers.insert(
"packy-gemini".to_string(),
Provider::with_id(
"packy-gemini".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"PackyCode Gemini should set security.auth.selectedType"
);
}
#[test]
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-meta".to_string();
let mut provider = Provider::with_id(
"packy-meta".to_string(),
"Generic Gemini".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-meta",
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
}
}),
Some("https://example.com".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("packycode".to_string()),
..ProviderMeta::default()
});
manager.providers.insert("packy-meta".to_string(), provider);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"Partner meta should set security.auth.selectedType even without packy keywords"
);
}
#[test]
fn switch_google_official_gemini_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should also reflect oauth-personal"
);
}
#[test]
fn provider_service_switch_claude_updates_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -321,8 +503,8 @@ fn provider_service_delete_codex_removes_provider_and_files() {
let sanitized = sanitize_provider_name("DeleteCodex");
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
let auth_path = codex_dir.join(format!("auth-{}.json", sanitized));
let cfg_path = codex_dir.join(format!("config-{}.toml", sanitized));
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
std::fs::write(&auth_path, "{}").expect("seed auth file");
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
@@ -384,7 +566,7 @@ fn provider_service_delete_claude_removes_provider_files() {
let sanitized = sanitize_provider_name("DeleteClaude");
let claude_dir = home.join(".claude");
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
let by_name = claude_dir.join(format!("settings-{}.json", sanitized));
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
let by_id = claude_dir.join("settings-delete.json");
std::fs::write(&by_name, "{}").expect("seed settings by name");
std::fs::write(&by_id, "{}").expect("seed settings by id");

View File

@@ -23,7 +23,7 @@ pub fn ensure_test_home() -> &'static Path {
/// 清理测试目录中生成的配置文件与缓存。
pub fn reset_test_fs() {
let home = ensure_test_home();
for sub in [".claude", ".codex", ".cc-switch"] {
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
let path = home.join(sub);
if path.exists() {
if let Err(err) = std::fs::remove_dir_all(&path) {

View File

@@ -1,8 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Settings, Edit3 } from "lucide-react";
import {
Plus,
Settings,
ArrowLeft,
Bot,
Book,
Wrench,
Server,
RefreshCw,
} from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery } from "@/lib/query";
import {
providersApi,
@@ -10,6 +20,7 @@ import {
type AppId,
type ProviderSwitchEvent,
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -17,27 +28,42 @@ import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import UsageScriptModal from "@/components/UsageScriptModal";
import McpPanel from "@/components/mcp/McpPanel";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { Button } from "@/components/ui/button";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
function App() {
const { t } = useTranslation();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [isEditMode, setIsEditMode] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [currentView, setCurrentView] = useState<View>("providers");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -72,6 +98,64 @@ function App() {
};
}, [activeApp, refetch]);
// 应用启动时检测所有应用的环境变量冲突
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
if (flatConflicts.length > 0) {
setEnvConflicts(flatConflicts);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on startup:",
error,
);
}
};
checkEnvOnStartup();
}, []);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
try {
const conflicts = await checkEnvConflicts(activeApp);
if (conflicts.length > 0) {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`),
);
const newConflicts = conflicts.filter(
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
);
return [...prev, ...newConflicts];
});
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on app switch:",
error,
);
}
};
checkEnvOnSwitch();
}, [activeApp]);
// 打开网站链接
const handleOpenWebsite = async (url: string) => {
try {
@@ -160,80 +244,276 @@ function App() {
}
};
const renderContent = () => {
switch (currentView) {
case "settings":
return (
<SettingsPage
open={true}
onOpenChange={() => setCurrentView("providers")}
onImportSuccess={handleImportSuccess}
/>
);
case "prompts":
return (
<PromptPanel
ref={promptPanelRef}
open={true}
onOpenChange={() => setCurrentView("providers")}
appId={activeApp}
/>
);
case "skills":
return (
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
/>
);
case "mcp":
return (
<UnifiedMcpPanel
ref={mcpPanelRef}
onOpenChange={() => setCurrentView("providers")}
/>
);
case "agents":
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
default:
return (
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
);
}
};
return (
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
title={t("common.settings")}
className="ml-2"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditMode(!isEditMode)}
title={t(
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
)}
className={
isEditMode
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
: ""
<div
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden" }}
>
{/* 全局拖拽区域(顶部 4px避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 h-4 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
/>
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => {
setShowEnvBanner(false);
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
setEnvConflicts(flatConflicts);
if (flatConflicts.length === 0) {
setShowEnvBanner(false);
}
>
<Edit3 className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
} catch (error) {
console.error(
"[App] Failed to re-check conflicts after deletion:",
error,
);
}
}}
/>
)}
<header
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView !== "providers" ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")}
{currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && "Agents"}
</h1>
</div>
) : (
<>
<div className="flex items-center gap-2">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentView("settings")}
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<UpdateBadge onClick={() => setCurrentView("settings")} />
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<Button
variant="mcp"
onClick={() => setIsMcpOpen(true)}
className="min-w-[80px]"
>
MCP
</Button>
<Button onClick={() => setIsAddOpen(true)}>
<Plus className="h-4 w-4" />
{t("header.addProvider")}
</Button>
<div
className="flex items-center gap-2"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("prompts.add")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === "mcp" && (
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="h-5 w-5" />
</Button>
</>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-y-scroll">
<div className="mx-auto max-w-4xl px-6 py-6">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isEditMode={isEditMode}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
<main
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main>
<AddProviderDialog
@@ -281,17 +561,7 @@ function App() {
onCancel={() => setConfirmDelete(null)}
/>
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
<McpPanel
open={isMcpOpen}
onOpenChange={setIsMcpOpen}
appId={activeApp}
/>
<DeepLinkImportDialog />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import type { AppId } from "@/lib/api";
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "./BrandIcons";
interface AppSwitcherProps {
activeApp: AppId;
@@ -13,13 +13,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent ">
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1">
<button
type="button"
onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
@@ -27,8 +27,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
size={16}
className={
activeApp === "claude"
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Claude</span>
@@ -39,13 +39,40 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
<CodexIcon size={16} />
<CodexIcon
size={16}
className={
activeApp === "codex"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Codex</span>
</button>
<button
type="button"
onClick={() => handleSwitch("gemini")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
<GeminiIcon
size={16}
className={
activeApp === "gemini"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Gemini</span>
</button>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,76 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface ColorPickerProps {
value?: string;
onValueChange: (color: string) => void;
label?: string;
presets?: string[];
}
const DEFAULT_PRESETS = [
"#00A67E",
"#D4915D",
"#4285F4",
"#FF6A00",
"#00A4FF",
"#FF9900",
"#0078D4",
"#FF0000",
"#1E88E5",
"#6366F1",
"#0F62FE",
"#2932E1",
];
export const ColorPicker: React.FC<ColorPickerProps> = ({
value = "#4285F4",
onValueChange,
label = "图标颜色",
presets = DEFAULT_PRESETS,
}) => {
return (
<div className="space-y-3">
<Label>{label}</Label>
{/* 颜色预设 */}
<div className="grid grid-cols-6 gap-2">
{presets.map((color) => (
<button
key={color}
type="button"
onClick={() => onValueChange(color)}
className={cn(
"w-full aspect-square rounded-lg border-2 transition-all",
"hover:scale-110 hover:shadow-lg",
value === color
? "border-primary ring-2 ring-primary/20"
: "border-border",
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* 自定义颜色输入 */}
<div className="flex items-center gap-2">
<Input
type="color"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="#4285F4"
className="flex-1 font-mono"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,430 @@
import { useState, useEffect, useMemo } 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",
async (event) => {
console.log("Deep link import event received:", event.payload);
// If config is present, merge it to get the complete configuration
if (event.payload.config || event.payload.configUrl) {
try {
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
event.payload,
);
console.log("Config merged successfully:", mergedRequest);
setRequest(mergedRequest);
} catch (error) {
console.error("Failed to merge config:", error);
toast.error(t("deeplink.configMergeError"), {
description:
error instanceof Error ? error.message : String(error),
});
// Fall back to original request
setRequest(event.payload);
}
} else {
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);
} 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);
};
// Mask API key for display (show first 4 chars + ***)
const maskedApiKey =
request?.apiKey && request.apiKey.length > 4
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
: "****";
// Check if config file is present
const hasConfigFile = !!(request?.config || request?.configUrl);
const configSource = request?.config
? "base64"
: request?.configUrl
? "url"
: null;
// Parse config file content for display
interface ParsedConfig {
type: "claude" | "codex" | "gemini";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
raw: Record<string, unknown>;
}
// Helper to decode base64 with UTF-8 support
const b64ToUtf8 = (str: string): string => {
try {
const binString = atob(str);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
return new TextDecoder().decode(bytes);
} catch (e) {
console.error("Failed to decode base64:", e);
return atob(str);
}
};
const parsedConfig = useMemo((): ParsedConfig | null => {
if (!request?.config) return null;
try {
const decoded = b64ToUtf8(request.config);
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
raw: parsed,
};
} else if (request.app === "codex") {
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: (parsed.config as string) || "",
raw: parsed,
};
} else if (request.app === "gemini") {
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
return {
type: "gemini",
env: parsed as Record<string, string>,
raw: parsed,
};
}
return null;
} catch (e) {
console.error("Failed to parse config:", e);
return null;
}
}, [request?.config, request?.app]);
// Helper to mask sensitive values
const maskValue = (key: string, value: string): string => {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) =>
key.toUpperCase().includes(k),
);
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
};
return (
<Dialog open={isOpen && !!request} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[500px]" zIndex="top">
{request && (
<>
{/* 标题显式左对齐,避免默认居中样式影响 */}
<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 max-h-[60vh] overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:block [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-200 dark:[&::-webkit-scrollbar-thumb]:bg-gray-700">
{/* 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>
)}
{/* Config File Details (v3.8+) */}
{hasConfigFile && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.configSource")}
</div>
<div className="col-span-2 text-sm">
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
{configSource === "base64"
? t("deeplink.configEmbedded")
: t("deeplink.configRemote")}
</span>
{request.configFormat && (
<span className="ml-2 text-xs text-muted-foreground uppercase">
{request.configFormat}
</span>
)}
</div>
</div>
{/* Parsed Config Details */}
{parsedConfig && (
<div className="rounded-lg bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t("deeplink.configDetails")}
</div>
{/* Claude config */}
{parsedConfig.type === "claude" && parsedConfig.env && (
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
{/* Codex config */}
{parsedConfig.type === "codex" && (
<div className="space-y-2">
{parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
Auth:
</div>
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs pl-2"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
{parsedConfig.tomlConfig && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
TOML Config:
</div>
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
{parsedConfig.tomlConfig.substring(0, 300)}
{parsedConfig.tomlConfig.length > 300 && "..."}
</pre>
</div>
)}
</div>
)}
{/* Gemini config */}
{parsedConfig.type === "gemini" && parsedConfig.env && (
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
</div>
)}
{/* Config URL (if remote) */}
{request.configUrl && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.configUrl")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground break-all">
{request.configUrl}
</div>
</div>
)}
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isImporting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,85 @@
import React, { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProviderIcon } from "./ProviderIcon";
import { iconList } from "@/icons/extracted";
import { searchIcons, getIconMetadata } from "@/icons/extracted/metadata";
import { cn } from "@/lib/utils";
interface IconPickerProps {
value?: string; // 当前选中的图标
onValueChange: (icon: string) => void; // 选择回调
color?: string; // 预览颜色
}
export const IconPicker: React.FC<IconPickerProps> = ({
value,
onValueChange,
}) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
// 过滤图标列表
const filteredIcons = useMemo(() => {
if (!searchQuery) return iconList;
return searchIcons(searchQuery);
}, [searchQuery]);
return (
<div className="space-y-4">
<div>
<Label htmlFor="icon-search">
{t("iconPicker.search", { defaultValue: "搜索图标" })}
</Label>
<Input
id="icon-search"
type="text"
placeholder={t("iconPicker.searchPlaceholder", {
defaultValue: "输入图标名称...",
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mt-2"
/>
</div>
<div className="max-h-[65vh] overflow-y-auto pr-1">
<div className="grid grid-cols-6 sm:grid-cols-8 lg:grid-cols-10 gap-2">
{filteredIcons.map((iconName) => {
const meta = getIconMetadata(iconName);
const isSelected = value === iconName;
return (
<button
key={iconName}
type="button"
onClick={() => onValueChange(iconName)}
className={cn(
"flex flex-col items-center gap-1 p-3 rounded-lg",
"border-2 transition-all duration-200",
"hover:bg-accent hover:border-primary/50",
isSelected
? "border-primary bg-primary/10"
: "border-transparent",
)}
title={meta?.displayName || iconName}
>
<ProviderIcon icon={iconName} name={iconName} size={32} />
<span className="text-xs text-muted-foreground truncate w-full text-center">
{meta?.displayName || iconName}
</span>
</button>
);
})}
</div>
</div>
{filteredIcons.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
{t("iconPicker.noResults", { defaultValue: "未找到匹配的图标" })}
</div>
)}
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface JsonEditorProps {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
@@ -19,7 +20,8 @@ interface JsonEditorProps {
rows?: number;
showValidation?: boolean;
language?: "json" | "javascript";
height?: string;
height?: string | number;
showMinimap?: boolean; // 添加此属性以防未来使用
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -84,19 +86,47 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
// 使用 baseTheme 定义基础样式,优先级低于 oneDark但可以正确响应主题
const baseTheme = EditorView.baseTheme({
"&light .cm-editor, &dark .cm-editor": {
".cm-editor": {
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
background: "transparent",
},
"&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": {
".cm-editor.cm-focused": {
outline: "none",
borderColor: "hsl(var(--primary))",
},
".cm-scroller": {
background: "transparent",
},
".cm-gutters": {
background: "transparent",
borderRight: "1px solid hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
},
".cm-selectionBackground, .cm-content ::selection": {
background: "hsl(var(--primary) / 0.18)",
},
".cm-selectionMatch": {
background: "hsl(var(--primary) / 0.12)",
},
".cm-activeLine": {
background: "hsl(var(--primary) / 0.08)",
},
".cm-activeLineGutter": {
background: "hsl(var(--primary) / 0.08)",
},
});
// 使用 theme 定义尺寸和字体样式
const heightValue = height
? typeof height === "number"
? `${height}px`
: height
: undefined;
const sizingTheme = EditorView.theme({
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
"&": heightValue
? { height: heightValue }
: { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
@@ -129,11 +159,32 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
".cm-editor": {
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
background: "transparent",
},
".cm-editor.cm-focused": {
outline: "none",
borderColor: "hsl(var(--primary))",
},
".cm-scroller": {
background: "transparent",
},
".cm-gutters": {
background: "transparent",
borderRight: "1px solid hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
},
".cm-selectionBackground, .cm-content ::selection": {
background: "hsl(var(--primary) / 0.18)",
},
".cm-selectionMatch": {
background: "hsl(var(--primary) / 0.12)",
},
".cm-activeLine": {
background: "hsl(var(--primary) / 0.08)",
},
".cm-activeLineGutter": {
background: "hsl(var(--primary) / 0.08)",
},
}),
);
}
@@ -196,14 +247,23 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
}
};
const isFullHeight = height === "100%";
return (
<div style={{ width: "100%" }}>
<div ref={editorRef} style={{ width: "100%" }} />
<div
style={{ width: "100%", height: isFullHeight ? "100%" : "auto" }}
className={isFullHeight ? "flex flex-col" : ""}
>
<div
ref={editorRef}
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
className={isFullHeight ? "flex-1 min-h-0" : ""}
/>
{language === "json" && (
<button
type="button"
onClick={handleFormat}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
className={`${isFullHeight ? "mt-2 flex-shrink-0" : "mt-2"} inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors`}
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}

View File

@@ -0,0 +1,159 @@
import React, { useRef, useEffect } from "react";
import { EditorView, basicSetup } from "codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder as placeholderExt } from "@codemirror/view";
interface MarkdownEditorProps {
value: string;
onChange?: (value: string) => void;
placeholder?: string;
darkMode?: boolean;
readOnly?: boolean;
className?: string;
minHeight?: string;
maxHeight?: string;
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder: placeholderText = "",
darkMode = false,
readOnly = false,
className = "",
minHeight = "300px",
maxHeight,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!editorRef.current) return;
// 定义基础主题
const baseTheme = EditorView.baseTheme({
"&": {
height: "100%",
minHeight,
maxHeight: maxHeight || "none",
},
".cm-scroller": {
overflow: "auto",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "14px",
},
"&light .cm-content, &dark .cm-content": {
padding: "12px 0",
},
"&light .cm-editor, &dark .cm-editor": {
backgroundColor: "transparent",
},
"&.cm-focused": {
outline: "none",
},
});
const extensions = [
basicSetup,
markdown(),
baseTheme,
EditorView.lineWrapping,
EditorState.readOnly.of(readOnly),
];
if (!readOnly) {
extensions.push(
placeholderExt(placeholderText),
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) {
onChange(update.state.doc.toString());
}
}),
);
} else {
// 只读模式下隐藏光标和高亮行
extensions.push(
EditorView.theme({
".cm-cursor, .cm-dropCursor": { border: "none" },
".cm-activeLine": { backgroundColor: "transparent !important" },
".cm-activeLineGutter": { backgroundColor: "transparent !important" },
}),
);
}
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
} else {
// 浅色模式下的简单样式调整,使其更融入 UI
extensions.push(
EditorView.theme(
{
"&": {
backgroundColor: "transparent",
},
".cm-content": {
color: "#374151", // text-gray-700
},
".cm-gutters": {
backgroundColor: "#f9fafb", // bg-gray-50
color: "#9ca3af", // text-gray-400
borderRight: "1px solid #e5e7eb", // border-gray-200
},
".cm-activeLineGutter": {
backgroundColor: "#e5e7eb",
},
},
{ dark: false },
),
);
}
// 创建初始状态
const state = EditorState.create({
doc: value,
extensions,
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [darkMode, readOnly, minHeight, maxHeight, placeholderText]); // 添加 placeholderText 依赖以支持国际化切换
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
const transaction = viewRef.current.state.update({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
viewRef.current.dispatch(transaction);
}
}, [value]);
return (
<div
ref={editorRef}
className={`border rounded-md overflow-hidden ${
darkMode ? "border-gray-800" : "border-gray-200"
} ${className}`}
/>
);
};
export default MarkdownEditor;

View File

@@ -0,0 +1,81 @@
import React, { useMemo } from "react";
import { getIcon, hasIcon } from "@/icons/extracted";
import { cn } from "@/lib/utils";
interface ProviderIconProps {
icon?: string; // 图标名称
name: string; // 供应商名称(用于 fallback
color?: string; // 自定义颜色 (Deprecated, kept for compatibility but ignored for SVG)
size?: number | string; // 尺寸
className?: string;
showFallback?: boolean; // 是否显示 fallback
}
export const ProviderIcon: React.FC<ProviderIconProps> = ({
icon,
name,
size = 32,
className,
showFallback = true,
}) => {
// 获取图标 SVG
const iconSvg = useMemo(() => {
if (icon && hasIcon(icon)) {
return getIcon(icon);
}
return "";
}, [icon]);
// 计算尺寸样式
const sizeStyle = useMemo(() => {
const sizeValue = typeof size === "number" ? `${size}px` : size;
return {
width: sizeValue,
height: sizeValue,
};
}, [size]);
// 如果有图标,显示图标
if (iconSvg) {
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0",
className,
)}
style={sizeStyle}
dangerouslySetInnerHTML={{ __html: iconSvg }}
/>
);
}
// Fallback显示首字母
if (showFallback) {
const initials = name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0 rounded-lg",
"bg-muted text-muted-foreground font-semibold",
className,
)}
style={sizeStyle}
>
<span
style={{
fontSize: `${typeof size === "number" ? size * 0.4 : 14}px`,
}}
>
{initials}
</span>
</span>
);
}
return null;
};

View File

@@ -60,7 +60,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
if (!usage.success) {
if (inline) {
return (
<div className="flex items-center gap-2 text-xs">
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
<AlertCircle size={12} />
<span>{t("usage.queryFailed")}</span>
@@ -68,7 +68,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -78,7 +78,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
}
return (
<div className="mt-3 pt-3 border-t border-border-default ">
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
<AlertCircle size={14} />
@@ -110,29 +110,32 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
const isExpired = firstUsage.isValid === false;
return (
<div className="flex flex-col gap-1 text-xs flex-shrink-0">
{/* 第一行:新时间 + 刷新按钮 */}
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
{/* 第一行:新时间刷新按钮 */}
<div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */}
{lastQueriedAt && (
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)}
</span>
)}
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t)
: t("usage.never", { defaultValue: "从未更新" })}
</span>
{/* 刷新按钮 */}
<button
onClick={() => refetch()}
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{/* 第二行:已用 + 剩余 + 单位 */}
{/* 第二行:用量和剩余 */}
<div className="flex items-center gap-2">
{/* 已用 */}
{firstUsage.used !== undefined && (
@@ -153,14 +156,13 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{t("usage.remaining")}
</span>
<span
className={`font-semibold tabular-nums ${
isExpired
className={`font-semibold tabular-nums ${isExpired
? "text-red-500 dark:text-red-400"
: firstUsage.remaining <
(firstUsage.total || firstUsage.remaining) * 0.1
(firstUsage.total || firstUsage.remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
}`}
>
{firstUsage.remaining.toFixed(2)}
</span>
@@ -179,7 +181,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
}
return (
<div className="mt-3 pt-3 border-t border-border-default ">
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
{/* 标题行:包含刷新按钮和自动查询时间 */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
@@ -196,7 +198,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -308,13 +310,12 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
{t("usage.remaining")}
</span>
<span
className={`font-semibold tabular-nums ${
isExpired
? "text-red-500 dark:text-red-400"
: remaining < (total || remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
className={`font-semibold tabular-nums ${isExpired
? "text-red-500 dark:text-red-400"
: remaining < (total || remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
>
{remaining.toFixed(2)}
</span>

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Play, Wand2 } from "lucide-react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "@/types";
@@ -8,14 +8,12 @@ import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
import * as pluginEstree from "prettier/plugins/estree";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { cn } from "@/lib/utils";
interface UsageScriptModalProps {
provider: Provider;
@@ -128,8 +126,53 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [testing, setTesting] = useState(false);
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
// 初始化:如果已有 accessToken 或 userId说明是 NewAPI 模板
// 🔧 失焦时的验证(严格)- 仅确保有效整数
const validateTimeout = (value: string): number => {
const num = Number(value);
if (isNaN(num) || value.trim() === "") {
return 10;
}
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
);
}
if (num < 0) {
toast.error(
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
);
return 10;
}
return Math.floor(num);
};
// 🔧 失焦时的验证(严格)- 自动查询间隔
const validateAndClampInterval = (value: string): number => {
const num = Number(value);
if (isNaN(num) || value.trim() === "") {
return 0;
}
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
);
}
if (num < 0) {
toast.error(
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
);
return 0;
}
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
if (clamped !== num && num > 0) {
toast.info(
t("usageScript.intervalAdjusted", { value: clamped }) ||
`自动查询间隔已调整为 ${clamped} 分钟`,
);
}
return clamped;
};
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
@@ -140,19 +183,18 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
},
);
const [showApiKey, setShowApiKey] = useState(false);
const [showAccessToken, setShowAccessToken] = useState(false);
const handleSave = () => {
// 验证脚本格式
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
return;
}
// 基本的 JS 语法检查(检查是否包含 return 语句)
if (script.enabled && !script.code.includes("return")) {
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
onSave(script);
onClose();
};
@@ -160,17 +202,17 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// 使用当前编辑器中的脚本内容进行测试
const result = await usageApi.testScript(
provider.id,
appId,
script.code,
script.timeout,
script.apiKey,
script.baseUrl,
script.accessToken,
script.userId,
);
if (result.success && result.data && result.data.length > 0) {
// 显示所有套餐数据
const summary = result.data
.map((plan) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
@@ -225,198 +267,491 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
// 如果选择的不是 NewAPI 模板,清空高级配置字段
if (presetName !== TEMPLATE_KEYS.NEW_API) {
if (presetName === TEMPLATE_KEYS.CUSTOM) {
setScript({
...script,
code: preset,
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
setScript({
...script,
code: preset,
accessToken: undefined,
userId: undefined,
});
} else {
setScript({ ...script, code: preset });
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
setScript({
...script,
code: preset,
apiKey: undefined,
});
}
setSelectedTemplate(presetName); // 记录选择的模板
setSelectedTemplate(presetName);
}
};
// 判断是否应该显示高级配置(仅 NewAPI 模板需要)
const shouldShowAdvancedConfig = selectedTemplate === TEMPLATE_KEYS.NEW_API;
const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
const footer = (
<>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} className="mr-1" />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title={t("usageScript.format")}
>
<Wand2 size={14} className="mr-1" />
{t("usageScript.format")}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onClose}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Save size={16} className="mr-2" />
{t("usageScript.saveConfig")}
</Button>
</div>
</>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{t("usageScript.title")} - {provider.name}
</DialogTitle>
</DialogHeader>
<FullScreenPanel
isOpen={isOpen}
title={`${t("usageScript.title")} - ${provider.name}`}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
setScript({ ...script, enabled: checked })
}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 启用开关 */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={script.enabled}
onChange={(e) =>
setScript({ ...script, enabled: e.target.checked })
}
className="w-4 h-4"
/>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("usageScript.enableUsageQuery")}
</span>
</label>
{script.enabled && (
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<span className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</span>
</div>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
return (
<Button
key={name}
type="button"
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"rounded-lg border",
isSelected
? "shadow-sm"
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => handleUsePreset(name)}
>
{t(TEMPLATE_NAME_KEYS[name])}
</Button>
);
})}
</div>
{script.enabled && (
<>
{/* 预设模板选择 */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
{t("usageScript.presetTemplate")}
</label>
<div className="flex gap-2">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
return (
<button
key={name}
onClick={() => handleUsePreset(name)}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
isSelected
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t(TEMPLATE_NAME_KEYS[name])}
</button>
);
})}
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<div className="grid gap-4 md:grid-cols-2">
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
<>
<div className="space-y-2">
<Label htmlFor="usage-api-key">API Key</Label>
<div className="relative">
<Input
id="usage-api-key"
type={showApiKey ? "text" : "password"}
value={script.apiKey || ""}
onChange={(e) =>
setScript({ ...script, apiKey: e.target.value })
}
placeholder="sk-xxxxx"
autoComplete="off"
className="border-white/10"
/>
{script.apiKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-base-url">Base URL</Label>
<Input
id="usage-base-url"
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.example.com"
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
<Input
id="usage-newapi-base-url"
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.newapi.com"
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-access-token">
{t("usageScript.accessToken")}
</Label>
<div className="relative">
<Input
id="usage-access-token"
type={showAccessToken ? "text" : "password"}
value={script.accessToken || ""}
onChange={(e) =>
setScript({
...script,
accessToken: e.target.value,
})
}
placeholder={t(
"usageScript.accessTokenPlaceholder",
)}
autoComplete="off"
className="border-white/10"
/>
{script.accessToken && (
<button
type="button"
onClick={() =>
setShowAccessToken(!showAccessToken)
}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showAccessToken
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showAccessToken ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-user-id">
{t("usageScript.userId")}
</Label>
<Input
id="usage-user-id"
type="text"
value={script.userId || ""}
onChange={(e) =>
setScript({ ...script, userId: e.target.value })
}
placeholder={t("usageScript.userIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
</div>
</div>
)}
</div>
{/* 脚本配置 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<h4 className="text-base font-medium text-foreground">
{t("usageScript.scriptConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</p>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="usage-request-url">
{t("usageScript.requestUrl")}
</Label>
<Input
id="usage-request-url"
type="text"
value={script.request?.url || ""}
onChange={(e) => {
setScript({
...script,
request: { ...script.request, url: e.target.value },
});
}}
placeholder={t("usageScript.requestUrlPlaceholder")}
className="border-white/10"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usage-method">
{t("usageScript.method")}
</Label>
<Input
id="usage-method"
type="text"
value={script.request?.method || "GET"}
onChange={(e) => {
setScript({
...script,
request: {
...script.request,
method: e.target.value.toUpperCase(),
},
});
}}
placeholder="GET / POST"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
</div>
</div>
{/* 高级配置Access Token 和 User ID仅 NewAPI 模板显示) */}
{shouldShowAdvancedConfig && (
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
{t("usageScript.accessToken")}
</span>
<input
type="text"
value={script.accessToken || ""}
onChange={(e) =>
setScript({ ...script, accessToken: e.target.value })
}
placeholder={t("usageScript.accessTokenPlaceholder")}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
{t("usageScript.userId")}
</span>
<input
type="text"
value={script.userId || ""}
onChange={(e) =>
setScript({ ...script, userId: e.target.value })
}
placeholder={t("usageScript.userIdPlaceholder")}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
</div>
)}
{/* 脚本编辑器 */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
{t("usageScript.queryScript")}
</label>
<div className="space-y-2">
<Label htmlFor="usage-headers">
{t("usageScript.headers")}
</Label>
<JsonEditor
value={script.code}
onChange={(code) => setScript({ ...script, code })}
height="300px"
language="javascript"
id="usage-headers"
value={
script.request?.headers
? JSON.stringify(script.request.headers, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed = JSON.parse(value || "{}");
setScript({
...script,
request: { ...script.request, headers: parsed },
});
} catch (error) {
console.error("Invalid headers JSON", error);
}
}}
height={180}
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t("usageScript.variablesHint", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</div>
<div className="space-y-2">
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
<JsonEditor
id="usage-body"
value={
script.request?.body
? JSON.stringify(script.request.body, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed =
value?.trim() === "" ? undefined : JSON.parse(value);
setScript({
...script,
request: { ...script.request, body: parsed },
});
} catch (error) {
toast.error(
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
);
}
}}
height={220}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")}
</Label>
<Input
id="usage-interval"
type="number"
min={0}
max={1440}
value={script.autoIntervalMinutes ?? 0}
onChange={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
}
onBlur={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
</div>
{/* 配置选项 */}
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("usageScript.timeoutSeconds")}
</span>
<input
type="number"
min="2"
max="30"
value={script.timeout || 10}
onChange={(e) =>
setScript({
...script,
timeout: parseInt(e.target.value),
})
}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</label>
{/* 🆕 自动查询间隔 */}
<label className="block">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("usageScript.autoQueryInterval")}
</span>
<input
type="number"
min="0"
max="1440"
step="1"
value={script.autoQueryInterval || 0}
onChange={(e) =>
setScript({
...script,
autoQueryInterval: parseInt(e.target.value) || 0,
})
}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("usageScript.autoQueryIntervalHint")}
</p>
</label>
{/* 提取器代码 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
{t("usageScript.extractorCode")}
</Label>
<div className="text-xs text-muted-foreground">
{t("usageScript.extractorHint")}
</div>
</div>
<JsonEditor
id="usage-code"
value={script.code || ""}
onChange={(value) => setScript({ ...script, code: value })}
height={480}
language="javascript"
showMinimap={false}
/>
</div>
{/* 脚本说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<h4 className="font-medium mb-2">
{t("usageScript.scriptHelp")}
</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
{`({
{/* 帮助信息 */}
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
{`({
request: {
url: "{{baseUrl}}/api/usage",
method: "POST",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
},
body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")}
}
},
extractor: function(response) {
// ${t("usageScript.commentResponseIsJson")}
return {
isValid: !response.error,
remaining: response.balance,
@@ -424,79 +759,41 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
}
})`}
</pre>
</div>
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div className="text-gray-600 dark:text-gray-400">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
</div>
</div>
</pre>
</div>
</>
)}
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div className="text-muted-foreground">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
</div>
</div>
</div>
</div>
{/* Footer */}
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
{/* Left side - Test and Format buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title={t("usageScript.format")}
>
<Wand2 size={14} />
{t("usageScript.format")}
</Button>
</div>
{/* Right side - Cancel and Save buttons */}
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="default" size="sm" onClick={handleSave}>
{t("usageScript.saveConfig")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</FullScreenPanel>
);
};

View File

@@ -0,0 +1,22 @@
import { Bot } from "lucide-react";
interface AgentsPanelProps {
onOpenChange: (open: boolean) => void;
}
export function AgentsPanel({}: AgentsPanelProps) {
return (
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
<Bot className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold">Coming Soon</h3>
<p className="text-muted-foreground max-w-md">
The Agents management feature is currently under development. Stay
tuned for powerful autonomous capabilities.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from "react";
import { createPortal } from "react-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FullScreenPanelProps {
isOpen: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
}
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
* Uses solid theme colors without transparency
*/
export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
isOpen,
title,
onClose,
children,
footer,
}) => {
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
)}
</div>,
document.body,
);
};

274
src/components/env/EnvWarningBanner.tsx vendored Normal file
View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ChevronDown, ChevronUp, X, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import type { EnvConflict } from "@/types/env";
import { deleteEnvVars } from "@/lib/api/env";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface EnvWarningBannerProps {
conflicts: EnvConflict[];
onDismiss: () => void;
onDeleted: () => void;
}
export function EnvWarningBanner({
conflicts,
onDismiss,
onDeleted,
}: EnvWarningBannerProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedConflicts, setSelectedConflicts] = useState<Set<string>>(
new Set(),
);
const [isDeleting, setIsDeleting] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
if (conflicts.length === 0) {
return null;
}
const toggleSelection = (key: string) => {
const newSelection = new Set(selectedConflicts);
if (newSelection.has(key)) {
newSelection.delete(key);
} else {
newSelection.add(key);
}
setSelectedConflicts(newSelection);
};
const toggleSelectAll = () => {
if (selectedConflicts.size === conflicts.length) {
setSelectedConflicts(new Set());
} else {
setSelectedConflicts(
new Set(conflicts.map((c) => `${c.varName}:${c.sourcePath}`)),
);
}
};
const handleDelete = async () => {
setShowConfirmDialog(false);
setIsDeleting(true);
try {
const conflictsToDelete = conflicts.filter((c) =>
selectedConflicts.has(`${c.varName}:${c.sourcePath}`),
);
if (conflictsToDelete.length === 0) {
toast.warning(t("env.error.noSelection"));
return;
}
const backupInfo = await deleteEnvVars(conflictsToDelete);
toast.success(t("env.delete.success"), {
description: t("env.backup.location", {
path: backupInfo.backupPath,
}),
duration: 5000,
});
// 清空选择并通知父组件
setSelectedConflicts(new Set());
onDeleted();
} catch (error) {
console.error("删除环境变量失败:", error);
toast.error(t("env.delete.error"), {
description: String(error),
});
} finally {
setIsDeleting(false);
}
};
const getSourceDescription = (conflict: EnvConflict): string => {
if (conflict.sourceType === "system") {
if (conflict.sourcePath.includes("HKEY_CURRENT_USER")) {
return t("env.source.userRegistry");
} else if (conflict.sourcePath.includes("HKEY_LOCAL_MACHINE")) {
return t("env.source.systemRegistry");
} else {
return t("env.source.systemEnv");
}
} else {
return conflict.sourcePath;
}
};
return (
<>
<div className="fixed top-0 left-0 right-0 z-[100] bg-yellow-50 dark:bg-yellow-950 border-b border-yellow-200 dark:border-yellow-900 shadow-lg animate-slide-down">
<div className="container mx-auto px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
{t("env.warning.title")}
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-0.5">
{t("env.warning.description", { count: conflicts.length })}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
>
{isExpanded ? (
<>
{t("env.actions.collapse")}
<ChevronUp className="h-4 w-4 ml-1" />
</>
) : (
<>
{t("env.actions.expand")}
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{isExpanded && (
<div className="mt-4 space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-900/50">
<Checkbox
id="select-all"
checked={selectedConflicts.size === conflicts.length}
onCheckedChange={toggleSelectAll}
/>
<label
htmlFor="select-all"
className="text-sm font-medium text-yellow-900 dark:text-yellow-100 cursor-pointer"
>
{t("env.actions.selectAll")}
</label>
</div>
<div className="max-h-96 overflow-y-auto space-y-2">
{conflicts.map((conflict) => {
const key = `${conflict.varName}:${conflict.sourcePath}`;
return (
<div
key={key}
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-900 rounded-md border border-yellow-200 dark:border-yellow-900/50"
>
<Checkbox
id={key}
checked={selectedConflicts.has(key)}
onCheckedChange={() => toggleSelection(key)}
/>
<div className="flex-1 min-w-0">
<label
htmlFor={key}
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
{conflict.varName}
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{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)}
</p>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-yellow-200 dark:border-yellow-900/50">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedConflicts(new Set())}
disabled={selectedConflicts.size === 0}
className="text-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-800"
>
{t("env.actions.clearSelection")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setShowConfirmDialog(true)}
disabled={selectedConflicts.size === 0 || isDeleting}
className="gap-1"
>
<Trash2 className="h-4 w-4" />
{isDeleting
? t("env.actions.deleting")
: t("env.actions.deleteSelected", {
count: selectedConflicts.size,
})}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="max-w-md" zIndex="top">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("env.confirm.title")}
</DialogTitle>
<DialogDescription className="space-y-2">
<p>
{t("env.confirm.message", { count: selectedConflicts.size })}
</p>
<p className="text-sm text-muted-foreground">
{t("env.confirm.backupNotice")}
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t("env.confirm.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,25 +1,12 @@
import React, { useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Save,
Plus,
AlertCircle,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { mcpApi, type AppId } from "@/lib/api";
import JsonEditor from "@/components/JsonEditor";
import type { AppId } from "@/lib/api/types";
import { McpServer, McpServerSpec } from "@/types";
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
import McpWizardModal from "./McpWizardModal";
@@ -32,38 +19,37 @@ import {
extractIdFromToml,
mcpServerToToml,
} from "@/utils/tomlUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
import { parseSmartMcpJson } from "@/utils/formatters";
import { useMcpValidation } from "./useMcpValidation";
import { useUpsertMcpServer } from "@/hooks/useMcp";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
interface McpFormModalProps {
appId: AppId;
editingId?: string;
initialData?: McpServer;
onSave: (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<void>;
onSave: () => Promise<void>;
onClose: () => void;
existingIds?: string[];
defaultFormat?: "json" | "toml";
defaultEnabledApps?: AppId[];
}
/**
* MCP 表单模态框组件(简化版)
* Claude: 使用 JSON 格式
* Codex: 使用 TOML 格式
*/
const McpFormModal: React.FC<McpFormModalProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
existingIds = [],
defaultFormat = "json",
defaultEnabledApps = ["claude", "codex", "gemini"],
}) => {
const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
useMcpValidation();
const upsertMutation = useUpsertMcpServer();
const [formId, setFormId] = useState(
() => editingId || initialData?.id || "",
);
@@ -75,10 +61,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 编辑模式下禁止修改 ID
const [enabledApps, setEnabledApps] = useState<{
claude: boolean;
codex: boolean;
gemini: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
}
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
};
});
const isEditing = !!editingId;
// 判断是否在编辑模式下有附加信息
const hasAdditionalInfo = !!(
initialData?.description ||
initialData?.tags?.length ||
@@ -86,16 +85,21 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
initialData?.docs
);
// 附加信息展开状态(编辑模式下有值时默认展开)
const [showMetadata, setShowMetadata] = useState(
isEditing ? hasAdditionalInfo : false,
);
// 根据 appId 决定初始格式
const useTomlFormat = useMemo(() => {
if (initialData?.server) {
return defaultFormat === "toml";
}
return defaultFormat === "toml";
}, [defaultFormat, initialData]);
const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server;
if (!spec) return "";
if (appId === "codex") {
if (useTomlFormat) {
return mcpServerToToml(spec);
}
return JSON.stringify(spec, null, 2);
@@ -105,39 +109,24 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
const [syncOtherSide, setSyncOtherSide] = useState(false);
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 判断是否使用 TOML 格式
const useToml = appId === "codex";
const syncTargetLabel =
appId === "claude" ? t("apps.codex") : t("apps.claude");
const otherAppType: AppId = appId === "claude" ? "codex" : "claude";
const syncCheckboxId = useMemo(() => `sync-other-side-${appId}`, [appId]);
// 检测另一侧是否有同名 MCP
useEffect(() => {
const checkOtherSide = async () => {
const currentId = formId.trim();
if (!currentId) {
setOtherSideHasConflict(false);
return;
}
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
const otherConfig = await mcpApi.getConfig(otherAppType);
const hasConflict = Object.keys(otherConfig.servers || {}).includes(
currentId,
);
setOtherSideHasConflict(hasConflict);
} catch (error) {
console.error("检查另一侧 MCP 配置失败:", error);
setOtherSideHasConflict(false);
}
};
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
checkOtherSide();
}, [formId, otherAppType]);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const useToml = useTomlFormat;
const wizardInitialSpec = useMemo(() => {
const fallback = initialData?.server;
@@ -164,7 +153,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}, [formConfig, initialData, useToml]);
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
);
@@ -186,7 +174,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return `${candidate}-${i}`;
};
// 应用预设(写入表单但不落库)
const applyPreset = (index: number) => {
if (index < 0 || index >= mcpPresets.length) return;
const preset = mcpPresets[index];
@@ -200,7 +187,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setFormDocs(presetWithDesc.docs || "");
setFormTags(presetWithDesc.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
@@ -213,10 +199,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSelectedPreset(index);
};
// 切回自定义
const applyCustom = () => {
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
@@ -228,33 +212,48 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
const handleConfigChange = (value: string) => {
setFormConfig(value);
const nextValue = useToml ? normalizeTomlText(value) : value;
setFormConfig(nextValue);
if (useToml) {
// TOML validation (use hook's complete validation)
const err = validateTomlConfig(value);
const err = validateTomlConfig(nextValue);
if (err) {
setConfigError(err);
return;
}
// Try to extract ID (if user hasn't filled it yet)
if (value.trim() && !formId.trim()) {
const extractedId = extractIdFromToml(value);
if (nextValue.trim() && !formId.trim()) {
const extractedId = extractIdFromToml(nextValue);
if (extractedId) {
setFormId(extractedId);
}
}
} else {
// JSON validation (use hook's complete validation)
const err = validateJsonConfig(value);
if (err) {
setConfigError(err);
return;
try {
const result = parseSmartMcpJson(value);
const configJson = JSON.stringify(result.config);
const validationErr = validateJsonConfig(configJson);
if (validationErr) {
setConfigError(validationErr);
return;
}
if (result.id && !formId.trim() && !isEditing) {
const uniqueId = ensureUniqueId(result.id);
setFormId(uniqueId);
if (!formName.trim()) {
setFormName(result.id);
}
}
setConfigError("");
} catch (err: any) {
const errorMessage = err?.message || String(err);
setConfigError(t("mcp.error.jsonInvalid") + ": " + errorMessage);
}
}
setConfigError("");
};
const handleWizardApply = (title: string, json: string) => {
@@ -262,7 +261,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formName.trim()) {
setFormName(title);
}
// Wizard returns JSON, convert based on format if needed
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
@@ -285,17 +283,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return;
}
// 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists"));
return;
}
// Validate configuration format
let serverSpec: McpServerSpec;
if (useToml) {
// TOML mode
const tomlError = validateTomlConfig(formConfig);
setConfigError(tomlError);
if (tomlError) {
@@ -304,7 +299,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -321,16 +315,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON mode
const jsonError = validateJsonConfig(formConfig);
setConfigError(jsonError);
if (jsonError) {
toast.error(t("mcp.error.jsonInvalid"), { duration: 3000 });
return;
}
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -338,45 +323,42 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
} else {
try {
serverSpec = JSON.parse(formConfig) as McpServerSpec;
const result = parseSmartMcpJson(formConfig);
serverSpec = result.config as McpServerSpec;
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
const errorMessage = e?.message || String(e);
setConfigError(t("mcp.error.jsonInvalid") + ": " + errorMessage);
toast.error(t("mcp.error.jsonInvalid"), { duration: 4000 });
return;
}
}
}
// 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return;
}
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
if (
(serverSpec?.type === "http" || serverSpec?.type === "sse") &&
!serverSpec?.url?.trim()
) {
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return;
}
setSaving(true);
try {
const nameTrimmed = (formName || trimmedId).trim();
const finalName = nameTrimmed || trimmedId;
const entry: McpServer = {
...(initialData ? { ...initialData } : {}),
id: trimmedId,
name: finalName,
server: serverSpec,
apps: enabledApps,
};
// 修复:新增 MCP 时默认启用enabled=true
// 编辑模式下保留原有的 enabled 状态
if (initialData?.enabled !== undefined) {
entry.enabled = initialData.enabled;
} else {
// 新增模式:默认启用
entry.enabled = true;
}
const nameTrimmed = (formName || trimmedId).trim();
entry.name = nameTrimmed || trimmedId;
const descriptionTrimmed = formDescription.trim();
if (descriptionTrimmed) {
entry.description = descriptionTrimmed;
@@ -408,8 +390,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
delete entry.tags;
}
// 显式等待父组件保存流程
await onSave(trimmedId, entry, { syncOtherSide });
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
await onSave();
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -421,27 +404,38 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
const getFormTitle = () => {
if (appId === "claude") {
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
} else {
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
}
return isEditing ? t("mcp.editServer") : t("mcp.addServer");
};
return (
<>
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{getFormTitle()}</DialogTitle>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<FullScreenPanel
isOpen={true}
title={getFormTitle()}
onClose={onClose}
footer={
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
}
>
<div className="flex flex-col h-full gap-6">
{/* 上半部分:表单字段 */}
<div className="glass rounded-xl p-6 border border-white/10 space-y-6 flex-shrink-0">
{/* 预设选择(仅新增时展示) */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
<label className="block text-sm font-medium text-foreground mb-3">
{t("mcp.presets.title")}
</label>
<div className="flex flex-wrap gap-2">
@@ -451,7 +445,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
{t("presetSelector.custom")}
@@ -466,7 +460,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === idx
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
title={t(descriptionKey)}
>
@@ -477,10 +471,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</div>
)}
{/* ID (标题) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="block text-sm font-medium text-foreground">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
{!isEditing && idError && (
@@ -500,7 +495,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.name")}
</label>
<Input
@@ -511,12 +506,68 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* 启用到哪些应用 */}
<div>
<label className="block text-sm font-medium text-foreground mb-3">
{t("mcp.form.enabledApps")}
</label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="enable-claude"
checked={enabledApps.claude}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, claude: checked })
}
/>
<label
htmlFor="enable-claude"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-codex"
checked={enabledApps.codex}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, codex: checked })
}
/>
<label
htmlFor="enable-codex"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-gemini"
checked={enabledApps.gemini}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, gemini: checked })
}
/>
<label
htmlFor="enable-gemini"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
</div>
</div>
</div>
{/* 可折叠的附加信息按钮 */}
<div>
<button
type="button"
onClick={() => setShowMetadata(!showMetadata)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showMetadata ? (
<ChevronUp size={16} />
@@ -530,9 +581,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* 附加信息区域(可折叠) */}
{showMetadata && (
<>
{/* Description (描述) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.description")}
</label>
<Input
@@ -543,9 +593,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.tags")}
</label>
<Input
@@ -556,9 +605,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Homepage */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.homepage")}
</label>
<Input
@@ -569,9 +617,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Docs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.docs")}
</label>
<Input
@@ -583,100 +630,51 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</>
)}
</div>
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{useToml
? t("mcp.form.tomlConfig")
: t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
{/* 下半部分JSON 配置编辑器 - 自适应剩余高度 */}
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<label className="text-sm font-medium text-foreground">
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0">
<JsonEditor
value={formConfig}
onChange={handleConfigChange}
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
darkMode={isDarkMode}
rows={12}
showValidation={!useToml}
language={useToml ? "javascript" : "json"}
height="100%"
/>
</div>
<Textarea
className="h-48 resize-none font-mono text-xs"
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
value={formConfig}
onChange={(e) => handleConfigChange(e.target.value)}
/>
{configError && (
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm flex-shrink-0">
<AlertCircle size={16} />
<span>{configError}</span>
</div>
)}
</div>
</div>
{/* Footer */}
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
{/* 双端同步选项 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<input
id={syncCheckboxId}
type="checkbox"
className="h-4 w-4 rounded border-border-default text-emerald-600 focus:ring-emerald-500 dark:bg-gray-800"
checked={syncOtherSide}
onChange={(event) => setSyncOtherSide(event.target.checked)}
/>
<label
htmlFor={syncCheckboxId}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
title={t("mcp.form.syncOtherSideHint", {
target: syncTargetLabel,
})}
>
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
</label>
</div>
{syncOtherSide && otherSideHasConflict && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<AlertTriangle size={14} />
<span className="text-xs font-medium">
{t("mcp.form.willOverwriteWarning", {
target: syncTargetLabel,
})}
</span>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
variant="mcp"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FullScreenPanel>
{/* Wizard Modal */}
<McpWizardModal

View File

@@ -1,122 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { McpServer } from "@/types";
import { mcpPresets } from "@/config/mcpPresets";
import McpToggle from "./McpToggle";
interface McpListItemProps {
id: string;
server: McpServer;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
/**
* MCP 列表项组件
* 每个 MCP 占一行,左侧是 Toggle 开关,中间是名称和详细信息,右侧是编辑和删除按钮
*/
const McpListItem: React.FC<McpListItemProps> = ({
id,
server,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
const enabled = server.enabled === true;
const name = server.name || id;
// 只显示 description没有则留空
const description = server.description || "";
// 匹配预设元信息(用于展示文档链接等)
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await settingsApi.openExternal(url);
} catch {
// ignore
}
};
return (
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
<div className="flex items-center gap-4 h-full">
{/* 左侧Toggle 开关 */}
<div className="flex-shrink-0">
<McpToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
{/* 中间:名称和详细信息 */}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{name}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
{/* 预设标记已移除 */}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
</div>
);
};
export default McpListItem;

View File

@@ -1,229 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Server, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { type AppId } from "@/lib/api";
import { McpServer } from "@/types";
import { useMcpActions } from "@/hooks/useMcpActions";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
interface McpPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ open, onOpenChange, appId }) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
// Use MCP actions hook
const { servers, loading, reload, toggleEnabled, saveServer, deleteServer } =
useMcpActions(appId);
useEffect(() => {
const setup = async () => {
try {
// Initialize: only import existing MCPs from corresponding client
if (appId === "claude") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromClaude();
} else if (appId === "codex") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromCodex();
}
} catch (e) {
console.warn("MCP initialization import failed (ignored)", e);
} finally {
await reload();
}
};
setup();
// Re-initialize when appId changes
}, [appId, reload]);
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await deleteServer(id);
setConfirmDialog(null);
} catch (e) {
// Error already handled by useMcpActions
}
},
});
};
const handleSave = async (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => {
await saveServer(id, server, options);
setIsFormOpen(false);
setEditingId(null);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(
() => Object.entries(servers) as Array<[string, McpServer]>,
[servers],
);
const enabledCount = useMemo(
() => serverEntries.filter(([_, server]) => server.enabled).length,
[serverEntries],
);
const panelTitle =
appId === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle>{panelTitle}</DialogTitle>
<Button type="button" variant="mcp" onClick={handleAdd}>
<Plus size={16} />
{t("mcp.add")}
</Button>
</div>
</DialogHeader>
{/* Info Section */}
<div className="flex-shrink-0 px-6 py-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
{t("mcp.enabledCount", { count: enabledCount })}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 pb-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : (
(() => {
const hasAny = serverEntries.length > 0;
if (!hasAny) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={`installed-${id}`}
id={id}
server={server}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
</div>
);
})()
)}
</div>
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
existingIds={Object.keys(servers)}
onSave={handleSave}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</>
);
};
export default McpPanel;

View File

@@ -80,13 +80,15 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
initialServer,
}) => {
const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">(
"stdio",
);
const [wizardTitle, setWizardTitle] = useState("");
// stdio 字段
const [wizardCommand, setWizardCommand] = useState("");
const [wizardArgs, setWizardArgs] = useState("");
const [wizardEnv, setWizardEnv] = useState("");
// http 字段
// http 和 sse 字段
const [wizardUrl, setWizardUrl] = useState("");
const [wizardHeaders, setWizardHeaders] = useState("");
@@ -115,7 +117,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
}
}
} else {
// http 类型必需字段
// http 和 sse 类型必需字段
config.url = wizardUrl.trim();
// 可选字段
@@ -139,7 +141,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return;
}
if (wizardType === "http" && !wizardUrl.trim()) {
if ((wizardType === "http" || wizardType === "sse") && !wizardUrl.trim()) {
toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return;
}
@@ -179,7 +181,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
setWizardType(resolvedType);
if (resolvedType === "http") {
if (resolvedType === "http" || resolvedType === "sse") {
setWizardUrl(initialServer?.url ?? "");
const headersCandidate = initialServer?.headers;
const headers =
@@ -250,7 +252,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
value="stdio"
checked={wizardType === "stdio"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
@@ -264,7 +266,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
value="http"
checked={wizardType === "http"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
@@ -272,6 +274,20 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{t("mcp.wizard.typeHttp")}
</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="sse"
checked={wizardType === "sse"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeSse")}
</span>
</label>
</div>
</div>
@@ -339,8 +355,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
</>
)}
{/* HTTP 类型字段 */}
{wizardType === "http" && (
{/* HTTP 和 SSE 类型字段 */}
{(wizardType === "http" || wizardType === "sse") && (
<>
{/* URL */}
<div>

View File

@@ -0,0 +1,345 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import { useDeleteMcpServer } from "@/hooks/useMcp";
import { Edit3, Trash2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets";
import { toast } from "sonner";
interface UnifiedMcpPanelProps {
onOpenChange: (open: boolean) => void;
}
/**
* 统一 MCP 管理面板
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
*/
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
}
const UnifiedMcpPanel = React.forwardRef<
UnifiedMcpPanelHandle,
UnifiedMcpPanelProps
>(({ onOpenChange: _onOpenChange }, ref) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
// Queries and Mutations
const { data: serversMap, isLoading } = useAllMcpServers();
const toggleAppMutation = useToggleMcpApp();
const deleteServerMutation = useDeleteMcpServer();
// Convert serversMap to array for easier rendering
const serverEntries = useMemo((): Array<[string, McpServer]> => {
if (!serversMap) return [];
return Object.entries(serversMap);
}, [serversMap]);
// Count enabled servers per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0 };
serverEntries.forEach(([_, server]) => {
if (server.apps.claude) counts.claude++;
if (server.apps.codex) counts.codex++;
if (server.apps.gemini) counts.gemini++;
});
return counts;
}, [serverEntries]);
const handleToggleApp = async (
serverId: string,
app: AppId,
enabled: boolean,
) => {
try {
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.unifiedPanel.deleteServer"),
message: t("mcp.unifiedPanel.deleteConfirm", { id }),
onConfirm: async () => {
try {
await deleteServerMutation.mutateAsync(id);
setConfirmDialog(null);
toast.success(t("common.success"));
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
},
});
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
return (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server size={24} className="text-gray-400 dark:text-gray-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
editingId={editingId || undefined}
initialData={
editingId && serversMap ? serversMap[editingId] : undefined
}
existingIds={serversMap ? Object.keys(serversMap) : []}
defaultFormat="json"
onSave={async () => {
setIsFormOpen(false);
setEditingId(null);
}}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
});
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
/**
* 统一 MCP 列表项组件
* 展示服务器名称、描述,以及三个应用的复选框
*/
interface UnifiedMcpListItemProps {
id: string;
server: McpServer;
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
id,
server,
onToggleApp,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
const name = server.name || id;
const description = server.description || "";
// 匹配预设元信息
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await settingsApi.openExternal(url);
} catch {
// ignore
}
};
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
<Switch
id={`${id}-claude`}
checked={server.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "claude", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
<Switch
id={`${id}-codex`}
checked={server.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
<Switch
id={`${id}-gemini`}
checked={server.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "gemini", checked)
}
/>
</div>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);
};
export default UnifiedMcpPanel;

View File

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

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import MarkdownEditor from "@/components/MarkdownEditor";
import type { Prompt, AppId } from "@/lib/api";
interface PromptFormModalProps {
appId: AppId;
editingId?: string;
initialData?: Prompt;
onSave: (id: string, prompt: Prompt) => Promise<void>;
onClose: () => void;
}
const PromptFormModal: React.FC<PromptFormModalProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
// 检测初始暗色模式状态
setIsDarkMode(document.documentElement.classList.contains("dark"));
// 监听 html 元素的 class 变化以实时响应主题切换
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (initialData) {
setName(initialData.name);
setDescription(initialData.description || "");
setContent(initialData.content);
}
}, [initialData]);
const handleSave = async () => {
if (!name.trim() || !content.trim()) {
return;
}
setSaving(true);
try {
const id = editingId || `prompt-${Date.now()}`;
const timestamp = Math.floor(Date.now() / 1000);
const prompt: Prompt = {
id,
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
enabled: initialData?.enabled || false,
createdAt: initialData?.createdAt || timestamp,
updatedAt: timestamp,
};
await onSave(id, prompt);
onClose();
} catch (error) {
// Error handled by hook
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{editingId
? t("prompts.editTitle", { appName })
: t("prompts.addTitle", { appName })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 px-6 py-4">
<div>
<Label htmlFor="name">{t("prompts.name")}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("prompts.namePlaceholder")}
/>
</div>
<div>
<Label htmlFor="description">{t("prompts.description")}</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("prompts.descriptionPlaceholder")}
/>
</div>
<div>
<Label htmlFor="content" className="mb-2 block">
{t("prompts.content")}
</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={t("prompts.contentPlaceholder", { filename })}
darkMode={isDarkMode}
minHeight="300px"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!name.trim() || !content.trim() || saving}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFormModal;

View File

@@ -0,0 +1,153 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import MarkdownEditor from "@/components/MarkdownEditor";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Prompt, AppId } from "@/lib/api";
interface PromptFormPanelProps {
appId: AppId;
editingId?: string;
initialData?: Prompt;
onSave: (id: string, prompt: Prompt) => Promise<void>;
onClose: () => void;
}
const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (initialData) {
setName(initialData.name);
setDescription(initialData.description || "");
setContent(initialData.content);
}
}, [initialData]);
const handleSave = async () => {
if (!name.trim() || !content.trim()) {
return;
}
setSaving(true);
try {
const id = editingId || `prompt-${Date.now()}`;
const timestamp = Math.floor(Date.now() / 1000);
const prompt: Prompt = {
id,
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
enabled: initialData?.enabled || false,
createdAt: initialData?.createdAt || timestamp,
updatedAt: timestamp,
};
await onSave(id, prompt);
onClose();
} catch (error) {
// Error handled by hook
} finally {
setSaving(false);
}
};
const title = editingId
? t("prompts.editTitle", { appName })
: t("prompts.addTitle", { appName });
return (
<FullScreenPanel
isOpen={true}
title={title}
onClose={onClose}
footer={
<Button
type="button"
onClick={handleSave}
disabled={!name.trim() || !content.trim() || saving}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
<div className="glass rounded-xl p-6 border border-white/10 space-y-6">
<div>
<Label htmlFor="name" className="text-foreground">
{t("prompts.name")}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("prompts.namePlaceholder")}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="description" className="text-foreground">
{t("prompts.description")}
</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("prompts.descriptionPlaceholder")}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="content" className="block mb-2 text-foreground">
{t("prompts.content")}
</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={t("prompts.contentPlaceholder", { filename })}
darkMode={isDarkMode}
minHeight="167px"
/>
</div>
</div>
</FullScreenPanel>
);
};
export default PromptFormPanel;

View File

@@ -0,0 +1,75 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Prompt } from "@/lib/api";
import PromptToggle from "./PromptToggle";
interface PromptListItemProps {
id: string;
prompt: Prompt;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
const PromptListItem: React.FC<PromptListItemProps> = ({
id,
prompt,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
const enabled = prompt.enabled === true;
return (
<div className="group relative h-16 rounded-xl border border-border-default bg-muted/50 p-4 transition-all duration-300 hover:bg-muted hover:border-border-default/80 hover:shadow-sm">
<div className="flex items-center gap-4 h-full">
{/* Toggle 开关 */}
<div className="flex-shrink-0">
<PromptToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{prompt.name}
</h3>
{prompt.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{prompt.description}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
</div>
);
};
export default PromptListItem;

View File

@@ -0,0 +1,155 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText } from "lucide-react";
import { type AppId } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import PromptListItem from "./PromptListItem";
import PromptFormPanel from "./PromptFormPanel";
import { ConfirmDialog } from "../ConfirmDialog";
interface PromptPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
}
export interface PromptPanelHandle {
openAdd: () => void;
}
const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
({ open, appId }, ref) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
titleKey: string;
messageKey: string;
messageParams?: Record<string, unknown>;
onConfirm: () => void;
} | null>(null);
const {
prompts,
loading,
reload,
savePrompt,
deletePrompt,
toggleEnabled,
} = usePromptActions(appId);
useEffect(() => {
if (open) reload();
}, [open, reload]);
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
const prompt = prompts[id];
setConfirmDialog({
isOpen: true,
titleKey: "prompts.confirm.deleteTitle",
messageKey: "prompts.confirm.deleteMessage",
messageParams: { name: prompt?.name },
onConfirm: async () => {
try {
await deletePrompt(id);
setConfirmDialog(null);
} catch (e) {
// Error handled by hook
}
},
});
};
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
{enabledPrompt
? t("prompts.enabledName", { name: enabledPrompt[1].name })
: t("prompts.noneEnabled")}
</div>
</div>
<div className="flex-1 overflow-y-auto pb-16">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")}
</div>
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{promptEntries.map(([id, prompt]) => (
<PromptListItem
key={id}
id={id}
prompt={prompt}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{isFormOpen && (
<PromptFormPanel
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? prompts[editingId] : undefined}
onSave={savePrompt}
onClose={() => setIsFormOpen(false)}
/>
)}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={t(confirmDialog.titleKey)}
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
},
);
PromptPanel.displayName = "PromptPanel";
export default PromptPanel;

View File

@@ -1,16 +1,16 @@
import React from "react";
interface McpToggleProps {
interface PromptToggleProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}
/**
* Toggle
* 绿
* Toggle
*
*/
const McpToggle: React.FC<McpToggleProps> = ({
const PromptToggle: React.FC<PromptToggleProps> = ({
enabled,
onChange,
disabled = false,
@@ -23,8 +23,8 @@ const McpToggle: React.FC<McpToggleProps> = ({
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
@@ -38,4 +38,4 @@ const McpToggle: React.FC<McpToggleProps> = ({
);
};
export default McpToggle;
export default PromptToggle;

View File

@@ -1,15 +1,8 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Provider, CustomEndpoint } from "@/types";
import type { AppId } from "@/lib/api";
import {
@@ -18,6 +11,7 @@ import {
} from "@/components/providers/forms/ProviderForm";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
interface AddProviderDialogProps {
open: boolean;
@@ -44,8 +38,11 @@ export function AddProviderDialog({
// 构造基础提交数据
const providerData: Omit<Provider, "id"> = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
icon: values.icon?.trim() || undefined,
iconColor: values.iconColor?.trim() || undefined,
...(values.presetCategory ? { category: values.presetCategory } : {}),
...(values.meta ? { meta: values.meta } : {}),
};
@@ -56,8 +53,6 @@ export function AddProviderDialog({
if (!hasCustomEndpoints) {
// 收集端点候选(仅在缺少自定义端点时兜底)
// 1. 从预设配置中获取 endpointCandidates
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => {
@@ -96,6 +91,21 @@ export function AddProviderDialog({
preset.endpointCandidates.forEach(addUrl);
}
}
} else if (appId === "gemini") {
const presets = geminiProviderPresets;
const presetIndex = parseInt(
values.presetId.replace("gemini-", ""),
);
if (
!isNaN(presetIndex) &&
presetIndex >= 0 &&
presetIndex < presets.length
) {
const preset = presets[presetIndex];
if (Array.isArray(preset.endpointCandidates)) {
preset.endpointCandidates.forEach(addUrl);
}
}
}
}
@@ -114,6 +124,11 @@ export function AddProviderDialog({
addUrl(baseUrlMatch[1]);
}
}
} else if (appId === "gemini") {
const env = parsedConfig.env as Record<string, any> | undefined;
if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
}
const urls = Array.from(urlSet);
@@ -144,36 +159,44 @@ export function AddProviderDialog({
const submitLabel =
appId === "claude"
? t("provider.addClaudeProvider")
: t("provider.addCodexProvider");
: appId === "codex"
? t("provider.addCodexProvider")
: t("provider.addGeminiProvider");
const footer = (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("common.add")}
</Button>
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<DialogTitle>{submitLabel}</DialogTitle>
<DialogDescription>{t("provider.addProviderHint")}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" form="provider-form">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<FullScreenPanel
isOpen={open}
title={submitLabel}
onClose={() => onOpenChange(false)}
footer={footer}
>
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</FullScreenPanel>
);
}

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