166 Commits

Author SHA1 Message Date
Jason
ea81c3d839 feat: improve i18n implementation with better translations and accessibility
- Add proper i18n keys for language switcher tooltips and aria-labels
- Replace hardcoded Chinese console error messages with i18n keys
- Add missing translation keys for new UI elements
- Improve accessibility with proper aria-label attributes
2025-09-28 20:35:14 +08:00
TinsFox
aa05a8475f feat: integrate i18next for internationalization support
- Added i18next and react-i18next dependencies for localization.
- Updated various components to utilize translation functions for user-facing text.
- Enhanced user experience by providing multilingual support across the application.
2025-09-28 18:23:23 +08:00
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
Jason
7ccef5f385 fix(ui): add dark mode support for Kimi model selector
- Update select dropdown with dark mode background and text colors
- Add dark mode styles to labels and refresh button
- Apply dark mode colors to error messages (red theme)
- Ensure hint box uses consistent amber theme in dark mode
- Fix chevron icon visibility in dark mode

This ensures the Kimi provider configuration UI is properly visible
and maintains consistency with other provider modals in dark mode.
2025-09-13 21:48:18 +08:00
Jason
85ba24f1c3 fix(ui): add dark mode support for provider modals
- Add dark mode styles to ProviderForm modal (background, borders, text)
- Update ApiKeyInput component with dark mode colors
- Add dark mode detection to ClaudeConfigEditor for JSON editor
- Apply dark mode styles to CodexConfigEditor textareas
- Update PresetSelector buttons for dark mode
- Ensure consistent amber color scheme for all hint boxes in dark mode

This ensures proper visibility and readability of add/edit provider dialogs
when dark mode is enabled.
2025-09-13 21:45:34 +08:00
Jason
0d2dedbb6d fix(migration): ensure config directory exists before writing marker
- Create parent directory before checking/writing migration marker
- Prevents runtime error for fresh installations without config directory
- Defensive fix for edge case where ~/.cc-switch/ doesn't exist yet
2025-09-13 21:03:48 +08:00
Jason
d76c675feb docs: update README and screenshots for v3.2.0 release
- Update README with v3.2.0 features and UI improvements
- Replace screenshots with new UI design
- Clarify SSOT architecture and one-time migration details
- Update version badge to 3.2.0
2025-09-13 17:54:21 +08:00
Jason
9372ecd3c6 feat(ui): enhance provider form with brand icons and colors
- Add Save icon to submit buttons in provider forms
- Replace generic Zap icon with brand-specific icons (ClaudeIcon, CodexIcon)
- Update selected state colors: Claude uses brand color #D97757, Codex uses black
- Maintain visual consistency with AppSwitcher component
2025-09-13 17:04:46 +08:00
Jason
d0b654f63e feat(ui): replace generic icons with official brand icons
- Add Claude and ChatGPT/Codex official brand SVG icons
- Create BrandIcons component with proper currentColor support
- Update AppSwitcher to use brand icons with Claude's official color (#D97757)
- Icons now dynamically change color based on active state
- Improve brand recognition and visual consistency
2025-09-13 16:21:15 +08:00
Jason
f035796654 chore(release): polish 3.2.0 changes\n\n- Docs: add 3.2.0 release notes to CHANGELOG\n- UI: silence debug logs in production via import.meta.env.DEV\n- CSS: replace pseudo-element Tailwind @apply with explicit selectors to fix minifier warnings 2025-09-13 15:48:14 +08:00
Jason
160da2729e fix(css): eliminate minifier warnings for scrollbar styles\n\n- Replace Tailwind @apply + custom dark variant on pseudo-elements\n- Use explicit selectors (html.dark) and hex colors for track/thumb\n- Prevent :where() empty selector warnings during production build 2025-09-13 15:38:23 +08:00
Jason
14db6b8a8f refactor(tauri): replace println/eprintln with structured logging\n\n- Use log::info/warn/error instead of println!/eprintln!\n- Keep behavior identical; improve log integration with tauri-plugin-log\n- Reduce stdout noise in production builds 2025-09-13 15:38:01 +08:00
Jason
d91bbb122c refactor(ui): silence debug logs in production\n\n- Wrap dark mode and event logs with import.meta.env.DEV\n- Keep error logging for failures intact\n- Reduce console noise in release builds 2025-09-13 15:37:39 +08:00
Jason
6df5dfc123 style: format codebase with Prettier\n\n- Apply Prettier across src to ensure consistent styling\n- No functional changes; whitespace and ordering only\n- Unblocks format:check for release pipeline 2025-09-13 15:36:43 +08:00
Jason
c8327f7632 feat: add API key links for third-party providers and simplify Kimi model labels
- Add "Get API Key" link support for third-party providers (e.g., PackyCode)
- Simplify Kimi model selector labels by removing technical field names
  - Changed "主模型 (ANTHROPIC_MODEL)" to "主模型"
  - Changed "快速模型 (ANTHROPIC_SMALL_FAST_MODEL)" to "快速模型"
- Improve user experience with cleaner, more intuitive interface labels
2025-09-13 13:23:32 +08:00
Jason
4a0e63d0b7 style: improve "Get API Key" link styling
- Change link color to light blue (text-blue-400)
- Remove arrow symbol from link text
- Apply to domestic official and aggregator provider API key links
2025-09-12 21:36:32 +08:00
Jason
e63b4e069b feat: enhance provider configuration UX with custom URL support and API key links
- Add custom base URL input for custom providers
  - New "Request URL" field appears only in custom mode
  - Automatically syncs with ANTHROPIC_BASE_URL in config
  - Includes helpful amber-styled hint about Claude API compatibility

- Add "Get API Key" links for non-official providers
  - Shows for cn_official, aggregator, and third_party categories
  - Links point to provider's official website
  - Styled as subtle helper text (text-xs, gray-500)
  - Positioned closely under API key input for better visual grouping

- Improve UI consistency and hints
  - Unify all hint boxes to use amber color scheme (amber-50/amber-200/amber-600)
  - Update model placeholders to latest versions (GLM-4.5, GLM-4.5-Air)
  - Simplify provider names (remove version numbers and redundant text)

- Update provider presets
  - GLM models: glm-4-plus → GLM-4.5, glm-4-flash → GLM-4.5-Air
  - Qwen models: qwen-coder-turbo → qwen3-coder-plus
  - Cleaner naming: "Claude官方登录" → "Claude官方", "DeepSeek v3.1" → "DeepSeek"

- Fix Kimi model selector behavior
  - Remove API key requirement for displaying selector
  - Avoid showing duplicate model input fields for Kimi preset
  - Improve hint message clarity
2025-09-12 20:14:59 +08:00
Jason
687c7de111 feat: improve custom provider configuration UX
- Show API key input field for custom mode
- Initialize default custom mode with JSON template on modal open
- Change default API key from placeholder to empty string
- Remove Save icon from submit button for cleaner UI
- Ensure consistent behavior between default and manually selected custom mode
2025-09-12 15:20:49 +08:00
Jason
876605e983 feat: require API key for non-official Claude providers
- Add required asterisk to API key input label for non-official providers
- Pre-fill API key placeholder when switching to custom mode
- Ensure consistent validation across Claude and Codex providers
2025-09-12 12:15:09 +08:00
Jason
442b05507c feat: simplify Claude provider configuration form
- Add optional model input fields (ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL)
- Place model inputs in a single row for better space utilization
- Move website URL field above API configuration section
- Add JSON template for custom mode to guide users
- Simplify field labels and remove redundant descriptions
- Keep JSON editor for advanced configuration flexibility
2025-09-12 12:04:19 +08:00
Jason
eca9c02147 feat(providers): add provider categorization system
- Add ProviderCategory type with official, cn_official, aggregator, third_party, and custom categories
- Update Provider interface and Rust struct to include optional category field
- Enhance ProviderForm to automatically sync category when selecting presets
- Improve PresetSelector to show category-based styling and hints
- Add category classification to all provider presets
- Support differentiated interactions (e.g., hide API key input for official providers)
- Maintain backward compatibility with existing configurations
2025-09-11 22:33:55 +08:00
Jason
9fbce5d0cf refactor(settings): rename Dock setting to system tray (showInDock → showInTray)
- compat: map legacy showInDock to showInTray when loading settings
- ui(copy): clarify “system tray (menu bar)” vs Dock in SettingsModal
- tauri(settings): return showInTray in get_settings; adjust default fallback
- docs(comment): align comments to “system tray” terminology across code
- note: no functional change yet; tray visibility toggle remains unimplemented
2025-09-11 20:20:27 +08:00
farion1231
c597b9b122 feat(ui): add "up-to-date" feedback for update check button
- Show green "Already up-to-date" state with check icon when no updates available
- Button changes color and text temporarily (3 seconds) to provide clear feedback
- Fix TypeScript type for checkUpdate to return Promise<boolean>
- Handle dev mode gracefully - show up-to-date instead of opening release page
- Simplify previous complex notification UI to inline button state change
2025-09-11 15:13:33 +08:00
Jason
54b88d9c89 refactor(ui): redesign update notification to match Linear design system
- Replace gradient background with solid colors and subtle borders
- Remove decorative Info icon from settings panel that had no functionality
- Change update badge icon from Sparkles to Download for better clarity
- Simplify update button states with consistent blue primary color
- Remove all gradient effects in favor of flat design
- Unify hover states and transitions across all update-related UI

The update notification now seamlessly integrates with the app's Linear-inspired
aesthetic, providing a clean and non-intrusive user experience.
2025-09-11 12:06:49 +08:00
Jason
319e5fa61a build(rust): optimize release binary size (thin LTO, strip symbols, s-level, codegen-units=1, panic=abort)\n\n- Helps reduce final AppImage size by trimming Rust binary 2025-09-11 10:17:35 +08:00
Jason
310086d5c9 ci(release): refine artifacts\n\n- macOS: add .app zip (plus tar.gz + .sig for updater)\n- Windows: only MSI + .sig; add portable zip\n- Linux: always include .deb; keep AppImage + .sig for updater 2025-09-11 10:17:35 +08:00
Jason
4297703ebe ci(release): pin runners for compatibility and update cache action\n\n- matrix: windows-2022 / ubuntu-22.04 / macos-14\n- assemble-latest-json runs on ubuntu-22.04\n- actions/cache bumped to v4 2025-09-11 09:33:49 +08:00
Jason
ca7ce99702 chore(version): bump app version to 3.2.0\n\n- package.json → 3.2.0\n- src-tauri/Cargo.toml → 3.2.0\n- src-tauri/tauri.conf.json → 3.2.0\n- src-tauri/Cargo.lock → 3.2.0 2025-09-11 09:33:49 +08:00
Jason
af8b9289fe feat(updater): 优化更新体验与 UI
- ui: UpdateBadge 使用 Tailwind 内置过渡,支持点击打开设置,保留图标动画

- updater: 新增 UpdateContext 首启延迟检查,忽略版本键名命名空间化(含旧键迁移),并发保护

- settings: 去除版本硬编码回退;检测到更新时复用 updateHandle 下载并安装,并新增常显“更新日志”入口

- a11y: 更新徽标支持键盘触达(Enter/Space)

- refactor: 移除未使用的 runUpdateFlow 导出

- chore: 类型检查通过,整体行为与权限边界未改变
2025-09-10 19:46:38 +08:00
Jason
bf7e13d4e9 chore: update repository URLs from jasonyoung to farion1231
- Update GitHub repository URLs in README.md badges
- Update repository URL in Cargo.toml metadata
- Update updater endpoint URL in tauri.conf.json for auto-updates
2025-09-10 15:26:21 +08:00
Jason
b015af173a feat(updater): refactor release workflow for proper Tauri updater support
- Switch macOS packaging from .app to .tar.gz updater artifacts
- Add automatic latest.json generation workflow
- Include signature file handling for all platforms
- Remove manual latest.json in favor of automated generation
- Improve updater artifact detection and path handling
2025-09-10 09:20:14 +08:00
Jason
4a4779a7e7 enhance(ci): improve release workflow with concurrency control and updated dependencies
- Add concurrency control to prevent multiple simultaneous releases
- Upgrade softprops/action-gh-release from v1 to v2 for better reliability
- Add docs/ directory to .gitignore to exclude documentation build artifacts
2025-09-10 08:10:01 +08:00
Jason
92a39a1a34 enhance(ci): implement cross-platform base64 encoding for private key
- Add support for multiple base64 encoders (base64, openssl, node.js)
- Encode complete private key file content as single-line base64
- Implement fallback chain for maximum platform compatibility
- Simplify environment variable handling with encoded content
2025-09-10 07:05:02 +08:00
Jason
ea56794a37 refactor(ci): use complete private key content with heredoc syntax
- Switch to passing complete two-line private key content instead of base64 only
- Use GitHub Actions heredoc syntax (<<'EOF') for proper multiline handling
- Preserve original minisign private key format with comment and base64 lines
- Improve compatibility with Tauri CLI's private key parsing
2025-09-09 22:52:34 +08:00
Jason
fd4864115c enhance(ci): improve Tauri signing key handling with direct base64 content
- Switch from file path to direct base64 content for better compatibility
- Extract private key base64 from second line for stable parsing
- Enhance error handling for key extraction process
- Improve cross-version compatibility for different Tauri CLI versions
2025-09-09 22:40:26 +08:00
Jason
74d4b42936 refactor(ci): standardize Tauri signing key variable and update pubkey
- Update CI workflow to use TAURI_SIGNING_PRIVATE_KEY consistently
- Simplify key handling logic and add password support
- Update pubkey in tauri.conf.json to match new signing key
2025-09-09 22:26:37 +08:00
Jason
a95f974787 refactor(ci): simplify Tauri signing key handling to use file path only
- Remove redundant environment variables for key content export
- Focus on providing proper key file path to Tauri CLI to avoid decoding ambiguity
- Maintain support for all three key formats (two-line, base64-wrapped, single base64)
- Improve reliability by standardizing on file-based key passing approach
2025-09-09 21:50:37 +08:00
Jason
29057c1fe0 refactor: optimize key handling to export base64 line only with multiple env var formats 2025-09-09 21:38:24 +08:00
Jason
63285acba8 enhance: improve private key handling with better base64 compatibility and single-line support 2025-09-09 21:21:30 +08:00
Jason
f99b614888 enhance: improve updater workflow with latest.json collection and prerelease flag 2025-09-09 21:14:44 +08:00
Jason
41f3aa7d76 fix: correct Tauri signing key environment variable usage 2025-09-09 16:38:59 +08:00
Jason
f23898a5c9 fix: correct GitHub Secret name to TAURI_PRIVATE_KEY 2025-09-09 16:29:42 +08:00
Jason
664391568c fix: add debug output for GitHub Secret issues 2025-09-09 16:28:12 +08:00
Jason
081aabe10f fix: add Tauri signing key decoding in GitHub Actions 2025-09-09 16:25:49 +08:00
Jason
036069a5c1 update pub key again 2025-09-09 16:06:14 +08:00
Jason
9b7091ba88 update pub key 2025-09-09 15:36:04 +08:00
Jason
2357d976dc chore: bump version to 3.1.2 for auto-updater testing 2025-09-09 15:21:49 +08:00
Jason Young
df43692bb9 Merge pull request #16 from farion1231/feat/auto-update
feat: Add auto-updater support with GitHub releases
2025-09-09 15:16:19 +08:00
Jason
6ed9cf47df feat: add auto-updater support with GitHub releases
- Configure Tauri updater plugin with Ed25519 signing
- Add GitHub Actions support for signed builds
- Set up GitHub releases as update endpoint
- Enable update checking in Settings modal
2025-09-09 15:13:06 +08:00
Jason
a3582f54e9 fix(updater): 避免因 Updater 配置不完整导致应用启动中止\n\n- Updater 插件注册改为容错(失败仅告警,不影响应用启动) 2025-09-09 10:28:34 +08:00
Jason
9ff7516c51 feat(updater): 集成 Rust 侧 Updater/Process 插件与权限配置
- Cargo 依赖:tauri-plugin-updater、tauri-plugin-process
- 插件注册:process 顶层 init,updater 于 setup 中注册
- 权限配置:capabilities 增加 updater:default、process:allow-restart
- 配置:tauri.conf.json 启用 createUpdaterArtifacts 与 updater 占位(pubkey/endpoints)
2025-09-08 16:58:41 +08:00
Jason
a1a16be2aa feat(updater): 前端 Updater 封装与设置页接入\n\n- 新增 src/lib/updater.ts(check→download→install→relaunch 封装与类型)\n- SettingsModal 按钮优先走 Updater,失败回退打开 Releases\n- 依赖:@tauri-apps/plugin-updater、@tauri-apps/plugin-process\n- 文档:精简并重写 docs/updater-plan.md(明确接口/流程/配置) 2025-09-08 16:48:24 +08:00
Jason
df7d818514 feat(ui): enhance dark mode styling support
- AppSwitcher: add dark mode backgrounds, borders and hover effects
- ConfirmDialog: apply dark theme colors to dialog elements
- ProviderList: update list items and buttons for dark mode
- Global styles: use color-scheme property for native controls theming

Ensures all interactive components have proper visual feedback and consistent UX in dark mode.
2025-09-08 15:57:29 +08:00
Jason
c0d9d0296d feat(ui): implement dark mode with system preference support
- Add useDarkMode hook for managing theme state and persistence
- Integrate dark mode toggle button in app header
- Update all components with dark variant styles using Tailwind v4
- Create centralized style utilities for consistent theming
- Support system color scheme preference as fallback
- Store user preference in localStorage for persistence
2025-09-08 15:38:06 +08:00
Jason
77a65aaad8 refactor(styles): migrate from CSS variables to Tailwind classes
- Replace all CSS custom properties with Tailwind utility classes
- Add tailwind.config.js with custom color palette matching Linear design
- Reduce index.css from 89 to 37 lines (58% reduction)
- Maintain consistent visual appearance with semantic color usage
- Update all components to use Tailwind classes instead of CSS variables
2025-09-08 11:48:05 +08:00
Jason
3ce847d2e0 refactor(ui): remove config file path display module
Remove the configuration file path information section from the main page footer, including:
- configStatus state and related functions
- loadConfigStatus function
- handleOpenConfigFolder function
- UI component showing config file path and folder open button

