98 Commits

Author SHA1 Message Date
Jason
052029b3b0 - merge: sync with origin/main, resolve renewed conflicts
- fix(tauri): union command handlers (import/export + speedtest/custom endpoints)
- fix(api): keep import/export APIs and provider-switched listener together
- chore(presets): retain legacy model keys alongside new defaults (GLM)
- chore(rust): keep chrono dep; lockfile updated by merge
2025-10-07 19:12:51 +08:00
Jason
5dc59dc7f8 - merge: merge origin/main, resolve conflicts and preserve both feature sets
- feat(tauri): register import/export and file dialogs; keep endpoint speed test and custom endpoints
- feat(api): add updateTrayMenu and onProviderSwitched; wire import/export APIs
- feat(types): extend global API declarations (import/export)
- chore(presets): GLM preset supports both new and legacy model keys
- chore(rust): add chrono dependency; refresh lockfile
2025-10-07 19:06:27 +08:00
Jason
331e48a530 refactor: improve endpoint management button UX
Replace ambiguous "Advanced" text with intuitive "Manage & Test" label accompanied by Zap icon, making the endpoint management panel entry point more discoverable and self-explanatory for both Claude and Codex configurations.
2025-10-07 16:25:36 +08:00
Jason
aefc5699a2 feat: add endpoint candidates support and code formatting improvements
- Add endpointCandidates field to ProviderPreset and CodexProviderPreset interfaces
- Integrate preset endpoint candidates into speed test endpoint selection
- Add multiple endpoint options for PackyCode providers (Claude & Codex)
- Apply consistent code formatting (trailing commas, line breaks)
- Improve template value type safety and readability
2025-10-07 12:03:11 +08:00
Jason
061aef1c2f - feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map
- feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit

- feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals

- fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends

- storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json)

- behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation
2025-10-07 11:09:00 +08:00
Jason
498920dea6 feat: persist custom endpoints to settings.json
- Extend AppSettings to store custom endpoints for Claude and Codex
- Add Tauri commands: get/add/remove/update custom endpoints
- Update frontend API with endpoint persistence methods
- Modify EndpointSpeedTest to load/save custom endpoints via API
- Track endpoint last used time for future sorting/cleanup
- Store endpoints per app type in settings.json instead of localStorage
2025-10-06 21:51:48 +08:00
Jason
9932b92745 refactor: improve endpoint list UI consistency
- Show delete button for all endpoints on hover for uniform UI
- Change selected state to use blue theme matching main interface:
  * Blue border (border-blue-500) for selected items
  * Light blue background (bg-blue-50/dark:bg-blue-900/20)
  * Blue indicator dot (bg-blue-500/dark:bg-blue-400)
- Switch from compact list (space-y-px) to card-based layout (space-y-2)
- Add rounded corners to each endpoint item for better visual separation
2025-10-06 21:30:11 +08:00
Jason
b4b176580e refactor: unify speed test panel UI with project design system
UI improvements:
- Update modal border radius from rounded-lg to rounded-xl
- Unify header padding from px-6 py-4 to p-6
- Change speed test button color to blue theme (bg-blue-500) for consistency
- Update footer background from bg-gray-50 to bg-gray-100
- Style "Done" button as primary action button with blue theme
- Adjust footer button spacing and hover states

Simplify endpoint display:
- Remove endpoint labels (e.g., "Current Address", "Custom 1")
- Display only URL for cleaner interface
- Clean up all label-related logic:
  * Remove label field from EndpointCandidate interface
  * Remove label generation in buildInitialEntries function
  * Remove label handling in useEffect merge logic
  * Remove label generation in handleAddEndpoint
  * Remove label parameters from claudeSpeedTestEndpoints
  * Remove label parameters from codexSpeedTestEndpoints
