88 Commits

Author SHA1 Message Date
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
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
54 changed files with 4039 additions and 590 deletions

View File

@@ -8,15 +8,19 @@ on:
permissions: permissions:
contents: write contents: write
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: true
jobs: jobs:
release: release:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
include: include:
- os: windows-latest - os: windows-2022
- os: ubuntu-latest - os: ubuntu-22.04
- os: macos-latest - os: macos-14
steps: steps:
- name: Checkout - name: Checkout
@@ -74,7 +78,7 @@ jobs:
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ${{ steps.pnpm-store.outputs.path }} path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -83,22 +87,72 @@ jobs:
- name: Install frontend deps - name: Install frontend deps
run: pnpm install --frozen-lockfile 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) - name: Build Tauri App (macOS)
if: runner.os == 'macOS' if: runner.os == 'macOS'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: pnpm tauri build --target universal-apple-darwin run: pnpm tauri build --target universal-apple-darwin
- name: Build Tauri App (Windows) - name: Build Tauri App (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: pnpm tauri build run: pnpm tauri build
- name: Build Tauri App (Linux) - name: Build Tauri App (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: pnpm tauri build run: pnpm tauri build
- name: Prepare macOS Assets - name: Prepare macOS Assets
@@ -107,29 +161,34 @@ jobs:
run: | run: |
set -euxo pipefail set -euxo pipefail
mkdir -p release-assets mkdir -p release-assets
echo "Looking for .app bundle..." echo "Looking for updater artifact (.tar.gz) and .app for zip..."
APP_PATH="" TAR_GZ=""; APP_PATH=""
for path in \ for path in \
"src-tauri/target/release/bundle/macos" \
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \ "src-tauri/target/universal-apple-darwin/release/bundle/macos" \
"src-tauri/target/aarch64-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 if [ -d "$path" ]; then
APP_PATH=$(find "$path" -name "*.app" -type d | head -1) [ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
[ -n "$APP_PATH" ] && break [ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
fi fi
done done
if [ -z "$APP_PATH" ]; then if [ -z "$TAR_GZ" ]; then
echo "No .app found" >&2 echo "No macOS .tar.gz updater artifact found" >&2
exit 1 exit 1
fi fi
APP_DIR=$(dirname "$APP_PATH") cp "$TAR_GZ" release-assets/
APP_NAME=$(basename "$APP_PATH") [ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
cd "$APP_DIR" echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
# 使用 ditto 打包更兼容资源分叉 if [ -n "$APP_PATH" ]; then
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip" APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/" cd "$APP_DIR"
echo "macOS zip ready" 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 - name: Prepare Windows Assets
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -137,18 +196,27 @@ jobs:
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null New-Item -ItemType Directory -Force -Path release-assets | Out-Null
# 安装器(优先 NSIS其次 MSI # 仅打包 MSI 安装器 + .sig用于 Updater
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue | $msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } | if ($null -eq $msi) {
Select-Object -First 1 # 兜底:全局搜索 .msi
if ($null -ne $installer) { $msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
$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'
} }
# 绿色版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 = @( $exeCandidates = @(
'src-tauri/target/release/cc-switch.exe', 'src-tauri/target/release/cc-switch.exe',
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe' 'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
@@ -171,14 +239,22 @@ jobs:
run: | run: |
set -euxo pipefail set -euxo pipefail
mkdir -p release-assets 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) DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then if [ -n "$DEB" ]; then
cp "$DEB" release-assets/ cp "$DEB" release-assets/
echo "Deb package copied" echo "Deb package copied"
else else
echo "No .deb found" >&2 echo "No .deb found (optional)"
exit 1
fi fi
- name: List prepared assets - name: List prepared assets
@@ -189,18 +265,16 @@ jobs:
- name: Collect Signatures - name: Collect Signatures
shell: bash shell: bash
run: | run: |
# 查找并复制签名文件到 release-assets set -euo pipefail
find src-tauri/target -name "*.sig" -type f 2>/dev/null | while read sig; do echo "Collected signatures (if any alongside artifacts):"
cp "$sig" release-assets/ || true
done
echo "Collected signatures:"
ls -la release-assets/*.sig || echo "No signatures found" ls -la release-assets/*.sig || echo "No signatures found"
- name: Upload Release Assets - name: Upload Release Assets
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: CC Switch ${{ github.ref_name }} name: CC Switch ${{ github.ref_name }}
prerelease: true
body: | body: |
## CC Switch ${{ github.ref_name }} ## CC Switch ${{ github.ref_name }}
@@ -209,7 +283,7 @@ jobs:
### 下载 ### 下载
- macOS: `CC-Switch-macOS.zip`(解压即用) - 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 安装包) - Linux: `*.deb`Debian/Ubuntu 安装包)
--- ---
@@ -224,3 +298,92 @@ jobs:
run: | run: |
echo "Listing bundles in src-tauri/target..." echo "Listing bundles in src-tauri/target..."
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true 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"

1
.gitignore vendored
View File

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

View File

@@ -5,6 +5,33 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 ## [3.1.1] - 2025-09-03
### 🐛 Bug Fixes ### 🐛 Bug Fixes

View File

@@ -1,26 +1,28 @@
# Claude Code & Codex 供应商切换器 # Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.2.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/jasonyoung/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.0-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。 一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与备份”)。 > v3.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍! > v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
## 功能特性 > v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用 ## 功能特性v3.2.0
- 一键切换不同供应商
- 同时支持 Claude Code 与 Codex 的供应商切换与导入 - **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置 - **系统托盘(菜单栏)快速切换**按应用分组Claude / Codex勾选态展示当前供应商
- 支持添加自定义供应商 - **内置更新器**:集成 Tauri Updater支持检测/下载/安装与一键重启
- 随时切换官方登录 - **单一事实源SSOT**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
- 简洁美观的图形界面 - **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
- 信息存储在本地 ~/.cc-switch/config.json无隐私风险 - **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
- 超小体积 - 仅 ~5MB 安装包 - **深色模式优化**Tailwind v4 适配与选择器修正
- **丰富预设与自定义**Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
## 界面预览 ## 界面预览
@@ -57,35 +59,52 @@
## 使用说明 ## 使用说明
1. 点击"添加供应商"添加你的 API 配置 1. 点击"添加供应商"添加你的 API 配置
2. 选择要使用的供应商,点击单选按钮切换 2. 切换方式:
3. 配置会自动保存到对应应用的配置文件中 - 在主界面选择供应商后点击切换
4. 重启或者新打开终端以生效 - 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
5. 如果需要切回 Claude 官方登录可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录 3. 切换会写入对应应用的“live 配置文件”Claude`settings.json`Codex`auth.json` + `config.toml`
4. 重启或新开终端以确保生效
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
### Codex 说明 ### 检查更新
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Codex 说明v3.2.0 SSOT
- 配置目录:`~/.codex/` - 配置目录:`~/.codex/`
- 主配置文件`auth.json`(必需)、`config.toml`(可为空) - live 主配置:`auth.json`(必需)、`config.toml`(可为空)
- 供应商副本:`auth-<name>.json``config-<name>.toml`
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY` - API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json``config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml` - 切换行为(不再写“副本文件”):
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。 - 供应商配置统一保存在 `~/.cc-switch/config.json`
- 官方登录可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。 - 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Claude Code 说明 ### Claude Code 说明v3.2.0 SSOT
- 配置目录:`~/.claude/` - 配置目录:`~/.claude/`
- 主配置文件`settings.json`推荐)或 `claude.json`(旧版兼容,若存在则继续使用) - live 主配置:`settings.json`优先)或历史兼容 `claude.json`
- 供应商副本:`settings-<name>.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN` - API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件 - 切换行为(不再写“副本文件”):
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。 - 供应商配置统一保存在 `~/.cc-switch/config.json`
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。 - 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 迁移与备份 ### 迁移与归档v3.2.0
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json` - 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- 实际生效的应用配置文件(如 `~/.claude/settings.json``~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。 - 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` 以便回滚
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
## 开发 ## 开发
@@ -138,7 +157,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/)** - 用户界面库 - **[React 18](https://react.dev/)** - 用户界面库
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript - **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具 - **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
@@ -177,6 +196,10 @@ cargo test
欢迎提交 Issue 和 Pull Request 欢迎提交 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 ## License
MIT © Jason Young MIT © Jason Young

View File

@@ -1,6 +1,6 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.1.1", "version": "3.2.0",
"description": "Claude Code & Codex 供应商切换工具", "description": "Claude Code & Codex 供应商切换工具",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
@@ -32,9 +32,11 @@
"@codemirror/view": "^6.38.2", "@codemirror/view": "^6.38.2",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tauri-apps/api": "^2.8.0", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

18
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.8.0 specifier: ^2.8.0
version: 2.8.0 version: 2.8.0
'@tauri-apps/plugin-dialog':
specifier: ^2.4.0
version: 2.4.0
'@tauri-apps/plugin-process': '@tauri-apps/plugin-process':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.3.0 version: 2.3.0
@@ -35,6 +38,9 @@ importers:
codemirror: codemirror:
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2 version: 6.0.2
jsonc-parser:
specifier: ^3.2.1
version: 3.3.1
lucide-react: lucide-react:
specifier: ^0.542.0 specifier: ^0.542.0
version: 0.542.0(react@18.3.1) version: 0.542.0(react@18.3.1)
@@ -632,6 +638,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-dialog@2.4.0':
resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==}
'@tauri-apps/plugin-process@2.3.0': '@tauri-apps/plugin-process@2.3.0':
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
@@ -755,6 +764,9 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -1439,6 +1451,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.1 '@tauri-apps/cli-win32-ia32-msvc': 2.8.1
'@tauri-apps/cli-win32-x64-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': '@tauri-apps/plugin-process@2.3.0':
dependencies: dependencies:
'@tauri-apps/api': 2.8.0 '@tauri-apps/api': 2.8.0
@@ -1580,6 +1596,8 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jsonc-parser@3.3.1: {}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
optional: true optional: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 162 KiB

336
src-tauri/Cargo.lock generated
View File

@@ -105,6 +105,27 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ashpd"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus 5.11.0",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -559,7 +580,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.1.1" version = "3.2.0"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"log", "log",
@@ -569,6 +590,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process", "tauri-plugin-process",
@@ -934,6 +956,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [ dependencies = [
"bitflags 2.9.3", "bitflags 2.9.3",
"block2 0.6.1",
"libc",
"objc2 0.6.2", "objc2 0.6.2",
] ]
@@ -948,6 +972,15 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "dlib"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading",
]
[[package]] [[package]]
name = "dlopen2" name = "dlopen2"
version = "0.8.0" version = "0.8.0"
@@ -971,6 +1004,12 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -2351,6 +2390,19 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.9.3",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
[[package]] [[package]]
name = "nodrop" name = "nodrop"
version = "0.1.14" version = "0.1.14"
@@ -2986,7 +3038,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.11.0", "indexmap 2.11.0",
"quick-xml", "quick-xml 0.38.2",
"serde", "serde",
"time", "time",
] ]
@@ -3068,6 +3120,15 @@ dependencies = [
"toml_edit 0.20.2", "toml_edit 0.20.2",
] ]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit 0.23.4",
]
[[package]] [[package]]
name = "proc-macro-error" name = "proc-macro-error"
version = "1.0.4" version = "1.0.4"
@@ -3127,6 +3188,15 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.2" version = "0.38.2"
@@ -3458,6 +3528,31 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2 0.6.1",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.2",
"objc2-app-kit 0.3.1",
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3658,6 +3753,12 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -4320,6 +4421,46 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.16",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.16",
"toml 0.9.5",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.6.0" version = "2.6.0"
@@ -4361,7 +4502,7 @@ dependencies = [
"thiserror 2.0.16", "thiserror 2.0.16",
"url", "url",
"windows 0.58.0", "windows 0.58.0",
"zbus", "zbus 4.0.1",
] ]
[[package]] [[package]]
@@ -4640,8 +4781,10 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"slab", "slab",
"socket2", "socket2",
"tracing",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -4737,6 +4880,18 @@ dependencies = [
"winnow 0.5.40", "winnow 0.5.40",
] ]
[[package]]
name = "toml_edit"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93"
dependencies = [
"indexmap 2.11.0",
"toml_datetime 0.7.0",
"toml_parser",
"winnow 0.7.13",
]
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.0.2" version = "1.0.2"
@@ -5154,6 +5309,66 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "wayland-backend"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
"bitflags 2.9.3",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
dependencies = [
"bitflags 2.9.3",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml 0.37.5",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.77" version = "0.3.77"
@@ -5795,6 +6010,9 @@ name = "winnow"
version = "0.7.13" version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
@@ -5963,7 +6181,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"hex", "hex",
"nix", "nix 0.27.1",
"ordered-stream", "ordered-stream",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
@@ -5974,9 +6192,37 @@ dependencies = [
"uds_windows", "uds_windows",
"windows-sys 0.52.0", "windows-sys 0.52.0",
"xdg-home", "xdg-home",
"zbus_macros", "zbus_macros 4.0.1",
"zbus_names", "zbus_names 3.0.0",
"zvariant", "zvariant 4.0.0",
]
[[package]]
name = "zbus"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"nix 0.30.1",
"ordered-stream",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"windows-sys 0.60.2",
"winnow 0.7.13",
"zbus_macros 5.11.0",
"zbus_names 4.2.0",
"zvariant 5.7.0",
] ]
[[package]] [[package]]
@@ -5990,7 +6236,22 @@ dependencies = [
"quote", "quote",
"regex", "regex",
"syn 1.0.109", "syn 1.0.109",
"zvariant_utils", "zvariant_utils 1.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.106",
"zbus_names 4.2.0",
"zvariant 5.7.0",
"zvariant_utils 3.2.1",
] ]
[[package]] [[package]]
@@ -6001,7 +6262,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [ dependencies = [
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant", "zvariant 4.0.0",
]
[[package]]
name = "zbus_names"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
dependencies = [
"serde",
"static_assertions",
"winnow 0.7.13",
"zvariant 5.7.0",
] ]
[[package]] [[package]]
@@ -6106,7 +6379,22 @@ dependencies = [
"enumflags2", "enumflags2",
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant_derive", "zvariant_derive 4.0.0",
]
[[package]]
name = "zvariant"
version = "5.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db"
dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 0.7.13",
"zvariant_derive 5.7.0",
"zvariant_utils 3.2.1",
] ]
[[package]] [[package]]
@@ -6119,7 +6407,20 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn 1.0.109",
"zvariant_utils", "zvariant_utils 1.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.106",
"zvariant_utils 3.2.1",
] ]
[[package]] [[package]]
@@ -6132,3 +6433,16 @@ dependencies = [
"quote", "quote",
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "zvariant_utils"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.106",
"winnow 0.7.13",
]

View File

@@ -1,10 +1,10 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.1.1" version = "3.2.0"
description = "Claude Code & Codex 供应商配置管理工具" description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
repository = "https://github.com/jasonyoung/cc-switch" repository = "https://github.com/farion1231/cc-switch"
edition = "2021" edition = "2021"
rust-version = "1.85.0" rust-version = "1.85.0"
@@ -26,9 +26,18 @@ tauri-plugin-log = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
dirs = "5.0" dirs = "5.0"
toml = "0.8" toml = "0.8"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5" objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] } 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

@@ -9,6 +9,7 @@
"core:default", "core:default",
"opener:default", "opener:default",
"updater:default", "updater:default",
"process:allow-restart" "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

@@ -10,6 +10,10 @@ use std::path::Path;
/// 获取 Codex 配置目录路径 /// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf { 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") dirs::home_dir().expect("无法获取用户主目录").join(".codex")
} }

View File

@@ -3,12 +3,45 @@
use std::collections::HashMap; use std::collections::HashMap;
use tauri::State; use tauri::State;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tauri_plugin_dialog::DialogExt;
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::codex_config; use crate::codex_config;
use crate::config::{get_claude_settings_path, ConfigStatus}; use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider; use crate::provider::Provider;
use crate::store::AppState; 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] #[tauri::command]
@@ -74,6 +107,8 @@ pub async fn add_provider(
.or_else(|| appType.as_deref().map(|s| s.into())) .or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude); .unwrap_or(AppType::Claude);
validate_provider_settings(&app_type, &provider)?;
// 读取当前是否是激活供应商(短锁) // 读取当前是否是激活供应商(短锁)
let is_current = { let is_current = {
let config = state let config = state
@@ -139,6 +174,8 @@ pub async fn update_provider(
.or_else(|| appType.as_deref().map(|s| s.into())) .or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude); .unwrap_or(AppType::Claude);
validate_provider_settings(&app_type, &provider)?;
// 读取校验 & 是否当前(短锁) // 读取校验 & 是否当前(短锁)
let (exists, is_current) = { let (exists, is_current) = {
let config = state let config = state
@@ -483,6 +520,26 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_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`(字符串) /// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command] #[tauri::command]
@@ -516,6 +573,38 @@ pub async fn open_config_folder(
Ok(true) 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] #[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> { pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
@@ -566,21 +655,15 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
/// 获取设置 /// 获取设置
#[tauri::command] #[tauri::command]
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> { pub async fn get_settings() -> Result<serde_json::Value, String> {
// 暂时返回默认设置 serde_json::to_value(crate::settings::get_settings())
Ok(serde_json::json!({ .map_err(|e| format!("序列化设置失败: {}", e))
"showInDock": true
}))
} }
/// 保存设置 /// 保存设置
#[tauri::command] #[tauri::command]
pub async fn save_settings( pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
_state: State<'_, AppState>, crate::settings::update_settings(settings)?;
settings: serde_json::Value,
) -> Result<bool, String> {
// TODO: 实现设置保存逻辑
log::info!("保存设置: {:?}", settings);
Ok(true) Ok(true)
} }
@@ -598,3 +681,36 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
Ok(true) Ok(true)
} }
/// 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

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

View File

@@ -4,12 +4,16 @@ mod commands;
mod config; mod config;
mod migration; mod migration;
mod provider; mod provider;
mod settings;
mod store; mod store;
mod vscode;
use store::AppState; use store::AppState;
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{ use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}, menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{TrayIconBuilder, TrayIconEvent},
}; };
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -25,6 +29,11 @@ fn create_tray_menu(
let mut menu_builder = MenuBuilder::new(app); 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) { if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识 // 添加Claude标题禁用状态仅作为分组标识
@@ -109,16 +118,23 @@ fn create_tray_menu(
/// 处理托盘菜单事件 /// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
println!("处理托盘菜单事件: {}", event_id); log::info!("处理托盘菜单事件: {}", event_id);
match event_id { match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => { "quit" => {
println!("退出应用"); log::info!("退出应用");
app.exit(0); app.exit(0);
} }
id if id.starts_with("claude_") => { id if id.starts_with("claude_") => {
let provider_id = id.strip_prefix("claude_").unwrap(); let provider_id = id.strip_prefix("claude_").unwrap();
println!("切换到Claude供应商: {}", provider_id); log::info!("切换到Claude供应商: {}", provider_id);
// 执行切换 // 执行切换
let app_handle = app.clone(); let app_handle = app.clone();
@@ -131,13 +147,13 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
) )
.await .await
{ {
eprintln!("切换Claude供应商失败: {}", e); log::error!("切换Claude供应商失败: {}", e);
} }
}); });
} }
id if id.starts_with("codex_") => { id if id.starts_with("codex_") => {
let provider_id = id.strip_prefix("codex_").unwrap(); let provider_id = id.strip_prefix("codex_").unwrap();
println!("切换到Codex供应商: {}", provider_id); log::info!("切换到Codex供应商: {}", provider_id);
// 执行切换 // 执行切换
let app_handle = app.clone(); let app_handle = app.clone();
@@ -150,16 +166,18 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
) )
.await .await
{ {
eprintln!("切换Codex供应商失败: {}", e); log::error!("切换Codex供应商失败: {}", e);
} }
}); });
} }
_ => { _ => {
println!("未处理的菜单事件: {}", event_id); log::warn!("未处理的菜单事件: {}", event_id);
} }
} }
} }
//
/// 内部切换供应商函数 /// 内部切换供应商函数
async fn switch_provider_internal( async fn switch_provider_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
@@ -184,7 +202,7 @@ async fn switch_provider_internal(
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") { if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) { if let Err(e) = tray.set_menu(Some(new_menu)) {
eprintln!("更新托盘菜单失败: {}", e); log::error!("更新托盘菜单失败: {}", e);
} }
} }
} }
@@ -195,7 +213,7 @@ async fn switch_provider_internal(
"providerId": provider_id_clone "providerId": provider_id_clone
}); });
if let Err(e) = app.emit("provider-switched", event_data) { if let Err(e) = app.emit("provider-switched", event_data) {
eprintln!("发射供应商切换事件失败: {}", e); log::error!("发射供应商切换事件失败: {}", e);
} }
} }
Ok(()) Ok(())
@@ -219,8 +237,17 @@ async fn update_tray_menu(
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() let builder = tauri::Builder::default()
// 拦截窗口关闭:仅隐藏窗口,保持进程与托盘常驻
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
let _ = window.hide();
}
_ => {}
})
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(|app| { .setup(|app| {
// 注册 Updater 插件(桌面端) // 注册 Updater 插件(桌面端)
@@ -294,33 +321,23 @@ pub fn run() {
// 创建动态托盘菜单 // 创建动态托盘菜单
let menu = create_tray_menu(&app.handle(), &app_state)?; let menu = create_tray_menu(&app.handle(), &app_state)?;
let _tray = TrayIconBuilder::with_id("main") // 构建托盘
.on_tray_icon_event(|tray, event| match event { let mut tray_builder = TrayIconBuilder::with_id("main")
TrayIconEvent::Click { .on_tray_icon_event(|_tray, event| match event {
button: MouseButton::Left, // 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
button_state: MouseButtonState::Up, TrayIconEvent::Click { .. } => {}
.. _ => log::debug!("unhandled event {event:?}"),
} => {
println!("left click pressed and released");
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {
println!("unhandled event {event:?}");
}
}) })
.menu(&menu) .menu(&menu)
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
handle_tray_menu_event(app, &event.id.0); handle_tray_menu_event(app, &event.id.0);
}) })
.icon(app.default_window_icon().unwrap().clone()) .show_menu_on_left_click(true);
.show_menu_on_left_click(true)
.build(app)?; // 统一使用应用默认图标;待托盘模板图标就绪后再启用
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致 // 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state); app.manage(app_state);
Ok(()) Ok(())
@@ -336,15 +353,42 @@ pub fn run() {
commands::get_claude_config_status, commands::get_claude_config_status,
commands::get_config_status, commands::get_config_status,
commands::get_claude_code_config_path, commands::get_claude_code_config_path,
commands::get_config_dir,
commands::open_config_folder, commands::open_config_folder,
commands::pick_directory,
commands::open_external, commands::open_external,
commands::get_app_config_path, commands::get_app_config_path,
commands::open_app_config_folder, commands::open_app_config_folder,
commands::get_settings, commands::get_settings,
commands::save_settings, commands::save_settings,
commands::check_for_updates, commands::check_for_updates,
commands::get_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
update_tray_menu, update_tray_menu,
]) ]);
.run(tauri::generate_context!())
let app = builder
.build(tauri::generate_context!())
.expect("error while running tauri application"); .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") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {}
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app_handle, event);
}
});
} }

View File

@@ -145,8 +145,11 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
} }
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> { pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
// 如果已迁移过则跳过 // 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
let marker = get_marker_path(); 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() { if marker.exists() {
return Ok(false); return Ok(false);
} }

View File

@@ -14,6 +14,8 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")] #[serde(rename = "websiteUrl")]
pub website_url: Option<String>, pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
} }
impl Provider { impl Provider {
@@ -29,6 +31,7 @@ impl Provider {
name, name,
settings_config, settings_config,
website_url, website_url,
category: None,
} }
} }
} }

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

@@ -0,0 +1,147 @@
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, 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
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: 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))
}

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

@@ -0,0 +1,61 @@
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", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.1.1", "version": "3.2.0",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -42,9 +42,9 @@
, ,
"plugins": { "plugins": {
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRERTRCNEUxQUE3MDA4QTYKUldTbUNIQ3E0YlRrVFF2cnFVVE1jczlNZFlmemxXd0h6cTdibXRJWjBDSytQODdZOTYvR3d3d2oK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [ "endpoints": [
"https://github.com/jasonyoung/cc-switch/releases/latest/download/latest.json" "https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
] ]
} }
} }

View File

@@ -7,12 +7,18 @@ import EditProviderModal from "./components/EditProviderModal";
import { ConfirmDialog } from "./components/ConfirmDialog"; import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher"; import { AppSwitcher } from "./components/AppSwitcher";
import SettingsModal from "./components/SettingsModal"; import SettingsModal from "./components/SettingsModal";
import { UpdateBadge } from "./components/UpdateBadge";
import { Plus, Settings, Moon, Sun } from "lucide-react"; import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles"; import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode"; 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() { function App() {
const { isDarkMode, toggleDarkMode } = useDarkMode(); const { isDarkMode, toggleDarkMode } = useDarkMode();
const { isAutoSyncEnabled } = useVSCodeAutoSync();
const [activeApp, setActiveApp] = useState<AppType>("claude"); const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({}); const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>(""); const [currentProviderId, setCurrentProviderId] = useState<string>("");
@@ -74,19 +80,26 @@ function App() {
}; };
}, []); }, []);
// 监听托盘切换事件 // 监听托盘切换事件(包括菜单切换)
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | null = null; let unlisten: (() => void) | null = null;
const setupListener = async () => { const setupListener = async () => {
try { try {
unlisten = await window.api.onProviderSwitched(async (data) => { unlisten = await window.api.onProviderSwitched(async (data) => {
console.log("收到供应商切换事件:", data); if (import.meta.env.DEV) {
console.log("收到供应商切换事件:", data);
}
// 如果当前应用类型匹配,则重新加载数据 // 如果当前应用类型匹配,则重新加载数据
if (data.appType === activeApp) { if (data.appType === activeApp) {
await loadProviders(); await loadProviders();
} }
// 若为 Codex 且开启自动同步,则静默同步到 VS Code覆盖
if (data.appType === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(data.providerId, true);
}
}); });
} catch (error) { } catch (error) {
console.error("设置供应商切换监听器失败:", error); console.error("设置供应商切换监听器失败:", error);
@@ -101,7 +114,7 @@ function App() {
unlisten(); unlisten();
} }
}; };
}, [activeApp]); // 依赖activeApp切换应用时重新设置监听器 }, [activeApp, isAutoSyncEnabled]);
const loadProviders = async () => { const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp); const loadedProviders = await window.api.getProviders(activeApp);
@@ -115,7 +128,6 @@ function App() {
} }
}; };
// 生成唯一ID // 生成唯一ID
const generateId = () => { const generateId = () => {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -146,7 +158,11 @@ function App() {
} catch (error) { } catch (error) {
console.error("更新供应商失败:", error); console.error("更新供应商失败:", error);
setEditingProviderId(null); setEditingProviderId(null);
showNotification("保存失败,请重试", "error"); const errorMessage = extractErrorMessage(error);
const message = errorMessage
? `保存失败:${errorMessage}`
: "保存失败,请重试";
showNotification(message, "error", errorMessage ? 6000 : 3000);
} }
}; };
@@ -167,6 +183,64 @@ function App() {
}); });
}; };
// 同步Codex供应商到VS Code设置静默覆盖
const syncCodexToVSCode = async (providerId: string, silent = false) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
if (!silent) {
showNotification(
"未找到 VS Code 用户设置文件 (settings.json)",
"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(
"当前配置缺少 base_url无法写入 VS Code",
"error",
4000,
);
}
return;
}
baseUrl = parsed;
}
const updatedSettings = applyProviderToVSCode(raw, {
baseUrl,
isOfficial,
});
if (updatedSettings !== raw) {
await window.api.writeVSCodeSettings(updatedSettings);
if (!silent) {
showNotification("已同步到 VS Code", "success", 1500);
}
}
// 触发providers重新加载以更新VS Code按钮状态
await loadProviders();
} catch (error: any) {
console.error("同步到VS Code失败:", error);
if (!silent) {
const errorMessage = error?.message || "同步 VS Code 失败";
showNotification(errorMessage, "error", 5000);
}
}
};
const handleSwitchProvider = async (id: string) => { const handleSwitchProvider = async (id: string) => {
const success = await window.api.switchProvider(id, activeApp); const success = await window.api.switchProvider(id, activeApp);
if (success) { if (success) {
@@ -180,6 +254,11 @@ function App() {
); );
// 更新托盘菜单 // 更新托盘菜单
await window.api.updateTrayMenu(); await window.api.updateTrayMenu();
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
if (activeApp === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(id, true); // silent模式不显示通知
}
} else { } else {
showNotification("切换失败,请检查配置", "error"); showNotification("切换失败,请检查配置", "error");
} }
@@ -203,16 +282,21 @@ function App() {
} }
}; };
return ( return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950"> <div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* Linear 风格的顶部导航 */} {/* 顶部导航区域 - 固定高度 */}
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4"> <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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400"> <a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noopener noreferrer"
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
title="在 GitHub 上查看"
>
CC Switch CC Switch
</h1> </a>
<button <button
onClick={toggleDarkMode} onClick={toggleDarkMode}
className={buttonStyles.icon} className={buttonStyles.icon}
@@ -220,13 +304,16 @@ function App() {
> >
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />} {isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
<button <div className="flex items-center gap-2">
onClick={() => setIsSettingsOpen(true)} <button
className={buttonStyles.icon} onClick={() => setIsSettingsOpen(true)}
title="设置" className={buttonStyles.icon}
> title="设置"
<Settings size={18} /> >
</button> <Settings size={18} />
</button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
</div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -243,30 +330,33 @@ function App() {
</div> </div>
</header> </header>
{/* 主内容区域 */} {/* 主内容区域 - 独立滚动 */}
<main className="flex-1 p-6"> <main className="flex-1 overflow-y-scroll">
<div className="max-w-4xl mx-auto"> <div className="pt-3 px-6 pb-6">
{/* 通知组件 */} <div className="max-w-4xl mx-auto">
{notification && ( {/* 通知组件 - 相对于视窗定位 */}
<div {notification && (
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${ <div
notification.type === "error" 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 ${
? "bg-red-500 text-white" notification.type === "error"
: "bg-green-500 text-white" ? "bg-red-500 text-white"
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`} : "bg-green-500 text-white"
> } ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
{notification.message} >
</div> {notification.message}
)} </div>
)}
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
/>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification}
/>
</div>
</div> </div>
</main> </main>

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,5 +1,5 @@
import { AppType } from "../lib/tauri-api"; import { AppType } from "../lib/tauri-api";
import { Terminal, Code2 } from "lucide-react"; import { ClaudeIcon, CodexIcon } from "./BrandIcons";
interface AppSwitcherProps { interface AppSwitcherProps {
activeApp: AppType; activeApp: AppType;
@@ -17,14 +17,21 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("claude")} onClick={() => handleSwitch("claude")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude" activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none" ? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 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" : "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"
}`} }`}
> >
<Code2 size={16} /> <ClaudeIcon
<span>Claude Code</span> 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> </button>
<button <button
@@ -36,7 +43,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
: "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" : "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"
}`} }`}
> >
<Terminal size={16} /> <CodexIcon size={16} />
<span>Codex</span> <span>Codex</span>
</button> </button>
</div> </div>

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { AlertTriangle, X } from "lucide-react"; import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -26,7 +27,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-black/50 backdrop-blur-sm" className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
onClick={onCancel} onClick={onCancel}
/> />

View File

@@ -1,9 +1,10 @@
import React, { useRef, useEffect } from "react"; import React, { useRef, useEffect, useMemo } from "react";
import { EditorView, basicSetup } from "codemirror"; import { EditorView, basicSetup } from "codemirror";
import { json } from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view"; import { placeholder } from "@codemirror/view";
import { linter, Diagnostic } from "@codemirror/lint";
interface JsonEditorProps { interface JsonEditorProps {
value: string; value: string;
@@ -11,6 +12,7 @@ interface JsonEditorProps {
placeholder?: string; placeholder?: string;
darkMode?: boolean; darkMode?: boolean;
rows?: number; rows?: number;
showValidation?: boolean;
} }
const JsonEditor: React.FC<JsonEditorProps> = ({ const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -19,10 +21,50 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
placeholder: placeholderText = "", placeholder: placeholderText = "",
darkMode = false, darkMode = false,
rows = 12, rows = 12,
showValidation = true,
}) => { }) => {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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(() => { useEffect(() => {
if (!editorRef.current) return; if (!editorRef.current) return;
@@ -43,6 +85,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
json(), json(),
placeholder(placeholderText || ""), placeholder(placeholderText || ""),
sizingTheme, sizingTheme,
jsonLinter,
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
const newValue = update.state.doc.toString(); const newValue = update.state.doc.toString();
@@ -75,7 +118,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
view.destroy(); view.destroy();
viewRef.current = null; viewRef.current = null;
}; };
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建 }, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 value 从外部改变时更新编辑器内容 // 当 value 从外部改变时更新编辑器内容
useEffect(() => { useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -28,15 +28,15 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${ const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled disabled
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed" ? "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 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" : "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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor={id} htmlFor={id}
className="block text-sm font-medium text-gray-900" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
{label} {required && "*"} {label} {required && "*"}
</label> </label>
@@ -56,7 +56,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
<button <button
type="button" type="button"
onClick={toggleShowKey} onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-900 transition-colors" 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"} aria-label={showKey ? "隐藏API Key" : "显示API Key"}
> >
{showKey ? <EyeOff size={16} /> : <Eye size={16} />} {showKey ? <EyeOff size={16} /> : <Eye size={16} />}

View File

@@ -1,52 +1,201 @@
import React from "react"; import React, { useEffect, useState } from "react";
import JsonEditor from "../JsonEditor"; import JsonEditor from "../JsonEditor";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
interface ClaudeConfigEditorProps { interface ClaudeConfigEditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disableCoAuthored: boolean; useCommonConfig: boolean;
onCoAuthoredToggle: (checked: boolean) => void; onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
configError: string;
} }
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value, value,
onChange, onChange,
disableCoAuthored, useCommonConfig,
onCoAuthoredToggle, 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label <label
htmlFor="settingsConfig" htmlFor="settingsConfig"
className="block text-sm font-medium text-gray-900" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
Claude Code (JSON) * Claude Code (JSON) *
</label> </label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 cursor-pointer"> <label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={disableCoAuthored} checked={useCommonConfig}
onChange={(e) => onCoAuthoredToggle(e.target.checked)} onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white border-gray-200 rounded focus:ring-blue-500 focus:ring-2" className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/> />
Claude Code
</label> </label>
</div> </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 <JsonEditor
value={value} value={value}
onChange={onChange} onChange={onChange}
darkMode={isDarkMode}
placeholder={`{ placeholder={`{
"env": { "env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here" "ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
} }
}`} }`}
rows={12} rows={12}
/> />
<p className="text-xs text-gray-500"> {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 Claude Code settings.json
</p> </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> </div>
); );
}; };

View File

@@ -1,4 +1,6 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
interface CodexConfigEditorProps { interface CodexConfigEditorProps {
authValue: string; authValue: string;
@@ -6,6 +8,12 @@ interface CodexConfigEditorProps {
onAuthChange: (value: string) => void; onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void; onConfigChange: (value: string) => void;
onAuthBlur?: () => void; onAuthBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
authError: string;
} }
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
@@ -14,52 +22,226 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onAuthChange, onAuthChange,
onConfigChange, onConfigChange,
onAuthBlur, onAuthBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
authError,
}) => { }) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
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 handleAuthChange = (value: string) => {
onAuthChange(value);
};
const handleConfigChange = (value: string) => {
onConfigChange(value);
};
const handleCommonConfigSnippetChange = (value: string) => {
onCommonConfigSnippetChange(value);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="codexAuth" htmlFor="codexAuth"
className="block text-sm font-medium text-gray-900" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
auth.json (JSON) * auth.json (JSON) *
</label> </label>
<textarea <textarea
id="codexAuth" id="codexAuth"
value={authValue} value={authValue}
onChange={(e) => onAuthChange(e.target.value)} onChange={(e) => handleAuthChange(e.target.value)}
onBlur={onAuthBlur} onBlur={onAuthBlur}
placeholder={`{ placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here" "OPENAI_API_KEY": "sk-your-api-key-here"
}`} }`}
rows={6} rows={6}
required required
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[8rem]" 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"
/> />
<p className="text-xs text-gray-500"> {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 Codex auth.json
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label <div className="flex items-center justify-between">
htmlFor="codexConfig" <label
className="block text-sm font-medium text-gray-900" htmlFor="codexConfig"
> className="block text-sm font-medium text-gray-900 dark:text-gray-100"
config.toml (TOML) >
</label> 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 <textarea
id="codexConfig" id="codexConfig"
value={configValue} value={configValue}
onChange={(e) => onConfigChange(e.target.value)} onChange={(e) => handleConfigChange(e.target.value)}
placeholder="" placeholder=""
rows={8} rows={8}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[10rem]" 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"> <p className="text-xs text-gray-500 dark:text-gray-400">
Codex config.toml Codex config.toml
</p> </p>
</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> </div>
); );
}; };

View File

@@ -86,10 +86,10 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
} }
}, [debouncedKey]); }, [debouncedKey]);
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white ${ const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
disabled disabled
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed" ? "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 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" : "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<{ const ModelSelect: React.FC<{
@@ -98,7 +98,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
onChange: (value: string) => void; onChange: (value: string) => void;
}> = ({ label, value, onChange }) => ( }> = ({ label, value, onChange }) => (
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-900"> <label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
{label} {label}
</label> </label>
<div className="relative"> <div className="relative">
@@ -123,7 +123,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
</select> </select>
<ChevronDown <ChevronDown
size={16} size={16}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 pointer-events-none" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
/> />
</div> </div>
</div> </div>
@@ -132,14 +132,14 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
</h3> </h3>
<button <button
type="button" type="button"
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)} onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
disabled={disabled || loading || !debouncedKey} disabled={disabled || loading || !debouncedKey}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 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" : ""} /> <RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -147,23 +147,23 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
</div> </div>
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-red-100 border border-red-500/20 rounded-lg"> <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 <AlertCircle
size={16} size={16}
className="text-red-500 flex-shrink-0" className="text-red-500 dark:text-red-400 flex-shrink-0"
/> />
<p className="text-red-500 text-xs">{error}</p> <p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ModelSelect <ModelSelect
label="主模型 (ANTHROPIC_MODEL)" label="主模型"
value={anthropicModel} value={anthropicModel}
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)} onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
/> />
<ModelSelect <ModelSelect
label="快速模型 (ANTHROPIC_SMALL_FAST_MODEL)" label="快速模型"
value={anthropicSmallFastModel} value={anthropicSmallFastModel}
onChange={(value) => onChange={(value) =>
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value) onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
@@ -172,9 +172,9 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
</div> </div>
{!apiKey.trim() && ( {!apiKey.trim() && (
<div className="p-3 bg-gray-100 border border-gray-200 rounded-lg"> <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-gray-500"> <p className="text-xs text-amber-600 dark:text-amber-400">
📝 API Keysk-xxx-api-key-here 💡 API Key
</p> </p>
</div> </div>
)} )}

View File

@@ -1,9 +1,12 @@
import React from "react"; import React from "react";
import { Zap } from "lucide-react"; import { Zap } from "lucide-react";
import { ProviderCategory } from "../../types";
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
interface Preset { interface Preset {
name: string; name: string;
isOfficial?: boolean; isOfficial?: boolean;
category?: ProviderCategory;
} }
interface PresetSelectorProps { interface PresetSelectorProps {
@@ -23,18 +26,24 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
onCustomClick, onCustomClick,
customLabel = "自定义", customLabel = "自定义",
}) => { }) => {
const getButtonClass = (index: number, isOfficial?: boolean) => { const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index; const isSelected = selectedIndex === index;
const baseClass = const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"; "inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) { if (isSelected) {
return isOfficial if (preset?.isOfficial || preset?.category === "official") {
? `${baseClass} bg-amber-500 text-white` // Codex 官方使用黑色背景
: `${baseClass} bg-blue-500 text-white`; 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 text-gray-500 hover:bg-gray-200`; 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 = () => { const getDescription = () => {
@@ -44,8 +53,8 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
if (selectedIndex !== null && selectedIndex >= 0) { if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex]; const preset = presets[selectedIndex];
return preset?.isOfficial return preset?.isOfficial || preset?.category === "official"
? "Claude 官方登录,不需要填写 API Key" ? "官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"; : "使用预设配置,只需填写 API Key";
} }
@@ -55,13 +64,13 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-900 mb-3"> <label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{title} {title}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? '' : ''}`} className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
onClick={onCustomClick} onClick={onCustomClick}
> >
{customLabel} {customLabel}
@@ -70,17 +79,27 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
<button <button
key={index} key={index}
type="button" type="button"
className={getButtonClass(index, preset.isOfficial)} className={getButtonClass(index, preset)}
onClick={() => onSelectPreset(index)} onClick={() => onSelectPreset(index)}
> >
{preset.isOfficial && <Zap size={14} />} {(preset.isOfficial || preset.category === "official") && (
<>
{preset.name.includes("Claude") ? (
<ClaudeIcon size={14} />
) : preset.name.includes("Codex") ? (
<CodexIcon size={14} />
) : (
<Zap size={14} />
)}
</>
)}
{preset.name} {preset.name}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{getDescription() && ( {getDescription() && (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500 dark:text-gray-400">
{getDescription()} {getDescription()}
</p> </p>
)} )}

View File

@@ -1,7 +1,16 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { Provider } from "../types"; import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react"; import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; 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 { interface ProviderListProps {
providers: Record<string, Provider>; providers: Record<string, Provider>;
@@ -9,6 +18,12 @@ interface ProviderListProps {
onSwitch: (id: string) => void; onSwitch: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onEdit: (id: string) => void; onEdit: (id: string) => void;
appType?: AppType;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
} }
const ProviderList: React.FC<ProviderListProps> = ({ const ProviderList: React.FC<ProviderListProps> = ({
@@ -17,6 +32,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch, onSwitch,
onDelete, onDelete,
onEdit, onEdit,
appType,
onNotify,
}) => { }) => {
// 提取API地址兼容不同供应商配置Claude env / Codex TOML // 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => { const getApiUrl = (provider: Provider): string => {
@@ -28,8 +45,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
} }
// Codex: 从 TOML 配置中解析 base_url // Codex: 从 TOML 配置中解析 base_url
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) { if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/); // 支持单/双引号
if (match && match[1]) return match[1]; const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
if (match && match[2]) return match[2];
} }
return "未配置官网地址"; return "未配置官网地址";
} catch { } catch {
@@ -45,6 +63,128 @@ const ProviderList: React.FC<ProviderListProps> = ({
} }
}; };
// 解析 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?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"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?.("当前配置缺少 base_url无法写入 VS Code", "error", 4000);
return;
}
}
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
if (next === raw) {
// 幂等:没有变化也提示成功
onNotify?.("已应用到 VS Code重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已应用到 VS Code重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
onNotify?.(msg, "error", 5000);
}
};
const handleRemoveFromVSCode = async () => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000,
);
return;
}
const raw = await window.api.readVSCodeSettings();
const next = applyProviderToVSCode(raw, {
baseUrl: undefined,
isOfficial: true,
});
if (next === raw) {
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "移除失败";
onNotify?.(msg, "error", 5000);
}
};
// 对供应商列表进行排序 // 对供应商列表进行排序
const sortedProviders = Object.values(providers).sort((a, b) => { const sortedProviders = Object.values(providers).sort((a, b) => {
// 按添加时间排序 // 按添加时间排序
@@ -52,16 +192,16 @@ const ProviderList: React.FC<ProviderListProps> = ({
// 有时间戳的按时间升序排列 // 有时间戳的按时间升序排列
const timeA = a.createdAt || 0; const timeA = a.createdAt || 0;
const timeB = b.createdAt || 0; const timeB = b.createdAt || 0;
// 如果都没有时间戳,按名称排序 // 如果都没有时间戳,按名称排序
if (timeA === 0 && timeB === 0) { if (timeA === 0 && timeB === 0) {
return a.name.localeCompare(b.name, 'zh-CN'); return a.name.localeCompare(b.name, "zh-CN");
} }
// 如果只有一个没有时间戳,没有时间戳的排在前面 // 如果只有一个没有时间戳,没有时间戳的排在前面
if (timeA === 0) return -1; if (timeA === 0) return -1;
if (timeB === 0) return 1; if (timeB === 0) return 1;
// 都有时间戳,按时间升序 // 都有时间戳,按时间升序
return timeA - timeB; return timeA - timeB;
}); });
@@ -90,7 +230,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div <div
key={provider.id} key={provider.id}
className={cn( className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive isCurrent ? cardStyles.selected : cardStyles.interactive,
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -99,12 +239,16 @@ const ProviderList: React.FC<ProviderListProps> = ({
<h3 className="font-medium text-gray-900 dark:text-gray-100"> <h3 className="font-medium text-gray-900 dark:text-gray-100">
{provider.name} {provider.name}
</h3> </h3>
{isCurrent && ( {/* 分类徽章已移除 */}
<div className={badgeStyles.success}> <div
<CheckCircle2 size={12} /> className={cn(
使 badgeStyles.success,
</div> !isCurrent && "invisible",
)} )}
>
<CheckCircle2 size={12} />
使
</div>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@@ -131,6 +275,32 @@ const ProviderList: React.FC<ProviderListProps> = ({
</div> </div>
<div className="flex items-center gap-2 ml-4"> <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] justify-center",
!isCurrent && "invisible",
vscodeAppliedFor === provider.id
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
)}
title={
vscodeAppliedFor === provider.id
? "从 VS Code 移除我们写入的配置"
: "将当前供应商应用到 VS Code"
}
>
{vscodeAppliedFor === provider.id
? "从 VS Code 移除"
: "应用到 VS Code"}
</button>
)}
<button <button
onClick={() => onSwitch(provider.id)} onClick={() => onSwitch(provider.id)}
disabled={isCurrent} disabled={isCurrent}
@@ -138,7 +308,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors", "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
isCurrent isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed" ? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700" : "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
)} )}
> >
<Play size={14} /> <Play size={14} />
@@ -160,7 +330,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
buttonStyles.icon, buttonStyles.icon,
isCurrent isCurrent
? "text-gray-400 cursor-not-allowed" ? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10" : "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
)} )}
title="删除供应商" title="删除供应商"
> >