This simplifies the interface while preserving all core functionality.
2025-09-07 22:41:55 +08:00
Jason
1482dc9e66 feat(providers): add timestamp-based sorting for provider list
- Add createdAt timestamp field to Provider interface
- Implement sorting logic: old providers without timestamp first, then by creation time
- Providers without timestamps are sorted alphabetically by name
- New providers are automatically timestamped on creation
2025-09-07 22:29:08 +08:00
Jason
fa2b11fcc2 fix(settings): update version display and comment out incomplete dock settings
- Fix version display to use actual app version (3.1.1) from Tauri API
- Comment out dock display settings as feature is not yet implemented
- Update GitHub releases URL from yungookim to farion1231
2025-09-07 22:14:17 +08:00
Jason
02bfc97ee6 refactor(types): introduce Settings and apply in API
- style(prettier): format src files
- style(rustfmt): format Rust sources
- refactor(tauri-api): type-safe getSettings/saveSettings
- refactor(d.ts): declare window.api with Settings

[skip ci]
2025-09-07 11:36:09 +08:00
Jason
77bdeb02fb feat(settings): add minimal settings panel
- Add settings icon button next to app title
- Create SettingsModal component with:
  - Show in Dock option (macOS)
  - Version info and check for updates button
  - Config file location with open folder button
- Add settings-related APIs in tauri-api
- Update type definitions for new API methods
2025-09-07 10:48:27 +08:00
Jason
48bd37a74b feat(ui): unify link/address styles and set primary header color
- Links use primary color; removed leading icon
- Show API address when website is missing; use secondary text color, non-monospace
- Parse base_url from Codex TOML; change fallback copy to "未配置官网地址"
- Use primary color for the top-left header title
- Clean up unused imports