2025-10-06 21:14:54 +08:00
Jason
1c9a9af11c fix: prevent modal cascade closing when ESC is pressed
- Add state checks to prevent parent modal from closing when child modals (endpoint speed test or template wizard) are open
- Update ESC key handler dependencies to track all modal states
- Ensures only the topmost modal responds to ESC key
2025-10-06 17:16:20 +08:00
WormW
3ad11acdb2 add: local config import and export (#84)
* add: local config import and export

* Fix import refresh flow and typings

* Clarify import refresh messaging

* Limit stored import backups

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-10-05 23:33:07 +08:00
Jason
d7fe4a7165 refactor: convert endpoint speed test to modal dialog
- Transform EndpointSpeedTest component into a modal dialog
- Add "Advanced" button next to base URL input to open modal
- Support ESC key and backdrop click to close modal
- Apply Linear design principles: minimal styling, clean layout
- Remove unused showBaseUrlInput variable
- Implement same modal pattern for both Claude and Codex
2025-10-05 20:43:11 +08:00
zhangweiii
f8c40d591f feat: update GLM provider model configuration (#90)
- Update GLM-4.5 to GLM-4.6 as default Sonnet and Opus models
- Use new model naming convention (glm-4.5-air, glm-4.6)
- Align with latest GLM API model naming standards
2025-10-05 20:41:39 +08:00
QuentinHsu
ce593248fc fix: update layout for Claude app type provider display (#87)
* feat: add .node-version file with Node.js version 22.4.1

* fix: update layout for Claude app type provider display
2025-10-05 11:09:03 +08:00
Jason
4fc76200e8 feat: add unified endpoint speed test for API providers
Add a comprehensive endpoint latency testing system that allows users to:
- Test multiple API endpoints concurrently
- Auto-select the fastest endpoint based on latency
- Add/remove custom endpoints dynamically
- View latency results with color-coded indicators

Backend (Rust):
- Implement parallel HTTP HEAD requests with configurable timeout
- Handle various error scenarios (timeout, connection failure, invalid URL)
- Return structured latency data with status codes

Frontend (React):
- Create interactive speed test UI component with auto-sort by latency
- Support endpoint management (add/remove custom endpoints)
- Extract and update Codex base_url from TOML configuration
- Integrate with provider presets for default endpoint candidates

This feature improves user experience when selecting optimal API endpoints,
especially useful for users with multiple provider options or proxy setups.
2025-10-04 18:04:40 +08:00
Jason
e0908701b4 Remove deprecated VS Code Codex integration 2025-10-03 20:03:55 +08:00
Lakr
d86994eb7e feat: support kat-coder & template value (#77) 2025-10-02 22:14:35 +08:00
Jason
94e93137a2 chore: bump version to 3.4.0
- Add i18next internationalization with Chinese/English support
- Add Claude plugin sync alongside VS Code integration
- Extend provider presets with new models (DeepSeek-V3.2-Exp, Qwen3-Max, GLM-4.6)
- Support portable mode and single instance enforcement
- Add tray minimize and macOS Dock visibility management
- Improve Settings UI with scrollable layout and save icon
- Fix layout shifts and provider toggle consistency
- Remove unnecessary OpenAI auth requirement
- Update Windows MSI installer to target per-user LocalAppData
2025-10-02 09:59:38 +08:00
Jason
db832a9654 fix: eliminate layout shift when switching app types with Claude plugin sync
- Separate sync button containers for Codex and Claude modes
- Only render the container in corresponding app type to prevent layout jumping
- Apply same fix pattern as commit 0bcc04a for VS Code sync button
2025-10-01 21:33:29 +08:00
Jason
45a639e73f feat: add optional apiKeyUrl field to provider presets
Allow third-party providers to specify a dedicated API key URL separate from the main website URL for easier key acquisition.
2025-10-01 21:28:09 +08:00
Jason
f74d641f86 Add Claude plugin sync alongside VS Code integration 2025-10-01 21:23:55 +08:00
Jason
fcfa9574e8 Update AI model versions in provider presets
- Update DeepSeek model from V3.1-Terminus to V3.2-Exp
- Update ModelScope model from GLM-4.5 to GLM-4.6
2025-09-30 22:19:20 +08:00
Jason
d739bb36e5 feat: add macOS Dock visibility management for tray mode
- Hide Dock icon when minimizing to tray
- Show Dock icon when restoring window from tray
- Apply appropriate activation policy (Accessory/Regular) based on window state
- Add error handling with logging for Dock operations
2025-09-29 17:03:13 +08:00
Jason
0bcc04adce fix: eliminate layout shift when switching between Claude and Codex
- Wrap VS Code sync button in fixed-width container to maintain stable layout
- Only render the container in Codex mode to avoid unnecessary space in Claude mode
- Change card transition from 'all' to specific properties (border-color, box-shadow) to prevent layout animations
- These changes prevent the horizontal position jumping of provider cards during app switching
2025-09-28 23:23:43 +08:00
Jason
fee0762e3e fix: improve Enable/In Use button consistency with fixed width and icons 2025-09-28 23:00:43 +08:00
Jason
1a8ae85e55 refactor: simplify language settings UI by removing description text and general section 2025-09-28 22:40:14 +08:00
Jason
c5aa244d65 feat: integrate language switcher into settings with modern segment control UI
- Move language switcher from header to settings modal for better organization
- Implement modern segment control UI instead of radio buttons for language selection
- Add language preference persistence in localStorage and backend settings
- Support instant language preview with cancel/revert functionality
- Remove standalone LanguageSwitcher component
- Improve initial language detection logic (localStorage -> browser -> default)
- Add proper i18n keys for language settings UI text
2025-09-28 22:23:49 +08:00
Jason
0bedbb2663 feat: change default language to Chinese with English fallback 2025-09-28 21:11:22 +08:00
TinsFox
5f3caa1484 feat: integrate i18next for internationalization support (#65)
* feat: integrate i18next for internationalization support

- Added i18next and react-i18next dependencies for localization.
- Updated various components to utilize translation functions for user-facing text.
- Enhanced user experience by providing multilingual support across the application.

* feat: improve i18n implementation with better translations and accessibility

- Add proper i18n keys for language switcher tooltips and aria-labels
- Replace hardcoded Chinese console error messages with i18n keys
- Add missing translation keys for new UI elements
- Improve accessibility with proper aria-label attributes

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-28 20:47:44 +08:00
farion1231
fd0e83ebd5 fix: remove unnecessary openai auth requirement from third-party config
Remove the env_key and requires_openai_auth fields from third-party provider config generation as they are not needed for custom API endpoints.
2025-09-28 09:53:55 +08:00
farion1231
e969bdbd73 feat: improve SettingsModal UX with scrolling and save icon
- Add scrollable content area with max height constraint
- Add Save icon to save button for better visual clarity
2025-09-27 11:20:37 +08:00
Jason
7435a34c66 update roadmap 2025-09-26 21:57:38 +08:00
ShaSan
5d2d15690c feat: support minimizing window to tray on close (#41)
fix: grant set_skip_taskbar permission through default capability

chore: align settings casing and defaults between Rust and frontend

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-26 20:18:11 +08:00
farion1231
11ee8bddf7 refactor: consolidate chatgpt.config cleanup logic
Merged duplicate try-catch blocks that handle invalid chatgpt.config values.
Now handles all edge cases (scalar values, arrays, empty objects) in a single
unified check, reducing code duplication and improving performance by parsing
JSON only once.
2025-09-26 09:27:56 +08:00
Jason
186c361a79 refactor: reorganize documentation structure
- Remove docs/ from .gitignore to track documentation files
- Delete completed plan documents (encrypted-config-plan.md, updater-plan.md)
- Add roadmap.md with project milestones and future features
2025-09-25 23:51:48 +08:00
Jason
cc1caea36d Add support for qwen3-max 2025-09-24 22:28:32 +08:00
Jason
9ede0ad27d feat: add portable mode support and improve update handling
- Add portable.ini marker file creation in GitHub Actions for portable builds
- Implement is_portable_mode() command to detect portable execution
- Redirect portable users to GitHub releases page for manual updates
- Change update URL to point to latest releases page
- Integrate portable mode detection in Settings UI
2025-09-24 11:25:33 +08:00
farion1231
20f0dd7e1c fix: improve per-user MSI installer component tracking
- Change File KeyPath to "no" and use registry value as KeyPath instead
- Add registry entry for better component state tracking
- Enhance uninstall cleanup to remove LocalAppData files and folders
2025-09-23 20:55:30 +08:00
Jason
4dd07dfd85 Switch MSI installer to per-user LocalAppData target 2025-09-23 14:41:28 +08:00
Jason
8c01be42fa feat: add single instance plugin to prevent multiple app instances
Integrates tauri_plugin_single_instance to ensure only one instance of the application
runs at a time. When attempting to launch a second instance, it will focus the existing
window instead.
2025-09-23 10:02:23 +08:00
Jason
aaf1af0743 release: bump version to v3.3.1
Add support for DeepSeek-V3.1-Terminus model with emergency hotfix release
2025-09-22 23:31:59 +08:00
Jason
aeb0007957 feat: add support for DeepSeek-V3.1-Terminus 2025-09-22 23:20:50 +08:00
Jason
077d491720 release: bump version to 3.3.0
Update version across all package files and add comprehensive changelog for v3.3.0 release featuring VS Code integration, shared config snippets, enhanced Codex wizard, and cross-platform improvements.
2025-09-22 22:50:07 +08:00
Jason
7e9930fe50 fix: stabilize provider action button width and improve visual consistency
- Set fixed width (76px) for enable/active buttons to prevent layout shift
- Hide play icon in active state to optimize space usage
- Add center alignment and nowrap to ensure consistent appearance
2025-09-22 22:16:47 +08:00
Jason
b17d915086 refactor: optimize React state updates and improve UI text clarity
- Use functional setState to ensure proper state updates in ProviderForm
- Improve Chinese UI text consistency in CodexConfigEditor:
  - Change "API 基础地址" to "API 请求地址" for clarity
  - Simplify "供应商官网" to "官网地址"
  - Update placeholder text for consistency
- Move requires_openai_auth to model_providers section in Codex config template
2025-09-22 16:25:58 +08:00
Jason
3e834e2c38 fix: correct HTML pattern attribute regex escape in Codex config wizard
Fixed validation pattern in provider name input field by removing incorrect double backslash escape (\S to \S) to properly validate non-whitespace input
2025-09-22 15:50:33 +08:00
Jason
cae625dab1 refactor: update VSCode apply button style to match Linear design theme
- Changed from solid emerald button to bordered style for better visual hierarchy
- Apply action: gray border, blue on hover (consistent with theme color)
- Remove action: gray border, red on hover (indicates destructive action)
- Better distinction between apply/remove states while maintaining Linear's minimalist aesthetic
2025-09-22 15:35:46 +08:00
Jason
122d7f1ad6 feat: add created_at timestamp field to Provider struct
- Add optional created_at field to track provider creation time
- Serialize field as camelCase (createdAt) for JSON compatibility
- Skip serialization when field is None to maintain backward compatibility
2025-09-22 10:46:18 +08:00
Jason
7eaf284400 feat: add dedicated API key URL support for third-party providers
- Add optional apiKeyUrl field to ProviderPreset interface for third-party providers
- Update ProviderForm to prioritize apiKeyUrl over websiteUrl for third-party category
- Make provider display name required in CodexConfigEditor with validation
- Configure PackyCode preset with affiliate API key URL

This allows third-party providers to have separate URLs for their service homepage
and API key acquisition, improving user experience when obtaining API keys.
2025-09-21 23:09:53 +08:00
TinsFox
86ef7afbdf fix: add @codemirror/lint (#45)
LGTM!
2025-09-21 20:54:58 +08:00
farion1231
615c431875 feat: enhance Codex provider configuration wizard with display name field
- Add separate display name field for provider (supports Chinese)
- Keep provider code field for internal identifier (English only)
- Add onNameChange callback to update provider name from wizard
- Improve code formatting consistency in ProviderForm
2025-09-21 20:37:01 +08:00
farion1231
d041ea7a56 refactor: improve form validation in CodexConfigEditor using HTML5 validation API
- Replace custom error state with native HTML5 form validation
- Add useRef hooks for input field validation management
- Add pattern attributes to enforce non-empty input validation
- Leverage browser's built-in validation UI for better UX
- Extract closeTemplateModal function for consistent modal closing
2025-09-21 20:04:50 +08:00
farion1231
c4c1747563 feat: add configuration wizard for custom Codex providers
- Add quick configuration wizard modal for custom providers
- Generate auth.json and config.toml from simple inputs (API key, base URL, model name)
- Extract generation logic into reusable functions (generateThirdPartyAuth, generateThirdPartyConfig)
- Pre-populate custom template when selecting custom option
- Add wizard button link in PresetSelector for custom mode
- Update PackyCode preset to use the new generation functions
2025-09-21 19:04:56 +08:00
farion1231
c284fe8348 fix: prevent text wrapping in VSCode apply button on Windows
Add whitespace-nowrap class to ensure button text stays on single line
across different font rendering systems
2025-09-21 10:50:08 +08:00
Jason Young
8f932b7358 Merge pull request #44 from farion1231/feature/wsl-support
feat: improve WSL config directory support
2025-09-21 10:24:49 +08:00
farion1231
d9e940e7a7 fix: update config directory placeholders for WSL environment
- Change placeholders from Windows-style WSL mount paths (/mnt/c/Users/...) to native WSL paths (/home/...)
- Better reflects the intended use case for WSL users configuring their native Linux home directories
2025-09-21 10:20:47 +08:00
farion1231
2147db6707 fix: update WSL config directory override description and fix formatting
- Clarify that WSL config directory is for WSL environments specifically
- Explain that vendor data stays consistent with main environment
- Add missing parentheses for proper conditional evaluation
- Remove trailing commas from GitHub release URLs
2025-09-21 10:16:20 +08:00
Jason
8c826b3073 fix(codex): improve config snippet handling with consistent trimming
- Trim config snippets during initialization from localStorage and defaults
- Trim snippets before comparison in toggle and change handlers
- Store trimmed values to localStorage for consistency
- Prevents configuration matching issues caused by accidental whitespace
2025-09-20 23:00:53 +08:00
Jason
54f1357bcc feat: add config directory override support for WSL
- Add persistent app settings with custom Claude Code and Codex config directories
- Add config directory override UI in settings modal with manual input, browse, and reset options
- Integrate tauri-plugin-dialog for native directory picker
- Support WSL and other special environments where config paths need manual specification

Changes:
- settings.rs: Implement settings load/save and directory override logic
- SettingsModal: Add config directory override UI components
- API: Add get_config_dir and pick_directory commands
2025-09-20 21:20:07 +08:00
Jason
b8d2daccde - fix(linux): disable modal backdrop blur on Linux (WebKitGTK/Wayland) to prevent freeze when opening Add Provider panel
- refactor(ui): add runtime platform detector and conditionally apply blur only on non-Linux platforms
- chore: verify typecheck and renderer build succeed; no functional regression expected
2025-09-19 16:17:14 +08:00
Jason
21205272a5 codex settings model update 2025-09-19 16:02:44 +08:00
Jason
ef067a6968 codex settings update 2025-09-19 16:00:31 +08:00
Jason Young
84204889f0 Merge pull request #37 from farion1231/feature/vscode-improvements
Feature/VS Code Integration Enhancements
2025-09-19 15:56:55 +08:00
Jason
31cdc2a5cf - feat(vscode-sync): restore auto-sync logic and enable by default
- refactor(settings): remove the VS Code auto-sync toggle from Settings UI
- feat(provider-list): enable auto-sync after "Apply to VS Code"; disable after "Remove"
- chore(prettier): run Prettier on changed files
- verify: typecheck and renderer build pass

- Files
  - added: src/hooks/useVSCodeAutoSync.ts
  - modified: src/App.tsx
  - modified: src/components/ProviderList.tsx
  - modified: src/components/SettingsModal.tsx

- Notes
  - Auto-sync now defaults to enabled for new users (stored in localStorage; existing saved state is respected).
  - No settings toggle is shown; manual Apply/Remove in the list still works as before.
2025-09-19 15:48:35 +08:00
Jason
7522ba3e03 feat: improve VS Code integration button and notifications
- Add fixed width and center alignment for VS Code button to ensure consistent width between 'Apply' and 'Remove' states
- Update notification messages to remind users to restart Codex plugin for changes to take effect
- Restore distinct color scheme: green for 'Apply to VS Code', gray for 'Remove from VS Code'
2025-09-19 15:10:11 +08:00
Jason
3ac3f122eb - refactor(utils): extract Codex base_url parsing into shared helpers
- refactor(ProviderList): use shared base_url helpers
- refactor(App): reuse shared base_url helpers for VS Code sync
- fix(auto-sync): global shared VS Code auto-apply state (localStorage + event broadcast)
- feat(tray): auto-apply to VS Code on Codex provider-switched when enabled
- behavior: manual Apply enables auto-sync; manual Remove disables; official providers clear managed keys only
- chore(typecheck): pass pnpm typecheck
2025-09-19 14:22:39 +08:00
Jason
67db492330 fix: prevent layout shift when switching providers
- Keep 'Current' badge always rendered with invisible class when not active
- Keep 'Apply to VS Code' button always rendered with invisible class when not active
- Use consistent border width (1px) for both selected and unselected cards
- Remove ring utility that was causing extra space outside elements

This ensures all provider cards maintain the same height regardless of selection state
2025-09-19 11:26:51 +08:00
Jason
358d6e001e refactor: extract VS Code sync logic to separate function
- Extract syncCodexToVSCode as a standalone function for better code organization
- Fix VS Code button state not updating after provider switch
- Add loadProviders() call after sync to trigger UI state refresh
- Improve error handling and variable naming
2025-09-19 11:06:26 +08:00
Jason
8a26cb51d8 fix: improve VS Code config sync reliability and TOML parsing
- Support both single and double quotes in TOML base_url parsing
- Fix regex capture group index for consistent parsing
- Improve "applied" status detection by comparing normalized URLs
- Add validation to prevent empty base_url writes for non-official providers
- Enhance error handling with user-friendly notifications
2025-09-19 09:41:08 +08:00
Jason
9f8c745f8c fix: hide 'Apply to VS Code' button for official Codex providers
Official Codex providers don't need VS Code integration since they use the default API endpoint
2025-09-19 09:19:53 +08:00
Jason
3a9a8036d2 - feat(codex): Add “Apply to VS Code/Remove from VS Code” button on current Codex provider card
- feat(tauri): Add commands to read/write VS Code settings.json with cross-variant detection (Code/Insiders/VSCodium/OSS)
- fix(vscode): Use top-level keys “chatgpt.apiBase” and “chatgpt.config.preferred_auth_method”
- fix(vscode): Handle empty settings.json (skip deletes, direct write) to avoid “Can not delete in empty document”
- fix(windows): Make atomic writes robust by removing target before rename
- ui(provider-list): Improve error surfacing when applying/removing
- chore(types): Extend window.api typings and tauri-api wrappers for VS Code commands
- deps: Add jsonc-parser
2025-09-19 08:30:29 +08:00
Jason
04e81ebbe3 refactor: simplify TOML common config handling by removing markers
- Remove COMMON_CONFIG_MARKER_START/END constants
- Simplify config snippet addition/removal logic
- Use natural append/replace approach instead of markers
- Fix unused variable warning
- Improve user experience with cleaner config output
2025-09-18 22:33:55 +08:00
Jason
c6e4f3599e Revert "feat: add VS Code ChatGPT plugin config sync functionality"
This reverts commit 9bf216b102.
2025-09-18 17:57:32 +08:00
Jason
60eb9ce2a4 Revert "refactor: improve UI layout for VS Code and common config options"
This reverts commit 2a9f093210.
2025-09-18 17:57:32 +08:00
Jason
50244f0055 Revert "fix: improve VS Code config toggle handling with async state management"
This reverts commit 32e66e054b.
2025-09-18 17:57:32 +08:00
Jason
eca14db58c Revert "fix: improve VS Code config synchronization and code formatting"
This reverts commit 463e430a3d.
2025-09-18 17:57:32 +08:00
Jason
463e430a3d fix: improve VS Code config synchronization and code formatting
- Add automatic VS Code config sync when base_url changes in TOML
- Improve error handling for VS Code configuration writes
- Enhance state management with ref tracking to prevent duplicate API calls
- Fix code formatting issues and improve readability across components
- Optimize common configuration handling for both Claude and Codex providers
2025-09-18 15:25:10 +08:00
Jason
32e66e054b fix: improve VS Code config toggle handling with async state management
- Add loading state to prevent race conditions during config operations
- Convert effect-based approach to direct async handler for better control
- Support both writing and removing VS Code config through toggle
- Disable checkbox during async operations to prevent multiple requests
2025-09-18 14:46:23 +08:00
Jason
2a9f093210 refactor: improve UI layout for VS Code and common config options
- Implement horizontal two-column layout for better space utilization
- Convert VS Code config from button to checkbox pattern for consistency
- Add automatic VS Code settings write on checkbox toggle
- Fix layout stability issues with fixed height containers
- Remove unnecessary wrapper functions for cleaner code
- Adjust spacing and alignment for more compact design
2025-09-18 12:07:36 +08:00
Jason
9bf216b102 feat: add VS Code ChatGPT plugin config sync functionality 2025-09-18 10:58:03 +08:00
Jason
b69d7f7979 feat: add TOML validation for Codex config and improve code formatting
- Add real-time TOML syntax validation for Codex config field
- Validate config TOML when saving provider settings
- Format code to improve readability with proper error handling blocks
- Reorganize imports for better consistency

This ensures Codex config is valid TOML before saving, preventing runtime errors.
2025-09-18 09:33:58 +08:00
Jason
efff780eea fix: improve JSON validation with unified validation function
- Extract common validateJsonConfig function for reuse
- Apply unified validation to both main config and common config snippets
- Add real-time JSON validation to JsonEditor component using CodeMirror linter
- Simplify error handling without over-engineering error position extraction
2025-09-18 08:35:09 +08:00
Jason
19dcc84c83 refactor: extract error message handling to utils module
- Move extractErrorMessage function from App.tsx to utils/errorUtils.ts
- Improve code organization and reusability
- Enhance error notification with dynamic message extraction and timeout
2025-09-17 23:47:17 +08:00
Jason
4e9e63f524 fix: prevent automatic quote correction in TOML input fields
- Remove unnecessary normalizeSmartQuotes function and its usage
- Add input attributes to prevent automatic text correction (inputMode, data-gramm, etc.)
- Delete unused textNormalization.ts utility file
- Keep user's original input without any quote transformation
2025-09-17 22:39:45 +08:00
Jason
1d1440f52f Revert "feat: add common config snippet management system"
This reverts commit 36b78d1b4b.
2025-09-17 16:14:43 +08:00
Jason
36b78d1b4b feat: add common config snippet management system
- Add settings module for managing common configuration snippets
- Implement UI for creating, editing, and deleting snippets
- Add tauri-plugin-fs for file operations
- Replace co-authored setting with flexible snippet system
- Enable users to define custom config snippets for frequently used settings
2025-09-17 12:25:05 +08:00
Jason
2b59a5d51b feat: add common config support for Codex with TOML format
- Added separate common config state and storage for Codex
- Implemented TOML-based common config merging with markers
- Created UI components for Codex common config editor
- Added toggle and edit functionality similar to Claude config
- Store Codex common config in localStorage separately
- Support appending/removing common TOML snippets to config.toml
2025-09-17 10:44:30 +08:00
Jason
15c12c8e65 fix: resolve checkbox sync issue when editing common config snippet
The checkbox state was becoming out of sync when users edited the common config
snippet. Added a ref flag to track when updates are coming from common config
changes to prevent the handleChange function from incorrectly resetting the
checkbox state during these updates.
2025-09-17 10:36:28 +08:00
Jason
3256b2f842 feat(ui): unify modal window styles for common config editor
- Unified close button with X icon (size 18) matching main modal style
- Added Save icon to save button with consistent blue theme styling
- Aligned window structure with main modal (padding, borders, backdrop)
- Added ESC key support to close modal
- Enabled click-outside-to-close functionality
- Standardized text sizes (xl for title, sm for buttons and errors)
- Consistent hover effects and transitions across all buttons
- Matched footer background color with other modals (gray-100/gray-800)
2025-09-17 09:47:44 +08:00
Jason
7374b934c7 refactor: replace co-authored setting with flexible common config snippet feature
- Replace single "disable co-authored" checkbox with universal "common config snippet" functionality
- Add localStorage persistence for common config snippets
- Implement deep merge/remove operations for complex JSON structures
- Add modal editor for managing common config snippets
- Optimize performance with custom deepClone function instead of JSON.parse/stringify
- Fix deep remove logic to only delete matching values
- Improve error handling and validation for JSON snippets
2025-09-16 22:59:00 +08:00
Jason
d9d7c5c342 feat(ui): add Claude icon hover effect
Add orange color transition when hovering over Claude button icon for better visual feedback
2025-09-16 20:16:55 +08:00
Jason
f4f7e10953 fix(ui): prevent update button jitter when checking for updates
- Add min-width to update button to maintain consistent width across states
- Ensure all button states have consistent border styling with transparent borders
2025-09-16 16:23:47 +08:00
Jason
6ad7e04a95 feat(ui): add click-outside to close functionality for settings modal
- Restructure modal overlay to separate backdrop and content layers
- Add onMouseDown handler to close modal when clicking outside
- Improve backdrop styling with blur effect
2025-09-16 15:50:16 +08:00
Jason
7122e10646 feat(macos): handle dock icon click to restore window
Add macOS-specific handling for app.run() to catch Reopen events when
the dock icon is clicked, automatically unminimizing and showing the
main window with focus.
2025-09-16 10:42:59 +08:00
Jason Young
bb685be43d add star history 2025-09-15 21:49:32 +08:00
Jason
c5b3b4027f feat(ui): convert title to GitHub link
- Make "CC Switch" title clickable link to GitHub repository
- Remove separate GitHub icon for cleaner design
- Add hover effects for better user interaction
- Update repository URL to farion1231/cc-switch
2025-09-15 10:24:41 +08:00
Jason
daba6b094b fix(ui): refactor layout with fixed header and prevent layout shift
- Convert layout to flexbox with fixed header and scrollable content area
- Use overflow-y-scroll to always show scrollbar track
- Prevents content width changes when switching between apps
- Ensures consistent 12px spacing between header and content
2025-09-14 23:22:57 +08:00
Jason
711ad843ce feat(tray): hide window on close and refine tray UX; simplify icon handling
- Intercept CloseRequested to hide instead of exit (keep tray resident)
- Add 'Open Main' menu item; left-click shows menu; reduce tray click logs
- Use default app icon for tray for now; remove runtime PNG decoding
- Drop unused image dependency and tray resources entry
- Prepare path for future macOS template icon under icons/tray/macos
2025-09-14 21:55:41 +08:00
Jason
189a70280f feat(tray): hide window on close and refine tray UX
- Intercept CloseRequested to prevent close and hide the window so the process and tray remain running
- Add 'Open Main Window' item at the top of the tray menu and keep left-click to only show the menu (no auto window focus)
- Downgrade tray left-click log from info to debug to reduce console noise in dev
- Keep native behavior: the tray menu closes after selection; removed any transient title/tooltip feedback attempts

Files:
- src-tauri/src/lib.rs
2025-09-14 16:07:51 +08:00
55 changed files with 6861 additions and 1215 deletions

View File

@@ -226,6 +226,12 @@ jobs:
$portableDir = 'release-assets/CC-Switch-Portable'
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath $portableDir
$portableIniPath = Join-Path $portableDir 'portable.ini'
$portableContent = @(
'# CC Switch portable build marker',
'portable=true'
)
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
Remove-Item -Recurse -Force $portableDir
Write-Host 'Windows portable zip created'

2
.gitignore vendored
View File

@@ -9,4 +9,4 @@ release/
.npmrc
CLAUDE.md
AGENTS.md
docs/
/.claude

1
.node-version Normal file
View File

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

View File

@@ -5,6 +5,44 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.4.0] - 2025-10-01
### ✨ Features
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
- Support portable mode launches and enforce a single running instance to avoid conflicts
### 🔧 Improvements
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
### 🐛 Fixes
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
- Fix layout shifts while switching app types with Claude plugin sync enabled
- Align Enable/In Use button states to avoid visual jank across app views
## [3.3.0] - 2025-09-22
### ✨ Features
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants *(Removed in 3.4.x)*
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently *(Removed in 3.4.x)*
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
### 🔧 Improvements
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
- Add a `created_at` timestamp to provider records for future sorting and analytics
### 🐛 Fixes
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
- Bundle `@codemirror/lint` to reinstate live linting in config editors
## [3.2.0] - 2025-09-13
### ✨ New Features

View File

@@ -1,28 +1,30 @@
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.4.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> v3.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)
> 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.2.0
## 功能特性v3.4.0
- **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
- **系统托盘(菜单栏)快速切换**按应用分组Claude / Codex勾选态展示当前供应商
- **内置更新器**:集成 Tauri Updater支持检测/下载/安装与一键重启
- **单一事实源SSOT**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
- **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
- **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
- **深色模式优化**Tailwind v4 适配与选择器修正
- **丰富预设与自定义**Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
- **国际化与语言切换**:内置 i18next默认显示中文可在设置中快速切换到英文界面文文案自动实时刷新。
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
- **VS Code Codex 设置停用**:由于新版 Codex 插件无需修改 `settings.json`,应用不再写入 VS Code 设置,避免潜在冲突。
- **供应商预设扩展**:新增 DeepSeek--V3.2-Exp、Qwen3-Max、GLM-4.6 等最新模型。
- **系统托盘与窗口行为**窗口关闭可最小化到托盘macOS 支持托盘模式下隐藏/显示 Dock托盘切换时同步 Claude/Codex/插件状态。
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
- **UI 与安装体验优化**设置面板改为可滚动布局并加入保存图标按钮宽度与状态一致性加强Windows MSI 安装默认写入 per-user LocalAppData 并改进组件跟踪Windows 便携版现在指向最新 release 页面,不再自动更为为安装版。
## 界面预览
@@ -54,7 +56,7 @@
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
从 [Releases](../../releases) 页面下载最新版本的 `.deb`或者 `AppImage`安装包
## 使用说明
@@ -70,7 +72,7 @@
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Codex 说明(v3.2.0 SSOT
### Codex 说明SSOT
- 配置目录:`~/.codex/`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
@@ -82,7 +84,7 @@
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Claude Code 说明(v3.2.0 SSOT
### Claude Code 说明SSOT
- 配置目录:`~/.claude/`
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
@@ -94,9 +96,9 @@
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 迁移与归档v3.2.0
### 迁移与归档(v3.2.0
- 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重若当前为空将 live 合并项设为当前
@@ -196,6 +198,10 @@ cargo test
欢迎提交 Issue 和 Pull Request
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)
## License
MIT © Jason Young

76
README_i18n.md Normal file
View File

@@ -0,0 +1,76 @@
# CC Switch 国际化功能说明
## 已完成的工作
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文和中文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
## 文件结构
```
src/
├── i18n/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
└── main.tsx # 导入国际化配置
```
## 默认语言设置
- **默认语言**:英文 (en)
- **回退语言**:英文 (en)
## 使用方式
1. 在组件中导入 `useTranslation`
```tsx
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return <div>{t('common.save')}</div>;
}
```
2. 切换语言:
```tsx
const { i18n } = useTranslation();
i18n.changeLanguage('zh'); // 切换到中文
```
## 翻译键结构
- `common.*` - 通用文案(保存、取消、设置等)
- `header.*` - 头部相关文案
- `provider.*` - 供应商相关文案
- `notifications.*` - 通知消息
- `settings.*` - 设置页面文案
- `apps.*` - 应用名称
- `console.*` - 控制台日志信息
## 测试功能
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
## 已更新的组件
- ✅ App.tsx - 主应用组件
- ✅ ConfirmDialog.tsx - 确认对话框
- ✅ AddProviderModal.tsx - 添加供应商弹窗
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
- ✅ ProviderList.tsx - 供应商列表
- ✅ LanguageSwitcher.tsx - 语言切换器
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
## 注意事项
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
2. 翻译键名应该有意义且结构化
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言

View File

@@ -1,193 +0,0 @@
# CC Switch 加密配置与切换重构方案V1
## 1. 目标与范围
- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源SSOT改为“加密落盘”切换时从解密后的内存配置写入目标应用主配置Claude/Codex
- 范围:
- 后端Rust/Tauri新增加密模块与读写改造。
- 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。
- 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。
- 兼容旧明文配置v1/v2首次保存迁移为加密文件。
## 2. 背景现状(简述)
- 当前:
- 全局配置:`~/.cc-switch/config.json`v2`MultiAppConfig`,含多个 `ProviderManager`)。
- 切换依赖“供应商副本文件”Claude`~/.claude/settings-<name>.json`Codex`~/.codex/auth-<name>.json``config-<name>.toml`)→ 恢复到主配置。
- 启动:若对应 App 的供应商列表为空,可从现有主配置自动创建一条“默认项”并设为当前。
- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。
## 3. 总体方案
- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。
- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到当前选中供应商(由 `manager.current` 指向),保留外部修改。
- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。
- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`v1/v2与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive/<ts>/`,生成 `manifest.json` 以便恢复;默认不做静默清理。
## 4. 密钥管理
- 存储系统级凭据管家keyring crate
- Service`cc-switch`Account`config-key-v1`内容Base64 编码的 32 字节随机密钥AES-256
- 首次运行:生成随机密钥,写入 Keychain。
- 进程内缓存:启动加载后缓存密钥,避免重复 IO。
- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。
- 回退策略Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。
## 5. 加密封装格式
- 文件:`~/.cc-switch/config.enc.json`
- 结构JSON 封装,便于演进):
```json
{
"v": 1,
"alg": "AES-256-GCM",
"nonce": "<base64-nonce>",
"ct": "<base64-ciphertext>"
}
```
- 明文:`serde_json::to_vec(MultiAppConfig)`加密AES-GCM12 字节随机 nonce每次保存生成新 nonce。
## 6. 模块与改造点
- 新增 `src-tauri/src/secure_store.rs`
- `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。
- `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)``decrypt_bytes(key, nonce, ciphertext)`。
- `read_encrypted_config() -> Result<MultiAppConfig, String>`:读取 `config.enc.json`、解析封装、解密、反序列化。
- `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。
- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档):
- `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`v1/v2与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`返回分组清单、大小、mtime永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。
- `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive/<ts>/` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`记录原路径、归档路径、大小、mtime、sha256、类别同分区 `rename`跨分区“copy + fsync + remove”。
- `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。
- 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。
- 安全护栏:操作前后做 mtime/hash 复核CAS发生变化中止并提示“外部已修改”。
- 调整 `src-tauri/src/app_config.rs`
- `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文:
- 若检测到 v1`ProviderManager`)→ 迁移到 v2原有逻辑保留
- `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup.<ts>.json`(或保留为只读,视实现选择)。
- 调整 `src-tauri/src/commands.rs::switch_provider`
- Claude
1. 回填:若 `~/.claude/settings.json` 存在且存在当前指针 → 读取 JSON写回 `manager.providers[manager.current].settings_config`。
2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。
- Codex
1. 回填:读取 `~/.codex/auth.json`JSON与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[manager.current].settings_config`。
2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML
- 更新 `manager.current = id``state.save()` → 触发加密保存。
- 保留/清理:
- 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。
- 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。
## 7. 数据流与时序
- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。
- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。
- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新当前指针(`manager.current`)→ `state.save()`。
- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。
## 8. 迁移策略
- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。
- 旧版支持:
- v1 明文(单 `ProviderManager`)→ 自动迁移为 v2已有逻辑
- v2 明文 → 直接加载。
- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup.<ts>.json`(或保留为只读)。
- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。
- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive/<ts>/`,保留 `manifest.json` 以支持恢复。
## 9. 回滚策略
- 加密回滚:保留 `config.v1.backup.<ts>.json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。
- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。
## 10. 安全与性能
- 算法AES-256-GCMAEAD随机 12 字节 nonce每次保存新 nonce。
- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 120ms可缓存。
- 可靠性:原子写入(临时文件 + rename写入失败不破坏现有文件。
- 可选增强:`zeroize` 清理密钥与明文Claude 配置 JSON Schema 校验。
- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。
## 11. API 与 UX 影响
- 前端 API现有行为不变新增清理相关命令Tauri供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive``purge_archived` 可选)。
- UI 提示:在“配置文件位置”旁提示“已加密存储”。
- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。
- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。
## 12. 开发任务拆解(阶段一为本次交付)
- 阶段一(核心改造 + 清理能力最小闭环)
- 新增模块 `secure_store.rs`Keychain 与加解密工具函数。
- 改造 `app_config.rs``load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。
- 改造 `commands.rs::switch_provider`
- 回填 live 配置 → 写入目标主配置Claude/Codex
- 去除对“副本恢复”的依赖(保留函数以便回退)。
- 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。
- 保持 `import_default_config`、`get_config_status` 行为不变。
- 阶段二(清理与增强)
- 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。
- Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。
- 阶段三(安全升级)
- 密钥轮换;可选 passphraseKDF: Argon2id + salt
## 14. 验收标准
- 功能:
- 无加密明文文件也能启动并正确读写;
- 切换成功将内存配置写入主配置;
- 外部手改在下一次切换前被回填保存;
- 旧配置自动迁移并生成加密文件;
- Keychain/解密异常时不损坏已有文件,给出可理解错误。
- 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。
- 质量:
- 关键路径加错误处理与日志;
- 写入采用原子替换;
- 代码变更集中、最小侵入,与现有风格一致。
- 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。
## 15. 风险与对策
- Keychain 不可用或权限受限:
- 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。
- 加密文件损坏:
- 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。
- 与“副本文件”并存导致混淆:
- 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。
- 清理误删或不可逆:
- 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。
## 16. 发布与回退
- 发布:随 Tauri 应用正常发布,无需前端变更。
- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。
## 17. 旧文件清理与归档(新增)
- 归档对象:
- `~/.cc-switch/config.json`v1/v2迁移成功后
- `~/.claude/settings-*.json`(保留 `settings.json`
- `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`
- 归档位置与结构:`~/.cc-switch/archive/<timestamp>/{cc-switch,claude,codex}/...`
- `manifest.json`记录原路径、归档路径、大小、mtime、sha256、类别v1/v2/claude/codex用于恢复与可视化。
- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。
- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验跨分区采用“copy+fsync+remove”失败即时回滚并提示。
## 18. 变更点清单(代码)
- 新增:`src-tauri/src/secure_store.rs`
- 修改:
- `src-tauri/src/app_config.rs`load/save 加密化、迁移与原子写入)
- `src-tauri/src/commands.rs`switch_provider 改为内存 → 主配置,并回填 live
- `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件)
- 保持:
- `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动)
- 前端 `src/lib/tauri-api.ts` 与 UI 逻辑
## 19. 开放问题(待确认)
- Keychain 失败时是否提供“本地明文密钥文件600 权限)”的应急模式(当前建议:不提供,保持只读)。
- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。
- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30
---
以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需我可以继续补充任务看板Issue 列表)与实施顺序的 PR 规划。