View File

@@ -1,9 +1,22 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { X, Info, RefreshCw, FolderOpen } from "lucide-react"; import {
X,
RefreshCw,
FolderOpen,
Download,
ExternalLink,
Check,
Undo2,
FolderSearch,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { homeDir, join } from "@tauri-apps/api/path";
import "../lib/tauri-api"; import "../lib/tauri-api";
import { runUpdateFlow } from "../lib/updater"; import { relaunchApp } from "../lib/updater";
import { useUpdate } from "../contexts/UpdateContext";
import type { Settings } from "../types"; import type { Settings } from "../types";
import type { AppType } from "../lib/tauri-api";
import { isLinux } from "../lib/platform";
interface SettingsModalProps { interface SettingsModalProps {
onClose: () => void; onClose: () => void;
@@ -11,16 +24,25 @@ interface SettingsModalProps {
export default function SettingsModal({ onClose }: SettingsModalProps) { export default function SettingsModal({ onClose }: SettingsModalProps) {
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
showInDock: true, showInTray: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
}); });
const [configPath, setConfigPath] = useState<string>(""); const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>(""); const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false); 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 { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
loadConfigPath(); loadConfigPath();
loadVersion(); loadVersion();
loadResolvedDirs();
}, []); }, []);
const loadVersion = async () => { const loadVersion = async () => {
@@ -29,16 +51,29 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
setVersion(appVersion); setVersion(appVersion);
} catch (error) { } catch (error) {
console.error("获取版本信息失败:", error); console.error("获取版本信息失败:", error);
setVersion("3.1.1"); // 降级使用默认版本 // 失败时不硬编码版本号,显示为未知
setVersion("未知");
} }
}; };
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const loadedSettings = await window.api.getSettings(); const loadedSettings = await window.api.getSettings();
if (loadedSettings?.showInDock !== undefined) { const showInTray =
setSettings({ showInDock: loadedSettings.showInDock }); (loadedSettings as any)?.showInTray ??
} (loadedSettings as any)?.showInDock ??
true;
setSettings({
showInTray,
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) { } catch (error) {
console.error("加载设置失败:", error); console.error("加载设置失败:", error);
} }
@@ -55,9 +90,34 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
} }
}; };
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("获取配置目录失败:", error);
}
};
const saveSettings = async () => { const saveSettings = async () => {
try { try {
await window.api.saveSettings(settings); 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(); onClose();
} catch (error) { } catch (error) {
console.error("保存设置失败:", error); console.error("保存设置失败:", error);
@@ -65,15 +125,49 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
}; };
const handleCheckUpdate = async () => { const handleCheckUpdate = async () => {
setIsCheckingUpdate(true); if (hasUpdate && updateHandle) {
try { // 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
// 优先使用 Tauri Updater 流程;失败时回退到打开 Releases 页面 setIsDownloading(true);
await runUpdateFlow({ timeout: 30000 }); try {
} catch (error) { resetDismiss();
console.error("检查更新失败,回退到 Releases 页面:", error); await updateHandle.downloadAndInstall();
await window.api.checkForUpdates(); await relaunchApp();
} finally { } catch (error) {
setIsCheckingUpdate(false); console.error("更新失败:", 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("检查更新失败:", error);
// 在开发模式下,模拟已是最新版本的响应
if (import.meta.env.DEV) {
setShowUpToDate(true);
setTimeout(() => {
setShowUpToDate(false);
}, 3000);
} else {
// 生产环境下如果更新插件不可用,回退到打开 Releases 页面
await window.api.checkForUpdates();
}
} finally {
setIsCheckingUpdate(false);
}
} }
}; };
@@ -85,9 +179,102 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
} }
}; };
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("选择配置目录失败:", 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("获取默认配置目录失败:", 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;
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === "未知") {
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("打开更新日志失败:", error);
}
};
return ( return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50"> <div
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden"> 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] overflow-hidden">
{/* 标题栏 */} {/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800"> <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"> <h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
@@ -103,26 +290,29 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
{/* 设置内容 */} {/* 设置内容 */}
<div className="px-6 py-4 space-y-6"> <div className="px-6 py-4 space-y-6">
{/* 显示设置 - 功能还未实现 */} {/* 系统托盘设置(未实现)
说明:此开关用于控制是否在系统托盘/菜单栏显示应用图标。 */}
{/* <div> {/* <div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
显示设置 显示设置(系统托盘)
</h3> </h3>
<label className="flex items-center justify-between"> <label className="flex items-center justify-between">
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
Dock 中显示macOS 菜单栏显示图标(系统托盘
</span> </span>
<input <input
type="checkbox" type="checkbox"
checked={settings.showInDock} checked={settings.showInTray}
onChange={(e) => onChange={(e) =>
setSettings({ ...settings, showInDock: e.target.checked }) setSettings({ ...settings, showInTray: e.target.checked })
} }
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20" className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/> />
</label> </label>
</div> */} </div> */}
{/* VS Code 自动同步设置已移除 */}
{/* 配置文件位置 */} {/* 配置文件位置 */}
<div> <div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -147,6 +337,90 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div> </div>
</div> </div>
{/* 配置目录覆盖 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
WSL 使 Claude Code Codex WSL
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Claude Code
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.claudeConfigDir ?? resolvedClaudeDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
claudeConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.claude"
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="浏览目录"
>
<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="恢复默认目录(需保存后生效)"
>
<Undo2 size={16} />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Codex
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.codexConfigDir ?? resolvedCodexDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
codexConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.codex"
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="浏览目录"
>
<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="恢复默认目录(需保存后生效)"
>
<Undo2 size={16} />
</button>
</div>
</div>
</div>
</div>
{/* 关于 */} {/* 关于 */}
<div> <div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -154,11 +428,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</h3> </h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"> <div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-3"> <div>
<Info
size={18}
className="text-gray-500 mt-0.5"
/>
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-gray-900 dark:text-gray-100"> <p className="font-medium text-gray-900 dark:text-gray-100">
CC Switch CC Switch
@@ -168,24 +438,57 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</p> </p>
</div> </div>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={handleCheckUpdate} <button
disabled={isCheckingUpdate} onClick={handleOpenReleaseNotes}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${ 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"
isCheckingUpdate title={
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500" hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400" }
}`} >
> <span className="inline-flex items-center gap-1">
{isCheckingUpdate ? ( <ExternalLink size={12} />
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
</span> </span>
) : ( </button>
"检查更新" <button
)} onClick={handleCheckUpdate}
</button> 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" />
...
</span>
) : isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
</span>
) : hasUpdate ? (
<span className="flex items-center gap-1">
<Download size={12} />
v{updateInfo?.availableVersion}
</span>
) : showUpToDate ? (
<span className="flex items-center gap-1">
<Check size={12} />
</span>
) : (
"检查更新"
)}
</button>
</div>
</div> </div>
</div> </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,15 @@
/** /**
* Codex 预设供应商配置模板 * Codex 预设供应商配置模板
*/ */
import { ProviderCategory } from "../types";
export interface CodexProviderPreset { export interface CodexProviderPreset {
name: string; name: string;
websiteUrl: string; websiteUrl: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串) config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设 isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
} }
export const codexProviderPresets: CodexProviderPreset[] = [ export const codexProviderPresets: CodexProviderPreset[] = [
@@ -14,6 +17,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
name: "Codex官方", name: "Codex官方",
websiteUrl: "https://chatgpt.com/codex", websiteUrl: "https://chatgpt.com/codex",
isOfficial: true, isOfficial: true,
category: "official",
// 官方的 key 为null // 官方的 key 为null
auth: { auth: {
OPENAI_API_KEY: null, OPENAI_API_KEY: null,
@@ -23,14 +27,17 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{ {
name: "PackyCode", name: "PackyCode",
websiteUrl: "https://codex.packycode.com/", websiteUrl: "https://codex.packycode.com/",
category: "third_party",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key // PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: { auth: {
OPENAI_API_KEY: "sk-your-api-key-here", OPENAI_API_KEY: "sk-your-api-key-here",
}, },
config: `model_provider = "packycode" config: `model_provider = "packycode"
model = "gpt-5" model = "gpt-5-codex"
model_reasoning_effort = "high" model_reasoning_effort = "high"
disable_response_storage = true disable_response_storage = true
requires_openai_auth = true
[model_providers.packycode] [model_providers.packycode]
name = "packycode" name = "packycode"

View File

@@ -1,24 +1,28 @@
/** /**
* 预设供应商配置模板 * 预设供应商配置模板
*/ */
import { ProviderCategory } from "../types";
export interface ProviderPreset { export interface ProviderPreset {
name: string; name: string;
websiteUrl: string; websiteUrl: string;
settingsConfig: object; settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设 isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
} }
export const providerPresets: ProviderPreset[] = [ export const providerPresets: ProviderPreset[] = [
{ {
name: "Claude官方登录", name: "Claude官方",
websiteUrl: "https://www.anthropic.com/claude-code", websiteUrl: "https://www.anthropic.com/claude-code",
settingsConfig: { settingsConfig: {
env: {}, env: {},
}, },
isOfficial: true, // 明确标识为官方预设 isOfficial: true, // 明确标识为官方预设
category: "official",
}, },
{ {
name: "DeepSeek v3.1", name: "DeepSeek",
websiteUrl: "https://platform.deepseek.com", websiteUrl: "https://platform.deepseek.com",
settingsConfig: { settingsConfig: {
env: { env: {
@@ -28,6 +32,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat", ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "智谱GLM", name: "智谱GLM",
@@ -36,19 +41,25 @@ export const providerPresets: ProviderPreset[] = [
env: { env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic", ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "", 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", websiteUrl: "https://bailian.console.aliyun.com",
settingsConfig: { settingsConfig: {
env: { env: {
ANTHROPIC_BASE_URL: ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy", "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "qwen3-coder-plus",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-coder-plus",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "Kimi k2", name: "Kimi k2",
@@ -61,18 +72,20 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview", ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "魔搭", name: "魔搭",
websiteUrl: "https://modelscope.cn", websiteUrl: "https://modelscope.cn",
settingsConfig: { settingsConfig: {
env: { env: {
ANTHROPIC_AUTH_TOKEN: "ms-your-api-key",
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn", ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5", ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5", ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
}, },
}, },
category: "aggregator",
}, },
{ {
name: "PackyCode", name: "PackyCode",
@@ -83,5 +96,6 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "", 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;
}

View File

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

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

View File

@@ -21,23 +21,33 @@ body {
} }
/* 暗色模式下启用暗色原生控件/滚动条配色 */ /* 暗色模式下启用暗色原生控件/滚动条配色 */
html.dark { color-scheme: dark; } html.dark {
color-scheme: dark;
}
/* 滚动条样式 */ /* 滚动条样式(避免在伪元素中使用自定义 dark 变体,消除构建警告) */
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-1.5 h-1.5; width: 0.375rem;
height: 0.375rem;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-800; background-color: #f4f4f5;
}
html.dark ::-webkit-scrollbar-track {
background-color: #27272a;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded dark:bg-gray-600; background-color: #d4d4d8;
border-radius: 0.25rem;
}
html.dark ::-webkit-scrollbar-thumb {
background-color: #52525b;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500; background-color: #a1a1aa;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background-color: #71717a;
} }
/* 焦点样式 */ /* 焦点样式 */

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

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

View File

@@ -5,20 +5,24 @@
// 按钮样式 // 按钮样式
export const buttonStyles = { 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", 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", 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", 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", 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", 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", disabled: "opacity-50 cursor-not-allowed pointer-events-none",
} as const; } as const;
@@ -27,42 +31,49 @@ export const buttonStyles = {
export const cardStyles = { export const cardStyles = {
// 基础卡片容器 // 基础卡片容器
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700", 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", 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 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10", 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; } as const;
// 输入控件样式 // 输入控件样式
export const inputStyles = { 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", 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", 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", 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; } as const;
// 徽标Badge样式 // 徽标Badge样式
export const badgeStyles = { 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", 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", 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", 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", 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; } as const;
// 组合类名的工具函数 // 组合类名的工具函数
export function cn(...classes: (string | undefined | false)[]) { export function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(' '); return classes.filter(Boolean).join(" ");
} }

View File

@@ -122,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 配置状态 // 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => { getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
try { try {
@@ -189,10 +199,22 @@ export const tauriAPI = {
// (保留空位,取消迁移提示) // (保留空位,取消迁移提示)
// 选择配置文件Tauri 暂不实现,保留接口兼容性) // 选择配置目录
selectConfigFile: async (): Promise<string | null> => { selectConfigDirectory: async (
console.warn("selectConfigFile 在 Tauri 版本中暂不支持"); defaultPath?: string,
return null; ): 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;
}
}, },
// 获取设置 // 获取设置
@@ -201,7 +223,7 @@ export const tauriAPI = {
return await invoke("get_settings"); return await invoke("get_settings");
} catch (error) { } catch (error) {
console.error("获取设置失败:", error); console.error("获取设置失败:", error);
return { showInDock: true }; return { showInTray: true };
} }
}, },
@@ -242,6 +264,38 @@ export const tauriAPI = {
console.error("打开应用配置文件夹失败:", 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)}`);
}
},
}; };
// 创建全局 API 对象,兼容现有代码 // 创建全局 API 对象,兼容现有代码