Affected files:
- src/components/ProviderList.tsx
- src/App.tsx
2025-09-06 23:57:10 +08:00
TinsFox
7346fcde2c feat: refactor ProviderForm component with new subcomponents (#13)
* feat: refactor ProviderForm component with new subcomponents

- Introduced PresetSelector, ApiKeyInput, ClaudeConfigEditor, and CodexConfigEditor for improved modularity and readability.
- Simplified preset selection logic for both Claude and Codex configurations.
- Enhanced API Key input handling with dedicated components for better user experience.
- Removed redundant code and improved state management in the ProviderForm component.

* feat: add Kimi model selection to ProviderForm component

- Introduced KimiModelSelector for enhanced model configuration options.
- Implemented state management for Kimi model selection, including initialization and updates based on preset selection.
- Improved user experience by conditionally displaying the Kimi model selector based on the selected preset.
- Refactored related logic to ensure proper handling of Kimi-specific settings in the ProviderForm.

* feat: enhance API Key input and model selection in ProviderForm

- Added toggle functionality to show/hide API Key in ApiKeyInput component for improved user experience.
- Updated placeholder text in ProviderForm to provide clearer instructions based on the selected preset.
- Enhanced KimiModelSelector to display a more informative message when API Key is not provided.
- Refactored provider presets to remove hardcoded API Key values for better security practices.

* fix(kimi): optimize debounce implementation in model selector

- Fix initial state: use empty string instead of apiKey.trim()
- Refactor fetchModels to fetchModelsWithKey with explicit key parameter
- Ensure consistent behavior between auto-fetch and manual refresh
- Eliminate mental overhead from optional parameter fallback logic

* fix(api-key): remove custom masking logic, use native password input

- Remove getDisplayValue function with custom star masking
- Use native browser password input behavior for better UX consistency
- Simplify component logic while maintaining show/hide toggle functionality

* chore: format code with prettier

- Apply consistent code formatting across all TypeScript files
- Fix indentation and spacing according to project style guide

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-06 23:13:01 +08:00
TinsFox
5af476d376 feat: 系统托盘 (#12)
* feat: 系统托盘

1. 添加系统托盘
2. 托盘添加切换供应商功能
3. 整理组件目录

* feat: 优化系统托盘菜单结构

- 扁平化Claude和Codex的菜单结构,直接将所有供应商添加到主菜单,简化用户交互。
- 添加无供应商时的提示信息,提升用户体验。
- 更新分隔符文本以增强可读性。

* feat: integrate Tailwind CSS and Lucide icons

- Added Tailwind CSS for styling and layout improvements.
- Integrated Lucide icons for enhanced UI elements.
- Updated project structure by removing unused CSS files and components.
- Refactored configuration files to support new styling and component structure.
- Introduced new components for managing providers with improved UI interactions.   

* fix: 修复类型声明和分隔符实现问题

- 修复 updateTrayMenu 返回类型不一致(Promise<void> -> Promise<boolean>)
- 添加缺失的 UnlistenFn 类型导入
- 使用 MenuBuilder.separator() 替代文本分隔符

---------

Co-authored-by: farion1231 <farion1231@gmail.c
2025-09-06 16:21:21 +08:00
farion1231
07b870488d fix: add .gitattributes to resolve line ending inconsistencies
- Configure unified line ending rules (LF)
- Fix issue where Cargo.toml always shows as modified on Windows
2025-09-06 11:28:58 +08:00
Jason
74ab14f572 feat: add ModelScope provider preset with GLM-4.5 model configuration 2025-09-06 11:01:09 +08:00
QuentinHsu
a14f7ef9b2 feat: JsonEditor for inputting JSON content (#4)
* feat(editor): add JsonEditor component for JSON configuration editing

* fix(provider): update API Key visibility logic in ProviderForm component

* fix(editor): stabilize JsonEditor height and restore API Key logic

- Revert API Key visibility to preset-based rule and injection to non-custom preset only.
- Reduce JsonEditor min-height from rows*22px to rows*18px to lessen layout jitter when switching presets.
- Keep fonts/size consistent with the previous textarea for visual parity.

* - fix(form): remove websiteUrl auto-extraction from JSON to prevent incorrect overrides

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-06 09:30:09 +08:00
Jason Young
07dd70570e Merge pull request #10 from farion1231/feat/ssot
feat(ssot): 首次迁移副本入库 + 切换回填不丢数据 + Codex 原子写入回滚
2025-09-05 21:42:11 +08:00
Jason
37d4c9b48d style: format frontend code and improve component structure 2025-09-05 21:26:01 +08:00
Jason
da4f7b5fe4 refactor(codex): unify TOML handling with robust error recovery
- Extract common TOML read/validation logic into dedicated helper functions
- Add graceful degradation for corrupted config files during migration
- Centralize Codex config.toml processing to ensure consistency across operations
- Improve error handling: log warnings for invalid files but continue processing
- Eliminate code duplication between migration, import, and runtime operations

This makes the system more resilient to user configuration issues while maintaining data integrity through unified validation logic.
2025-09-05 21:03:11 +08:00
Jason
e119d1cb31 refactor(concurrency): optimize error handling and reduce lock contention
- Fix operation order: write live files first, then save config.json for consistency
- Implement short-lock pattern: read state quickly, release lock before I/O operations
- Add atomic Codex dual-file writes with rollback on failure
- Simplify add_provider and update_provider logic with consistent structure
- Remove unnecessary duplicate code and improve error handling reliability

This ensures data consistency when operations fail and significantly improves concurrency by minimizing lock holding time during file I/O.
2025-09-05 20:52:08 +08:00
Jason
54003d69e2 refactor(archive): remove archive on provider switches/updates
- Remove file archiving when switching providers or updating current provider
- Keep archive functionality only for initial migration (one-time operation)
- Retain simple .bak backup for config.json on each save
- Simplify code by removing unnecessary archive operations from daily workflows

This change prevents unlimited archive growth while maintaining data safety through:
1. Initial migration archives for historical data preservation
2. Single .bak file for basic rollback capability
3. All provider configs stored in SSOT (config.json)
2025-09-05 16:39:12 +08:00
Jason
ab6be1d510 docs(cleanup): remove 'current' as special provider; align UI/messages and migration naming to 'default' and one-time import rule
- App: update auto-import message to '默认供应商'
- README: clarify default import only when providers are empty
- Plan doc: replace 'current entry' wording with current pointer (manager.current)
- Migration: name live-imported item 'default' instead of 'current'
2025-09-05 15:16:03 +08:00
Jason
a1dfdf4e68 refactor(provider): remove runtime duplicate checks in add/update; dedupe only during initial migration per product decision 2025-09-05 15:11:20 +08:00
Jason
464ca70d7b feat(init): only import from live when providers are empty; create a single 'default' entry and set as current
- Remove special handling for 'current' entry
- Import default only when manager.providers is empty
- Name/id the initial entry 'default' and mark it current
2025-09-05 15:07:00 +08:00
Jason
2dca85c881 refactor(migration): run dedupe only during first-time migration; remove startup dedupe
- Remove per-startup dedupe; keep it limited to migration
- Call dedupe at end of migrate_copies_into_config, then write marker
- Avoid unintended changes on every app launch
2025-09-05 14:48:03 +08:00
Jason
837435223a chore(migration): remove legacy copy files after successful archive
- During copies→config migration, delete original legacy files only if archived
- Applies to Claude settings-* and Codex auth-*/config-* pairs
- Keep archive_file non-destructive for live backups
2025-09-05 14:29:16 +08:00
Jason
79ad0b9368 fix(provider): prevent case-insensitive name + key duplicates; migrate and startup dedupe
- Use case-insensitive compare for name + API key in migration
- Add runtime uniqueness checks in add/update commands
- Startup dedupe prefers current provider; archives others
- Keep original display casing; only normalize for comparisons
- Validate Codex config.toml as before; archive before overwrite
2025-09-05 14:26:11 +08:00
Jason
5624a2d11a feat(tauri): sync current provider edits to live config and archive before overwrite\n\n- Update: when editing the current provider, persist changes to live files (Claude settings.json; Codex auth.json + config.toml) and archive previous versions first.\n- Align add_provider behavior: archive live files before overwriting if the added provider is current.\n- Hardening: when deleting a Claude provider, also attempt removing legacy copy path by id (settings-{id}.json).\n\nThis keeps switching, adding and editing consistent with SSOT design and improves safety via archival. 2025-09-05 11:00:53 +08:00
Jason
29367ff576 refactor(rust): remove unused code/imports to silence warnings\n\n- Drop unused backup/import helpers and redundant imports\n- Remove unnecessary mut parameter\n- Remove unused ProviderManager::add_provider\n 2025-09-05 10:19:14 +08:00
Jason
64c94804ee chore(rust): revert Cargo edition to 2021\n\n- Lower edition from "2024" to "2021" for broader toolchain compatibility\n- Keep rust-version and deps unchanged\n 2025-09-05 10:19:14 +08:00
Jason
1d9fb7bf26 fix(tauri): correct window config and CSP\n\n- Set titleBarStyle to "Transparent" to match v2 schema\n- Add ipc: and http://ipc.localhost to connect-src for IPC\n- Add label: "main" for the primary window\n 2025-09-05 10:19:14 +08:00
Jason
30a441d9ec refactor(migration): dedupe by (name + raw key) without hashing\n\n- Compare API key strings directly for Claude/Codex during migration\n- Remove sha2/hex deps and hashing helpers\n- Keep O(N^2) matching acceptable for small provider sets 2025-09-04 23:00:16 +08:00
Jason
33753c72cd docs: update plan to use 'current' instead of 'default' for initial import\n\n- Aligns documentation with implementation across migration and import flows 2025-09-04 22:39:03 +08:00
Jason
02d7eca2ad refactor(import): rename default imported provider to 'current'\n\n- import_default_config now creates provider id/name 'current'\n- Avoid duplicate import by checking 'current' key\n- Set manager.current to 'current' when empty 2025-09-04 21:38:35 +08:00
Jason
2c6fe6c31a chore(migration): use 'current' as the name for live-imported providers\n\n- Rename live-imported provider name from 'default' to 'current' for Claude/Codex\n- Keeps current selection logic intact when setting manager.current 2025-09-04 21:35:51 +08:00
Jason
ab71b11532 feat(migration-live): import current live settings on first run and set as current if empty\n\n- Read Claude ~/.claude/settings.json and Codex ~/.codex/auth.json + config.toml\n- Merge with priority: live > copies > existing\n- Set manager.current to the live-imported provider when empty 2025-09-04 21:33:19 +08:00
Jason
a858596fa2 feat(edit-current): allow editing current provider and sync to live on save\n\n- Enable Edit button for current provider in UI\n- On update_provider, if updating current provider, write changes to live config (Claude/Codex)\n- Maintain SSOT in cc-switch/config.json with atomic writes 2025-09-04 16:34:47 +08:00
Jason
5176134c28 feat(migration): import provider copy files into cc-switch config on first run\n\n- Scan Claude settings-*.json and Codex auth-*.json/config-*.toml\n- Merge into ~/.cc-switch/config.json, de-dupe by provider name (override with copies)\n- Backup old config.json and archive all scanned copy files\n- Add migration marker to avoid re-importing\n- Stop writing provider copy files in ProviderManager::add_provider 2025-09-04 16:16:51 +08:00
Jason
79370dd8a1 feat(backup): archive current live config before switching\n\n- Archive Claude settings.json and Codex auth.json/config.toml to ~/.cc-switch/archive/<ts>\n- Preserve user edits while centralizing SSOT in cc-switch config.json\n- Uses atomic writes for all subsequent updates 2025-09-04 16:07:38 +08:00
Jason
3c32f12152 refactor(ssot): stop writing per-provider backup files on add/update/import\n\n- Add/update no longer write vendor copy files for Claude and Codex\n- Keep all state in memory (persisted via app config) to enforce SSOT\n- Import default provider now only reads from live config 2025-09-04 16:00:45 +08:00
Jason
64f7e47b20 feat(fs): atomic writes for JSON and TOML saves\n\n- Introduce atomic_write utility and use it in write_json_file\n- Add write_text_file for TOML/strings and use in Codex paths\n- Reduce risk of partial writes and ensure directory creation 2025-09-04 16:00:19 +08:00
Jason
25c112856d feat(ssot): backfill live config and write from in-memory settings during switch\n\n- Stop relying on provider backup files for switching (Claude/Codex)\n- Backfill current live config into the active provider before switching\n- Write target provider settings directly to app main config\n- Avoid writing provider copies when importing default provider 2025-09-04 15:59:28 +08:00
farion1231
3665a79e50 chore: bump version to v3.1.1
- Update version in package.json, Cargo.toml, and tauri.conf.json
- Add CHANGELOG entries for v3.1.0 and v3.1.1
2025-09-03 16:43:29 +08:00
farion1231
4dce31aff7 Fix the default codex config.toml to match the latest modifications. 2025-09-03 16:33:12 +08:00
Jason
451ca949ec feat(ui): improve provider configuration UX with custom option
- Add explicit "Custom" button in preset selection
- Set "Custom" as default selection when adding new provider
- Update label from "One-click import" to "Choose configuration type"
- Add contextual hints for different configuration modes:
  - Custom mode: "Manually configure provider, complete configuration required"
  - Official preset: "Official login, no API Key required"
  - Other presets: "Use preset configuration, only API Key required"
- Remove redundant "(optional)" text from Codex config.toml hint
- Improve clarity for users who were confused about adding custom providers
2025-09-03 15:58:02 +08:00
Jason
a9ff8ce01c update readme 2025-09-01 15:33:24 +08:00
Jason Young
7848248df7 Merge pull request #3 from farion1231/codex-adaptation
feat(codex): 支持 Codex 供应商管理与一键切换;迁移前自动备份
2025-09-01 11:41:31 +08:00
74 changed files with 9437 additions and 2246 deletions

38
.gitattributes vendored Normal file
View File

@@ -0,0 +1,38 @@
# Auto detect text files and perform LF normalization
* text=auto
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.rs text eol=lf
*.toml text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.txt text eol=lf
# TypeScript/JavaScript files
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
# HTML/CSS files
*.html text eol=lf
*.css text eol=lf
*.scss text eol=lf
# Shell scripts
*.sh text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.exe binary
*.dll binary

View File

@@ -8,15 +8,19 @@ on:
permissions:
contents: write
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: true
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-latest
- os: ubuntu-latest
- os: macos-latest
- os: windows-2022
- os: ubuntu-22.04
- os: macos-14
steps:
- name: Checkout
@@ -74,7 +78,7 @@ jobs:
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -83,6 +87,62 @@ jobs:
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Prepare Tauri signing key
shell: bash
run: |
# 调试:检查 Secret 是否存在
if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then
echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2
echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2
exit 1
fi
RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
# 目标:提供正确的私钥“文件路径”给 Tauri CLI避免内容解码歧义
KEY_PATH="$RUNNER_TEMP/tauri_signing.key"
# 情况 1原始两行文本第一行以 "untrusted comment:" 开头)
if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then
printf '%s\n' "$RAW" > "$KEY_PATH"
echo "✅ 使用原始两行密钥文件格式"
else
# 情况 2整体被 base64 包裹(解包后应当是两行)
if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \
&& echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then
printf '%s\n' "$DECODED" > "$KEY_PATH"
echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件"
else
# 情况 3已是第二行纯 Base64 一行)→ 构造两行文件
if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then
ONE=$(printf '%s' "$RAW" | tr -d '\r\n')
printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH"
echo "✅ 使用一行 Base64 私钥,已构造两行文件"
else
echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64亦非一行 base64" >&2
echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2
exit 1
fi
fi
fi
# 将“完整两行内容”作为环境变量注入Tauri 支持传入完整私钥文本或文件路径)
# 使用多行写入语法,保持换行以便解析
# 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量
if command -v base64 >/dev/null 2>&1; then
KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n')
elif command -v openssl >/dev/null 2>&1; then
KEY_B64=$(openssl base64 -A -in "$KEY_PATH")
else
KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))")
fi
if [ -z "$KEY_B64" ]; then
echo "❌ 无法生成私钥 base64 内容" >&2
exit 1
fi
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV
fi
echo "✅ Tauri signing key prepared"
- name: Build Tauri App (macOS)
if: runner.os == 'macOS'
run: pnpm tauri build --target universal-apple-darwin
@@ -101,29 +161,34 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
echo "Looking for .app bundle..."
APP_PATH=""
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
TAR_GZ=""; APP_PATH=""
for path in \
"src-tauri/target/release/bundle/macos" \
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
"src-tauri/target/release/bundle/macos"; do
if [ -d "$path" ]; then
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
[ -n "$APP_PATH" ] && break
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
fi
done
if [ -z "$APP_PATH" ]; then
echo "No .app found" >&2
if [ -z "$TAR_GZ" ]; then
echo "No macOS .tar.gz updater artifact found" >&2
exit 1
fi
APP_DIR=$(dirname "$APP_PATH")
APP_NAME=$(basename "$APP_PATH")
cd "$APP_DIR"
# 使用 ditto 打包更兼容资源分叉
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready"
cp "$TAR_GZ" release-assets/
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
if [ -n "$APP_PATH" ]; then
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
cd "$APP_DIR"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: CC-Switch-macOS.zip"
else
echo "No .app found to zip (optional)" >&2
fi
- name: Prepare Windows Assets
if: runner.os == 'Windows'
@@ -131,18 +196,27 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
# 安装器(优先 NSIS其次 MSI
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
Select-Object -First 1
if ($null -ne $installer) {
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
Copy-Item $installer.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
} else {
Write-Warning 'No Windows installer found'
# 仅打包 MSI 安装器 + .sig用于 Updater
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $msi) {
# 兜底:全局搜索 .msi
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
}
# 绿色版portable仅可执行文件
if ($null -ne $msi) {
$dest = 'CC-Switch-Setup.msi'
Copy-Item $msi.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
$sigPath = "$($msi.FullName).sig"
if (Test-Path $sigPath) {
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
Write-Host "Signature copied: $dest.sig"
} else {
Write-Warning "Signature not found for $($msi.Name)"
}
} else {
Write-Warning 'No Windows MSI installer found'
}
# 绿色版portable仅可执行文件打 zip不参与 Updater
$exeCandidates = @(
'src-tauri/target/release/cc-switch.exe',
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
@@ -152,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'
@@ -165,14 +245,22 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
# 仅上传安装包deb
# Updater artifact: AppImage含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
cp "$APPIMAGE" release-assets/
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
echo "AppImage copied"
else
echo "No AppImage found under target/release/bundle" >&2
fi
# 额外上传 .deb用于手动安装不参与 Updater
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
cp "$DEB" release-assets/
echo "Deb package copied"
else
echo "No .deb found" >&2
exit 1
echo "No .deb found (optional)"
fi
- name: List prepared assets
@@ -180,11 +268,19 @@ jobs:
run: |
ls -la release-assets || true
- name: Collect Signatures
shell: bash
run: |
set -euo pipefail
echo "Collected signatures (if any alongside artifacts):"
ls -la release-assets/*.sig || echo "No signatures found"
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: CC Switch ${{ github.ref_name }}
prerelease: true
body: |
## CC Switch ${{ github.ref_name }}
@@ -193,7 +289,7 @@ jobs:
### 下载
- macOS: `CC-Switch-macOS.zip`(解压即用)
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
- Windows: `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
- Linux: `*.deb`Debian/Ubuntu 安装包)
---
@@ -208,3 +304,92 @@ jobs:
run: |
echo "Listing bundles in src-tauri/target..."
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
assemble-latest-json:
name: Assemble latest.json
runs-on: ubuntu-22.04
needs: release
permissions:
contents: write
steps:
- name: Prepare GH
run: |
gh --version || (type -p curl >/dev/null && sudo apt-get update && sudo apt-get install -y gh || true)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download all release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF_NAME}"
mkdir -p dl
gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY"
ls -la dl || true
- name: Generate latest.json
env:
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
base_url="https://github.com/$REPO/releases/download/$TAG"
# 初始化空平台映射
mac_url=""; mac_sig=""
win_url=""; win_sig=""
linux_url=""; linux_sig=""
shopt -s nullglob
for sig in dl/*.sig; do
base=${sig%.sig}
fname=$(basename "$base")
url="$base_url/$fname"
sig_content=$(cat "$sig")
case "$fname" in
*.tar.gz)
# 视为 macOS updater artifact
mac_url="$url"; mac_sig="$sig_content";;
*.AppImage|*.appimage)
linux_url="$url"; linux_sig="$sig_content";;
*.msi|*.exe)
win_url="$url"; win_sig="$sig_content";;
esac
done
# 构造 JSON仅包含存在的目标
tmp_json=$(mktemp)
{
echo '{'
echo " \"version\": \"$VERSION\",";
echo " \"notes\": \"Release $TAG\",";
echo " \"pub_date\": \"$PUB_DATE\",";
echo ' "platforms": {'
first=1
if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then
# 为兼容 arm64 / x64重复写入两个键指向同一 universal 包
for key in darwin-aarch64 darwin-x86_64; do
[ $first -eq 0 ] && echo ','
echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}"
first=0
done
fi
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
first=0
fi
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
first=0
fi
echo ' }'
echo '}'
} > "$tmp_json"
echo "Generated latest.json:" && cat "$tmp_json"
mv "$tmp_json" latest.json
- name: Upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euxo pipefail
gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"

View File

@@ -5,6 +5,90 @@ 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.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
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
### 🔧 Improvements
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
- Add a `created_at` timestamp to provider records for future sorting and analytics
### 🐛 Fixes
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
- Bundle `@codemirror/lint` to reinstate live linting in config editors
## [3.2.0] - 2025-09-13
### ✨ New Features
- System tray provider switching with dynamic menu for Claude/Codex
- Frontend receives `provider-switched` events and refreshes active app
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
### 🔧 Improvements
- Single source of truth for provider configs; no duplicate copy files
- One-time migration imports existing copies into `config.json` and archives originals
- Duplicate provider de-duplication by name + API key at startup
- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure
- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints
- Tailwind v4 integration and refined dark mode handling
### 🐛 Fixes
- Remove/minimize debug console logs in production builds
- Fix CSS minifier warnings for scrollbar pseudo-elements
- Prettier formatting across codebase for consistent style
### 📦 Dependencies
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
### 🔄 Notes
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
## [3.1.1] - 2025-09-03
### 🐛 Bug Fixes
- Fixed the default codex config.toml to match the latest modifications
- Improved provider configuration UX with custom option
### 📝 Documentation
- Updated README with latest information
## [3.1.0] - 2025-09-01
### ✨ New Features
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
- Manage auth.json and config.toml for Codex
- Support for backup and restore operations
- Preset providers for Codex (Official, PackyCode)
- API Key auto-write to auth.json when using presets
- **New UI components**
- App switcher with segmented control design
- Dual editor form for Codex configuration
- Pills-style app switcher with consistent button widths
- **Enhanced configuration management**
- Multi-app config v2 structure (claude/codex)
- Automatic v1→v2 migration with backup
- OPENAI_API_KEY validation for non-official presets
- TOML syntax validation for config.toml
### 🔧 Technical Improvements
- Unified Tauri command API with app_type parameter
- Backward compatibility for app/appType parameters
- Added get_config_status/open_config_folder/open_external commands
- Improved error handling for empty config.toml
### 🐛 Bug Fixes
- Fixed config path reporting and folder opening for Codex
- Corrected default import behavior when main config is missing
- Fixed non_snake_case warnings in commands.rs
## [3.0.0] - 2025-08-27
### 🚀 Major Changes

View File

@@ -1,23 +1,27 @@
# Claude Code 供应商切换器
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/)
[![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> **v3.0.0 重大更新**:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍!
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。
## 功能特性
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
- 一键切换不同供应商
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- 支持添加自定义供应商
- 随时切换官方登录
- 简洁美观的图形界面
- 信息存储在本地 ~/.cc-switch/config.json无隐私风险
- 超小体积 - 仅 ~5MB 安装包
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.3.0
- **VS Code Codex 插件一键配置**:供应商卡片支持「应用到 VS Code / 从 VS Code 移除」,默认开启自动同步,并可跨 Code / Insiders / VSCodium 写入 `settings.json`
- **通用配置片段**Claude 与 Codex 共用 JSON/TOML 片段,提供编辑器 lint、内容校验、统一错误提示与本地持久化
- **Codex 配置向导**:新增显示名称、专用 API Key URL、HTML5 校验与预设模板,方便快速配置第三方服务
- **系统托盘与快捷操作**:窗口隐藏时仍可通过托盘切换供应商,并在自动同步开启时触发 VS Code 写入
- **平台适配**:新增 Windows WSL 环境支持、Linux 自动禁用模态背景模糊解决白屏问题、macOS Dock 点击即可恢复窗口
- **UI优化**:多处 UI 和使用体验优化
## 界面预览
@@ -49,24 +53,57 @@
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
从 [Releases](../../releases) 页面下载最新版本的 `.deb`或者 `AppImage`安装包
## 使用说明
1. 点击"添加供应商"添加你的 API 配置
2. 选择要使用的供应商,点击单选按钮切换
3. 配置会自动保存到对应应用的配置文件中
4. 重启或者新打开终端以生效
5. 如果需要切回 Claude 官方登录可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
2. 切换方式:
- 在主界面选择供应商后点击切换
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
3. 切换会写入对应应用的“live 配置文件”Claude`settings.json`Codex`auth.json` + `config.toml`
4. 重启或新开终端以确保生效
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
### Codex 说明
### 检查更新
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Codex 说明SSOT
- 配置目录:`~/.codex/`
- 主配置文件`auth.json`(必需)、`config.toml`(可为空)
- 供应商副本:`auth-<name>.json``config-<name>.toml`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json``config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`
- 导入默认:若 `~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商;`config.toml` 不存在时按空处理。
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Claude Code 说明SSOT
- 配置目录:`~/.claude/`
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 迁移与归档(自 v3.2.0 起)
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重若当前为空将 live 合并项设为当前
- 归档与清理:
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
- 归档成功后删除原副本;失败则保留原文件(保守策略)
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
## 开发
@@ -119,7 +156,7 @@ cargo test
## 技术栈
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon
- **[React 18](https://react.dev/)** - 用户界面库
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
@@ -158,6 +195,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` 配置来更改默认语言

8
docs/roadmap.md Normal file
View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "cc-switch",
"version": "3.0.0",
"description": "Claude Code 供应商切换工具",
"version": "3.3.1",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -26,8 +26,23 @@
"vite": "^5.0.0"
},
"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-dom": "^18.2.0",
"react-i18next": "^16.0.0",
"tailwindcss": "^4.1.13"
}
}

677
pnpm-lock.yaml generated
View File

@@ -8,15 +8,60 @@ importers:
.:
dependencies:
'@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
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@codemirror/view':
specifier: ^6.38.2
version: 6.38.2
'@tailwindcss/vite':
specifier: ^4.1.13
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
'@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
'@tauri-apps/plugin-updater':
specifier: ^2.0.0
version: 2.9.0
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)
react:
specifier: ^18.2.0
version: 18.3.1
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
devDependencies:
'@tauri-apps/cli':
specifier: ^2.8.0
@@ -32,7 +77,7 @@ importers:
version: 18.3.7(@types/react@18.3.23)
'@vitejs/plugin-react':
specifier: ^4.2.0
version: 4.7.0(vite@5.4.19(@types/node@20.19.9))
version: 4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -41,7 +86,7 @@ importers:
version: 5.9.2
vite:
specifier: ^5.0.0
version: 5.4.19(@types/node@20.19.9)
version: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)
packages:
@@ -120,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'}
@@ -132,6 +181,33 @@ packages:
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.18.7':
resolution: {integrity: sha512-8EzdeIoWPJDsMBwz3zdzwXnUpCzMiCyz5/A3FIPpriaclFCGDkAzK13sMcnsu5rowqiyeQN2Vs2TsOcoDPZirQ==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
'@codemirror/lint@6.8.5':
resolution: {integrity: sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==}
'@codemirror/search@6.5.11':
resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.38.2':
resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -270,9 +346,16 @@ packages:
cpu: [x64]
os: [win32]
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -280,9 +363,27 @@ packages:
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -386,6 +487,96 @@ packages:
cpu: [x64]
os: [win32]
'@tailwindcss/node@4.1.13':
resolution: {integrity: sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==}
'@tailwindcss/oxide-android-arm64@4.1.13':
resolution: {integrity: sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.13':
resolution: {integrity: sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.13':
resolution: {integrity: sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.13':
resolution: {integrity: sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13':
resolution: {integrity: sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.13':
resolution: {integrity: sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.13':
resolution: {integrity: sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.13':
resolution: {integrity: sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.13':
resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==}
engines: {node: '>= 10'}
'@tailwindcss/vite@4.1.13':
resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tauri-apps/api@2.8.0':
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
@@ -460,6 +651,15 @@ 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==}
'@tauri-apps/plugin-updater@2.9.0':
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -503,9 +703,19 @@ packages:
caniuse-lite@1.0.30001731:
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -518,9 +728,17 @@ packages:
supports-color:
optional: true
detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
electron-to-chromium@1.5.197:
resolution: {integrity: sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -539,6 +757,24 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
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
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -552,6 +788,73 @@ 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'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.1:
resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.1:
resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.1:
resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.1:
resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.1:
resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.1:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -559,6 +862,27 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.542.0:
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.0.2:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -587,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'}
@@ -611,6 +951,20 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
style-mod@4.1.2:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
tailwindcss@4.1.13:
resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==}
tapable@2.2.3:
resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
engines: {node: '>=6'}
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
@@ -656,9 +1010,20 @@ 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==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
snapshots:
'@ampproject/remapping@2.3.0':
@@ -755,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
@@ -778,6 +1145,64 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@codemirror/autocomplete@6.18.7':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.11.3
'@lezer/json': 1.0.3
'@codemirror/language@6.11.3':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
style-mod: 4.1.2
'@codemirror/lint@6.8.5':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
crelt: 1.0.6
'@codemirror/search@6.5.11':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
crelt: 1.0.6
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/highlight': 1.2.1
'@codemirror/view@6.38.2':
dependencies:
'@codemirror/state': 6.5.2
crelt: 1.0.6
style-mod: 4.1.2
w3c-keyname: 2.2.8
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -847,20 +1272,49 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@lezer/common@1.2.3': {}
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/lr@1.4.2':
dependencies:
'@lezer/common': 1.2.3
'@marijn/find-cluster-break@1.0.2': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.46.2':
@@ -923,6 +1377,77 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
'@tailwindcss/node@4.1.13':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3
jiti: 2.5.1
lightningcss: 1.30.1
magic-string: 0.30.18
source-map-js: 1.2.1
tailwindcss: 4.1.13
'@tailwindcss/oxide-android-arm64@4.1.13':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.13':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.13':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.13':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.13':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.13':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.13':
optional: true
'@tailwindcss/oxide@4.1.13':
dependencies:
detect-libc: 2.0.4
tar: 7.4.3
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.13
'@tailwindcss/oxide-darwin-arm64': 4.1.13
'@tailwindcss/oxide-darwin-x64': 4.1.13
'@tailwindcss/oxide-freebsd-x64': 4.1.13
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.13
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.13
'@tailwindcss/oxide-linux-arm64-musl': 4.1.13
'@tailwindcss/oxide-linux-x64-gnu': 4.1.13
'@tailwindcss/oxide-linux-x64-musl': 4.1.13
'@tailwindcss/oxide-wasm32-wasi': 4.1.13
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.13
'@tailwindcss/oxide-win32-x64-msvc': 4.1.13
'@tailwindcss/vite@4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
dependencies:
'@tailwindcss/node': 4.1.13
'@tailwindcss/oxide': 4.1.13
tailwindcss: 4.1.13
vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)
'@tauri-apps/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.8.1':
@@ -972,6 +1497,18 @@ 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
'@tauri-apps/plugin-updater@2.9.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.0
@@ -1010,7 +1547,7 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.1.3
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9))':
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
@@ -1018,7 +1555,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.19(@types/node@20.19.9)
vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)
transitivePeerDependencies:
- supports-color
@@ -1031,16 +1568,37 @@ snapshots:
caniuse-lite@1.0.30001731: {}
chownr@3.0.0: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/commands': 6.8.1
'@codemirror/language': 6.11.3
'@codemirror/lint': 6.8.5
'@codemirror/search': 6.5.11
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
convert-source-map@2.0.0: {}
crelt@1.0.6: {}
csstype@3.1.3: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
detect-libc@2.0.4: {}
electron-to-chromium@1.5.197: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.3
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -1074,12 +1632,73 @@ snapshots:
gensync@1.0.0-beta.2: {}
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: {}
jsesc@3.1.0: {}
json5@2.2.3: {}
jsonc-parser@3.3.1: {}
lightningcss-darwin-arm64@1.30.1:
optional: true
lightningcss-darwin-x64@1.30.1:
optional: true
lightningcss-freebsd-x64@1.30.1:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.1:
optional: true
lightningcss-linux-arm64-gnu@1.30.1:
optional: true
lightningcss-linux-arm64-musl@1.30.1:
optional: true
lightningcss-linux-x64-gnu@1.30.1:
optional: true
lightningcss-linux-x64-musl@1.30.1:
optional: true
lightningcss-win32-arm64-msvc@1.30.1:
optional: true
lightningcss-win32-x64-msvc@1.30.1:
optional: true
lightningcss@1.30.1:
dependencies:
detect-libc: 2.0.4
optionalDependencies:
lightningcss-darwin-arm64: 1.30.1
lightningcss-darwin-x64: 1.30.1
lightningcss-freebsd-x64: 1.30.1
lightningcss-linux-arm-gnueabihf: 1.30.1
lightningcss-linux-arm64-gnu: 1.30.1
lightningcss-linux-arm64-musl: 1.30.1
lightningcss-linux-x64-gnu: 1.30.1
lightningcss-linux-x64-musl: 1.30.1
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -1088,6 +1707,22 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.542.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.18:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minipass@7.1.2: {}
minizlib@3.0.2:
dependencies:
minipass: 7.1.2
mkdirp@3.0.1: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -1110,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:
@@ -1150,6 +1795,21 @@ snapshots:
source-map-js@1.2.1: {}
style-mod@4.1.2: {}
tailwindcss@4.1.13: {}
tapable@2.2.3: {}
tar@7.4.3:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.0.2
mkdirp: 3.0.1
yallist: 5.0.0
typescript@5.9.2: {}
undici-types@6.21.0: {}
@@ -1160,7 +1820,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
vite@5.4.19(@types/node@20.19.9):
vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
@@ -1168,5 +1828,12 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.9
fsevents: 2.3.3
lightningcss: 1.30.1
void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
yallist@3.1.1: {}
yallist@5.0.0: {}

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 162 KiB

1324
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
[package]
name = "cc-switch"
version = "3.0.0"
description = "Claude Code MCP 服务器配置管理工具"
version = "3.3.1"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/jasonyoung/cc-switch"
edition = "2024"
repository = "https://github.com/farion1231/cc-switch"
edition = "2021"
rust-version = "1.85.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -21,12 +21,26 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.8.2", features = [] }
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"
[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"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
# Optimize release binary size to help reduce AppImage footprint
[profile.release]
codegen-units = 1
lto = "thin"
opt-level = "s"
panic = "abort"
strip = "symbols"

View File

@@ -7,6 +7,10 @@
],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"updater:default",
"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

@@ -107,7 +107,16 @@ impl MultiAppConfig {
/// 保存配置到文件
pub fn save(&self) -> Result<(), String> {
let config_path = get_app_config_path();
write_json_file(&config_path, self)
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak再写入新内容
if config_path.exists() {
let backup_path = get_app_config_dir().join("config.json.bak");
if let Err(e) = copy_file(&config_path, &backup_path) {
log::warn!("备份 config.json 到 .bak 失败: {}", e);
}
}
write_json_file(&config_path, self)?;
Ok(())
}
/// 获取指定应用的管理器

View File

@@ -1,13 +1,19 @@
use serde_json::Value;
use std::fs;
// unused imports removed
use std::path::PathBuf;
use crate::config::{
copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file,
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use serde_json::Value;
use std::fs;
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")
}
@@ -36,57 +42,6 @@ pub fn get_codex_provider_paths(
(auth_path, config_path)
}
/// 备份 Codex 当前配置
pub fn backup_codex_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
let (backup_auth_path, backup_config_path) =
get_codex_provider_paths(provider_id, Some(provider_name));
// 备份 auth.json
if auth_path.exists() {
copy_file(&auth_path, &backup_auth_path)?;
log::info!("已备份 Codex auth.json: {}", backup_auth_path.display());
}
// 备份 config.toml
if config_path.exists() {
copy_file(&config_path, &backup_config_path)?;
log::info!("已备份 Codex config.toml: {}", backup_config_path.display());
}
Ok(())
}
/// 保存 Codex 供应商配置副本
pub fn save_codex_provider_config(
provider_id: &str,
provider_name: &str,
settings_config: &Value,
) -> Result<(), String> {
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
// 保存 auth.json
if let Some(auth) = settings_config.get("auth") {
write_json_file(&auth_path, auth)?;
}
// 保存 config.toml
if let Some(config) = settings_config.get("config") {
if let Some(config_str) = config.as_str() {
// 若非空则进行 TOML 语法校验
if !config_str.trim().is_empty() {
toml::from_str::<toml::Table>(config_str)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
fs::write(&config_path, config_str)
.map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?;
}
}
Ok(())
}
/// 删除 Codex 供应商配置文件
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
@@ -97,76 +52,95 @@ pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> R
Ok(())
}
/// 从 Codex 供应商配置副本恢复到主配置
pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let (provider_auth_path, provider_config_path) =
get_codex_provider_paths(provider_id, Some(provider_name));
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
// 确保目录存在
if let Some(parent) = auth_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
}
// 复制 auth.json必需
if provider_auth_path.exists() {
copy_file(&provider_auth_path, &auth_path)?;
log::info!("已恢复 Codex auth.json");
// 读取旧内容用于回滚
let old_auth = if auth_path.exists() {
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
} else {
return Err(format!(
"供应商 auth.json 不存在: {}",
provider_auth_path.display()
));
None
};
let _old_config = if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
} else {
None
};
// 准备写入内容
let cfg_text = match config_text_opt {
Some(s) => s.to_string(),
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
// 复制 config.toml可选允许为空不存在则创建空文件以保持一致性
if provider_config_path.exists() {
copy_file(&provider_config_path, &config_path)?;
log::info!("已恢复 Codex config.toml");
} else {
// 写入空文件
fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?;
log::info!("供应商 config.toml 缺失,已创建空文件");
// 第一步:写 auth.json
write_json_file(&auth_path, auth)?;
// 第二步:写 config.toml失败则回滚 auth.json
if let Err(e) = write_text_file(&config_path, &cfg_text) {
// 回滚 auth.json
if let Some(bytes) = old_auth {
let _ = atomic_write(&auth_path, &bytes);
} else {
let _ = delete_file(&auth_path);
}
return Err(e);
}
Ok(())
}
/// 导入当前 Codex 配置为默认供应商
pub fn import_current_codex_config() -> Result<Value, String> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
// 行为放宽:仅要求 auth.json 存在config.toml 可缺失
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
// 读取 auth.json
let auth = read_json_file::<Value>(&auth_path)?;
// 读取 config.toml允许不存在或读取失败时为空
let config_str = if config_path.exists() {
let s = fs::read_to_string(&config_path)
.map_err(|e| format!("读取 config.toml 失败: {}", e))?;
if !s.trim().is_empty() {
toml::from_str::<toml::Table>(&s)
.map_err(|e| format!("config.toml 语法错误: {}", e))?;
}
s
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
pub fn read_codex_config_text() -> Result<String, String> {
let path = get_codex_config_path();
if path.exists() {
std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e))
} else {
String::new()
};
// 组合成完整配置
let settings_config = serde_json::json!({
"auth": auth,
"config": config_str
});
// 保存为默认供应商副本
save_codex_provider_config("default", "default", &settings_config)?;
Ok(settings_config)
Ok(String::new())
}
}
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
pub fn read_config_text_from_path(path: &Path) -> Result<String, String> {
if path.exists() {
std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e))
} else {
Ok(String::new())
}
}
/// 对非空的 TOML 文本进行语法校验
pub fn validate_config_toml(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|e| format!("config.toml 语法错误: {}", e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
pub fn read_and_validate_codex_config_text() -> Result<String, String> {
let s = read_codex_config_text()?;
validate_config_toml(&s)?;
Ok(s)
}
/// 从指定路径读取并校验 config.toml返回文本可能为空
pub fn read_and_validate_config_from_path(path: &Path) -> Result<String, String> {
let s = read_config_text_from_path(path)?;
validate_config_toml(&s)?;
Ok(s)
}

View File

@@ -2,13 +2,46 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::store::AppState;
use crate::vscode;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
AppType::Claude => {
if !provider.settings_config.is_object() {
return Err("Claude 配置必须是 JSON 对象".to_string());
}
}
AppType::Codex => {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
let auth = settings
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
if !auth.is_object() {
return Err("Codex auth 配置必须是 JSON 对象".to_string());
}
if let Some(config_value) = settings.get("config") {
if !(config_value.is_string() || config_value.is_null()) {
return Err("Codex config 字段必须是字符串".to_string());
}
if let Some(cfg_text) = config_value.as_str() {
codex_config::validate_config_toml(cfg_text)?;
}
}
}
}
Ok(())
}
/// 获取所有供应商
#[tauri::command]
@@ -74,37 +107,54 @@ pub async fn add_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
validate_provider_settings(&app_type, &provider)?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 读取当前是否是激活供应商(短锁)
let is_current = {
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.current == provider.id
};
// 根据应用类型保存配置文件
match app_type {
AppType::Codex => {
// Codex: 保存两个文件
codex_config::save_codex_provider_config(
&provider.id,
&provider.name,
&provider.settings_config,
)?;
}
AppType::Claude => {
// Claude: 使用原有逻辑
use crate::config::{get_provider_config_path, write_json_file};
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
// 若目标为当前供应商,则先写 live成功后再落盘配置
if is_current {
match app_type {
AppType::Claude => {
let settings_path = crate::config::get_claude_settings_path();
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
}
AppType::Codex => {
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
let cfg_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str());
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
}
}
}
manager.providers.insert(provider.id.clone(), provider);
// 保存配置
drop(config); // 释放锁
// 更新内存并保存配置
{
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
Ok(true)
@@ -124,59 +174,60 @@ pub async fn update_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
validate_provider_settings(&app_type, &provider)?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在
if !manager.providers.contains_key(&provider.id) {
// 读取校验 & 是否当前(短锁)
let (exists, is_current) = {
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
(
manager.providers.contains_key(&provider.id),
manager.current == provider.id,
)
};
if !exists {
return Err(format!("供应商不存在: {}", provider.id));
}
// 如果名称改变了,需要处理配置文件
if let Some(old_provider) = manager.providers.get(&provider.id) {
if old_provider.name != provider.name {
// 删除旧配置文件
match app_type {
AppType::Codex => {
codex_config::delete_codex_provider_config(&provider.id, &old_provider.name)
.ok();
}
AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path};
let old_config_path =
get_provider_config_path(&provider.id, Some(&old_provider.name));
delete_file(&old_config_path).ok();
}
// 若更新的是当前供应商,先写 live 成功再保存
if is_current {
match app_type {
AppType::Claude => {
let settings_path = crate::config::get_claude_settings_path();
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
}
AppType::Codex => {
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
let cfg_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str());
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
}
}
}
// 保存新配置文件
match app_type {
AppType::Codex => {
codex_config::save_codex_provider_config(
&provider.id,
&provider.name,
&provider.settings_config,
)?;
}
AppType::Claude => {
use crate::config::{get_provider_config_path, write_json_file};
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
}
// 更新内存并保存
{
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
manager.providers.insert(provider.id.clone(), provider);
// 保存配置
drop(config); // 释放锁
state.save()?;
Ok(true)
@@ -224,8 +275,11 @@ pub async fn delete_provider(
}
AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path};
let config_path = get_provider_config_path(&id, Some(&provider.name));
delete_file(&config_path)?;
// 兼容历史两种命名settings-{name}.json 与 settings-{id}.json
let by_name = get_provider_config_path(&id, Some(&provider.name));
let by_id = get_provider_config_path(&id, None);
delete_file(&by_name)?;
delete_file(&by_id)?;
}
}
@@ -269,54 +323,67 @@ pub async fn switch_provider(
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// 根据应用类型执行切换
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
match app_type {
AppType::Codex => {
// 备份当前配置(如果存在)
use serde_json::Value;
// 回填:读取 liveauth.json + config.toml写回当前供应商 settings_config
if !manager.current.is_empty() {
if let Some(current_provider) = manager.providers.get(&manager.current) {
codex_config::backup_codex_config(&manager.current, &current_provider.name)?;
log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name);
let auth_path = codex_config::get_codex_auth_path();
let config_path = codex_config::get_codex_config_path();
if auth_path.exists() {
let auth: Value = crate::config::read_json_file(&auth_path)?;
let config_str = if config_path.exists() {
std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
} else {
String::new()
};
let live = serde_json::json!({
"auth": auth,
"config": config_str,
});
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
}
}
}
// 恢复目标供应商配置
codex_config::restore_codex_provider_config(&id, &provider.name)?;
// 切换:从目标供应商 settings_config 写入主配置Codex 双文件原子+回滚)
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
let cfg_text = provider
.settings_config
.get("config")
.and_then(|v| v.as_str());
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
}
AppType::Claude => {
// 使用原有的 Claude 切换逻辑
use crate::config::{
backup_config, copy_file, get_claude_settings_path, get_provider_config_path,
};
use crate::config::{read_json_file, write_json_file};
let settings_path = get_claude_settings_path();
let provider_config_path = get_provider_config_path(&id, Some(&provider.name));
// 检查供应商配置文件是否存在
if !provider_config_path.exists() {
return Err(format!(
"供应商配置文件不存在: {}",
provider_config_path.display()
));
}
// 如果当前有配置,先备份到当前供应商
// 回填:读取 live settings.json 写回当前供应商 settings_config
if settings_path.exists() && !manager.current.is_empty() {
if let Some(current_provider) = manager.providers.get(&manager.current) {
let current_provider_path =
get_provider_config_path(&manager.current, Some(&current_provider.name));
backup_config(&settings_path, &current_provider_path)?;
log::info!("已备份当前供应商配置: {}", current_provider.name);
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
}
}
}
// 确保主配置父目录存在
// 切换:从目标供应商 settings_config 写入主配置
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 复制新供应商配置到主配置
copy_file(&provider_config_path, &settings_path)?;
// 不做归档,直接写入
write_json_file(&settings_path, &provider.settings_config)?;
}
}
@@ -345,7 +412,7 @@ pub async fn import_default_config(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
// 若已存在 default 供应商,则直接返回,避免重复导入
// 仅当 providers 为空时才从 live 导入一条默认项
{
let config = state
.config
@@ -353,19 +420,38 @@ pub async fn import_default_config(
.map_err(|e| format!("获取锁失败: {}", e))?;
if let Some(manager) = config.get_manager(&app_type) {
if manager.get_all_providers().contains_key("default") {
if !manager.get_all_providers().is_empty() {
return Ok(true);
}
}
}
// 根据应用类型导入配置
// 读取当前主配置为默认供应商(不再写入副本文件)
let settings_config = match app_type {
AppType::Codex => codex_config::import_current_codex_config()?,
AppType::Claude => import_current_config_as_default()?,
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
let auth: serde_json::Value =
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let config_str = match crate::codex_config::read_and_validate_codex_config_text() {
Ok(s) => s,
Err(e) => return Err(e),
};
serde_json::json!({ "auth": auth, "config": config_str })
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
crate::config::read_json_file::<serde_json::Value>(&settings_path)?
}
};
// 创建默认供应商
// 创建默认供应商(仅首次初始化)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
@@ -383,28 +469,9 @@ pub async fn import_default_config(
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 根据应用类型保存配置文件
match app_type {
AppType::Codex => {
codex_config::save_codex_provider_config(
&provider.id,
&provider.name,
&provider.settings_config,
)?;
}
AppType::Claude => {
use crate::config::{get_provider_config_path, write_json_file};
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
}
}
manager.providers.insert(provider.id.clone(), provider);
// 如果没有当前供应商,设置为 default
if manager.current.is_empty() {
manager.current = "default".to_string();
}
// 设置当前供应商为默认项
manager.current = "default".to_string();
// 保存配置
drop(config); // 释放锁
@@ -453,6 +520,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]
@@ -466,7 +553,7 @@ pub async fn open_config_folder(
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config_dir = match app_type {
AppType::Claude => crate::config::get_claude_config_dir(),
AppType::Codex => crate::codex_config::get_codex_config_dir(),
@@ -478,13 +565,46 @@ pub async fn open_config_folder(
}
// 使用 opener 插件打开文件夹
handle.opener()
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: tauri::AppHandle,
default_path: Option<String>,
) -> Result<Option<String>, String> {
let initial = default_path
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let result = tauri::async_runtime::spawn_blocking(move || {
let mut builder = app.dialog().file();
if let Some(path) = initial {
builder = builder.set_directory(path);
}
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
}
}
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
@@ -502,3 +622,111 @@ pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, S
Ok(true)
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
use crate::config::get_app_config_path;
let config_path = get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, String> {
use crate::config::get_app_config_dir;
let config_dir = get_app_config_dir();
// 确保目录存在
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)?;
Ok(true)
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String> {
// 打开 GitHub releases 页面
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// VS Code: 获取用户 settings.json 状态
#[tauri::command]
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
if let Some(p) = vscode::find_existing_settings() {
Ok(ConfigStatus {
exists: true,
path: p.to_string_lossy().to_string(),
})
} else {
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
let preferred = vscode::candidate_settings_paths().into_iter().next();
Ok(ConfigStatus {
exists: false,
path: preferred.unwrap_or_default().to_string_lossy().to_string(),
})
}
}
/// VS Code: 读取 settings.json 文本(仅当文件存在)
#[tauri::command]
pub async fn read_vscode_settings() -> Result<String, String> {
if let Some(p) = vscode::find_existing_settings() {
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
#[tauri::command]
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
if let Some(p) = vscode::find_existing_settings() {
config::write_text_file(&p, &content)?;
Ok(true)
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}

View File

@@ -1,10 +1,15 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
// unused import removed
use std::fs;
use std::io::Write;
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")
@@ -38,6 +43,55 @@ pub fn get_app_config_path() -> PathBuf {
get_app_config_dir().join("config.json")
}
/// 归档根目录 ~/.cc-switch/archive
pub fn get_archive_root() -> PathBuf {
get_app_config_dir().join("archive")
}
fn ensure_unique_path(dest: PathBuf) -> PathBuf {
if !dest.exists() {
return dest;
}
let file_name = dest
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "file".into());
let ext = dest
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let parent = dest.parent().map(|p| p.to_path_buf()).unwrap_or_default();
for i in 2..1000 {
let mut candidate = parent.clone();
candidate.push(format!("{}-{}{}", file_name, i, ext));
if !candidate.exists() {
return candidate;
}
}
dest
}
/// 将现有文件归档到 `~/.cc-switch/archive/<ts>/<category>/` 下,返回归档路径
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, String> {
if !src.exists() {
return Ok(None);
}
let mut dest_dir = get_archive_root();
dest_dir.push(ts.to_string());
dest_dir.push(category);
fs::create_dir_all(&dest_dir).map_err(|e| format!("创建归档目录失败: {}", e))?;
let file_name = src
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "file".into());
let mut dest = dest_dir.join(file_name);
dest = ensure_unique_path(dest);
copy_file(src, &dest)?;
Ok(Some(dest))
}
/// 清理供应商名称,确保文件名安全
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -79,7 +133,66 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String
let json =
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e))
atomic_write(path, json.as_bytes())
}
/// 原子写入文本文件(用于 TOML/纯文本)
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
atomic_write(path, data.as_bytes())
}
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
let mut tmp = parent.to_path_buf();
let file_name = path
.file_name()
.ok_or_else(|| "无效的文件名".to_string())?
.to_string_lossy()
.to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{}.tmp.{}", file_name, ts));
{
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
f.write_all(data)
.map_err(|e| format!("写入临时文件失败: {}", e))?;
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let perm = meta.permissions().mode();
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
}
}
#[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(())
}
/// 复制文件
@@ -112,30 +225,4 @@ pub fn get_claude_config_status() -> ConfigStatus {
}
}
/// 备份配置文件
pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> {
if from.exists() {
copy_file(from, to)?;
log::info!("已备份配置文件: {} -> {}", from.display(), to.display());
}
Ok(())
}
/// 导入当前 Claude Code 配置为默认供应商
pub fn import_current_config_as_default() -> Result<Value, String> {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
// 读取当前配置
let settings_config: Value = read_json_file(&settings_path)?;
// 保存为 default 供应商
let default_provider_path = get_provider_config_path("default", Some("default"));
write_json_file(&default_provider_path, &settings_config)?;
log::info!("已导入当前配置为默认供应商");
Ok(settings_config)
}
//(移除未使用的备份/导入函数,避免 dead_code 告警)

View File

@@ -2,17 +2,292 @@ mod app_config;
mod codex_config;
mod commands;
mod config;
mod migration;
mod provider;
mod settings;
mod store;
mod vscode;
use store::AppState;
use tauri::Manager;
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager};
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, String> {
let config = app_state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", 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标题禁用状态仅作为分组标识
let claude_header =
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
.map_err(|e| format!("创建Claude标题失败: {}", e))?;
menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() {
for (id, provider) in &claude_manager.providers {
let is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("claude_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"claude_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Claude空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
// 添加Codex标题禁用状态仅作为分组标识
let codex_header =
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
.map_err(|e| format!("创建Codex标题失败: {}", e))?;
menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() {
for (id, provider) in &codex_manager.providers {
let is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("codex_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"codex_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Codex空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
.map_err(|e| format!("创建退出菜单失败: {}", e))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| format!("构建菜单失败: {}", e))
}
/// 处理托盘菜单事件
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();
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
}
id if id.starts_with("claude_") => {
let provider_id = id.strip_prefix("claude_").unwrap();
log::info!("切换到Claude供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Claude,
provider_id,
)
.await
{
log::error!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
let provider_id = id.strip_prefix("codex_").unwrap();
log::info!("切换到Codex供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Codex,
provider_id,
)
.await
{
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
log::warn!("未处理的菜单事件: {}", event_id);
}
}
}
//
/// 内部切换供应商函数
async fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: crate::app_config::AppType,
provider_id: String,
) -> Result<(), String> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(
app_state.clone().into(),
Some(app_type),
None,
None,
provider_id,
)
.await?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {}", e);
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {}", e);
}
}
Ok(())
}
/// 更新托盘菜单的Tauri命令
#[tauri::command]
async fn update_tray_menu(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
if let Ok(new_menu) = create_tray_menu(&app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
return Ok(true);
}
}
Ok(false)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
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);
}
} else {
window.app_handle().exit(0);
}
}
_ => {}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
{
if let Err(e) = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
}
}
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
@@ -55,53 +330,41 @@ pub fn run() {
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage
let app_state = AppState::new();
// 如果没有供应商且存在 Claude Code 配置,自动导入
// 首次启动迁移:扫描副本文件,合并到 config.json并归档副本旧 config.json 先归档
{
let mut config = app_state.config.lock().unwrap();
// 检查 Claude 供应商
let need_import = if let Some(claude_manager) =
config.get_manager(&app_config::AppType::Claude)
{
claude_manager.providers.is_empty()
} else {
// 确保 Claude 应用存在
config.ensure_app(&app_config::AppType::Claude);
true
};
if need_import {
let settings_path = config::get_claude_settings_path();
if settings_path.exists() {
log::info!("检测到 Claude Code 配置,自动导入为默认供应商");
if let Ok(settings_config) = config::import_current_config_as_default() {
if let Some(manager) =
config.get_manager_mut(&app_config::AppType::Claude)
{
let provider = provider::Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
if manager.add_provider(provider).is_ok() {
manager.current = "default".to_string();
log::info!("成功导入默认供应商");
}
}
}
}
let mut config_guard = app_state.config.lock().unwrap();
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
if migrated {
log::info!("已将副本文件导入到 config.json并完成归档");
}
// 确保 Codex 应用存在
config.ensure_app(&app_config::AppType::Codex);
// 确保两个 App 条目存在
config_guard.ensure_app(&app_config::AppType::Claude);
config_guard.ensure_app(&app_config::AppType::Codex);
}
// 保存配置
let _ = app_state.save();
// 创建动态托盘菜单
let menu = create_tray_menu(&app.handle(), &app_state)?;
// 构建托盘
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);
})
.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(())
@@ -117,9 +380,47 @@ 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,
])
.run(tauri::generate_context!())
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_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
update_tray_menu,
]);
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();
}
}
_ => {}
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app_handle, event);
}
});
}

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

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

View File

@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use crate::config::{get_provider_config_path, write_json_file};
// SSOT 模式:不再写供应商副本文件
/// 供应商结构体
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -14,6 +14,11 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
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>,
}
impl Provider {
@@ -29,6 +34,8 @@ impl Provider {
name,
settings_config,
website_url,
category: None,
created_at: None,
}
}
}
@@ -50,17 +57,6 @@ impl Default for ProviderManager {
}
impl ProviderManager {
/// 添加供应商
pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> {
// 保存供应商配置到独立文件
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
write_json_file(&config_path, &provider.settings_config)?;
// 添加到管理器
self.providers.insert(provider.id.clone(), provider);
Ok(())
}
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
&self.providers

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

@@ -0,0 +1,148 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 应用设置结构,允许覆盖默认配置目录
#[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>,
}
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,
}
}
}
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());
}
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))
}

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

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

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.0.0",
"version": "3.3.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -12,6 +12,7 @@
"app": {
"windows": [
{
"label": "main",
"title": "",
"width": 900,
"height": 650,
@@ -23,18 +24,32 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:"
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"windows": {
"wix": {
"template": "wix/per-user-main.wxs"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
]
}
}
}

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,242 +0,0 @@
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
color: white;
padding: 0.35rem 2rem 0.45rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto auto;
grid-template-areas:
". title ."
"tabs . actions";
align-items: center;
row-gap: 0.6rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
user-select: none;
}
.app-tabs {
grid-area: tabs;
}
/* Segmented control */
.segmented {
--seg-bg: rgba(255, 255, 255, 0.16);
--seg-thumb: #ffffff;
--seg-color: rgba(255, 255, 255, 0.85);
--seg-active: #2d89c7;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 280px;
background: var(--seg-bg);
border-radius: 999px;
padding: 4px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
backdrop-filter: saturate(140%) blur(2px);
}
.segmented-thumb {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--seg-thumb);
border-radius: 999px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition:
transform 220ms ease,
width 220ms ease;
will-change: transform;
}
.segmented-item {
position: relative;
z-index: 1;
background: transparent;
border: none;
border-radius: 999px;
padding: 6px 16px; /* 更紧凑的高度 */
color: var(--seg-color);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.2px;
cursor: pointer;
transition: color 200ms ease;
}
.segmented-item.active {
color: var(--seg-active);
}
.segmented-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.8);
outline-offset: 2px;
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
grid-area: title;
text-align: center;
}
.header-actions {
display: flex;
gap: 1rem;
grid-area: actions;
justify-self: end;
}
.refresh-btn,
.add-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.refresh-btn {
background: #3498db;
color: white;
}
.refresh-btn:hover:not(:disabled) {
background: #2980b9;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.import-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.import-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.import-btn:focus {
outline: none;
}
.add-btn {
background: #27ae60;
color: white;
border: none;
}
.add-btn:hover {
background: #229954;
}
.add-btn:focus {
outline: none;
}
.app-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.config-path {
margin-top: 2rem;
padding: 1rem;
background: #ecf0f1;
border-radius: 4px;
font-size: 0.9rem;
color: #7f8c8d;
display: flex;
justify-content: space-between;
align-items: center;
}
.browse-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #3498db;
color: white;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
margin-left: 1rem;
}
.browse-btn:hover {
background: #2980b9;
}
/* 供应商列表区域 - 相对定位容器 */
.provider-section {
position: relative;
}
/* 浮动通知 - 绝对定位,不占据空间 */
.notification-floating {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
width: fit-content;
white-space: nowrap;
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
.fade-out {
animation: fadeOut 0.3s ease-out;
}
.notification-success {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: white;
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3);
}
.notification-error {
background: linear-gradient(135deg, #e74c3c 0%, #ec7063 100%);
color: white;
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.3);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

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";
@@ -6,19 +7,27 @@ import AddProviderModal from "./components/AddProviderModal";
import EditProviderModal from "./components/EditProviderModal";
import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher";
import "./App.css";
import SettingsModal from "./components/SettingsModal";
import { UpdateBadge } from "./components/UpdateBadge";
import LanguageSwitcher from "./components/LanguageSwitcher";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
import { extractErrorMessage } from "./utils/errorUtils";
import { applyProviderToVSCode } from "./utils/vscodeSettings";
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
function App() {
const { t } = useTranslation();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { isAutoSyncEnabled } = useVSCodeAutoSync();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [configStatus, setConfigStatus] = useState<{
exists: boolean;
path: string;
} | null>(null);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
null
);
const [notification, setNotification] = useState<{
message: string;
@@ -31,13 +40,14 @@ function App() {
message: string;
onConfirm: () => void;
} | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 设置通知的辅助函数
const showNotification = (
message: string,
type: "success" | "error",
duration = 3000,
duration = 3000
) => {
// 清除之前的定时器
if (timeoutRef.current) {
@@ -62,7 +72,6 @@ function App() {
// 加载供应商列表
useEffect(() => {
loadProviders();
loadConfigStatus();
}, [activeApp]); // 当切换应用时重新加载
// 清理定时器
@@ -74,26 +83,54 @@ function App() {
};
}, []);
// 监听托盘切换事件(包括菜单切换)
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
try {
unlisten = await window.api.onProviderSwitched(async (data) => {
if (import.meta.env.DEV) {
console.log(t("console.providerSwitchReceived"), data);
}
// 如果当前应用类型匹配,则重新加载数据
if (data.appType === activeApp) {
await loadProviders();
}
// 若为 Codex 且开启自动同步,则静默同步到 VS Code覆盖
if (data.appType === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(data.providerId, true);
}
});
} catch (error) {
console.error(t("console.setupListenerFailed"), error);
}
};
setupListener();
// 清理监听器
return () => {
if (unlisten) {
unlisten();
}
};
}, [activeApp, isAutoSyncEnabled]);
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
const currentId = await window.api.getCurrentProvider(activeApp);
setProviders(loadedProviders);
setCurrentProviderId(currentId);
// 如果供应商列表为空,尝试自动导入现有配置为"default"供应商
// 如果供应商列表为空,尝试自动从 live 导入一条默认供应商
if (Object.keys(loadedProviders).length === 0) {
await handleAutoImportDefault();
}
};
const loadConfigStatus = async () => {
const status = await window.api.getConfigStatus(activeApp);
setConfigStatus({
exists: Boolean(status?.exists),
path: String(status?.path || ""),
});
};
// 生成唯一ID
const generateId = () => {
return crypto.randomUUID();
@@ -103,10 +140,13 @@ function App() {
const newProvider: Provider = {
...provider,
id: generateId(),
createdAt: Date.now(), // 添加创建时间戳
};
await window.api.addProvider(newProvider, activeApp);
await loadProviders();
setIsAddModalOpen(false);
// 更新托盘菜单
await window.api.updateTrayMenu();
};
const handleEditProvider = async (provider: Provider) => {
@@ -115,11 +155,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);
}
};
@@ -127,113 +173,196 @@ 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();
},
});
};
// 同步Codex供应商到VS Code设置静默覆盖
const syncCodexToVSCode = async (providerId: string, silent = false) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
if (!silent) {
showNotification(
t("notifications.vscodeSettingsNotFound"),
"error",
3000
);
}
return;
}
const raw = await window.api.readVSCodeSettings();
const provider = providers[providerId];
const isOfficial = provider?.category === "official";
// 非官方供应商需要解析 base_url使用公共工具函数
let baseUrl: string | undefined = undefined;
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
if (!silent) {
showNotification(t("notifications.missingBaseUrl"), "error", 4000);
}
return;
}
baseUrl = parsed;
}
const updatedSettings = applyProviderToVSCode(raw, {
baseUrl,
isOfficial,
});
if (updatedSettings !== raw) {
await window.api.writeVSCodeSettings(updatedSettings);
if (!silent) {
showNotification(t("notifications.syncedToVSCode"), "success", 1500);
}
}
// 触发providers重新加载以更新VS Code按钮状态
await loadProviders();
} catch (error: any) {
console.error(t("console.syncToVSCodeFailed"), error);
if (!silent) {
const errorMessage =
error?.message || t("notifications.syncVSCodeFailed");
showNotification(errorMessage, "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();
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
if (activeApp === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(id, true); // silent模式不显示通知
}
} else {
showNotification("切换失败,请检查配置", "error");
showNotification(t("notifications.switchFailed"), "error");
}
};
// 自动导入现有配置为"default"供应商
// 自动从 live 导入一条默认供应商(仅首次初始化时)
const handleAutoImportDefault = async () => {
try {
const result = await window.api.importCurrentConfigAsDefault(activeApp);
if (result.success) {
await loadProviders();
showNotification(
"已自动导入现有配置为 default 供应商",
"success",
3000,
);
showNotification(t("notifications.autoImported"), "success", 3000);
// 更新托盘菜单
await window.api.updateTrayMenu();
}
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) {
console.error("自动导入默认配置失败:", error);
console.error(t("console.autoImportFailed"), error);
// 静默处理,不影响用户体验
}
};
const handleOpenConfigFolder = async () => {
await window.api.openConfigFolder(activeApp);
};
return (
<div className="app">
<header className="app-header">
<h1>CC Switch</h1>
<div className="app-tabs">
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
/>
</div>
<div className="header-actions">
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
</button>
<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">
<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
</a>
<button
onClick={toggleDarkMode}
className={buttonStyles.icon}
title={
isDarkMode
? t("header.toggleLightMode")
: t("header.toggleDarkMode")
}
>
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
<LanguageSwitcher />
<div className="flex items-center gap-2">
<button
onClick={() => setIsSettingsOpen(true)}
className={buttonStyles.icon}
title={t("common.settings")}
>
<Settings size={18} />
</button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
</div>
</div>
<div className="flex items-center gap-4">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<button
onClick={() => setIsAddModalOpen(true)}
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
>
<Plus size={16} />
{t("header.addProvider")}
</button>
</div>
</div>
</header>
<main className="app-main">
<div className="provider-section">
{/* 浮动通知组件 */}
{notification && (
<div
className={`notification-floating ${
notification.type === "error"
? "notification-error"
: "notification-success"
} ${isNotificationVisible ? "fade-in" : "fade-out"}`}
>
{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}
/>
</div>
{configStatus && (
<div className="config-path">
<span>
: {configStatus.path}
{!configStatus.exists ? "(未创建,切换或保存时会自动创建)" : ""}
</span>
<button
className="browse-btn"
onClick={handleOpenConfigFolder}
title="打开配置文件夹"
>
</button>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification}
/>
</div>
)}
</div>
</main>
{isAddModalOpen && (
@@ -262,6 +391,10 @@ function App() {
onCancel={() => setConfirmDialog(null)}
/>
)}
{isSettingsOpen && (
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
)}
</div>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1757750114641" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M202.112 678.656l200.64-112.64 3.392-9.792-3.392-5.44h-9.792l-33.6-2.048-114.624-3.072-99.456-4.224-96.384-5.12-24.192-5.12-22.72-29.952 2.304-14.976 20.48-13.696 29.12 2.56 64.576 4.416 96.832 6.72 70.208 4.096 104.064 10.88h16.576l2.304-6.72-5.696-4.16-4.352-4.096-100.224-67.968-108.48-71.744-56.768-41.344-30.72-20.928-15.488-19.584-6.72-42.88 27.84-30.72 37.504 2.56 9.536 2.56 37.952 29.184 81.088 62.784 105.856 77.952 15.488 12.928 6.208-4.352 0.768-3.136L395.264 360l-57.6-104.064-61.44-105.92-27.392-43.904-7.168-26.304c-2.56-10.88-4.48-19.904-4.48-30.976l31.808-43.136L286.592 0l42.304 5.696 17.856 15.488 26.304 60.16 42.624 94.72 66.112 128.896 19.392 38.208 10.24 35.392 3.904 10.88h6.72v-6.208l5.44-72.576 10.048-89.088 9.856-114.688 3.328-32.256 16-38.72 31.808-20.928 24.768 11.904 20.416 29.184-2.88 18.816-12.16 78.72-23.68 123.52-15.552 82.56h9.088l10.304-10.24 41.856-55.552 70.208-87.808 30.976-34.88 36.16-38.464 23.232-18.368h43.904l32.32 48.064-14.464 49.6-45.184 57.28-37.44 48.576-53.76 72.32-33.536 57.856 3.072 4.608 8-0.768 121.408-25.792 65.6-11.904 78.208-13.44 35.392 16.512 3.84 16.832-13.952 34.304-83.648 20.672-98.112 19.648-146.176 34.56-1.792 1.28 2.048 2.56 65.92 6.272 28.096 1.536h68.928l128.384 9.6 33.536 22.144 20.16 27.136-3.392 20.672-51.648 26.304-69.696-16.512-162.688-38.72-55.744-13.952h-7.744v4.672l46.464 45.44 85.184 76.928 106.688 99.2 5.376 24.512-13.632 19.328-14.464-2.048-93.76-70.464-36.16-31.808-81.856-68.928h-5.44v7.232l18.88 27.648 99.648 149.76 5.184 45.952-7.232 14.976-25.856 9.024-28.352-5.12L673.408 856l-60.16-92.16-48.576-82.624-5.952 3.392-28.672 308.544-13.44 15.744-30.976 11.904-25.792-19.648-13.696-31.744 13.696-62.72 16.512-81.92 13.44-65.024 12.16-80.832 7.232-26.88-0.512-1.792-5.952 0.768-60.928 83.648-92.736 125.248-73.344 78.528-17.536 6.976-30.464-15.808 2.816-28.16 17.024-24.96 101.504-129.152 61.184-80 39.552-46.272-0.256-6.72h-2.368L177.6 789.44l-48 6.144-20.736-19.328 2.56-31.744 9.856-10.368 81.088-55.744-0.256 0.256z" p-id="1476" fill="#bfbfbf"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,268 +0,0 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 10px;
padding: 0;
width: 90%;
max-width: 640px;
max-height: 90vh;
overflow: hidden; /* 由 body 滚动,标题栏固定 */
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1001;
display: flex; /* 纵向布局,便于底栏固定 */
flex-direction: column;
}
/* 模拟窗口标题栏 */
.modal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem; /* 与主窗口标题栏一致 */
padding: 0 12px; /* 接近主头部的水平留白 */
background: #3498db; /* 与 .app-header 相同 */
color: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
.modal-spacer {
width: 32px;
flex: 0 0 32px;
}
.modal-title {
flex: 1;
text-align: center;
color: #fff;
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-close-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.modal-close-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.modal-form {
/* 表单外层包裹 body + footer */
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0; /* 允许子元素正确计算高度 */
}
.modal-body {
padding: 1.25rem 1.5rem 1.5rem;
overflow: auto; /* 仅内容区滚动 */
flex: 1 1 auto;
min-height: 0;
}
.error-message {
background: #fee;
color: #c33;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #fcc;
font-size: 0.9rem;
}
.presets {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #ecf0f1;
}
.presets label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.preset-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.preset-btn:hover,
.preset-btn.selected {
background: #3498db;
color: white;
}
/* 官方按钮橙色主题Anthropic 风格) */
.preset-btn.official {
border: 1px solid #d97706;
color: #d97706;
}
.preset-btn.official:hover,
.preset-btn.official.selected {
background: #d97706;
color: white;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-weight: 500;
}
/* API Key 输入框容器 - 预留空间避免抖动 */
.form-group.api-key-group {
min-height: 88px; /* 固定高度label + input + 间距 */
transition: opacity 0.2s ease;
}
.form-group.api-key-group.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.2s;
background: white;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
min-height: 200px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.modal-footer {
/* 固定在弹窗底部(非滚动区) */
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 0.75rem 1.5rem;
border-top: 1px solid #ecf0f1;
background: #fff;
}
.cancel-btn,
.submit-btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
.cancel-btn {
background: #ecf0f1;
color: #555;
}
.cancel-btn:hover {
background: #bdc3c7;
}
.submit-btn {
background: #27ae60;
color: white;
}
.submit-btn:hover {
background: #229954;
}
.field-hint {
display: block;
margin-top: 0.25rem;
color: #7f8c8d;
font-size: 0.8rem;
line-height: 1.3;
}
/* 添加标签和选择框的样式 */
.label-with-checkbox {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.label-with-checkbox label:first-child {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: #666;
font-weight: normal;
margin-bottom: 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 2px;
cursor: pointer;
transform: translateY(2px);
}

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

@@ -1,66 +0,0 @@
/* 药丸式切换按钮 */
.switcher-pills {
display: inline-flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.08);
padding: 6px 8px;
border-radius: 50px;
backdrop-filter: blur(10px);
}
.switcher-pill {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 50px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 200ms ease;
min-width: 120px;
}
.switcher-pill:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
.switcher-pill.active {
background: rgba(255, 255, 255, 0.15);
color: white;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.1);
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
transition: all 200ms ease;
}
.switcher-pill.active .pill-dot {
opacity: 1;
box-shadow: 0 0 8px currentColor;
animation: pulse 2s infinite;
}
.pills-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
}

View File

@@ -1,5 +1,5 @@
import { AppType } from "../lib/tauri-api";
import "./AppSwitcher.css";
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
interface AppSwitcherProps {
activeApp: AppType;
@@ -13,24 +13,39 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="switcher-pills">
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent dark:border-gray-700">
<button
type="button"
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "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"
}`}
>
<span className="pill-dot" />
<span>Claude Code</span>
<ClaudeIcon
size={16}
className={
activeApp === "claude"
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
}
/>
<span>Claude</span>
</button>
<div className="pills-divider" />
<button
type="button"
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "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"
}`}
>
<span className="pill-dot" />
<CodexIcon size={16} />
<span>Codex</span>
</button>
</div>
);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,107 +0,0 @@
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.confirm-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 400px;
animation: confirmSlideIn 0.2s ease-out;
}
@keyframes confirmSlideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.confirm-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid #eee;
}
.confirm-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
}
.confirm-content {
padding: 1rem 1.5rem;
}
.confirm-content p {
margin: 0;
color: #666;
line-height: 1.5;
}
.confirm-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.confirm-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition:
background-color 0.2s,
transform 0.1s;
min-width: 70px;
}
.confirm-btn:hover {
transform: translateY(-1px);
}
.confirm-btn:active {
transform: translateY(0);
}
.cancel-btn {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.cancel-btn:hover {
background: #e9ecef;
}
.confirm-btn-primary {
background: #dc3545;
color: white;
}
.confirm-btn-primary:hover {
background: #c82333;
}
.confirm-btn:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}