7
docs/roadmap.md Normal file
View File

@@ -0,0 +1,7 @@
- 自动升级自定义路径 ✅
- win 绿色版报毒问题 ✅
- codex 更多预设供应商
- mcp 管理器
- i18n
- gemini cli
- homebrew 支持

View File

@@ -1,91 +0,0 @@
# 更新功能开发计划Tauri v2 Updater
> 目标:基于 Tauri v2 官方 Updater完成“检查更新 → 下载 → 安装 → 重启”的完整闭环;提供清晰的前后端接口、配置与测试/发布流程。
## 范围与目标
- 能力:静态 JSON 与动态接口两种更新源;可选稳定/测试通道;进度反馈与错误处理。
- 平台macOS `.app` 优先Windows 使用安装器NSIS/MSI
- 安全:启用 Ed25519 更新签名校验;上线前建议平台代码签名与公证。
## 架构与依赖
- 插件:`tauri-plugin-updater`(更新)、`@tauri-apps/plugin-updater`JS`tauri-plugin-process``@tauri-apps/plugin-process`(重启)。
- 签名与构建:`tauri signer generate` 生成密钥CI/本机注入 `TAURI_SIGNING_PRIVATE_KEY``bundle.createUpdaterArtifacts: true` 生成签名制品。
- 权限:在 `src-tauri/capabilities/default.json` 启用 `updater:default``process:allow-restart`
- 配置(`src-tauri/tauri.conf.json`
- `plugins.updater.pubkey: "<PUBLICKEY.PEM>"`
- `plugins.updater.endpoints: ["<更新源 URL 列表>"]`
- Windows可选`plugins.updater.windows.installMode: "passive|basicUi|quiet"`
## 前端接口设计TypeScript
- 类型
- `type UpdateChannel = 'stable' | 'beta'`
- `type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'restarting' | 'upToDate' | 'error'`
- `type UpdateInfo = { currentVersion: string; availableVersion: string; notes?: string; pubDate?: string }`
- `type UpdateProgressEvent = { event: 'Started' | 'Progress' | 'Finished'; total?: number; downloaded?: number }`
- `type UpdateError = { code: string; message: string; cause?: unknown }`
- `type CheckOptions = { timeout?: number; channel?: UpdateChannel }`
- API`src/lib/updater.ts`
- `getCurrentVersion(): Promise<string>` 读取当前版本。
- `checkForUpdate(opts?: CheckOptions)``up-to-date``{ status: 'available', info, update }`
- `downloadAndInstall(update, onProgress?)` 下载并安装,进度回调映射 Started/Progress/Finished。
- `relaunchApp()` 调用 `@tauri-apps/plugin-process.relaunch()`
- `runUpdateFlow(opts?)` 编排:检查 → 下载安装 → 重启;错误统一抛出 `UpdateError`
- `setUpdateChannel(channel)` 前端记录偏好;实际端点切换见“端点动态化”。
- Hook可选 `useUpdater()`
- 返回 `{ phase, info?, progress?, error?, actions: { check, startUpdate, relaunch } }`
- UI组件建议
- `UpdateBanner`:发现新版本时展示;`UpdaterDialog`:显示说明、进度与错误/重试。
## Rust 集成与权限
- 插件注册(`src-tauri/src/main.rs`
- `app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;`
- `.plugin(tauri_plugin_process::init())` 用于重启。
- Windows 清理钩子(可选):`UpdaterExt::on_before_exit(app.cleanup_before_exit)`,避免安装器启动前文件占用。
- 端点动态化(可选):在 `setup` 根据配置/环境切换 `endpoints`、超时、代理或 headers。
## 更新源与格式
- 静态 JSONlatest.json字段 `version``platforms[target].url``platforms[target].signature``.sig` 内容);可选 `notes``pub_date`
- 动态接口:
- 无更新HTTP 204
- 有更新HTTP 200 → `{ version, url, signature, notes?, pub_date? }`
- 通道组织:`/stable/latest.json``/beta/latest.json`CDN 缓存需可控,回滚可强制刷新。
## 用户流程与 UX
- 流程:检查 → 展示版本/日志 → 下载进度(累计/百分比)→ 安装 → 提示并重启。
- 错误:网络异常(超时/断网/证书)、签名不匹配、权限/文件占用Win。提供“重试/稍后更新”。
- 平台提示:
- macOS建议安装在 `~/Applications`,避免 `/Applications` 提权导致失败。
- Windows优先安装器分发并选择合适 `installMode`
## 测试计划
- 功能:有更新/无更新204/下载中断/重试/安装后重启成功与版本号提升。
- 安全:签名不匹配必须拒绝更新;端点不可用/被劫持有清晰提示。
- 网络:超时/断网/代理场景提示与恢复。
- 平台:
- macOS`/Applications``~/Applications` 的权限差异。
- Windows`passive|basicUi|quiet` 行为差异与成功率。
- 本地自测:以 v1.0.0 运行,构建 v1.0.1 制品+`.sig`,本地 HTTP 托管 `latest.json`,验证全链路。
## 发布与回滚
- 发布CI 推荐):注入 `TAURI_SIGNING_PRIVATE_KEY` → 构建生成各平台制品+签名 → 上传产物与 `latest.json` 至 Releases/CDN。
- 回滚:撤下问题版本或将 `latest.json` 指回上一个稳定版本如需降级Rust 侧可定制版本比较策略(可选)。
## 里程碑与验收
- D1密钥与基础集成插件/配置/权限)。
- D2前端入口与进度 UI静态 JSON 自测通过。
- D3Releases/CDN 端到端验证,平台专项测试。
- D4文档完善、回滚与异常流程演练。
- 验收:两平台完成“发现→下载→安装→重启→版本提升”;签名校验生效;异常有明确提示与可行恢复。
## 待确认
- 更新源托管GitHub Releases 还是自有 CDN
- 是否需要 beta 通道与运行时切换。
- Windows 是否仅支持安装器分发;便携版兼容策略是否需要明确说明。
- UI 文案与样式偏好。
## 落地步骤(实施顺序)
1) 生成 Ed25519 密钥,将公钥写入 `plugins.updater.pubkey`,在构建环境配置 `TAURI_SIGNING_PRIVATE_KEY`
2) `src-tauri` 注册 `tauri-plugin-updater``tauri-plugin-process`,补齐 `capabilities/default.json``tauri.conf.json`
3) 前端新增 `src/lib/updater.ts` 封装与 `UpdateBanner`/`UpdaterDialog` 组件,接入入口按钮。
4) 本地静态 `latest.json` 自测全链路;完善错误与进度提示。
5) 配置 CI 发布产物与 `latest.json`;编写发布/回滚操作手册。

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.2.0",
"version": "3.4.0",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
@@ -27,17 +27,22 @@
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2",
"@tailwindcss/vite": "^4.1.13",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"codemirror": "^6.0.2",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^16.0.0",
"tailwindcss": "^4.1.13"
}
}

86
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lint':
specifier: ^6.8.5
version: 6.8.5
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.2
@@ -26,6 +29,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.8.0
'@tauri-apps/plugin-dialog':
specifier: ^2.4.0
version: 2.4.0
'@tauri-apps/plugin-process':
specifier: ^2.0.0
version: 2.3.0
@@ -35,6 +41,12 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
i18next:
specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2)
jsonc-parser:
specifier: ^3.2.1
version: 3.3.1
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@18.3.1)
@@ -44,6 +56,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
react-i18next:
specifier: ^16.0.0
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
tailwindcss:
specifier: ^4.1.13
version: 4.1.13
@@ -150,6 +165,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -632,6 +651,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.4.0':
resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==}
'@tauri-apps/plugin-process@2.3.0':
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
@@ -738,6 +760,17 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
i18next@25.5.2:
resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
jiti@2.5.1:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
@@ -755,6 +788,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -875,6 +911,22 @@ packages:
peerDependencies:
react: ^18.3.1
react-i18next@16.0.0:
resolution: {integrity: sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==}
peerDependencies:
i18next: '>= 25.5.2'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -958,6 +1010,10 @@ packages:
terser:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -1064,6 +1120,8 @@ snapshots:
'@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
'@babel/runtime@7.28.4': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -1439,6 +1497,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.1
'@tauri-apps/cli-win32-x64-msvc': 2.8.1
'@tauri-apps/plugin-dialog@2.4.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-process@2.3.0':
dependencies:
'@tauri-apps/api': 2.8.0
@@ -1572,6 +1634,16 @@ snapshots:
graceful-fs@4.2.11: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
i18next@25.5.2(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.9.2
jiti@2.5.1: {}
js-tokens@4.0.0: {}
@@ -1580,6 +1652,8 @@ snapshots:
json5@2.2.3: {}
jsonc-parser@3.3.1: {}
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -1671,6 +1745,16 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-i18next@16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 25.5.2(typescript@5.9.2)
react: 18.3.1
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
typescript: 5.9.2
react-refresh@0.17.0: {}
react@18.3.1:
@@ -1746,6 +1830,8 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.30.1
void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
yallist@3.1.1: {}

1172
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.2.0"
version = "3.4.0"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
@@ -21,13 +21,21 @@ 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"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
dirs = "5.0"
toml = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
futures = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"

View File

@@ -9,6 +9,8 @@
"core:default",
"opener:default",
"updater:default",
"process:allow-restart"
"core:window:allow-set-skip-taskbar",
"process:allow-restart",
"dialog:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

View File

@@ -0,0 +1,103 @@
use std::fs;
use std::path::PathBuf;
const CLAUDE_DIR: &str = ".claude";
const CLAUDE_CONFIG_FILE: &str = "config.json";
const CLAUDE_CONFIG_PAYLOAD: &str = "{\n \"primaryApiKey\": \"any\"\n}\n";
fn claude_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
Ok(home.join(CLAUDE_DIR))
}
pub fn claude_config_path() -> Result<PathBuf, String> {
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
}
pub fn ensure_claude_dir_exists() -> Result<PathBuf, String> {
let dir = claude_dir()?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
}
Ok(dir)
}
pub fn read_claude_config() -> Result<Option<String>, String> {
let path = claude_config_path()?;
if path.exists() {
let content =
fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?;
Ok(Some(content))
} else {
Ok(None)
}
}
fn is_managed_config(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(value) => value
.get("primaryApiKey")
.and_then(|v| v.as_str())
.map(|val| val == "any")
.unwrap_or(false),
Err(_) => false,
}
}
pub fn write_claude_config() -> Result<bool, String> {
let path = claude_config_path()?;
ensure_claude_dir_exists()?;
let need_write = match read_claude_config()? {
Some(existing) => existing != CLAUDE_CONFIG_PAYLOAD,
None => true,
};
if need_write {
fs::write(&path, CLAUDE_CONFIG_PAYLOAD)
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
}
Ok(need_write)
}
pub fn clear_claude_config() -> Result<bool, String> {
let path = claude_config_path()?;
if !path.exists() {
return Ok(false);
}
let content = match read_claude_config()? {
Some(content) => content,
None => return Ok(false),
};
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(_) => return Ok(false),
};
let obj = match value.as_object_mut() {
Some(obj) => obj,
None => return Ok(false),
};
if obj.remove("primaryApiKey").is_none() {
return Ok(false);
}
let serialized = serde_json::to_string_pretty(&value)
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
fs::write(&path, format!("{}\n", serialized))
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
Ok(true)
}
pub fn claude_config_status() -> Result<(bool, PathBuf), String> {
let path = claude_config_path()?;
Ok((path.exists(), path))
}
pub fn is_claude_config_applied() -> Result<bool, String> {
match read_claude_config()? {
Some(content) => Ok(is_managed_config(&content)),
None => Ok(false),
}
}

View File

@@ -10,6 +10,10 @@ use std::path::Path;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
}

View File

@@ -2,14 +2,48 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::claude_plugin;
use crate::codex_config;
use crate::config::{get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::{Provider, ProviderMeta};
use crate::speedtest;
use crate::store::AppState;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
AppType::Claude => {
if !provider.settings_config.is_object() {
return Err("Claude 配置必须是 JSON 对象".to_string());
}
}
AppType::Codex => {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
let auth = settings
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
if !auth.is_object() {
return Err("Codex auth 配置必须是 JSON 对象".to_string());
}
if let Some(config_value) = settings.get("config") {
if !(config_value.is_string() || config_value.is_null()) {
return Err("Codex config 字段必须是字符串".to_string());
}
if let Some(cfg_text) = config_value.as_str() {
codex_config::validate_config_toml(cfg_text)?;
}
}
}
}
Ok(())
}
/// 获取所有供应商
#[tauri::command]
pub async fn get_providers(
@@ -74,6 +108,8 @@ pub async fn add_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
validate_provider_settings(&app_type, &provider)?;
// 读取当前是否是激活供应商(短锁)
let is_current = {
let config = state
@@ -139,6 +175,8 @@ pub async fn update_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
validate_provider_settings(&app_type, &provider)?;
// 读取校验 & 是否当前(短锁)
let (exists, is_current) = {
let config = state
@@ -483,6 +521,26 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<String, String> {
let app = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let dir = match app {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
};
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command]
@@ -516,6 +574,38 @@ pub async fn open_config_folder(
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: tauri::AppHandle,
default_path: Option<String>,
) -> Result<Option<String>, String> {
let initial = default_path
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let result = tauri::async_runtime::spawn_blocking(move || {
let mut builder = app.dialog().file();
if let Some(path) = initial {
builder = builder.set_directory(path);
}
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
}
}
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
@@ -566,21 +656,14 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
/// 获取设置
#[tauri::command]
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// 暂时返回默认设置:系统托盘(菜单栏)显示开关
Ok(serde_json::json!({
"showInTray": true
}))
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(
_state: State<'_, AppState>,
settings: serde_json::Value,
) -> Result<bool, String> {
// TODO: 实现系统托盘显示开关的保存与应用(显示/隐藏菜单栏托盘图标)
log::info!("保存设置: {:?}", settings);
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)?;
Ok(true)
}
@@ -591,10 +674,243 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases",
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// Claude 插件:获取 ~/.claude/config.json 状态
#[tauri::command]
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
match claude_plugin::claude_config_status() {
Ok((exists, path)) => Ok(ConfigStatus {
exists,
path: path.to_string_lossy().to_string(),
}),
Err(err) => Err(err),
}
}
/// Claude 插件:读取配置内容(若不存在返回 Ok(None)
#[tauri::command]
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
claude_plugin::read_claude_config()
}
/// Claude 插件:写入/清除固定配置
#[tauri::command]
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
if official {
claude_plugin::clear_claude_config()
} else {
claude_plugin::write_claude_config()
}
}
/// Claude 插件:检测是否已写入目标配置
#[tauri::command]
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
claude_plugin::is_claude_config_applied()
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<speedtest::EndpointLatency>, String> {
let filtered: Vec<String> = urls
.into_iter()
.filter(|url| !url.trim().is_empty())
.collect();
speedtest::test_endpoints(filtered, timeout_secs).await
}
/// 获取自定义端点列表
#[tauri::command]
pub async fn get_custom_endpoints(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Ok(vec![]);
};
// 首选从 provider.meta 读取
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
if !meta.custom_endpoints.is_empty() {
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
return Ok(result);
}
Ok(vec![])
}
/// 添加自定义端点
#[tauri::command]
pub async fn add_custom_endpoint(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
if normalized.is_empty() {
return Err("URL 不能为空".to_string());
}
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Err("供应商不存在或未选择".to_string());
};
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let endpoint = crate::settings::CustomEndpoint {
url: normalized.clone(),
added_at: timestamp,
last_used: None,
};
meta.custom_endpoints.insert(normalized, endpoint);
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 删除自定义端点
#[tauri::command]
pub async fn remove_custom_endpoint(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.remove(&normalized);
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 更新端点最后使用时间
#[tauri::command]
pub async fn update_endpoint_last_used(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
endpoint.last_used = Some(timestamp);
}
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}