View File

@@ -122,25 +122,5 @@ export async function relaunchApp(): Promise<void> {
await relaunch(); await relaunch();
} }
export async function runUpdateFlow( // 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
opts: CheckOptions = {}, // 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
): Promise<{ status: "up-to-date" | "done" }> {
const result = await checkForUpdate(opts);
if (result.status === "up-to-date") return result;
let downloaded = 0;
let total = 0;
await result.update.downloadAndInstall((e) => {
if (e.event === "Started") {
total = e.total ?? 0;
downloaded = 0;
} else if (e.event === "Progress") {
downloaded += e.downloaded ?? 0;
// 调用方可监听此处并更新 UI目前设置页仅显示加载态
console.debug("update progress", { downloaded, total });
}
});
await relaunchApp();
return { status: "done" };
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { UpdateProvider } from "./contexts/UpdateContext";
import "./index.css"; import "./index.css";
// 导入 Tauri API自动绑定到 window.api // 导入 Tauri API自动绑定到 window.api
import "./lib/tauri-api"; import "./lib/tauri-api";
@@ -19,6 +20,8 @@ try {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <UpdateProvider>
<App />
</UpdateProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -1,8 +1,17 @@
export type ProviderCategory =
| "official" // 官方
| "cn_official" // 国产官方
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
| "custom"; // 自定义
export interface Provider { export interface Provider {
id: string; id: string;
name: string; name: string;
settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config } settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config }
websiteUrl?: string; websiteUrl?: string;
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒) createdAt?: number; // 添加时间戳(毫秒)
} }
@@ -13,5 +22,10 @@ export interface AppConfig {
// 应用设置类型(用于 SettingsModal 与 Tauri API // 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings { export interface Settings {
showInDock: boolean; // 是否在系统托盘macOS 菜单栏)显示图标
showInTray: 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,33 +1,162 @@
// 供应商配置处理工具函数 // 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除 const isPlainObject = (value: unknown): value is Record<string, any> => {
export const updateCoAuthoredSetting = ( return Object.prototype.toString.call(value) === "[object Object]";
jsonString: string, };
disable: boolean,
): string => {
try {
const config = JSON.parse(jsonString);
if (disable) { const deepMerge = (
// 添加或更新includeCoAuthoredBy字段 target: Record<string, any>,
config.includeCoAuthoredBy = false; 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 { } else {
// 删除includeCoAuthoredBy字段 // 直接覆盖非对象字段(数组/基础类型)
delete config.includeCoAuthoredBy; target[key] = value;
} }
});
return target;
};
return JSON.stringify(config, null, 2); const deepRemove = (
} catch (err) { target: Record<string, any>,
// 如果JSON解析失败返回原始字符串 source: Record<string, any>,
return jsonString; ) => {
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设置 // 将通用配置片段写入/移除 settingsConfig
export const checkCoAuthoredSetting = (jsonString: string): boolean => { export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try { try {
const config = JSON.parse(jsonString); config = jsonString ? JSON.parse(jsonString) : {};
return config.includeCoAuthoredBy === false; } 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) { } catch (err) {
return false; return false;
} }
@@ -79,3 +208,113 @@ export const setApiKeyInConfig = (
return jsonString; 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;
}
};

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

@@ -0,0 +1,124 @@
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 变为空对象,顺便移除(不影响其他 chatgpt* 键)
try {
const data = parse(out) as any;
const cfg = data?.["chatgpt.config"];
if (
cfg &&
typeof cfg === "object" &&
!Array.isArray(cfg) &&
Object.keys(cfg).length === 0
) {
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;
}

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

@@ -28,7 +28,8 @@ declare global {
getClaudeCodeConfigPath: () => Promise<string>; getClaudeCodeConfigPath: () => Promise<string>;
getClaudeConfigStatus: () => Promise<ConfigStatus>; getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => 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>; openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>; updateTrayMenu: () => Promise<boolean>;
@@ -40,6 +41,10 @@ declare global {
checkForUpdates: () => Promise<void>; checkForUpdates: () => Promise<void>;
getAppConfigPath: () => Promise<string>; getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>; openAppConfigFolder: () => Promise<void>;
// VS Code settings.json 能力
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
readVSCodeSettings: () => Promise<string>;
writeVSCodeSettings: (content: string) => Promise<boolean>;
}; };
platform: { platform: {
isMac: boolean; isMac: boolean;