View File

@@ -1,5 +1,7 @@
import React from "react";
import "./ConfirmDialog.css";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -15,35 +17,64 @@ 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="confirm-overlay">
<div className="confirm-dialog">
<div className="confirm-header">
<h3>{title}</h3>
</div>
<div className="confirm-content">
<p>{message}</p>
</div>
<div className="confirm-actions">
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
onClick={onCancel}
/>
{/* Dialog */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
</div>
<button
className="confirm-btn cancel-btn"
onClick={onCancel}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-gray-500 dark:text-gray-400 leading-relaxed">
{message}
</p>
</div>
{/* Actions */}
<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-900">
<button
onClick={onCancel}
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
className="confirm-btn confirm-btn-primary"
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,140 @@
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;
onChange: (value: string) => void;
placeholder?: string;
darkMode?: boolean;
rows?: number;
showValidation?: boolean;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
value,
onChange,
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;
// 创建编辑器扩展
const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动
const sizingTheme = EditorView.theme({
"&": { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "14px",
},
});
const extensions = [
basicSetup,
json(),
placeholder(placeholderText || ""),
sizingTheme,
jsonLinter,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
];
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
}
// 创建初始状态
const state = EditorState.create({
doc: value,
extensions,
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// 清理函数
return () => {
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
const transaction = viewRef.current.state.update({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
viewRef.current.dispatch(transaction);
}
}, [value]);
return <div ref={editorRef} style={{ width: "100%" }} />;
};
export default JsonEditor;

View File

@@ -0,0 +1,31 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Globe } from "lucide-react";
import { buttonStyles } from "../lib/styles";
const LanguageSwitcher: React.FC = () => {
const { t, i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === "en" ? "zh" : "en";
i18n.changeLanguage(newLang);
};
const titleKey =
i18n.language === "en"
? "header.switchToChinese"
: "header.switchToEnglish";
return (
<button
onClick={toggleLanguage}
className={buttonStyles.icon}
title={t(titleKey)}
aria-label={t(titleKey)}
>
<Globe size={18} />
</button>
);
};
export default LanguageSwitcher;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
interface ApiKeyInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
label?: string;
id?: string;
}
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
value,
onChange,
placeholder = "请输入API Key",
disabled = false,
required = false,
label = "API Key",
id = "apiKey",
}) => {
const [showKey, setShowKey] = useState(false);
const toggleShowKey = () => {
setShowKey(!showKey);
};
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 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"
}`;
return (
<div className="space-y-2">
<label
htmlFor={id}
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{label} {required && "*"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
className={inputClass}
/>
{!disabled && value && (
<button
type="button"
onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
</div>
);
};
export default ApiKeyInput;

View File

@@ -0,0 +1,203 @@
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;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
configError: string;
}
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
configError,
}) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
useEffect(() => {
// 检测暗色模式
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
};
checkDarkMode();
// 监听暗色模式变化
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
checkDarkMode();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
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">
<label
htmlFor="settingsConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
Claude Code (JSON) *
</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>
)}
<JsonEditor
value={value}
onChange={onChange}
darkMode={isDarkMode}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
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>
);
};
export default ClaudeConfigEditor;

View File

@@ -0,0 +1,668 @@
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">
<label
htmlFor="codexAuth"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={authValue}
onChange={(e) => handleAuthChange(e.target.value)}
onBlur={onAuthBlur}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
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">
<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) => 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>
);
};
export default CodexConfigEditor;

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from "react";
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
interface KimiModel {
id: string;
object: string;
created: number;
owned_by: string;
}
interface KimiModelSelectorProps {
apiKey: string;
anthropicModel: string;
anthropicSmallFastModel: string;
onModelChange: (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
) => void;
disabled?: boolean;
}
const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
apiKey,
anthropicModel,
anthropicSmallFastModel,
onModelChange,
disabled = false,
}) => {
const [models, setModels] = useState<KimiModel[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [debouncedKey, setDebouncedKey] = useState("");
// 获取模型列表
const fetchModelsWithKey = async (key: string) => {
if (!key) {
setError("请先填写 API Key");
return;
}
setLoading(true);
setError("");
try {
const response = await fetch("https://api.moonshot.cn/v1/models", {
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
setModels(data.data);
} else {
throw new Error("返回数据格式错误");
}
} catch (err) {
console.error("获取模型列表失败:", err);
setError(err instanceof Error ? err.message : "获取模型列表失败");
} finally {
setLoading(false);
}
};
// 500ms 防抖 API Key
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKey(apiKey.trim());
}, 500);
return () => clearTimeout(timer);
}, [apiKey]);
// 当防抖后的 Key 改变时自动获取模型列表
useEffect(() => {
if (debouncedKey) {
fetchModelsWithKey(debouncedKey);
} else {
setModels([]);
setError("");
}
}, [debouncedKey]);
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
disabled
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 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"
}`;
const ModelSelect: React.FC<{
label: string;
value: string;
onChange: (value: string) => void;
}> = ({ label, value, onChange }) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading || models.length === 0}
className={selectClass}
>
<option value="">
{loading
? "加载中..."
: models.length === 0
? "暂无模型"
: "请选择模型"}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.id}
</option>
))}
</select>
<ChevronDown
size={16}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
/>
</div>
</div>
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
</h3>
<button
type="button"
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
disabled={disabled || loading || !debouncedKey}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
<AlertCircle
size={16}
className="text-red-500 dark:text-red-400 flex-shrink-0"
/>
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ModelSelect
label="主模型"
value={anthropicModel}
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
/>
<ModelSelect
label="快速模型"
value={anthropicSmallFastModel}
onChange={(value) =>
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
}
/>
</div>
{!apiKey.trim() && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">
💡 API Key
</p>
</div>
)}
</div>
);
};
export default KimiModelSelector;