View File

@@ -6,6 +6,10 @@ use std::path::{Path, PathBuf};
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")
@@ -175,7 +179,19 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
}
}
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
#[cfg(windows)]
{
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
if path.exists() {
let _ = fs::remove_file(path);
}
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
Ok(())
}

View File

@@ -0,0 +1,170 @@
use chrono::Utc;
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
// 默认仅保留最近 10 份备份,避免目录无限膨胀
const MAX_BACKUPS: usize = 10;
/// 创建配置文件备份
pub fn create_backup(config_path: &PathBuf) -> Result<String, String> {
if !config_path.exists() {
return Ok(String::new());
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_id = format!("backup_{}", timestamp);
let backup_dir = config_path
.parent()
.ok_or("Invalid config path")?
.join("backups");
// 创建备份目录
fs::create_dir_all(&backup_dir)
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
let backup_path = backup_dir.join(format!("{}.json", backup_id));
// 复制配置文件到备份
fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
// 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份)
cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
Ok(backup_id)
}
fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> {
if retain == 0 {
return Ok(());
}
let mut entries: Vec<_> = match fs::read_dir(backup_dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect(),
Err(_) => return Ok(()),
};
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(retain);
entries.sort_by(|a, b| {
let a_time = a.metadata().and_then(|m| m.modified()).ok();
let b_time = b.metadata().and_then(|m| m.modified()).ok();
a_time.cmp(&b_time)
});
for entry in entries.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!(
"Failed to remove old backup {}: {}",
entry.path().display(),
err
);
}
}
Ok(())
}
/// 导出配置文件
#[tauri::command]
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
// 读取当前配置文件
let config_path = crate::config::get_app_config_path();
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read configuration: {}", e))?;
// 写入到指定文件
fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(json!({
"success": true,
"message": "Configuration exported successfully",
"filePath": file_path
}))
}
/// 从文件导入配置
#[tauri::command]
pub async fn import_config_from_file(
file_path: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Value, String> {
// 读取导入的文件
let import_content =
fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?;
// 验证并解析为配置对象
let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content)
.map_err(|e| format!("Invalid configuration file: {}", e))?;
// 备份当前配置
let config_path = crate::config::get_app_config_path();
let backup_id = create_backup(&config_path)?;
// 写入新配置到磁盘
fs::write(&config_path, &import_content)
.map_err(|e| format!("Failed to write configuration: {}", e))?;
// 更新内存中的状态
{
let mut config_state = state
.config
.lock()
.map_err(|e| format!("Failed to lock config: {}", e))?;
*config_state = new_config;
}
Ok(json!({
"success": true,
"message": "Configuration imported successfully",
"backupId": backup_id
}))
}
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
default_name: String,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.set_file_name(&default_name)
.blocking_save_file();
Ok(result.map(|p| p.to_string()))
}
/// 打开文件对话框
#[tauri::command]
pub async fn open_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}

View File

@@ -1,16 +1,22 @@
mod app_config;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod import_export;
mod migration;
mod provider;
mod settings;
mod store;
mod speedtest;
use store::AppState;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::{Emitter, Manager};
/// 创建动态托盘菜单
@@ -25,6 +31,11 @@ fn create_tray_menu(
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识
@@ -107,11 +118,43 @@ fn create_tray_menu(
.map_err(|e| format!("构建菜单失败: {}", e))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {}", err);
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {}", err);
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {}", event_id);
match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
@@ -130,7 +173,9 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
provider_id,
)
.await
{ log::error!("切换Claude供应商失败: {}", e); }
{
log::error!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
@@ -147,7 +192,9 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
provider_id,
)
.await
{ log::error!("切换Codex供应商失败: {}", e); }
{
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
@@ -156,6 +203,8 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
//
/// 内部切换供应商函数
async fn switch_provider_internal(
app: &tauri::AppHandle,
@@ -215,8 +264,44 @@ async fn update_tray_menu(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let mut builder = tauri::Builder::default();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}));
}
let builder = builder
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let settings = crate::settings::get_settings();
if settings.minimize_to_tray_on_close {
api.prevent_close();
let _ = window.hide();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(true);
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(&window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
}
}
_ => {}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 注册 Updater 插件(桌面端)
@@ -290,33 +375,23 @@ pub fn run() {
// 创建动态托盘菜单
let menu = create_tray_menu(&app.handle(), &app_state)?;
let _tray = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
log::info!("left click pressed and released");
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {
log::debug!("unhandled event {event:?}");
}
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|_tray, event| match event {
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
TrayIconEvent::Click { .. } => {}
_ => log::debug!("unhandled event {event:?}"),
})
.menu(&menu)
.on_menu_event(|app, event| {
handle_tray_menu_event(app, &event.id.0);
})
.icon(app.default_window_icon().unwrap().clone())
.show_menu_on_left_click(true)
.build(app)?;
.show_menu_on_left_click(true);
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
Ok(())
@@ -332,15 +407,60 @@ pub fn run() {
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
commands::get_config_dir,
commands::open_config_folder,
commands::pick_directory,
commands::open_external,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
commands::is_portable_mode,
commands::get_claude_plugin_status,
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
commands::add_custom_endpoint,
commands::remove_custom_endpoint,
commands::update_endpoint_last_used,
// theirs: config import/export and dialogs
import_export::export_config_to_file,
import_export::import_config_from_file,
import_export::save_file_dialog,
import_export::open_file_dialog,
update_tray_menu,
])
.run(tauri::generate_context!())
]);
let app = builder
.build(tauri::generate_context!())
.expect("error while running tauri application");
app.run(|app_handle, event| {
#[cfg(target_os = "macos")]
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
match event {
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
}
}
_ => {}
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app_handle, event);
}
});
}

View File

@@ -148,8 +148,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
let marker = get_marker_path();
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
}
if marker.exists() {
return Ok(false);

View File

@@ -16,6 +16,12 @@ pub struct Provider {
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
}
impl Provider {
@@ -32,6 +38,8 @@ impl Provider {
settings_config,
website_url,
category: None,
created_at: None,
meta: None,
}
}
}
@@ -52,6 +60,14 @@ impl Default for ProviderManager {
}
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
/// 自定义端点列表(按 URL 去重存储)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
}
impl ProviderManager {
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {

177
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,177 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
pub url: String,
pub added_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
true
}
fn default_minimize_to_tray_on_close() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
claude_config_dir: None,
codex_config_dir: None,
language: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
impl AppSettings {
fn settings_path() -> PathBuf {
crate::config::get_app_config_dir().join("settings.json")
}
fn normalize_paths(&mut self) {
self.claude_config_dir = self
.claude_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.codex_config_dir = self
.codex_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh"))
.map(|s| s.to_string());
}
pub fn load() -> Self {
let path = Self::settings_path();
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => {
settings.normalize_paths();
settings
}
Err(err) => {
log::warn!(
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
path.display(),
err
);
Self::default()
}
}
} else {
Self::default()
}
}
pub fn save(&self) -> Result<(), String> {
let mut normalized = self.clone();
normalized.normalize_paths();
let path = Self::settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
}
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| format!("序列化设置失败: {}", e))?;
fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?;
Ok(())
}
}
fn settings_store() -> &'static RwLock<AppSettings> {
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
}
fn resolve_override_path(raw: &str) -> PathBuf {
if raw == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
} else if let Some(stripped) = raw.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
} else if let Some(stripped) = raw.strip_prefix("~\\") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
PathBuf::from(raw)
}
pub fn get_settings() -> AppSettings {
settings_store().read().expect("读取设置锁失败").clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
new_settings.normalize_paths();
new_settings.save()?;
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.claude_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_codex_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.codex_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}

102
src-tauri/src/speedtest.rs Normal file
View File

@@ -0,0 +1,102 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_SECS: u64 = 8;
const MAX_TIMEOUT_SECS: u64 = 30;
const MIN_TIMEOUT_SECS: u64 = 2;
#[derive(Debug, Clone, Serialize)]
pub struct EndpointLatency {
pub url: String,
pub latency: Option<u128>,
pub status: Option<u16>,
pub error: Option<String>,
}
fn build_client(timeout_secs: u64) -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
}
pub async fn test_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<EndpointLatency>, String> {
if urls.is_empty() {
return Ok(vec![]);
}
let timeout = sanitize_timeout(timeout_secs);
let client = build_client(timeout)?;
let tasks = urls.into_iter().map(|raw_url| {
let client = client.clone();
async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
let start = Instant::now();
match client.get(parsed_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis();
EndpointLatency {
url: trimmed,
latency: Some(latency),
status: Some(resp.status().as_u16()),
error: None,
}
}
Err(err) => {
let status = err.status().map(|s| s.as_u16());
let error_message = if err.is_timeout() {
"请求超时".to_string()
} else if err.is_connect() {
"连接失败".to_string()
} else {
err.to_string()
};
EndpointLatency {
url: trimmed,
latency: None,
status,
error: Some(error_message),
}
}
}
}
});
let results = join_all(tasks).await;
Ok(results)
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.2.0",
"version": "3.4.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -37,9 +37,13 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
,
],
"windows": {
"wix": {
"template": "wix/per-user-main.wxs"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",

View File

@@ -0,0 +1,360 @@
<?if $(sys.BUILDARCH)="x86"?>
<?define Win64 = "no" ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?elseif $(sys.BUILDARCH)="x64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?elseif $(sys.BUILDARCH)="arm64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else?>
<?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>
<?endif?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="*"
Name="{{product_name}}"
UpgradeCode="{{upgrade_code}}"
Language="!(loc.TauriLanguage)"
Manufacturer="{{manufacturer}}"
Version="{{version}}">
<Package Id="*"
Keywords="Installer"
InstallerVersion="450"
Languages="0"
Compressed="yes"
InstallScope="perUser"
InstallPrivileges="limited"
SummaryCodepage="!(loc.TauriCodepage)"/>
<!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->
<!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->
<Property Id="REINSTALLMODE" Value="amus" />
<!-- Auto launch app after installation, useful for passive mode which usually used in updates -->
<Property Id="AUTOLAUNCHAPP" Secure="yes" />
<!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->
<Property Id="LAUNCHAPPARGS" Secure="yes" />
{{#if allow_downgrades}}
<MajorUpgrade Schedule="afterInstallInitialize" AllowDowngrades="yes" />
{{else}}
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="!(loc.DowngradeErrorMessage)" AllowSameVersionUpgrades="yes" />
{{/if}}
<InstallExecuteSequence>
<RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>
</InstallExecuteSequence>
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
{{#if banner_path}}
<WixVariable Id="WixUIBannerBmp" Value="{{banner_path}}" />
{{/if}}
{{#if dialog_image_path}}
<WixVariable Id="WixUIDialogBmp" Value="{{dialog_image_path}}" />
{{/if}}
{{#if license}}
<WixVariable Id="WixUILicenseRtf" Value="{{license}}" />
{{/if}}
<Icon Id="ProductIcon" SourceFile="{{icon_path}}"/>
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" /> <!-- Remove repair -->
<SetProperty Id="ARPNOMODIFY" Value="1" After="InstallValidate" Sequence="execute"/>
{{#if homepage}}
<Property Id="ARPURLINFOABOUT" Value="{{homepage}}"/>
<Property Id="ARPHELPLINK" Value="{{homepage}}"/>
<Property Id="ARPURLUPDATEINFO" Value="{{homepage}}"/>
{{/if}}
<Property Id="INSTALLDIR">
<!-- First attempt: Search for "InstallDir" -->
<RegistrySearch Id="PrevInstallDirWithName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="InstallDir" Type="raw" />
<!-- Second attempt: If the first fails, search for the default key value (this is how the nsis installer currently stores the path) -->
<RegistrySearch Id="PrevInstallDirNoName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Type="raw" />
</Property>
<!-- launch app checkbox -->
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.LaunchApp)" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
<CustomAction Id="LaunchApplication" Impersonate="yes" FileKey="Path" ExeCommand="[LAUNCHAPPARGS]" Return="asyncNoWait" />
<UI>
<!-- launch app checkbox -->
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
{{#unless license}}
<!-- Skip license dialog -->
<Publish Dialog="WelcomeDlg"
Control="Next"
Event="NewDialog"
Value="InstallDirDlg"
Order="2">1</Publish>
<Publish Dialog="InstallDirDlg"
Control="Back"
Event="NewDialog"
Value="WelcomeDlg"
Order="2">1</Publish>
{{/unless}}
</UI>
<UIRef Id="WixUI_InstallDir" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="DesktopFolder" Name="Desktop">
<Component Id="ApplicationShortcutDesktop" Guid="*">
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
<RemoveFolder Id="DesktopFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
</Component>
</Directory>
<Directory Id="LocalAppDataFolder">
<Directory Id="TauriLocalAppDataPrograms" Name="Programs">
<Directory Id="INSTALLDIR" Name="{{product_name}}"/>
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="{{product_name}}"/>
</Directory>
</Directory>
<DirectoryRef Id="INSTALLDIR">
<Component Id="RegistryEntries" Guid="*">
<RegistryKey Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}">
<RegistryValue Name="InstallDir" Type="string" Value="[INSTALLDIR]" KeyPath="yes" />
</RegistryKey>
<!-- Change the Root to HKCU for perUser installations -->
{{#each deep_link_protocols as |protocol| ~}}
<RegistryKey Root="HKCU" Key="Software\Classes\\{{protocol}}">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:{{bundle_id}} protocol"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="&quot;[!Path]&quot;,0" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[!Path]&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
{{/each~}}
</Component>
<Component Id="Path" Guid="{{path_component_guid}}" Win64="$(var.Win64)">
<File Id="Path" Source="{{main_binary_path}}" KeyPath="no" Checksum="yes"/>
<RegistryValue Root="HKCU" Key="Software\{{manufacturer}}\{{product_name}}" Name="PathComponent" Type="integer" Value="1" KeyPath="yes" />
{{#each file_associations as |association| ~}}
{{#each association.ext as |ext| ~}}
<ProgId Id="{{../../product_name}}.{{ext}}" Advertise="yes" Description="{{association.description}}">
<Extension Id="{{ext}}" Advertise="yes">
<Verb Id="open" Command="Open with {{../../product_name}}" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
{{/each~}}
{{/each~}}
</Component>
{{#each binaries as |bin| ~}}
<Component Id="{{ bin.id }}" Guid="{{bin.guid}}" Win64="$(var.Win64)">
<File Id="Bin_{{ bin.id }}" Source="{{bin.path}}" KeyPath="yes"/>
</Component>
{{/each~}}
{{#if enable_elevated_update_task}}
<Component Id="UpdateTask" Guid="C492327D-9720-4CD5-8DB8-F09082AF44BE" Win64="$(var.Win64)">
<File Id="UpdateTask" Source="update.xml" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskInstaller" Guid="011F25ED-9BE3-50A7-9E9B-3519ED2B9932" Win64="$(var.Win64)">
<File Id="UpdateTaskInstaller" Source="install-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskUninstaller" Guid="D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1" Win64="$(var.Win64)">
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
{{/if}}
{{resources}}
<Component Id="CMP_UninstallShortcut" Guid="*">
<Shortcut Id="UninstallShortcut"
Name="Uninstall {{product_name}}"
Description="Uninstalls {{product_name}}"
Target="[System64Folder]msiexec.exe"
Arguments="/x [ProductCode]" />
<RemoveFile Id="RemoveUserProgramsFiles" Directory="TauriLocalAppDataPrograms" Name="*" On="uninstall" />
<RemoveFolder Id="RemoveUserProgramsFolder" Directory="TauriLocalAppDataPrograms" On="uninstall" />
<RemoveFolder Id="INSTALLDIR"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\\{{manufacturer}}\\{{product_name}}"
Name="Uninstaller Shortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="{{product_name}}"
Description="Runs {{product_name}}"
Target="[!Path]"
Icon="ProductIcon"
WorkingDirectory="INSTALLDIR">
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
</Shortcut>
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Start Menu Shortcut" Type="integer" Value="1" KeyPath="yes"/>
</Component>
</DirectoryRef>
{{#each merge_modules as |msm| ~}}
<DirectoryRef Id="TARGETDIR">
<Merge Id="{{ msm.name }}" SourceFile="{{ msm.path }}" DiskId="1" Language="!(loc.TauriLanguage)" />
</DirectoryRef>
<Feature Id="{{ msm.name }}" Title="{{ msm.name }}" AllowAdvertise="no" Display="hidden" Level="1">
<MergeRef Id="{{ msm.name }}"/>
</Feature>
{{/each~}}
<Feature
Id="MainProgram"
Title="Application"
Description="!(loc.InstallAppFeature)"
Level="1"
ConfigurableDirectory="INSTALLDIR"
AllowAdvertise="no"
Display="expand"
Absent="disallow">
<ComponentRef Id="RegistryEntries"/>
{{#each resource_file_ids as |resource_file_id| ~}}
<ComponentRef Id="{{ resource_file_id }}"/>
{{/each~}}
{{#if enable_elevated_update_task}}
<ComponentRef Id="UpdateTask" />
<ComponentRef Id="UpdateTaskInstaller" />
<ComponentRef Id="UpdateTaskUninstaller" />
{{/if}}
<Feature Id="ShortcutsFeature"
Title="Shortcuts"
Level="1">
<ComponentRef Id="Path"/>
<ComponentRef Id="CMP_UninstallShortcut" />
<ComponentRef Id="ApplicationShortcut" />
<ComponentRef Id="ApplicationShortcutDesktop" />
</Feature>
<Feature
Id="Environment"
Title="PATH Environment Variable"
Description="!(loc.PathEnvVarFeature)"
Level="1"
Absent="allow">
<ComponentRef Id="Path"/>
{{#each binaries as |bin| ~}}
<ComponentRef Id="{{ bin.id }}"/>
{{/each~}}
</Feature>
</Feature>
<Feature Id="External" AllowAdvertise="no" Absent="disallow">
{{#each component_group_refs as |id| ~}}
<ComponentGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each component_refs as |id| ~}}
<ComponentRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_group_refs as |id| ~}}
<FeatureGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_refs as |id| ~}}
<FeatureRef Id="{{ id }}"/>
{{/each~}}
{{#each merge_refs as |id| ~}}
<MergeRef Id="{{ id }}"/>
{{/each~}}
</Feature>
{{#if install_webview}}
<!-- WebView2 -->
<Property Id="WVRTINSTALLED">
<RegistrySearch Id="WVRTInstalledSystem" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" Win64="no" />
<RegistrySearch Id="WVRTInstalledUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw"/>
</Property>
{{#if download_bootstrapper}}
<CustomAction Id='DownloadAndInvokeBootstrapper' Directory="INSTALLDIR" Execute="deferred" ExeCommand='powershell.exe -NoProfile -windowstyle hidden try [\{] [\[]Net.ServicePointManager[\]]::SecurityProtocol = [\[]Net.SecurityProtocolType[\]]::Tls12 [\}] catch [\{][\}]; Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" ; Start-Process -FilePath "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" -ArgumentList ({{webview_installer_args}} &apos;/install&apos;) -Wait' Return='check'/>
<InstallExecuteSequence>
<Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- Embedded webview bootstrapper mode -->
{{#if webview2_bootstrapper_path}}
<Binary Id="MicrosoftEdgeWebview2Setup.exe" SourceFile="{{webview2_bootstrapper_path}}"/>
<CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- Embedded offline installer -->
{{#if webview2_installer_path}}
<Binary Id="MicrosoftEdgeWebView2RuntimeInstaller.exe" SourceFile="{{webview2_installer_path}}"/>
<CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeStandalone' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
{{/if}}
{{#if enable_elevated_update_task}}
<!-- Install an elevated update task within Windows Task Scheduler -->
<CustomAction
Id="CreateUpdateTask"
Return="check"
Directory="INSTALLDIR"
Execute="commit"
Impersonate="yes"
ExeCommand="powershell.exe -WindowStyle hidden .\install-task.ps1" />
<InstallExecuteSequence>
<Custom Action='CreateUpdateTask' Before='InstallFinalize'>
NOT(REMOVE)
</Custom>
</InstallExecuteSequence>
<!-- Remove elevated update task during uninstall -->
<CustomAction
Id="DeleteUpdateTask"
Return="check"
Directory="INSTALLDIR"
ExeCommand="powershell.exe -WindowStyle hidden .\uninstall-task.ps1" />
<InstallExecuteSequence>
<Custom Action="DeleteUpdateTask" Before='InstallFinalize'>
(REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE
</Custom>
</InstallExecuteSequence>
{{/if}}
<InstallExecuteSequence>
<Custom Action="LaunchApplication" After="InstallFinalize">AUTOLAUNCHAPP AND NOT Installed</Custom>
</InstallExecuteSequence>
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]" After="CostFinalize"/>
</Product>
</Wix>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "./types";
import { AppType } from "./lib/tauri-api";
import ProviderList from "./components/ProviderList";
@@ -11,15 +12,17 @@ import { UpdateBadge } from "./components/UpdateBadge";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
import { extractErrorMessage } from "./utils/errorUtils";
function App() {
const { t } = useTranslation();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
null
);
const [notification, setNotification] = useState<{
message: string;
@@ -39,7 +42,7 @@ function App() {
const showNotification = (
message: string,
type: "success" | "error",
duration = 3000,
duration = 3000
) => {
// 清除之前的定时器
if (timeoutRef.current) {
@@ -75,7 +78,7 @@ function App() {
};
}, []);
// 监听托盘切换事件
// 监听托盘切换事件(包括菜单切换)
useEffect(() => {
let unlisten: (() => void) | null = null;
@@ -83,16 +86,21 @@ function App() {
try {
unlisten = await window.api.onProviderSwitched(async (data) => {
if (import.meta.env.DEV) {
console.log("收到供应商切换事件:", data);
console.log(t("console.providerSwitchReceived"), data);
}
// 如果当前应用类型匹配,则重新加载数据
if (data.appType === activeApp) {
await loadProviders();
}
// 若为 Claude则同步插件配置
if (data.appType === "claude") {
await syncClaudePlugin(data.providerId, true);
}
});
} catch (error) {
console.error("设置供应商切换监听器失败:", error);
console.error(t("console.setupListenerFailed"), error);
}
};
@@ -104,7 +112,7 @@ function App() {
unlisten();
}
};
}, [activeApp]); // 依赖activeApp切换应用时重新设置监听器
}, [activeApp]);
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
@@ -142,13 +150,17 @@ function App() {
await loadProviders();
setEditingProviderId(null);
// 显示编辑成功提示
showNotification("供应商配置已保存", "success", 2000);
showNotification(t("notifications.providerSaved"), "success", 2000);
// 更新托盘菜单
await window.api.updateTrayMenu();
} catch (error) {
console.error("更新供应商失败:", error);
console.error(t("console.updateProviderFailed"), error);
setEditingProviderId(null);
showNotification("保存失败,请重试", "error");
const errorMessage = extractErrorMessage(error);
const message = errorMessage
? t("notifications.saveFailed", { error: errorMessage })
: t("notifications.saveFailedGeneric");
showNotification(message, "error", errorMessage ? 6000 : 3000);
}
};
@@ -156,34 +168,73 @@ function App() {
const provider = providers[id];
setConfirmDialog({
isOpen: true,
title: "删除供应商",
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
title: t("confirm.deleteProvider"),
message: t("confirm.deleteProviderMessage", { name: provider?.name }),
onConfirm: async () => {
await window.api.deleteProvider(id, activeApp);
await loadProviders();
setConfirmDialog(null);
showNotification("供应商删除成功", "success");
showNotification(t("notifications.providerDeleted"), "success");
// 更新托盘菜单
await window.api.updateTrayMenu();
},
});
};
// 同步 Claude 插件配置(写入/移除固定 JSON
const syncClaudePlugin = async (providerId: string, silent = false) => {
try {
const provider = providers[providerId];
if (!provider) return;
const isOfficial = provider.category === "official";
await window.api.applyClaudePluginConfig({ official: isOfficial });
if (!silent) {
showNotification(
isOfficial
? t("notifications.removedFromClaudePlugin")
: t("notifications.appliedToClaudePlugin"),
"success",
2000,
);
}
} catch (error: any) {
console.error("同步 Claude 插件失败:", error);
if (!silent) {
const message =
error?.message || t("notifications.syncClaudePluginFailed");
showNotification(message, "error", 5000);
}
}
};
const handleSwitchProvider = async (id: string) => {
const success = await window.api.switchProvider(id, activeApp);
if (success) {
setCurrentProviderId(id);
// 显示重启提示
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
const appName = t(`apps.${activeApp}`);
showNotification(
`切换成功!请重启 ${appName} 终端以生效`,
t("notifications.switchSuccess", { appName }),
"success",
2000,
2000
);
// 更新托盘菜单
await window.api.updateTrayMenu();
if (activeApp === "claude") {
await syncClaudePlugin(id, true);
}
} else {
showNotification("切换失败,请检查配置", "error");
showNotification(t("notifications.switchFailed"), "error");
}
};
const handleImportSuccess = async () => {
await loadProviders();
try {
await window.api.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to refresh tray menu after import", error);
}
};
@@ -194,30 +245,40 @@ function App() {
if (result.success) {
await loadProviders();
showNotification("已从现有配置创建默认供应商", "success", 3000);
showNotification(t("notifications.autoImported"), "success", 3000);
// 更新托盘菜单
await window.api.updateTrayMenu();
}
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) {
console.error("自动导入默认配置失败:", error);
console.error(t("console.autoImportFailed"), error);
// 静默处理,不影响用户体验
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* Linear 风格的顶部导航 */}
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* 顶部导航区域 - 固定高度 */}
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noopener noreferrer"
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
title={t("header.viewOnGithub")}
>
CC Switch
</h1>
</a>
<button
onClick={toggleDarkMode}
className={buttonStyles.icon}
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
title={
isDarkMode
? t("header.toggleLightMode")
: t("header.toggleDarkMode")
}
>
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
@@ -225,7 +286,7 @@ function App() {
<button
onClick={() => setIsSettingsOpen(true)}
className={buttonStyles.icon}
title="设置"
title={t("common.settings")}
>
<Settings size={18} />
</button>
@@ -241,35 +302,39 @@ function App() {
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
>
<Plus size={16} />
{t("header.addProvider")}
</button>
</div>
</div>
</header>
{/* 主内容区域 */}
<main className="flex-1 p-6">
<div className="max-w-4xl mx-auto">
{/* 通知组件 */}
{notification && (
<div
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
notification.type === "error"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
>
{notification.message}
</div>
)}
{/* 主内容区域 - 独立滚动 */}
<main className="flex-1 overflow-y-scroll">
<div className="pt-3 px-6 pb-6">
<div className="max-w-4xl mx-auto">
{/* 通知组件 - 相对于视窗定位 */}
{notification && (
<div
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
notification.type === "error"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
>
{notification.message}
</div>
)}
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
/>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification}
/>
</div>
</div>
</main>
@@ -301,7 +366,10 @@ function App() {
)}
{isSettingsOpen && (
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess}
/>
)}
</div>
);

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
@@ -14,11 +15,13 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
onAdd,
onClose,
}) => {
const { t } = useTranslation();
return (
<ProviderForm
appType={appType}
title="添加新供应商"
submitText="添加"
title={t("provider.addNewProvider")}
submitText={t("common.add")}
showPresets={true}
onSubmit={onAdd}
onClose={onClose}

View File

@@ -17,7 +17,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button
type="button"
onClick={() => handleSwitch("claude")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
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"
: "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"
@@ -26,7 +26,9 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<ClaudeIcon
size={16}
className={
activeApp === "claude" ? "text-[#D97757] dark:text-[#D97757]" : ""
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"
}
/>
<span>Claude</span>

View File

@@ -1,5 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -15,18 +17,20 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = "确定",
cancelText = "取消",
confirmText,
cancelText,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
onClick={onCancel}
/>
@@ -64,13 +68,13 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
autoFocus
>
{cancelText}
{cancelText || t("common.cancel")}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
>
{confirmText}
{confirmText || t("common.confirm")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
@@ -16,6 +17,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
onSave,
onClose,
}) => {
const { t } = useTranslation();
const handleSubmit = (data: Omit<Provider, "id">) => {
onSave({
...provider,
@@ -26,8 +29,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
return (
<ProviderForm
appType={appType}
title="编辑供应商"
submitText="保存"
title={t("common.edit")}
submitText={t("common.save")}
initialData={provider}
showPresets={false}
onSubmit={handleSubmit}

View File

@@ -0,0 +1,103 @@
import { useEffect } from "react";
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ImportProgressModalProps {
status: 'importing' | 'success' | 'error';
message?: string;
backupId?: string;
onComplete?: () => void;
onSuccess?: () => void;
}
export function ImportProgressModal({
status,
message,
backupId,
onComplete,
onSuccess
}: ImportProgressModalProps) {
const { t } = useTranslation();
useEffect(() => {
if (status === 'success') {
console.log('[ImportProgressModal] Success detected, starting 2 second countdown');
// 成功后等待2秒自动关闭并刷新数据
const timer = setTimeout(() => {
console.log('[ImportProgressModal] 2 seconds elapsed, calling callbacks...');
if (onSuccess) {
onSuccess();
}
if (onComplete) {
onComplete();
}
}, 2000);
return () => {
console.log('[ImportProgressModal] Cleanup timer');
clearTimeout(timer);
};
}
}, [status, onComplete, onSuccess]);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
<div className="flex flex-col items-center text-center">
{status === 'importing' && (
<>
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importing")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("common.loading")}
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importSuccess")}
</h3>
{backupId && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t("settings.backupId")}: {backupId}
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("settings.autoReload")}
</p>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importFailed")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{message || t("settings.configCorrupted")}
</p>
<button
onClick={() => {
if (onComplete) {
onComplete();
}
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
{t("common.close")}
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import React, { useRef, useEffect } from "react";
import React, { useRef, useEffect, useMemo } from "react";
import { EditorView, basicSetup } from "codemirror";
import { json } from "@codemirror/lang-json";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view";
import { linter, Diagnostic } from "@codemirror/lint";
interface JsonEditorProps {
value: string;
@@ -11,6 +12,7 @@ interface JsonEditorProps {
placeholder?: string;
darkMode?: boolean;
rows?: number;
showValidation?: boolean;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -19,10 +21,50 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
placeholder: placeholderText = "",
darkMode = false,
rows = 12,
showValidation = true,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
// JSON linter 函数
const jsonLinter = useMemo(
() =>
linter((view) => {
const diagnostics: Diagnostic[] = [];
if (!showValidation) return diagnostics;
const doc = view.state.doc.toString();
if (!doc.trim()) return diagnostics;
try {
const parsed = JSON.parse(doc);
// 检查是否是JSON对象
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
// 格式正确
} else {
diagnostics.push({
from: 0,
to: doc.length,
severity: "error",
message: "配置必须是JSON对象不能是数组或其他类型",
});
}
} catch (e) {
// 简单处理JSON解析错误
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
diagnostics.push({
from: 0,
to: doc.length,
severity: "error",
message,
});
}
return diagnostics;
}),
[showValidation],
);
useEffect(() => {
if (!editorRef.current) return;
@@ -43,6 +85,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
json(),
placeholder(placeholderText || ""),
sizingTheme,
jsonLinter,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
@@ -75,7 +118,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,31 @@
import React, { useEffect, useState } from "react";
import JsonEditor from "../JsonEditor";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
interface ClaudeConfigEditorProps {
value: string;
onChange: (value: string) => void;
disableCoAuthored: boolean;
onCoAuthoredToggle: (checked: boolean) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
configError: string;
}
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value,
onChange,
disableCoAuthored,
onCoAuthoredToggle,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
configError,
}) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
useEffect(() => {
// 检测暗色模式
@@ -40,6 +51,30 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
return () => observer.disconnect();
}, []);
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
if (!isCommonConfigModalOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isCommonConfigModalOpen]);
const closeModal = () => {
setIsCommonConfigModalOpen(false);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -52,13 +87,27 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
Claude Code
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<JsonEditor
value={value}
onChange={onChange}
@@ -71,9 +120,82 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
}`}
rows={12}
/>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Claude Code settings.json
</p>
{isCommonConfigModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
{/* Backdrop - 统一背景样式 */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal - 统一窗口样式 */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header - 统一标题栏样式 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h2>
<button
type="button"
onClick={closeModal}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{/* Content - 统一内容区域样式 */}
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
"写入通用配置" settings.json
</p>
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
darkMode={isDarkMode}
rows={12}
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
{/* Footer - 统一底部按钮样式 */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={closeModal}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,20 +1,234 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import {
generateThirdPartyAuth,
generateThirdPartyConfig,
} from "../../config/codexProviderPresets";
interface CodexConfigEditorProps {
authValue: string;
configValue: string;
onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void;
onAuthBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
authError: string;
isCustomMode?: boolean; // 新增:是否为自定义模式
onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调
isTemplateModalOpen?: boolean; // 新增:模态框状态
setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态
onNameChange?: (name: string) => void; // 新增:更新供应商名称回调
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
authValue,
configValue,
onAuthChange,
onConfigChange,
onAuthBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
authError,
onWebsiteUrlChange,
onNameChange,
isTemplateModalOpen: externalTemplateModalOpen,
setIsTemplateModalOpen: externalSetTemplateModalOpen,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// 使用内部状态或外部状态
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
useState(false);
const isTemplateModalOpen =
externalTemplateModalOpen ?? internalTemplateModalOpen;
const setIsTemplateModalOpen =
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
const [templateApiKey, setTemplateApiKey] = useState("");
const [templateProviderName, setTemplateProviderName] = useState("");
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
const apiKeyInputRef = useRef<HTMLInputElement>(null);
const baseUrlInputRef = useRef<HTMLInputElement>(null);
const modelNameInputRef = useRef<HTMLInputElement>(null);
const displayNameInputRef = useRef<HTMLInputElement>(null);
// 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充
const [templateDisplayName, setTemplateDisplayName] = useState("");
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
if (!isCommonConfigModalOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isCommonConfigModalOpen]);
const closeModal = () => {
setIsCommonConfigModalOpen(false);
};
const closeTemplateModal = () => {
setIsTemplateModalOpen(false);
};
const applyTemplate = () => {
const requiredInputs = [
displayNameInputRef.current,
apiKeyInputRef.current,
baseUrlInputRef.current,
modelNameInputRef.current,
];
for (const input of requiredInputs) {
if (input && !input.checkValidity()) {
input.reportValidity();
input.focus();
return;
}
}
const trimmedKey = templateApiKey.trim();
const trimmedBaseUrl = templateBaseUrl.trim();
const trimmedModel = templateModelName.trim();
const auth = generateThirdPartyAuth(trimmedKey);
const config = generateThirdPartyConfig(
templateProviderName || "custom",
trimmedBaseUrl,
trimmedModel
);
onAuthChange(JSON.stringify(auth, null, 2));
onConfigChange(config);
if (onWebsiteUrlChange) {
const trimmedWebsite = templateWebsiteUrl.trim();
if (trimmedWebsite) {
onWebsiteUrlChange(trimmedWebsite);
}
}
if (onNameChange) {
const trimmedName = templateDisplayName.trim();
if (trimmedName) {
onNameChange(trimmedName);
}
}
setTemplateApiKey("");
setTemplateProviderName("");
setTemplateBaseUrl("");
setTemplateWebsiteUrl("");
setTemplateModelName("gpt-5-codex");
setTemplateDisplayName("");
closeTemplateModal();
};
const handleTemplateInputKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
applyTemplate();
}
};
const handleAuthChange = (value: string) => {
onAuthChange(value);
};
const handleConfigChange = (value: string) => {
onConfigChange(value);
};
const handleCommonConfigSnippetChange = (value: string) => {
onCommonConfigSnippetChange(value);
};
return (
<div className="space-y-6">
<div className="space-y-2">
@@ -24,10 +238,11 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={authValue}
onChange={(e) => onAuthChange(e.target.value)}
onChange={(e) => handleAuthChange(e.target.value)}
onBlur={onAuthBlur}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
@@ -35,31 +250,417 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
rows={6}
required
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{authError && (
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Codex auth.json
</p>
</div>
<div className="space-y-2">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
config.toml (TOML)
</label>
<div className="flex items-center justify-between">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
config.toml (TOML)
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<textarea
id="codexConfig"
value={configValue}
onChange={(e) => onConfigChange(e.target.value)}
onChange={(e) => handleConfigChange(e.target.value)}
placeholder=""
rows={8}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Codex config.toml
</p>
</div>
{isTemplateModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
closeTemplateModal();
}
}}
>
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
<div className="flex h-full min-h-0 flex-col" role="form">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h2>
<button
type="button"
onClick={closeTemplateModal}
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
auth.json config.toml
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
API *
</label>
<input
type="text"
value={templateApiKey}
ref={apiKeyInputRef}
onChange={(e) => setTemplateApiKey(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title="请输入有效的内容"
placeholder="sk-your-api-key-here"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
*
</label>
<input
type="text"
value={templateDisplayName}
ref={displayNameInputRef}
onChange={(e) => {
setTemplateDisplayName(e.target.value);
if (onNameChange) {
onNameChange(e.target.value);
}
}}
onKeyDown={handleTemplateInputKeyDown}
placeholder="例如Codex 官方"
required
pattern=".*\S.*"
title="请输入有效的内容"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
使
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
</label>
<input
type="text"
value={templateProviderName}
onChange={(e) => setTemplateProviderName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="custom可选"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
custom
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
API *
</label>
<input
type="url"
value={templateBaseUrl}
ref={baseUrlInputRef}
onChange={(e) => setTemplateBaseUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="https://your-api-endpoint.com/v1"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
</label>
<input
type="url"
value={templateWebsiteUrl}
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="https://example.com"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
*
</label>
<input
type="text"
value={templateModelName}
ref={modelNameInputRef}
onChange={(e) => setTemplateModelName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title="请输入有效的内容"
placeholder="gpt-5-codex"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
{(templateApiKey ||
templateProviderName ||
templateBaseUrl) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
</h3>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
auth.json
</label>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{JSON.stringify(
generateThirdPartyAuth(templateApiKey),
null,
2
)}
</pre>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
config.toml
</label>
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{templateProviderName && templateBaseUrl
? generateThirdPartyConfig(
templateProviderName,
templateBaseUrl,
templateModelName
)
: ""}
</pre>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
<button
type="button"
onClick={closeTemplateModal}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
applyTemplate();
}}
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)}
{isCommonConfigModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
{/* Backdrop - 统一背景样式 */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal - 统一窗口样式 */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header - 统一标题栏样式 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Codex
</h2>
<button
type="button"
onClick={closeModal}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{/* Content - 统一内容区域样式 */}
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
"写入通用配置" config.toml
</p>
<textarea
value={commonConfigSnippet}
onChange={(e) =>
handleCommonConfigSnippetChange(e.target.value)
}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
rows={12}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
{/* Footer - 统一底部按钮样式 */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={closeModal}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,602 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Zap, Loader2, Plus, X, AlertCircle } from "lucide-react";
import { isLinux } from "../../lib/platform";
import type { AppType } from "../../lib/tauri-api";
export interface EndpointCandidate {
id?: string;
url: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps {
appType: AppType;
providerId?: string;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
onCustomEndpointsChange?: (urls: string[]) => void;
}
interface EndpointEntry extends EndpointCandidate {
id: string;
latency: number | null;
status?: number;
error?: string | null;
}
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
const normalizeEndpointUrl = (url: string): string =>
url.trim().replace(/\/+$/, "");
const buildInitialEntries = (
candidates: EndpointCandidate[],
selected: string,
): EndpointEntry[] => {
const map = new Map<string, EndpointEntry>();
const addCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
if (!sanitized) return;
if (map.has(sanitized)) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
};
candidates.forEach(addCandidate);
const selectedUrl = normalizeEndpointUrl(selected);
if (selectedUrl && !map.has(selectedUrl)) {
addCandidate({ url: selectedUrl, isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
providerId,
value,
onChange,
initialEndpoints,
visible = true,
onClose,
onCustomEndpointsChange,
}) => {
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
// 加载保存的自定义端点(按正在编辑的供应商)
useEffect(() => {
const loadCustomEndpoints = async () => {
try {
if (!providerId) return;
const customEndpoints = await window.api.getCustomEndpoints(
appType,
providerId,
);
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
url: ep.url,
isCustom: true,
}));
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 合并自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
});
}
});
return Array.from(map.values());
});
} catch (error) {
console.error("加载自定义端点失败:", error);
}
};
if (visible) {
loadCustomEndpoints();
}
}, [appType, visible, providerId]);
useEffect(() => {
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
prev.forEach((entry) => {
map.set(entry.url, entry);
});
let changed = false;
const mergeCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url
? normalizeEndpointUrl(candidate.url)
: "";
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected && !map.has(normalizedSelected)) {
mergeCandidate({ url: normalizedSelected, isCustom: true });
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
// 将自定义端点变化透传给父组件(仅限 isCustom
useEffect(() => {
if (!onCustomEndpointsChange) return;
try {
const customUrls = Array.from(
new Set(
entries
.filter((e) => e.isCustom)
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
.filter(Boolean),
),
);
onCustomEndpointsChange(customUrls);
} catch (err) {
// ignore
}
// 仅在 entries 变化时同步
}, [entries, onCustomEndpointsChange]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a, b) => {
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
if (aLatency === bLatency) {
return a.url.localeCompare(b.url);
}
return aLatency - bLatency;
});
}, [entries]);
const handleAddEndpoint = useCallback(
async () => {
const candidate = customUrl.trim();
let errorMsg: string | null = null;
if (!candidate) {
errorMsg = "请输入有效的 URL";
}
let parsed: URL | null = null;
if (!errorMsg) {
try {
parsed = new URL(candidate);
} catch {
errorMsg = "URL 格式不正确";
}
}
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
errorMsg = "仅支持 HTTP/HTTPS";
}
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = "该地址已存在";
}
}
if (errorMsg) {
setAddError(errorMsg);
return;
}
setAddError(null);
// 保存到后端
try {
if (providerId) {
await window.api.addCustomEndpoint(appType, providerId, sanitized);
}
// 更新本地状态
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setAddError(message || "保存失败,请重试");
console.error("添加自定义端点失败:", error);
}
},
[customUrl, entries, normalizedSelected, onChange, appType, providerId],
);
const handleRemoveEndpoint = useCallback(
async (entry: EndpointEntry) => {
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
if (entry.isCustom && providerId) {
try {
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
} catch (error) {
console.error("删除自定义端点失败:", error);
return;
}
}
// 更新本地状态
setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) {
const fallback = next[0];
onChange(fallback ? fallback.url : "");
}
return next;
});
},
[normalizedSelected, onChange, appType, providerId],
);
const runSpeedTest = useCallback(async () => {
const urls = entries.map((entry) => entry.url);
if (urls.length === 0) {
setLastError("请先添加端点");
return;
}
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
setLastError("测速功能不可用");
return;
}
setIsTesting(true);
setLastError(null);
try {
const results = await window.api.testApiEndpoints(urls, {
timeoutSecs: appType === "codex" ? 12 : 8,
});
const resultMap = new Map(
results.map((item) => [normalizeEndpointUrl(item.url), item]),
);
setEntries((prev) =>
prev.map((entry) => {
const match = resultMap.get(entry.url);
if (!match) {
return {
...entry,
latency: null,
status: undefined,
error: "未返回结果",
};
}
return {
...entry,
latency:
typeof match.latency === "number" ? Math.round(match.latency) : null,
status: match.status,
error: match.error ?? null,
};
}),
);
if (autoSelect) {
const successful = results
.filter((item) => typeof item.latency === "number" && item.latency !== null)
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
const best = successful[0];
if (best && best.url && best.url !== normalizedSelected) {
onChange(best.url);
}
}
} catch (error) {
const message =
error instanceof Error ? error.message : `测速失败: ${String(error)}`;
setLastError(message);
} finally {
setIsTesting(false);
}
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
const handleSelect = useCallback(
async (url: string) => {
if (!url || url === normalizedSelected) return;
// 更新最后使用时间(对自定义端点)
const entry = entries.find((e) => e.url === url);
if (entry?.isCustom && providerId) {
await window.api.updateEndpointLastUsed(appType, providerId, url);
}
onChange(url);
},
[normalizedSelected, onChange, appType, entries, providerId],
);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
if (!visible) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{entries.length}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
/>
</label>
<button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
className="flex h-7 items-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
</>
)}
</button>
</div>
</div>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<input
type="url"
value={customUrl}
placeholder="https://api.example.com"
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
/>
<button
type="button"
onClick={handleAddEndpoint}
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
>
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div className="font-mono text-sm font-medium text-gray-900 dark:text-gray-100">
{latency}ms
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400"></div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium"
>
</button>
</div>
</div>
</div>
);
};
export default EndpointSpeedTest;

View File

@@ -16,6 +16,7 @@ interface PresetSelectorProps {
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
@@ -25,6 +26,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
onSelectPreset,
onCustomClick,
customLabel = "自定义",
renderCustomDescription,
}) => {
const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index;
@@ -48,6 +50,10 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
const getDescription = () => {
if (selectedIndex === -1) {
// 如果提供了自定义描述渲染函数,使用它
if (renderCustomDescription) {
return renderCustomDescription();
}
return "手动配置供应商,需要填写完整的配置信息";
}
@@ -99,9 +105,9 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
</div>
</div>
{getDescription() && (
<p className="text-sm text-gray-500 dark:text-gray-400">
<div className="text-sm text-gray-500 dark:text-gray-400">
{getDescription()}
</p>
</div>
)}
</div>
);

View File

@@ -1,7 +1,9 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
import { AppType } from "../lib/tauri-api";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps {
@@ -10,6 +12,12 @@ interface ProviderListProps {
onSwitch: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string) => void;
appType?: AppType;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number
) => void;
}
const ProviderList: React.FC<ProviderListProps> = ({
@@ -18,7 +26,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch,
onDelete,
onEdit,
appType,
onNotify,
}) => {
const { t } = useTranslation();
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
@@ -29,12 +40,13 @@ const ProviderList: React.FC<ProviderListProps> = ({
}
// Codex: 从 TOML 配置中解析 base_url
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
if (match && match[1]) return match[1];
// 支持单/双引号
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
if (match && match[2]) return match[2];
}
return "未配置官网地址";
return t("provider.notConfigured");
} catch {
return "配置错误";
return t("provider.configError");
}
};
@@ -42,7 +54,57 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
await window.api.openExternal(url);
} catch (error) {
console.error("打开链接失败:", error);
console.error(t("console.openLinkFailed"), error);
}
};
const [claudeApplied, setClaudeApplied] = useState<boolean>(false);
// 检查 Claude 插件配置是否已应用
useEffect(() => {
const checkClaude = async () => {
if (appType !== "claude" || !currentProviderId) {
setClaudeApplied(false);
return;
}
try {
const applied = await window.api.isClaudePluginApplied();
setClaudeApplied(applied);
} catch (error) {
console.error("检测 Claude 插件配置失败:", error);
setClaudeApplied(false);
}
};
checkClaude();
}, [appType, currentProviderId, providers]);
const handleApplyToClaudePlugin = async () => {
try {
await window.api.applyClaudePluginConfig({ official: false });
onNotify?.(t("notifications.appliedToClaudePlugin"), "success", 3000);
setClaudeApplied(true);
} catch (error: any) {
console.error(error);
const msg =
error && error.message
? error.message
: t("notifications.syncClaudePluginFailed");
onNotify?.(msg, "error", 5000);
}
};
const handleRemoveFromClaudePlugin = async () => {
try {
await window.api.applyClaudePluginConfig({ official: true });
onNotify?.(t("notifications.removedFromClaudePlugin"), "success", 3000);
setClaudeApplied(false);
} catch (error: any) {
console.error(error);
const msg =
error && error.message
? error.message
: t("notifications.syncClaudePluginFailed");
onNotify?.(msg, "error", 5000);
}
};
@@ -75,10 +137,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
<Users size={24} className="text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("provider.noProviders")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
"添加供应商"API供应商
{t("provider.noProvidersDescription")}
</p>
</div>
) : (
@@ -91,7 +153,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div
key={provider.id}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive,
isCurrent ? cardStyles.selected : cardStyles.interactive
)}
>
<div className="flex items-start justify-between">
@@ -101,12 +163,15 @@ const ProviderList: React.FC<ProviderListProps> = ({
{provider.name}
</h3>
{/* 分类徽章已移除 */}
{isCurrent && (
<div className={badgeStyles.success}>
<CheckCircle2 size={12} />
使
</div>
)}
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible"
)}
>
<CheckCircle2 size={12} />
{t("provider.currentlyUsing")}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
@@ -133,24 +198,52 @@ const ProviderList: React.FC<ProviderListProps> = ({
</div>
<div className="flex items-center gap-2 ml-4">
{appType === "claude" ? (
<div className="flex-shrink-0">
{provider.category !== "official" && isCurrent && (
<button
onClick={() =>
claudeApplied
? handleRemoveFromClaudePlugin()
: handleApplyToClaudePlugin()
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-full whitespace-nowrap justify-center",
claudeApplied
? "border border-gray-300 text-gray-600 hover:border-red-300 hover:text-red-600 hover:bg-red-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-red-800 dark:hover:text-red-400 dark:hover:bg-red-900/20"
: "border border-gray-300 text-gray-700 hover:border-green-300 hover:text-green-600 hover:bg-green-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-green-700 dark:hover:text-green-400 dark:hover:bg-green-900/20"
)}
title={
claudeApplied
? t("provider.removeFromClaudePlugin")
: t("provider.applyToClaudePlugin")
}
>
{claudeApplied
? t("provider.removeFromClaudePlugin")
: t("provider.applyToClaudePlugin")}
</button>
)}
</div>
) : null}
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
)}
>
<Play size={14} />
{isCurrent ? "使用中" : "启用"}
{isCurrent ? <Check size={14} /> : <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title="编辑供应商"
title={t("provider.editProvider")}
>
<Edit3 size={16} />
</button>
@@ -162,9 +255,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
buttonStyles.icon,
isCurrent
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
)}
title="删除供应商"
title={t("provider.deleteProvider")}
>
<Trash2 size={16} />
</button>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
X,
RefreshCw,
@@ -6,33 +7,77 @@ import {
Download,
ExternalLink,
Check,
Undo2,
FolderSearch,
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { ImportProgressModal } from "./ImportProgressModal";
import { homeDir, join } from "@tauri-apps/api/path";
import "../lib/tauri-api";
import { relaunchApp } from "../lib/updater";
import { useUpdate } from "../contexts/UpdateContext";
import type { Settings } from "../types";
import type { AppType } from "../lib/tauri-api";
import { isLinux } from "../lib/platform";
interface SettingsModalProps {
onClose: () => void;
onImportSuccess?: () => void | Promise<void>;
}
export default function SettingsModal({ onClose }: SettingsModalProps) {
export default function SettingsModal({ onClose, onImportSuccess }: SettingsModalProps) {
const { t, i18n } = useTranslation();
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
lang === "en" ? "en" : "zh";
const readPersistedLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
};
const persistedLanguage = readPersistedLanguage();
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
language: persistedLanguage,
});
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
persistedLanguage,
);
const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [showUpToDate, setShowUpToDate] = useState(false);
const [resolvedClaudeDir, setResolvedClaudeDir] = useState<string>("");
const [resolvedCodexDir, setResolvedCodexDir] = useState<string>("");
const [isPortable, setIsPortable] = useState(false);
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
// 导入/导出相关状态
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'success' | 'error'>('idle');
const [importError, setImportError] = useState<string>("");
const [importBackupId, setImportBackupId] = useState<string>("");
const [selectedImportFile, setSelectedImportFile] = useState<string>('');
useEffect(() => {
loadSettings();
loadConfigPath();
loadVersion();
loadResolvedDirs();
loadPortableFlag();
}, []);
const loadVersion = async () => {
@@ -40,23 +85,48 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error("获取版本信息失败:", error);
console.error(t("console.getVersionFailed"), error);
// 失败时不硬编码版本号,显示为未知
setVersion("未知");
setVersion(t("common.unknown"));
}
};
const loadSettings = async () => {
try {
const loadedSettings = await window.api.getSettings();
if ((loadedSettings as any)?.showInTray !== undefined) {
setSettings({ showInTray: (loadedSettings as any).showInTray });
} else if ((loadedSettings as any)?.showInDock !== undefined) {
// 向后兼容:若历史上有 showInDock则映射为 showInTray
setSettings({ showInTray: (loadedSettings as any).showInDock });
const showInTray =
(loadedSettings as any)?.showInTray ??
(loadedSettings as any)?.showInDock ??
true;
const minimizeToTrayOnClose =
(loadedSettings as any)?.minimizeToTrayOnClose ??
(loadedSettings as any)?.minimize_to_tray_on_close ??
true;
const storedLanguage = normalizeLanguage(
typeof (loadedSettings as any)?.language === "string"
? (loadedSettings as any).language
: persistedLanguage,
);
setSettings({
showInTray,
minimizeToTrayOnClose,
claudeConfigDir:
typeof (loadedSettings as any)?.claudeConfigDir === "string"
? (loadedSettings as any).claudeConfigDir
: undefined,
codexConfigDir:
typeof (loadedSettings as any)?.codexConfigDir === "string"
? (loadedSettings as any).codexConfigDir
: undefined,
language: storedLanguage,
});
setInitialLanguage(storedLanguage);
if (i18n.language !== storedLanguage) {
void i18n.changeLanguage(storedLanguage);
}
} catch (error) {
console.error("加载设置失败:", error);
console.error(t("console.loadSettingsFailed"), error);
}
};
@@ -67,21 +137,87 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setConfigPath(path);
}
} catch (error) {
console.error("获取配置路径失败:", error);
console.error(t("console.getConfigPathFailed"), error);
}
};
const loadResolvedDirs = async () => {
try {
const [claudeDir, codexDir] = await Promise.all([
window.api.getConfigDir("claude"),
window.api.getConfigDir("codex"),
]);
setResolvedClaudeDir(claudeDir || "");
setResolvedCodexDir(codexDir || "");
} catch (error) {
console.error(t("console.getConfigDirFailed"), error);
}
};
const loadPortableFlag = async () => {
try {
const portable = await window.api.isPortable();
setIsPortable(portable);
} catch (error) {
console.error(t("console.detectPortableFailed"), error);
}
};
const saveSettings = async () => {
try {
await window.api.saveSettings(settings);
const selectedLanguage = settings.language === "en" ? "en" : "zh";
const payload: Settings = {
...settings,
claudeConfigDir:
settings.claudeConfigDir && settings.claudeConfigDir.trim() !== ""
? settings.claudeConfigDir.trim()
: undefined,
codexConfigDir:
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
language: selectedLanguage,
};
await window.api.saveSettings(payload);
setSettings(payload);
try {
window.localStorage.setItem("language", selectedLanguage);
} catch (error) {
console.warn("[Settings] Failed to persist language preference", error);
}
setInitialLanguage(selectedLanguage);
if (i18n.language !== selectedLanguage) {
void i18n.changeLanguage(selectedLanguage);
}
onClose();
} catch (error) {
console.error("保存设置失败:", error);
console.error(t("console.saveSettingsFailed"), error);
}
};
const handleLanguageChange = (lang: "zh" | "en") => {
setSettings((prev) => ({ ...prev, language: lang }));
if (i18n.language !== lang) {
void i18n.changeLanguage(lang);
}
};
const handleCancel = () => {
if (settings.language !== initialLanguage) {
setSettings((prev) => ({ ...prev, language: initialLanguage }));
if (i18n.language !== initialLanguage) {
void i18n.changeLanguage(initialLanguage);
}
}
onClose();
};
const handleCheckUpdate = async () => {
if (hasUpdate && updateHandle) {
if (isPortable) {
await window.api.checkForUpdates();
return;
}
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
setIsDownloading(true);
try {
@@ -89,7 +225,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
await updateHandle.downloadAndInstall();
await relaunchApp();
} catch (error) {
console.error("更新失败:", error);
console.error(t("console.updateFailed"), error);
// 更新失败时回退到打开 Releases 页面
await window.api.checkForUpdates();
} finally {
@@ -110,7 +246,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
}, 3000);
}
} catch (error) {
console.error("检查更新失败:", error);
console.error(t("console.checkUpdateFailed"), error);
// 在开发模式下,模拟已是最新版本的响应
if (import.meta.env.DEV) {
setShowUpToDate(true);
@@ -131,17 +267,80 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
try {
await window.api.openAppConfigFolder();
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error(t("console.openConfigFolderFailed"), error);
}
};
const handleBrowseConfigDir = async (app: AppType) => {
try {
const currentResolved =
app === "claude"
? (settings.claudeConfigDir ?? resolvedClaudeDir)
: (settings.codexConfigDir ?? resolvedCodexDir);
const selected = await window.api.selectConfigDirectory(currentResolved);
if (!selected) {
return;
}
const sanitized = selected.trim();
if (sanitized === "") {
return;
}
if (app === "claude") {
setSettings((prev) => ({ ...prev, claudeConfigDir: sanitized }));
setResolvedClaudeDir(sanitized);
} else {
setSettings((prev) => ({ ...prev, codexConfigDir: sanitized }));
setResolvedCodexDir(sanitized);
}
} catch (error) {
console.error(t("console.selectConfigDirFailed"), error);
}
};
const computeDefaultConfigDir = async (app: AppType) => {
try {
const home = await homeDir();
const folder = app === "claude" ? ".claude" : ".codex";
return await join(home, folder);
} catch (error) {
console.error(t("console.getDefaultConfigDirFailed"), error);
return "";
}
};
const handleResetConfigDir = async (app: AppType) => {
setSettings((prev) => ({
...prev,
...(app === "claude"
? { claudeConfigDir: undefined }
: { codexConfigDir: undefined }),
}));
const defaultDir = await computeDefaultConfigDir(app);
if (!defaultDir) {
return;
}
if (app === "claude") {
setResolvedClaudeDir(defaultDir);
} else {
setResolvedCodexDir(defaultDir);
}
};
const handleOpenReleaseNotes = async () => {
try {
const targetVersion = updateInfo?.availableVersion || version;
const unknownLabel = t("common.unknown");
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === "未知") {
if (!targetVersion || targetVersion === unknownLabel) {
await window.api.openExternal(
"https://github.com/farion1231/cc-switch/releases",
"https://github.com/farion1231/cc-switch/releases"
);
return;
}
@@ -149,23 +348,93 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
? targetVersion
: `v${targetVersion}`;
await window.api.openExternal(
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
);
} catch (error) {
console.error("打开更新日志失败:", error);
console.error(t("console.openReleaseNotesFailed"), error);
}
};
// 导出配置处理函数
const handleExportConfig = async () => {
try {
const defaultName = `cc-switch-config-${new Date().toISOString().split('T')[0]}.json`;
const filePath = await window.api.saveFileDialog(defaultName);
if (!filePath) return; // 用户取消了
const result = await window.api.exportConfigToFile(filePath);
if (result.success) {
alert(`${t("settings.configExported")}\n${result.filePath}`);
}
} catch (error) {
console.error("导出配置失败:", error);
alert(`${t("settings.exportFailed")}: ${error}`);
}
};
// 选择要导入的文件
const handleSelectImportFile = async () => {
try {
const filePath = await window.api.openFileDialog();
if (filePath) {
setSelectedImportFile(filePath);
setImportStatus('idle'); // 重置状态
setImportError('');
}
} catch (error) {
console.error('选择文件失败:', error);
alert(`${t("settings.selectFileFailed")}: ${error}`);
}
};
// 执行导入
const handleExecuteImport = async () => {
if (!selectedImportFile || isImporting) return;
setIsImporting(true);
setImportStatus('importing');
try {
const result = await window.api.importConfigFromFile(selectedImportFile);
if (result.success) {
setImportBackupId(result.backupId || '');
setImportStatus('success');
// ImportProgressModal 会在2秒后触发数据刷新回调
} else {
setImportError(result.message || t("settings.configCorrupted"));
setImportStatus('error');
}
} catch (error) {
setImportError(String(error));
setImportStatus('error');
} finally {
setIsImporting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] max-h-[90vh] flex flex-col overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
{t("settings.title")}
</h2>
<button
onClick={onClose}
onClick={handleCancel}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
@@ -173,43 +442,83 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6">
{/* 系统托盘设置(未实现)
说明:此开关用于控制是否在系统托盘/菜单栏显示应用图标。 */}
{/* <div>
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
{/* 语言设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
显示设置(系统托盘)
{t("settings.language")}
</h3>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-500">
在菜单栏显示图标(系统托盘)
</span>
<input
type="checkbox"
checked={settings.showInTray}
onChange={(e) =>
setSettings({ ...settings, showInTray: e.target.checked })
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div> */}
<div className="inline-flex p-0.5 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
type="button"
onClick={() => handleLanguageChange("zh")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
(settings.language ?? "zh") === "zh"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionChinese")}
</button>
<button
type="button"
onClick={() => handleLanguageChange("en")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
settings.language === "en"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionEnglish")}
</button>
</div>
</div>
{/* 窗口行为设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.windowBehavior")}
</h3>
<div className="space-y-3">
<label className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("settings.minimizeToTray")}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("settings.minimizeToTrayDescription")}
</p>
</div>
<input
type="checkbox"
checked={settings.minimizeToTrayOnClose}
onChange={(e) =>
setSettings((prev) => ({
...prev,
minimizeToTrayOnClose: e.target.checked,
}))
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div>
</div>
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.configFileLocation")}
</h3>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{configPath || "加载中..."}
{configPath || t("common.loading")}
</span>
</div>
<button
onClick={handleOpenConfigFolder}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="打开文件夹"
title={t("settings.openFolder")}
>
<FolderOpen
size={18}
@@ -219,10 +528,143 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
</div>
{/* 配置目录覆盖 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("settings.configDirectoryOverride")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
{t("settings.configDirectoryDescription")}
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t("settings.claudeConfigDir")}
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.claudeConfigDir ?? resolvedClaudeDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
claudeConfigDir: e.target.value,
})
}
placeholder={t("settings.browsePlaceholderClaude")}
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title={t("settings.browseDirectory")}
>
<FolderSearch size={16} />
</button>
<button
type="button"
onClick={() => handleResetConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title={t("settings.resetDefault")}
>
<Undo2 size={16} />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t("settings.codexConfigDir")}
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.codexConfigDir ?? resolvedCodexDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
codexConfigDir: e.target.value,
})
}
placeholder={t("settings.browsePlaceholderCodex")}
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title={t("settings.browseDirectory")}
>
<FolderSearch size={16} />
</button>
<button
type="button"
onClick={() => handleResetConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title={t("settings.resetDefault")}
>
<Undo2 size={16} />
</button>
</div>
</div>
</div>
</div>
{/* 导入导出 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.importExport")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="space-y-3">
{/* 导出按钮 */}
<button
onClick={handleExportConfig}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<Save size={12} />
{t("settings.exportConfig")}
</button>
{/* 导入区域 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={handleSelectImportFile}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<FolderOpen size={12} />
{t("settings.selectConfigFile")}
</button>
<button
onClick={handleExecuteImport}
disabled={!selectedImportFile || isImporting}
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
!selectedImportFile || isImporting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
}`}
>
{isImporting ? t("settings.importing") : t("settings.import")}
</button>
</div>
{/* 显示选择的文件 */}
{selectedImportFile && (
<div className="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-900 rounded break-all">
{selectedImportFile.split('/').pop() || selectedImportFile.split('\\').pop() || selectedImportFile}
</div>
)}
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("common.about")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between">
@@ -232,7 +674,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
CC Switch
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{version}
{t("common.version")} {version}
</p>
</div>
</div>
@@ -241,22 +683,24 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
onClick={handleOpenReleaseNotes}
className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
title={
hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"
hasUpdate
? t("settings.viewReleaseNotes")
: t("settings.viewCurrentReleaseNotes")
}
>
<span className="inline-flex items-center gap-1">
<ExternalLink size={12} />
{t("settings.releaseNotes")}
</span>
</button>
<button
onClick={handleCheckUpdate}
disabled={isCheckingUpdate || isDownloading}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
className={`min-w-[88px] px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
isCheckingUpdate || isDownloading
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed border border-transparent"
: hasUpdate
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white"
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-transparent"
: showUpToDate
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"
@@ -265,25 +709,27 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{isDownloading ? (
<span className="flex items-center gap-1">
<Download size={12} className="animate-pulse" />
...
{t("settings.updating")}
</span>
) : isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
{t("settings.checking")}
</span>
) : hasUpdate ? (
<span className="flex items-center gap-1">
<Download size={12} />
v{updateInfo?.availableVersion}
{t("settings.updateTo", {
version: updateInfo?.availableVersion ?? "",
})}
</span>
) : showUpToDate ? (
<span className="flex items-center gap-1">
<Check size={12} />
{t("settings.upToDate")}
</span>
) : (
"检查更新"
t("settings.checkForUpdates")
)}
</button>
</div>
@@ -295,19 +741,42 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={onClose}
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={saveSettings}
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors"
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors flex items-center gap-2"
>
<Save size={16} />
{t("common.save")}
</button>
</div>
</div>
{/* Import Progress Modal */}
{importStatus !== 'idle' && (
<ImportProgressModal
status={importStatus}
message={importError}
backupId={importBackupId}
onComplete={() => {
setImportStatus('idle');
setImportError('');
setSelectedImportFile('');
}}
onSuccess={() => {
if (onImportSuccess) {
void onImportSuccess();
}
void window.api
.updateTrayMenu()
.catch((error) => console.error("[SettingsModal] Failed to refresh tray menu", error));
}}
/>
)}
</div>
);
}

View File

@@ -6,10 +6,50 @@ import { ProviderCategory } from "../types";
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
// 第三方供应商可提供单独的获取 API Key 链接
apiKeyUrl?: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
/**
* 生成第三方供应商的 auth.json
*/
export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
return {
OPENAI_API_KEY: apiKey || "sk-your-api-key-here",
};
}
/**
* 生成第三方供应商的 config.toml
*/
export function generateThirdPartyConfig(
providerName: string,
baseUrl: string,
modelName = "gpt-5-codex"
): string {
// 清理供应商名称确保符合TOML键名规范
const cleanProviderName =
providerName
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_")
.replace(/^_+|_+$/g, "") || "custom";
return `model_provider = "${cleanProviderName}"
model = "${modelName}"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.${cleanProviderName}]
name = "${cleanProviderName}"
base_url = "${baseUrl}"
wire_api = "responses"`;
}
export const codexProviderPresets: CodexProviderPreset[] = [
@@ -18,7 +58,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
category: "official",
// 官方的 key 为null
auth: {
OPENAI_API_KEY: null,
},
@@ -28,19 +67,17 @@ export const codexProviderPresets: CodexProviderPreset[] = [
name: "PackyCode",
websiteUrl: "https://codex.packycode.com/",
category: "third_party",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: {
OPENAI_API_KEY: "sk-your-api-key-here",
},
config: `model_provider = "packycode"
model = "gpt-5"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"
env_key = "packycode"`,
auth: generateThirdPartyAuth("sk-your-api-key-here"),
config: generateThirdPartyConfig(
"packycode",
"https://codex-api.packycode.com/v1",
"gpt-5-codex"
),
// Codex 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://codex-api.packycode.com/v1",
"https://codex-api-hk-cn2.packycode.com/v1",
"https://codex-api-hk-cdn.packycode.com/v1",
],
},
];

View File

@@ -3,12 +3,25 @@
*/
import { ProviderCategory } from "../types";
export interface TemplateValueConfig {
label: string;
placeholder: string;
defaultValue?: string;
editorValue: string;
}
export interface ProviderPreset {
name: string;
websiteUrl: string;
// 新增:第三方/聚合等可单独配置获取 API Key 的链接
apiKeyUrl?: string;
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
// 新增:模板变量定义,用于动态替换配置中的值
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
export const providerPresets: ProviderPreset[] = [
@@ -28,8 +41,8 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.2-Exp",
},
},
category: "cn_official",
@@ -41,8 +54,12 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air",
// 兼容旧键名,保持前端读取一致
ANTHROPIC_MODEL: "GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
},
},
category: "cn_official",
@@ -55,8 +72,8 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "qwen3-coder-plus",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-coder-plus",
ANTHROPIC_MODEL: "qwen3-max",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-max",
},
},
category: "cn_official",
@@ -81,8 +98,8 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.6",
},
},
category: "aggregator",
@@ -90,12 +107,43 @@ export const providerPresets: ProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
apiKeyUrl: "https://www.packycode.com/?aff=rlo54mgz",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.packycode.com",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://api.packycode.com",
"https://api-hk-cn2.packycode.com",
"https://api-hk-g.packycode.com",
"https://api-us-cn2.packycode.com",
"https://api-cf-pro.packycode.com",
],
category: "third_party",
},
{
name: "KAT-Coder 官方",
websiteUrl: "https://console.streamlake.ai/wanqing/",
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "KAT-Coder",
ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder",
},
},
category: "cn_official",
templateValues: {
ENDPOINT_ID: {
label: "Vanchin Endpoint ID",
placeholder: "ep-xxx-xxx",
defaultValue: "",
editorValue: "",
},
},
},
];

59
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,59 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
const getInitialLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en") {
return stored;
}
} catch (error) {
console.warn("[i18n] Failed to read stored language preference", error);
}
}
const navigatorLang =
typeof navigator !== "undefined"
? navigator.language?.toLowerCase() ?? navigator.languages?.[0]?.toLowerCase()
: undefined;
if (navigatorLang?.startsWith("zh")) {
return "zh";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
return DEFAULT_LANGUAGE;
};
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n.use(initReactI18next).init({
resources,
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
fallbackLng: "en", // 如果缺少中文翻译则退回英文
interpolation: {
escapeValue: false, // React 已经默认转义
},
// 开发模式下显示调试信息
debug: false,
});
export default i18n;

124
src/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,124 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex Provider Switching Tool"
},
"common": {
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"settings": "Settings",
"about": "About",
"version": "Version",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"unknown": "Unknown"
},
"header": {
"viewOnGithub": "View on GitHub",
"toggleDarkMode": "Switch to Dark Mode",
"toggleLightMode": "Switch to Light Mode",
"addProvider": "Add Provider",
"switchToChinese": "Switch to Chinese",
"switchToEnglish": "Switch to English"
},
"provider": {
"noProviders": "No providers added yet",
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
"currentlyUsing": "Currently Using",
"enable": "Enable",
"inUse": "In Use",
"editProvider": "Edit Provider",
"deleteProvider": "Delete Provider",
"addNewProvider": "Add New Provider",
"configError": "Configuration Error",
"notConfigured": "Not configured for official website",
"applyToClaudePlugin": "Apply to Claude plugin",
"removeFromClaudePlugin": "Remove from Claude plugin"
},
"notifications": {
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
"switchFailed": "Switch failed, please check configuration",
"autoImported": "Default provider created from existing configuration",
"saveFailed": "Save failed: {{error}}",
"saveFailedGeneric": "Save failed, please try again",
"appliedToClaudePlugin": "Applied to Claude plugin",
"removedFromClaudePlugin": "Removed from Claude plugin",
"syncClaudePluginFailed": "Sync Claude plugin failed"
},
"confirm": {
"deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
},
"settings": {
"title": "Settings",
"general": "General",
"language": "Language",
"importExport": "Import/Export Config",
"exportConfig": "Export Config to File",
"selectConfigFile": "Select Config File",
"import": "Import",
"importing": "Importing...",
"importSuccess": "Import Successful!",
"importFailed": "Import Failed",
"configExported": "Config exported to:",
"exportFailed": "Export failed",
"selectFileFailed": "Failed to select file",
"configCorrupted": "Config file may be corrupted or invalid",
"backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "Window Behavior",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"configFileLocation": "Configuration File Location",
"openFolder": "Open Folder",
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory in WSL to keep provider data consistent with the main environment.",
"claudeConfigDir": "Claude Code Configuration Directory",
"codexConfigDir": "Codex Configuration Directory",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
"updateTo": "Update to v{{version}}",
"updating": "Updating...",
"checking": "Checking...",
"upToDate": "Up to Date",
"releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes"
},
"apps": {
"claude": "Claude Code",
"codex": "Codex"
},
"console": {
"providerSwitchReceived": "Received provider switch event:",
"setupListenerFailed": "Failed to setup provider switch listener:",
"updateProviderFailed": "Update provider failed:",
"autoImportFailed": "Auto import default configuration failed:",
"openLinkFailed": "Failed to open link:",
"getVersionFailed": "Failed to get version info:",
"loadSettingsFailed": "Failed to load settings:",
"getConfigPathFailed": "Failed to get config path:",
"getConfigDirFailed": "Failed to get config directory:",
"detectPortableFailed": "Failed to detect portable mode:",
"saveSettingsFailed": "Failed to save settings:",
"updateFailed": "Update failed:",
"checkUpdateFailed": "Check for updates failed:",
"openConfigFolderFailed": "Failed to open config folder:",
"selectConfigDirFailed": "Failed to select config directory:",
"getDefaultConfigDirFailed": "Failed to get default config directory:",
"openReleaseNotesFailed": "Failed to open release notes:"
}
}

124
src/i18n/locales/zh.json Normal file
View File

@@ -0,0 +1,124 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex 供应商切换工具"
},
"common": {
"add": "添加",
"edit": "编辑",
"delete": "删除",
"save": "保存",
"cancel": "取消",
"confirm": "确定",
"close": "关闭",
"settings": "设置",
"about": "关于",
"version": "版本",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"unknown": "未知"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
"toggleDarkMode": "切换到暗色模式",
"toggleLightMode": "切换到亮色模式",
"addProvider": "添加供应商",
"switchToChinese": "切换到中文",
"switchToEnglish": "切换到英文"
},
"provider": {
"noProviders": "还没有添加任何供应商",
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
"currentlyUsing": "当前使用",
"enable": "启用",
"inUse": "使用中",
"editProvider": "编辑供应商",
"deleteProvider": "删除供应商",
"addNewProvider": "添加新供应商",
"configError": "配置错误",
"notConfigured": "未配置官网地址",
"applyToClaudePlugin": "应用到 Claude 插件",
"removeFromClaudePlugin": "从 Claude 插件移除"
},
"notifications": {
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchFailed": "切换失败,请检查配置",
"autoImported": "已从现有配置创建默认供应商",
"saveFailed": "保存失败:{{error}}",
"saveFailedGeneric": "保存失败,请重试",
"appliedToClaudePlugin": "已应用到 Claude 插件",
"removedFromClaudePlugin": "已从 Claude 插件移除",
"syncClaudePluginFailed": "同步 Claude 插件失败"
},
"confirm": {
"deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
},
"settings": {
"title": "设置",
"general": "通用",
"language": "界面语言",
"importExport": "导入导出配置",
"exportConfig": "导出配置到文件",
"selectConfigFile": "选择配置文件",
"import": "导入",
"importing": "导入中...",
"importSuccess": "导入成功!",
"importFailed": "导入失败",
"configExported": "配置已导出到:",
"exportFailed": "导出失败",
"selectFileFailed": "选择文件失败",
"configCorrupted": "配置文件可能已损坏或格式不正确",
"backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "窗口行为",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"configFileLocation": "配置文件位置",
"openFolder": "打开文件夹",
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定 WSL 里的配置目录,供应商数据与主环境保持一致。",
"claudeConfigDir": "Claude Code 配置目录",
"codexConfigDir": "Codex 配置目录",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
"updateTo": "更新到 v{{version}}",
"updating": "更新中...",
"checking": "检查中...",
"upToDate": "已是最新",
"releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志"
},
"apps": {
"claude": "Claude Code",
"codex": "Codex"
},
"console": {
"providerSwitchReceived": "收到供应商切换事件:",
"setupListenerFailed": "设置供应商切换监听器失败:",
"updateProviderFailed": "更新供应商失败:",
"autoImportFailed": "自动导入默认配置失败:",
"openLinkFailed": "打开链接失败:",
"getVersionFailed": "获取版本信息失败:",
"loadSettingsFailed": "加载设置失败:",
"getConfigPathFailed": "获取配置路径失败:",
"getConfigDirFailed": "获取配置目录失败:",
"detectPortableFailed": "检测便携模式失败:",
"saveSettingsFailed": "保存设置失败:",
"updateFailed": "更新失败:",
"checkUpdateFailed": "检查更新失败:",
"openConfigFolderFailed": "打开配置文件夹失败:",
"selectConfigDirFailed": "选择配置目录失败:",
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
"openReleaseNotesFailed": "打开更新日志失败:"
}
}

30
src/lib/platform.ts Normal file
View File

@@ -0,0 +1,30 @@
// 轻量平台检测,避免在 SSR 或无 navigator 的环境报错
export const isMac = (): boolean => {
try {
const ua = navigator.userAgent || "";
const plat = (navigator.platform || "").toLowerCase();
return /mac/i.test(ua) || plat.includes("mac");
} catch {
return false;
}
};
export const isWindows = (): boolean => {
try {
const ua = navigator.userAgent || "";
return /windows|win32|win64/i.test(ua);
} catch {
return false;
}
};
export const isLinux = (): boolean => {
try {
const ua = navigator.userAgent || "";
// WebKitGTK/Chromium 在 Linux/Wayland/X11 下 UA 通常包含 Linux 或 X11
return /linux|x11/i.test(ua) && !/android/i.test(ua) && !isMac() && !isWindows();
} catch {
return false;
}
};

View File

@@ -34,11 +34,11 @@ export const cardStyles = {
// 带悬浮效果的卡片
interactive:
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-[border-color,box-shadow] duration-200",
// 选中/激活态卡片
selected:
"bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10",
"bg-white rounded-lg border border-blue-500 shadow-sm bg-blue-50 p-4 dark:bg-gray-900 dark:border-blue-400 dark:bg-blue-400/10",
} as const;
// 输入控件样式

View File

@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { Provider, Settings } from "../types";
import { Provider, Settings, CustomEndpoint } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -18,6 +18,13 @@ interface ImportResult {
message?: string;
}
export interface EndpointLatencyResult {
url: string;
latency: number | null;
status?: number;
error?: string;
}
// Tauri API 封装,提供统一的全局 API 接口
export const tauriAPI = {
// 获取所有供应商
@@ -122,40 +129,32 @@ export const tauriAPI = {
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
// 获取当前生效的配置目录
getConfigDir: async (app?: AppType): Promise<string> => {
try {
return await invoke("get_claude_config_status");
return await invoke("get_config_dir", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
console.error("获取配置目录失败:", error);
return "";
}
},
// 获取应用配置状态(通用
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
try {
return await invoke("get_config_status", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 打开配置文件夹
// 打开配置目录(按应用类型
openConfigFolder: async (app?: AppType): Promise<void> => {
try {
await invoke("open_config_folder", { app_type: app, app });
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error("打开配置目录失败:", error);
}
},
// 选择配置目录(可选默认路径)
selectConfigDirectory: async (defaultPath?: string): Promise<string | null> => {
try {
return await invoke("pick_directory", { defaultPath });
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
@@ -171,37 +170,20 @@ export const tauriAPI = {
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke("update_tray_menu");
return await invoke<boolean>("update_tray_menu");
} catch (error) {
console.error("更新托盘菜单失败:", error);
return false;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
return await listen("provider-switched", (event) => {
callback(event.payload as { appType: string; providerId: string });
});
},
// (保留空位,取消迁移提示)
// 选择配置文件Tauri 暂不实现,保留接口兼容性)
selectConfigFile: async (): Promise<string | null> => {
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
return null;
},
// 获取设置
// 获取应用设置
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
return { showInTray: true };
throw error;
}
},
@@ -224,6 +206,16 @@ export const tauriAPI = {
}
},
// 判断是否为便携模式
isPortable: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_portable_mode");
} catch (error) {
console.error("检测便携模式失败:", error);
return false;
}
},
// 获取应用配置文件路径
getAppConfigPath: async (): Promise<string> => {
try {
@@ -242,6 +234,215 @@ export const tauriAPI = {
console.error("打开应用配置文件夹失败:", error);
}
},
// Claude 插件:获取 ~/.claude/config.json 状态
getClaudePluginStatus: async (): Promise<ConfigStatus> => {
try {
return await invoke<ConfigStatus>("get_claude_plugin_status");
} catch (error) {
console.error("获取 Claude 插件状态失败:", error);
return { exists: false, path: "", error: String(error) };
}
},
// Claude 插件:读取配置内容
readClaudePluginConfig: async (): Promise<string | null> => {
try {
return await invoke<string | null>("read_claude_plugin_config");
} catch (error) {
throw new Error(`读取 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude 插件:应用或移除固定配置
applyClaudePluginConfig: async (options: {
official: boolean;
}): Promise<boolean> => {
const { official } = options;
try {
return await invoke<boolean>("apply_claude_plugin_config", { official });
} catch (error) {
throw new Error(`写入 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude 插件:检测是否已应用目标配置
isClaudePluginApplied: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_claude_plugin_applied");
} catch (error) {
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
}
},
// ours: 第三方/自定义供应商——测速与端点管理
// 第三方/自定义供应商:批量测试端点延迟
testApiEndpoints: async (
urls: string[],
options?: { timeoutSecs?: number },
): Promise<EndpointLatencyResult[]> => {
try {
return await invoke<EndpointLatencyResult[]>("test_api_endpoints", {
urls,
timeout_secs: options?.timeoutSecs,
});
} catch (error) {
console.error("测速调用失败:", error);
throw error;
}
},
// 获取自定义端点列表
getCustomEndpoints: async (
appType: AppType,
providerId: string,
): Promise<CustomEndpoint[]> => {
try {
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
// 兼容不同后端参数命名
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
});
} catch (error) {
console.error("获取自定义端点列表失败:", error);
return [];
}
},
// 添加自定义端点
addCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("add_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("添加自定义端点失败:", error);
// 尽量抛出可读信息
if (error instanceof Error) {
throw error;
} else {
throw new Error(String(error));
}
}
},
// 删除自定义端点
removeCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("remove_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("删除自定义端点失败:", error);
throw error;
}
},
// 更新端点最后使用时间
updateEndpointLastUsed: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("update_endpoint_last_used", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("更新端点最后使用时间失败:", error);
// 不抛出错误,因为这不是关键操作
}
},
// theirs: 导入导出与文件对话框
// 导出配置到文件
exportConfigToFile: async (filePath: string): Promise<{
success: boolean;
message: string;
filePath: string;
}> => {
try {
return await invoke("export_config_to_file", { filePath });
} catch (error) {
throw new Error(`导出配置失败: ${String(error)}`);
}
},
// 从文件导入配置
importConfigFromFile: async (filePath: string): Promise<{
success: boolean;
message: string;
backupId?: string;
}> => {
try {
return await invoke("import_config_from_file", { filePath });
} catch (error) {
throw new Error(`导入配置失败: ${String(error)}`);
}
},
// 保存文件对话框
saveFileDialog: async (defaultName: string): Promise<string | null> => {
try {
const result = await invoke<string | null>("save_file_dialog", { defaultName });
return result;
} catch (error) {
console.error("打开保存对话框失败:", error);
return null;
}
},
// 打开文件对话框
openFileDialog: async (): Promise<string | null> => {
try {
const result = await invoke<string | null>("open_file_dialog");
return result;
} catch (error) {
console.error("打开文件对话框失败:", error);
return null;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
const unlisten = await listen("provider-switched", (event) => {
try {
// 事件 payload 形如 { appType: string, providerId: string }
callback(event.payload as any);
} catch (e) {
console.error("处理 provider-switched 事件失败: ", e);
}
});
return unlisten;
},
};
// 创建全局 API 对象,兼容现有代码

View File

@@ -5,6 +5,8 @@ import { UpdateProvider } from "./contexts/UpdateContext";
import "./index.css";
// 导入 Tauri API自动绑定到 window.api
import "./lib/tauri-api";
// 导入国际化配置
import "./i18n";
// 根据平台添加 body class便于平台特定样式
try {
@@ -23,5 +25,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<UpdateProvider>
<App />
</UpdateProvider>
</React.StrictMode>,
</React.StrictMode>
);

View File

@@ -13,6 +13,8 @@ export interface Provider {
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
meta?: ProviderMeta;
}
export interface AppConfig {
@@ -20,8 +22,33 @@ export interface AppConfig {
current: string;
}
// 自定义端点配置
export interface CustomEndpoint {
url: string;
addedAt: number;
lastUsed?: number;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
}

38
src/utils/errorUtils.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* 从各种错误对象中提取错误信息
* @param error 错误对象
* @returns 提取的错误信息字符串
*/
export const extractErrorMessage = (error: unknown): string => {
if (!error) return "";
if (typeof error === "string") {
return error;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "object") {
const errObject = error as Record<string, unknown>;
const candidate = errObject.message ?? errObject.error ?? errObject.detail;
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
const payload = errObject.payload;
if (typeof payload === "string" && payload.trim()) {
return payload;
}
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
const payloadCandidate =
payloadObj.message ?? payloadObj.error ?? payloadObj.detail;
if (typeof payloadCandidate === "string" && payloadCandidate.trim()) {
return payloadCandidate;
}
}
}
return "";
};

View File

@@ -1,33 +1,164 @@
// 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除
export const updateCoAuthoredSetting = (
jsonString: string,
disable: boolean,
): string => {
try {
const config = JSON.parse(jsonString);
import type { TemplateValueConfig } from "../config/providerPresets";
if (disable) {
// 添加或更新includeCoAuthoredBy字段
config.includeCoAuthoredBy = false;
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
}
deepMerge(target[key], value);
} else {
// 删除includeCoAuthoredBy字段
delete config.includeCoAuthoredBy;
// 直接覆盖非对象字段(数组/基础类型)
target[key] = value;
}
});
return target;
};
return JSON.stringify(config, null, 2);
} catch (err) {
// 如果JSON解析失败返回原始字符串
return jsonString;
const deepRemove = (
target: Record<string, any>,
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
// 只移除完全匹配的嵌套属性
deepRemove(target[key], value);
if (Object.keys(target[key]).length === 0) {
delete target[key];
}
} else if (isSubset(target[key], value)) {
// 只有当值完全匹配时才删除
delete target[key];
}
});
};
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) => isSubset(target[index], item));
}
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 验证JSON配置格式
export const validateJsonConfig = (
value: string,
fieldName: string = "配置",
): string => {
if (!value.trim()) {
return "";
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return `${fieldName}必须是 JSON 对象`;
}
return "";
} catch {
return `${fieldName}JSON格式错误请检查语法`;
}
};
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try {
const config = JSON.parse(jsonString);
return config.includeCoAuthoredBy === false;
config = jsonString ? JSON.parse(jsonString) : {};
} catch (err) {
return {
updatedConfig: jsonString,
error: "配置 JSON 解析失败,无法写入通用配置",
};
}
if (!snippetString.trim()) {
return {
updatedConfig: JSON.stringify(config, null, 2),
};
}
// 使用统一的验证函数
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
if (snippetError) {
return {
updatedConfig: JSON.stringify(config, null, 2),
error: snippetError,
};
}
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
const merged = deepMerge(deepClone(config), snippet);
return {
updatedConfig: JSON.stringify(merged, null, 2),
};
}
const cloned = deepClone(config);
deepRemove(cloned, snippet);
return {
updatedConfig: JSON.stringify(cloned, null, 2),
};
};
// 检查当前配置是否已包含通用配置片段
export const hasCommonConfigSnippet = (
jsonString: string,
snippetString: string,
): boolean => {
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
}
@@ -44,6 +175,51 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
}
};
// 模板变量替换
export const applyTemplateValues = (
config: any,
templateValues: Record<string, TemplateValueConfig> | undefined
): any => {
const resolvedValues = Object.fromEntries(
Object.entries(templateValues ?? {}).map(([key, value]) => {
const resolvedValue =
value.editorValue !== undefined
? value.editorValue
: value.defaultValue ?? "";
return [key, resolvedValue];
})
);
const replaceInString = (str: string): string => {
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
const placeholder = `\${${key}}`;
if (!acc.includes(placeholder)) {
return acc;
}
return acc.split(placeholder).join(value ?? "");
}, str);
};
const traverse = (obj: any): any => {
if (typeof obj === "string") {
return replaceInString(obj);
}
if (Array.isArray(obj)) {
return obj.map(traverse);
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = traverse(value);
}
return result;
}
return obj;
};
return traverse(config);
};
// 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (jsonString: string): boolean => {
try {
@@ -79,3 +255,135 @@ export const setApiKeyInConfig = (
return jsonString;
}
};
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 保存之前的通用配置片段,用于替换操作
let previousCommonSnippet = "";
// 将通用配置片段写入/移除 TOML 配置
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
// 如果片段为空,直接返回原始配置
return {
updatedConfig: tomlString,
};
}
if (enabled) {
// 添加通用配置
// 先移除旧的通用配置(如果有)
let updatedConfig = tomlString;
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
updatedConfig = tomlString.replace(previousCommonSnippet, "");
}
// 在文件末尾添加新的通用配置
// 确保有适当的换行
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
updatedConfig =
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
// 保存当前通用配置片段
previousCommonSnippet = snippetString;
return {
updatedConfig: updatedConfig.trim() + "\n",
};
} else {
// 移除通用配置
if (tomlString.includes(snippetString)) {
const updatedConfig = tomlString.replace(snippetString, "");
// 清理多余的空行
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
// 清空保存的状态
previousCommonSnippet = "";
return {
updatedConfig: cleaned ? cleaned + "\n" : "",
};
}
return {
updatedConfig: tomlString,
};
}
};
// 检查 TOML 配置是否已包含通用配置片段
export const hasTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
): boolean => {
if (!snippetString.trim()) return false;
// 简单检查配置是否包含片段内容
// 去除空白字符后比较,避免格式差异影响
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
return normalizeWhitespace(tomlString).includes(
normalizeWhitespace(snippetString),
);
};
// ========== Codex base_url utils ==========
// 从 Codex 的 TOML 配置文本中提取 base_url支持单/双引号)
export const extractCodexBaseUrl = (
configText: string | undefined | null,
): string | undefined => {
try {
const text = typeof configText === "string" ? configText : "";
if (!text) return undefined;
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
return m && m[2] ? m[2] : undefined;
} catch {
return undefined;
}
};
// 从 Provider 对象中提取 Codex base_url当 settingsConfig.config 为 TOML 字符串时)
export const getCodexBaseUrl = (
provider: { settingsConfig?: Record<string, any> } | undefined | null,
): string | undefined => {
try {
const text =
typeof provider?.settingsConfig?.config === "string"
? (provider as any).settingsConfig.config
: "";
return extractCodexBaseUrl(text);
} catch {
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
export const setCodexBaseUrl = (
configText: string,
baseUrl: string,
): string => {
const trimmed = baseUrl.trim();
if (!trimmed) {
return configText;
}
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(configText)) {
return configText.replace(pattern, replacementLine);
}
const prefix = configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
return `${prefix}${replacementLine}\n`;
};

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

@@ -1,6 +1,6 @@
/// <reference types="vite/client" />
import { Provider, Settings } from "./types";
import { Provider, Settings, CustomEndpoint } from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -28,7 +28,20 @@ declare global {
getClaudeCodeConfigPath: () => Promise<string>;
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
selectConfigFile: () => Promise<string | null>;
getConfigDir: (app?: AppType) => Promise<string>;
saveFileDialog: (defaultName: string) => Promise<string | null>;
openFileDialog: () => Promise<string | null>;
exportConfigToFile: (filePath: string) => Promise<{
success: boolean;
message: string;
filePath: string;
}>;
importConfigFromFile: (filePath: string) => Promise<{
success: boolean;
message: string;
backupId?: string;
}>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>;
@@ -38,8 +51,45 @@ declare global {
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
checkForUpdates: () => Promise<void>;
isPortable: () => Promise<boolean>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// Claude 插件配置能力
getClaudePluginStatus: () => Promise<ConfigStatus>;
readClaudePluginConfig: () => Promise<string | null>;
applyClaudePluginConfig: (options: {
official: boolean;
}) => Promise<boolean>;
isClaudePluginApplied: () => Promise<boolean>;
testApiEndpoints: (
urls: string[],
options?: { timeoutSecs?: number },
) => Promise<Array<{
url: string;
latency: number | null;
status?: number;
error?: string;
}>>;
// 自定义端点管理
getCustomEndpoints: (
appType: AppType,
providerId: string
) => Promise<CustomEndpoint[]>;
addCustomEndpoint: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
removeCustomEndpoint: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
updateEndpointLastUsed: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
};
platform: {
isMac: boolean;