View File

@@ -0,0 +1,116 @@
import React from "react";
import { Zap } from "lucide-react";
import { ProviderCategory } from "../../types";
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
interface Preset {
name: string;
isOfficial?: boolean;
category?: ProviderCategory;
}
interface PresetSelectorProps {
title?: string;
presets: Preset[];
selectedIndex: number | null;
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
title = "选择配置类型",
presets,
selectedIndex,
onSelectPreset,
onCustomClick,
customLabel = "自定义",
renderCustomDescription,
}) => {
const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index;
const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) {
if (preset?.isOfficial || preset?.category === "official") {
// Codex 官方使用黑色背景
if (preset?.name.includes("Codex")) {
return `${baseClass} bg-gray-900 text-white`;
}
// Claude 官方使用品牌色背景
return `${baseClass} bg-[#D97757] text-white`;
}
return `${baseClass} bg-blue-500 text-white`;
}
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
};
const getDescription = () => {
if (selectedIndex === -1) {
// 如果提供了自定义描述渲染函数,使用它
if (renderCustomDescription) {
return renderCustomDescription();
}
return "手动配置供应商,需要填写完整的配置信息";
}
if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex];
return preset?.isOfficial || preset?.category === "official"
? "官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key";
}
return null;
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{title}
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
onClick={onCustomClick}
>
{customLabel}
</button>
{presets.map((preset, index) => (
<button
key={index}
type="button"
className={getButtonClass(index, preset)}
onClick={() => onSelectPreset(index)}
>
{(preset.isOfficial || preset.category === "official") && (
<>
{preset.name.includes("Claude") ? (
<ClaudeIcon size={14} />
) : preset.name.includes("Codex") ? (
<CodexIcon size={14} />
) : (
<Zap size={14} />
)}
</>
)}
{preset.name}
</button>
))}
</div>
</div>
{getDescription() && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{getDescription()}
</div>
)}
</div>
);
};
export default PresetSelector;

View File

@@ -1,206 +0,0 @@
.provider-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #7f8c8d;
}
.empty-state p:first-child {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.provider-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-item {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid #ecf0f1;
border-radius: 6px;
transition: all 0.2s;
}
.provider-item:hover {
border-color: #3498db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.provider-item.current {
border-color: #27ae60;
background: #f0fdf4;
}
.provider-info {
flex: 1;
min-width: 0;
}
.provider-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.provider-name input[type="radio"] {
cursor: pointer;
}
.provider-name input[type="radio"]:disabled {
cursor: not-allowed;
}
.current-badge {
background: #27ae60;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
}
.provider-url {
color: #7f8c8d;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.url-link {
color: #3498db;
text-decoration: none;
cursor: pointer;
transition: color 0.2s;
}
.url-link:hover {
color: #2980b9;
text-decoration: underline;
}
.api-url {
color: #7f8c8d;
}
.provider-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 2rem;
}
.status-icon {
font-size: 1.1rem;
}
.status-text {
color: #555;
font-size: 0.9rem;
}
.response-time {
color: #3498db;
font-size: 0.85rem;
font-family: monospace;
}
.provider-actions {
display: flex;
gap: 0.5rem;
}
.check-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #f39c12;
background: white;
color: #f39c12;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.check-btn:hover:not(:disabled) {
background: #f39c12;
color: white;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.enable-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.enable-btn:hover:not(:disabled) {
background: #27ae60;
color: white;
}
.enable-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.edit-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.edit-btn:hover:not(:disabled) {
background: #3498db;
color: white;
}
.edit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #e74c3c;
background: white;
color: #e74c3c;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.delete-btn:hover:not(:disabled) {
background: #e74c3c;
color: white;
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,6 +1,17 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import "./ProviderList.css";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
import { AppType } from "../lib/tauri-api";
import {
applyProviderToVSCode,
detectApplied,
normalizeBaseUrl,
} from "../utils/vscodeSettings";
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -8,6 +19,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> = ({
@@ -16,17 +33,27 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch,
onDelete,
onEdit,
appType,
onNotify,
}) => {
// 提取API地址
const { t } = useTranslation();
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
const config = provider.settingsConfig;
if (config?.env?.ANTHROPIC_BASE_URL) {
return config.env.ANTHROPIC_BASE_URL;
const cfg = provider.settingsConfig;
// Claude/Anthropic: 从 env 中读取
if (cfg?.env?.ANTHROPIC_BASE_URL) {
return cfg.env.ANTHROPIC_BASE_URL;
}
return "未设置";
// Codex: 从 TOML 配置中解析 base_url
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
// 支持单/双引号
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
if (match && match[2]) return match[2];
}
return t("provider.notConfigured");
} catch {
return "配置错误";
return t("provider.configError");
}
};
@@ -34,77 +61,278 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
await window.api.openExternal(url);
} catch (error) {
console.error("打开链接失败:", error);
console.error(t("console.openLinkFailed"), error);
}
};
// 解析 Codex 配置中的 base_url已提取到公共工具
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
useEffect(() => {
const check = async () => {
if (appType !== "codex" || !currentProviderId) {
setVscodeAppliedFor(null);
return;
}
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
setVscodeAppliedFor(null);
return;
}
try {
const content = await window.api.readVSCodeSettings();
const detected = detectApplied(content);
// 认为“已应用”的条件非官方供应商VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
const current = providers[currentProviderId];
let applied = false;
if (current && current.category !== "official") {
const base = getCodexBaseUrl(current);
if (detected.apiBase && base) {
applied =
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
}
}
setVscodeAppliedFor(applied ? currentProviderId : null);
} catch {
setVscodeAppliedFor(null);
}
};
check();
}, [appType, currentProviderId, providers]);
const handleApplyToVSCode = async (provider: Provider) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000);
return;
}
const raw = await window.api.readVSCodeSettings();
const isOfficial = provider.category === "official";
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
onNotify?.(t("notifications.missingBaseUrl"), "error", 4000);
return;
}
}
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
if (next === raw) {
// 幂等:没有变化也提示成功
onNotify?.(t("notifications.appliedToVSCode"), "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.(t("notifications.appliedToVSCode"), "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
} catch (e: any) {
console.error(e);
const msg =
e && e.message ? e.message : t("notifications.syncVSCodeFailed");
onNotify?.(msg, "error", 5000);
}
};
const handleRemoveFromVSCode = async () => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000);
return;
}
const raw = await window.api.readVSCodeSettings();
const next = applyProviderToVSCode(raw, {
baseUrl: undefined,
isOfficial: true,
});
if (next === raw) {
onNotify?.(t("notifications.removedFromVSCode"), "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.(t("notifications.removedFromVSCode"), "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
} catch (e: any) {
console.error(e);
const msg =
e && e.message ? e.message : t("notifications.syncVSCodeFailed");
onNotify?.(msg, "error", 5000);
}
};
// 对供应商列表进行排序
const sortedProviders = Object.values(providers).sort((a, b) => {
// 按添加时间排序
// 没有时间戳的视为最早添加的(排在最前面)
// 有时间戳的按时间升序排列
const timeA = a.createdAt || 0;
const timeB = b.createdAt || 0;
// 如果都没有时间戳,按名称排序
if (timeA === 0 && timeB === 0) {
return a.name.localeCompare(b.name, "zh-CN");
}
// 如果只有一个没有时间戳,没有时间戳的排在前面
if (timeA === 0) return -1;
if (timeB === 0) return 1;
// 都有时间戳,按时间升序
return timeA - timeB;
});
return (
<div className="provider-list">
{Object.values(providers).length === 0 ? (
<div className="empty-state">
<p></p>
<p>"添加供应商"</p>
<div className="space-y-4">
{sortedProviders.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<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">
{t("provider.noProvidersDescription")}
</p>
</div>
) : (
<div className="provider-items">
{Object.values(providers).map((provider) => {
<div className="space-y-3">
{sortedProviders.map((provider) => {
const isCurrent = provider.id === currentProviderId;
const apiUrl = getApiUrl(provider);
return (
<div
key={provider.id}
className={`provider-item ${isCurrent ? "current" : ""}`}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive
)}
>
<div className="provider-info">
<div className="provider-name">
<span>{provider.name}</span>
{isCurrent && (
<span className="current-badge">使</span>
)}
</div>
<div className="provider-url">
{provider.websiteUrl ? (
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="url-link"
title={`访问 ${provider.websiteUrl}`}
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{provider.name}
</h3>
{/* 分类徽章已移除 */}
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible"
)}
>
{provider.websiteUrl}
</a>
) : (
<span className="api-url" title={getApiUrl(provider)}>
{getApiUrl(provider)}
</span>
)}
</div>
</div>
<CheckCircle2 size={12} />
{t("provider.currentlyUsing")}
</div>
</div>
<div className="provider-actions">
<button
className="enable-btn"
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
>
</button>
<button
className="edit-btn"
onClick={() => onEdit(provider.id)}
disabled={isCurrent}
>
</button>
<button
className="delete-btn"
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
>
</button>
<div className="flex items-center gap-2 text-sm">
{provider.websiteUrl ? (
<button
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
title={`访问 ${provider.websiteUrl}`}
>
{provider.websiteUrl}
</button>
) : (
<span
className="text-gray-500 dark:text-gray-400"
title={apiUrl}
>
{apiUrl}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
{appType === "codex" &&
provider.category !== "official" && (
<button
onClick={() =>
vscodeAppliedFor === provider.id
? handleRemoveFromVSCode()
: handleApplyToVSCode(provider)
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] whitespace-nowrap justify-center",
!isCurrent && "invisible",
vscodeAppliedFor === provider.id
? "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-blue-300 hover:text-blue-600 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-700 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
)}
title={
vscodeAppliedFor === provider.id
? t("provider.removeFromVSCode")
: t("provider.applyToVSCode")
}
>
{vscodeAppliedFor === provider.id
? t("provider.removeFromVSCode")
: t("provider.applyToVSCode")}
</button>
)}
<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 w-[76px] 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"
)}
>
{!isCurrent && <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title={t("provider.editProvider")}
>
<Edit3 size={16} />
</button>
<button
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
className={cn(
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"
)}
title={t("provider.deleteProvider")}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,553 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
X,
RefreshCw,
FolderOpen,
Download,
ExternalLink,
Check,
Undo2,
FolderSearch,
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
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;
}
export default function SettingsModal({ onClose }: SettingsModalProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
});
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();
useEffect(() => {
loadSettings();
loadConfigPath();
loadVersion();
loadResolvedDirs();
loadPortableFlag();
}, []);
const loadVersion = async () => {
try {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error(t("console.getVersionFailed"), error);
// 失败时不硬编码版本号,显示为未知
setVersion(t("common.unknown"));
}
};
const loadSettings = async () => {
try {
const loadedSettings = await window.api.getSettings();
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;
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,
});
} catch (error) {
console.error(t("console.loadSettingsFailed"), error);
}
};
const loadConfigPath = async () => {
try {
const path = await window.api.getAppConfigPath();
if (path) {
setConfigPath(path);
}
} catch (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 {
const payload: Settings = {
...settings,
claudeConfigDir:
settings.claudeConfigDir && settings.claudeConfigDir.trim() !== ""
? settings.claudeConfigDir.trim()
: undefined,
codexConfigDir:
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
};
await window.api.saveSettings(payload);
setSettings(payload);
onClose();
} catch (error) {
console.error(t("console.saveSettingsFailed"), error);
}
};
const handleCheckUpdate = async () => {
if (hasUpdate && updateHandle) {
if (isPortable) {
await window.api.checkForUpdates();
return;
}
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
setIsDownloading(true);
try {
resetDismiss();
await updateHandle.downloadAndInstall();
await relaunchApp();
} catch (error) {
console.error(t("console.updateFailed"), error);
// 更新失败时回退到打开 Releases 页面
await window.api.checkForUpdates();
} finally {
setIsDownloading(false);
}
} else {
// 尚未检测到更新:先检查
setIsCheckingUpdate(true);
setShowUpToDate(false);
try {
const hasNewUpdate = await checkUpdate();
// 检查完成后,如果没有更新,显示"已是最新"
if (!hasNewUpdate) {
setShowUpToDate(true);
// 3秒后恢复按钮文字
setTimeout(() => {
setShowUpToDate(false);
}, 3000);
}
} catch (error) {
console.error(t("console.checkUpdateFailed"), error);
// 在开发模式下,模拟已是最新版本的响应
if (import.meta.env.DEV) {
setShowUpToDate(true);
setTimeout(() => {
setShowUpToDate(false);
}, 3000);
} else {
// 生产环境下如果更新插件不可用,回退到打开 Releases 页面
await window.api.checkForUpdates();
}
} finally {
setIsCheckingUpdate(false);
}
}
};
const handleOpenConfigFolder = async () => {
try {
await window.api.openAppConfigFolder();
} catch (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 === unknownLabel) {
await window.api.openExternal(
"https://github.com/farion1231/cc-switch/releases"
);
return;
}
const tag = targetVersion.startsWith("v")
? targetVersion
: `v${targetVersion}`;
await window.api.openExternal(
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
);
} catch (error) {
console.error(t("console.openReleaseNotesFailed"), error);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<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}
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" />
</button>
</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.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>
{/* VS Code 自动同步设置已移除 */}
{/* 配置文件位置 */}
<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 || 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={t("settings.openFolder")}
>
<FolderOpen
size={18}
className="text-gray-500 dark:text-gray-400"
/>
</button>
</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("common.about")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between">
<div>
<div className="text-sm">
<p className="font-medium text-gray-900 dark:text-gray-100">
CC Switch
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t("common.version")} {version}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
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
? 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={`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 border border-transparent"
: hasUpdate
? "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"
}`}
>
{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} />
{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>
</div>
</div>
</div>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={onClose}
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 flex items-center gap-2"
>
<Save size={16} />
{t("common.save")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "../contexts/UpdateContext";
interface UpdateBadgeProps {
className?: string;
onClick?: () => void;
}
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
// 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
return null;
}
return (
<div
className={`
flex items-center gap-1.5 px-2.5 py-1
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg text-xs
shadow-sm
transition-all duration-200
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
${className}
`}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
<span className="text-gray-700 dark:text-gray-300 font-medium">
v{updateInfo.availableVersion}
</span>
<button
onClick={(e) => {
e.stopPropagation();
dismissUpdate();
}}
className="
ml-1 -mr-0.5 p-0.5 rounded
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label="关闭更新提醒"
>
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
</button>
</div>
);
}

View File

@@ -1,12 +1,51 @@
/**
* Codex 预设供应商配置模板
*/
import { ProviderCategory } from "../types";
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
}
/**
* 生成第三方供应商的 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[] = [
@@ -14,7 +53,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
name: "Codex官方",
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
// 官方的 key 为null
category: "official",
auth: {
OPENAI_API_KEY: null,
},
@@ -23,18 +62,12 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://codex.packycode.com/",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: {
OPENAI_API_KEY: "sk-your-api-key-here",
},
config: `model_provider = "packycode"
model = "gpt-5"
model_reasoning_effort = "high"
[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"
env_key = "packycode"`,
category: "third_party",
auth: generateThirdPartyAuth("sk-your-api-key-here"),
config: generateThirdPartyConfig(
"packycode",
"https://codex-api.packycode.com/v1",
"gpt-5-codex"
),
},
];

View File

@@ -1,33 +1,40 @@
/**
* 预设供应商配置模板
*/
import { ProviderCategory } from "../types";
export interface ProviderPreset {
name: string;
websiteUrl: string;
// 新增:第三方/聚合等可单独配置获取 API Key 的链接
apiKeyUrl?: string;
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
}
export const providerPresets: ProviderPreset[] = [
{
name: "Claude官方登录",
name: "Claude官方",
websiteUrl: "https://www.anthropic.com/claude-code",
settingsConfig: {
env: {},
},
isOfficial: true, // 明确标识为官方预设
category: "official",
},
{
name: "DeepSeek v3.1",
name: "DeepSeek",
websiteUrl: "https://platform.deepseek.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "DeepSeek-V3.1-Terminus",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.1-Terminus",
},
},
category: "cn_official",
},
{
name: "智谱GLM",
@@ -35,20 +42,26 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air",
},
},
category: "cn_official",
},
{
name: "千问Qwen-Coder",
name: "Qwen-Coder",
websiteUrl: "https://bailian.console.aliyun.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "qwen3-max",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-max",
},
},
category: "cn_official",
},
{
name: "Kimi k2",
@@ -56,20 +69,36 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
},
},
category: "cn_official",
},
{
name: "魔搭",
websiteUrl: "https://modelscope.cn",
settingsConfig: {
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",
},
},
category: "aggregator",
},
{
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: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
},
},
category: "third_party",
},
];

View File

@@ -0,0 +1,155 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
} from "react";
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
import { checkForUpdate } from "../lib/updater";
interface UpdateContextValue {
// 更新状态
hasUpdate: boolean;
updateInfo: UpdateInfo | null;
updateHandle: UpdateHandle | null;
isChecking: boolean;
error: string | null;
// 提示状态
isDismissed: boolean;
dismissUpdate: () => void;
// 操作方法
checkUpdate: () => Promise<boolean>;
resetDismiss: () => void;
}
const UpdateContext = createContext<UpdateContextValue | undefined>(undefined);
export function UpdateProvider({ children }: { children: React.ReactNode }) {
const DISMISSED_VERSION_KEY = "ccswitch:update:dismissedVersion";
const LEGACY_DISMISSED_KEY = "dismissedUpdateVersion"; // 兼容旧键
const [hasUpdate, setHasUpdate] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
// 从 localStorage 读取已关闭的版本
useEffect(() => {
const current = updateInfo?.availableVersion;
if (!current) return;
// 读取新键;若不存在,尝试迁移旧键
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
if (!dismissedVersion) {
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
if (legacy) {
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
dismissedVersion = legacy;
}
}
setIsDismissed(dismissedVersion === current);
}, [updateInfo?.availableVersion]);
const isCheckingRef = useRef(false);
const checkUpdate = useCallback(async () => {
if (isCheckingRef.current) return false;
isCheckingRef.current = true;
setIsChecking(true);
setError(null);
try {
const result = await checkForUpdate({ timeout: 30000 });
if (result.status === "available") {
setHasUpdate(true);
setUpdateInfo(result.info);
setUpdateHandle(result.update);
// 检查是否已经关闭过这个版本的提醒
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
if (!dismissedVersion) {
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
if (legacy) {
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
dismissedVersion = legacy;
}
}
setIsDismissed(dismissedVersion === result.info.availableVersion);
return true; // 有更新
} else {
setHasUpdate(false);
setUpdateInfo(null);
setUpdateHandle(null);
setIsDismissed(false);
return false; // 已是最新
}
} catch (err) {
console.error("检查更新失败:", err);
setError(err instanceof Error ? err.message : "检查更新失败");
setHasUpdate(false);
throw err; // 抛出错误让调用方处理
} finally {
setIsChecking(false);
isCheckingRef.current = false;
}
}, []);
const dismissUpdate = useCallback(() => {
setIsDismissed(true);
if (updateInfo?.availableVersion) {
localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.availableVersion);
// 清理旧键
localStorage.removeItem(LEGACY_DISMISSED_KEY);
}
}, [updateInfo?.availableVersion]);
const resetDismiss = useCallback(() => {
setIsDismissed(false);
localStorage.removeItem(DISMISSED_VERSION_KEY);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
}, []);
// 应用启动时自动检查更新
useEffect(() => {
// 延迟1秒后检查避免影响启动体验
const timer = setTimeout(() => {
checkUpdate().catch(console.error);
}, 1000);
return () => clearTimeout(timer);
}, [checkUpdate]);
const value: UpdateContextValue = {
hasUpdate,
updateInfo,
updateHandle,
isChecking,
error,
isDismissed,
dismissUpdate,
checkUpdate,
resetDismiss,
};
return (
<UpdateContext.Provider value={value}>{children}</UpdateContext.Provider>
);
}
export function useUpdate() {
const context = useContext(UpdateContext);
if (!context) {
throw new Error("useUpdate must be used within UpdateProvider");
}
return context;
}

85
src/hooks/useDarkMode.ts Normal file
View File

@@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
export function useDarkMode() {
// 初始设为 false挂载后在 useEffect 中加载真实值
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
const [isInitialized, setIsInitialized] = useState(false);
const isDev = import.meta.env.DEV;
// 组件挂载后加载初始值(兼容 Tauri 环境)
useEffect(() => {
if (typeof window === "undefined") return;
try {
// 尝试读取已保存的偏好
const saved = localStorage.getItem("darkMode");
if (saved !== null) {
const savedBool = saved === "true";
setIsDarkMode(savedBool);
if (isDev)
console.log("[DarkMode] Loaded from localStorage:", savedBool);
} else {
// 回退到系统偏好
const prefersDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
setIsDarkMode(prefersDark);
if (isDev)
console.log("[DarkMode] Using system preference:", prefersDark);
}
} catch (error) {
console.error("[DarkMode] Error loading preference:", error);
setIsDarkMode(false);
}
setIsInitialized(true);
}, []); // 仅在首次挂载时运行
// 将 dark 类应用到文档根节点
useEffect(() => {
if (!isInitialized) return;
// 添加短暂延迟以确保 Tauri 中 DOM 已就绪
const timer = setTimeout(() => {
try {
if (isDarkMode) {
document.documentElement.classList.add("dark");
if (isDev) console.log("[DarkMode] Added dark class to document");
} else {
document.documentElement.classList.remove("dark");
if (isDev) console.log("[DarkMode] Removed dark class from document");
}
// 检查类名是否已成功应用
const hasClass = document.documentElement.classList.contains("dark");
if (isDev) console.log("[DarkMode] Document has dark class:", hasClass);
} catch (error) {
console.error("[DarkMode] Error applying dark class:", error);
}
}, 0);
return () => clearTimeout(timer);
}, [isDarkMode, isInitialized]);
// 将偏好保存到 localStorage
useEffect(() => {
if (!isInitialized) return;
try {
localStorage.setItem("darkMode", isDarkMode.toString());
if (isDev) console.log("[DarkMode] Saved to localStorage:", isDarkMode);
} catch (error) {
console.error("[DarkMode] Error saving preference:", error);
}
}, [isDarkMode, isInitialized]);
const toggleDarkMode = () => {
setIsDarkMode((prev) => {
const newValue = !prev;
if (isDev) console.log("[DarkMode] Toggling from", prev, "to", newValue);
return newValue;
});
};
return { isDarkMode, toggleDarkMode };
}

View File

@@ -0,0 +1,99 @@
import { useState, useEffect, useCallback } from "react";
const VSCODE_AUTO_SYNC_KEY = "vscode-auto-sync-enabled";
const VSCODE_AUTO_SYNC_EVENT = "vscode-auto-sync-changed";
export function useVSCodeAutoSync() {
// 默认开启自动同步;若本地存储存在记录,则以记录为准
const [isAutoSyncEnabled, setIsAutoSyncEnabled] = useState<boolean>(true);
// 从 localStorage 读取初始状态
useEffect(() => {
try {
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
if (saved !== null) {
setIsAutoSyncEnabled(saved === "true");
}
} catch (error) {
console.error("读取自动同步状态失败:", error);
}
}, []);
// 订阅同窗口的自定义事件,以及跨窗口的 storage 事件,实现全局同步
useEffect(() => {
const onCustom = (e: Event) => {
try {
const detail = (e as CustomEvent).detail as
| { enabled?: boolean }
| undefined;
if (detail && typeof detail.enabled === "boolean") {
setIsAutoSyncEnabled(detail.enabled);
} else {
// 兜底:从 localStorage 读取
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
if (saved !== null) setIsAutoSyncEnabled(saved === "true");
}
} catch {
// 忽略
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === VSCODE_AUTO_SYNC_KEY) {
setIsAutoSyncEnabled(e.newValue === "true");
}
};
window.addEventListener(VSCODE_AUTO_SYNC_EVENT, onCustom as EventListener);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(
VSCODE_AUTO_SYNC_EVENT,
onCustom as EventListener,
);
window.removeEventListener("storage", onStorage);
};
}, []);
// 启用自动同步
const enableAutoSync = useCallback(() => {
try {
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "true");
setIsAutoSyncEnabled(true);
// 通知同窗口其他订阅者
window.dispatchEvent(
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: true } }),
);
} catch (error) {
console.error("保存自动同步状态失败:", error);
}
}, []);
// 禁用自动同步
const disableAutoSync = useCallback(() => {
try {
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "false");
setIsAutoSyncEnabled(false);
// 通知同窗口其他订阅者
window.dispatchEvent(
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: false } }),
);
} catch (error) {
console.error("保存自动同步状态失败:", error);
}
}, []);
// 切换自动同步状态
const toggleAutoSync = useCallback(() => {
if (isAutoSyncEnabled) {
disableAutoSync();
} else {
enableAutoSync();
}
}, [isAutoSyncEnabled, enableAutoSync, disableAutoSync]);
return {
isAutoSyncEnabled,
enableAutoSync,
disableAutoSync,
toggleAutoSync,
};
}

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

@@ -0,0 +1,29 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n.use(initReactI18next).init({
resources,
lng: "en", // 默认语言设置为英文
fallbackLng: "en", // 回退语言也设置为英文
interpolation: {
escapeValue: false, // React 已经默认转义
},
// 开发模式下显示调试信息
debug: false,
});
export default i18n;

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

@@ -0,0 +1,111 @@
{
"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",
"applyToVSCode": "Apply to VS Code",
"removeFromVSCode": "Remove from VS Code"
},
"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",
"appliedToVSCode": "Applied to VS Code, restart Codex plugin to take effect",
"removedFromVSCode": "Removed from VS Code, restart Codex plugin to take effect",
"syncedToVSCode": "Synced to VS Code",
"vscodeSettingsNotFound": "VS Code user settings file (settings.json) not found",
"missingBaseUrl": "Current configuration missing base_url, cannot write to VS Code",
"saveFailed": "Save failed: {{error}}",
"saveFailedGeneric": "Save failed, please try again",
"syncVSCodeFailed": "Sync to VS Code failed"
},
"confirm": {
"deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
},
"settings": {
"title": "Settings",
"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:",
"syncToVSCodeFailed": "Sync to VS Code 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:"
}
}

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

@@ -0,0 +1,111 @@
{
"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": "未配置官网地址",
"applyToVSCode": "应用到 VS Code",
"removeFromVSCode": "从 VS Code 移除"
},
"notifications": {
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchFailed": "切换失败,请检查配置",
"autoImported": "已从现有配置创建默认供应商",
"appliedToVSCode": "已应用到 VS Code重启 Codex 插件以生效",
"removedFromVSCode": "已从 VS Code 移除,重启 Codex 插件以生效",
"syncedToVSCode": "已同步到 VS Code",
"vscodeSettingsNotFound": "未找到 VS Code 用户设置文件 (settings.json)",
"missingBaseUrl": "当前配置缺少 base_url无法写入 VS Code",
"saveFailed": "保存失败:{{error}}",
"saveFailedGeneric": "保存失败,请重试",
"syncVSCodeFailed": "同步 VS Code 失败"
},
"confirm": {
"deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
},
"settings": {
"title": "设置",
"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": "更新供应商失败:",
"syncToVSCodeFailed": "同步到VS Code失败:",
"autoImportFailed": "自动导入默认配置失败:",
"openLinkFailed": "打开链接失败:",
"getVersionFailed": "获取版本信息失败:",
"loadSettingsFailed": "加载设置失败:",
"getConfigPathFailed": "获取配置路径失败:",
"getConfigDirFailed": "获取配置目录失败:",
"detectPortableFailed": "检测便携模式失败:",
"saveSettingsFailed": "保存设置失败:",
"updateFailed": "更新失败:",
"checkUpdateFailed": "检查更新失败:",
"openConfigFolderFailed": "打开配置文件夹失败:",
"selectConfigDirFailed": "选择配置目录失败:",
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
"openReleaseNotesFailed": "打开更新日志失败:"
}
}

View File

@@ -1,30 +1,56 @@
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为“类选择器”模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* 全局基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
@apply font-sans antialiased;
line-height: 1.5;
/* 让原生控件与滚动条随主题切换配色 */
color-scheme: light;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
@apply m-0 p-0 bg-gray-50 text-gray-900 text-sm dark:bg-gray-950 dark:text-gray-100;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
/* 暗色模式下启用暗色原生控件/滚动条配色 */
html.dark {
color-scheme: dark;
}
/* 仅在 macOS 下为顶部预留交通灯空间 */
/* 保持 mac 下与内容区域左对齐(不额外偏移) */
/* 在 macOS 下稍微增加 banner 高度,拉开与交通灯的垂直距离 */
body.is-mac .app-header {
padding-top: 1.4rem;
padding-bottom: 1.4rem;
/* 滚动条样式(避免在伪元素中使用自定义 dark 变体,消除构建警告) */
::-webkit-scrollbar {
width: 0.375rem;
height: 0.375rem;
}
::-webkit-scrollbar-track {
background-color: #f4f4f5;
}
html.dark ::-webkit-scrollbar-track {
background-color: #27272a;
}
::-webkit-scrollbar-thumb {
background-color: #d4d4d8;
border-radius: 0.25rem;
}
html.dark ::-webkit-scrollbar-thumb {
background-color: #52525b;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a1a1aa;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background-color: #71717a;
}
/* 焦点样式 */
*:focus-visible {
@apply outline-2 outline-blue-500 outline-offset-2;
}

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;
}
};

79
src/lib/styles.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* 复用的 Tailwind 样式组合,覆盖常见 UI 模式
*/
// 按钮样式
export const buttonStyles = {
// 主按钮:蓝底白字
primary:
"px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
// 次按钮:灰背景,深色文本
secondary:
"px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
// 危险按钮:用于不可撤销/破坏性操作
danger:
"px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
// 幽灵按钮:无背景,仅悬浮反馈
ghost:
"px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
// 图标按钮:小尺寸,仅图标
icon: "p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors",
// 禁用态:可与其他样式组合
disabled: "opacity-50 cursor-not-allowed pointer-events-none",
} as const;
// 卡片样式
export const cardStyles = {
// 基础卡片容器
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700",
// 带悬浮效果的卡片
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",
// 选中/激活态卡片
selected:
"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;
// 输入控件样式
export const inputStyles = {
// 文本输入框
text: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
// 下拉选择框
select:
"w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
// 复选框
checkbox:
"w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
} as const;
// 徽标Badge样式
export const badgeStyles = {
// 成功徽标
success:
"inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
// 信息徽标
info: "inline-flex items-center gap-1 px-2 py-1 bg-blue-500/10 text-blue-500 rounded-md text-xs font-medium",
// 警告徽标
warning:
"inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
// 错误徽标
error:
"inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
} as const;
// 组合类名的工具函数
export function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { Provider } from "../types";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { Provider, Settings } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -121,6 +122,16 @@ export const tauriAPI = {
}
},
// 获取当前生效的配置目录
getConfigDir: async (app?: AppType): Promise<string> => {
try {
return await invoke("get_config_dir", { app_type: app, app });
} catch (error) {
console.error("获取配置目录失败:", error);
return "";
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
try {
@@ -167,12 +178,133 @@ export const tauriAPI = {
}
},
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke("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;
// 选择配置目录
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
const sanitized =
defaultPath && defaultPath.trim() !== ""
? defaultPath
: undefined;
return await invoke<string | null>("pick_directory", {
defaultPath: sanitized,
});
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
// 获取设置
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
return { showInTray: true, minimizeToTrayOnClose: true };
}
},
// 保存设置
saveSettings: async (settings: Settings): Promise<boolean> => {
try {
return await invoke("save_settings", { settings });
} catch (error) {
console.error("保存设置失败:", error);
return false;
}
},
// 检查更新
checkForUpdates: async (): Promise<void> => {
try {
await invoke("check_for_updates");
} catch (error) {
console.error("检查更新失败:", error);
}
},
// 判断是否为便携模式
isPortable: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_portable_mode");
} catch (error) {
console.error("检测便携模式失败:", error);
return false;
}
},
// 获取应用配置文件路径
getAppConfigPath: async (): Promise<string> => {
try {
return await invoke("get_app_config_path");
} catch (error) {
console.error("获取应用配置路径失败:", error);
return "";
}
},
// 打开应用配置文件夹
openAppConfigFolder: async (): Promise<void> => {
try {
await invoke("open_app_config_folder");
} catch (error) {
console.error("打开应用配置文件夹失败:", error);
}
},
// VS Code: 获取 settings.json 状态
getVSCodeSettingsStatus: async (): Promise<{
exists: boolean;
path: string;
error?: string;
}> => {
try {
return await invoke("get_vscode_settings_status");
} catch (error) {
console.error("获取 VS Code 设置状态失败:", error);
return { exists: false, path: "", error: String(error) };
}
},
// VS Code: 读取 settings.json 文本
readVSCodeSettings: async (): Promise<string> => {
try {
return await invoke("read_vscode_settings");
} catch (error) {
throw new Error(`读取 VS Code 设置失败: ${String(error)}`);
}
},
// VS Code: 写回 settings.json 文本(不自动创建)
writeVSCodeSettings: async (content: string): Promise<boolean> => {
try {
return await invoke("write_vscode_settings", { content });
} catch (error) {
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
}
},
};

126
src/lib/updater.ts Normal file
View File

@@ -0,0 +1,126 @@
import { getVersion } from "@tauri-apps/api/app";
// 可选导入:在未注册插件或非 Tauri 环境下,调用时会抛错,外层需做兜底
// 我们按需加载并在运行时捕获错误,避免构建期类型问题
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { Update } from "@tauri-apps/plugin-updater";
export type UpdateChannel = "stable" | "beta";
export type UpdaterPhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "restarting"
| "upToDate"
| "error";
export interface UpdateInfo {
currentVersion: string;
availableVersion: string;
notes?: string;
pubDate?: string;
}
export interface UpdateProgressEvent {
event: "Started" | "Progress" | "Finished";
total?: number;
downloaded?: number;
}
export interface UpdateHandle {
version: string;
notes?: string;
date?: string;
downloadAndInstall: (
onProgress?: (e: UpdateProgressEvent) => void,
) => Promise<void>;
download?: () => Promise<void>;
install?: () => Promise<void>;
}
export interface CheckOptions {
timeout?: number;
channel?: UpdateChannel;
}
function mapUpdateHandle(raw: Update): UpdateHandle {
return {
version: (raw as any).version ?? "",
notes: (raw as any).notes,
date: (raw as any).date,
async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) {
await (raw as any).downloadAndInstall((evt: any) => {
if (!onProgress) return;
const mapped: UpdateProgressEvent = {
event: evt?.event,
};
if (evt?.event === "Started") {
mapped.total = evt?.data?.contentLength ?? 0;
mapped.downloaded = 0;
} else if (evt?.event === "Progress") {
mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成
}
onProgress(mapped);
});
},
// 透传可选 API若插件版本支持
download: (raw as any).download
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).download();
}
: undefined,
install: (raw as any).install
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).install();
}
: undefined,
};
}
export async function getCurrentVersion(): Promise<string> {
try {
return await getVersion();
} catch {
return "";
}
}
export async function checkForUpdate(
opts: CheckOptions = {},
): Promise<
| { status: "up-to-date" }
| { status: "available"; info: UpdateInfo; update: UpdateHandle }
> {
// 动态引入,避免在未安装插件时导致打包期问题
const { check } = await import("@tauri-apps/plugin-updater");
const currentVersion = await getCurrentVersion();
const update = await check({ timeout: opts.timeout ?? 30000 } as any);
if (!update) {
return { status: "up-to-date" };
}
const mapped = mapUpdateHandle(update);
const info: UpdateInfo = {
currentVersion,
availableVersion: mapped.version,
notes: mapped.notes,
pubDate: mapped.date,
};
return { status: "available", info, update: mapped };
}
export async function relaunchApp(): Promise<void> {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用

View File

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

View File

@@ -1,11 +1,33 @@
export type ProviderCategory =
| "official" // 官方
| "cn_official" // 国产官方
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
| "custom"; // 自定义
export interface Provider {
id: string;
name: string;
settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config }
websiteUrl?: string;
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
}
export interface AppConfig {
providers: Record<string, Provider>;
current: string;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
}

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,54 +1,167 @@
// 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除
export const updateCoAuthoredSetting = (
jsonString: string,
disable: boolean,
): string => {
try {
const config = JSON.parse(jsonString);
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
if (disable) {
// 添加或更新includeCoAuthoredBy字段
config.includeCoAuthoredBy = false;
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;
}
};
// 从JSON配置中提取并处理官网地址
export const extractWebsiteUrl = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString);
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
if (baseUrl && typeof baseUrl === "string") {
// 去掉 "api." 前缀
return baseUrl.replace(/^https?:\/\/api\./, "https://");
}
} catch (err) {
// 忽略JSON解析错误
}
return "";
};
// 读取配置中的 API Keyenv.ANTHROPIC_AUTH_TOKEN
export const getApiKeyFromConfig = (jsonString: string): string => {
try {
@@ -95,3 +208,113 @@ 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;
}
};

134
src/utils/vscodeSettings.ts Normal file
View File

@@ -0,0 +1,134 @@
import { applyEdits, modify, parse } from "jsonc-parser";
const fmt = { insertSpaces: true, tabSize: 2, eol: "\n" } as const;
export interface AppliedCheck {
hasApiBase: boolean;
apiBase?: string;
hasPreferredAuthMethod: boolean;
}
export function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}
const isDocEmpty = (s: string) => s.trim().length === 0;
// 检查 settings.jsonJSONC 文本)中是否已经应用了我们的键
export function detectApplied(content: string): AppliedCheck {
try {
// 允许 JSONC 的宽松解析jsonc-parser 的 parse 可以直接处理注释
const data = parse(content) as any;
const apiBase = data?.["chatgpt.apiBase"];
const method = data?.["chatgpt.config"]?.preferred_auth_method;
return {
hasApiBase: typeof apiBase === "string",
apiBase,
hasPreferredAuthMethod: typeof method === "string",
};
} catch {
return { hasApiBase: false, hasPreferredAuthMethod: false };
}
}
// 生成“清理我们管理的键”后的文本(仅删除我们写入的两个键)
export function removeManagedKeys(content: string): string {
if (isDocEmpty(content)) return content; // 空文档无需删除
let out = content;
// 删除 chatgpt.apiBase
try {
out = applyEdits(
out,
modify(out, ["chatgpt.apiBase"], undefined, { formattingOptions: fmt }),
);
} catch {
// 忽略删除失败
}
// 删除 chatgpt.config.preferred_auth_method注意 chatgpt.config 是顶层带点的键)
try {
out = applyEdits(
out,
modify(out, ["chatgpt.config", "preferred_auth_method"], undefined, {
formattingOptions: fmt,
}),
);
} catch {
// 忽略删除失败
}
// 兼容早期错误写入:若曾写成嵌套 chatgpt.config.preferred_auth_method也一并清理
try {
out = applyEdits(
out,
modify(out, ["chatgpt", "config", "preferred_auth_method"], undefined, {
formattingOptions: fmt,
}),
);
} catch {
// 忽略删除失败
}
// 清理 chatgpt.config 的异常情况:
// 1. 早期遗留的标量值(字符串/数字/null等
// 2. 空对象
// 3. 数组类型
try {
const data = parse(out) as any;
const cfg = data?.["chatgpt.config"];
// 需要清理的情况:
// - 标量值null、字符串、数字等
// - 数组
// - 空对象
const shouldRemove = cfg !== undefined && (
cfg === null ||
typeof cfg !== "object" ||
Array.isArray(cfg) ||
(typeof cfg === "object" && Object.keys(cfg).length === 0)
);
if (shouldRemove) {
out = applyEdits(
out,
modify(out, ["chatgpt.config"], undefined, { formattingOptions: fmt }),
);
}
} catch {
// 忽略解析失败,保持已删除的键
}
return out;
}
// 生成“应用供应商到 VS Code”后的文本
// - 先清理我们管理的键
// - 再根据是否官方决定写入(官方:不写入;非官方:写入两个键)
export function applyProviderToVSCode(
content: string,
opts: { baseUrl?: string | null; isOfficial?: boolean },
): string {
let out = removeManagedKeys(content);
if (!opts.isOfficial && opts.baseUrl) {
const apiBase = normalizeBaseUrl(opts.baseUrl);
if (isDocEmpty(out)) {
// 简化:空文档直接写入新对象
const obj: any = {
"chatgpt.apiBase": apiBase,
"chatgpt.config": { preferred_auth_method: "apikey" },
};
out = JSON.stringify(obj, null, 2) + "\n";
} else {
out = applyEdits(
out,
modify(out, ["chatgpt.apiBase"], apiBase, { formattingOptions: fmt }),
);
out = applyEdits(
out,
modify(out, ["chatgpt.config", "preferred_auth_method"], "apikey", {
formattingOptions: fmt,
}),
);
}
}
return out;
}

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

@@ -1,7 +1,8 @@
/// <reference types="vite/client" />
import { Provider } from "./types";
import { Provider, Settings } from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
interface ImportResult {
success: boolean;
@@ -27,9 +28,24 @@ declare global {
getClaudeCodeConfigPath: () => Promise<string>;
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
selectConfigFile: () => Promise<string | null>;
getConfigDir: (app?: AppType) => Promise<string>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>;
onProviderSwitched: (
callback: (data: { appType: string; providerId: string }) => void,
) => Promise<UnlistenFn>;
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
checkForUpdates: () => Promise<void>;
isPortable: () => Promise<boolean>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// VS Code settings.json 能力
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
readVSCodeSettings: () => Promise<string>;
writeVSCodeSettings: (content: string) => Promise<boolean>;
};
platform: {
isMac: boolean;

62
tailwind.config.js Normal file
View File

@@ -0,0 +1,62 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// 扩展蓝色系列以匹配 Linear 风格
blue: {
500: '#3498db',
600: '#2980b9',
400: '#5dade2',
},
// 自定义灰色系列
gray: {
50: '#fafafa', // bg-primary
100: '#f4f4f5', // bg-tertiary
200: '#e4e4e7', // border
300: '#d4d4d8', // border-hover
400: '#a1a1aa', // text-tertiary
500: '#71717a', // text-secondary
600: '#52525b', // text-secondary-dark
700: '#3f3f46', // bg-tertiary-dark
800: '#27272a', // bg-secondary-dark
900: '#18181b', // text-primary
950: '#0a0a0b', // bg-primary-dark
},
// 状态颜色
green: {
500: '#10b981',
100: '#d1fae5',
},
red: {
500: '#ef4444',
100: '#fee2e2',
},
amber: {
500: '#f59e0b',
100: '#fef3c7',
},
},
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
borderRadius: {
'sm': '0.375rem',
'md': '0.5rem',
'lg': '0.75rem',
'xl': '0.875rem',
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'],
},
},
},
plugins: [],
}

View File

@@ -2,8 +2,8 @@
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "CommonJS",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"esModuleInterop": true,
@@ -11,5 +11,5 @@
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
"include": ["vite.config.mts"]
}

19
vite.config.mts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "src",
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "../dist",
emptyOutDir: true,
},
server: {
port: 3000,
strictPort: true,
},
clearScreen: false,
envPrefix: ["VITE_", "TAURI_"],
});

View File

@@ -1,18 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: 'src',
plugins: [react()],
base: './',
build: {
outDir: '../dist',
emptyOutDir: true
},
server: {
port: 3000,
strictPort: true
},
clearScreen: false,
envPrefix: ['VITE_', 'TAURI_']
})