Compare commits

..

165 Commits

Author SHA1 Message Date
YoVinchen de0758eba4 Merge branch 'refs/heads/main' into fix/skill-duplicate-install-detection 2026-01-25 13:39:14 +08:00
YoVinchen 22552f7a7f fix(skills): prevent duplicate skill installation from different repos
- Add directory conflict detection before installation
- Fix installed status check to match repo owner and name
- Add i18n translations for conflict error messages
2026-01-25 06:10:24 +08:00
Jason 1c6689a0bc chore: update Cargo.lock version to 3.10.2 2026-01-24 23:49:06 +08:00
Jason 9404341f14 feat(ui): replace update badge dot with ArrowUpCircle icon
- Replace blue dot indicator with lucide ArrowUpCircle icon
- Change color scheme from blue to green for better visibility
- Enlarge button (h-8 w-8) and icon (h-5 w-5) for better UX
- Move UpdateBadge from title area to after settings icon
2026-01-24 23:32:05 +08:00
Jason 53dd0a90f3 chore: release v3.10.2 2026-01-24 22:15:21 +08:00
Jason 779fefd86d feat(skills): add skill sync method setting (symlink/copy)
- Add SyncMethod enum (Auto/Symlink/Copy) in Rust backend
- Implement sync_to_app_dir with symlink support (cross-platform)
- Add SkillSyncMethodSettings UI component (simplified 2-button selector)
- Add i18n support for zh/en/ja
- Replace copy_to_app with configurable sync_to_app_dir
- Add skill_sync_method field to AppSettings

User can now choose between symlink (disk space saving) or copy (best compatibility) in Settings > General.
2026-01-24 18:24:05 +08:00
Jason 096c1d57c4 feat(partner): add RightCode as official partner
Update RightCode referral link to use CCSWITCH affiliate code and mark
as official partner with promotional messages in all supported languages.
2026-01-23 22:59:48 +08:00
Jason adb868d0cf fix(prompt): clear prompt file when all prompts are disabled
When disabling a prompt, check if any other prompts remain enabled.
If all prompts are disabled, clear the prompt file to ensure UI state
matches the actual configuration that Claude Code reads.
2026-01-23 22:43:07 +08:00
Jason a6ad896db0 fix(opencode): preserve extra model fields during serialization
Add `extra` field with serde flatten to OpenCodeModel struct to capture
custom fields like cost, modalities, thinking, and variants that were
previously lost during save operations.
2026-01-23 20:42:50 +08:00
Jason d6cf4390ac fix(form): backfill model fields when editing Claude provider
Use lazy initialization in useState to parse model values from config
on first render, matching the pattern used in useApiKeyState. This
fixes an issue where model fields were not populated in edit mode.
2026-01-23 19:13:38 +08:00
Jason 0ef670325b chore: release v3.10.1
- Bump version to 3.10.1 across all config files
- Update CHANGELOG with all fixes since v3.10.0
- Fix Rust Clippy warning by using derive(Default)
- Apply code formatting
2026-01-23 10:41:38 +08:00
Jason 07fc6b175e fix(proxy): change rectifier default state to disabled
Change the default state of the rectifier from enabled to disabled.
This allows users to opt-in to the rectifier feature rather than having it enabled by default.

Changes:
- Set RectifierConfig::default() enabled and request_thinking_signature to false
- Update serde default attributes from default_true to default
- Update unit tests to reflect new default behavior
2026-01-23 09:45:57 +08:00
Jason 79d3ecc1b8 feat(icons): update RightCode provider icon
- Import new detailed RC icon from rc.svg (128x128 viewBox)
- Add icon configuration to Claude provider preset
- Remove duplicate old RC icon definition to fix TS1117 error
2026-01-23 09:25:09 +08:00
Jason 9c249d9486 fix(ui): increase app icon collapse threshold from 3 to 4
Improve readability by keeping app names visible when 3 apps are shown.
Icons now only collapse to compact mode when 4 or more apps are visible.
2026-01-23 00:12:33 +08:00
Jason 15f60e8ce2 fix(ui): improve ProviderIcon color validation to prevent black icons
Enhance the effectiveColor logic in ProviderIcon to properly validate
the color prop before using it. Now only uses color when it's a valid
non-empty string, otherwise falls back to icon metadata defaultColor.

This fixes the issue where Gemini icons would turn black when selected
in ProviderCard due to null/undefined/empty string color values being
passed through.
2026-01-23 00:02:11 +08:00
Jason 3d733a3b80 fix(ui): unify layout padding across all panels
Remove max-width constraints and standardize padding to px-6 for consistent full-width layout across header and all content panels.
2026-01-22 23:55:19 +08:00
Jason cfb113ac8d fix(settings): reorder window settings and change default values
Move "minimize to tray on close" to the bottom of window settings.
Change skipClaudeOnboarding default to false.
2026-01-22 23:35:41 +08:00
Jason 62a4ab2ad3 fix(terminal): keep Windows terminal window open after execution
Use /K instead of /C flag to prevent cmd window from closing
immediately after batch file execution. Also remove the buggy
errorlevel check that was checking del command's return value
instead of claude's.

Fixes #726
2026-01-22 23:04:18 +08:00
Jason aa0f191420 fix(config): correct OpenCode config path on Windows
OpenCode uses ~/.config/opencode on all platforms, not %APPDATA%\opencode
on Windows. Remove the platform-specific path handling to use unified
~/.config/opencode path across all operating systems.
2026-01-22 17:09:45 +08:00
Jason b8305f281b fix(ui): align panel content with header constraints
Add max-w-[56rem] and mx-auto to Skills, MCP, and Prompts panels
to match the header layout constraints, ensuring the back button
and action buttons align with the content area.
2026-01-22 12:41:06 +08:00
Jason b3af191fab docs: add v3.10.0 release notes in zh/en/ja 2026-01-22 11:58:25 +08:00
Jason e6b6bc18a9 feat(config): add RightCode provider and rename Z.ai GLM
- Add RightCode provider preset with GPT-5.2 models
- Rename "Z.ai GLM" to "Zhipu GLM en" for clarity
2026-01-22 10:35:43 +08:00
Jason b1f7ff4768 fix(config): remove icon config from provider preset
Remove unused icon and iconColor fields from Requesty Cloud preset
2026-01-22 00:07:35 +08:00
Jason 09d8c27972 feat(icons): replace MCP button icon with official MCP logo
Use inline SVG for MCP icon to support currentColor inheritance,
enabling proper hover color transitions.
2026-01-22 00:01:32 +08:00
Jason 9736861ace feat(icons): add new provider icons and fix SiliconFlow iconColor
- Add rc.svg for RightCode (optimized from 96KB to 581 bytes)
- Add catcoder.svg for KAT-Coder (快手)
- Add mcp.svg for Model Context Protocol
- Add siliconflow.svg for SiliconFlow
- Register all new icons in index.ts
- Add icon config for RightCode in claude/codex presets
- Add icon config for KAT-Coder in claude/opencode presets
- Fix SiliconFlow iconColor from #000000 to #6E29F6
2026-01-21 23:36:18 +08:00
Jason fef750bb4c chore: release v3.10.0 2026-01-21 20:01:29 +08:00
Jason 3677bb61f5 refactor(settings): move rectifier section below failover in advanced settings 2026-01-21 18:02:21 +08:00
Jason 2d17bde790 feat(skills): add baoyu-skills preset repo and auto-supplement missing defaults
- Add JimLiu/baoyu-skills to default skill repositories
- Change init_default_skill_repos() from "first-run only" to "supplement missing"
- New preset repos will now auto-appear for existing users on upgrade
2026-01-21 16:20:28 +08:00
Jason dc865fbbbf fix(icon): auto-apply default color from metadata when color prop is not provided
When ProviderIcon is used without a color prop, it now automatically
fetches the defaultColor from icon metadata. This fixes the issue where
Gemini app icon turns black when selected in AppSwitcher, because the
component was inheriting the parent's text-foreground color.
2026-01-21 15:46:54 +08:00
Jason a187380d6f fix(failover): switch to P1 immediately when enabling auto failover
Previously, enabling auto failover kept using the current provider until
the first failure, causing inconsistency when the current provider was
not in the failover queue. When stopping proxy, the restored config
would not match user expectations.

New behavior:
- Enable auto failover = immediately switch to queue P1
- Subsequent routing follows queue order (P1→P2→...)
- Auto-add current provider to queue if queue is empty

Changes:
- Add switch_proxy_target() for hot-switching during proxy mode
- Update provider_router to use queue order when failover enabled
- Sync tray menu Auto click with the same logic
- Update UI tooltips to reflect new semantics
- Add tests for queue-only routing scenario
2026-01-21 11:27:44 +08:00
Jason b993b1f664 chore: fix code formatting and test setup
- Format Rust code with rustfmt (misc.rs, types.rs)
- Format TypeScript/React code with Prettier (4 files)
- Fix ProviderList test by wrapping with QueryClientProvider
2026-01-20 23:40:33 +08:00
Jason 00168877d9 fix(terminal): use temp script files to avoid escaping issues on all platforms
- macOS: Replace inline AppleScript commands with temp .sh script file
- Linux: Use temp .sh script file instead of inline bash -c command
- Windows: Add proper error capturing with output() instead of spawn()
- Remove unused escape_shell_path and generate_wrapper_script functions
- Unified approach across platforms for better maintainability
2026-01-20 22:21:01 +08:00
Jason 4496110dd8 feat(app-switcher): add compact mode for takeover with 3+ visible apps 2026-01-20 22:02:13 +08:00
Jason c9e85e8cac feat(tray): sync tray menu with app visibility settings
Apply visibleApps setting to filter tray menu sections, so hidden apps
no longer appear in the system tray menu.
2026-01-20 21:05:06 +08:00
Jason eab1d08527 feat(settings): add app visibility settings
Allow users to choose which apps (Claude, Codex, Gemini, OpenCode) to display on the homepage.

- Add VisibleApps type and settings field in both frontend and backend
- Refactor AppSwitcher to render apps dynamically based on visibility
- Extract ToggleRow component for reuse
- Add i18n support for app visibility settings
2026-01-20 21:05:06 +08:00
Jason 30009ad5f1 feat(settings): set Gemini visibility to false by default
New users will see Claude, Codex, and OpenCode by default, with Gemini hidden.
2026-01-20 21:05:06 +08:00
Dex Miller e7badb1a24 Feat/provider individual config (#663)
* refactor(ui): simplify UpdateBadge to minimal dot indicator

* feat(provider): add individual test and proxy config for providers

Add support for provider-specific model test and proxy configurations:

- Add ProviderTestConfig and ProviderProxyConfig types in Rust and TypeScript
- Create ProviderAdvancedConfig component with collapsible panels
- Update stream_check service to merge provider config with global config
- Proxy config UI follows global proxy style (single URL input)

Provider-level configs stored in meta field, no database schema changes needed.

* feat(ui): add failover toggle and improve proxy controls

- Add FailoverToggle component with slide animation
- Simplify ProxyToggle style to match FailoverToggle
- Add usage statistics button when proxy is active
- Fix i18n parameter passing for failover messages
- Add missing failover translation keys (inQueue, addQueue, priority)
- Replace AboutSection icon with app logo

* fix(proxy): support system proxy fallback and provider-level proxy config

- Remove no_proxy() calls in http_client.rs to allow system proxy fallback
- Add get_for_provider() to build HTTP client with provider-specific proxy
- Update forwarder.rs and stream_check.rs to use provider proxy config
- Fix EditProviderDialog.tsx to include provider.meta in useMemo deps
- Add useEffect in ProviderAdvancedConfig.tsx to sync expand state

Fixes #636
Fixes #583

* fix(ui): sync toast theme with app setting

* feat(settings): add log config management

Fixes #612
Fixes #514

* fix(proxy): increase request body size limit to 200MB

Fixes #666

* docs(proxy): update timeout config descriptions and defaults

Fixes #612

* fix(proxy): filter x-goog-api-key header to prevent duplication

* fix(proxy): prevent proxy recursion when system proxy points to localhost

Detect if HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY environment variables
point to loopback addresses (localhost, 127.0.0.1), and bypass system
proxy in such cases to avoid infinite request loops.

* fix(i18n): add providerAdvanced i18n keys and fix failover toast parameter

- Add providerAdvanced.* i18n keys to en.json, zh.json, and ja.json
- Fix failover toggleFailed toast to pass detail parameter
- Remove Chinese fallback text from UI for English/Japanese users

* fix(tray): restore tray-provider events and enable Auto failover properly

- Emit provider-switched event on tray provider click (backward compatibility)
- Auto button now: starts proxy, takes over live config, enables failover

* fix(log): enable dynamic log level and single file mode

- Initialize log at Trace level for dynamic adjustment
- Change rotation strategy to KeepSome(1) for single file
- Set max file size to 1GB
- Delete old log file on startup for clean start

* fix(tray): fix clippy uninlined format args warning

Use inline format arguments: {app_type_str} instead of {}

* fix(provider): allow typing :// in endpoint URL inputs

Change input type from "url" to "text" to prevent browser
URL validation from blocking :// input.

Closes #681

* fix(stream-check): use Gemini native streaming API format

- Change endpoint from OpenAI-compatible to native streamGenerateContent
- Add alt=sse parameter for SSE format response
- Use x-goog-api-key header instead of Bearer token
- Convert request body to Gemini contents/parts format

* feat(proxy): add request logging for debugging

Add debug logs for outgoing requests including URL and body content
with byte size, matching the existing response logging format.

* fix(log): prevent usize underflow in KeepSome rotation strategy

KeepSome(n) internally computes n-2, so n=1 causes underflow.
Use KeepSome(2) as the minimum safe value.
2026-01-20 21:02:44 +08:00
咸蛋黄 7bb458eecb feat: 添加 ESC 键快捷返回功能 (#670)
* feat: 添加 ESC 键快捷返回功能

- FullScreenPanel 组件支持 ESC 键关闭
- App.tsx 主页面支持 ESC 键返回主界面
- 优化键盘事件处理,合并多个监听器
- 使用事件捕获阶段避免冲突
- 适用于所有子页面:MCP、设置、Prompts、Skills 等
- 跨平台兼容:macOS、Windows、Linux

* perf: 优化 ESC 键处理逻辑

- 使用 useRef 避免闭包陷阱,提升性能
- 修复输入框中按 ESC 会关闭面板的问题
- 检测焦点元素,不干扰输入框的 ESC 行为
- 改进用户体验,避免意外关闭导致数据丢失

* fix: enhance global keyboard shortcuts and improve useModelState sync

- App & FullScreenPanel: Use `isTextEditableTarget` to prevent shortcuts (ESC, etc.) from triggering while editing text.
- useModelState: Prevent overwriting user input during config synchronization.
- App: Add `Cmd/Ctrl + ,` shortcut to open settings.
- Add `isTextEditableTarget` utility.
2026-01-20 16:33:50 +08:00
kkkman22 76897e2b97 <feat>: 添加 fnm 路径支持 (#564)
Co-authored-by: Gruby Wang <gruby.wang@shijigroup.com>
2026-01-20 10:32:35 +08:00
杨永安 fb9e7dee50 fix(provider): fix stale data shown when reopening edit dialog after save (#654)
Add `open` to initialData useMemo dependencies to ensure latest provider
data is read each time the dialog opens.
2026-01-20 10:31:35 +08:00
Jason Young e1d4dd7f55 Merge pull request #695 from farion1231/feat/opencode-support
feat: add OpenCode as the fourth managed CLI application
2026-01-19 20:25:39 +08:00
Jason c847fff768 feat(provider): update OpenCode presets with Claude 4.5 models and SDK changes 2026-01-19 16:35:39 +08:00
Jason 1eb0a0d7ac fix(opencode): move model options add button to bottom 2026-01-19 15:32:56 +08:00
Jason 3bd3845ec0 feat(opencode): add model-level options editor
Add support for configuring per-model options like provider routing.
Each model row now has an expand/collapse toggle to show a key-value
editor for model-specific options (e.g., provider order, fallbacks).

- Add options field to OpenCodeModel in Rust and TypeScript
- Add expandable key-value editor UI for each model
- Use local state pattern for option key input to prevent focus loss
- Add i18n translations for zh/en/ja
2026-01-19 15:05:01 +08:00
Jason b0d0a2c466 feat(opencode): add extra options editor for SDK configuration
Add key-value pair editor for configuring additional SDK options like
timeout, setCacheKey, etc. Values are automatically parsed to appropriate
types (number, boolean, object) on save.

- Add `extra` field with serde flatten in Rust backend
- Add index signature to OpenCodeProviderOptions type
- Create ExtraOptionKeyInput component with local state pattern
- Place extra options section above models configuration
2026-01-19 11:42:24 +08:00
Jason 73013c10af feat(opencode): add column headers for model configuration 2026-01-18 21:57:36 +08:00
Jason 1a0872c153 fix(opencode): prevent model ID input focus loss on keystroke
Use local state + onBlur pattern for ModelIdInput to keep React key
stable during editing. Previously, each keystroke changed the object
key, causing React to unmount/remount the input and lose focus.
2026-01-18 21:52:44 +08:00
Jason 255a7f570a fix(opencode): use AGENTS.md as prompt filename
OpenCode follows the same convention as Codex, using AGENTS.md
instead of OPENCODE.md for the system prompt file.
2026-01-17 23:36:51 +08:00
Jason fb44fb136f fix(opencode): hide test model button for unsupported adapter
OpenCode lacks a dedicated adapter and falls back to Codex adapter,
which has incompatible config structure. Hide the test button in UI
to prevent users from triggering unsupported operations.
2026-01-17 22:42:56 +08:00
Jason 58d3bb89d2 fix(opencode): generate unique provider key when duplicating
OpenCode uses user-provided provider keys as IDs instead of random UUIDs.
When duplicating a provider, generate a unique key by appending "-copy"
suffix (or "-copy-2", "-copy-3", etc. if already exists).
2026-01-17 22:10:08 +08:00
Jason 403227c901 feat(opencode): import providers and MCP on first launch
Add startup import logic for OpenCode providers and MCP servers.
Unlike other apps that use replacement mode, OpenCode uses additive
mode where multiple providers can coexist in the config file.
2026-01-17 21:15:16 +08:00
Jason 5bcf5bf382 feat(opencode): add manual provider key input with duplicate check
- Add Provider Key input field for OpenCode providers (between icon and name)
- User must manually enter a unique key instead of auto-generating from name
- Real-time validation: format check and duplicate detection
- Key is immutable after creation (disabled in edit mode)
- Remove slugify auto-generation logic from mutations
- Add beforeNameSlot prop to BasicFormFields for extensibility
- Add i18n translations for zh/en/ja
2026-01-17 20:28:11 +08:00
WuLiang 6ce6f16a99 fix(docs): change 'ArchLinux 用户' to 'ArchLinux Users' in README (#671) 2026-01-17 19:06:56 +08:00
Jason ad6f5b388b fix(opencode): fix add/remove provider flow and toast messages
- Create separate removeFromLiveConfig API for additive mode apps
  (remove only removes from live config, not database)
- Fix useSwitchProviderMutation to invalidate opencodeLiveProviderIds
  cache so button state updates correctly after add operation
- Show appropriate toast messages:
  - Add: "已添加到配置" / "Added to config"
  - Remove: "已从配置移除" / "Removed from config"
- Add i18n texts for addToConfigSuccess and removeFromConfigSuccess
2026-01-17 17:51:32 +08:00
Jason 2844f7c557 fix(opencode): distinguish remove and delete confirmation dialogs
Separate the confirmation dialogs for "remove from config" and "delete
provider" operations in OpenCode mode to help users understand the
different impacts of each action.
2026-01-17 16:45:31 +08:00
Jason 882c73234f fix(opencode): enable usage auto-query for providers in config
For OpenCode (additive mode), use isInConfig instead of isCurrent to
determine whether to enable usage auto-query. This allows providers
that have been added to the config to have their usage queried
automatically.
2026-01-17 15:48:44 +08:00
Jason b70de25de4 fix(opencode): allow delete button for all providers in additive mode
OpenCode uses additive mode where the main "Remove" button removes from
live config, while the delete button should delete from database. The
delete button should always be enabled for OpenCode providers.
2026-01-17 15:29:02 +08:00
Jason 88dbeb5335 fix(opencode): remove current provider concept for additive mode
OpenCode uses additive mode where all providers coexist in config file,
so there's no "current" provider concept. This commit:

- Skip setting is_current in switch_normal for OpenCode
- Return empty string from ProviderService::current for OpenCode
- Disable active provider highlight in ProviderCard for OpenCode
2026-01-17 15:24:09 +08:00
Jason 966d7b5782 fix(opencode): show Base URL field for all SDK types
Previously Base URL was only shown for @ai-sdk/openai-compatible.
Now it's always visible to support proxy scenarios for official SDKs
like DeepSeek, Anthropic, etc.
2026-01-17 11:46:50 +08:00
Jason 42a92c712a fix(opencode): skip reading live config when editing provider
OpenCode's read_live_settings returns the full opencode.json file
instead of just the provider fragment. This caused the edit dialog
to save the complete config structure as settingsConfig, creating
nested provider configurations.

For OpenCode's additive mode, use DB config directly since each
provider's config is stored independently.
2026-01-17 11:34:39 +08:00
Jason 2f0998c6c8 fix(opencode): prevent config nesting and use slugified provider IDs
- Skip backfill logic for OpenCode (additive mode doesn't need it)
- Add defensive check in write_live_snapshot to extract provider fragment
- Use slugified name as provider ID for readable config keys
2026-01-17 11:16:58 +08:00
Jason 938e2eb563 feat(opencode): add provider presets and fix preset selection handler
- Add 19 new provider presets for OpenCode (cn_official, aggregator, third_party)
- Add OpenCode handling branch in handlePresetChange to properly populate
  form fields (baseURL, apiKey, npm, models) when selecting a preset
- Add OpenCode reset logic in custom mode branch
2026-01-16 22:33:04 +08:00
Jason 2cc36b3950 fix(opencode): add OpenCode support to skills functionality
Add missing OpenCode branch in parse_app_type() and include OpenCode
in all app iteration loops for skills operations (uninstall, scan,
import, migrate).
2026-01-16 21:23:54 +08:00
Jason e06c6176d9 fix(opencode): hide common config snippet UI and prevent auto-merge
- Add `enabled` parameter to useCommonConfigSnippet hook
- Skip all loading and auto-merge logic when enabled=false
- Replace CommonConfigEditor with simplified JsonEditor for OpenCode
- Prevent Claude's common config snippet from being injected into OpenCode
2026-01-16 21:03:33 +08:00
Jason 9b4485e111 refactor(opencode): simplify API format selector
- Reduce npm package options from 10 to 4 core API formats (OpenAI, OpenAI Compatible, Anthropic, Google)
- Rename "AI SDK Package" to "API Format" in i18n (zh/en/ja)
- Remove check icon from Select dropdown items for cleaner UI
2026-01-16 20:29:12 +08:00
Jason f349d85e85 fix(ui): resolve Select dropdown not appearing in FullScreenPanel
- Increase SelectContent z-index from z-50 to z-[100] to appear above FullScreenPanel (z-[60])
- Replace form.watch() with form.getValues() in useCallback handlers for correct react-hook-form usage
- Remove max-w-[56rem] constraints from various panels for consistent full-width layout
2026-01-16 20:21:00 +08:00
Jason 58a13cc69a feat(provider): hide universal tab for OpenCode
OpenCode doesn't support universal providers, so the tab is
hidden to avoid confusion for users.
2026-01-16 16:09:43 +08:00
Jason d765364a18 chore(opencode): remove unused functions and legacy code
Remove dead code that was never called after v3.7.0 architecture change:
- mcp/opencode.rs: sync_enabled_to_opencode, collect_enabled_servers
- opencode_config.rs: 8 unused utility functions
- provider.rs: OpenCodeProviderConfig impl block (4 methods)

These functions were designed for batch operations but McpService uses
per-server sync pattern instead. No functionality affected.
2026-01-16 15:56:03 +08:00
Jason 5c6956b6e2 feat(opencode): add OpenCode toggle switches to MCP and Skills panels
- Add opencode to AppType and SkillApps interfaces in skills.ts
- Add OpenCode Switch component to UnifiedMcpPanel list items
- Add OpenCode Switch component to UnifiedSkillsPanel list items
- Include OpenCode in enabled counts and header statistics for both panels
2026-01-16 15:40:33 +08:00
Jason cb1b45ae4e feat(opencode): add OpenCode icon to icon system
Register OpenCode SVG icon in the icons index so AppSwitcher displays
the proper logo instead of fallback initials.
2026-01-16 13:03:21 +08:00
Jason e4df1a32a5 feat(opencode): implement isInConfig semantics for additive provider management
OpenCode uses additive provider management where providers can exist in
the database but not necessarily in the live opencode.json config. This
commit implements proper isInConfig state:

Backend:
- Add get_opencode_live_provider_ids command to query live config

Frontend:
- Add getOpenCodeLiveProviderIds API method
- ProviderList queries live provider IDs and computes isInConfig
- ProviderCard receives and passes isInConfig to ProviderActions
- ProviderActions already handles the add/remove button logic
2026-01-15 19:37:35 +08:00
Jason 2494eaaa32 feat(opencode): hide proxy UI for OpenCode
OpenCode uses additive provider management (multiple providers coexist),
so proxy/failover features are not applicable. This commit:
- Conditionally renders ProxyToggle only for non-OpenCode apps
- Fixes toast message label to properly handle opencode app type
2026-01-15 19:32:34 +08:00
Jason 45b9cf1df0 feat(opencode): add OpenCode button to AppSwitcher
Add the fourth tab button for OpenCode in the app switcher component,
making the OpenCode providers page accessible from the main navigation.
2026-01-15 19:31:44 +08:00
Jason de3a22535d fix(opencode): address issues found during OpenCode integration review
- Fix MCP server not removed from opencode.json when unchecked in edit modal
- Fix Windows atomic write failure when opencode.json already exists
- Fix i18n keys mismatch in OpenCodeFormFields (use opencode.* namespace)
- Fix unit test missing apps.opencode field assertion
2026-01-15 19:07:49 +08:00
Jason 36d6d48002 feat(opencode): Phase 10 - Internationalization for OpenCode
Update i18n translation files (zh.json, en.json, ja.json):

- Add "opencode" to apps section: "OpenCode"
- Add provider section keys:
  - addOpenCodeProvider: Add OpenCode Provider
  - addToConfig: Add (for additive mode button)
  - removeFromConfig: Remove
  - inConfig: Added
- Add new "opencode" section with UI labels:
  - npmPackage: AI SDK Package
  - npmPackageHint: Package selection hint
  - baseUrl, baseUrlHint: Base URL configuration
  - models, modelsHint: Model configuration
  - addModel, modelId, modelName: Model editor labels
  - noModels: Empty state message
- Add "opencode" to mcp.unifiedPanel.apps section
- Add "opencode" to skills.apps section
2026-01-15 16:42:15 +08:00
Jason 864884926a feat(opencode): Phase 9 - Frontend UI components for OpenCode
- Create OpenCodeFormFields.tsx with:
  - NPM package selector (from AI SDK ecosystem)
  - API Key input using shared ApiKeySection component
  - Base URL input (shown for openai-compatible)
  - Dynamic models editor (add/remove models)

- Update ProviderForm.tsx:
  - Import OpenCode presets and form fields
  - Add OPENCODE_DEFAULT_CONFIG constant
  - Add OpenCode to PresetEntry type union
  - Add OpenCode preset entries in useMemo
  - Add OpenCode state hooks (npm, apiKey, baseUrl, models)
  - Add OpenCode change handlers syncing to form
  - Add OpenCodeFormFields rendering section
  - Add OpenCode config editor using CommonConfigEditor

- Update ProviderActions.tsx for OpenCode additive mode:
  - Add appId and isInConfig props
  - Implement "Add to Config" / "Remove from Config" buttons
  - Disable failover mode for OpenCode
  - Update delete button logic for additive mode

- Update ProviderCard.tsx:
  - Pass appId and isInConfig to ProviderActions

- Update AddProviderDialog.tsx:
  - Add OpenCode base URL extraction from options.baseURL
2026-01-15 16:38:26 +08:00
Jason 093ff0ba29 feat(opencode): Phase 8 - OpenCode provider presets configuration
- Create src/config/opencodeProviderPresets.ts with:
  - OpenCodeProviderPreset interface for preset structure
  - opencodeNpmPackages: AI SDK npm package options
  - Provider presets: OpenAI, Anthropic, Google, DeepSeek, Mistral, Groq
  - OpenAI Compatible custom template for third-party providers
- Each preset uses OpenCodeProviderConfig with npm, options, models structure
- Includes apiKeyUrl for quick access to API key pages
- Template values for dynamic API key input
2026-01-15 16:26:46 +08:00
Jason 21754a7349 feat(opencode): Phase 7 - Frontend TypeScript type definitions
- Add "opencode" to AppId type in lib/api/types.ts
- Extend McpApps interface with opencode field in types.ts
- Add OpenCode-specific types: OpenCodeModel, OpenCodeProviderOptions,
  OpenCodeProviderConfig, OpenCodeMcpServerSpec
- Add opencodeConfigDir to Settings interface
- Add importOpenCodeFromLive() to providersApi
- Fix type errors across components:
  - AppSwitcher: add opencode to icon/name mappings
  - McpFormModal: add opencode to enabledApps state
  - PromptFormModal/Panel: add opencode filename mapping
  - EndpointSpeedTest: add opencode timeout config
  - useBaseUrlState: add opencode to appType union
  - ProxyToggle: add opencode label
  - App.tsx: handle opencode fallback for SkillsPage
- Update ProxyTakeoverStatus with opencode field (always false)
- Fix test mocks in tests/msw/state.ts
2026-01-15 16:25:14 +08:00
Jason 7997b2c7b3 feat(opencode): Phase 6 - Tauri command extensions for OpenCode
- Add import_opencode_providers_from_live command to provider.rs
- Register new command in lib.rs invoke_handler
- Update commands/mcp.rs: include OpenCode in sync_other_side logic
- Add McpService::import_from_opencode to import_mcp_from_apps
- Implement MCP sync/remove for OpenCode in services/mcp.rs
  - sync_server_to_app_no_config now calls sync_single_server_to_opencode
  - remove_server_from_app now calls remove_server_from_opencode
2026-01-15 16:20:03 +08:00
Jason b8538b211d feat(opencode): complete Phase 5 - provider service layer
Implement OpenCode-specific provider service logic with additive mode:
- add(): Always write to live config (no is_current check needed)
- update(): Always sync changes to live config
- delete(): Remove from both DB and live config (no is_current check)

New helper functions in live.rs:
- write_live_snapshot(): Write provider to opencode.json provider section
- remove_opencode_provider_from_live(): Remove provider from live config
- import_opencode_providers_from_live(): Import existing providers from
  ~/.config/opencode/opencode.json into CC Switch database

Key design: OpenCode uses additive mode where all providers coexist
in the config file, unlike Claude/Codex/Gemini which use replacement
mode with a single active provider.
2026-01-15 16:15:01 +08:00
Jason 7ea2c3452b feat(opencode): complete Phase 4 - MCP sync module
Add mcp/opencode.rs with format conversion between CC Switch and OpenCode:
- stdio ↔ local type conversion
- command+args ↔ command array format
- env ↔ environment field mapping
- sse/http ↔ remote type conversion

Public API:
- sync_enabled_to_opencode: Batch sync all enabled servers
- sync_single_server_to_opencode: Sync individual server
- remove_server_from_opencode: Remove from live config
- import_from_opencode: Import servers from OpenCode config

Also fix test files to include new opencode field in McpApps struct.
All 4 unit tests pass for format conversion.
2026-01-15 16:11:25 +08:00
Jason a30d72bb68 feat(opencode): complete Phase 3 - enhanced config read/write module
Add typed provider functions and utilities to opencode_config.rs:
- Typed provider operations (get_typed_providers, get_typed_provider,
  set_typed_provider, set_providers_batch)
- MCP batch operations (set_mcp_servers_batch, clear_mcp_servers)
- Utility functions (create_provider_config, validate_provider_config,
  provider_to_opencode_config, opencode_config_to_provider)
- Enhanced module documentation with config file format examples

The typed API layer provides type-safe access to OpenCode provider
configurations while the untyped layer allows raw JSON operations
when needed.
2026-01-15 16:07:14 +08:00
Jason 58ecc44ee6 feat(opencode): Phase 2 - Add OpenCode provider data structures
Add OpenCode-specific configuration structures for the AI SDK format:

- OpenCodeProviderConfig: Main config with npm package, options, and models
- OpenCodeProviderOptions: baseURL, apiKey, and headers support
- OpenCodeModel: Model definition with name and token limits
- OpenCodeModelLimit: Context and output token limits

Key features:
- Supports environment variable references (e.g., "{env:API_KEY}")
- Custom headers support for specialized providers
- Model-level token limits for context management
- Helper methods for parsing and validation
2026-01-15 15:59:44 +08:00
Jason 5658d93924 feat(opencode): Phase 1 - Backend data structure expansion for OpenCode support
Add OpenCode as the 4th supported application with additive provider management:

- Add OpenCode variant to AppType enum with all related match statements
- Add enabled_opencode field to McpApps and SkillApps structures
- Add opencode field to McpRoot and PromptRoot
- Add database schema migration v3→v4 with enabled_opencode columns
- Add settings.rs support for opencode_config_dir and current_provider_opencode
- Create opencode_config.rs module for config file I/O operations
- Update all services (proxy, mcp, skill, provider, stream_check) for OpenCode
- Add OpenCode support to deeplink provider and MCP parsing
- Update commands/config.rs for OpenCode config status and paths

Key design decisions:
- OpenCode uses additive mode (no is_current needed, no proxy support)
- Config path: ~/.config/opencode/opencode.json
- MCP format: stdio→local, sse/http→remote conversion planned
- Stream check returns error (not yet implemented for OpenCode)
2026-01-15 15:54:29 +08:00
Jason e4d24f2df9 docs: add OpenCode implementation plan
Add comprehensive implementation plan for OpenCode (4th app) support:
- Additive provider management (vs. replacement model)
- MCP format conversion (stdio<->local, sse<->remote)
- Config file: ~/.config/opencode/opencode.json
- No proxy/failover support (OpenCode handles internally)
2026-01-15 15:31:38 +08:00
杨永安 07d022ba9f feat(usage): improve custom template system with variable hints and validation fixes (#628)
* feat(usage): improve custom template with variables display and explicit type detection

Combine two feature improvements:
1. Display supported variables ({{baseUrl}}, {{apiKey}}) with actual values in custom template mode
2. Add explicit templateType field for accurate template mode detection

## Changes

### Frontend
- Display template variables with actual values extracted from provider settings
- Add templateType field to UsageScript for explicit mode detection
- Support template mode persistence across sessions

### Backend
- Add template_type field to UsageScript struct
- Improve validation logic based on explicit template type
- Maintain backward compatibility with type inference

### I18n
- Add "Supported Variables" section translation (zh/en/ja)

### Benefits
- More accurate template mode detection (no more guessing)
- Better user experience with variable hints
- Clearer validation rules per template type

* fix(usage): resolve custom template cache and validation issues

Combine three bug fixes to make custom template mode work correctly:

1. **Update cache after test**: Testing usage script successfully now updates the main list cache immediately
2. **Fix same-origin check**: Custom template mode can now access different domains (SSRF protection still active)
3. **Fix field naming**: Unified to use autoQueryInterval consistently between frontend and backend

## Problems Solved

- Main provider list showing "Query failed" after successful test
- Custom templates blocked by overly strict same-origin validation
- Auto-query intervals not saved correctly due to inconsistent naming

## Changes

### Frontend (UsageScriptModal)
- Import useQueryClient and update cache after successful test
- Invalidate usage cache when saving script configuration
- Use standardized autoQueryInterval field name

### Backend (usage_script.rs)
- Allow custom template mode to bypass same-origin checks
- Maintain SSRF protection for all modes

### Hooks (useProviderActions)
- Invalidate usage query cache when saving script

## Impact

Users can now use custom templates freely while security validations remain intact for general templates.

* fix(usage): correct provider credential field names

- Claude: support both ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN
- Gemini: use GEMINI_API_KEY instead of GOOGLE_GEMINI_API_KEY
- Codex: use OPENAI_API_KEY and parse base_url from TOML config string

Addresses review feedback from PR #628

* style: format code

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-14 15:42:05 +08:00
Dex Miller f3343992f2 feat(proxy): add thinking signature rectifier for Claude API (#595)
* feat(proxy): add thinking signature rectifier for Claude API

Add automatic request rectification when Anthropic API returns signature
validation errors. This improves compatibility when switching between
different Claude providers or when historical messages contain incompatible
thinking block signatures.

- Add thinking_rectifier.rs module with trigger detection and rectification
- Integrate rectifier into forwarder error handling flow
- Remove thinking/redacted_thinking blocks and signature fields on retry
- Delete top-level thinking field when assistant message lacks thinking prefix

* fix(proxy): complete rectifier retry path with failover switch and chain continuation

- Add failover switch trigger on rectifier retry success when provider differs from start
- Replace direct error return with error categorization on rectifier retry failure
- Continue failover chain for retryable errors instead of terminating early

* feat(proxy): add rectifier config with master switch

- Add RectifierConfig struct with enabled and requestThinkingSignature fields
- Update should_rectify_thinking_signature to check master switch first
- Add tests for master switch functionality

* feat(db): add rectifier config storage in settings table

Store rectifier config as JSON in single key for extensibility

* feat(commands): add get/set rectifier config commands

* feat(ui): add rectifier config panel in advanced settings

- Add RectifierConfigPanel component with master switch and thinking signature toggle
- Add API wrapper for rectifier config
- Add i18n translations for zh/en/ja

* feat(proxy): integrate rectifier config into request forwarding

- Load rectifier config from database in RequestContext
- Pass config to RequestForwarder for runtime checking
- Use should_rectify_thinking_signature with config parameter

* test(proxy): add nested JSON error detection test for thinking rectifier

* fix(proxy): resolve HalfOpen permit leak and RectifierConfig default values

- Fix RectifierConfig::default() to return enabled=true (was false due to derive)
- Add release_permit_neutral() for releasing permits without affecting health stats
- Fix 3 permit leak points in rectifier retry branches
- Add unit tests for default values and permit release

* style(ui): format ProviderCard style attribute

* fix(rectifier): add detection for signature field required error

Add support for detecting "signature: Field required" error pattern
in the thinking signature rectifier. This enables automatic request
rectification when upstream API returns this specific validation error.
2026-01-14 00:12:13 +08:00
Jason 53f40b2d7a refactor(ui): unify pricing edit modal with FullScreenPanel
Replace Dialog component with FullScreenPanel in PricingEditModal
to match the UI style of other edit dialogs (provider, MCP).

Changes:
- Switch from small centered Dialog to full-screen panel
- Add back button in header and fixed footer for actions
- Add Save/Plus icons to submit button
2026-01-13 15:13:33 +08:00
Dex Miller 1393f89797 feat(misc): add WSL tool version detection with security hardening (#… (#627)
* feat(misc): add WSL tool version detection with security hardening (#608)

- Add WSL distro detection from UNC path (wsl$/wsl.localhost)
- Add distro name validation to prevent command injection
- Add defensive assertion for tool parameter
- Unify cfg macros to target_os = "windows"
- Standardize error message format to [WSL:{distro}]

Closes #608

* fix(misc): add CREATE_NO_WINDOW flag and unify error messages to English

- Add CREATE_NO_WINDOW flag to wsl.exe command to prevent console window flash
- Standardize error messages from Chinese to English for consistency

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-13 12:11:54 +08:00
Dex Miller f9d80b8dc3 feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests (#623)
- Add configurable test prompt field to StreamCheckConfig
- Implement Claude CLI-compatible request format with proper headers:
  - Authorization + x-api-key dual auth
  - anthropic-beta, anthropic-version headers
  - x-stainless-* SDK headers with dynamic OS/arch detection
  - URL with ?beta=true parameter
- Implement Codex CLI-compatible Responses API format:
  - /v1/responses endpoint
  - input array format with reasoning effort support
  - codex_cli_rs user-agent and originator headers
- Add dynamic OS name and CPU architecture detection
- Internationalize error messages (Chinese -> English)
- Add test prompt Textarea UI component with i18n support
- Remove obsolete testPromptDesc translation key
2026-01-13 11:36:19 +08:00
Dex Miller 8b92982112 Feature/global proxy (#596)
* refactor(proxy): simplify logging for better readability

- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.

* fix: replace unsafe unwrap() calls with proper error handling

- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).

* refactor(proxy): simplify verbose logging output

- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.

* feat(proxy): add structured log codes for i18n support

Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel

* feat(pricing): support @ separator in model name matching

- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching

* feat(proxy): add global proxy settings support

Add ability to configure a global HTTP/HTTPS proxy for all outbound
requests including provider API calls, speed tests, and stream checks.

* fix(proxy): improve validation and error handling in proxy config panels

- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation

* fix(proxy): harden error handling and input validation

- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels

* feat(proxy): add local proxy auto-scan and fix hot-reload

- Add scan_local_proxies command to detect common proxy ports
- Fix SkillService not using updated proxy after hot-reload
- Move global proxy settings to advanced tab
- Add error handling for scan failures

* fix(proxy): allow localhost input in proxy address field

* fix(proxy): restore request timeout and fix proxy hot-reload issues

- Add URL scheme validation in build_client (http/https/socks5/socks5h)
- Restore per-request timeout for speedtest, stream_check, usage_script, forwarder
- Fix set_global_proxy_url to validate before persisting to DB
- Mask proxy credentials in all log outputs
- Fix forwarder hot-reload by fetching client on each request

* style: format code with prettier

* fix(proxy): improve global proxy stability and error handling

- Fix RwLock silent failures with explicit error propagation
- Handle init() duplicate calls gracefully with warning log
- Align fallback client config with build_client settings
- Make scan_local_proxies async to avoid UI blocking
- Add mixed mode support for Clash 7890 port (http+socks5)
- Use multiple test targets for better proxy connectivity test
- Clear invalid proxy config on init failure
- Restore timeout constraints in usage_script
- Fix mask_url output for URLs without port
- Add structured error codes [GP-001 to GP-009]

* feat(proxy): add username/password authentication support

- Add separate username and password input fields
- Implement password visibility toggle with eye icon
- Add clear button to reset all proxy fields
- Auto-extract auth info from saved URL and merge on save
- Update i18n translations (zh/en/ja)

* fix(proxy): fix double encoding issue in proxy auth and add debug logs

- Remove encodeURIComponent in mergeAuth() since URL object's
  username/password setters already do percent-encoding automatically
- Add GP-010 debug log for database read operations
- Add GP-011 debug log to track incoming URL info (length, has_auth)
- Fix username.trim() in fallback branch for consistent behavior
2026-01-13 10:55:53 +08:00
Dex Miller 74b4d4ecbb fix(ui): auto-adapt usage block offset based on action buttons width (#613) 2026-01-12 23:07:02 +08:00
Dex Miller 99c910e58e fix(provider): persist endpoint auto-select state (#611)
- Add endpointAutoSelect field to ProviderMeta for persistence
- Lift autoSelect state from EndpointSpeedTest to ProviderForm
- Save auto-select preference when provider is saved
- Restore preference when editing existing provider

Fixes https://github.com/farion1231/cc-switch/issues/589
2026-01-12 16:26:17 +08:00
Dex Miller 8f7423f011 Feat/deeplink multi endpoints (#597)
* feat(deeplink): support comma-separated multiple endpoints in URL

Allow importing multiple API endpoints via single endpoint parameter.
First URL becomes primary endpoint, rest are added as custom endpoints.

* feat(deeplink): add usage query fields to deeplink generator

Add form fields for usage query configuration in deeplink HTML generator:
- usageEnabled, usageBaseUrl, usageApiKey
- usageScript, usageAutoInterval
- usageAccessToken, usageUserId

* fix(deeplink): auto-infer homepage and improve multi-endpoint display

- Auto-infer homepage from primary endpoint when not provided
- Display multiple endpoints as list in import dialog (primary marked)
- Update deeplink parser in deplink.html to show multi-endpoint info
- Add test for homepage inference from endpoint
- Minor log format fix in live.rs

* fix(deeplink): use primary endpoint for usage script base_url

- Fix usage_script.base_url getting comma-separated string when multiple endpoints
- Add i18n support for primary endpoint label in DeepLinkImportDialog
2026-01-12 15:57:45 +08:00
Jason c56523c9c0 Merge tianrking/main: feat: add provider-specific terminal button
Merged PR #452 which adds:
- Terminal button for Claude providers to launch with provider-specific config
- Cross-platform support (macOS/Linux/Windows)
- Auto-cleanup of temporary config files
2026-01-12 09:13:24 +08:00
Jason 6aef472fd2 fix(deeplink): prioritize GOOGLE_GEMINI_BASE_URL over GEMINI_BASE_URL
When merging Gemini config from local env file during deeplink import,
check GOOGLE_GEMINI_BASE_URL first (official variable name) before
falling back to GEMINI_BASE_URL.
2026-01-11 23:24:06 +08:00
Xyfer 4a8883ecc3 fix(mcp): skip cmd /c wrapper for WSL target paths (#592)
* fix(mcp): skip cmd /c wrapper for WSL target paths

When the Claude config directory is set to a WSL network path
(e.g., \wsl$\Ubuntu\home\user\.claude), the MCP export should
not wrap npx/npm commands with cmd /c since WSL runs Linux.

- Add is_wsl_path() to detect \wsl$\ and \wsl.localhost\ paths
- Skip wrap_command_for_windows() when target is WSL path
- Add comprehensive tests for various WSL distributions

* chore(mcp): add debug log for WSL path detection

* refactor(mcp): optimize is_wsl_path with next() and rename variable
2026-01-11 20:51:56 +08:00
Dex Miller 6dd809701b Refactor/simplify proxy logs (#585)
* refactor(proxy): simplify logging for better readability

- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.

* fix: replace unsafe unwrap() calls with proper error handling

- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).

* refactor(proxy): simplify verbose logging output

- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.

* feat(proxy): add structured log codes for i18n support

Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel

* feat(pricing): support @ separator in model name matching

- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching

* fix(proxy): improve validation and error handling in proxy config panels

- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation

* fix(proxy): harden error handling and input validation

- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels
2026-01-11 20:50:54 +08:00
Jason 76fa830688 fix(live): sync skills to app directories on config path change
When users change app config directories (claudeConfigDir, codexConfigDir,
geminiConfigDir), MCP servers were being synced to the new paths but Skills
were not. This adds Skill synchronization to sync_current_to_live() to ensure
installed Skills are also copied to the new app directories.
2026-01-11 16:39:50 +08:00
Jason c9a4938866 fix(provider-form): reset baseUrl and apiKey states when switching presets
Fix state synchronization in useBaseUrlState and useApiKeyState hooks
to properly clear values when config is reset. Previously, when switching
from a preset to "custom", the baseUrl and apiKey states would retain
their old values because the sync logic only updated when new values
existed, not when they were cleared.

Changes:
- useBaseUrlState: Always sync baseUrl to config value (empty if undefined)
- useApiKeyState: Remove hasApiKeyField check that prevented clearing
2026-01-11 16:39:50 +08:00
Jason 95ed6d6903 fix(usage): prevent usage script config from leaking between providers
Add key prop to UsageScriptModal to ensure component remounts when
switching between different providers. This fixes issue #569 where
configuring usage query for one provider would incorrectly apply
the same configuration to all providers.

The root cause was that useState initialization only runs on first
mount, and due to useLastValidValue hook keeping the modal rendered
during close animation, the component might not fully unmount when
switching providers rapidly.

Closes #569
2026-01-11 16:39:50 +08:00
Jason 83db457b10 refactor(proxy): disable OpenRouter compat mode by default and hide UI toggle
OpenRouter now natively supports Claude Code compatible API (/v1/messages),
so format transformation (Anthropic ↔ OpenAI) is no longer needed by default.

- Change default value from `true` to `false` in both frontend and backend
- Hide the "OpenRouter Compatibility Mode" toggle in provider form
- Users can still enable it manually by adding `"openrouter_compat_mode": true` in config JSON
- Update unit tests to reflect new default behavior
2026-01-11 16:39:50 +08:00
Xyfer 392756e373 fix(gemini): convert timeout params to Gemini CLI format (#580)
Claude Code/Codex uses startup_timeout_sec and tool_timeout_sec,
but Gemini CLI only supports a single timeout param in milliseconds.

- Collect startup and tool timeout separately with defaults
  - startup_timeout_sec default: 10s
  - tool_timeout_sec default: 60s
- Take max of both values as final timeout
- Support both sec and ms variants
- Remove original fields and insert Gemini-compatible timeout
2026-01-11 10:42:44 +08:00
Jason 6021274b82 fix(ci): temporarily remove Flatpak build
Flatpak build has persistent issues with libdbusmenu dependencies.
Removing it for now to allow release. Can be re-added later with
proper libayatana dependency configuration.
2026-01-09 22:01:33 +08:00
Jason b7fd70075c fix(flatpak): remove --enable-tests=no to fix HAVE_VALGRIND error
libdbusmenu's configure.ac has a bug where AM_CONDITIONAL([HAVE_VALGRIND])
is only defined when tests are enabled. Removing --enable-tests=no allows
the conditional to be properly defined.

Ref: https://bugs.launchpad.net/ubuntu/+source/libdbusmenu/+bug/1708938
2026-01-09 21:47:59 +08:00
Jason eeb6afef01 fix(flatpak): bundle intltool for libdbusmenu build
intltool was removed from org.gnome.Sdk in 2019. libdbusmenu's configure
script requires it even with --disable-nls. Using cleanup: ["*"] ensures
intltool is only used at build time.
2026-01-09 21:26:53 +08:00
Jason d3074eadb5 fix(flatpak): disable NLS for libdbusmenu-gtk3
The Flatpak SDK lacks intltool, causing libdbusmenu-gtk3 configure to
fail. Disabling NLS avoids this dependency.
2026-01-09 21:07:38 +08:00
Jason 31d34a4512 fix(ci): add elfutils for Flatpak build
The libayatana modules added in 2923627b require eu-strip to strip
debug symbols during flatpak-builder execution.
2026-01-09 20:44:23 +08:00
Jason 3ef86bdb99 chore: bump version to v3.9.1
- Update version in package.json, tauri.conf.json, Cargo.toml
- Update version badges and current version in README files
- Add v3.9.1 changelog entry with bug fixes and improvements
2026-01-09 20:19:38 +08:00
Jason df3f8a05c4 fix(presets): rename AiGoCode to AIGoCode
Update the display name casing for AIGoCode across all provider presets
and i18n files to match their official branding.
2026-01-09 20:03:24 +08:00
Jason 8e6fad7af2 fix(windows): correct window title and remove extra titlebar spacing
- Add missing "title" field to tauri.windows.conf.json to display
  "CC Switch" instead of default "Tauri app"
- Make DRAG_BAR_HEIGHT platform-aware: 0px on Windows/Linux (native
  titlebar), 28px on macOS (Overlay mode needs traffic light space)
- Apply same fix to FullScreenPanel component for consistency

Fixes the issue where Windows showed wrong title and had ~28px extra
blank space below the native titlebar introduced in v3.9.0.
2026-01-09 19:45:36 +08:00
Jason f22000a4df feat(presets): add AiGoCode icon and partner promotion
- Add AiGoCode colored icon (blue body #5B7FFF, purple star #7C6AEF)
- Add original SVG source file for reference
- Add icon metadata with keywords and default color
- Add iconColor to AiGoCode presets for Claude, Codex, and Gemini
- Add partner promotion messages in zh/en/ja locales
2026-01-09 16:29:13 +08:00
Dex Miller 412906fb09 feat(logging): add crash logging and improve log management (#562)
* feat(logging): add crash logging and improve log management

- Add panic hook to capture crash info to ~/.cc-switch/crash.log
  - Records timestamp, app version, OS/arch, thread info
  - Full stack trace with force_capture for release builds
  - Safe error handling (no nested panics)

- Enable logging for both Debug and Release builds
  - Info level for all builds
  - Output to console and ~/.cc-switch/logs/
  - 5MB max file size with rotation

- Add log cleanup on startup
  - Keep only 2 most recent log files
  - Works on all platforms

- Change panic strategy from "abort" to "unwind"
  - Required for backtrace capture in release builds

* fix(logging): use OnceLock for config dir and add URL redaction

- Use OnceLock to support custom config directory override for crash.log
- Add redact_url_for_log() to protect sensitive URL parameters in logs
- Change verbose deep link logs from info to debug level
- Move Store refresh before panic_hook init to ensure correct path

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-09 16:23:59 +08:00
Jason Young a268127f1f Fix/Resolve panic issues in proxy-related code (#560)
* fix(proxy): change default port from 5000 to 15721

Port 5000 conflicts with AirPlay Receiver on macOS 12+.
Also adds error handling for proxy toggle and i18n placeholder updates.

* fix(proxy): replace unwrap/expect with graceful error handling

- Handle HTTP client initialization failure with no_proxy fallback
- Fix potential panic on Unicode slicing in API key preview
- Add proper error handling for response body builder
- Handle edge case where SystemTime is before UNIX_EPOCH

* fix(proxy): handle UTF-8 char boundary when truncating request body log

Rust strings are UTF-8 encoded, slicing at a fixed byte index may cut
in the middle of a multi-byte character (e.g., Chinese, emoji), causing
a panic. Use is_char_boundary() to find the nearest safe cut point.

* fix(proxy): improve robustness and prevent panics

- Add reqwest socks feature to support SOCKS proxy environments
- Fix UTF-8 safety in masked_key/masked_access_token (use chars() instead of byte slicing)
- Fix UTF-8 boundary check in usage_script HTTP response truncation
- Add defensive checks for JSON operations in proxy service
- Remove verbose debug logs that could trigger panic-prone code paths
2026-01-09 13:09:19 +08:00
sada-dev 2923627b76 fix(flatpak): bundle libayatana-appindicator for tray icon support (#556)
The Flatpak was failing to load because libayatana-appindicator3 is not
included in org.gnome.Platform runtime. This adds the required modules:
- libayatana-ido
- libdbusmenu-gtk3
- libayatana-indicator
- libayatana-appindicator

Fixes panic: 'Failed to load ayatana-appindicator3 or appindicator3 dynamic library'

Co-authored-by: Said John <said.john@gmail.com>
2026-01-09 12:20:16 +08:00
Jason 48db113b37 docs: update v3.9.0 release notes with acknowledgments and improved macOS instructions
- Add special thanks section for contributors @xunyu @deijing @su-fen
- Update macOS tip: replace xattr command with System Settings GUI guidance
- Reformat markdown tables with aligned columns
- Move Linux section after Homebrew section
2026-01-08 16:36:45 +08:00
Jason e9fc56525d docs: enhance download section with detailed installation guide for v3.9.0
- Convert system requirements to table format with architecture info
- Add Windows/macOS file descriptions with recommended options
- Replace Linux section with comprehensive distro-based table
- Include complete installation commands for deb/rpm/AppImage/flatpak
- Add helpful tips for macOS Gatekeeper and AppImage usage
2026-01-08 15:37:07 +08:00
苏风 75d512b36c docs: add download options for each system version in 3.9.0 release note (#547)
* docs: add download options for each system version in 3.9.0 release note.

* docs: add download options for each system version in 3.9.0 release note.
2026-01-08 15:04:27 +08:00
Jason 157ebaaad2 fix(ci): add --user flag to flatpak remote-add command 2026-01-08 12:49:28 +08:00
Jason 42d9afa3e2 fix(ci): remove extra '--' from Linux build command 2026-01-08 12:36:32 +08:00
Jason a0b5e3b808 chore(release): prepare v3.9.0 stable release
- Bump version from 3.9.0-3 to 3.9.0 across all config files
- Add comprehensive release notes in English, Chinese, and Japanese
- Update CHANGELOG with v3.9.0 stable and v3.9.0-2 entries
- Update README badges and release note links to v3.9.0
2026-01-08 11:24:07 +08:00
Jason effb931a1b feat(presets): add Cubence as partner provider
- Add Cubence provider presets for Claude, Codex, and Gemini
- Add Cubence icon to icon system
- Add partner promotion text in zh/en/ja
2026-01-08 11:24:07 +08:00
Jason 0456255625 docs: add Cubence as sponsor partner
Add Cubence sponsor information to README in all three languages
(English, Chinese, Japanese).
2026-01-08 11:24:07 +08:00
Dex Miller 847f1c5377 Feat/proxy header improvements (#538)
* fix(proxy): improve header handling for Claude API compatibility

- Streamline header blacklist by removing overly aggressive filtering
  (browser-specific headers like sec-fetch-*, accept-language)
- Ensure anthropic-beta header always includes 'claude-code-20250219'
  marker required by upstream services for request validation
- Centralize anthropic-version header handling in forwarder to prevent
  duplicate headers across different auth strategies
- Add ?beta=true query parameter to /v1/messages endpoint for
  compatibility with certain upstream services (e.g., DuckCoding)
- Remove redundant anthropic-version from ClaudeAdapter auth headers
  as it's now managed exclusively by the forwarder

This improves proxy reliability with various Claude API endpoints
and third-party services that have specific header requirements.

* style(services): use inline format arguments in format strings

Apply Rust 1.58+ format string syntax across provider and skill
services. This replaces format!("msg {}", var) with format!("msg {var}")
for improved readability and consistency with modern Rust idioms.

Changed files:
- services/provider/mod.rs: 1 format string
- services/skill.rs: 10 format strings (error messages, log statements)

No functional changes, purely stylistic improvement.

* fix(proxy): restrict Anthropic headers to Claude adapter only

- Move anthropic-beta and anthropic-version header handling inside
  Claude-specific condition to avoid sending unnecessary headers
  to Codex and Gemini APIs
- Update test cases to reflect ?beta=true query parameter behavior
- Add edge case tests for non-messages endpoints and existing queries
2026-01-08 11:04:42 +08:00
Jason 24a36df140 chore(presets): update model versions for provider presets
Claude presets:
- Zhipu GLM: glm-4.6 → glm-4.7
- Z.ai GLM: glm-4.6 → glm-4.7
- ModelScope: GLM-4.6 → GLM-4.7
- MiniMax: MiniMax-M2 → MiniMax-M2.1

Codex presets:
- Azure, AiHubMix, DMXAPI, PackyCode: gpt-5.1-codex → gpt-5.2
- Custom template: gpt-5-codex → gpt-5.2
2026-01-06 23:05:22 +08:00
Jason 2c4c2ce83d fix(settings): navigate to About tab when clicking update badge
- Add defaultTab prop to SettingsPage for external tab control
- UpdateBadge click now opens settings directly to About tab
- Settings button still opens to General tab (default behavior)
- Change update badge text from version number to "Update available"
2026-01-06 11:27:32 +08:00
Jason 0020889a8f fix(prompts): allow saving prompts with empty content
Remove content validation requirement to allow users to save prompts
with empty content for placeholder or draft purposes.
2026-01-05 23:30:03 +08:00
Jason 671cda60d9 fix(database): show error dialog on initialization failure with retry option
- Add user-friendly dialog when database init or schema migration fails
- Support retry mechanism instead of crashing the app
- Include bilingual (Chinese/English) error messages with troubleshooting tips
- Add comprehensive test for v3.8 → current schema migration path
2026-01-05 22:39:39 +08:00
Jason efa653809b style: format code with Prettier 2026-01-05 21:53:01 +08:00
Jason 5aa35906d8 feat(release): add RPM and Flatpak packaging support for Linux
- Add RPM bundle to Linux build targets in CI workflow
- Add Flatpak manifest, desktop entry, and AppStream metainfo
- Update release workflow to build and publish .rpm and .flatpak artifacts
- Update README docs with new Linux package formats and installation instructions
- Add .gitignore rules for Flatpak build artifacts
2026-01-05 16:35:36 +08:00
Jason 4777c99b38 refactor(settings): reorder advanced tab items for better UX
Move Auto Failover section directly after Proxy section since they are
functionally related (failover depends on proxy service).
2026-01-05 16:35:36 +08:00
duskzhen 8912216cb2 fix(common): improve window dragging area in FullScreenPanel (#525)
Add data-tauri-drag-region and WebkitAppRegion styles to header area to match	App.tsx behavior, ensuring proper window dragging on macOS.
2026-01-05 16:25:38 +08:00
Jason 32149b1eeb chore(proxy): remove unused body filter helper
Remove unused `filter_recursive` function from body_filter.rs to fix
`dead-code` warning from `cargo clippy -- -D warnings`.
2026-01-04 23:00:13 +08:00
Jason 8979d964d6 fix(proxy): clean up model override env vars when switching providers in takeover mode
When proxy takeover is enabled, switching providers no longer writes to
the Live config. However, if model override fields (ANTHROPIC_MODEL,
ANTHROPIC_REASONING_MODEL, etc.) remain in the Live config, Claude Code
continues sending requests with the old model name, causing failures
when the new provider doesn't support that model.

This fix:
- Removes model override env keys from Claude Live config during takeover
- Adds cleanup when switching providers in takeover mode
- Fixes has_mapping() to include reasoning_model in the check
- Adds test coverage for reasoning-only model mapping scenarios
2026-01-04 16:09:37 +08:00
Jason 37396b9c70 fix(common-config): reset initialization flag when preset changes
The common config checkbox was only auto-enabled for the first preset
(custom) because hasInitializedNewMode ref was permanently locked to
true after first initialization.

Add selectedPresetId parameter to all three common config hooks and
reset the initialization flag when preset changes, allowing the
initialization logic to re-run for each preset switch.
2026-01-04 12:27:22 +08:00
Jason 2c35372ca0 fix(codex): remove entire model_providers table from common config extraction
Previously only removed base_url from model_providers.* tables. Now removes
the entire model_providers section since all its fields (name, base_url,
wire_api, requires_openai_auth) are provider-specific configuration.

MCP servers configuration remains preserved as it's provider-agnostic.
2026-01-04 12:27:22 +08:00
Jason 63bb673bf2 refactor(common-config): extract snippet from editor content instead of active provider
- Change extraction source from current active provider to editor's live content
- Add i18n support for JSON parse error messages via invalid_json_format_error()
- Simplify API by removing unused providerId parameter
- Update button labels and error messages in zh/en/ja locales
2026-01-04 12:27:22 +08:00
Jason 058f86aff3 fix(codex): prevent extract_common_config from removing MCP servers' base_url
- Replace regex patterns with toml_edit for precise field removal
- Only remove top-level model/model_provider/base_url fields
- Only remove base_url from [model_providers.*] tables
- Add regression test to ensure [mcp_servers.*] base_url is preserved
2026-01-04 12:27:22 +08:00
Jason 188c94f2e3 feat(common-config): add extract from current provider functionality
- Add backend command to extract common config snippet from current provider
- Automatically extract common config on first run after importing default provider
- Auto-enable common config checkbox in new provider mode when snippet exists
- Refactor Gemini common config to operate on .env instead of config.json
- Add "Extract from Current" button to all three common config modals
- Update i18n translations for new extraction feature
2026-01-04 12:27:22 +08:00
Dex Miller c049c5f2bb feat(proxy): update failover timeout and circuit breaker defaults (#521)
- Double all timeout values (streaming/non-streaming)
- Codex/Gemini: circuit_failure_threshold 5→4, error_rate 0.5→0.6
- Claude: circuit_error_rate_threshold 0.6→0.7
2026-01-04 11:52:12 +08:00
jwaterwater c71b030662 feat: change the default Qwen BaseUrl (#517) 2026-01-04 10:54:36 +08:00
w0x7ce f363bb1dd0 Merge origin/main into main
Resolved conflict in src-tauri/src/commands/misc.rs by combining imports from both sides.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 09:12:12 +08:00
Jason 2105f2d05b fix(i18n): remove hardcoded defaultValue and unused placeholder for reasoning model 2026-01-03 12:04:34 +08:00
Jason 0a9de282a3 fix(skills): skip hidden directories when scanning for skills
Filter out directories starting with '.' (e.g., .system) during skill
scanning to avoid exposing internal system directories from Codex.
2026-01-03 11:42:10 +08:00
Jason 0e085aa01a fix(prompts): change toggle color from blue to emerald
Align with the app-wide Switch component color scheme for
visual consistency across all toggle elements.
2026-01-03 10:15:28 +08:00
Jason 2c95f697cd fix(prompts): unify add button style with other panels
Replace circular orange icon button with ghost text button
to match Skills and MCP panel styling.
2026-01-03 10:13:32 +08:00
Jason ba97a5f373 fix(mcp): unify header buttons style with skills panel
Changed MCP panel buttons to match Skills panel styling:
- Replace circular orange icon button with ghost text button
- Update button labels: "Import" → "Import Existing", add "Add MCP"
2026-01-03 10:11:26 +08:00
Jason 86e802bd4b feat(mcp): add import button to import MCP servers from apps
- Add import_mcp_from_apps command that reuses existing import logic
- Add Import button in MCP panel header (consistent with Skills)
- Fix import count to only return truly new servers (not already in DB)
- Update translations for import success/no-import messages (zh/en/ja)
2026-01-03 10:04:46 +08:00
Jason 6460c1d5dd fix(skills): move import button to header for better discoverability
- Add import button next to discover button in skills page header
- Expose openImport method via UnifiedSkillsPanel ref
- Show toast instead of dialog when no unmanaged skills found
- Remove redundant buttons from empty state view
2026-01-03 09:21:15 +08:00
Jason 6b73e55bfe fix(ui): remove hover scale effect from skill cards
The hover:scale-[1.01] effect caused cards to overflow their container
boundaries. Keeping only hover:shadow-lg provides sufficient visual
feedback without the overflow issue.
2026-01-02 23:57:22 +08:00
Jason 47aa4c6bee fix(skills): show loading indicator when refreshing discovery list
Use isFetching instead of just isLoading to show the loading spinner.
isLoading is only true on initial load, while isFetching is true
during any fetch operation including refetch.
2026-01-02 23:51:03 +08:00
Jason 4dc59dff21 fix(skills): remove refresh button from installed skills panel
The refresh button is only needed in the discovery panel (to fetch
latest skills from GitHub). The installed skills panel uses local
database which auto-updates on install/uninstall operations.
2026-01-02 23:41:28 +08:00
Jason c8750f5550 fix(i18n): rename Skills title to be app-agnostic
- Remove "Claude" prefix from Skills management title
- Update descriptions to include Gemini alongside Claude Code/Codex
- Applied to all three locales: zh, en, ja
2026-01-02 23:15:22 +08:00
Jason 22460de976 perf(skills): use infinite cache for discoverable skills
Change staleTime from 5 minutes to Infinity. Cache is still properly
invalidated when repos are added/removed or skills are installed/uninstalled.
2026-01-02 23:04:40 +08:00
Jason e69c1bd8aa fix(ui): align FullScreenPanel header with App.tsx layout
- Use same DRAG_BAR_HEIGHT (28px) and HEADER_HEIGHT (64px) as App.tsx
- Remove border-b divider line from header
- Add rounded-lg class to back button for consistency
2026-01-02 22:35:33 +08:00
Jason a17fa8098b fix(skills): remove redundant navigation buttons in skills pages
- Remove duplicate "Repo Manager" button from installed skills view
  (should only appear in discovery view)
- Remove redundant back button from SkillsPage component
  (header already provides unified navigation)
- Clean up unused openRepoManagerOnDiscovery state and related useEffect
- Remove unused onClose prop and ArrowLeft import from SkillsPage
2026-01-02 22:33:33 +08:00
Jason ff03ca1e63 feat(skills): unified management architecture with SSOT and React Query
- Introduce SSOT (Single Source of Truth) at ~/.cc-switch/skills/
- Add three-app toggle support (Claude/Codex/Gemini) for each skill
- Refactor frontend to use TanStack Query hooks instead of manual state
- Add UnifiedSkillsPanel for managing installed skills with app toggles
- Add useSkills.ts with declarative data fetching hooks
- Extend skills.ts API with unified install/uninstall/toggle methods
- Support importing unmanaged skills from app directories
- Add v2→v3 database migration for new skills table structure
2026-01-02 22:04:02 +08:00
Jason cce6ae86a5 fix: prevent env check card border overflow on hover
Add horizontal padding to the grid container to accommodate the scale
transform effect when hovering over environment check cards.
2025-12-31 22:58:03 +08:00
Dex Miller 5376ea042b Feat/usage improvements (#508)
* i18n: update cache terminology across all languages

- Change 'Cache Read' to 'Cache Hit' in all languages
- Change 'Cache Write' to 'Cache Creation' in all languages
- Update zh: 缓存读取 → 缓存命中, 缓存写入 → 缓存创建
- Update en: Cache Read → Cache Hit, Cache Write → Cache Creation
- Update ja: キャッシュ読取 → キャッシュヒット, キャッシュ書込 → キャッシュ作成

Affected keys: cacheReadTokens, cacheCreationTokens, cacheReadCost,
cacheWriteCost, cacheRead, cacheWrite

* feat(usage): add cache metrics to trend chart

- Add cache creation tokens visualization (orange line)
- Add cache hit tokens visualization (purple line)
- Add gradient definitions for new cache metrics
- Include cache data in hourly aggregation
- Display cache metrics alongside input/output tokens

This provides better visibility into cache usage patterns over time.

* fix(usage): fix timezone handling in datetime picker

- Add timestampToLocalDatetime() to convert Unix timestamp to local datetime
- Add localDatetimeToTimestamp() with validation for incomplete input
- Fix issue where typing hours/minutes would jump to previous day
- Validate datetime format completeness before conversion
- Use local timezone instead of UTC for datetime-local input

This resolves the issue where users couldn't fine-tune time selection
and the input would jump unexpectedly when editing hours or minutes.

* feat(usage): add auto-refresh for usage statistics

- Add 30-second auto-refresh interval for all usage queries
- Disable background refresh to save resources
- Apply to: summary, trends, provider stats, model stats, request logs
- Queries automatically update when tab is active
- Pause refresh when user switches to another tab

This keeps usage data fresh without manual refresh.

* fix(proxy): improve usage logging and cache token parsing

- Log requests even when usage parsing fails (with default values)
- Add detailed debug logging for usage metrics
- Support cache_read_input_tokens field in Codex responses
- Fallback to input_tokens_details.cached_tokens if needed
- Add test case for cached_tokens in input_tokens_details
- Ensure all requests are tracked in database for analytics

This fixes missing request logs when API responses lack usage data
and improves cache token detection across different response formats.

* style(rust): use inline format args in format! macros

- Replace format!("...", var) with format!("...{var}")
- Update universal provider ID formatting
- Update error message formatting
- Update config.toml generation in Codex provider

Fixes clippy::uninlined_format_args warnings.

* feat(proxy): enhance provider router logging

- Add debug logs for failover queue provider count
- Log circuit breaker state for each provider check
- Add logs for missing current provider scenarios
- Log when no current provider is configured
- Use inline format args for better readability

This improves debugging of provider selection and failover behavior.

* feat(database): update model pricing data

- Update Claude models to full version format (e.g. claude-opus-4-5-20251101)
- Add GPT-5.2 series model pricing (10 models)
- Add GPT-5.1 series model pricing (10 models)
- Add GPT-5 series model pricing (12 models)
- Add Gemini 3 series model pricing (2 models)
- Update Gemini 2.5 series model ID format (use dot separator)
- Unify display names by removing thinking level suffixes

* fix(usage): correct Gemini output token calculation

Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.

- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic

* fix(usage): correct cache token billing and add Codex format auto-detection

- Avoid double-billing cache tokens by subtracting from input before calculation
- Add smart Codex parser that auto-detects OpenAI vs Codex API format
- Extract model name from Codex responses for accurate tracking

* fix(proxy): improve takeover detection with live config check

- Add live config takeover detection for hot-switch decision
- Rebuild takeover when backup is missing or placeholder remains
- Make detect_takeover_in_live_config_for_app public
- Fix is_takeover_active to use actual takeover status

* refactor(usage): simplify model pricing lookup by removing suffix fallback

Replace complex suffix-stripping fallback with direct prefix/suffix cleanup.
Model IDs are now cleaned by removing vendor prefix (before /) and colon
suffix (after :), then matched exactly against pricing table.

* feat(database): add Chinese AI model pricing data

Add pricing for domestic AI models (CNY/1M tokens):
- Doubao-Seed-Code (ByteDance)
- DeepSeek V3/V3.1/V3.2
- Kimi K2/K2-Thinking/K2-Turbo (Moonshot)
- MiniMax M2/M2.1/M2.1-Lightning
- GLM-4.6/4.7 (Zhipu)
- Mimo V2 Flash (Xiaomi)

Also fix test case to use correct model ID and remove invalid currency column.

* refactor(proxy): improve header forwarding with blacklist approach

Change from whitelist to blacklist mode for request header forwarding.
Only skip headers that will be overridden (auth, host, content-length).
This preserves client's original headers and improves compatibility.

* fix(proxy): bypass timeout and retry configs when failover is disabled

When auto_failover_enabled is false, timeout and retry configurations
should not affect normal request flow. This change ensures:

- create_forwarder: passes 0 for all timeout/retry params when failover
  is disabled, effectively bypassing these checks
- streaming_timeout_config: returns 0 for both first_byte_timeout and
  idle_timeout when failover is disabled

This prevents unnecessary timeout errors and retry attempts when users
have explicitly disabled the failover feature.

* fix(proxy): handle zero value input in failover config fields

* refactor(proxy): remove retry logic and add enabled check for failover

* refactor(proxy): distinguish circuit-open from no-provider errors

* Align usage stats to sliding windows

* feat(proxy): add body and header filtering for upstream requests

* feat(proxy): enable transparent passthrough for headers

- Passthrough anthropic-beta header as-is from client
- Passthrough anthropic-version header from client
- Passthrough client IP headers (x-forwarded-for, x-real-ip) by default
- Filter private params (underscore-prefixed fields) from request body
- No database changes required

* feat(proxy): extract session ID from client requests for logging

- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
2025-12-31 22:57:00 +08:00
w0x7ce a2becf0917 refactor(commands): improve terminal launch code structure and fix env vars
重构 open_provider_terminal 相关代码,提升可维护性和可读性。

主要改进:
- 将 launch_terminal_with_env 拆分为多个职责单一的小函数
  * write_claude_config: 写入配置文件
  * escape_shell_path: 转义 shell 路径
  * generate_wrapper_script: 生成包装脚本
  * launch_macos_terminal / launch_linux_terminal / launch_windows_terminal: 平台特定启动逻辑
- 使用 let Some else 提前返回模式,减少嵌套
- 修复 Gemini 环境变量名为 GEMINI_API_KEY(而非 GOOGLE_API_KEY)
- 完善临时文件清理逻辑:
  * macOS/Linux: 使用 trap EXIT 自动清理
  * Windows: 批处理文件自删除
- 代码格式化和 import 排序优化

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 09:17:36 +08:00
tianrking debe4232bc Update misc.rs 2025-12-29 23:47:30 +08:00
w0x7ce 49a2e52b20 fix(ui): only show terminal button for Claude Code providers
open_provider_terminal 功能仅支持 Claude Code,因此只在 Claude 应用中显示终端按钮。

修改内容:
- 在 ProviderList 组件调用时,根据 activeApp 条件传递 onOpenTerminal
- 仅当 activeApp === "claude" 时传递 handleOpenTerminal 回调
- Codex 和 Gemini 不会显示终端按钮(onOpenTerminal 为 undefined)

影响范围:
- src/App.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 10:05:52 +08:00
w0x7ce ed9d9b5436 fix(commands): add auto-cleanup for temp config files when terminal closes
在 open_provider_terminal 功能中添加了临时配置文件的自动清理逻辑,
确保在用户关闭终端窗口时自动删除创建的临时配置文件。

修改内容:
- Linux: 使用 bash -c 嵌套包装脚本,通过 trap EXIT 信号在 shell 退出时清理配置文件
- macOS: 同样使用嵌套 bash + trap 机制来处理清理
- Windows: 保持原有的批处理文件自删除逻辑(del 命令)

技术细节:
- 之前使用 sh -c "...; exec $SHELL" 会导致 trap 失效
- 现在使用 bash -c 'trap ... EXIT; ...; exec bash --norc --noprofile'
- exec 会替换进程但保留 trap 信号处理器
- 当用户关闭终端时,EXIT 信号触发清理操作

影响范围:
- src-tauri/src/commands/misc.rs (launch_terminal_with_env 函数)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 09:36:55 +08:00
w0x7ce 390839a8d5 Merge upstream/main into main
Resolved conflicts in src-tauri/src/lib.rs:
- Kept both: open_provider_terminal (my feature) and universal provider commands (upstream)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 09:14:42 +08:00
w0x7ce 3dcbe313be feat: add provider-specific terminal button
Add a terminal button next to each provider card that opens a new terminal
window with that provider's specific API configuration. This allows using
different providers independently without changing the global setting.

Changes:
- Backend: Add `open_provider_terminal` command that extracts provider
  config and creates a temporary claude settings file
- Frontend: Add terminal button to provider cards with proper callback
  propagation through component hierarchy
- Support macOS (Terminal.app), Linux (gnome-terminal, konsole, etc.),
  and Windows (cmd)

Each provider gets a unique config file named `claude_<providerId>_<pid>.json`
in the temp directory, containing the provider's API configuration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 17:11:20 +08:00
215 changed files with 18324 additions and 3545 deletions
+16 -3
View File
@@ -53,7 +53,11 @@ jobs:
wget \
file \
patchelf \
libssl-dev
libssl-dev \
rpm \
flatpak \
flatpak-builder \
elfutils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -153,7 +157,7 @@ jobs:
- name: Build Tauri App (Linux)
if: runner.os == 'Linux'
run: pnpm tauri build
run: pnpm tauri build --bundles appimage,deb,rpm
- name: Prepare macOS Assets
if: runner.os == 'macOS'
@@ -271,6 +275,15 @@ jobs:
else
echo "No .deb found (optional)"
fi
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
if [ -n "$RPM" ]; then
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
cp "$RPM" "release-assets/$NEW_RPM"
echo "RPM package copied: $NEW_RPM"
else
echo "No .rpm found (optional)"
fi
- name: List prepared assets
shell: bash
@@ -299,7 +312,7 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
+5
View File
@@ -18,3 +18,8 @@ GEMINI.md
/.vscode
vitest-report.json
nul
# Flatpak build artifacts
flatpak/cc-switch.deb
flatpak-build/
flatpak-repo/
+206 -39
View File
@@ -5,6 +5,182 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
---
## [3.10.2] - 2026-01-24
### Patch Release
This maintenance release adds skill sync options and includes important bug fixes.
### Added
- **Skills**: Add skill sync method setting with symlink/copy options
- **Partners**: Add RightCode as official partner
### Fixed
- **Prompts**: Clear prompt file when all prompts are disabled
- **OpenCode**: Preserve extra model fields during serialization
- **Provider Form**: Backfill model fields when editing Claude provider
---
## [3.10.1] - 2026-01-23
### Patch Release
This maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.
### Added
- **Provider Icons**: Updated RightCode provider icon with improved visual design
### Changed
- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability
- **Window Settings**: Reordered window settings and updated default values for improved UX
- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons
- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`
### Fixed
- **Windows Platform**:
- Fixed terminal window closing immediately after execution on Windows
- Corrected OpenCode config path resolution on Windows
- **UI Improvements**:
- Fixed ProviderIcon color validation to prevent black icons from appearing
- Unified layout padding across all panels for consistent spacing
- Fixed panel content alignment with header constraints
- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting
---
## [3.10.0] - 2026-01-21
### Feature Release
This release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX.
### Added
- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695)
- **Global Proxy** - Add global proxy settings for outbound network requests (#596)
- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595)
- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623)
- **Per-Provider Config** - Support provider-specific configuration and persistence (#663)
- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default)
- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps
- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670)
- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564)
- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627)
- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos
### Changed
- **Proxy Logging** - Simplify proxy log output (#585)
- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel`
- **Advanced Settings Layout** - Move rectifier section below failover for better flow
- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle
### Fixed
- **Auto Failover** - Switch to P1 immediately when enabling auto failover
- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654)
- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597)
- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592)
- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628)
- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580)
- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset
- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613)
- **Provider Endpoint** - Persist endpoint auto-select state (#611)
- **Provider Form** - Reset baseUrl and apiKey states when switching presets
---
## [3.9.1] - 2026-01-09
### Bug Fix Release
This release focuses on stability improvements and crash prevention.
### Added
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
### Fixed
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
---
## [3.9.0] - 2026-01-07
### Stable Release
This stable release includes all changes from `3.9.0-1`, `3.9.0-2`, and `3.9.0-3`.
### Added
- **Local API Proxy** - High-performance local HTTP proxy for Claude Code, Codex, and Gemini CLI (Axum-based)
- **Per-App Takeover** - Independently route each app through the proxy with automatic live-config backup/redirect
- **Auto Failover** - Circuit breaker + smart failover with independent queues and health tracking per app
- **Universal Provider** - Shared provider configurations that can sync to Claude/Codex/Gemini (ideal for API gateways like NewAPI)
- **Provider Search Filter** - Quick filter to find providers by name (#435)
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Skills Multi-App Support** - Skills now support both Claude Code and Codex (#365)
- **Closable Toasts** - Close button for switch toast and all success toasts (#350)
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
- **MCP Import** - Import MCP servers from installed apps
- **Common Config Snippet Extraction** - Extract reusable common config snippets from the current provider or editor content (Claude/Codex/Gemini)
- **Usage Enhancements** - Model extraction, request logging improvements, cache hit/creation metrics, and auto-refresh (#455, #508)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Linux Packaging** - Added RPM and Flatpak packaging targets
- **Provider Presets & Icons** - Added/updated partner presets and icons (e.g., MiMo, DMXAPI, Cubence)
### Changed
- **Usage Terminology** - Rename "Cache Read/Write" to "Cache Hit/Creation" across all languages (#508)
- **Model Pricing Data** - Refresh built-in model pricing table (Claude full version IDs, GPT-5 series, Gemini ID formats, and Chinese models) (#508)
- **Proxy Header Forwarding** - Switch to a blacklist approach and improve header passthrough compatibility (#508)
- **Failover Behavior** - Bypass timeout/retry configs when failover is disabled; update default failover timeout and circuit breaker values (#508, #521)
- **Provider Presets** - Update default model versions and change the default Qwen base URL (#517)
- **Skills Management** - Unify Skills management architecture with SSOT + React Query; improve caching for discoverable skills
- **Settings UX** - Reorder items in the Advanced tab for better discoverability
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
### Fixed
- **Security** - Security fixes for JavaScript executor and usage script (#151)
- **Usage Timezone & Parsing** - Fix datetime picker timezone handling; improve token parsing/billing for Gemini and Codex formats (#508)
- **Windows Compatibility** - Improve MCP export and version check behavior to avoid terminal popups
- **Windows Startup** - Use system titlebar to prevent black screen on startup
- **WebView Compatibility** - Add fallback for crypto.randomUUID() on older WebViews
- **macOS Autostart** - Use `.app` bundle path to prevent terminal window popups
- **Database** - Add missing schema migrations; show an error dialog on initialization failure with a retry option
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
- **Prompts** - Allow saving prompts with empty content
- **MCP Sync** - Skip sync when the target CLI app is not installed
- **Common Config (Codex)** - Preserve MCP server `base_url` during extraction and remove provider-specific `model_providers` blocks
- **Proxy** - Improve takeover detection and stability; clean up model override env vars when switching providers in takeover mode (#508)
- **Skills** - Skip hidden directories during discovery; fix wrong skill repo branch
- **Settings Navigation** - Navigate to About tab when clicking update badge
- **UI** - Fix dialogs not opening on first click and improve window dragging area in `FullScreenPanel`
---
## [3.9.0-3] - 2025-12-29
### Beta Release
@@ -63,6 +239,34 @@ Third beta release with important bug fixes for Windows compatibility, UI improv
---
## [3.9.0-2] - 2025-12-20
### Beta Release
Second beta release focusing on proxy stability, import safety, and provider preset polish.
### Added
- **DMXAPI Partner** - Added DMXAPI as an official partner provider preset
- **Provider Icons** - Added provider icons for OpenRouter, LongCat, ModelScope, and AiHubMix
### Changed
- **Proxy (OpenRouter)** - Switched OpenRouter to passthrough mode for native Claude API
### Fixed
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
- **Proxy** - Respect existing Claude token when syncing; add fallback recovery for orphaned takeover state; remove global auto-start flag
- **Windows** - Add minimum window size to Windows platform config
- **UI** - Improve About section UI (#419) and unify header toolbar styling
### Stats
- 13 commits since v3.9.0-1
---
## [3.9.0-1] - 2025-12-18
### Beta Release
@@ -555,8 +759,8 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
### ⚠ Breaking Changes
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`
- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.
- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.
### ✨ New Features
@@ -799,40 +1003,3 @@ For users upgrading from v2.x (Electron version):
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes
- **Runtime auto-migration from v1 to v2 config format has been removed**
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
- When a v1 config is detected, the app now returns a clear error with migration instructions
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
- **Legacy v1 copy file migration logic has been removed**
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
- No longer archives copy files or performs automatic deduplication
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
- **Tauri commands now only accept `app` parameter**
- Removed legacy `app_type`/`appType` compatibility paths
- Explicit error with available values when unknown `app` is provided
### 🔧 Improvements
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
- Simplified startup logic: Only ensures config structure exists, no migration overhead
### 🧪 Tests
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
- Added comprehensive config loading tests:
- `load_v1_config_returns_error_and_does_not_write`
- `load_v1_with_extra_version_still_treated_as_v1`
- `load_invalid_json_returns_parse_error_and_does_not_write`
- `load_valid_v2_config_succeeds`
+21 -4
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.10.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -37,6 +37,11 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
</tr>
</table>
## Screenshots
@@ -47,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
@@ -176,7 +181,7 @@ Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) pa
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
### ArchLinux 用户
### Arch Linux Users
**Install via paru (Recommended)**
@@ -186,7 +191,19 @@ paru -S cc-switch-bin
### Linux Users
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
Download the latest Linux build from the [Releases](../../releases) page:
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
Flatpak install & run:
```bash
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## Quick Start
+21 -4
View File
@@ -2,14 +2,14 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.10.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.9.0 リリースノート](docs/release-note-v3.9.0-ja.md)
</div>
@@ -37,6 +37,11 @@
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
</tr>
</table>
## スクリーンショット
@@ -47,7 +52,7 @@
## 特長
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
### 現在のバージョン:v3.10.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
@@ -186,7 +191,19 @@ paru -S cc-switch-bin
### Linux ユーザー
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード
[Releases](../../releases) から最新版の Linux ビルドをダウンロード
- `CC-Switch-v{version}-Linux.deb`Debian/Ubuntu
- `CC-Switch-v{version}-Linux.rpm`Fedora/RHEL/openSUSE
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
- `CC-Switch-v{version}-Linux.flatpak`Flatpak
Flatpak のインストールと起動:
```bash
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## クイックスタート
+23 -6
View File
@@ -2,14 +2,14 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.10.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.9.0 发布说明](docs/release-note-v3.9.0-zh.md)
</div>
@@ -24,7 +24,7 @@
<table>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠</td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠</td>
</tr>
<tr>
@@ -35,8 +35,13 @@
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
</tr>
</table>
## 界面预览
@@ -47,7 +52,7 @@
## 功能特性
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
### 当前版本:v3.10.2 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
**v3.8.0 重大更新(2025-11-28**
@@ -186,7 +191,19 @@ paru -S cc-switch-bin
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包
从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包
- `CC-Switch-v{版本号}-Linux.deb`Debian/Ubuntu
- `CC-Switch-v{版本号}-Linux.rpm`Fedora/RHEL/openSUSE
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
- `CC-Switch-v{版本号}-Linux.flatpak`Flatpak
Flatpak 安装与运行:
```bash
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## 快速开始
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+239 -776
View File
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
# OpenCode 第四应用支持实现计划
> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。
## 概述
为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。
## 关键设计决策
| 特性 | Claude/Codex/Gemini | OpenCode |
|------|---------------------|----------|
| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) |
| UI 按钮 | 启用/切换 | 添加/删除 |
| is_current | 需要 | 不需要 |
| 代理/故障转移 | 支持 | 不支持 |
| API 格式字段 | 无 | 需要(npm 包名) |
| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` |
## 配置文件格式
### 供应商配置
```json
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"provider-id": {
"npm": "@ai-sdk/openai-compatible",
"name": "Provider Name",
"options": {
"baseURL": "https://api.example.com/v1",
"apiKey": "{env:API_KEY}"
},
"models": {
"model-id": { "name": "Model Name" }
}
}
}
}
```
### MCP 配置
```json
{
"mcp": {
"remote-server": {
"type": "remote",
"url": "https://example.com/mcp",
"enabled": true
},
"local-server": {
"type": "local",
"command": ["npx", "-y", "my-mcp-command"],
"enabled": true,
"environment": { "KEY": "value" }
}
}
}
```
---
## 实现步骤
### Phase 1: 后端数据结构扩展
#### 1.1 AppType 枚举扩展
**文件**: `src-tauri/src/app_config.rs`
```rust
pub enum AppType {
Claude,
Codex,
Gemini,
OpenCode, // 新增
}
```
#### 1.2 McpApps / SkillApps 扩展
**文件**: `src-tauri/src/app_config.rs`
```rust
pub struct McpApps {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub opencode: bool, // 新增
}
pub struct SkillApps {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub opencode: bool, // 新增
}
```
#### 1.3 数据库 Schema 迁移
**文件**: `src-tauri/src/database/schema.rs`
- `SCHEMA_VERSION` 递增
- 添加迁移:
```sql
ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
```
### Phase 2: OpenCode 供应商数据结构
#### 2.1 OpenCode 专属配置结构
**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`
```rust
/// OpenCode 供应商的 settings_config 结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeProviderConfig {
/// AI SDK 包名,如 "@ai-sdk/openai-compatible"
pub npm: String,
/// 供应商选项
pub options: OpenCodeProviderOptions,
/// 模型定义
pub models: HashMap<String, OpenCodeModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeProviderOptions {
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeModel {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<OpenCodeModelLimit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeModelLimit {
pub context: Option<u64>,
pub output: Option<u64>,
}
```
### Phase 3: OpenCode Live 配置读写
#### 3.1 新建 OpenCode 配置模块
**文件**: `src-tauri/src/opencode_config.rs`
核心功能:
- `get_opencode_config_path()` → `~/.config/opencode/opencode.json`
- `read_opencode_config()` → 读取整个配置文件
- `write_opencode_config()` → 原子写入配置文件
- `get_providers()` → 获取 `provider` 对象
- `set_provider(id, config)` → 添加/更新供应商
- `remove_provider(id)` → 删除供应商
- `get_mcp_servers()` → 获取 `mcp` 对象
- `set_mcp_server(id, config)` → 添加/更新 MCP 服务器
- `remove_mcp_server(id)` → 删除 MCP 服务器
### Phase 4: MCP 同步模块
#### 4.1 新建 OpenCode MCP 同步
**文件**: `src-tauri/src/mcp/opencode.rs`
```rust
/// 同步所有 enabled_opencode=true 的服务器到 OpenCode 配置
pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError>
/// 同步单个服务器
pub fn sync_single_server_to_opencode(
config: &MultiAppConfig,
id: &str,
server_spec: &Value
) -> Result<(), AppError>
/// 从 OpenCode 配置移除服务器
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError>
/// 从 OpenCode 配置导入服务器
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError>
```
**格式转换**
| CC Switch 统一格式 | OpenCode 格式 |
|-------------------|---------------|
| `type: "stdio"` | `type: "local"` |
| `command` + `args` | `command: [cmd, ...args]` |
| `env` | `environment` |
| `type: "sse"/"http"` | `type: "remote"` |
| `url` | `url` |
### Phase 5: 供应商服务层
#### 5.1 OpenCode 供应商服务
**文件**: `src-tauri/src/services/provider/opencode.rs`
核心方法:
```rust
/// 获取所有 OpenCode 供应商
pub fn list(state: &AppState) -> Result<IndexMap<String, Provider>, AppError>
/// 添加供应商(同时写入 live 配置)
pub fn add(state: &AppState, provider: Provider) -> Result<bool, AppError>
/// 更新供应商
pub fn update(state: &AppState, provider: Provider) -> Result<bool, AppError>
/// 删除供应商(同时从 live 配置移除)
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError>
/// 从 live 配置导入供应商到数据库
pub fn import_from_live(state: &AppState) -> Result<usize, AppError>
```
**关键差异**
- 不需要 `switch()` 方法
- 不需要 `is_current` 管理
- `add()` 自动写入 live
- `delete()` 自动从 live 移除
### Phase 6: Tauri 命令扩展
#### 6.1 更新现有命令
**文件**: `src-tauri/src/commands/providers.rs`
- 所有命令支持 `app_type = "opencode"`
- OpenCode 特定逻辑分支
#### 6.2 新增 OpenCode 专属命令(如需要)
```rust
#[tauri::command]
pub async fn opencode_sync_all_providers(state: State<'_, AppState>) -> Result<(), AppError>
```
### Phase 7: 前端类型定义
#### 7.1 TypeScript 类型扩展
**文件**: `src/types.ts`
```typescript
// AppId 扩展
type AppId = "claude" | "codex" | "gemini" | "opencode";
// OpenCode 专属配置
interface OpenCodeProviderConfig {
npm: string; // AI SDK 包名
options: {
baseURL?: string;
apiKey?: string;
headers?: Record<string, string>;
};
models: Record<string, OpenCodeModel>;
}
interface OpenCodeModel {
name: string;
limit?: {
context?: number;
output?: number;
};
}
```
#### 7.2 MCP 应用状态扩展
**文件**: `src/types.ts`
```typescript
interface McpApps {
claude: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean; // 新增
}
```
### Phase 8: 前端预设配置
#### 8.1 新建 OpenCode 供应商预设
**文件**: `src/config/opencodeProviderPresets.ts`
```typescript
export const opencodeProviderPresets: ProviderPreset[] = [
{
name: "OpenAI",
npmPackage: "@ai-sdk/openai",
settingsConfig: {
npm: "@ai-sdk/openai",
options: { apiKey: "{env:OPENAI_API_KEY}" },
models: {
"gpt-4o": { name: "GPT-4o" },
"gpt-4o-mini": { name: "GPT-4o Mini" },
},
},
theme: { icon: "openai", iconColor: "#00A67E" },
},
{
name: "Anthropic",
npmPackage: "@ai-sdk/anthropic",
settingsConfig: {
npm: "@ai-sdk/anthropic",
options: { apiKey: "{env:ANTHROPIC_API_KEY}" },
models: {
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4" },
},
},
},
{
name: "OpenAI Compatible",
npmPackage: "@ai-sdk/openai-compatible",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "",
apiKey: "{env:API_KEY}",
},
models: {},
},
isCustomTemplate: true,
},
// ... 更多预设
];
// npm 包选项
export const opencodeNpmPackages = [
{ value: "@ai-sdk/openai", label: "OpenAI" },
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
{ value: "@ai-sdk/google", label: "Google" },
{ value: "@ai-sdk/azure", label: "Azure OpenAI" },
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
// ... 更多选项
];
```
### Phase 9: 前端 UI 组件
#### 9.1 OpenCode 供应商表单
**文件**: `src/components/providers/forms/OpenCodeFormFields.tsx`
新增字段:
- npm 包选择器(下拉框 + 自定义输入)
- options 编辑器(baseURL, apiKey, headers
- models 编辑器(动态添加/删除模型)
#### 9.2 供应商卡片按钮适配
**文件**: `src/components/providers/ProviderActions.tsx`
```tsx
// OpenCode 使用不同的主按钮
if (appId === "opencode") {
return (
<Button onClick={onAdd}>
{isInConfig ? t("provider.removeFromConfig") : t("provider.addToConfig")}
</Button>
);
}
```
#### 9.3 隐藏 OpenCode 不需要的功能
在以下组件中检查 `appId !== "opencode"`
- 代理设置面板
- 故障转移队列
- 供应商切换逻辑
### Phase 10: 国际化
#### 10.1 新增翻译 Key
**文件**: `src/locales/zh/translation.json` & `en/translation.json`
```json
{
"app.opencode": "OpenCode",
"provider.addToConfig": "添加到配置",
"provider.removeFromConfig": "从配置移除",
"provider.inConfig": "已添加",
"provider.npmPackage": "AI SDK 包",
"provider.models": "模型配置",
// ...
}
```
---
## 关键文件清单
### 后端(Rust
| 操作 | 文件路径 |
|------|---------|
| 修改 | `src-tauri/src/app_config.rs` |
| 修改 | `src-tauri/src/database/schema.rs` |
| 修改 | `src-tauri/src/database/dao/mcp.rs` |
| 修改 | `src-tauri/src/database/dao/providers.rs` |
| 修改 | `src-tauri/src/services/provider/mod.rs` |
| 修改 | `src-tauri/src/services/mcp.rs` |
| 修改 | `src-tauri/src/commands/providers.rs` |
| 修改 | `src-tauri/src/commands/mcp.rs` |
| 修改 | `src-tauri/src/mcp/mod.rs` |
| 新建 | `src-tauri/src/opencode_config.rs` |
| 新建 | `src-tauri/src/mcp/opencode.rs` |
| 新建 | `src-tauri/src/services/provider/opencode.rs` |
### 前端(TypeScript/React
| 操作 | 文件路径 |
|------|---------|
| 修改 | `src/types.ts` |
| 修改 | `src/lib/api/types.ts` |
| 修改 | `src/lib/api/providers.ts` |
| 修改 | `src/components/providers/ProviderActions.tsx` |
| 修改 | `src/components/providers/ProviderCard.tsx` |
| 修改 | `src/components/providers/AddProviderDialog.tsx` |
| 修改 | `src/components/providers/forms/ProviderForm.tsx` |
| 修改 | `src/App.tsx` |
| 新建 | `src/config/opencodeProviderPresets.ts` |
| 新建 | `src/components/providers/forms/OpenCodeFormFields.tsx` |
### 国际化
| 操作 | 文件路径 |
|------|---------|
| 修改 | `src/locales/zh/translation.json` |
| 修改 | `src/locales/en/translation.json` |
| 修改 | `src/locales/ja/translation.json` |
---
## 验证计划
### 单元测试
1. OpenCode 配置读写测试
2. MCP 格式转换测试(stdio ↔ local, sse ↔ remote
3. 供应商 CRUD 操作测试
### 集成测试
1. 添加 OpenCode 供应商 → 验证写入 `~/.config/opencode/opencode.json`
2. 删除供应商 → 验证从配置文件移除
3. MCP 同步测试 → 验证格式正确转换
4. 从 live 配置导入 → 验证正确解析
### 手动测试
1. UI 流程:添加预设 → 编辑 → 删除
2. 切换应用 Tab → OpenCode 显示正确的 UI(无代理/故障转移)
3. 托盘菜单正确显示 OpenCode 供应商
4. 深链接导入 OpenCode 供应商
---
## 风险评估
1. **数据库迁移**:需要在升级时自动执行 `ALTER TABLE` 语句
2. **配置文件冲突**:OpenCode 可能有自己的配置,需要合并而非覆盖
3. **MCP 格式差异**`stdio` → `local` 转换需要处理边界情况
4. **UI 一致性**OpenCode 的"添加/删除"模式需要与其他应用的"启用/切换"清晰区分
---
## 补充说明
### 托盘菜单特殊处理
由于 OpenCode 采用累加式管理,托盘菜单行为需要调整:
- **现有三应用**:托盘菜单显示 `CheckMenuItem`(单选,切换当前供应商)
- **OpenCode**:显示当前所有启用的供应商(普通 MenuItem,无勾选逻辑),点击打开主界面
**修改文件**`src-tauri/src/tray.rs``TRAY_SECTIONS` 常量)
### 数据库约束更新
`proxy_config` 表的 CHECK 约束需要扩展:
```sql
CHECK (app_type IN ('claude','codex','gemini','opencode'))
```
### Settings 结构体扩展
**文件**`src-tauri/src/settings.rs`
需要添加:
- `current_provider_opencode: Option<String>` - 对 OpenCode 可能无意义,但保持结构一致
- `opencode_config_dir: Option<String>` - 自定义配置目录
+206
View File
@@ -0,0 +1,206 @@
# CC Switch v3.10.0
> OpenCode Support, Global Proxy, Claude Rectifier & Multi-App Experience Enhancements
**[中文版 →](release-note-v3.10.0-zh.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
---
## Overview
CC Switch v3.10.0 introduces OpenCode support, becoming the fourth managed CLI application.
This release also brings global proxy settings, Claude Rectifier (thinking signature fixer), enhanced health checks, per-provider configuration, and many other important features, along with comprehensive improvements to multi-app workflows and terminal experience.
**Release Date**: 2026-01-21
---
## Highlights
- OpenCode Support: Full management of providers, MCP servers, and Skills with auto-import on first launch
- Global Proxy: Configure a unified proxy for all outbound network requests
- Claude Rectifier: Thinking signature fixer for better compatibility with third-party APIs
- Enhanced Health Checks: Configurable prompts and CLI-compatible request format
- Per-Provider Config: Persistent provider-specific configuration support
- App Visibility Control: Freely show/hide apps with synchronized tray menu updates
- Terminal Improvements: Provider-specific terminal buttons, fnm path support, cross-platform safe launch
- WSL Tool Detection: Detect tool versions in WSL environment with security hardening
---
## Main Features
### OpenCode Support (New Fourth App)
- Complete OpenCode provider management: add, edit, switch, delete
- MCP server management: unified architecture with Claude/Codex/Gemini
- Skills support: OpenCode can also use Skills functionality
- Auto-import on first launch: automatically imports existing OpenCode configuration when detected
- Full internationalization: Chinese/English/Japanese support (#695)
### Global Proxy
- Configure a unified proxy for all outbound network requests (#596, thanks @yovinchen)
- Supports HTTP/HTTPS proxy protocols
- Suitable for network environments requiring proxy access to external APIs
### Claude Rectifier (Thinking Signature Fixer)
- Automatically fixes Claude API thinking signatures (#595, thanks @yovinchen)
- Resolves incompatible thinking block formats returned by some third-party API gateways
- Can be enabled/disabled in Advanced Settings
### Enhanced Health Checks
- Configurable custom prompts for streaming health checks (#623, thanks @yovinchen)
- Supports CLI-compatible request format for better simulation of real usage scenarios
- Improves fault detection accuracy
### Per-Provider Config
- Support for saving configuration separately for each provider (#663, thanks @yovinchen)
- Persistent configuration: provider-specific settings retained after restart
- Suitable for scenarios where different providers require different configurations
### App Visibility Control
- Freely show/hide any app (Gemini hidden by default)
- Tray menu automatically syncs visibility settings
- Hidden apps won't appear in the main interface or tray menu
### Takeover Compact Mode
- Automatically uses compact layout when 3 or more visible apps are displayed
- Optimizes space utilization in multi-app scenarios
### Terminal Improvements
- Provider-specific terminal button: one-click to use current provider in terminal (#564, thanks @kkkman22)
- `fnm` path support: automatically recognizes Node.js paths managed by fnm
- Cross-platform safe launch: improved terminal launch logic for Windows/macOS/Linux
### WSL Tool Detection
- Detect tool versions in WSL environment (#627, thanks @yovinchen)
- Added security hardening to prevent command injection risks
### Skills Preset Enhancements
- Added `baoyu-skills` preset repository
- Automatically supplements missing default repositories for out-of-the-box experience
---
## Experience Improvements
- Keyboard shortcuts: Press `ESC` to quickly return/close panels (#670, thanks @xxk8)
- Simplified proxy logs: cleaner and more readable output (#585, thanks @yovinchen)
- Pricing editor UX: unified `FullScreenPanel` style
- Advanced settings layout: Rectifier section moved below Failover for better logical flow
- OpenRouter compatibility mode: disabled by default, UI toggle hidden (reduces clutter)
---
## Bug Fixes
### Proxy & Failover
- Immediately switch to P1 when auto-failover is enabled (instead of waiting for next request)
### Provider Management
- Fixed stale data when reopening provider edit dialog after save (#654, thanks @YangYongAn)
- Fixed baseUrl and apiKey state not resetting when switching presets
- Fixed endpoint auto-selection state not persisting (#611, thanks @yovinchen)
- Automatically apply default color when icon color is not set
### Deep Links
- Support multi-endpoint import (#597, thanks @yovinchen)
- Prefer `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL`
### MCP
- Skip `cmd /c` wrapper for WSL target paths (#592, thanks @cxyfer)
### Usage Templates
- Added variable hints, fixed validation issues (#628, thanks @YangYongAn)
- Prevent configuration leakage between providers
- Usage block offset automatically adapts to action button width (#613, thanks @yovinchen)
### Gemini
- Convert timeout parameters to Gemini CLI format (#580, thanks @cxyfer)
### UI
- Fixed Select dropdown rendering issues in `FullScreenPanel`
---
## Notes & Considerations
- **OpenCode is a newly supported app**: OpenCode CLI must be installed first to use related features.
- **Global proxy affects all outbound requests**: including usage queries, health checks, and other network operations.
- **Rectifier is experimental**: can be disabled in Advanced Settings if issues occur.
---
## Special Thanks
Thanks to @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 for their contributions to this release!
Thanks to @libukai for designing the elegant failover-related UI!
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------ | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ---------------------------------------- | ---------------------------------------------------- |
| `CC-Switch-v3.10.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.10.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| -------------------------------- | ------------------------------------------------------------------ |
| `CC-Switch-v3.10.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.10.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+206
View File
@@ -0,0 +1,206 @@
# CC Switch v3.10.0
> OpenCode サポート、グローバルプロキシ、Claude Rectifier とマルチアプリ体験の強化
**[中文版 →](release-note-v3.10.0-zh.md) | [English →](release-note-v3.10.0-en.md)**
---
## 概要
CC Switch v3.10.0 では OpenCode サポートが追加され、4番目の管理対象 CLI アプリケーションとなりました。
また、グローバルプロキシ設定、Claude Rectifierthinking 署名修正機能)、ヘルスチェックの強化、プロバイダー別設定など、多くの重要な機能が追加され、マルチアプリワークフローとターミナル体験が全面的に改善されました。
**リリース日**: 2026-01-21
---
## ハイライト
- OpenCode サポート:プロバイダー、MCP サーバー、Skills の完全管理、初回起動時の自動インポート
- グローバルプロキシ:すべての送信ネットワークリクエストに統一プロキシを設定
- Claude Rectifierthinking 署名修正機能、サードパーティ API との互換性向上
- ヘルスチェック強化:カスタムプロンプト設定、CLI 互換リクエスト形式
- プロバイダー別設定:プロバイダー固有の設定の永続化をサポート
- アプリ表示制御:アプリの表示/非表示を自由に設定、トレイメニューと同期
- ターミナル改善:プロバイダー専用ターミナルボタン、fnm パスサポート、クロスプラットフォーム安全起動
- WSL ツール検出:WSL 環境でのツールバージョン検出とセキュリティ強化
---
## 主な機能
### OpenCode サポート(新しい4番目のアプリ)
- 完全な OpenCode プロバイダー管理:追加、編集、切り替え、削除
- MCP サーバー管理:Claude/Codex/Gemini と統一されたアーキテクチャ
- Skills サポート:OpenCode でも Skills 機能を使用可能
- 初回起動時の自動インポート:既存の OpenCode 設定を検出すると自動的にインポート
- 完全な国際化:中国語/英語/日本語サポート (#695)
### グローバルプロキシ
- すべての送信ネットワークリクエストに統一プロキシを設定 (#596@yovinchen に感謝)
- HTTP/HTTPS プロキシプロトコルをサポート
- 外部 API へのプロキシアクセスが必要なネットワーク環境に適用
### Claude RectifierThinking 署名修正機能)
- Claude API の thinking 署名を自動修正 (#595@yovinchen に感謝)
- 一部のサードパーティ API ゲートウェイが返す互換性のない thinking ブロック形式を解決
- 詳細設定で有効/無効を切り替え可能
### ヘルスチェック強化
- ストリーミングヘルスチェック用のカスタムプロンプトを設定可能 (#623@yovinchen に感謝)
- CLI 互換リクエスト形式をサポートし、実際の使用シナリオをより良くシミュレート
- 障害検出の精度を向上
### プロバイダー別設定
- 各プロバイダーごとに設定を個別に保存可能 (#663@yovinchen に感謝)
- 設定の永続化:再起動後もプロバイダー固有の設定を保持
- 異なるプロバイダーに異なる設定が必要なシナリオに適用
### アプリ表示制御
- 任意のアプリを自由に表示/非表示(Gemini はデフォルトで非表示)
- トレイメニューは表示設定と自動的に同期
- 非表示のアプリはメインインターフェースとトレイメニューに表示されない
### Takeover コンパクトモード
- 3つ以上の表示アプリがある場合、自動的にコンパクトレイアウトを使用
- マルチアプリシナリオでのスペース利用を最適化
### ターミナル改善
- プロバイダー専用ターミナルボタン:ワンクリックでターミナルで現在のプロバイダーを使用 (#564@kkkman22 に感謝)
- `fnm` パスサポート:fnm で管理された Node.js パスを自動認識
- クロスプラットフォーム安全起動:Windows/macOS/Linux のターミナル起動ロジックを改善
### WSL ツール検出
- WSL 環境でツールバージョンを検出 (#627@yovinchen に感謝)
- コマンドインジェクションリスクを防ぐためのセキュリティ強化を追加
### Skills プリセット強化
- `baoyu-skills` プリセットリポジトリを追加
- 不足しているデフォルトリポジトリを自動補完し、すぐに使える状態を確保
---
## 体験の改善
- キーボードショートカット:`ESC` を押してパネルをすばやく戻る/閉じる (#670@xxk8 に感謝)
- プロキシログの簡素化:より明確で読みやすい出力 (#585@yovinchen に感謝)
- 価格エディター UX:統一された `FullScreenPanel` スタイル
- 詳細設定レイアウト:Rectifier セクションを Failover の下に移動し、論理的な流れを改善
- OpenRouter 互換モード:デフォルトで無効、UI トグルを非表示(煩雑さを軽減)
---
## バグ修正
### プロキシとフェイルオーバー
- 自動フェイルオーバーが有効な場合、すぐに P1 に切り替え(次のリクエストを待たずに)
### プロバイダー管理
- 保存後にプロバイダー編集ダイアログを再度開いたときにデータが古い問題を修正 (#654@YangYongAn に感謝)
- プリセット切り替え時に baseUrl と apiKey の状態がリセットされない問題を修正
- エンドポイント自動選択状態が永続化されない問題を修正 (#611@yovinchen に感謝)
- アイコンカラーが設定されていない場合、デフォルトカラーを自動適用
### ディープリンク
- マルチエンドポイントインポートをサポート (#597@yovinchen に感謝)
- `GEMINI_BASE_URL` より `GOOGLE_GEMINI_BASE_URL` を優先
### MCP
- WSL ターゲットパスの `cmd /c` ラッパーをスキップ (#592@cxyfer に感謝)
### 使用量テンプレート
- 変数ヒントを追加、検証の問題を修正 (#628@YangYongAn に感謝)
- プロバイダー間での設定漏洩を防止
- 使用量ブロックのオフセットがアクションボタンの幅に自動適応 (#613@yovinchen に感謝)
### Gemini
- タイムアウトパラメータを Gemini CLI 形式に変換 (#580@cxyfer に感謝)
### UI
- `FullScreenPanel` での Select ドロップダウンのレンダリング問題を修正
---
## 注意事項
- **OpenCode は新しくサポートされたアプリです**:関連機能を使用するには、まず OpenCode CLI をインストールする必要があります。
- **グローバルプロキシはすべての送信リクエストに影響します**:使用量クエリ、ヘルスチェックなどのネットワーク操作を含みます。
- **Rectifier は実験的機能です**:問題が発生した場合は、詳細設定で無効にできます。
---
## 特別な感謝
@yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 の皆様、このリリースへの貢献に感謝します!
@libukai 様、エレガントなフェイルオーバー関連 UI のデザインに感謝します!
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | ---------------------------------------------------- |
| `CC-Switch-v3.10.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.10.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| -------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.10.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.10.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> **注意**:作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+206
View File
@@ -0,0 +1,206 @@
# CC Switch v3.10.0
> OpenCode 支持、全局代理、Claude Rectifier 与多应用体验增强
**[English →](release-note-v3.10.0-en.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
---
## 概览
CC Switch v3.10.0 新增 OpenCode 支持,成为第四个受管理的 CLI 应用。
同时带来全局代理设置、Claude Rectifierthinking 签名修正器)、健康检查增强、按供应商配置等多项重要功能,并对多应用工作流与终端体验做了全面改进。
**发布日期**2026-01-21
---
## 重点内容
- OpenCode 支持:供应商、MCP 服务器、Skills 全面管理,首次启动自动导入
- 全局代理:为出站网络请求统一配置代理
- Claude Rectifierthinking 签名修正器,兼容更多第三方 API
- 健康检查增强:可配置提示词、CLI 兼容请求
- 按供应商配置:支持供应商特定配置的持久化
- 应用可见性控制:自由显示/隐藏应用,托盘菜单同步更新
- 终端改进:供应商专属终端按钮、fnm 路径支持、跨平台安全启动
- WSL 工具检测:在 WSL 环境检测工具版本,并增加安全加固
---
## 主要功能
### OpenCode 支持(新增第四应用)
- 完整的 OpenCode 供应商管理:新增、编辑、切换、删除
- MCP 服务器管理:与 Claude/Codex/Gemini 统一架构
- Skills 支持:OpenCode 也可使用 Skills 功能
- 首次启动自动导入:检测到已有 OpenCode 配置时自动导入
- 完整国际化:中/英/日三语支持(#695
### 全局代理(Global Proxy
- 为所有出站网络请求配置统一代理(#596,感谢 @yovinchen
- 支持 HTTP/HTTPS 代理协议
- 适用于需要代理访问外部 API 的网络环境
### Claude RectifierThinking 签名修正器)
- 自动修正 Claude API 的 thinking 签名(#595,感谢 @yovinchen
- 解决部分第三方 API 网关返回的 thinking 块格式不兼容问题
- 在高级设置中可开启/关闭
### 健康检查增强
- 可配置自定义提示词(prompt)用于流式健康检查(#623,感谢 @yovinchen
- 支持 CLI 兼容请求格式,更好地模拟真实使用场景
- 提升故障检测的准确性
### 按供应商配置(Per-Provider Config
- 支持为每个供应商单独保存配置(#663,感谢 @yovinchen
- 配置持久化:重启后保留供应商专属设置
- 适用于不同供应商需要不同配置的场景
### 应用可见性控制
- 自由显示/隐藏任意应用(Gemini 默认隐藏)
- 托盘菜单自动同步可见性设置
- 隐藏的应用不会出现在主界面和托盘菜单中
### Takeover Compact Mode
- 当显示 3 个及以上可见应用时,自动使用紧凑布局
- 优化多应用场景下的空间利用
### 终端改进
- 供应商专属终端按钮:一键在终端中使用当前供应商(#564,感谢 @kkkman22
- `fnm` 路径支持:自动识别 fnm 管理的 Node.js 路径
- 跨平台安全启动:改进 Windows/macOS/Linux 的终端启动逻辑
### WSL 工具检测
- 在 WSL 环境中检测工具版本(#627,感谢 @yovinchen
- 增加安全加固,防止命令注入风险
### Skills 预设增强
- 新增 `baoyu-skills` 预设仓库
- 自动补充缺失的默认仓库,确保开箱即用
---
## 体验优化
- 键盘快捷键:按 `ESC` 快速返回/关闭面板(#670,感谢 @xxk8
- 代理日志简化:输出更清晰易读(#585,感谢 @yovinchen
- 定价编辑器 UX:统一使用 `FullScreenPanel` 风格
- 高级设置布局:Rectifier 区块移至 Failover 下方,逻辑更顺畅
- OpenRouter 兼容模式:默认禁用,UI 开关隐藏(减少干扰)
---
## Bug 修复
### 代理与故障切换
- 启用自动故障切换时立即切换到 P1(而非等待下次请求)
### 供应商管理
- 修复供应商编辑对话框保存后重新打开时数据过时的问题(#654,感谢 @YangYongAn
- 修复切换预设时 baseUrl 和 apiKey 状态未重置的问题
- 修复端点自动选择状态未持久化的问题(#611,感谢 @yovinchen
- 未设置图标颜色时自动应用默认颜色
### 深链接
- 支持多端点导入(#597,感谢 @yovinchen
- 优先使用 `GOOGLE_GEMINI_BASE_URL` 而非 `GEMINI_BASE_URL`
### MCP
- WSL 目标路径跳过 `cmd /c` 包裹(#592,感谢 @cxyfer
### 用量模板
- 新增变量提示,修复验证问题(#628,感谢 @YangYongAn
- 防止配置在供应商之间泄漏
- 用量区块偏移量根据操作按钮宽度自动适应(#613,感谢 @yovinchen
### Gemini
- 超时参数转换为 Gemini CLI 格式(#580,感谢 @cxyfer
### UI
- 修复 `FullScreenPanel` 中 Select 下拉框渲染问题
---
## 说明与注意事项
- **OpenCode 为新支持的应用**:需要先安装 OpenCode CLI 才能使用相关功能。
- **全局代理会影响所有出站请求**:包括用量查询、健康检查等网络操作。
- **Rectifier 功能为实验性**:如遇问题可在高级设置中关闭。
---
## 特别感谢
感谢 @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 为本版本做出的贡献!
感谢 @libukai 设计的故障转移相关 UI,非常优雅!
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.10.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.10.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.10.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.10.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+188
View File
@@ -0,0 +1,188 @@
# CC Switch v3.9.0
> Local API Proxy, Auto Failover, Universal Provider, and a more complete multi-app workflow
**[中文版 →](release-note-v3.9.0-zh.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
---
## Overview
CC Switch v3.9.0 is the stable release of the v3.9 beta series (`3.9.0-1`, `3.9.0-2`, `3.9.0-3`).
It introduces a local API proxy with per-app takeover, automatic failover, universal providers, and many stability and UX improvements across Claude Code, Codex, and Gemini CLI.
**Release Date**: 2026-01-07
---
## Highlights
- Local API Proxy for Claude Code / Codex / Gemini CLI
- Auto Failover with circuit breaker and per-app failover queues
- Universal Provider: one shared config synced across apps (ideal for API gateways like NewAPI)
- Skills improvements: multi-app support, unified management with SSOT + React Query
- Common config snippets: extract reusable snippets from the editor or the current provider
- MCP import: import MCP servers from installed apps
- Usage improvements: auto-refresh, cache hit/creation metrics, and timezone fixes
- Linux packaging: RPM and Flatpak artifacts
---
## Major Features
### Local API Proxy
- Runs a local high-performance HTTP proxy server (Axum-based)
- Supports Claude Code, Codex, and Gemini CLI with a unified proxy
- Per-app takeover: you can independently decide which app routes through the proxy
- Live config takeover: backs up and redirects the CLI live config to the local proxy when takeover is enabled
- Monitoring: request logging and usage statistics for easier debugging and cost tracking
- Error request logging: keep detailed logs for failed proxy requests to simplify debugging (#401, thanks @yovinchen)
### Auto Failover (Circuit Breaker)
- Automatically detects provider failures and triggers protection (circuit breaker)
- Automatically switches to a backup provider when the current one is unhealthy
- Tracks provider health in real time, and keeps independent failover queues per app
- When failover is disabled, timeout/retry related settings no longer affect normal request flow
### Skills Management
- Multi-app Skills support for Claude Code and Codex, with smoother migration from older skill layouts (#365, #378, thanks @yovinchen)
- Unified Skills management architecture (SSOT + React Query) for more consistent state and refresh behavior
- Better discovery UX and performance:
- Skip hidden directories during discovery
- Faster discovery with long-lived caching for discoverable skills
- Clear loading indicators and more discoverable header actions (import/refresh)
- Fix wrong skill repo branch (#505, thanks @kjasn)
### Universal Provider
- Add a shared provider configuration that can sync to Claude/Codex/Gemini (#348, thanks @Calcium-Ion)
- Designed for API gateways that support multiple protocols (e.g., NewAPI)
- Allows per-app default model mapping under a single provider
### Common Config Snippets (Claude/Codex/Gemini)
- Maintain a reusable "common config" snippet and merge/append it into providers that enable it
- New extraction workflow:
- Extract from the editor content (what you are currently editing)
- Or extract from the current active provider when the editor content is not provided
- Codex extraction is safer:
- Removes provider-specific sections like `model_provider`, `model`, and the entire `model_providers` table
- Preserves `base_url` under `[mcp_servers.*]` so MCP configs are not accidentally broken
### MCP Management
- Import MCP servers from installed apps
- Improve robustness: skip sync when the target CLI app is not installed; handle invalid Codex `config.toml` gracefully (#461, thanks @majiayu000)
- Windows compatibility: wrap npx/npm commands with `cmd /c` for MCP export
### Usage & Pricing
- Usage & pricing improvements: auto-refresh, cache hit/creation metrics, timezone handling fixes, and refreshed built-in pricing table (#508, thanks @yovinchen)
- DeepLink support: import usage query configuration via deeplink (#400, thanks @qyinter)
- Model extraction for usage statistics (#455, thanks @yovinchen)
- Usage query credentials can fall back to provider config (#360, thanks @Sirhexs)
---
## UX Improvements
- Provider search filter: quickly find providers by name (#435, thanks @TinsFox)
- Provider icon colors: customize provider icon colors for quicker visual identification (#385, thanks @yovinchen)
- Keyboard shortcut: `Cmd/Ctrl + ,` opens Settings (#436, thanks @TinsFox)
- Skip Claude Code first-run confirmation dialog (optional)
- Closable toasts: close buttons for switch toast and all success toasts (#350, thanks @ForteScarlet)
- Update badge navigation: clicking the update badge opens the About tab
- Settings page tab style improvements (#342, thanks @wenyuanw)
- Smoother transitions: fade transitions for app/view switching and exit animations for panels
- Proxy takeover active theme: apply an emerald theme while takeover is active
- Dark mode readability improvements for forms and labels
- Better window dragging area for full-screen panels (#525, thanks @zerob13)
---
## Platform Notes
### Windows
- Prevent terminal windows from appearing during version checks
- Improve window sizing defaults (minimum width/height)
- Fix black screen on startup by using the system titlebar
- Add a fallback for `crypto.randomUUID()` on older WebViews
### macOS
- Use `.app` bundle path for autostart to avoid terminal window popups (#462, thanks @majiayu000)
- Improve tray/icon behavior and header alignment
---
## Packaging
- Linux: RPM and Flatpak packaging targets are now available for building release artifacts
---
## Notes
- Security improvements for the JavaScript executor and usage script execution (#151, thanks @luojiyin1987).
- SQL import is restricted to CC Switch exported backups to reduce the risk of importing unsafe or incompatible SQL dumps.
- Proxy takeover modifies CLI live configs; CC Switch will back up the live config before redirecting it to the local proxy. If you want to revert, disable takeover/stop the proxy and restore from the backup when needed.
## Special Thanks
Special thanks to @xunyu @deijing @su-fen for their support and contributions. This release wouldn't be possible without you!
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| --------------------------------------- | -------------------------------------------------- |
| `CC-Switch-v3.9.0-Windows.msi` | **Recommended** - MSI installer with auto-update support |
| `CC-Switch-v3.9.0-Windows-Portable.zip` | Portable version, no installation required |
### macOS
| File | Description |
| ------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.9.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.9.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author does not have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Close the app, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
### Homebrew (MacOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
| Other distros / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| Sandboxed installation | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
+188
View File
@@ -0,0 +1,188 @@
# CC Switch v3.9.0
> ローカル API プロキシ、自動フェイルオーバー、Universal Provider、多アプリ対応の強化
**[English →](release-note-v3.9.0-en.md) | [中文版 →](release-note-v3.9.0-zh.md)**
---
## 概要
CC Switch v3.9.0 は v3.9 ベータ(`3.9.0-1``3.9.0-2``3.9.0-3`)の安定版です。
ローカル API プロキシ(アプリ別テイクオーバー対応)、自動フェイルオーバー、Universal Provider を追加し、Claude Code / Codex / Gemini CLI の安定性と操作性を大きく改善しました。
**リリース日**2026-01-07
---
## ハイライト
- ローカル API プロキシ:Claude Code / Codex / Gemini CLI を統一的にプロキシ
- 自動フェイルオーバー:サーキットブレーカーとアプリ別のフェイルオーバーキュー
- Universal Provider1つの設定を複数アプリへ同期(NewAPI などのゲートウェイ向け)
- Skills の改善:マルチアプリ対応、SSOT + React Query による管理の統一
- 共通設定スニペット:エディタ内容または現在のプロバイダから抽出
- MCP インポート:インストール済みアプリから MCP servers を取り込み
- 使用量の改善:自動更新、キャッシュ指標、タイムゾーン修正
- Linux パッケージ:RPM / Flatpak の成果物を追加
---
## 主要機能
### ローカル API プロキシ(Local API Proxy
- ローカルで高性能な HTTP プロキシサーバーを起動(Axum ベース)
- Claude Code / Codex / Gemini CLI の API リクエストを統一的に扱う
- アプリ別テイクオーバー:アプリごとにプロキシ経由にするかを個別に切り替え可能
- Live 設定テイクオーバー:有効化時に CLI の live 設定をバックアップし、ローカルプロキシへリダイレクト
- 監視:リクエストログと使用量統計でデバッグとコスト把握を支援
- エラーリクエストのログ:失敗したプロキシリクエストも詳細に記録してデバッグを容易に(#401@yovinchen に感謝)
### 自動フェイルオーバー(Auto Failover / サーキットブレーカー)
- 障害を検知して保護(サーキットブレーカー)を自動で発動
- 現在のプロバイダが不調な場合、バックアッププロバイダへ自動切り替え
- アプリごとに独立したフェイルオーバーキューとヘルス状態を管理
- フェイルオーバーを無効化している場合、タイムアウト/リトライ関連の設定は通常フローに影響しません
### Skills 管理
- Claude Code と Codex の Skills をマルチアプリで利用可能にし、旧レイアウトからの移行もよりスムーズに(#365#378@yovinchen に感謝)
- SSOT + React Query による Skills 管理の統一で、状態の一貫性と更新挙動を改善
- Discovery の体験と性能を改善:
- スキャン時に隠しディレクトリをスキップ
- Discoverable skills に長寿命キャッシュを適用して高速化
- ローディング表示の改善と、インポート/更新などの操作導線を整理
- Skills リポジトリのブランチ設定を修正(#505@kjasn に感謝)
### Universal Provider
- 複数アプリで共有できるプロバイダ設定を追加(Claude/Codex/Gemini へ同期)(#348@Calcium-Ion に感謝)
- NewAPI のような複数プロトコル対応の API ゲートウェイを想定
- 1つのプロバイダ内でアプリ別にデフォルトモデルを割り当て可能
### 共通設定スニペット(Claude/Codex/Gemini
- 「共通設定スニペット」を保持し、有効化したプロバイダへマージ/追記
- 新しい抽出フロー:
- エディタの現在内容から抽出(編集している内容)
- エディタ内容がない場合は、現在アクティブなプロバイダから抽出
- Codex の抽出はより安全:
- `model_provider``model`、および `model_providers` テーブル全体など、プロバイダ固有の設定を除去
- `[mcp_servers.*]` 配下の `base_url` は保持し、MCP 設定を壊しにくくしています
### MCP 管理
- インストール済みアプリから MCP servers をインポート
- 安定性向上:対象 CLI が未インストールなら同期をスキップし、無効な Codex `config.toml` も適切に扱います(#461@majiayu000 に感謝)
- Windows 互換性:MCP エクスポート時の npx/npm 呼び出しを `cmd /c` でラップ
### 使用量と価格データ
- 使用量/価格の改善:自動更新、キャッシュ指標、タイムゾーン修正、内蔵価格テーブル更新(#508@yovinchen に感謝)
- DeepLink 対応:deeplink から使用量クエリ設定をインポート(#400@qyinter に感謝)
- 使用量統計からモデル情報を抽出(#455@yovinchen に感謝)
- 使用量クエリ資格情報はプロバイダ設定へフォールバック可能(#360@Sirhexs に感謝)
---
## 使い勝手の改善
- プロバイダ検索フィルター(名前で素早く検索)(#435@TinsFox に感謝)
- プロバイダのアイコン色:アイコンに任意の色を設定して見分けやすく(#385@yovinchen に感謝)
- ショートカット:`Cmd/Ctrl + ,` で設定を開く(#436@TinsFox に感謝)
- Claude Code の初回確認ダイアログをスキップ可能(任意)
- トースト通知のクローズボタン:切り替え通知と成功通知を閉じられるように(#350@ForteScarlet に感謝)
- 更新バッジをクリックすると About タブへ移動
- 設定ページのタブスタイル改善(#342@wenyuanw に感謝)
- アプリ/ビュー切り替えのフェードとパネル終了アニメーション
- プロキシテイクオーバー中はエメラルド系テーマを適用して状態を分かりやすく
- ダークモードの視認性改善
- FullScreenPanel のウィンドウドラッグ領域を改善(#525@zerob13 に感謝)
---
## プラットフォーム別メモ
### Windows
- バージョンチェック時にターミナルが表示されないよう改善
- ウィンドウ最小サイズのデフォルトを調整
- 起動時の黒画面を避けるため、システムタイトルバー方式を採用
- 古い WebView 向けに `crypto.randomUUID()` のフォールバックを追加
### macOS
- 自動起動で `.app` バンドルパスを使用し、ターミナル表示を回避(#462@majiayu000 に感謝)
- トレイとヘッダー周りの体験を改善
---
## パッケージ
- LinuxRPM と Flatpak のパッケージングを追加し、リリース成果物の生成に対応
---
## 注意事項
- セキュリティ強化:JavaScript 実行器と使用量スクリプト実行に関するセキュリティ問題を修正(#151@luojiyin1987 に感謝)。
- SQL インポートは CC Switch がエクスポートしたバックアップのみに制限されます(安全性のため)。
- プロキシのテイクオーバーは CLI の live 設定を変更します。CC Switch はリダイレクト前に live 設定をバックアップします。元に戻す場合はテイクオーバー無効化/プロキシ停止を行い、必要に応じてバックアップから復元してください。
## 特別な謝辞
@xunyu @deijing @su-fen の皆様のサポートと貢献に特別な感謝を申し上げます。皆様なしではこのリリースは実現しませんでした!
## ダウンロード & インストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から該当するバージョンをダウンロードしてください。
### システム要件
| システム | 最低バージョン | アーキテクチャ |
| -------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| --------------------------------------- | -------------------------------------------- |
| `CC-Switch-v3.9.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.9.0-Windows-Portable.zip` | ポータブル版、インストール不要 |
### macOS
| ファイル | 説明 |
| ------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.9.0-macOS.zip` | **推奨** - 解凍して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.9.0-macOS.tar.gz` | Homebrew インストールおよび自動更新用 |
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元が未確認」という警告が表示される場合があります。アプリを閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、正常に開けるようになります。
### Homebrew (MacOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
アップデート:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接実行、または AUR を使用 |
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| サンドボックスで実行したい場合 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
+188
View File
@@ -0,0 +1,188 @@
# CC Switch v3.9.0
> 本地 API 代理、自动故障切换、统一供应商与多应用工作流增强
**[English →](release-note-v3.9.0-en.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
---
## 概览
CC Switch v3.9.0 是 v3.9 测试版序列(`3.9.0-1``3.9.0-2``3.9.0-3`)的稳定版。
本次更新带来本地 API 代理(支持按应用接管)、自动故障切换、统一供应商(Universal Provider),并对 Claude Code / Codex / Gemini CLI 的稳定性与使用体验做了大量改进。
**发布日期**2026-01-07
---
## 重点内容
- 本地 API 代理:Claude Code / Codex / Gemini CLI 统一接入
- 自动故障切换:熔断保护 + 每个应用独立的 failover 队列
- 统一供应商:一份配置可同步到多个应用(适合 NewAPI 等网关)
- Skills 相关增强:支持多应用、管理架构统一(SSOT + React Query
- 通用配置片段:支持从编辑器内容或当前供应商提取可复用片段
- MCP 导入:支持从已安装应用导入 MCP servers
- 用量增强:自动刷新、缓存命中/创建指标、时区修复
- Linux 打包:新增 RPM 与 Flatpak 制品
---
## 主要功能
### 本地 API 代理(Local API Proxy
- 运行一个本地高性能 HTTP 代理服务(基于 Axum)
- 统一代理 Claude Code、Codex、Gemini CLI 的 API 请求
- 按应用接管:你可以分别控制每个应用是否走本地代理
- Live 配置接管:启用接管时,会备份并重定向 CLI 的 live 配置到本地代理
- 监控能力:记录请求日志与用量统计,便于排错与成本分析
- 错误请求日志:代理会记录失败请求的详细信息,便于定位问题(#401,感谢 @yovinchen
### 自动故障切换(Auto Failover / 熔断)
- 自动检测供应商异常并触发熔断保护
- 当前供应商不可用时自动切换到备用供应商
- 每个应用维护独立的 failover 队列,并实时追踪健康状态
- 当关闭故障切换时,超时/重试相关配置不会影响正常请求流程
### Skills 管理
- Skills 支持 Claude Code 与 Codex 多应用使用,并提供旧结构到新结构的平滑迁移(#365#378,感谢 @yovinchen
- Skills 管理架构统一(SSOT + React Query),状态刷新与数据一致性更稳定
- 发现(Discovery)体验与性能改进:
- 扫描时跳过隐藏目录
- Discoverable skills 使用长生命周期缓存提升性能
- 增加加载状态提示,导入/刷新等操作入口更显眼
- 修复 Skills 仓库分支配置错误(#505,感谢 @kjasn
### 统一供应商(Universal Provider
- 新增“跨应用共享”的供应商配置,可同步到 Claude/Codex/Gemini#348,感谢 @Calcium-Ion
- 适配支持多协议的 API 网关(例如 NewAPI
- 同一个供应商下可按应用分别设置默认模型映射
### 通用配置片段(Claude/Codex/Gemini
- 维护一段“通用配置片段”,并将其合并/追加到启用该功能的供应商配置中
- 新增“提取通用配置片段”工作流:
- 优先从编辑器当前内容提取(你正在编辑的内容)
- 若未提供编辑器内容,则从当前激活的供应商提取
- Codex 场景提取更安全:
- 自动移除 `model_provider``model` 以及整个 `model_providers` 表等供应商相关内容
- 会保留 `[mcp_servers.*]` 下的 `base_url`,避免误伤 MCP 配置
### MCP 管理
- 支持从已安装应用导入 MCP servers
- 同步更稳健:目标 CLI 未安装则跳过;无效的 Codex `config.toml` 可更优雅处理(#461,感谢 @majiayu000
- Windows 兼容性:MCP 导出相关的 npx/npm 调用使用 `cmd /c` 包裹
### 用量与计费数据
- 用量与计费增强:自动刷新、缓存命中/创建指标、时区修复,以及内置价格表更新(#508,感谢 @yovinchen
- 深链支持:可通过 deeplink 导入用量查询配置(#400,感谢 @qyinter
- 用量统计支持提取模型信息(#455,感谢 @yovinchen
- 用量查询凭证支持从供应商配置回退(#360,感谢 @Sirhexs
---
## 体验优化
- 供应商搜索过滤:按名称快速查找(#435,感谢 @TinsFox
- 供应商图标颜色:支持为供应商图标设置自定义颜色,便于快速区分(#385,感谢 @yovinchen
- 快捷键:`Cmd/Ctrl + ,` 打开设置(#436,感谢 @TinsFox
- 可跳过 Claude Code 首次确认弹窗(可选)
- Toast 通知可关闭:切换提示与成功提示都支持关闭按钮(#350,感谢 @ForteScarlet
- 点击更新徽章会自动跳转到 About 标签页
- 设置页 Tab 样式改进(#342,感谢 @wenyuanw
- 更顺滑的切换动效:应用/视图淡入淡出与面板退出动画
- 代理接管激活时应用翡翠绿主题,便于一眼识别当前状态
- 深色模式可读性增强(表单与标签对比度等)
- FullScreenPanel 的窗口拖拽区域优化(#525,感谢 @zerob13
---
## 平台说明
### Windows
- 版本检查不再弹出终端窗口
- 改进窗口尺寸默认值(最小宽高)
- 修复部分设备启动黑屏问题(使用系统标题栏方案)
- 兼容旧 WebView:为 `crypto.randomUUID()` 增加降级方案
### macOS
- 自启动使用 `.app bundle` 路径,避免弹出终端窗口(#462,感谢 @majiayu000
- 托盘与标题栏相关体验优化
---
## 打包
- Linux:新增 RPM 与 Flatpak 打包目标,用于生成发布制品
---
## 说明与注意事项
- 安全增强:修复 JavaScript 执行器与用量脚本相关的安全问题(#151,感谢 @luojiyin1987)。
- 为降低导入风险,SQL 导入被限制为仅允许导入 CC Switch 自己导出的备份。
- Proxy 接管会修改 CLI 的 live 配置;CC Switch 会在重定向前自动备份 live 配置。如需回退,可关闭接管/停止代理,并在必要时从备份恢复。
## 特别感谢
特别感谢 @xunyu @deijing @su-fen 做出的支持和贡献,没有你们就没有这个版本!
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| --------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.9.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.9.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.9.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.9.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewMacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| 沙箱隔离需求 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
+63
View File
@@ -0,0 +1,63 @@
# Flatpak Build Guide
This directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.
## Dependencies
- `flatpak`
- `flatpak-builder`
- Flathub remote (for installing `org.gnome.Platform//46` runtime)
For Ubuntu/Debian:
```bash
sudo apt install flatpak flatpak-builder
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
```
## Local Build (Generate .flatpak from .deb)
1) Build the deb on Linux first:
```bash
pnpm tauri build -- --bundles deb
```
2) Copy the generated deb to this directory:
```bash
cp "$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)" flatpak/cc-switch.deb
```
3) Build the local Flatpak repository and export the `.flatpak`:
```bash
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop
```
4) Install and run:
```bash
flatpak install --user ./CC-Switch-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## Permissions Note
The current manifest uses `--filesystem=home` by default for "download and run" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the "directory override" feature).
If you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:
```yaml
- --filesystem=~/.cc-switch:create
- --filesystem=~/.claude:create
- --filesystem=~/.claude.json
- --filesystem=~/.codex:create
- --filesystem=~/.gemini:create
```
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
If you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=CC Switch
Comment=All-in-One Assistant for Claude Code, Codex & Gemini CLI
Exec=cc-switch
Icon=com.ccswitch.desktop
Terminal=false
Categories=Utility;Development;
StartupNotify=true
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.ccswitch.desktop</id>
<name>CC Switch</name>
<summary>All-in-One Assistant for Claude Code, Codex &amp; Gemini CLI</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<description>
<p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>
<ul>
<li>Manage multiple provider configurations and endpoints</li>
<li>One-click switch and sync to client live configurations</li>
<li>MCP servers and Prompt/Skills management</li>
</ul>
</description>
<launchable type="desktop-id">com.ccswitch.desktop.desktop</launchable>
<provides>
<binary>cc-switch</binary>
</provides>
<url type="homepage">https://github.com/farion1231/cc-switch</url>
<url type="bugtracker">https://github.com/farion1231/cc-switch/issues</url>
</component>
+98
View File
@@ -0,0 +1,98 @@
id: com.ccswitch.desktop
runtime: org.gnome.Platform
runtime-version: '46'
sdk: org.gnome.Sdk
command: cc-switch
finish-args:
- --share=ipc
- --share=network
- --socket=wayland
- --socket=fallback-x11
- --device=dri
# Tray icon permissions (required by Tauri tray-icon)
- --talk-name=org.kde.StatusNotifierWatcher
- --filesystem=xdg-run/tray-icon:create
# GitHub Releases scenario: Users download and install manually.
# For "download and run" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,
# and supports custom directory overrides), we grant full Home access by default.
# If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).
- --filesystem=home
modules:
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
- name: intltool
cleanup:
- "*"
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
# Required for tray icon support
- name: libayatana-ido
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=NO
sources:
- type: git
url: https://github.com/AyatanaIndicators/ayatana-ido.git
tag: 0.10.4
- name: libdbusmenu-gtk3
buildsystem: autotools
build-options:
cflags: -Wno-error
config-opts:
- --with-gtk=3
- --disable-dumper
- --disable-static
- --disable-nls
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
- name: libayatana-indicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=NO
- -DENABLE_IDO=YES
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
tag: 0.9.4
- name: libayatana-appindicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_BINDINGS_MONO=NO
- -DENABLE_BINDINGS_VALA=NO
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
tag: 0.5.93
- name: cc-switch
buildsystem: simple
sources:
# Placed in flatpak/ directory by CI or local build script
- type: file
path: cc-switch.deb
- type: file
path: com.ccswitch.desktop.desktop
- type: file
path: com.ccswitch.desktop.metainfo.xml
- type: file
path: ../src-tauri/icons/128x128.png
build-commands:
- ar -x *.deb
- tar -xf data.tar.*
- cp -a usr/* /app/
# Use our own desktop/metainfo/icon to align with Flatpak app id
- rm -f /app/share/applications/*.desktop
- install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop
- install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml
- install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.9.0-3",
"version": "3.10.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -87,4 +87,4 @@
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
}
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.9.0-3"
version = "3.10.2"
dependencies = [
"anyhow",
"async-stream",
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.9.0-3"
version = "3.10.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -37,7 +37,7 @@ tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-stream = "0.3"
@@ -77,7 +77,8 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
codegen-units = 1
lto = "thin"
opt-level = "s"
panic = "abort"
# 使用 unwind 以便 panic hook 能捕获 backtraceabort 会直接终止无法捕获)
panic = "unwind"
strip = "symbols"
[dev-dependencies]
+152 -8
View File
@@ -13,6 +13,8 @@ pub struct McpApps {
pub codex: bool,
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
}
impl McpApps {
@@ -22,6 +24,7 @@ impl McpApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
@@ -31,6 +34,7 @@ impl McpApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
}
}
@@ -46,15 +50,129 @@ impl McpApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
!self.claude && !self.codex && !self.gemini && !self.opencode
}
}
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SkillApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
}
impl SkillApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<AppType> {
let mut apps = Vec::new();
if self.claude {
apps.push(AppType::Claude);
}
if self.codex {
apps.push(AppType::Codex);
}
if self.gemini {
apps.push(AppType::Gemini);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode
}
/// 仅启用指定应用(其他应用设为禁用)
pub fn only(app: &AppType) -> Self {
let mut apps = Self::default();
apps.set_enabled_for(app, true);
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledSkill {
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory"
pub id: String,
/// 显示名称
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 安装目录名(在 SSOT 目录中的子目录名)
pub directory: String,
/// 仓库所有者(GitHub 用户/组织)
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
/// 仓库分支
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_branch: Option<String>,
/// README URL
#[serde(skip_serializing_if = "Option::is_none")]
pub readme_url: Option<String>,
/// 应用启用状态
pub apps: SkillApps,
/// 安装时间(Unix 时间戳)
pub installed_at: i64,
}
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnmanagedSkill {
/// 目录名
pub directory: String,
/// 显示名称(从 SKILL.md 解析)
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
}
/// MCP 服务器定义(v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
@@ -101,6 +219,9 @@ pub struct McpRoot {
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
}
impl Default for McpRoot {
@@ -112,6 +233,7 @@ impl Default for McpRoot {
claude: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
}
}
}
@@ -132,6 +254,8 @@ pub struct PromptRoot {
pub codex: PromptConfig,
#[serde(default)]
pub gemini: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -145,7 +269,8 @@ use crate::provider::ProviderManager;
pub enum AppType {
Claude,
Codex,
Gemini, // 新增
Gemini,
OpenCode,
}
impl AppType {
@@ -153,7 +278,8 @@ impl AppType {
match self {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini", // 新增
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
}
}
}
@@ -166,11 +292,12 @@ impl FromStr for AppType {
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini), // 新增
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
)),
}
}
@@ -187,6 +314,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
}
impl CommonConfigSnippets {
@@ -196,6 +326,7 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
}
}
@@ -205,6 +336,7 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
}
}
}
@@ -243,7 +375,8 @@ impl Default for MultiAppConfig {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -402,6 +535,7 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
}
}
@@ -411,6 +545,7 @@ impl MultiAppConfig {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
}
}
@@ -424,6 +559,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Ok(config)
}
@@ -443,6 +579,7 @@ impl MultiAppConfig {
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
{
return Ok(false);
}
@@ -450,7 +587,12 @@ impl MultiAppConfig {
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false;
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
@@ -519,6 +661,7 @@ impl MultiAppConfig {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
};
prompts.insert(id, prompt);
@@ -552,6 +695,7 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
};
for (id, entry) in old_servers {
+97 -2
View File
@@ -65,6 +65,30 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
}
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
#[cfg(windows)]
fn is_wsl_path(path: &Path) -> bool {
use std::path::{Component, Prefix};
if let Some(Component::Prefix(prefix)) = path.components().next() {
match prefix.kind() {
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
let s = server.to_string_lossy();
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
}
_ => false,
}
} else {
false
}
}
#[cfg(not(windows))]
fn is_wsl_path(_path: &Path) -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
@@ -371,6 +395,11 @@ pub fn set_mcp_servers_map(
};
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
let is_wsl_target = is_wsl_path(&path);
if is_wsl_target {
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
}
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式WSL 路径除外)
if !is_wsl_target {
wrap_command_for_windows(&mut obj);
}
out.insert(id.clone(), Value::Object(obj));
}
@@ -545,4 +576,68 @@ mod tests {
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
}
}
/// 测试 WSL 路径检测功能
#[test]
fn test_is_wsl_path_wsl_dollar() {
// wsl$ 格式 - 各种发行版
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
assert!(is_wsl_path(Path::new(
r"\\wsl$\openSUSE-Leap-15.2\home\user"
)));
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
}
#[cfg(not(windows))]
{
// 非 Windows 平台始终返回 false
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
}
}
#[test]
fn test_is_wsl_path_wsl_localhost() {
// wsl.localhost 格式
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\Ubuntu\home\user\.claude"
)));
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
)));
}
}
#[test]
fn test_is_wsl_path_case_insensitive() {
// 大小写不敏感
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
}
}
#[test]
fn test_is_wsl_path_non_wsl() {
// 非 WSL 路径
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
#[cfg(windows)]
{
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
}
}
}
+9 -1
View File
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
get_home_dir().join(".codex")
}
/// 获取 Codex auth.json 路径
+54 -3
View File
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
@@ -16,6 +17,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
use std::str::FromStr;
fn invalid_json_format_error(error: serde_json::Error) -> String {
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
match lang.as_str() {
"en" => format!("Invalid JSON format: {error}"),
"ja" => format!("JSON形式が無効です: {error}"),
_ => format!("无效的 JSON 格式: {error}"),
}
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -38,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::OpenCode => {
let config_path = crate::opencode_config::get_opencode_config_path();
let exists = config_path.exists();
let path = crate::opencode_config::get_opencode_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -54,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -66,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
};
if !config_dir.exists() {
@@ -155,8 +179,7 @@ pub async fn set_claude_common_config_snippet(
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
@@ -197,7 +220,7 @@ pub async fn set_common_config_snippet(
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
.map_err(invalid_json_format_error)?;
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
@@ -219,3 +242,31 @@ pub async fn set_common_config_snippet(
.map_err(|e| e.to_string())?;
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
settingsConfig: Option<String>,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<String, String> {
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
let settings: serde_json::Value =
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app,
&settings,
)
.map_err(|e| e.to_string());
}
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
.map_err(|e| e.to_string())
}
+70 -1
View File
@@ -5,6 +5,8 @@
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
use crate::store::AppState;
use std::str::FromStr;
use tauri::Emitter;
/// 获取故障转移队列
#[tauri::command]
@@ -75,6 +77,7 @@ pub async fn get_auto_failover_enabled(
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command]
pub async fn set_auto_failover_enabled(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
@@ -83,6 +86,47 @@ pub async fn set_auto_failover_enabled(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空)
//
// 说明:
// - 仅在 enabled=true 时执行“切到 P1”
// - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启)
let p1_provider_id = if enabled {
let mut queue = state
.db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
if queue.is_empty() {
let app_enum = crate::app_config::AppType::from_str(&app_type)
.map_err(|_| format!("无效的应用类型: {app_type}"))?;
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_enum)
.map_err(|e| e.to_string())?;
let Some(current_id) = current_id else {
return Err("故障转移队列为空,且未设置当前供应商,无法开启故障转移".to_string());
};
state
.db
.add_to_failover_queue(&app_type, &current_id)
.map_err(|e| e.to_string())?;
queue = state
.db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
}
queue
.first()
.map(|item| item.provider_id.clone())
.ok_or_else(|| "故障转移队列为空,无法开启故障转移".to_string())?
} else {
String::new()
};
// 读取当前配置
let mut config = state
.db
@@ -98,5 +142,30 @@ pub async fn set_auto_failover_enabled(
.db
.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
// 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下)
if enabled {
state
.proxy_service
.switch_proxy_target(&app_type, &p1_provider_id)
.await?;
// 发射 provider-switched 事件(让前端刷新当前供应商)
let event_data = serde_json::json!({
"appType": app_type,
"providerId": p1_provider_id,
"source": "failoverEnabled"
});
let _ = app.emit("provider-switched", event_data);
}
// 刷新托盘菜单,确保状态同步
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_menu(Some(new_menu));
}
}
Ok(())
}
+247
View File
@@ -0,0 +1,247 @@
//! 全局出站代理相关命令
//!
//! 提供获取、设置和测试全局代理的 Tauri 命令。
use crate::proxy::http_client;
use crate::store::AppState;
use serde::Serialize;
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::time::{Duration, Instant};
/// 获取全局代理 URL
///
/// 返回当前配置的代理 URL,null 表示直连。
#[tauri::command]
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
log::debug!(
"[GlobalProxy] [GP-010] Read from database: {}",
result
.as_ref()
.map(|u| http_client::mask_url(u))
.unwrap_or_else(|| "None".to_string())
);
Ok(result)
}
/// 设置全局代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串:清除代理(直连)
///
/// 执行顺序:先验证 → 写 DB → 再应用
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
#[tauri::command]
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
// 调试:显示接收到的 URL 信息(不包含敏感内容)
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
log::debug!(
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
url.len(),
has_auth
);
let url_opt = if url.trim().is_empty() {
None
} else {
Some(url.as_str())
};
// 1. 先验证代理配置是否有效(不应用)
http_client::validate_proxy(url_opt)?;
// 2. 验证成功后保存到数据库
state
.db
.set_global_proxy_url(url_opt)
.map_err(|e| e.to_string())?;
// 3. DB 写入成功后再应用到运行态
http_client::apply_proxy(url_opt)?;
log::info!(
"[GlobalProxy] [GP-009] Configuration updated: {}",
url_opt
.map(http_client::mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 代理测试结果
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTestResult {
/// 是否连接成功
pub success: bool,
/// 延迟(毫秒)
pub latency_ms: u64,
/// 错误信息
pub error: Option<String>,
}
/// 测试代理连接
///
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
/// 使用多个测试目标,任一成功即认为代理可用。
#[tauri::command]
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
if url.trim().is_empty() {
return Err("Proxy URL is empty".to_string());
}
let start = Instant::now();
// 构建带代理的临时客户端
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build client: {e}"))?;
// 使用多个测试目标,提高兼容性
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
let test_urls = [
"https://httpbin.org/get",
"https://www.google.com",
"https://api.anthropic.com",
];
let mut last_error = None;
for test_url in test_urls {
match client.head(test_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis() as u64;
log::debug!(
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
http_client::mask_url(&url),
test_url,
resp.status(),
latency
);
return Ok(ProxyTestResult {
success: true,
latency_ms: latency,
error: None,
});
}
Err(e) => {
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
last_error = Some(e);
}
}
}
// 所有测试目标都失败
let latency = start.elapsed().as_millis() as u64;
let error_msg = last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "All test targets failed".to_string());
log::debug!(
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
http_client::mask_url(&url),
error_msg,
latency
);
Ok(ProxyTestResult {
success: false,
latency_ms: latency,
error: Some(error_msg),
})
}
/// 获取当前出站代理状态
///
/// 返回当前是否启用了出站代理以及代理 URL。
#[tauri::command]
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
let url = http_client::get_current_proxy_url();
UpstreamProxyStatus {
enabled: url.is_some(),
proxy_url: url,
}
}
/// 出站代理状态信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpstreamProxyStatus {
/// 是否启用代理
pub enabled: bool,
/// 代理 URL
pub proxy_url: Option<String>,
}
/// 检测到的代理信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedProxy {
/// 代理 URL
pub url: String,
/// 代理类型 (http/socks5)
pub proxy_type: String,
/// 端口
pub port: u16,
}
/// 常见代理端口配置
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
/// 对于 mixed 端口,会同时返回两种协议供用户选择
const PROXY_PORTS: &[(u16, &str, bool)] = &[
(7890, "http", true), // Clash (mixed mode)
(7891, "socks5", false), // Clash SOCKS only
(1080, "socks5", false), // 通用 SOCKS5
(8080, "http", false), // 通用 HTTP
(8888, "http", false), // Charles/Fiddler
(3128, "http", false), // Squid
(10808, "socks5", false), // V2Ray SOCKS
(10809, "http", false), // V2Ray HTTP
];
/// 扫描本地代理
///
/// 检测常见端口是否有代理服务在运行。
/// 使用异步任务避免阻塞 UI 线程。
#[tauri::command]
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
// 使用 spawn_blocking 避免阻塞主线程
tokio::task::spawn_blocking(|| {
let mut found = Vec::new();
for &(port, primary_type, is_mixed) in PROXY_PORTS {
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
// 添加主要类型
found.push(DetectedProxy {
url: format!("{primary_type}://127.0.0.1:{port}"),
proxy_type: primary_type.to_string(),
port,
});
// 对于 mixed 端口,同时添加另一种协议
if is_mixed {
let alt_type = if primary_type == "http" {
"socks5"
} else {
"http"
};
found.push(DetectedProxy {
url: format!("{alt_type}://127.0.0.1:{port}"),
proxy_type: alt_type.to_string(),
port,
});
}
}
}
found
})
.await
.unwrap_or_default()
}
+12
View File
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
new_server.apps.claude = true;
new_server.apps.codex = true;
new_server.apps.gemini = true;
new_server.apps.opencode = true;
}
McpService::upsert_server(&state, new_server)
@@ -192,3 +193,14 @@ pub async fn toggle_mcp_app(
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
#[tauri::command]
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
let mut total = 0;
total += McpService::import_from_claude(&state).unwrap_or(0);
total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
total += McpService::import_from_opencode(&state).unwrap_or(0);
Ok(total)
}
+482 -13
View File
@@ -1,7 +1,14 @@
#![allow(non_snake_case)]
use crate::init_status::InitErrorPayload;
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
@@ -65,6 +72,13 @@ pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
Ok(crate::init_status::take_skills_migration_result())
}
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
@@ -78,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 用于获取远程版本的 client
let client = reqwest::Client::builder()
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = {
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
try_get_version_wsl(tool, &distro)
} else {
// 先尝试直接执行
let direct_result = try_get_version(tool);
@@ -135,11 +148,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
re.find(raw)
VERSION_RE
.find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
@@ -171,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
(None, Some("not installed or not executable".to_string()))
} else {
(Some(extract_version(raw)), None)
}
@@ -180,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
(
None,
Some(if err.is_empty() {
"未安装或无法执行".to_string()
"not installed or not executable".to_string()
} else {
err
}),
@@ -191,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
/// 校验 WSL 发行版名称是否合法
/// WSL 发行版名称只允许字母、数字、连字符和下划线
#[cfg(target_os = "windows")]
fn is_valid_wsl_distro_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini"].contains(&tool),
"unexpected tool name: {tool}"
);
// 校验 distro 名称,防止命令注入
if !is_valid_wsl_distro_name(distro) {
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
}
let output = Command::new("wsl.exe")
.args([
"-d",
distro,
"--",
"sh",
"-lc",
&format!("{tool} --version"),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(
None,
Some(format!("[WSL:{distro}] not installed or not executable")),
)
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(format!(
"[WSL:{distro}] {}",
if err.is_empty() {
"not installed or not executable".to_string()
} else {
err
}
)),
)
}
}
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
}
}
/// 非 Windows 平台的 WSL 版本检测存根
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
(
None,
Some("WSL check not supported on this platform".to_string()),
)
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
@@ -224,6 +322,19 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
}
// 添加 fnm 路径支持
let fnm_base = home.join(".local/state/fnm_multishells");
if fnm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
search_paths.push(bin_path);
}
}
}
}
// 扫描 nvm 目录下的所有 node 版本
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
@@ -286,5 +397,363 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
(None, Some("未安装或无法执行".to_string()))
(None, Some("not installed or not executable".to_string()))
}
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
_ => None,
}?;
wsl_distro_from_path(&override_dir)
}
/// 从 UNC 路径中提取 WSL 发行版名称
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
#[cfg(target_os = "windows")]
fn wsl_distro_from_path(path: &Path) -> Option<String> {
use std::path::{Component, Prefix};
let Some(Component::Prefix(prefix)) = path.components().next() else {
return None;
};
match prefix.kind() {
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
let server_name = server.to_string_lossy();
if server_name.eq_ignore_ascii_case("wsl$")
|| server_name.eq_ignore_ascii_case("wsl.localhost")
{
let distro = share.to_string_lossy().to_string();
if !distro.is_empty() {
return Some(distro);
}
}
None
}
_ => None,
}
}
/// 非 Windows 平台不支持 WSL 路径解析
#[cfg(not(target_os = "windows"))]
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
None
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
let provider = providers
.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();
let Some(obj) = config.as_object() else {
return env_vars;
};
// 处理 env 字段(Claude/Gemini 通用)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
if let Some(key) = base_url_key {
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
env_vars.push((key.to_string(), url_str.to_string()));
}
}
}
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
if *app_type == AppType::Codex {
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
}
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
if *app_type == AppType::Gemini {
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
}
}
env_vars
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
"claude_{}_{}.json",
provider_id,
std::process::id()
));
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
/// 写入 claude 配置文件
fn write_claude_config(
config_file: &std::path::Path,
env_vars: &[(String, String)],
) -> Result<(), String> {
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();
for (key, value) in env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
let config_json =
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
// Write the shell script to a temp file (no escaping needed!)
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
script_file = script_file.display()
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
// Make script executable
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Simple AppleScript - just execute the script file
let applescript = format!(
r#"tell application "Terminal"
activate
do script "bash '{}'"
end tell"#,
script_file.display()
);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"AppleScript 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
("mate-terminal", vec!["--"]),
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
];
// Create temp script file (same approach as macOS)
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
script_file = script_file.display()
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals {
// Check if terminal exists
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
let result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.output();
match result {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
last_error = format!("启动 {} 失败: {}", terminal, stderr);
}
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
}
}
}
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let content = format!(
"@echo off
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
",
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
// Use output() to capture errors from the start command
// Use /K instead of /C to keep the window open after execution
let output = Command::new("cmd")
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&bat_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"启动 Windows 终端失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
+2
View File
@@ -4,6 +4,7 @@ mod config;
mod deeplink;
mod env;
mod failover;
mod global_proxy;
mod import_export;
mod mcp;
mod misc;
@@ -20,6 +21,7 @@ pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use failover::*;
pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
+36
View File
@@ -60,6 +60,16 @@ pub fn delete_provider(
.map_err(|e| e.to_string())
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
ProviderService::switch(state, app_type, id)
@@ -133,6 +143,7 @@ pub async fn testUsageScript(
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
templateType.as_deref(),
)
.await
.map_err(|e| e.to_string())
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
Ok(result)
}
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
+48
View File
@@ -59,3 +59,51 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
}
/// 获取整流器配置
#[tauri::command]
pub async fn get_rectifier_config(
state: tauri::State<'_, crate::AppState>,
) -> Result<crate::proxy::types::RectifierConfig, String> {
state.db.get_rectifier_config().map_err(|e| e.to_string())
}
/// 设置整流器配置
#[tauri::command]
pub async fn set_rectifier_config(
state: tauri::State<'_, crate::AppState>,
config: crate::proxy::types::RectifierConfig,
) -> Result<bool, String> {
state
.db
.set_rectifier_config(&config)
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 获取日志配置
#[tauri::command]
pub async fn get_log_config(
state: tauri::State<'_, crate::AppState>,
) -> Result<crate::proxy::types::LogConfig, String> {
state.db.get_log_config().map_err(|e| e.to_string())
}
/// 设置日志配置
#[tauri::command]
pub async fn set_log_config(
state: tauri::State<'_, crate::AppState>,
config: crate::proxy::types::LogConfig,
) -> Result<bool, String> {
state
.db
.set_log_config(&config)
.map_err(|e| e.to_string())?;
log::set_max_level(config.to_level_filter());
log::info!(
"日志配置已更新: enabled={}, level={}",
config.enabled,
config.level
);
Ok(true)
}
+143 -127
View File
@@ -1,12 +1,17 @@
use crate::app_config::AppType;
//! Skills 命令层
//!
//! v3.10.0+ 统一管理架构:
//! - 支持三应用开关(Claude/Codex/Gemini
//! - SSOT 存储在 ~/.cc-switch/skills/
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
@@ -15,69 +20,122 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
_ => Err(format!("不支持的 app 类型: {app}")),
}
}
/// 根据 app_type 生成带前缀的 skill key
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
let prefix = match app_type {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
};
format!("{prefix}:{directory}")
// ========== 统一管理命令 ==========
/// 获取所有已安装的 Skills
#[tauri::command]
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
/// 安装 Skill(新版统一安装)
///
/// 参数:
/// - skill: 从发现列表获取的技能信息
/// - current_app: 当前选中的应用,安装后默认启用该应用
#[tauri::command]
pub async fn install_skill_unified(
skill: DiscoverableSkill,
current_app: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
let app_type = parse_app_type(&current_app)?;
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 卸载 Skill(新版统一卸载)
#[tauri::command]
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
Ok(true)
}
/// 切换 Skill 的应用启用状态
#[tauri::command]
pub fn toggle_skill_app(
id: String,
app: String,
enabled: bool,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
Ok(true)
}
/// 扫描未管理的 Skills
#[tauri::command]
pub fn scan_unmanaged_skills(
app_state: State<'_, AppState>,
) -> Result<Vec<UnmanagedSkill>, String> {
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
}
/// 从应用目录导入 Skills
#[tauri::command]
pub fn import_skills_from_apps(
directories: Vec<String>,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
}
// ========== 发现功能命令 ==========
/// 发现可安装的 Skills(从仓库获取)
#[tauri::command]
pub async fn discover_available_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<DiscoverableSkill>, String> {
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())
}
// ========== 兼容旧 API 的命令 ==========
/// 获取技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
get_skills_for_app("claude".to_string(), service, app_state).await
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.list_skills(repos, &app_state.db)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills_for_app(
app: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
// 这样用户在首次运行时,已有的 skills 会被自动记录
let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills {
if skill.installed {
let key = get_skill_key(&app_type, &skill.directory);
if !existing_states.contains_key(&key) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
}
}
}
}
Ok(skills)
// 新版本不再区分应用,统一返回所有技能
let _ = parse_app_type(&app)?; // 验证 app 参数有效
get_skills(service, app_state).await
}
/// 安装技能(兼容旧 API
#[tauri::command]
pub async fn install_skill(
directory: String,
@@ -87,27 +145,34 @@ pub async fn install_skill(
install_skill_for_app("claude".to_string(), directory, service, app_state).await
}
/// 安装指定应用的技能(兼容旧 API)
#[tauri::command]
pub async fn install_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
// 先在不持有写锁的情况下收集仓库与技能信息
// 先获取技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.list_skills(repos)
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.into_iter()
.find(|s| {
let install_name = std::path::Path::new(&s.directory)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.directory.clone());
install_name.eq_ignore_ascii_case(&directory)
|| s.directory.eq_ignore_ascii_case(&directory)
})
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
@@ -116,103 +181,54 @@ pub async fn install_skill_for_app(
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
};
service
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
)
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
}
/// 卸载指定应用的技能(兼容旧 API)
#[tauri::command]
pub fn uninstall_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let _ = parse_app_type(&app)?; // 验证参数
service
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
// 通过 directory 找到对应的 skill id
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
let skill = skills
.into_iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
Ok(true)
}
// ========== 仓库管理命令 ==========
/// 获取技能仓库列表
#[tauri::command]
pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string())
}
/// 添加技能仓库
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
app_state
.db
.save_skill_repo(&repo)
@@ -220,11 +236,11 @@ pub fn add_skill_repo(
Ok(true)
}
/// 删除技能仓库
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
+10 -6
View File
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")
get_home_dir().join(".claude")
}
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf {
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude.json")
get_home_dir().join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
+11 -5
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,6 +30,7 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -44,6 +45,7 @@ impl Database {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
},
description,
homepage,
@@ -68,19 +70,23 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
server.id,
server.name,
serde_json::to_string(&server.server).unwrap(),
serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
"Failed to serialize server config: {e}"
)))?,
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
serde_json::to_string(&server.tags)
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.opencode,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
+13 -5
View File
@@ -220,7 +220,9 @@ impl Database {
WHERE id = ?13 AND app_type = ?14",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
serde_json::to_string(&provider.settings_config).map_err(|e| {
AppError::Database(format!("Failed to serialize settings_config: {e}"))
})?,
provider.website_url,
provider.category,
provider.created_at,
@@ -228,7 +230,9 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
"Failed to serialize meta: {e}"
)))?,
is_current,
in_failover_queue,
provider.id,
@@ -247,7 +251,8 @@ impl Database {
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
serde_json::to_string(&provider.settings_config)
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
provider.website_url,
provider.category,
provider.created_at,
@@ -255,7 +260,8 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
serde_json::to_string(&meta_clone)
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
is_current,
in_failover_queue,
],
@@ -324,7 +330,9 @@ impl Database {
conn.execute(
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
params![
serde_json::to_string(settings_config).unwrap(),
serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
"Failed to serialize settings_config: {e}"
)))?,
provider_id,
app_type
],
+57 -10
View File
@@ -41,7 +41,7 @@ impl Database {
Ok(GlobalProxyConfig {
proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 5000,
listen_port: 15721,
enable_logging: true,
})
}
@@ -121,13 +121,13 @@ impl Database {
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
circuit_failure_threshold: 4,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5,
circuit_error_rate_threshold: 0.6,
circuit_min_requests: 10,
})
}
@@ -210,12 +210,12 @@ impl Database {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 300, // 废弃字段,返回默认值
request_timeout: 600, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
})
},
)
@@ -614,4 +614,51 @@ impl Database {
log::info!("已删除所有 Live 配置备份");
Ok(())
}
// ==================== Sync Methods for Tray Menu ====================
/// 同步获取应用的 proxy 启用状态和自动故障转移状态
///
/// 用于托盘菜单构建等同步场景
/// 返回 (enabled, auto_failover_enabled)
pub fn get_proxy_flags_sync(&self, app_type: &str) -> (bool, bool) {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return (false, false),
};
conn.query_row(
"SELECT enabled, auto_failover_enabled FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)),
)
.unwrap_or((false, false))
}
/// 同步设置应用的 proxy 启用状态和自动故障转移状态
///
/// 用于托盘菜单点击等同步场景
pub fn set_proxy_flags_sync(
&self,
app_type: &str,
enabled: bool,
auto_failover_enabled: bool,
) -> Result<(), AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("Mutex lock failed: {e}")))?;
conn.execute(
"UPDATE proxy_config SET enabled = ?2, auto_failover_enabled = ?3, updated_at = datetime('now') WHERE app_type = ?1",
rusqlite::params![
app_type,
if enabled { 1 } else { 0 },
if auto_failover_enabled { 1 } else { 0 },
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
+76
View File
@@ -63,6 +63,41 @@ impl Database {
}
}
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
/// 获取全局出站代理 URL
///
/// 返回 None 表示未配置或已清除代理(直连)
/// 返回 Some(url) 表示已配置代理
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
}
/// 设置全局出站代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串或 None:清除代理设置(直连)
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
match url {
Some(u) if !u.trim().is_empty() => {
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
}
_ => {
// 清除代理设置
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![Self::GLOBAL_PROXY_URL_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
@@ -128,4 +163,45 @@ impl Database {
log::info!("已清除所有代理接管状态");
Ok(())
}
// --- 整流器配置 ---
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
None => Ok(crate::proxy::types::RectifierConfig::default()),
}
}
/// 更新整流器配置
pub fn set_rectifier_config(
&self,
config: &crate::proxy::types::RectifierConfig,
) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
self.set_setting("rectifier_config", &json)
}
// --- 日志配置 ---
/// 获取日志配置
pub fn get_log_config(&self) -> Result<crate::proxy::types::LogConfig, AppError> {
match self.get_setting("log_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析日志配置失败: {e}"))),
None => Ok(crate::proxy::types::LogConfig::default()),
}
}
/// 更新日志配置
pub fn set_log_config(&self, config: &crate::proxy::types::LogConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Database(format!("序列化日志配置失败: {e}")))?;
self.set_setting("log_config", &json)
}
}
+140 -45
View File
@@ -1,73 +1,159 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持四应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState};
use crate::services::skill::SkillRepo;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
// ========== InstalledSkill CRUD ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let directory: String = row.get(0)?;
let app_type: String = row.get(1)?;
let installed: bool = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok((
key,
SkillState {
installed,
installed_at,
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
},
))
installed_at: row.get(12)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(skill.id.clone(), skill);
}
Ok(skills)
}
/// 更新 Skill 状态
/// key 格式为 "app_type:directory"
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
// 解析 key
let (app_type, directory) = if let Some(idx) = key.find(':') {
let (app, dir) = key.split_at(idx);
(app, &dir[1..]) // 跳过冒号
} else {
// 向后兼容:如果没有前缀,默认为 claude
("claude", key)
};
/// 获取单个已安装的 Skill
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let result = stmt.query_row([id], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
},
installed_at: row.get(12)?,
})
});
match result {
Ok(skill) => Ok(Some(skill)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存 Skill(添加或更新)
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
params![directory, app_type, state.installed, state.installed_at.timestamp()],
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.opencode,
skill.installed_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 Skill
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM skills WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 清空所有 Skills(用于迁移)
pub fn clear_skills(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM skills", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 更新 Skill 的应用启用状态
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
// ========== SkillRepo CRUD(保持原有) ==========
/// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
@@ -101,7 +187,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
).map_err(|e| AppError::Database(e.to_string()))?;
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -116,24 +203,32 @@ impl Database {
Ok(())
}
/// 初始化默认的 Skill 仓库(首次启动时调用)
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
// 检查是否已有仓库
// 获取已有仓库列表
let existing = self.get_skill_repos()?;
if !existing.is_empty() {
return Ok(0);
}
let existing_keys: std::collections::HashSet<(String, String)> = existing
.iter()
.map(|r| (r.owner.clone(), r.name.clone()))
.collect();
// 获取默认仓库列表
let default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
// 仅插入缺失的默认仓库
for repo in &default_store.repos {
self.save_skill_repo(repo)?;
count += 1;
let key = (repo.owner.clone(), repo.name.clone());
if !existing_keys.contains(&key) {
self.save_skill_repo(repo)?;
count += 1;
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
}
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
if count > 0 {
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
}
Ok(count)
}
}
+10 -7
View File
@@ -192,13 +192,16 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
for (key, state) in &config.skills.skills {
tx.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
}
// v3.10.0+Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/+ 数据库统一结构。
//
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
//
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
// 迁移后可通过:
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
// - 或后续启动时的自动扫描逻辑
// 来重建已安装技能记录。
for repo in &config.skills.repos {
tx.execute(
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 2;
pub(crate) const SCHEMA_VERSION: i32 = 4;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+148 -29
View File
@@ -58,7 +58,7 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
@@ -71,11 +71,22 @@ impl Database {
PRIMARY KEY (id, app_type)
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表
// 5. Skills 表v3.10.0+ 统一结构)
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
repo_owner TEXT,
repo_name TEXT,
repo_branch TEXT DEFAULT 'main',
readme_url TEXT,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
)",
[],
)
@@ -102,12 +113,12 @@ impl Database {
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -123,7 +134,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -132,7 +143,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -141,7 +152,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -243,7 +254,7 @@ impl Database {
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000",
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
[],
);
let _ = conn.execute(
@@ -253,15 +264,15 @@ impl Database {
// 尝试添加超时配置列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
[],
);
@@ -331,6 +342,16 @@ impl Database {
Self::migrate_v1_to_v2(conn)?;
Self::set_user_version(conn, 2)?;
}
2 => {
log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?;
}
3 => {
log::info!("迁移数据库从 v3 到 v4OpenCode 支持)");
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -454,7 +475,7 @@ impl Database {
conn,
"proxy_config",
"listen_port",
"INTEGER NOT NULL DEFAULT 5000",
"INTEGER NOT NULL DEFAULT 15721",
)?;
Self::add_column_if_missing(
conn,
@@ -467,19 +488,19 @@ impl Database {
conn,
"proxy_config",
"streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 60",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 120",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 300",
"INTEGER NOT NULL DEFAULT 600",
)?;
}
@@ -649,12 +670,12 @@ impl Database {
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -689,6 +710,17 @@ impl Database {
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
// v3 结构(统一管理架构)已经是更高版本的 skills 表:
// - 主键为 id
// - 包含 enabled_claude / enabled_codex / enabled_gemini 等列
// 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。
if Self::has_column(conn, "skills", "enabled_claude")?
|| Self::has_column(conn, "skills", "id")?
{
log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
return Ok(());
}
// 检查是否已经是新表结构
if Self::has_column(conn, "skills", "app_type")? {
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
@@ -760,6 +792,93 @@ impl Database {
Ok(())
}
/// v2 -> v3 迁移:Skills 统一管理架构
///
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
/// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。
///
/// 迁移策略:
/// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统
/// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据
fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
// 检查是否已经是新结构(通过检查是否有 enabled_claude 列)
if Self::has_column(conn, "skills", "enabled_claude")? {
log::info!("skills 表已经是 v3 结构,跳过迁移");
return Ok(());
}
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
// 1. 备份旧数据(用于日志)
let old_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
.unwrap_or(0);
log::info!("旧 skills 表有 {old_count} 条记录");
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
[],
);
// 2. 删除旧表
conn.execute("DROP TABLE IF EXISTS skills", [])
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
// 3. 创建新表
conn.execute(
"CREATE TABLE skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
repo_owner TEXT,
repo_name TEXT,
repo_branch TEXT DEFAULT 'main',
readme_url TEXT,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
)",
[],
)
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
log::info!(
"skills 表已迁移到 v3 结构。\n\
注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
);
Ok(())
}
/// v3 -> v4 迁移:添加 OpenCode 支持
///
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
// 为 mcp_servers 表添加 enabled_opencode 列
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_opencode",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 为 skills 表添加 enabled_opencode 列
Self::add_column_if_missing(
conn,
"skills",
"enabled_opencode",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
+187 -1
View File
@@ -6,7 +6,7 @@ use super::*;
use crate::app_config::MultiAppConfig;
use crate::provider::{Provider, ProviderManager};
use indexmap::IndexMap;
use rusqlite::Connection;
use rusqlite::{params, Connection};
use serde_json::json;
use std::collections::HashMap;
@@ -51,6 +51,74 @@ const LEGACY_SCHEMA_SQL: &str = r#"
);
"#;
// v3.8.xschema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路
// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs
const V3_8_SCHEMA_V1_SQL: &str = r#"
CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
website_url TEXT,
category TEXT,
created_at INTEGER,
sort_index INTEGER,
notes TEXT,
icon TEXT,
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
);
CREATE TABLE provider_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER,
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
homepage TEXT,
docs TEXT,
tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE prompts (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
);
CREATE TABLE skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
);
"#;
#[derive(Debug)]
struct ColumnInfo {
r#type: String,
@@ -246,6 +314,124 @@ fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
.expect("query by app_type");
}
#[test]
fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable foreign keys");
// 模拟 v3.8.* 用户的数据库(schema v1
conn.execute_batch(V3_8_SCHEMA_V1_SQL)
.expect("seed v3.8 schema v1");
Database::set_user_version(&conn, 1).expect("set user_version=1");
// 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据)
conn.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
"p1",
"claude",
"Test Provider",
serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(),
Option::<String>::None,
Option::<String>::None,
Option::<i64>::None,
Option::<usize>::None,
Option::<String>::None,
Option::<String>::None,
Option::<String>::None,
"{}",
1,
],
)
.expect("seed provider");
conn.execute(
"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params!["claude:demo-skill", 1, 1700000000i64],
)
.expect("seed legacy skill");
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
Database::create_tables_on_conn(&conn).expect("create tables");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
assert_eq!(
Database::get_user_version(&conn).expect("user_version after migration"),
SCHEMA_VERSION
);
// v1 -> v2providers 新增字段必须补齐
for column in [
"cost_multiplier",
"limit_daily_usd",
"limit_monthly_usd",
"provider_type",
"in_failover_queue",
] {
assert!(
Database::has_column(&conn, "providers", column).expect("check column"),
"providers.{column} should exist after migration"
);
}
// 旧 provider 不应丢失,且新增字段应有默认值
let provider_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("count providers");
assert_eq!(provider_count, 1);
let cost_multiplier: String = conn
.query_row(
"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("read cost_multiplier");
assert_eq!(cost_multiplier, "1.0");
// v2 -> v3skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据)
assert!(
Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"),
"skills table should be migrated to v3 structure"
);
let skills_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
.expect("count skills");
assert_eq!(skills_count, 0, "skills table should be rebuilt empty");
let pending: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'",
[],
|r| r.get(0),
)
.ok();
assert!(
matches!(pending.as_deref(), Some("true") | Some("1")),
"skills_ssot_migration_pending should be set after v2->v3 migration"
);
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0))
.expect("count model_pricing rows");
assert!(pricing_rows > 0, "model_pricing should be seeded");
}
#[test]
fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
+2
View File
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false,
codex: false,
gemini: false,
opencode: false,
};
for app in apps_str.split(',') {
@@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
+1 -1
View File
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
/// Provider homepage URL
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
/// API endpoint/base URL
/// API endpoint/base URL (supports comma-separated multiple URLs)
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// API key
+6 -2
View File
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
validate_url(hp, "homepage")?;
}
}
// Validate each endpoint (supports comma-separated multiple URLs)
if let Some(ref ep) = endpoint {
if !ep.is_empty() {
validate_url(ep, "endpoint")?;
for (i, url) in ep.split(',').enumerate() {
let trimmed = url.trim();
if !trimmed.is_empty() {
validate_url(trimmed, &format!("endpoint[{i}]"))?;
}
}
}
+120 -48
View File
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
}
// Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?;
let mut merged_request = parse_and_merge_config(&request)?;
// Extract required fields (now as Option)
let app_str = merged_request
.app
.as_ref()
.clone()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
));
}
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
// Get endpoint: supports comma-separated multiple URLs (first is primary)
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
})?;
if endpoint.is_empty() {
return Err(AppError::InvalidInput(
"Endpoint cannot be empty".to_string(),
));
// Parse endpoints: split by comma, first is primary
let all_endpoints: Vec<String> = endpoint_str
.split(',')
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.collect();
let primary_endpoint = all_endpoints
.first()
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
// Auto-infer homepage from endpoint if not provided
if merged_request
.homepage
.as_ref()
.is_none_or(|s| s.is_empty())
{
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
}
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
let name = merged_request
.name
.as_ref()
.clone()
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
// Parse app type
let app_type = AppType::from_str(app_str)
let app_type = AppType::from_str(&app_str)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
// Build provider configuration based on app type
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
let normalized = ep.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() {
if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
normalized.clone(),
) {
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
}
}
}
// If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
@@ -115,6 +145,7 @@ pub(crate) fn build_provider_from_request(
AppType::Claude => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
};
// Build usage script configuration if provided
@@ -138,6 +169,16 @@ pub(crate) fn build_provider_from_request(
Ok(provider)
}
/// Get primary endpoint from request (first one if comma-separated)
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
request
.endpoint
.as_ref()
.and_then(|ep| ep.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
@@ -165,6 +206,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
@@ -174,12 +216,17 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request
.usage_base_url
.clone()
.or_else(|| request.endpoint.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
};
@@ -198,7 +245,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
);
env.insert(
"ANTHROPIC_BASE_URL".to_string(),
json!(request.endpoint.clone().unwrap_or_default()),
json!(get_primary_endpoint(request)),
);
// Add default model if provided
@@ -271,11 +318,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.unwrap_or("gpt-5-codex")
.to_string();
// Endpoint: normalize trailing slashes
let endpoint = request
.endpoint
.as_deref()
.unwrap_or("")
// Endpoint: normalize trailing slashes (use primary endpoint only)
let endpoint = get_primary_endpoint(request)
.trim()
.trim_end_matches('/')
.to_string();
@@ -309,7 +353,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(),
json!(request.endpoint),
json!(get_primary_endpoint(request)),
);
// Add model if provided
@@ -320,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env })
}
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build options object
let mut options = serde_json::Map::new();
if !endpoint.is_empty() {
options.insert("baseURL".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
options.insert("apiKey".to_string(), json!(api_key));
}
// Build models object
let mut models = serde_json::Map::new();
if let Some(model) = &request.model {
models.insert(model.clone(), json!({ "name": model }));
}
// Default to openai-compatible npm package
json!({
"npm": "@ai-sdk/openai-compatible",
"options": options,
"models": models
})
}
// =============================================================================
// Config Merge Logic
// =============================================================================
@@ -409,27 +480,26 @@ fn merge_claude_config(
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = Some(token.to_string());
}
}
// Auto-fill endpoint if not provided in URL
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string());
}
}
// Auto-fill homepage from endpoint if not provided
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://anthropic.com".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://anthropic.com".to_string());
}
}
}
@@ -468,7 +538,7 @@ fn merge_codex_config(
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
@@ -483,7 +553,7 @@ fn merge_codex_config(
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = Some(base_url);
}
@@ -499,13 +569,12 @@ fn merge_codex_config(
}
// Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://openai.com".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://openai.com".to_string());
}
}
}
@@ -518,14 +587,18 @@ fn merge_gemini_config(
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = Some(api_key.to_string());
}
}
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config
.get("GOOGLE_GEMINI_BASE_URL")
.or_else(|| config.get("GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string());
}
}
@@ -538,13 +611,12 @@ fn merge_gemini_config(
}
// Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://ai.google.dev".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://ai.google.dev".to_string());
}
}
}
+54
View File
@@ -404,3 +404,57 @@ fn test_parse_skill_deeplink() {
assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev");
}
// =============================================================================
// Multiple Endpoints Tests
// =============================================================================
#[test]
fn test_parse_multiple_endpoints_comma_separated() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert!(request.endpoint.is_some());
let endpoint = request.endpoint.unwrap();
// Should contain all endpoints comma-separated
assert!(endpoint.contains("https://api1.example.com"));
assert!(endpoint.contains("https://api2.example.com"));
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
}
#[test]
fn test_parse_endpoints_with_spaces_trimmed() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
// Validation should pass (spaces are trimmed during validation)
assert!(request.endpoint.is_some());
}
#[test]
fn test_infer_homepage_from_endpoint_without_homepage() {
// Test that homepage is auto-inferred from endpoint when not provided
assert_eq!(
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
Some("https://cubence.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://cubence.com"),
Some("https://cubence.com".to_string())
);
}
+9 -3
View File
@@ -5,15 +5,21 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".gemini")
get_home_dir().join(".gemini")
}
/// 获取 Gemini .env 文件路径
+27
View File
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
// Gemini CLI 只支持 timeout(单位 ms
// 默认值:startup=10s, tool=60s
const DEFAULT_STARTUP_MS: u64 = 10_000;
const DEFAULT_TOOL_MS: u64 = 60_000;
let extract_timeout =
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
obj.remove(key).and_then(|val| {
val.as_u64()
.map(|n| n * multiplier)
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
})
};
// 分别收集 startup 和 tool timeout,未设置时使用默认值
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
.unwrap_or(DEFAULT_STARTUP_MS);
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
.unwrap_or(DEFAULT_TOOL_MS);
// 取最大值作为 Gemini timeout
let final_timeout = startup_ms.max(tool_ms);
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
out.insert(id.clone(), Value::Object(obj));
}
+41
View File
@@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool {
}
}
// ============================================================
// Skills SSOT 迁移结果状态
// ============================================================
#[derive(Debug, Clone, Serialize)]
pub struct SkillsMigrationPayload {
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
}
pub fn set_skills_migration_result(count: usize) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload { count, error: None });
}
}
pub fn set_skills_migration_error(error: String) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload {
count: 0,
error: Some(error),
});
}
}
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
if let Ok(mut guard) = skills_migration_cell().write() {
guard.take()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+315 -23
View File
@@ -13,6 +13,8 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod opencode_config;
mod panic_hook;
mod prompt;
mod prompt_files;
mod provider;
@@ -26,6 +28,7 @@ mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
@@ -54,6 +57,37 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent;
use tauri::{Emitter, Manager};
fn redact_url_for_log(url_str: &str) -> String {
match url::Url::parse(url_str) {
Ok(url) => {
let mut output = format!("{}://", url.scheme());
if let Some(host) = url.host_str() {
output.push_str(host);
}
output.push_str(url.path());
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
keys.sort();
keys.dedup();
if !keys.is_empty() {
output.push_str("?[keys:");
output.push_str(&keys.join(","));
output.push(']');
}
output
}
Err(_) => {
let base = url_str.split('#').next().unwrap_or(url_str);
match base.split_once('?') {
Some((prefix, _)) => format!("{prefix}?[redacted]"),
None => base.to_string(),
}
}
}
}
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
@@ -69,7 +103,9 @@ fn handle_deeplink_url(
return false;
}
log::info!("✓ Deep link URL detected from {source}: {url_str}");
let redacted_url = redact_url_for_log(url_str);
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
log::debug!("Deep link URL (raw) from {source}: {url_str}");
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
@@ -150,15 +186,18 @@ fn macos_tray_icon() -> Option<Image<'static>> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 设置 panic hook,在应用崩溃时记录日志到 <app_config_dir>/crash.log(默认 ~/.cc-switch/crash.log
panic_hook::setup_panic_hook();
let mut builder = tauri::Builder::default();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
log::info!("=== Single Instance Callback Triggered ===");
log::info!("Args count: {}", args.len());
log::debug!("Args count: {}", args.len());
for (i, arg) in args.iter().enumerate() {
log::info!(" arg[{i}]: {arg}");
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
@@ -212,6 +251,10 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build())
.setup(|app| {
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
app_store::refresh_app_config_dir_override(app.handle());
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
{
@@ -223,18 +266,43 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
// 初始化日志
if cfg!(debug_assertions) {
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log
{
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
let log_dir = panic_hook::get_log_dir();
// 确保日志目录存在
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!("创建日志目录失败: {e}");
}
// 启动时删除旧日志文件,实现单文件覆盖效果
let log_file_path = log_dir.join("cc-switch.log");
let _ = std::fs::remove_file(&log_file_path);
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别
.level(log::LevelFilter::Trace)
.targets([
Target::new(TargetKind::Stdout),
Target::new(TargetKind::Folder {
path: log_dir,
file_name: Some("cc-switch".into()),
}),
])
// 单文件模式:启动时删除旧文件,达到大小时轮转
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
// KeepSome(2) 是最小安全值,表示不保留轮转文件
.rotation_strategy(RotationStrategy::KeepSome(2))
// 单文件大小限制 1GB
.max_file_size(1024 * 1024 * 1024)
.timezone_strategy(TimezoneStrategy::UseLocal)
.build(),
)?;
}
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
app_store::refresh_app_config_dir_override(app.handle());
// 初始化数据库
let app_config_dir = crate::config::get_app_config_dir();
let db_path = app_config_dir.join("cc-switch.db");
@@ -273,12 +341,25 @@ pub fn run() {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
return Err(Box::new(e));
// 现在创建数据库(包含 Schema 迁移)
//
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
// 否则表现可能只是“应用打不开/闪退”。
let db = loop {
match crate::database::Database::init() {
Ok(db) => break Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())
{
log::info!("用户选择退出程序");
std::process::exit(1);
}
log::info!("用户选择重试初始化数据库");
}
}
};
@@ -324,6 +405,47 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
match app_state.db.get_setting("skills_ssot_migration_pending") {
Ok(Some(flag)) if flag == "true" || flag == "1" => {
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
let has_existing = app_state
.db
.get_all_installed_skills()
.map(|skills| !skills.is_empty())
.unwrap_or(false);
if has_existing {
log::info!(
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
);
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
} else {
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
Ok(count) => {
log::info!("✓ Auto imported {count} skill(s) into SSOT");
if count > 0 {
crate::init_status::set_skills_migration_result(count);
}
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
}
Err(e) => {
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
crate::init_status::set_skills_migration_error(e.to_string());
// 保留 pending 标志,方便下次启动重试
}
}
}
}
Ok(_) => {} // 未开启迁移标志,静默跳过
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
for app in [
crate::app_config::AppType::Claude,
@@ -336,6 +458,27 @@ pub fn run() {
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
if app_state
.db
.get_config_snippet(app.as_str())
.ok()
.flatten()
.is_none()
{
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
} else {
log::info!("✓ Extracted common config snippet for {}", app.as_str());
}
}
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
}
}
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
@@ -348,6 +491,17 @@ pub fn run() {
}
}
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
// 需要遍历 provider 字段下的每个供应商并导入
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
@@ -375,6 +529,14 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
}
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
}
}
// 4. 导入提示词文件(表空时触发)
@@ -454,7 +616,7 @@ pub fn run() {
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::info!(" URL[{i}]: {url_str}");
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
break; // Process only first ccswitch:// URL
@@ -506,13 +668,51 @@ pub fn run() {
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 从数据库加载日志配置并应用
{
let db = &app.state::<AppState>().db;
if let Ok(log_config) = db.get_log_config() {
log::set_max_level(log_config.to_level_filter());
log::info!(
"已加载日志配置: enabled={}, level={}",
log_config.enabled,
log_config.level
);
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
// 初始化 SkillService
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
let proxy_url = db.get_global_proxy_url().ok().flatten();
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
log::error!(
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
);
// 清除无效的代理配置
if proxy_url.is_some() {
log::warn!(
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
);
if let Err(clear_err) = db.set_global_proxy_url(None) {
log::error!(
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
);
}
}
// 使用直连模式重新初始化
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
log::error!(
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
);
}
}
}
@@ -553,6 +753,7 @@ pub fn run() {
commands::add_provider,
commands::update_provider,
commands::delete_provider,
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_claude_config_status,
@@ -564,15 +765,21 @@ pub fn run() {
commands::open_external,
commands::get_init_error,
commands::get_migration_result,
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::get_log_config,
commands::set_log_config,
commands::restart_app,
commands::check_for_updates,
commands::is_portable_mode,
@@ -601,6 +808,7 @@ pub fn run() {
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
commands::import_mcp_from_apps,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
@@ -635,7 +843,15 @@ pub fn run() {
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
// Skill management (legacy API compatibility)
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
@@ -694,12 +910,23 @@ pub fn run() {
commands::get_stream_check_config,
commands::save_stream_check_config,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
// OpenCode specific
commands::import_opencode_providers_from_live,
commands::get_opencode_live_provider_ids,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
]);
let app = builder
@@ -966,3 +1193,68 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
))
.blocking_show()
}
/// 显示数据库初始化/Schema 迁移失败对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_database_init_error_dialog(
app: &tauri::AppHandle,
db_path: &std::path::Path,
error: &str,
) -> bool {
let title = if is_chinese_locale() {
"数据库初始化失败"
} else {
"Database Initialization Failed"
};
let message = if is_chinese_locale() {
format!(
"初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\
数据库文件路径:\n{db}\n\n\
您的数据尚未丢失,应用不会自动删除数据库文件。\n\
常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\
建议:\n\
1) 先备份整个配置目录(包含 cc-switch.db\n\
2) 如果提示“数据库版本过新”,请升级到更新版本\n\
3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\
点击「重试」重新尝试初始化\n\
点击「退出」关闭程序",
db = db_path.display()
)
} else {
format!(
"An error occurred while initializing or migrating the database:\n\n{error}\n\n\
Database file path:\n{db}\n\n\
Your data is NOT lost - the app will not delete the database automatically.\n\
Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\
Suggestions:\n\
1) Back up the entire config directory (including cc-switch.db)\n\
2) If you see “database version is newer”, please upgrade CC Switch\n\
3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\
Click 'Retry' to attempt initialization again\n\
Click 'Exit' to close the program",
db = db_path.display()
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
+1
View File
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+1
View File
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false,
codex: true,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+1
View File
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: true,
opencode: false,
},
description: None,
homepage: None,
+5
View File
@@ -8,10 +8,12 @@
//! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
mod claude;
mod codex;
mod gemini;
mod opencode;
mod validation;
// 重新导出公共 API
@@ -26,3 +28,6 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
+354
View File
@@ -0,0 +1,354 @@
//! OpenCode MCP 同步和导入模块
//!
//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。
//!
//! ## 格式差异
//!
//! | CC Switch 统一格式 | OpenCode 格式 |
//! |----------------------|---------------------|
//! | `type: "stdio"` | `type: "local"` |
//! | `command` + `args` | `command: [cmd, ...args]` |
//! | `env` | `environment` |
//! | `type: "sse"/"http"` | `type: "remote"` |
//! | `url` | `url` |
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::error::AppError;
use crate::opencode_config;
use super::validation::validate_server_spec;
// ============================================================================
// Helper Functions
// ============================================================================
/// Check if OpenCode MCP sync should proceed
fn should_sync_opencode_mcp() -> bool {
// Skip if OpenCode config directory doesn't exist
opencode_config::get_opencode_dir().exists()
}
// ============================================================================
// Format Conversion: CC Switch → OpenCode
// ============================================================================
/// Convert CC Switch unified format to OpenCode format
///
/// Conversion rules:
/// - `stdio` → `local`, command+args → command array, env → environment
/// - `sse`/`http` → `remote`, url preserved
pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
let obj = spec
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let mut result = serde_json::Map::new();
match typ {
"stdio" => {
// Convert to "local" type
result.insert("type".into(), json!("local"));
// Merge command and args into a single array
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
let mut command_arr = vec![json!(cmd)];
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
for arg in args {
command_arr.push(arg.clone());
}
}
result.insert("command".into(), Value::Array(command_arr));
// Convert env → environment
if let Some(env) = obj.get("env") {
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("environment".into(), env.clone());
}
}
// Add enabled flag (OpenCode expects this)
result.insert("enabled".into(), json!(true));
}
"sse" | "http" => {
// Convert to "remote" type
result.insert("type".into(), json!("remote"));
// Preserve url
if let Some(url) = obj.get("url") {
result.insert("url".into(), url.clone());
}
// Convert headers if present
if let Some(headers) = obj.get("headers") {
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
{
result.insert("headers".into(), headers.clone());
}
}
// Add enabled flag
result.insert("enabled".into(), json!(true));
}
_ => {
return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
}
}
Ok(Value::Object(result))
}
// ============================================================================
// Format Conversion: OpenCode → CC Switch
// ============================================================================
/// Convert OpenCode format to CC Switch unified format
///
/// Conversion rules:
/// - `local` → `stdio`, command array → command+args, environment → env
/// - `remote` → `sse`, url preserved
pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
let obj = spec
.as_object()
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
let mut result = serde_json::Map::new();
match typ {
"local" => {
// Convert to "stdio" type
result.insert("type".into(), json!("stdio"));
// Split command array into command and args
if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) {
if !cmd_arr.is_empty() {
// First element is the command
if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {
result.insert("command".into(), json!(cmd));
}
// Rest are args
if cmd_arr.len() > 1 {
let args: Vec<Value> = cmd_arr[1..].to_vec();
result.insert("args".into(), Value::Array(args));
}
}
}
// Convert environment → env
if let Some(env) = obj.get("environment") {
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("env".into(), env.clone());
}
}
}
"remote" => {
// Convert to "sse" type (default remote protocol)
result.insert("type".into(), json!("sse"));
// Preserve url
if let Some(url) = obj.get("url") {
result.insert("url".into(), url.clone());
}
// Preserve headers
if let Some(headers) = obj.get("headers") {
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
{
result.insert("headers".into(), headers.clone());
}
}
}
_ => {
return Err(AppError::McpValidation(format!(
"Unknown OpenCode MCP type: {typ}"
)));
}
}
Ok(Value::Object(result))
}
// ============================================================================
// Public API: Sync Functions
// ============================================================================
/// Sync a single MCP server to OpenCode live config
pub fn sync_single_server_to_opencode(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_opencode_mcp() {
return Ok(());
}
// Convert to OpenCode format
let opencode_spec = convert_to_opencode_format(server_spec)?;
// Set in OpenCode config
opencode_config::set_mcp_server(id, opencode_spec)
}
/// Remove a single MCP server from OpenCode live config
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
if !should_sync_opencode_mcp() {
return Ok(());
}
opencode_config::remove_mcp_server(id)
}
/// Import MCP servers from OpenCode config to unified structure
///
/// Existing servers will have OpenCode app enabled without overwriting other fields.
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let mcp_map = opencode_config::get_mcp_servers()?;
if mcp_map.is_empty() {
return Ok(0);
}
// Ensure servers map exists
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in mcp_map {
// Convert from OpenCode format to unified format
let unified_spec = match convert_from_opencode_format(&spec) {
Ok(s) => s,
Err(e) => {
log::warn!("Skip invalid OpenCode MCP server '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
};
// Validate the converted spec
if let Err(e) = validate_server_spec(&unified_spec) {
log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(&id) {
// Existing server: just enable OpenCode app
if !existing.apps.opencode {
existing.apps.opencode = true;
changed += 1;
log::info!("MCP server '{id}' enabled for OpenCode");
}
} else {
// New server: default to only OpenCode enabled
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: unified_spec,
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("Imported new MCP server '{id}' from OpenCode");
}
}
if !errors.is_empty() {
log::warn!(
"Import completed with {} failures: {:?}",
errors.len(),
errors
);
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_stdio_to_local() {
let spec = json!({
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": { "HOME": "/Users/test" }
});
let result = convert_to_opencode_format(&spec).unwrap();
assert_eq!(result["type"], "local");
assert_eq!(result["command"][0], "npx");
assert_eq!(result["command"][1], "-y");
assert_eq!(
result["command"][2],
"@modelcontextprotocol/server-filesystem"
);
assert_eq!(result["environment"]["HOME"], "/Users/test");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_sse_to_remote() {
let spec = json!({
"type": "sse",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" }
});
let result = convert_to_opencode_format(&spec).unwrap();
assert_eq!(result["type"], "remote");
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_local_to_stdio() {
let spec = json!({
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
"environment": { "HOME": "/Users/test" }
});
let result = convert_from_opencode_format(&spec).unwrap();
assert_eq!(result["type"], "stdio");
assert_eq!(result["command"], "npx");
assert_eq!(result["args"][0], "-y");
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
assert_eq!(result["env"]["HOME"], "/Users/test");
}
#[test]
fn test_convert_remote_to_sse() {
let spec = json!({
"type": "remote",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" }
});
let result = convert_from_opencode_format(&spec).unwrap();
assert_eq!(result["type"], "sse");
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
}
}
+210
View File
@@ -0,0 +1,210 @@
//! OpenCode 配置文件读写模块
//!
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json
//! {
//! "$schema": "https://opencode.ai/config.json",
//! "provider": {
//! "my-provider": {
//! "npm": "@ai-sdk/openai-compatible",
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
//! }
//! },
//! "mcp": {
//! "my-server": { "type": "local", "command": ["..."] }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::provider::OpenCodeProviderConfig;
use crate::settings::get_opencode_override_dir;
use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenCode 配置目录
///
/// 默认路径: `~/.config/opencode/`
/// 可通过 settings.opencode_config_dir 覆盖
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.config/opencode
dirs::home_dir()
.map(|h| h.join(".config").join("opencode"))
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
}
/// 获取 OpenCode 配置文件路径
///
/// 返回 `~/.config/opencode/opencode.json`
pub fn get_opencode_config_path() -> PathBuf {
get_opencode_dir().join("opencode.json")
}
/// 获取 OpenCode 环境变量文件路径(如果存在)
///
/// 返回 `~/.config/opencode/.env`
#[allow(dead_code)]
pub fn get_opencode_env_path() -> PathBuf {
get_opencode_dir().join(".env")
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenCode 配置文件
///
/// 返回完整的配置 JSON 对象
pub fn read_opencode_config() -> Result<Value, AppError> {
let path = get_opencode_config_path();
if !path.exists() {
// Return empty config with schema
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
}
/// 写入 OpenCode 配置文件(原子写入)
///
/// 使用临时文件 + 重命名确保原子性
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
write_json_file(&path, config)?;
log::debug!("OpenCode config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
.get("provider")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
if full_config.get("provider").is_none() {
full_config["provider"] = json!({});
}
if let Some(providers) = full_config
.get_mut("provider")
.and_then(|v| v.as_object_mut())
{
providers.insert(id.to_string(), config);
}
write_opencode_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
providers.remove(id);
}
write_opencode_config(&config)
}
// ============================================================================
// Provider Functions (Typed - using OpenCodeProviderConfig)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
for (id, value) in providers {
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
Ok(config) => {
result.insert(id, config);
}
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// MCP Functions
// ============================================================================
/// 获取所有 MCP 服务器配置
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
.get("mcp")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default())
}
/// 设置 MCP 服务器配置
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
if full_config.get("mcp").is_none() {
full_config["mcp"] = json!({});
}
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
mcp.insert(id.to_string(), config);
}
write_opencode_config(&full_config)
}
/// 删除 MCP 服务器配置
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
mcp.remove(id);
}
write_opencode_config(&config)
}
+205
View File
@@ -0,0 +1,205 @@
//! Panic Hook 模块
//!
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
//! 便于用户和开发者诊断闪退问题。
use std::fs::OpenOptions;
use std::io::Write;
use std::panic;
use std::path::PathBuf;
use std::sync::OnceLock;
/// 应用版本号(从 Cargo.toml 读取)
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn init_app_config_dir(dir: PathBuf) {
let _ = APP_CONFIG_DIR.set(dir);
}
/// 获取默认应用配置目录(不会 panic)
fn default_app_config_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cc-switch")
}
/// 获取应用配置目录(优先使用初始化时写入的值;不会 panic)
fn get_app_config_dir() -> PathBuf {
APP_CONFIG_DIR
.get()
.cloned()
.unwrap_or_else(default_app_config_dir)
}
/// 获取崩溃日志文件路径
fn get_crash_log_path() -> PathBuf {
get_app_config_dir().join("crash.log")
}
/// 获取日志目录路径
pub fn get_log_dir() -> PathBuf {
get_app_config_dir().join("logs")
}
/// 安全获取环境信息(不会 panic)
fn get_system_info() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let family = std::env::consts::FAMILY;
// 安全获取当前工作目录
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".to_string());
// 安全获取当前线程信息
let thread = std::thread::current();
let thread_name = thread.name().unwrap_or("unnamed");
let thread_id = format!("{:?}", thread.id());
format!(
"OS: {os} ({family})\n\
Arch: {arch}\n\
App Version: {APP_VERSION}\n\
Working Dir: {cwd}\n\
Thread: {thread_name} (ID: {thread_id})"
)
}
/// 设置 panic hook,捕获崩溃信息并写入日志文件
///
/// 在应用启动时调用此函数,确保任何 panic 都会被记录。
/// 日志格式包含:
/// - 时间戳
/// - 应用版本和系统信息
/// - Panic 信息
/// - 发生位置(文件:行号)
/// - Backtrace(完整调用栈)
pub fn setup_panic_hook() {
// 启用 backtrace(确保 release 模式也能捕获)
if std::env::var("RUST_BACKTRACE").is_err() {
std::env::set_var("RUST_BACKTRACE", "1");
}
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let log_path = get_crash_log_path();
// 确保目录存在
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// 构建崩溃信息(使用 catch_unwind 保护时间格式化,避免嵌套 panic)
let timestamp = std::panic::catch_unwind(|| {
chrono::Local::now()
.format("%Y-%m-%d %H:%M:%S%.3f")
.to_string()
})
.unwrap_or_else(|_| {
// chrono panic 时回退到 unix timestamp
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("unix:{}.{:03}", d.as_secs(), d.subsec_millis()))
.unwrap_or_else(|_| "unknown".to_string())
});
// 获取系统信息
let system_info = std::panic::catch_unwind(get_system_info)
.unwrap_or_else(|_| "Failed to get system info".to_string());
// 获取 panic 消息(尝试多种方式提取)
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
// 尝试使用 Display trait
format!("{panic_info}")
};
// 获取位置信息
let location = if let Some(loc) = panic_info.location() {
format!(
"File: {}\n Line: {}\n Column: {}",
loc.file(),
loc.line(),
loc.column()
)
} else {
"Unknown location".to_string()
};
// 捕获 backtrace(完整调用栈)
let backtrace = std::backtrace::Backtrace::force_capture();
let backtrace_str = format!("{backtrace}");
// 格式化日志条目
let separator = "=".repeat(80);
let sub_separator = "-".repeat(40);
let crash_entry = format!(
r#"
{separator}
[CRASH REPORT] {timestamp}
{separator}
{sub_separator}
System Information
{sub_separator}
{system_info}
{sub_separator}
Error Details
{sub_separator}
Message: {message}
Location: {location}
{sub_separator}
Stack Trace (Backtrace)
{sub_separator}
{backtrace_str}
{separator}
"#
);
// 写入文件(追加模式)
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = file.write_all(crash_entry.as_bytes());
let _ = file.flush();
// 记录日志文件位置到 stderr
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
}
// 同时输出到 stderr(便于开发调试)
eprintln!("{crash_entry}");
// 调用默认 hook
default_hook(panic_info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crash_log_path() {
let path = get_crash_log_path();
assert!(path.ends_with("crash.log"));
assert!(path.to_string_lossy().contains(".cc-switch"));
}
#[test]
fn test_system_info() {
let info = get_system_info();
assert!(info.contains("OS:"));
assert!(info.contains("Arch:"));
assert!(info.contains("App Version:"));
}
}
+3
View File
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
@@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
};
Ok(base_dir.join(filename))
+159
View File
@@ -98,6 +98,10 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
/// 模板类型(用于后端判断验证规则)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "templateType")]
pub template_type: Option<String>,
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
@@ -138,6 +142,55 @@ pub struct UsageResult {
pub error: Option<String>,
}
/// 供应商单独的模型测试配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderTestConfig {
/// 是否启用单独配置(false 时使用全局配置)
#[serde(default)]
pub enabled: bool,
/// 测试用的模型名称(覆盖全局配置)
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
pub test_model: Option<String>,
/// 超时时间(秒)
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
/// 测试提示词
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
pub test_prompt: Option<String>,
/// 降级阈值(毫秒)
#[serde(
rename = "degradedThresholdMs",
skip_serializing_if = "Option::is_none"
)]
pub degraded_threshold_ms: Option<u64>,
/// 最大重试次数
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
}
/// 供应商单独的代理配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderProxyConfig {
/// 是否启用单独配置(false 时使用全局/系统代理)
#[serde(default)]
pub enabled: bool,
/// 代理类型:http, https, socks5
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
pub proxy_type: Option<String>,
/// 代理主机
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
pub proxy_host: Option<String>,
/// 代理端口
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
pub proxy_port: Option<u16>,
/// 代理用户名(可选)
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
pub proxy_username: Option<String>,
/// 代理密码(可选)
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
pub proxy_password: Option<String>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -147,6 +200,9 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
@@ -165,6 +221,12 @@ pub struct ProviderMeta {
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
/// 供应商单独的模型测试配置
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
pub test_config: Option<ProviderTestConfig>,
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
}
impl ProviderManager {
@@ -455,3 +517,100 @@ requires_openai_auth = true"#
})
}
}
// ============================================================================
// OpenCode 供应商配置结构
// ============================================================================
/// OpenCode 供应商的 settings_config 结构
///
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
/// 配置示例:
/// ```json
/// {
/// "npm": "@ai-sdk/openai-compatible",
/// "options": { "baseURL": "https://api.example.com/v1", "apiKey": "sk-xxx" },
/// "models": { "gpt-4o": { "name": "GPT-4o" } }
/// }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeProviderConfig {
/// AI SDK 包名,如 "@ai-sdk/openai-compatible", "@ai-sdk/anthropic"
pub npm: String,
/// 供应商名称(可选,用于显示)
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 供应商选项(API 密钥、基础 URL 等)
#[serde(default)]
pub options: OpenCodeProviderOptions,
/// 模型定义映射
#[serde(default)]
pub models: HashMap<String, OpenCodeModel>,
}
impl Default for OpenCodeProviderConfig {
fn default() -> Self {
Self {
npm: "@ai-sdk/openai-compatible".to_string(),
name: None,
options: OpenCodeProviderOptions::default(),
models: HashMap::new(),
}
}
}
/// OpenCode 供应商选项
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenCodeProviderOptions {
/// API 基础 URL
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// API 密钥(支持环境变量引用,如 "{env:API_KEY}"
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// 自定义请求头
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
/// 额外选项(timeout, setCacheKey 等)
/// 使用 flatten 捕获所有未明确定义的字段
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
/// OpenCode 模型定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeModel {
/// 模型显示名称
pub name: String,
/// 模型限制(上下文和输出 token 数)
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<OpenCodeModelLimit>,
/// 模型额外选项(provider 路由等)
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<HashMap<String, Value>>,
/// 额外字段(cost、modalities、thinking、variants 等)
/// 使用 flatten 捕获所有未明确定义的字段
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
/// OpenCode 模型限制
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenCodeModelLimit {
/// 上下文 token 限制
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<u64>,
/// 输出 token 限制
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
}
-6
View File
@@ -68,12 +68,6 @@ pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
}
/// 递归过滤实现
#[cfg(test)]
fn filter_recursive(value: Value, removed_keys: &mut Vec<String>) -> Value {
filter_recursive_with_whitelist(value, removed_keys, &HashSet::new())
}
/// 递归过滤实现(支持白名单)
fn filter_recursive_with_whitelist(
value: Value,
+31 -55
View File
@@ -2,6 +2,7 @@
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
use super::log_codes::cb as log_cb;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
@@ -49,10 +50,10 @@ pub struct CircuitBreakerConfig {
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
failure_threshold: 4,
success_threshold: 2,
timeout_seconds: 60,
error_rate_threshold: 0.5,
error_rate_threshold: 0.6,
min_requests: 10,
}
}
@@ -106,7 +107,6 @@ impl CircuitBreaker {
/// 更新熔断器配置(热更新,不重置状态)
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write().await = new_config;
log::debug!("Circuit breaker config updated");
}
/// 判断当前 Provider 是否“可被纳入候选链路”
@@ -128,7 +128,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
"[{}] 熔断器 Open HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
);
self.transition_to_half_open().await;
return true;
@@ -155,7 +156,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
"[{}] 熔断器 Open HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
);
self.transition_to_half_open().await;
@@ -197,25 +199,17 @@ impl CircuitBreaker {
self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst);
match state {
CircuitState::HalfOpen => {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
successes,
config.success_threshold
);
if state == CircuitState::HalfOpen {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
self.transition_to_closed().await;
}
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
log::info!(
"[{}] 熔断器 HalfOpen → Closed (恢复正常)",
log_cb::HALF_OPEN_TO_CLOSED
);
self.transition_to_closed().await;
}
CircuitState::Closed => {
log::debug!("Circuit breaker Closed: request succeeded");
}
_ => {}
}
}
@@ -236,18 +230,14 @@ impl CircuitBreaker {
// 重置成功计数
self.consecutive_successes.store(0, Ordering::SeqCst);
log::debug!(
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
state,
failures,
config.failure_threshold
);
// 检查是否应该打开熔断器
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
log::warn!(
"[{}] 熔断器 HalfOpen 探测失败 → Open",
log_cb::HALF_OPEN_PROBE_FAILED
);
drop(config);
self.transition_to_open().await;
}
@@ -255,9 +245,8 @@ impl CircuitBreaker {
// 检查连续失败次数
if failures >= config.failure_threshold {
log::warn!(
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
failures,
config.failure_threshold
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
log_cb::TRIGGERED_FAILURES
);
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
@@ -268,18 +257,12 @@ impl CircuitBreaker {
if total >= config.min_requests {
let error_rate = failed as f64 / total as f64;
log::debug!(
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
error_rate * 100.0,
failed,
total
);
if error_rate >= config.error_rate_threshold {
log::warn!(
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
error_rate * 100.0,
config.error_rate_threshold * 100.0
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
log_cb::TRIGGERED_ERROR_RATE,
error_rate * 100.0
);
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
@@ -312,22 +295,16 @@ impl CircuitBreaker {
/// 重置熔断器(手动恢复)
#[allow(dead_code)]
pub async fn reset(&self) {
log::info!("Circuit breaker manually reset to Closed state");
log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
self.transition_to_closed().await;
}
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult {
allowed: true,
used_half_open_permit: true,
@@ -335,9 +312,6 @@ impl CircuitBreaker {
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult {
allowed: false,
used_half_open_permit: false,
@@ -345,12 +319,14 @@ impl CircuitBreaker {
}
}
fn release_half_open_permit(&self) {
/// 仅释放 HalfOpen permit,不影响健康统计
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return;
}
+12
View File
@@ -17,6 +17,12 @@ pub enum ProxyError {
#[error("地址绑定失败: {0}")]
BindFailed(String),
#[error("停止超时")]
StopTimeout,
#[error("停止失败: {0}")]
StopFailed(String),
#[error("请求转发失败: {0}")]
ForwardFailed(String),
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopTimeout => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
+5 -7
View File
@@ -86,17 +86,17 @@ impl FailoverSwitchManager {
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.enabled,
Err(e) => {
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
return Ok(false);
}
};
if !app_enabled {
log::info!("[Failover] {app_type} 未被代理接管(enabled=false,跳过切换");
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
return Ok(false);
}
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
log::info!("[FO-001] 切换: {app_type} {provider_name}");
// 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?;
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
.update_live_backup_from_provider(app_type, &provider)
.await
{
log::warn!("[Failover] 更新 Live 备份失败: {e}");
log::warn!("[FO-003] Live 备份更新失败: {e}");
}
}
@@ -138,12 +138,10 @@ impl FailoverSwitchManager {
"source": "failover" // 标识来源是故障转移
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("[Failover] 发射供应商切换事件失败: {e}");
log::error!("[Failover] 发射事件失败: {e}");
}
}
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
Ok(true)
}
}
+293 -256
View File
@@ -7,36 +7,31 @@ use super::{
error::*,
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter},
types::ProxyStatus,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::{app_config::AppType, provider::Provider};
use reqwest::{Client, Response};
use reqwest::Response;
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
///
/// 参考 Claude Code Hub 设计,过滤以下类别:
/// 1. 认证类(会被覆盖)
/// 2. 连接类(由 HTTP 客户端管理)
/// 3. 代理转发类
/// 4. CDN/云服务商特定头
/// 5. 请求追踪类
/// 6. 浏览器特定头(可能被上游检测)
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
/// 参考成功透传的请求,保留更多原始 header
///
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖)
"authorization",
"x-api-key",
// 连接类
"x-goog-api-key",
// 连接类(由 HTTP 客户端管理)
"host",
"content-length",
"connection",
"transfer-encoding",
// 编码类(会被覆盖为 identity)
"accept-encoding",
@@ -68,16 +63,9 @@ const HEADER_BLACKLIST: &[&str] = &[
"x-b3-sampled",
"traceparent",
"tracestate",
// 浏览器特定头(可能被上游检测为非 CLI 请求)
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-dest",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-language",
// anthropic-beta 单独处理,避免重复
// anthropic 特定头单独处理,避免重复
"anthropic-beta",
"anthropic-version",
// 客户端 IP 单独处理(默认透传)
"x-forwarded-for",
"x-real-ip",
@@ -94,7 +82,6 @@ pub struct ForwardError {
}
pub struct RequestForwarder {
client: Client,
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
@@ -105,6 +92,10 @@ pub struct RequestForwarder {
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
}
impl RequestForwarder {
@@ -119,32 +110,17 @@ impl RequestForwarder {
current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let mut client_builder = Client::builder();
if non_streaming_timeout > 0 {
// 使用配置的非流式超时
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
} else {
// 禁用超时时使用全局超时作为保底
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
}
let client = client_builder
.build()
.expect("Failed to create HTTP client");
Self {
client,
router,
status,
current_providers,
failover_manager,
app_handle,
current_provider_id_at_start,
rectifier_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
}
}
@@ -160,7 +136,7 @@ impl RequestForwarder {
&self,
app_type: &AppType,
endpoint: &str,
body: Value,
mut body: Value,
headers: axum::http::HeaderMap,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
@@ -175,16 +151,13 @@ impl RequestForwarder {
});
}
log::info!(
"[{}] 故障转移链: {} 个可用供应商",
app_type_str,
providers.len()
);
let mut last_error = None;
let mut last_provider = None;
let mut attempted_providers = 0usize;
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
@@ -203,25 +176,11 @@ impl RequestForwarder {
};
if !allowed {
log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str,
provider.name
);
continue;
}
attempted_providers += 1;
log::info!(
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
app_type_str,
attempted_providers,
providers.len(),
provider.name,
provider.sort_index.unwrap_or(999999)
);
// 更新状态中的当前Provider信息
{
let mut status = self.status.write().await;
@@ -231,18 +190,14 @@ impl RequestForwarder {
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
}
let start = Instant::now();
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
let latency = start.elapsed().as_millis() as u64;
// 成功:记录成功并更新熔断器
if let Err(e) = self
let _ = self
.router
.record_result(
&provider.id,
@@ -251,10 +206,7 @@ impl RequestForwarder {
true,
None,
)
.await
{
log::warn!("Failed to record success: {e}");
}
.await;
// 更新当前应用类型使用的 provider
{
@@ -274,12 +226,6 @@ impl RequestForwarder {
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
status.failover_count += 1;
log::info!(
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
app_type_str,
provider.name,
latency
);
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
let fm = self.failover_manager.clone();
@@ -289,10 +235,7 @@ impl RequestForwarder {
let at = app_type_str.to_string();
tokio::spawn(async move {
if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await
{
log::error!("[Failover] 切换供应商失败: {e}");
}
let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;
});
}
// 重新计算成功率
@@ -303,23 +246,213 @@ impl RequestForwarder {
}
}
log::info!(
"[{}] 请求成功 - Provider: {} - {}ms",
app_type_str,
provider.name,
latency
);
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(e) => {
let latency = start.elapsed().as_millis() as u64;
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
let is_anthropic_provider = matches!(
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_signature(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if rectifier_retried {
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:直接返回错误(不可重试客户端错误)
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
}
ProxyError::UpstreamError { status, .. } => *status >= 500,
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
// 失败:记录失败并更新熔断器
if let Err(record_err) = self
let _ = self
.router
.record_result(
&provider.id,
@@ -328,10 +461,7 @@ impl RequestForwarder {
false,
Some(e.to_string()),
)
.await
{
log::warn!("Failed to record failure: {record_err}");
}
.await;
// 分类错误
let category = self.categorize_proxy_error(&e);
@@ -346,11 +476,11 @@ impl RequestForwarder {
}
log::warn!(
"[{}] Provider {} 失败(可重试): {} - {}ms",
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
app_type_str,
provider.name,
e,
latency
attempted_providers,
providers.len()
);
last_error = Some(e);
@@ -370,12 +500,6 @@ impl RequestForwarder {
* 100.0;
}
}
log::error!(
"[{}] Provider {} 失败(不可重试): {}",
app_type_str,
provider.name,
e
);
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
@@ -414,11 +538,7 @@ impl RequestForwarder {
}
}
log::error!(
"[{}] 所有 {} 个供应商都失败了",
app_type_str,
providers.len()
);
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
@@ -437,7 +557,6 @@ impl RequestForwarder {
) -> Result<Response, ProxyError> {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
log::info!("[{}] base_url: {}", adapter.name(), base_url);
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
@@ -452,36 +571,13 @@ impl RequestForwarder {
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
// 记录原始请求 JSON
log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, mapped_model) =
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
if let Some(ref mapped) = mapped_model {
log::info!(
"[{}] >>> 模型映射后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
);
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
}
// 转换请求体(如果需要)
let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(mapped_body, provider)?;
log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&transformed).unwrap_or_default()
);
transformed
adapter.transform_request(mapped_body, provider)?
} else {
mapped_body
};
@@ -490,157 +586,97 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// ========== 请求体日志(截断显示) ==========
let body_str = serde_json::to_string_pretty(&filtered_body)
.unwrap_or_else(|_| filtered_body.to_string());
let body_preview = if body_str.len() > 2000 {
format!(
"{}...\n[截断,总长度: {} 字符]",
&body_str[..2000],
body_str.len()
)
} else {
body_str
};
log::info!(
"[{}] ====== 最终请求体 ======\n{}",
adapter.name(),
body_preview
);
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = super::http_client::get_for_provider(proxy_config);
let mut request = client.post(&url);
log::info!(
"[{}] 转发请求: {} -> {}",
adapter.name(),
provider.name,
url
);
// 构建请求
let mut request = self.client.post(&url);
// ========== 详细 Headers 日志 ==========
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
for (key, value) in headers {
log::info!(
"[{}] {}: {:?}",
adapter.name(),
key.as_str(),
value.to_str().unwrap_or("<binary>")
);
// 只有当 timeout > 0 时才设置请求超时
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
// 过滤黑名单 Headers,保护隐私并避免冲突
let mut filtered_headers: Vec<String> = Vec::new();
let mut passed_headers: Vec<(String, String)> = Vec::new();
for (key, value) in headers {
let key_str = key.as_str().to_lowercase();
if HEADER_BLACKLIST.contains(&key_str.as_str()) {
filtered_headers.push(key_str);
if HEADER_BLACKLIST
.iter()
.any(|h| key.as_str().eq_ignore_ascii_case(h))
{
continue;
}
let value_str = value.to_str().unwrap_or("<binary>").to_string();
passed_headers.push((key.as_str().to_string(), value_str.clone()));
request = request.header(key, value);
}
if !filtered_headers.is_empty() {
log::info!(
"[{}] ====== 被过滤的 Headers ({}) ======",
adapter.name(),
filtered_headers.len()
);
for h in &filtered_headers {
log::info!("[{}] - {}", adapter.name(), h);
}
}
// 处理 anthropic-beta Header(透传)
// 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记
if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
request = request.header("anthropic-beta", beta_str);
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string()));
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str);
}
// 处理 anthropic-beta Header(仅 Claude
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
if adapter.name() == "Claude" {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
// 检查是否已包含 claude-code-20250219
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
// 补充 claude-code-20250219
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
// 如果客户端没有发送,使用默认值
CLAUDE_CODE_BETA.to_string()
};
request = request.header("anthropic-beta", &beta_value);
}
// 客户端 IP 透传(默认开启)
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
request = request.header("x-forwarded-for", xff_str);
passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string()));
log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str);
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
request = request.header("x-real-ip", real_ip_str);
passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string()));
log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str);
}
}
// 禁用压缩,避免 gzip 流式响应解析错误
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
request = request.header("accept-encoding", "identity");
passed_headers.push(("accept-encoding".to_string(), "identity".to_string()));
// 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) {
log::debug!(
"[{}] 使用认证: {:?} (key: {})",
adapter.name(),
auth.strategy,
auth.masked_key()
);
request = adapter.add_auth_headers(request, &auth);
// 记录认证头(脱敏)
passed_headers.push((
"authorization".to_string(),
format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]),
));
passed_headers.push((
"x-api-key".to_string(),
format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]),
));
} else {
log::error!(
"[{}] 未找到 API KeyProvider: {}",
adapter.name(),
provider.name
}
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
// 注意:只设置一次,避免重复
if adapter.name() == "Claude" {
let version_str = headers
.get("anthropic-version")
.and_then(|v| v.to_str().ok())
.unwrap_or("2023-06-01");
request = request.header("anthropic-version", version_str);
}
// 输出请求信息日志
let tag = adapter.name();
log::debug!("[{tag}] >>> 请求 URL: {url}");
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
}
// anthropic-version 透传:优先使用客户端的版本号
// 参考 Claude Code Hub:透传客户端值而非固定版本
if let Some(version) = headers.get("anthropic-version") {
if let Ok(version_str) = version.to_str() {
// 覆盖适配器设置的默认版本
request = request.header("anthropic-version", version_str);
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
log::info!(
"[{}] 透传 anthropic-version: {}",
adapter.name(),
version_str
);
}
}
// ========== 最终发送的 Headers 日志 ==========
log::info!(
"[{}] ====== 最终发送的 Headers ({}) ======",
adapter.name(),
passed_headers.len()
);
for (k, v) in &passed_headers {
log::info!("[{}] {}: {}", adapter.name(), k, v);
}
// 发送请求
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
let response = request.json(&filtered_body).send().await.map_err(|e| {
log::error!("[{}] 请求失败: {}", adapter.name(), e);
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
@@ -652,19 +688,12 @@ impl RequestForwarder {
// 检查响应状态
let status = response.status();
log::info!("[{}] 响应状态: {}", adapter.name(), status);
if status.is_success() {
Ok(response)
} else {
let status_code = status.as_u16();
let body_text = response.text().await.ok();
log::error!(
"[{}] 上游错误 ({}): {:?}",
adapter.name(),
status_code,
body_text
);
Err(ProxyError::UpstreamError {
status: status_code,
@@ -695,3 +724,11 @@ impl RequestForwarder {
}
}
}
/// 从 ProxyError 中提取错误消息
fn extract_error_message(error: &ProxyError) -> Option<String> {
match error {
ProxyError::UpstreamError { body, .. } => body.clone(),
_ => Some(error.to_string()),
}
}
+13 -4
View File
@@ -5,7 +5,10 @@
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
extract_session_id,
forwarder::RequestForwarder,
server::ProxyState,
types::{AppProxyConfig, RectifierConfig},
ProxyError,
};
use axum::http::HeaderMap;
@@ -54,6 +57,8 @@ pub struct RequestContext {
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
/// 整流器配置
pub rectifier_config: RectifierConfig,
}
impl RequestContext {
@@ -86,6 +91,9 @@ impl RequestContext {
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 从数据库读取整流器配置
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
@@ -127,7 +135,7 @@ impl RequestContext {
.cloned()
.ok_or(ProxyError::NoAvailableProvider)?;
log::info!(
log::debug!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag,
provider.name,
@@ -147,6 +155,7 @@ impl RequestContext {
app_type_str,
app_type,
session_id,
rectifier_config,
})
}
@@ -168,7 +177,6 @@ impl RequestContext {
.unwrap_or("unknown")
.to_string();
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
self
}
@@ -190,7 +198,7 @@ impl RequestContext {
)
} else {
// 故障转移关闭:不启用超时配置
log::info!(
log::debug!(
"[{}] Failover disabled, timeout configs are bypassed",
self.tag
);
@@ -207,6 +215,7 @@ impl RequestContext {
self.current_provider_id.clone(),
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),
)
}
+11 -55
View File
@@ -98,16 +98,6 @@ pub async fn handle_messages(
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
let status = response.status();
log::info!("[Claude] 上游响应状态: {status}");
// Claude 特有:格式转换处理
if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
@@ -131,8 +121,6 @@ async fn handle_claude_transform(
if is_stream {
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream);
@@ -196,13 +184,10 @@ async fn handle_claude_transform(
);
let body = axum::body::Body::from_stream(logged_stream);
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
return Ok((headers, body).into_response());
}
// 非流式响应转换 (OpenAI → Anthropic)
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
let response_headers = response.headers().clone();
let body_bytes = response.bytes().await.map_err(|e| {
@@ -211,31 +196,17 @@ async fn handle_claude_transform(
})?;
let body_str = String::from_utf8_lossy(&body_bytes);
log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len());
log::debug!("[Claude] OpenAI 原始响应: {body_str}");
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
})?;
log::info!("[Claude] 解析 OpenAI 响应成功");
log::info!(
"[Claude] <<< OpenAI 响应 JSON:\n{}",
serde_json::to_string_pretty(&openai_response).unwrap_or_default()
);
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
log::info!("[Claude] 转换响应成功");
log::info!(
"[Claude] <<< Anthropic 响应 JSON:\n{}",
serde_json::to_string_pretty(&anthropic_response).unwrap_or_default()
);
// 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
let model = anthropic_response
@@ -265,8 +236,6 @@ async fn handle_claude_transform(
});
}
log::info!("[Claude] ====== 请求结束 ======");
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
@@ -285,13 +254,11 @@ async fn handle_claude_transform(
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
})?;
log::info!(
"[Claude] 返回转换后的响应, 长度: {} bytes",
response_body.len()
);
let body = axum::body::Body::from(response_body);
Ok(builder.body(body).unwrap())
builder.body(body).map_err(|e| {
log::error!("[Claude] 构建响应失败: {e}");
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
// ============================================================================
@@ -304,8 +271,6 @@ pub async fn handle_chat_completions(
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
@@ -314,12 +279,6 @@ pub async fn handle_chat_completions(
.and_then(|v| v.as_bool())
.unwrap_or(false);
log::info!(
"[Codex] 请求模型: {}, 流式: {}",
ctx.request_model,
is_stream
);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
@@ -344,8 +303,6 @@ pub async fn handle_chat_completions(
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
}
@@ -387,8 +344,6 @@ pub async fn handle_responses(
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
}
@@ -414,8 +369,6 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
@@ -445,8 +398,6 @@ pub async fn handle_gemini(
ctx.provider = result.provider;
let response = result.response;
log::info!("[Gemini] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
}
@@ -505,7 +456,12 @@ async fn log_usage(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
@@ -532,6 +488,6 @@ async fn log_usage(
None, // provider_type
is_streaming,
) {
log::warn!("记录使用量失败: {e}");
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
+484
View File
@@ -0,0 +1,484 @@
//! 全局 HTTP 客户端模块
//!
//! 提供支持全局代理配置的 HTTP 客户端。
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
use crate::provider::ProviderProxyConfig;
use once_cell::sync::OnceCell;
use reqwest::Client;
use std::env;
use std::net::IpAddr;
use std::sync::RwLock;
use std::time::Duration;
/// 全局 HTTP 客户端实例
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
/// 当前代理 URL(用于日志和状态查询)
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
/// 初始化全局 HTTP 客户端
///
/// 应在应用启动时调用一次。
///
/// # Arguments
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
/// 传入 None 或空字符串表示直连
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let client = build_client(effective_url)?;
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
log::warn!(
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
// 已初始化,改用 apply_proxy 更新
return apply_proxy(proxy_url);
}
// 初始化代理 URL 记录
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
log::info!(
"[GlobalProxy] Initialized: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 验证代理配置(不应用)
///
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
/// 用于在持久化之前验证配置的有效性。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
///
/// # Returns
/// 验证成功返回 Ok(()),失败返回错误信息
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
// 只调用 build_client 来验证,但不应用
build_client(effective_url)?;
Ok(())
}
/// 应用代理配置(假设已验证)
///
/// 直接应用代理配置到全局客户端,不做额外验证。
/// 应在 validate_proxy 成功后调用。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Applied: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 更新代理配置(热更新)
///
/// 可在运行时调用以更改代理设置,无需重启应用。
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
/// 请使用 validate_proxy + apply_proxy 组合。
///
/// # Arguments
/// * `proxy_url` - 新的代理 URLNone 或空字符串表示直连
#[allow(dead_code)]
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Updated: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 获取全局 HTTP 客户端
///
/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。
pub fn get() -> Client {
GLOBAL_CLIENT
.get()
.and_then(|lock| lock.read().ok())
.map(|c| c.clone())
.unwrap_or_else(|| {
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
build_client(None).unwrap_or_default()
})
}
/// 获取当前代理 URL
///
/// 返回当前配置的代理 URL,None 表示直连。
pub fn get_current_proxy_url() -> Option<String> {
CURRENT_PROXY_URL
.get()
.and_then(|lock| lock.read().ok())
.and_then(|url| url.clone())
}
/// 检查是否正在使用代理
#[allow(dead_code)]
pub fn is_proxy_enabled() -> bool {
get_current_proxy_url().is_some()
}
/// 构建 HTTP 客户端
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60));
// 有代理地址则使用代理,否则跟随系统代理
if let Some(url) = proxy_url {
// 先验证 URL 格式和 scheme
let parsed = url::Url::parse(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
let scheme = parsed.scheme();
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
return Err(format!(
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
scheme,
mask_url(url)
));
}
let proxy = reqwest::Proxy::all(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
builder = builder.proxy(proxy);
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
} else {
// 未设置全局代理时,让 reqwest 自动检测系统代理(环境变量)
// 若系统代理指向本机,禁用系统代理避免自环
if system_proxy_points_to_loopback() {
builder = builder.no_proxy();
log::warn!(
"[GlobalProxy] System proxy points to localhost, bypassing to avoid recursion"
);
} else {
log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)");
}
}
builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
fn system_proxy_points_to_loopback() -> bool {
const KEYS: [&str; 6] = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
];
KEYS.iter()
.filter_map(|key| env::var(key).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.any(|value| proxy_points_to_loopback(&value))
}
fn proxy_points_to_loopback(value: &str) -> bool {
fn host_is_loopback(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
host.parse::<IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
if let Ok(parsed) = url::Url::parse(value) {
if let Some(host) = parsed.host_str() {
return host_is_loopback(host);
}
return false;
}
let with_scheme = format!("http://{value}");
if let Ok(parsed) = url::Url::parse(&with_scheme) {
if let Some(host) = parsed.host_str() {
return host_is_loopback(host);
}
}
false
}
/// 隐藏 URL 中的敏感信息(用于日志)
pub fn mask_url(url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
// 隐藏用户名和密码,保留 scheme、host 和端口
let host = parsed.host_str().unwrap_or("?");
match parsed.port() {
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
None => format!("{}://{}", parsed.scheme(), host),
}
} else {
// URL 解析失败,返回部分内容
if url.len() > 20 {
format!("{}...", &url[..20])
} else {
url.to_string()
}
}
}
/// 根据供应商单独代理配置构建代理 URL
///
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
let host = config.proxy_host.as_deref()?;
let port = config.proxy_port?;
// 构建带认证的代理 URL
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
if !username.is_empty() && !password.is_empty() {
return Some(format!(
"{proxy_type}://{username}:{password}@{host}:{port}"
));
}
}
Some(format!("{proxy_type}://{host}:{port}"))
}
/// 根据供应商单独代理配置构建 HTTP 客户端
///
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
/// 否则返回 None,调用方应使用全局客户端。
///
/// # Arguments
/// * `proxy_config` - 供应商的代理配置
///
/// # Returns
/// 如果配置有效则返回 Some(Client),否则返回 None
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
let config = proxy_config.filter(|c| c.enabled)?;
let proxy_url = build_proxy_url_from_config(config)?;
log::debug!(
"[ProviderProxy] Building client with proxy: {}",
mask_url(&proxy_url)
);
// 构建带代理的客户端
let proxy = match reqwest::Proxy::all(&proxy_url) {
Ok(p) => p,
Err(e) => {
log::error!(
"[ProviderProxy] Failed to create proxy from '{}': {}",
mask_url(&proxy_url),
e
);
return None;
}
};
match Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.proxy(proxy)
.build()
{
Ok(client) => {
log::info!(
"[ProviderProxy] Client built with proxy: {}",
mask_url(&proxy_url)
);
Some(client)
}
Err(e) => {
log::error!("[ProviderProxy] Failed to build client: {e}");
None
}
}
}
/// 获取供应商专用的 HTTP 客户端
///
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
///
/// # Arguments
/// * `proxy_config` - 供应商的代理配置
///
/// # Returns
/// 返回适合该供应商的 HTTP 客户端
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
// 优先使用供应商单独代理
if let Some(client) = build_client_for_provider(proxy_config) {
return client;
}
// 回退到全局客户端
get()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn test_mask_url() {
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
assert_eq!(
mask_url("http://user:pass@127.0.0.1:7890"),
"http://127.0.0.1:7890"
);
assert_eq!(
mask_url("socks5://admin:secret@proxy.example.com:1080"),
"socks5://proxy.example.com:1080"
);
// 无端口的 URL 不应显示 ":?"
assert_eq!(
mask_url("http://proxy.example.com"),
"http://proxy.example.com"
);
assert_eq!(
mask_url("https://user:pass@proxy.example.com"),
"https://proxy.example.com"
);
}
#[test]
fn test_build_client_direct() {
let result = build_client(None);
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_http_proxy() {
let result = build_client(Some("http://127.0.0.1:7890"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_socks5_proxy() {
let result = build_client(Some("socks5://127.0.0.1:1080"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_invalid_url() {
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
// 使用明确无效的 scheme 来触发错误
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
assert!(result.is_err(), "Should reject invalid proxy scheme");
}
#[test]
fn test_proxy_points_to_loopback() {
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
}
#[test]
fn test_system_proxy_points_to_loopback() {
let _guard = env_lock().lock().unwrap();
let keys = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
];
for key in &keys {
std::env::remove_var(key);
}
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
assert!(system_proxy_points_to_loopback());
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
assert!(!system_proxy_points_to_loopback());
for key in &keys {
std::env::remove_var(key);
}
}
}
+59
View File
@@ -0,0 +1,59 @@
//! 代理模块日志错误码定义
//!
//! 格式: [模块-编号] 消息
//! - CB: Circuit Breaker (熔断器)
//! - SRV: Server (服务器)
//! - FWD: Forwarder (转发器)
//! - FO: Failover (故障转移)
//! - RSP: Response (响应处理)
//! - USG: Usage (使用量)
#![allow(dead_code)]
/// 熔断器日志码
pub mod cb {
pub const OPEN_TO_HALF_OPEN: &str = "CB-001";
pub const HALF_OPEN_TO_CLOSED: &str = "CB-002";
pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003";
pub const TRIGGERED_FAILURES: &str = "CB-004";
pub const TRIGGERED_ERROR_RATE: &str = "CB-005";
pub const MANUAL_RESET: &str = "CB-006";
}
/// 服务器日志码
pub mod srv {
pub const STARTED: &str = "SRV-001";
pub const STOPPED: &str = "SRV-002";
pub const STOP_TIMEOUT: &str = "SRV-003";
pub const TASK_ERROR: &str = "SRV-004";
}
/// 转发器日志码
pub mod fwd {
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
}
/// 故障转移日志码
pub mod fo {
pub const SWITCH_SUCCESS: &str = "FO-001";
pub const CONFIG_READ_ERROR: &str = "FO-002";
pub const LIVE_BACKUP_ERROR: &str = "FO-003";
pub const ALL_CIRCUIT_OPEN: &str = "FO-004";
pub const NO_PROVIDERS: &str = "FO-005";
}
/// 响应处理日志码
pub mod rsp {
pub const BUILD_STREAM_ERROR: &str = "RSP-001";
pub const READ_BODY_ERROR: &str = "RSP-002";
pub const BUILD_RESPONSE_ERROR: &str = "RSP-003";
pub const STREAM_TIMEOUT: &str = "RSP-004";
pub const STREAM_ERROR: &str = "RSP-005";
}
/// 使用量日志码
pub mod usg {
pub const LOG_FAILED: &str = "USG-001";
pub const PRICING_NOT_FOUND: &str = "USG-002";
}
+3
View File
@@ -12,6 +12,8 @@ pub mod handler_config;
pub mod handler_context;
mod handlers;
mod health;
pub mod http_client;
pub mod log_codes;
pub mod model_mapper;
pub mod provider_router;
pub mod providers;
@@ -19,6 +21,7 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod usage;
+48 -1
View File
@@ -54,6 +54,7 @@ impl ModelMapping {
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.default_model.is_some()
|| self.reasoning_model.is_some()
}
/// 根据原始模型名称获取映射后的模型
@@ -126,7 +127,7 @@ pub fn apply_model_mapping(
let mapped = mapping.map_model(original, has_thinking);
if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped));
}
@@ -182,6 +183,27 @@ mod tests {
}
}
fn create_provider_with_reasoning_only() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_REASONING_MODEL": "reasoning-only-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_sonnet_mapping() {
let provider = create_provider_with_mapping();
@@ -222,6 +244,31 @@ mod tests {
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_reasoning_only_mapping_in_thinking_mode() {
let provider = create_provider_with_reasoning_only();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-only-model");
assert_eq!(mapped, Some("reasoning-only-model".to_string()));
}
#[test]
fn test_reasoning_only_mapping_does_not_affect_non_thinking() {
let provider = create_provider_with_reasoning_only();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_thinking_disabled() {
let provider = create_provider_with_mapping();
+197 -111
View File
@@ -2,11 +2,13 @@
//!
//! 负责选择和管理代理目标供应商,实现智能故障转移
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -31,7 +33,7 @@ impl ProviderRouter {
///
/// 返回按优先级排序的可用供应商列表:
/// - 故障转移关闭时:仅返回当前供应商
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
/// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...)
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new();
let mut total_providers = 0usize;
@@ -39,102 +41,70 @@ impl ProviderRouter {
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => {
let enabled = config.auto_failover_enabled;
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
enabled
}
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
false
}
};
if auto_failover_enabled {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len();
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
log::info!(
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
);
// 故障转移开启:仅按队列顺序依次尝试(P1 → P2 → ...)
let all_providers = self.db.get_all_providers(app_type)?;
for provider in failover_providers {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id);
// 使用 DAO 返回的排序结果,确保和前端展示一致
let ordered_ids: Vec<String> = self
.db
.get_failover_queue(app_type)?
.into_iter()
.map(|item| item.provider_id)
.collect();
total_providers = ordered_ids.len();
for provider_id in ordered_ids {
let Some(provider) = all_providers.get(&provider_id).cloned() else {
continue;
};
let circuit_key = format!("{app_type}:{}", provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
let state = breaker.get_state().await;
if breaker.is_available().await {
log::debug!(
"[{}] Queue provider available: {} ({}) (state: {:?})",
app_type,
provider.name,
provider.id,
state
);
log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type,
provider.name,
provider.id,
provider.sort_index
);
result.push(provider);
} else {
circuit_open_count += 1;
log::debug!(
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
app_type,
provider.name,
state
);
}
}
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
let current_id = AppType::from_str(app_type)
.ok()
.and_then(|app_enum| {
crate::settings::get_effective_current_provider(&self.db, &app_enum)
.ok()
.flatten()
})
.or_else(|| self.db.get_current_provider(app_type).ok().flatten());
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current_id) = current_id {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
log::info!(
"[{}] Current provider: {} ({})",
app_type,
current.name,
current.id
);
total_providers = 1;
result.push(current);
} else {
log::debug!(
"[{app_type}] Current provider id {current_id} not found in database"
);
}
} else {
log::debug!("[{app_type}] No current provider configured");
}
}
if result.is_empty() {
// 区分两种情况:全部熔断 vs 未配置供应商
if total_providers > 0 && circuit_open_count == total_providers {
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
return Err(AppError::AllProvidersCircuitOpen);
} else {
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
log::warn!("[{app_type}] [FO-005] 未配置供应商");
return Err(AppError::NoProvidersConfigured);
}
}
log::info!(
"[{}] Provider chain: {} provider(s) available",
app_type,
result.len()
);
Ok(result)
}
@@ -161,15 +131,10 @@ impl ProviderRouter {
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
// 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold,
Err(e) => {
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
Err(_) => 5, // 默认值
};
// 2. 更新熔断器状态
@@ -178,14 +143,8 @@ impl ProviderRouter {
if success {
breaker.record_success(used_half_open_permit).await;
log::debug!("Provider {provider_id} request succeeded");
} else {
breaker.record_failure(used_half_open_permit).await;
log::warn!(
"Provider {} request failed: {}",
provider_id,
error_msg.as_deref().unwrap_or("Unknown error")
);
}
// 3. 更新数据库健康状态(使用配置的阈值)
@@ -206,7 +165,6 @@ impl ProviderRouter {
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(circuit_key) {
log::info!("Manually resetting circuit breaker for {circuit_key}");
breaker.reset().await;
}
}
@@ -217,19 +175,30 @@ impl ProviderRouter {
self.reset_circuit_breaker(&circuit_key).await;
}
/// 更新所有熔断器的配置(热更新
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口
///
/// 当用户在 UI 中修改熔断器配置后调用此方法
/// 所有现有的熔断器会立即使用新配置
/// 用于整流器等场景:请求结果不应计入 Provider 健康度
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub async fn release_permit_neutral(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if !used_half_open_permit {
return;
}
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.release_half_open_permit();
}
/// 更新所有熔断器的配置(热更新)
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await;
let count = breakers.len();
for breaker in breakers.values() {
breaker.update_config(config.clone()).await;
}
log::info!("已更新 {count} 个熔断器的配置");
}
/// 获取熔断器状态
@@ -272,32 +241,16 @@ impl ProviderRouter {
// 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => {
log::debug!(
"Loading circuit breaker config for {key} (app={app_type}): \
failure_threshold={}, success_threshold={}, timeout={}s",
app_config.circuit_failure_threshold,
app_config.circuit_success_threshold,
app_config.circuit_timeout_seconds
);
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
},
Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
};
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
let breaker = Arc::new(CircuitBreaker::new(config));
breakers.insert(key.to_string(), breaker.clone());
@@ -310,9 +263,53 @@ mod tests {
use super::*;
use crate::database::Database;
use serde_json::json;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
struct TempHome {
#[allow(dead_code)]
dir: TempDir,
original_home: Option<String>,
original_userprofile: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = TempDir::new().expect("failed to create temp home");
let original_home = env::var("HOME").ok();
let original_userprofile = env::var("USERPROFILE").ok();
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
crate::settings::reload_settings().expect("reload settings");
Self {
dir,
original_home,
original_userprofile,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match &self.original_userprofile {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
}
}
#[tokio::test]
#[serial]
async fn test_provider_router_creation() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
let router = ProviderRouter::new(db);
@@ -321,7 +318,9 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_failover_disabled_uses_current_provider() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
let provider_a =
@@ -342,7 +341,9 @@ mod tests {
}
#[tokio::test]
async fn test_failover_enabled_uses_queue_order() {
#[serial]
async fn test_failover_enabled_uses_queue_order_ignoring_current() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
// 设置 sort_index 来控制顺序:b=1, a=2
@@ -369,13 +370,45 @@ mod tests {
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 按 sort_index 排序:b(1) 在前,a(2) 在后
// 故障转移开启时:仅按队列顺序选择(忽略当前供应商)
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
#[tokio::test]
#[serial]
async fn test_failover_enabled_uses_queue_only_even_if_current_not_in_queue() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let mut provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
db.add_to_failover_queue("claude", "b").unwrap();
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].id, "b");
}
#[tokio::test]
#[serial]
async fn test_select_providers_does_not_consume_half_open_permit() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
db.update_circuit_breaker_config(&CircuitBreakerConfig {
@@ -414,4 +447,57 @@ mod tests {
assert!(router.allow_provider_request("b", "claude").await.allowed);
}
#[tokio::test]
#[serial]
async fn test_release_permit_neutral_frees_half_open_slot() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
// 触发熔断:1 次失败
router
.record_result("a", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
// 第一次请求:获取 HalfOpen 探测名额
let first = router.allow_provider_request("a", "claude").await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
// 第二次请求应被拒绝(名额已被占用)
let second = router.allow_provider_request("a", "claude").await;
assert!(!second.allowed);
// 使用 release_permit_neutral 释放名额(不影响健康统计)
router
.release_permit_neutral("a", "claude", first.used_half_open_permit)
.await;
// 第三次请求应被允许(名额已释放)
let third = router.allow_provider_request("a", "claude").await;
assert!(third.allowed);
assert!(third.used_half_open_permit);
}
}
+39 -8
View File
@@ -38,13 +38,20 @@ impl AuthInfo {
///
/// 显示前4位和后4位,中间用 `...` 代替
/// 如果 key 长度不足8位,则返回 `***`
#[allow(dead_code)]
pub fn masked_key(&self) -> String {
if self.api_key.len() > 8 {
format!(
"{}...{}",
&self.api_key[..4],
&self.api_key[self.api_key.len() - 4..]
)
if self.api_key.chars().count() > 8 {
let prefix: String = self.api_key.chars().take(4).collect();
let suffix: String = self
.api_key
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
} else {
"***".to_string()
}
@@ -54,8 +61,17 @@ impl AuthInfo {
#[allow(dead_code)]
pub fn masked_access_token(&self) -> Option<String> {
self.access_token.as_ref().map(|token| {
if token.len() > 8 {
format!("{}...{}", &token[..4], &token[token.len() - 4..])
if token.chars().count() > 8 {
let prefix: String = token.chars().take(4).collect();
let suffix: String = token
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
} else {
"***".to_string()
}
@@ -126,6 +142,13 @@ mod tests {
assert_eq!(auth.masked_key(), "1234...6789");
}
#[test]
fn test_masked_key_utf8_safe() {
let auth = AuthInfo::new("测试⚠️1234567890".to_string(), AuthStrategy::Bearer);
let masked = auth.masked_key();
assert!(!masked.is_empty());
}
#[test]
fn test_auth_strategy_equality() {
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
@@ -160,6 +183,14 @@ mod tests {
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
}
#[test]
fn test_masked_access_token_utf8_safe() {
let auth =
AuthInfo::with_access_token("refresh".to_string(), "令牌⚠️1234567890".to_string());
let masked = auth.masked_access_token().unwrap();
assert!(!masked.is_empty());
}
#[test]
fn test_masked_access_token_short() {
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
+53 -15
View File
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
_ => true,
// OpenRouter now supports Claude Code compatible API, default to passthrough
_ => false,
}
}
@@ -217,28 +218,37 @@ impl ProviderAdapter for ClaudeAdapter {
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
format!(
let base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
)
);
// 为 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
format!("{base}?beta=true")
} else {
base
}
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
// 这里不再设置 anthropic-version,避免 header 重复
match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
// Anthropic 官方: Authorization Bearer + x-api-key
AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"),
.header("x-api-key", &auth.api_key),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"),
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
// OpenRouter: Bearer
AuthStrategy::Bearer => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"),
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
_ => request,
}
}
@@ -416,15 +426,33 @@ mod tests {
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
}
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
#[test]
fn test_build_url_preserve_existing_query() {
let adapter = ClaudeAdapter::new();
// 已有查询参数时不重复添加
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
@@ -438,12 +466,22 @@ mod tests {
}));
assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(adapter.needs_transform(&openrouter_provider));
assert!(!adapter.needs_transform(&openrouter_provider));
// OpenRouter provider with explicit compat mode enabled should transform
let openrouter_enabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({
"env": {
+8
View File
@@ -132,6 +132,10 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode => {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
}
}
@@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode => {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
}
+3 -13
View File
@@ -75,8 +75,6 @@ pub fn create_anthropic_sse_stream(
let mut current_block_type: Option<String> = None;
let mut tool_call_id = None;
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
@@ -96,25 +94,17 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 记录原始 OpenAI 事件(格式化显示)
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
log::info!(
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
}
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
if message_id.is_none() {
message_id = Some(chunk.id.clone());
+60 -25
View File
@@ -9,9 +9,10 @@ use super::{
usage::parser::TokenUsage,
ProxyError,
};
use axum::response::Response;
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap;
use rust_decimal::Decimal;
use serde_json::Value;
use std::{
@@ -46,9 +47,13 @@ pub async fn handle_streaming(
state: &ProxyState,
parser_config: &UsageParserConfig,
) -> Response {
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
let status = response.status();
log::debug!(
"[{}] 已接收上游流式响应: status={}, headers={}",
ctx.tag,
status.as_u16(),
format_headers(response.headers())
);
let mut builder = axum::response::Response::builder().status(status);
// 复制响应头
@@ -72,7 +77,13 @@ pub async fn handle_streaming(
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
let body = axum::body::Body::from_stream(logged_stream);
builder.body(body).unwrap()
match builder.body(body) {
Ok(resp) => resp,
Err(e) => {
log::error!("[{}] 构建流式响应失败: {e}", ctx.tag);
ProxyError::Internal(format!("Failed to build streaming response: {e}")).into_response()
}
}
}
/// 处理非流式响应
@@ -90,15 +101,22 @@ pub async fn handle_non_streaming(
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
})?;
log::debug!(
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
ctx.tag,
status.as_u16(),
body_bytes.len(),
format_headers(&response_headers)
);
log::debug!(
"[{}] 上游响应体内容: {}",
ctx.tag,
String::from_utf8_lossy(&body_bytes)
);
// 解析并记录使用量
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
log::info!(
"[{}] <<< 响应 JSON:\n{}",
ctx.tag,
serde_json::to_string_pretty(&json_value).unwrap_or_default()
);
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
@@ -131,7 +149,7 @@ pub async fn handle_non_streaming(
);
}
} else {
log::info!(
log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag,
body_bytes.len()
@@ -146,8 +164,6 @@ pub async fn handle_non_streaming(
);
}
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
@@ -155,7 +171,10 @@ pub async fn handle_non_streaming(
}
let body = axum::body::Body::from(body_bytes);
Ok(builder.body(body).unwrap())
builder.body(body).map_err(|e| {
log::error!("[{}] 构建响应失败: {e}", ctx.tag);
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
/// 通用响应处理入口
@@ -373,7 +392,12 @@ async fn log_usage_internal(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
@@ -409,7 +433,7 @@ async fn log_usage_internal(
None, // provider_type
is_streaming,
) {
log::warn!("记录使用量失败: {e}");
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
@@ -466,6 +490,12 @@ pub fn create_logged_passthrough_stream(
match chunk_result {
Some(Ok(bytes)) => {
if is_first_chunk {
log::debug!(
"[{tag}] 已接收上游流式首包: bytes={}",
bytes.len()
);
}
is_first_chunk = false;
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
@@ -484,16 +514,12 @@ pub fn create_logged_passthrough_stream(
if let Some(c) = &collector {
c.push(json_value.clone()).await;
}
log::info!(
"[{}] <<< SSE 事件:\n{}",
tag,
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
log::debug!("[{tag}] <<< SSE 事件: {data}");
} else {
log::info!("[{tag}] <<< SSE 数据: {data}");
log::debug!("[{tag}] <<< SSE 数据: {data}");
}
} else {
log::info!("[{tag}] <<< SSE: [DONE]");
log::debug!("[{tag}] <<< SSE: [DONE]");
}
}
}
@@ -514,10 +540,19 @@ pub fn create_logged_passthrough_stream(
}
}
log::info!("[{}] ====== 流结束 ======", tag);
if let Some(c) = collector.take() {
c.finish().await;
}
}
}
fn format_headers(headers: &HeaderMap) -> String {
headers
.iter()
.map(|(key, value)| {
let value_str = value.to_str().unwrap_or("<non-utf8>");
format!("{key}={value_str}")
})
.collect::<Vec<_>>()
.join(", ")
}
+35 -8
View File
@@ -3,11 +3,12 @@
//! 基于Axum的HTTP服务器,处理代理请求
use super::{
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
ProxyError,
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
provider_router::ProviderRouter, types::*, ProxyError,
};
use crate::database::Database;
use axum::{
extract::DefaultBodyLimit,
routing::{get, post},
Router,
};
@@ -95,7 +96,7 @@ impl ProxyServer {
.await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
log::info!("代理服务器启动于 {addr}");
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -146,13 +147,25 @@ impl ProxyServer {
// 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
Ok(Ok(())) => {
log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
Ok(())
}
Ok(Err(e)) => {
log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
Err(ProxyError::StopFailed(e.to_string()))
}
Err(_) => {
log::warn!(
"[{}] 代理服务器停止超时(5秒),强制继续",
log_srv::STOP_TIMEOUT
);
Err(ProxyError::StopTimeout)
}
}
} else {
Ok(())
}
Ok(())
}
pub async fn get_status(&self) -> ProxyStatus {
@@ -177,6 +190,18 @@ impl ProxyServer {
status
}
/// 更新某个应用类型当前“目标供应商”(用于 UI 展示 active_targets
///
/// 注意:这不代表该供应商一定已经处理过请求,而是用于“热切换/启用故障转移立即切 P1”
/// 等场景下,让 UI 能立刻反映最新目标。
pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) {
let mut current_providers = self.state.current_providers.write().await;
current_providers.insert(
app_type.to_string(),
(provider_id.to_string(), provider_name.to_string()),
);
}
fn build_router(&self) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
@@ -212,6 +237,8 @@ impl ProxyServer {
// Gemini API (支持带前缀和不带前缀)
.route("/v1beta/*path", post(handlers::handle_gemini))
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
// 提高默认请求体大小限制(避免 413 Payload Too Large
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
.layer(cors)
.with_state(self.state.clone())
}
+421
View File
@@ -0,0 +1,421 @@
//! Thinking Signature 整流器
//!
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
use super::types::RectifierConfig;
use serde_json::Value;
/// 整流结果
#[derive(Debug, Clone, Default)]
pub struct RectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 移除的 thinking block 数量
pub removed_thinking_blocks: usize,
/// 移除的 redacted_thinking block 数量
pub removed_redacted_thinking_blocks: usize,
/// 移除的 signature 字段数量
pub removed_signature_fields: usize,
}
/// 检测是否需要触发 thinking 签名整流器
///
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
/// 会检查配置开关。
pub fn should_rectify_thinking_signature(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_signature {
return false;
}
// 检测错误类型
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 场景1: thinking block 中的签名无效
// 错误示例: "Invalid 'signature' in 'thinking' block"
if lower.contains("invalid")
&& lower.contains("signature")
&& lower.contains("thinking")
&& lower.contains("block")
{
return true;
}
// 场景2: assistant 消息必须以 thinking block 开头
// 错误示例: "must start with a thinking block"
if lower.contains("must start with a thinking block") {
return true;
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
{
return true;
}
// 场景4: signature 字段必需但缺失
// 错误示例: "signature: Field required"
if lower.contains("signature") && lower.contains("field required") {
return true;
}
false
}
/// 对 Anthropic 请求体做最小侵入整流
///
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
/// - 移除非 thinking block 上遗留的 signature 字段
/// - 特定条件下删除顶层 thinking 字段
///
/// 注意:该函数会原地修改 body 对象
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
let mut result = RectifyResult::default();
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
Some(m) => m,
None => return result,
};
// 遍历所有消息
for msg in messages.iter_mut() {
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
Some(c) => c,
None => continue,
};
let mut new_content = Vec::with_capacity(content.len());
let mut content_modified = false;
for block in content.iter() {
let block_type = block.get("type").and_then(|t| t.as_str());
match block_type {
Some("thinking") => {
result.removed_thinking_blocks += 1;
content_modified = true;
continue;
}
Some("redacted_thinking") => {
result.removed_redacted_thinking_blocks += 1;
content_modified = true;
continue;
}
_ => {}
}
// 移除非 thinking block 上的 signature 字段
if block.get("signature").is_some() {
let mut block_clone = block.clone();
if let Some(obj) = block_clone.as_object_mut() {
obj.remove("signature");
result.removed_signature_fields += 1;
content_modified = true;
new_content.push(Value::Object(obj.clone()));
continue;
}
}
new_content.push(block.clone());
}
if content_modified {
result.applied = true;
*content = new_content;
}
}
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
let messages_snapshot: Vec<Value> = body
.get("messages")
.and_then(|m| m.as_array())
.map(|a| a.to_vec())
.unwrap_or_default();
if should_remove_top_level_thinking(body, &messages_snapshot) {
if let Some(obj) = body.as_object_mut() {
obj.remove("thinking");
result.applied = true;
}
}
result
}
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_enabled = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if !thinking_enabled {
return false;
}
// 找到最后一条 assistant 消息
let last_assistant = messages
.iter()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
let last_assistant_content = match last_assistant
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
Some(c) if !c.is_empty() => c,
_ => return false,
};
// 检查首块是否为 thinking/redacted_thinking
let first_block_type = last_assistant_content
.first()
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str());
let missing_thinking_prefix =
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
if !missing_thinking_prefix {
return false;
}
// 检查是否存在 tool_use
last_assistant_content
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
}
}
fn disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
}
}
// ==================== should_rectify_thinking_signature 测试 ====================
#[test]
fn test_detect_invalid_signature() {
assert!(should_rectify_thinking_signature(
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_no_backticks() {
assert!(should_rectify_thinking_signature(
Some("Messages.1.Content.0: invalid signature in thinking block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_nested_json() {
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_expected() {
assert!(should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
Some("a final `assistant` message must start with a thinking block"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_signature(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(
Some("Connection refused"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
}
#[test]
fn test_detect_signature_field_required() {
// 场景4: signature 字段缺失
assert!(should_rectify_thinking_signature(
Some("***.***.***.***.***.signature: Field required"),
&enabled_config()
));
// 嵌套 JSON 格式
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_disabled_config() {
// 即使错误匹配,配置关闭时也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&disabled_config()
));
}
#[test]
fn test_master_disabled() {
// 总开关关闭时,即使子开关开启也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&master_disabled_config()
));
}
// ==================== rectify_anthropic_request 测试 ====================
#[test]
fn test_rectify_removes_thinking_blocks() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig" },
{ "type": "text", "text": "hello", "signature": "sig_text" },
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
assert_eq!(result.removed_redacted_thinking_blocks, 1);
assert_eq!(result.removed_signature_fields, 2);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(content[1]["type"], "tool_use");
assert!(content[1].get("signature").is_none());
}
#[test]
fn test_rectify_removes_top_level_thinking() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert!(body.get("thinking").is_none());
}
#[test]
fn test_rectify_no_change_when_no_issues() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(result.removed_thinking_blocks, 0);
}
#[test]
fn test_rectify_no_messages() {
let mut body = json!({ "model": "claude-test" });
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
}
#[test]
fn test_rectify_preserves_thinking_when_prefix_exists() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "some thought" },
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
]
}]
});
let result = rectify_anthropic_request(&mut body);
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
}
+180 -8
View File
@@ -16,23 +16,23 @@ pub struct ProxyConfig {
/// 是否正在接管 Live 配置
#[serde(default)]
pub live_takeover_active: bool,
/// 流式首字超时(秒)- 等待首个数据块的最大时间
/// 流式首字超时(秒)- 等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒
#[serde(default = "default_streaming_first_byte_timeout")]
pub streaming_first_byte_timeout: u64,
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
/// 流式静默超时(秒)- 两个数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)
#[serde(default = "default_streaming_idle_timeout")]
pub streaming_idle_timeout: u64,
/// 非流式总超时(秒)- 非流式请求的总超时时间
/// 非流式总超时(秒)- 非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)
#[serde(default = "default_non_streaming_timeout")]
pub non_streaming_timeout: u64,
}
fn default_streaming_first_byte_timeout() -> u64 {
30
60
}
fn default_streaming_idle_timeout() -> u64 {
60
120
}
fn default_non_streaming_timeout() -> u64 {
@@ -45,11 +45,11 @@ impl Default for ProxyConfig {
listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3,
request_timeout: 300,
request_timeout: 600,
enable_logging: true,
live_takeover_active: false,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
}
}
@@ -191,3 +191,175 @@ pub struct AppProxyConfig {
/// 计算错误率的最小请求数
pub circuit_min_requests: u32,
}
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器
#[serde(default)]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default)]
pub request_thinking_signature: bool,
}
fn default_true() -> bool {
true
}
fn default_log_level() -> String {
"info".to_string()
}
/// 日志配置
///
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogConfig {
/// 总开关:是否启用日志
#[serde(default = "default_true")]
pub enabled: bool,
/// 日志级别: error, warn, info, debug, trace
#[serde(default = "default_log_level")]
pub level: String,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
enabled: true,
level: "info".to_string(),
}
}
}
impl LogConfig {
/// 将配置转换为 log::LevelFilter
pub fn to_level_filter(&self) -> log::LevelFilter {
if !self.enabled {
return log::LevelFilter::Off;
}
match self.level.to_lowercase().as_str() {
"error" => log::LevelFilter::Error,
"warn" => log::LevelFilter::Warn,
"info" => log::LevelFilter::Info,
"debug" => log::LevelFilter::Debug,
"trace" => log::LevelFilter::Trace,
_ => log::LevelFilter::Info,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_disabled() {
// 验证 RectifierConfig::default() 返回全禁用状态
let config = RectifierConfig::default();
assert!(!config.enabled, "整流器总开关默认应为 false");
assert!(
!config.request_thinking_signature,
"thinking 签名整流器默认应为 false"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用默认值 false
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
}
#[test]
fn test_rectifier_config_serde_explicit_true() {
// 验证显式设置 true 时正确反序列化
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
}
#[test]
fn test_log_config_default() {
let config = LogConfig::default();
assert!(config.enabled);
assert_eq!(config.level, "info");
}
#[test]
fn test_log_config_serde_default() {
let json = "{}";
let config: LogConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.level, "info");
}
#[test]
fn test_log_config_to_level_filter() {
let config = LogConfig {
level: "error".to_string(),
..Default::default()
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Error);
let config = LogConfig {
level: "warn".to_string(),
..Default::default()
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Warn);
let config = LogConfig {
level: "info".to_string(),
..Default::default()
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
let config = LogConfig {
level: "debug".to_string(),
..Default::default()
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Debug);
let config = LogConfig {
level: "trace".to_string(),
..Default::default()
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Trace);
// 无效级别回退到 info
let config = LogConfig {
level: "invalid".to_string(),
..Default::default()
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
// 禁用时返回 Off
let config = LogConfig {
enabled: false,
level: "debug".to_string(),
};
assert_eq!(config.to_level_filter(), log::LevelFilter::Off);
}
#[test]
fn test_log_config_serde_roundtrip() {
let config = LogConfig {
enabled: true,
level: "debug".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: LogConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.level, "debug");
}
}
+6 -3
View File
@@ -65,8 +65,11 @@ impl<'a> UsageLogger<'a> {
let created_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
.map(|d| d.as_secs() as i64)
.unwrap_or_else(|e| {
log::warn!("SystemTime is before UNIX_EPOCH, falling back to 0: {e}");
0
});
conn.execute(
"INSERT INTO proxy_request_logs (
@@ -211,7 +214,7 @@ impl<'a> UsageLogger<'a> {
let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() {
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
}
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
+4
View File
@@ -122,6 +122,10 @@ impl ConfigService {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
}
}
Ok(())
+66 -3
View File
@@ -37,6 +37,9 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -113,6 +116,13 @@ impl McpService {
AppType::Gemini => {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(
&Default::default(),
&server.id,
&server.server,
)?;
}
}
Ok(())
}
@@ -135,6 +145,9 @@ impl McpService {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
}
Ok(())
}
@@ -206,6 +219,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_claude(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
@@ -217,6 +232,8 @@ impl McpService {
merged.apps.claude = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -229,7 +246,7 @@ impl McpService {
}
}
Ok(count)
Ok(new_count)
}
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
@@ -240,6 +257,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_codex(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
@@ -251,6 +270,8 @@ impl McpService {
merged.apps.codex = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -263,7 +284,7 @@ impl McpService {
}
}
Ok(count)
Ok(new_count)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
@@ -274,6 +295,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_gemini(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
@@ -285,6 +308,8 @@ impl McpService {
merged.apps.gemini = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -297,6 +322,44 @@ impl McpService {
}
}
Ok(count)
Ok(new_count)
}
/// 从 OpenCode 导入 MCPv3.9.2+ 新增)
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
let mut temp_config = crate::app_config::MultiAppConfig::default();
// 调用原有的导入逻辑(从 mcp/opencode.rs
let count = crate::mcp::import_from_opencode(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
// 已存在:仅启用 OpenCode,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.opencode = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
}
}
}
Ok(new_count)
}
}
+2 -1
View File
@@ -15,7 +15,8 @@ pub use mcp::McpService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
pub use skill::{Skill, SkillRepo, SkillService};
#[allow(unused_imports)]
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)]
pub use usage_stats::{
+14 -2
View File
@@ -36,10 +36,22 @@ impl PromptService {
state.db.save_prompt(app.as_str(), &prompt)?;
// 如果是已启用的提示词,同步更新到对应的文件
if is_enabled {
// 启用提示词:写入内容到文件
let target_path = prompt_file_path(&app)?;
write_text_file(&target_path, &prompt.content)?;
} else {
// 禁用提示词:检查是否还有其他已启用的提示词
let prompts = state.db.get_prompts(app.as_str())?;
let any_enabled = prompts.values().any(|p| p.enabled);
if !any_enabled {
// 所有提示词都已禁用,清空文件
let target_path = prompt_file_path(&app)?;
if target_path.exists() {
write_text_file(&target_path, "")?;
}
}
}
Ok(())
@@ -85,7 +97,7 @@ impl PromptService {
if !content_exists {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs() as i64;
let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt {
+172
View File
@@ -120,6 +120,64 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
use crate::provider::OpenCodeProviderConfig;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
provider.id
);
// Try to extract from provider.{id}
obj.get("provider")
.and_then(|p| p.get(&provider.id))
.cloned()
.unwrap_or_else(|| provider.settings_config.clone())
} else {
provider.settings_config.clone()
}
} else {
provider.settings_config.clone()
};
// Convert settings_config to OpenCodeProviderConfig
let opencode_config_result =
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
match opencode_config_result {
Ok(config) => {
opencode_config::set_typed_provider(&provider.id, &config)?;
log::info!("OpenCode provider '{}' written to live config", provider.id);
}
Err(e) => {
log::warn!(
"Failed to parse OpenCode provider config for '{}': {}",
provider.id,
e
);
// Only write if config looks like a valid provider fragment
if config_to_write.get("npm").is_some()
|| config_to_write.get("options").is_some()
{
opencode_config::set_provider(&provider.id, config_to_write)?;
log::info!(
"OpenCode provider '{}' written as raw JSON to live config",
provider.id
);
} else {
log::error!(
"OpenCode provider '{}' has invalid config structure, skipping write",
provider.id
);
}
}
}
}
}
Ok(())
}
@@ -148,6 +206,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// MCP sync
McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
}
}
Ok(())
}
@@ -211,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
"config": config_obj
}))
}
AppType::OpenCode => {
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.config.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file not found",
));
}
let config = read_opencode_config()?;
Ok(config)
}
}
}
@@ -286,6 +368,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
AppType::OpenCode => {
// OpenCode uses additive mode - import from live is not the same pattern
// For now, return an empty config structure
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.live.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file is missing",
));
}
// For OpenCode, we return the full config - but note that OpenCode
// uses additive mode, so importing defaults works differently
read_opencode_config()?
}
};
let mut provider = Provider::with_id(
@@ -390,3 +490,75 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
Ok(())
}
/// Remove an OpenCode provider from the live configuration
///
/// This is specific to OpenCode's additive mode - removing a provider
/// from the opencode.json file.
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::opencode_config;
// Check if OpenCode config directory exists
if !opencode_config::get_opencode_dir().exists() {
log::debug!("OpenCode config directory doesn't exist, skipping removal of '{provider_id}'");
return Ok(());
}
opencode_config::remove_provider(provider_id)?;
log::info!("OpenCode provider '{provider_id}' removed from live config");
Ok(())
}
/// Import all providers from OpenCode live config to database
///
/// This imports existing providers from ~/.config/opencode/opencode.json
/// into the CC Switch database. Each provider found will be added to the
/// database with is_current set to false.
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::opencode_config;
let providers = opencode_config::get_typed_providers()?;
if providers.is_empty() {
return Ok(0);
}
let mut imported = 0;
let existing = state.db.get_all_providers("opencode")?;
for (id, config) in providers {
// Skip if already exists in database
if existing.contains_key(&id) {
log::debug!("OpenCode provider '{id}' already exists in database, skipping");
continue;
}
// Convert to Value for settings_config
let settings_config = match serde_json::to_value(&config) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to serialize OpenCode provider '{id}': {e}");
continue;
}
};
// Create provider
let provider = Provider::with_id(
id.clone(),
config.name.clone().unwrap_or_else(|| id.clone()),
settings_config,
None,
);
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
log::warn!("Failed to import OpenCode provider '{id}': {e}");
continue;
}
imported += 1;
log::info!("Imported OpenCode provider '{id}' from live config");
}
Ok(imported)
}
+366 -18
View File
@@ -20,13 +20,16 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
pub use live::{
import_default_config, import_opencode_providers_from_live, read_live_settings,
sync_current_to_live,
};
// Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use live::write_gemini_live;
use live::{remove_opencode_provider_from_live, write_gemini_live};
use usage::validate_usage_script;
/// Provider business logic service
@@ -71,6 +74,47 @@ mod tests {
assert_eq!(api_key, "token");
assert_eq!(base_url, "https://claude.example");
}
#[test]
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
let config_toml = r#"model_provider = "azure"
model = "gpt-4"
disable_response_storage = true
[model_providers.azure]
name = "Azure OpenAI"
base_url = "https://azure.example/v1"
wire_api = "responses"
[mcp_servers.my_server]
base_url = "http://localhost:8080"
"#;
let settings = json!({ "config": config_toml });
let extracted = ProviderService::extract_codex_common_config(&settings)
.expect("extract_codex_common_config should succeed");
assert!(
!extracted
.lines()
.any(|line| line.trim_start().starts_with("model_provider")),
"should remove top-level model_provider"
);
assert!(
!extracted
.lines()
.any(|line| line.trim_start().starts_with("model =")),
"should remove top-level model"
);
assert!(
!extracted.contains("[model_providers"),
"should remove entire model_providers table"
);
assert!(
extracted.contains("http://localhost:8080"),
"should keep mcp_servers.* base_url"
);
}
}
impl ProviderService {
@@ -96,7 +140,13 @@ impl ProviderService {
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// OpenCode uses additive mode - no "current" provider concept
if matches!(app_type, AppType::OpenCode) {
return Ok(String::new());
}
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
@@ -111,7 +161,13 @@ impl ProviderService {
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// Check if sync is needed (if this is current provider, or no current provider)
// OpenCode uses additive mode - always write to live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
let current = state.db.get_current_provider(app_type.as_str())?;
if current.is_none() {
// No current provider, set as current and sync
@@ -135,14 +191,20 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider (use effective current, not just DB)
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
// For other apps: Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管)
@@ -175,8 +237,18 @@ impl ProviderService {
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check both local settings and database
// OpenCode uses additive mode - no current provider concept
if matches!(app_type, AppType::OpenCode) {
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
// Also remove from live config
remove_opencode_provider_from_live(id)?;
return Ok(());
}
// For other apps: Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
@@ -189,6 +261,27 @@ impl ProviderService {
state.db.delete_provider(app_type.as_str(), id)
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
///
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
/// but keep it available for future use.
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
remove_opencode_provider_from_live(id)?;
}
// Future: add other additive mode apps here
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
app_type.as_str()
)));
}
}
Ok(())
}
/// Switch to a provider
///
/// Switch flow:
@@ -251,6 +344,14 @@ impl ProviderService {
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
// 关键修复:接管模式下切换供应商不会写回 Live 配置,
// 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
if matches!(app_type, AppType::Claude) {
if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
}
}
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
return Ok(());
@@ -277,22 +378,29 @@ impl ProviderService {
if let Some(current_id) = current_id {
if current_id != id {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
}
}
}
}
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// OpenCode uses additive mode - skip setting is_current (no such concept)
if !matches!(app_type, AppType::OpenCode) {
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
@@ -308,6 +416,199 @@ impl ProviderService {
sync_current_to_live(state)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
pub fn extract_common_config_snippet(
state: &AppState,
app_type: AppType,
) -> Result<String, AppError> {
// Get current provider
let current_id = Self::current(state, app_type.clone())?;
if current_id.is_empty() {
return Err(AppError::Message("No current provider".to_string()));
}
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&current_id)
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
}
}
/// Extract common config snippet from a config value (e.g. editor content).
pub fn extract_common_config_snippet_from_settings(
app_type: AppType,
settings_config: &Value,
) -> Result<String, AppError> {
match app_type {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
}
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
// Fields to exclude from common config
const ENV_EXCLUDES: &[&str] = &[
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models (5 fields)
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
// Endpoint
"ANTHROPIC_BASE_URL",
];
const TOP_LEVEL_EXCLUDES: &[&str] = &[
"apiBaseUrl",
// Legacy model fields
"primaryModel",
"smallFastModel",
];
// Remove env fields
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
for key in ENV_EXCLUDES {
env.remove(*key);
}
// If env is empty after removal, remove the env object itself
if env.is_empty() {
config.as_object_mut().map(|obj| obj.remove("env"));
}
}
// Remove top-level fields
if let Some(obj) = config.as_object_mut() {
for key in TOP_LEVEL_EXCLUDES {
obj.remove(*key);
}
}
// Check if result is empty
if config.as_object().is_none_or(|obj| obj.is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for Codex (TOML format)
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
// Codex config is stored as { "auth": {...}, "config": "toml string" }
let config_toml = settings
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
if config_toml.is_empty() {
return Ok(String::new());
}
let mut doc = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
// Remove provider-specific fields.
let root = doc.as_table_mut();
root.remove("model");
root.remove("model_provider");
// Legacy/alt formats might use a top-level base_url.
root.remove("base_url");
// Remove entire model_providers table (provider-specific configuration)
root.remove("model_providers");
// Clean up multiple empty lines (keep at most one blank line).
let mut cleaned = String::new();
let mut blank_run = 0usize;
for line in doc.to_string().lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run <= 1 {
cleaned.push('\n');
}
continue;
}
blank_run = 0;
cleaned.push_str(line);
cleaned.push('\n');
}
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (JSON format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut snippet = serde_json::Map::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
continue;
}
let Value::String(v) = value else {
continue;
};
let trimmed = v.trim();
if !trimmed.is_empty() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
// Keep npm and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -398,6 +699,7 @@ impl ProviderService {
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
usage::test_usage_script(
state,
@@ -409,6 +711,7 @@ impl ProviderService {
base_url,
access_token,
user_id,
template_type,
)
.await
}
@@ -472,6 +775,17 @@ impl ProviderService {
use crate::gemini_config::validate_gemini_settings;
validate_gemini_settings(&provider.settings_config)?
}
AppType::OpenCode => {
// OpenCode uses a different config structure: { npm, options, models }
// Basic validation - must be an object
if !provider.settings_config.is_object() {
return Err(AppError::localized(
"provider.opencode.settings.not_object",
"OpenCode 配置必须是 JSON 对象",
"OpenCode configuration must be a JSON object",
));
}
}
}
// Validate and clean UsageScript configuration (common for all app types)
@@ -609,6 +923,40 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::OpenCode => {
// OpenCode uses options.apiKey and options.baseURL
let options = provider
.settings_config
.get("options")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::localized(
"provider.opencode.options.missing",
"配置格式错误: 缺少 options",
"Invalid configuration: missing options section",
)
})?;
let api_key = options
.get("apiKey")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.opencode.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let base_url = options
.get("baseURL")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((api_key, base_url))
}
}
}
}
+7 -1
View File
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
timeout: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script(
script_code,
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
timeout,
access_token,
user_id,
template_type,
)
.await
{
@@ -113,7 +115,7 @@ pub async fn query_usage(
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
@@ -164,6 +166,7 @@ pub async fn query_usage(
base_url,
usage_script.access_token.clone(),
usage_script.user_id.clone(),
usage_script.template_type.clone(),
)
};
@@ -174,6 +177,7 @@ pub async fn query_usage(
timeout,
access_token.as_deref(),
user_id.as_deref(),
template_type.as_deref(),
)
.await
}
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing
execute_and_format_usage_result(
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
timeout,
access_token,
user_id,
template_type,
)
.await
}
+246 -10
View File
@@ -17,6 +17,20 @@ use tokio::sync::RwLock;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// 代理接管模式下需要从 Claude Live 配置中移除的“模型覆盖”字段。
///
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
"ANTHROPIC_SMALL_FAST_MODEL",
];
#[derive(Clone)]
pub struct ProxyService {
db: Arc<Database>,
@@ -34,6 +48,31 @@ impl ProxyService {
}
}
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
///
/// 这可以避免“接管开启后切换供应商仍使用旧模型”的问题。
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
let mut config = self.read_claude_live()?;
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
return Ok(());
};
let mut changed = false;
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
if env.remove(key).is_some() {
changed = true;
}
}
if changed {
self.write_claude_live(&config)?;
}
Ok(())
}
/// 设置 AppHandle(在应用初始化时调用)
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
futures::executor::block_on(async {
@@ -329,6 +368,10 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_live()?,
AppType::Gemini => self.read_gemini_live()?,
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
};
self.sync_live_config_to_provider(app_type, &live_config)
@@ -402,8 +445,21 @@ impl ProxyService {
}
None => {
// 至少写入一份可用的 Token
provider.settings_config["env"] =
json!({ token_key: token });
if provider.settings_config.is_null() {
provider.settings_config = json!({});
}
if let Some(root) = provider.settings_config.as_object_mut()
{
root.insert(
"env".to_string(),
json!({ token_key: token }),
);
} else {
log::warn!(
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
);
}
}
}
@@ -446,9 +502,20 @@ impl ProxyService {
{
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
} else {
provider.settings_config["auth"] = json!({
"OPENAI_API_KEY": token
});
if provider.settings_config.is_null() {
provider.settings_config = json!({});
}
if let Some(root) = provider.settings_config.as_object_mut() {
root.insert(
"auth".to_string(),
json!({ "OPENAI_API_KEY": token }),
);
} else {
log::warn!(
"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
);
}
}
if let Err(e) = self.db.update_provider_settings_config(
@@ -487,9 +554,20 @@ impl ProxyService {
{
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
} else {
provider.settings_config["env"] = json!({
"GEMINI_API_KEY": token
});
if provider.settings_config.is_null() {
provider.settings_config = json!({});
}
if let Some(root) = provider.settings_config.as_object_mut() {
root.insert(
"env".to_string(),
json!({ "GEMINI_API_KEY": token }),
);
} else {
log::warn!(
"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
);
}
}
if let Err(e) = self.db.update_provider_settings_config(
@@ -507,6 +585,9 @@ impl ProxyService {
}
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -685,6 +766,10 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
};
let json_str = serde_json::to_string(&config)
@@ -740,6 +825,10 @@ impl ProxyService {
if let Ok(mut live_config) = self.read_claude_live() {
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
env.remove(key);
}
// 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑;
// 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。
let token_keys = [
@@ -820,6 +909,10 @@ impl ProxyService {
let mut live_config = self.read_claude_live()?;
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
env.remove(key);
}
let token_keys = [
"ANTHROPIC_AUTH_TOKEN",
@@ -885,6 +978,10 @@ impl ProxyService {
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
}
Ok(())
@@ -899,6 +996,10 @@ impl ProxyService {
if let Ok(mut live_config) = self.read_claude_live() {
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
env.remove(key);
}
let token_keys = [
"ANTHROPIC_AUTH_TOKEN",
@@ -964,6 +1065,9 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config);
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -996,6 +1100,9 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复");
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1075,6 +1182,10 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
}
}
@@ -1092,6 +1203,10 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
AppType::OpenCode => {
// OpenCode doesn't support proxy takeover
false
}
}
}
@@ -1131,6 +1246,10 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Ok(())
}
}
}
@@ -1411,7 +1530,10 @@ impl ProxyService {
app_type: &str,
provider_id: &str,
) -> Result<(), String> {
// 更新数据库中的 is_current 标记
// 代理模式切换供应商(热切换):
// - 更新 SSOT(数据库 is_current
// - 同步本地 settings(设备级 current_provider_*
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
@@ -1419,6 +1541,35 @@ impl ProxyService {
.set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
// 同步本地 settings(设备级优先)
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
let has_backup = self
.db
.get_live_backup(app_type_enum.as_str())
.await
.ok()
.flatten()
.is_some();
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
if has_backup || live_taken_over {
self.update_live_backup_from_provider(app_type, &provider)
.await?;
}
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
if let Some(server) = self.server.read().await.as_ref() {
server
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
.await;
}
}
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
Ok(())
}
@@ -1475,7 +1626,30 @@ impl ProxyService {
if !path.exists() {
return Err("Claude 配置文件不存在".to_string());
}
read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))
let mut value: Value =
read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))?;
if value.is_null() {
value = json!({});
}
if !value.is_object() {
let kind = match &value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
};
return Err(format!(
"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}",
path.display()
));
}
Ok(value)
}
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
@@ -1916,4 +2090,66 @@ model = "gpt-5.1-codex"
"should not add ANTHROPIC_AUTH_TOKEN when absent"
);
}
#[tokio::test]
#[serial]
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({
"env": {
"ANTHROPIC_API_KEY": "a-key"
}
}),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({
"env": {
"ANTHROPIC_API_KEY": "b-key"
}
}),
None,
);
db.save_provider("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
.expect("save provider b");
db.set_current_provider("claude", "a")
.expect("set current provider");
// 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新)
db.save_live_backup("claude", "{\"env\":{}}")
.await
.expect("seed live backup");
service
.switch_proxy_target("claude", "b")
.await
.expect("switch proxy target");
// 断言:本地 settings 的 current provider 已同步
assert_eq!(
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
Some("b")
);
// 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
}
}
File diff suppressed because it is too large Load Diff
+13 -17
View File
@@ -1,7 +1,7 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::error::AppError;
@@ -65,17 +65,21 @@ impl SpeedtestService {
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let (client, request_timeout) = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone();
async move {
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await;
let _ = client
.get(parsed_url.clone())
.timeout(request_timeout)
.send()
.await;
// 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now();
let latency = match client.get(parsed_url).send().await {
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
@@ -112,19 +116,11 @@ impl SpeedtestService {
Ok(results.into_iter().flatten().collect::<Vec<_>>())
}
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| {
AppError::localized(
"speedtest.client_create_failed",
format!("创建 HTTP 客户端失败: {e}"),
format!("Failed to create HTTP client: {e}"),
)
})
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
// 使用全局 HTTP 客户端(已包含代理配置)
// 返回 timeout Duration 供请求级别使用
let timeout = std::time::Duration::from_secs(timeout_secs);
Ok((crate::proxy::http_client::get(), timeout))
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
+268 -57
View File
@@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::app_config::AppType;
use crate::error::AppError;
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
pub codex_model: String,
/// Gemini 测试模型
pub gemini_model: String,
/// 检查提示词
#[serde(default = "default_test_prompt")]
pub test_prompt: String,
}
fn default_test_prompt() -> String {
"Who are you?".to_string()
}
impl Default for StreamCheckConfig {
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(),
test_prompt: default_test_prompt(),
}
}
}
@@ -70,15 +78,19 @@ pub struct StreamCheckService;
impl StreamCheckService {
/// 执行流式健康检查(带重试)
///
/// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置
pub async fn check_with_retry(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> Result<StreamCheckResult, AppError> {
// 合并供应商单独配置和全局配置
let effective_config = Self::merge_provider_config(provider, config);
let mut last_result = None;
for attempt in 0..=config.max_retries {
let result = Self::check_once(app_type, provider, config).await;
for attempt in 0..=effective_config.max_retries {
let result = Self::check_once(app_type, provider, &effective_config).await;
match &result {
Ok(r) if r.success => {
@@ -89,7 +101,7 @@ impl StreamCheckService {
}
Ok(r) => {
// 失败但非异常,判断是否重试
if Self::should_retry(&r.message) && attempt < config.max_retries {
if Self::should_retry(&r.message) && attempt < effective_config.max_retries {
last_result = Some(r.clone());
continue;
}
@@ -99,7 +111,8 @@ impl StreamCheckService {
});
}
Err(e) => {
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries
{
continue;
}
return Err(AppError::Message(e.to_string()));
@@ -110,15 +123,56 @@ impl StreamCheckService {
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: "检查失败".to_string(),
message: "Check failed".to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: config.max_retries,
retry_count: effective_config.max_retries,
}))
}
/// 合并供应商单独配置和全局配置
///
/// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置
fn merge_provider_config(
provider: &Provider,
global_config: &StreamCheckConfig,
) -> StreamCheckConfig {
let test_config = provider
.meta
.as_ref()
.and_then(|m| m.test_config.as_ref())
.filter(|tc| tc.enabled);
match test_config {
Some(tc) => StreamCheckConfig {
timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs),
max_retries: tc.max_retries.unwrap_or(global_config.max_retries),
degraded_threshold_ms: tc
.degraded_threshold_ms
.unwrap_or(global_config.degraded_threshold_ms),
claude_model: tc
.test_model
.clone()
.unwrap_or_else(|| global_config.claude_model.clone()),
codex_model: tc
.test_model
.clone()
.unwrap_or_else(|| global_config.codex_model.clone()),
gemini_model: tc
.test_model
.clone()
.unwrap_or_else(|| global_config.gemini_model.clone()),
test_prompt: tc
.test_prompt
.clone()
.unwrap_or_else(|| global_config.test_prompt.clone()),
},
None => global_config.clone(),
}
}
/// 单次流式检查
async fn check_once(
app_type: &AppType,
@@ -130,29 +184,61 @@ impl StreamCheckService {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = crate::proxy::http_client::get_for_provider(proxy_config);
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_claude_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_codex_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_gemini_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::OpenCode => {
// OpenCode doesn't support stream check yet
return Err(AppError::localized(
"opencode_no_stream_check",
"OpenCode 暂不支持健康检查",
"OpenCode does not support health check yet",
));
}
};
@@ -166,7 +252,7 @@ impl StreamCheckService {
Ok(StreamCheckResult {
status: health_status,
success: true,
message: "检查成功".to_string(),
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
@@ -188,31 +274,69 @@ impl StreamCheckService {
}
/// Claude 流式检查
///
/// 严格按照 Claude CLI 真实请求格式构建请求
async fn check_claude_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") {
format!("{base}/messages")
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages")
format!("{base}/v1/messages?beta=true")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 严格按照 Claude CLI 请求格式设置 headers
let response = client
.post(&url)
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.header(
"anthropic-beta",
"claude-code-20250219,interleaved-thinking-2025-05-14",
)
.header("anthropic-dangerous-direct-browser-access", "true")
// 内容类型 headers
.header("content-type", "application/json")
.header("accept", "application/json")
.header("accept-encoding", "identity")
.header("accept-language", "*")
// 客户端标识 headers
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
.header("x-app", "cli")
// x-stainless SDK headers(动态获取本地系统信息)
.header("x-stainless-lang", "js")
.header("x-stainless-package-version", "0.70.0")
.header("x-stainless-os", os_name)
.header("x-stainless-arch", arch_name)
.header("x-stainless-runtime", "node")
.header("x-stainless-runtime-version", "v22.20.0")
.header("x-stainless-retry-count", "0")
.header("x-stainless-timeout", "600")
// 其他 headers
.header("sec-fetch-mode", "cors")
.header("connection", "keep-alive")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -230,51 +354,64 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
/// Codex 流式检查
///
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
async fn check_codex_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") {
format!("{base}/chat/completions")
format!("{base}/responses")
} else {
format!("{base}/v1/chat/completions")
format!("{base}/v1/responses")
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// Responses API 请求体格式 (input 必须是数组)
let mut body = json!({
"model": actual_model,
"messages": [
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"input": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort);
body["reasoning"] = json!({ "effort": effort });
}
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -291,35 +428,48 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
/// Gemini 流式检查
///
/// 使用 Gemini 原生 API 格式 (streamGenerateContent)
async fn check_gemini_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions");
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
// 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta
// alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组
let url = if base.contains("/v1beta") || base.contains("/v1/") {
format!("{base}/models/{model}:streamGenerateContent?alt=sse")
} else {
format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse")
};
// Gemini 原生请求体格式
let body = json!({
"model": model,
"messages": [{ "role": "user", "content": "hi" }],
"max_tokens": 1,
"temperature": 0,
"stream": true
"contents": [{
"role": "user",
"parts": [{ "text": test_prompt }]
}]
});
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-goog-api-key", &auth.api_key)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -336,10 +486,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -354,7 +504,6 @@ impl StreamCheckService {
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string();
@@ -367,17 +516,14 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("timeout")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
}
fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
AppError::Message("Request timeout".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
AppError::Message(format!("Connection failed: {e}"))
} else {
AppError::Message(e.to_string())
}
@@ -396,9 +542,24 @@ impl StreamCheckService {
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()),
AppType::OpenCode => {
// OpenCode uses models map in settings_config
// Try to extract first model from the models object
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
}
}
fn extract_opencode_model(provider: &Provider) -> Option<String> {
let models = provider
.settings_config
.get("models")
.and_then(|m| m.as_object())?;
// Return the first model ID from the models map
models.keys().next().map(|s| s.to_string())
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider
.settings_config
@@ -424,6 +585,26 @@ impl StreamCheckService {
.map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
fn get_os_name() -> &'static str {
match std::env::consts::OS {
"macos" => "MacOS",
"linux" => "Linux",
"windows" => "Windows",
other => other,
}
}
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
fn get_arch_name() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x86_64",
"x86" => "x86",
other => other,
}
}
}
#[cfg(test)]
@@ -448,9 +629,10 @@ mod tests {
#[test]
fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时"));
assert!(StreamCheckService::should_retry("request timeout"));
assert!(!StreamCheckService::should_retry("API Key 无效"));
assert!(StreamCheckService::should_retry("Request timeout"));
assert!(StreamCheckService::should_retry("request timed out"));
assert!(StreamCheckService::should_retry("connection abort"));
assert!(!StreamCheckService::should_retry("API Key invalid"));
}
#[test]
@@ -478,4 +660,33 @@ mod tests {
assert_eq!(model, "gpt-4o-mini");
assert_eq!(effort, None);
}
#[test]
fn test_get_os_name() {
let os_name = StreamCheckService::get_os_name();
// 确保返回非空字符串
assert!(!os_name.is_empty());
// 在 macOS 上应该返回 "MacOS"
#[cfg(target_os = "macos")]
assert_eq!(os_name, "MacOS");
// 在 Linux 上应该返回 "Linux"
#[cfg(target_os = "linux")]
assert_eq!(os_name, "Linux");
// 在 Windows 上应该返回 "Windows"
#[cfg(target_os = "windows")]
assert_eq!(os_name, "Windows");
}
#[test]
fn test_get_arch_name() {
let arch_name = StreamCheckService::get_arch_name();
// 确保返回非空字符串
assert!(!arch_name.is_empty());
// 在 ARM64 上应该返回 "arm64"
#[cfg(target_arch = "aarch64")]
assert_eq!(arch_name, "arm64");
// 在 x86_64 上应该返回 "x86_64"
#[cfg(target_arch = "x86_64")]
assert_eq!(arch_name, "x86_64");
}
}
+16 -9
View File
@@ -802,25 +802,25 @@ pub(crate) fn find_model_pricing_row(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905
let without_prefix = model_id
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
let cleaned = model_id
.rsplit_once('/')
.map(|(_, rest)| rest)
.unwrap_or(model_id);
let cleaned = without_prefix
.map_or(model_id, |(_, r)| r)
.split(':')
.next()
.map(str::trim)
.unwrap_or(without_prefix);
.unwrap_or(model_id)
.trim()
.replace('@', "-");
// 2) 精确匹配清洗后的名称
// 精确匹配清洗后的名称
let exact = conn
.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id = ?1",
[cleaned],
[&cleaned],
|row| {
Ok((
row.get::<_, String>(0)?,
@@ -952,6 +952,13 @@ mod tests {
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
);
// 清洗:@ 替换为 -seed_model_pricing 已预置 gpt-5.2-codex-low
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
assert!(
result.is_some(),
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
);
// 测试不存在的模型
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
assert!(result.is_none(), "不应该匹配不存在的模型");
+115 -16
View File
@@ -5,6 +5,7 @@ use std::sync::{OnceLock, RwLock};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::skill::SyncMethod;
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -16,6 +17,47 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
fn default_true() -> bool {
true
}
/// 主页面显示的应用配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisibleApps {
#[serde(default = "default_true")]
pub claude: bool,
#[serde(default = "default_true")]
pub codex: bool,
#[serde(default = "default_true")]
pub gemini: bool,
#[serde(default = "default_true")]
pub opencode: bool,
}
impl Default for VisibleApps {
fn default() -> Self {
Self {
claude: true,
codex: true,
gemini: true,
opencode: true,
}
}
}
impl VisibleApps {
/// Check if the specified app is visible
pub fn is_visible(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -32,7 +74,7 @@ pub struct AppSettings {
#[serde(default)]
pub enable_claude_plugin_integration: bool,
/// 是否跳过 Claude Code 初次安装确认
#[serde(default = "default_true")]
#[serde(default)]
pub skip_claude_onboarding: bool,
/// 是否开机自启
#[serde(default)]
@@ -40,6 +82,10 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
// ===== 主页面显示的应用 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_apps: Option<VisibleApps>,
// ===== 设备级目录覆盖 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
@@ -47,6 +93,8 @@ pub struct AppSettings {
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
@@ -58,6 +106,14 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
// ===== Skill 同步设置 =====
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)]
pub skill_sync_method: SyncMethod,
}
fn default_show_in_tray() -> bool {
@@ -68,36 +124,33 @@ fn default_minimize_to_tray_on_close() -> bool {
true
}
fn default_true() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
skip_claude_onboarding: true,
skip_claude_onboarding: false,
launch_on_startup: false,
language: None,
visible_apps: None,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
opencode_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_opencode: None,
skill_sync_method: SyncMethod::default(),
}
}
}
impl AppSettings {
fn settings_path() -> PathBuf {
fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
.join("settings.json")
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
}
fn normalize_paths(&mut self) {
@@ -122,6 +175,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.opencode_config_dir = self
.opencode_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
@@ -131,7 +191,9 @@ impl AppSettings {
}
fn load_from_file() -> Self {
let path = Self::settings_path();
let Some(path) = Self::settings_path() else {
return Self::default();
};
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => {
@@ -156,7 +218,9 @@ impl AppSettings {
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let path = AppSettings::settings_path();
let Some(path) = AppSettings::settings_path() else {
return Err(AppError::Config("无法获取用户主目录".to_string()));
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -193,14 +257,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
}
pub fn get_settings() -> AppSettings {
settings_store().read().expect("读取设置锁失败").clone()
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
let mut guard = settings_store().write().expect("写入设置锁失败");
let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
*guard = new_settings;
Ok(())
}
@@ -209,7 +282,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file();
let mut guard = settings_store().write().expect("写入设置锁失败");
let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
*guard = fresh_settings;
Ok(())
}
@@ -238,6 +314,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_opencode_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.opencode_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
@@ -250,6 +334,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
}
}
@@ -264,6 +349,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
}
update_settings(settings)
@@ -303,3 +389,16 @@ pub fn get_effective_current_provider(
// Fallback 到数据库的 is_current
db.get_current_provider(app_type.as_str())
}
// ===== Skill 同步方式管理函数 =====
/// 获取 Skill 同步方式配置
pub fn get_skill_sync_method() -> SyncMethod {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.skill_sync_method
}
+199 -42
View File
@@ -15,6 +15,7 @@ pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub quit: &'static str,
pub auto_label: &'static str,
}
impl TrayTexts {
@@ -24,17 +25,20 @@ impl TrayTexts {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
auto_label: "Auto (Failover)",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
auto_label: "自動 (フェイルオーバー)",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
auto_label: "自动 (故障转移)",
},
}
}
@@ -50,6 +54,9 @@ pub struct TrayAppSection {
pub log_name: &'static str,
}
/// Auto 菜单项后缀
pub const AUTO_SUFFIX: &str = "auto";
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
@@ -84,6 +91,7 @@ fn append_provider_section<'a>(
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
app_state: &AppState,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
@@ -111,6 +119,23 @@ fn append_provider_section<'a>(
return Ok(menu_builder.item(&empty_hint));
}
// 获取 proxy 状态,决定 Auto 是否选中
let (proxy_enabled, auto_failover) =
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
let auto_mode = proxy_enabled && auto_failover;
// 添加 Auto 菜单项(始终显示在供应商列表前)
let auto_item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, AUTO_SUFFIX),
tray_texts.auto_label,
true,
auto_mode,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&auto_item);
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
@@ -131,7 +156,8 @@ fn append_provider_section<'a>(
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
// Auto 模式下所有供应商都不选中
let is_current = !auto_mode && manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
@@ -150,13 +176,27 @@ fn append_provider_section<'a>(
/// 处理供应商托盘事件
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
if let Some(suffix) = event_id.strip_prefix(section.prefix) {
// 处理 Auto 点击
if suffix == AUTO_SUFFIX {
log::info!("切换到{} Auto模式", section.log_name);
let app_handle = app.clone();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = handle_auto_click(&app_handle, &app_type) {
log::error!("切换{}Auto模式失败: {e}", section.log_name);
}
});
return true;
}
// 处理供应商点击
log::info!("切换到{}供应商: {suffix}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let provider_id = suffix.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
if let Err(e) = handle_provider_click(&app_handle, &app_type, &provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
@@ -166,6 +206,143 @@ pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> boo
false
}
/// 处理 Auto 点击:启用 proxy 和 auto_failover
fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), AppError> {
if let Some(app_state) = app.try_state::<AppState>() {
let app_type_str = app_type.as_str();
// 强一致语义:Auto 模式开启后立即切到队列 P1(P1→P2→...)
// 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户陷入无法开启的死锁。
let mut queue = app_state.db.get_failover_queue(app_type_str)?;
if queue.is_empty() {
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, app_type)?;
let Some(current_id) = current_id else {
return Err(AppError::Message(
"故障转移队列为空,且未设置当前供应商,无法启用 Auto 模式".to_string(),
));
};
app_state
.db
.add_to_failover_queue(app_type_str, &current_id)?;
queue = app_state.db.get_failover_queue(app_type_str)?;
}
let p1_provider_id = queue
.first()
.map(|item| item.provider_id.clone())
.ok_or_else(|| AppError::Message("故障转移队列为空,无法启用 Auto 模式".to_string()))?;
// 真正启用 failover:启动代理服务 + 执行接管 + 开启 auto_failover
let proxy_service = &app_state.proxy_service;
// 1) 确保代理服务运行(会自动设置 proxy_enabled = true
let is_running = futures::executor::block_on(proxy_service.is_running());
if !is_running {
log::info!("[Tray] Auto 模式:启动代理服务");
if let Err(e) = futures::executor::block_on(proxy_service.start()) {
log::error!("[Tray] 启动代理服务失败: {e}");
return Err(AppError::Message(format!("启动代理服务失败: {e}")));
}
}
// 2) 执行 Live 配置接管(确保该 app 被代理接管)
log::info!("[Tray] Auto 模式:对 {app_type_str} 执行接管");
if let Err(e) =
futures::executor::block_on(proxy_service.set_takeover_for_app(app_type_str, true))
{
log::error!("[Tray] 执行接管失败: {e}");
return Err(AppError::Message(format!("执行接管失败: {e}")));
}
// 3) 设置 auto_failover_enabled = true
app_state
.db
.set_proxy_flags_sync(app_type_str, true, true)?;
// 3.1) 立即切到队列 P1(热切换:不写 Live,仅更新 DB/settings/备份)
if let Err(e) = futures::executor::block_on(
proxy_service.switch_proxy_target(app_type_str, &p1_provider_id),
) {
log::error!("[Tray] Auto 模式切换到队列 P1 失败: {e}");
return Err(AppError::Message(format!(
"Auto 模式切换到队列 P1 失败: {e}"
)));
}
// 4) 更新托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_menu(Some(new_menu));
}
}
// 5) 发射事件到前端
let event_data = serde_json::json!({
"appType": app_type_str,
"proxyEnabled": true,
"autoFailoverEnabled": true,
"providerId": p1_provider_id
});
if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) {
log::error!("发射 proxy-flags-changed 事件失败: {e}");
}
// 发射 provider-switched 事件(保持向后兼容,Auto 切换也算一种切换)
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射 provider-switched 事件失败: {e}");
}
}
Ok(())
}
/// 处理供应商点击:关闭 auto_failover + 切换供应商
fn handle_provider_click(
app: &tauri::AppHandle,
app_type: &AppType,
provider_id: &str,
) -> Result<(), AppError> {
if let Some(app_state) = app.try_state::<AppState>() {
let app_type_str = app_type.as_str();
// 获取当前 proxy 状态,保持 enabled 不变,只关闭 auto_failover
let (proxy_enabled, _) = app_state.db.get_proxy_flags_sync(app_type_str);
app_state
.db
.set_proxy_flags_sync(app_type_str, proxy_enabled, false)?;
// 切换供应商
crate::commands::switch_provider(
app_state.clone(),
app_type_str.to_string(),
provider_id.to_string(),
)
.map_err(AppError::Message)?;
// 更新托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_menu(Some(new_menu));
}
}
// 发射事件到前端
let event_data = serde_json::json!({
"appType": app_type_str,
"proxyEnabled": proxy_enabled,
"autoFailoverEnabled": false,
"providerId": provider_id
});
if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) {
log::error!("发射 proxy-flags-changed 事件失败: {e}");
}
// 发射 provider-switched 事件(保持向后兼容)
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射 provider-switched 事件失败: {e}");
}
}
Ok(())
}
/// 创建动态托盘菜单
pub fn create_tray_menu(
app: &tauri::AppHandle,
@@ -174,6 +351,9 @@ pub fn create_tray_menu(
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
// Get visible apps setting, default to all visible
let visible_apps = app_settings.visible_apps.unwrap_or_default();
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
@@ -183,7 +363,13 @@ pub fn create_tray_menu(
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
// Only add visible app sections
for section in TRAY_SECTIONS.iter() {
// Skip hidden apps
if !visible_apps.is_visible(&section.app_type) {
continue;
}
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
@@ -197,8 +383,14 @@ pub fn create_tray_menu(
current: current_id,
};
menu_builder =
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
menu_builder = append_provider_section(
app,
menu_builder,
Some(&manager),
section,
&tray_texts,
app_state,
)?;
}
// 分隔符和退出菜单
@@ -263,38 +455,3 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
}
/// 内部切换供应商函数
pub fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: AppType,
provider_id: String,
) -> Result<(), AppError> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
.map_err(AppError::Message)?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {e}");
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {e}");
}
}
Ok(())
}
+104 -78
View File
@@ -1,8 +1,6 @@
use reqwest::Client;
use rquickjs::{Context, Function, Runtime};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use url::{Host, Url};
use crate::error::AppError;
@@ -15,13 +13,21 @@ pub async fn execute_usage_script(
timeout_secs: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<Value, AppError> {
// 检测是否为自定义模板模式
// 优先使用前端传递的 template_type
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
// 1. 替换模板变量,避免泄露敏感信息
let script_with_vars =
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
// 2. 验证 base_url 的安全性
validate_base_url(base_url)?;
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
if !base_url.is_empty() {
validate_base_url(base_url)?;
}
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
@@ -99,7 +105,8 @@ pub async fn execute_usage_script(
})?;
// 5. 验证请求 URL 是否安全(防止 SSRF)
validate_request_url(&request.url, base_url)?;
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
validate_request_url(&request.url, base_url, is_custom_template)?;
// 6. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?;
@@ -215,18 +222,10 @@ struct RequestConfig {
/// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 约束超时范围,防止异常配置导致长时间阻塞
let timeout = timeout_secs.clamp(2, 30);
let client = Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
// 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| {
@@ -237,7 +236,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
)
})?;
let mut req = client.request(method.clone(), &config.url);
let mut req = client
.request(method.clone(), &config.url)
.timeout(request_timeout);
// 添加请求头
for (k, v) in &config.headers {
@@ -269,7 +270,11 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
if !status.is_success() {
let preview = if text.len() > 200 {
format!("{}...", &text[..200])
let mut safe_cut = 200usize;
while !text.is_char_boundary(safe_cut) {
safe_cut = safe_cut.saturating_sub(1);
}
format!("{}...", &text[..safe_cut])
} else {
text.clone()
};
@@ -476,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
}
/// 验证请求 URL 是否安全(防止 SSRF)
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
fn validate_request_url(
request_url: &str,
base_url: &str,
is_custom_template: bool,
) -> Result<(), AppError> {
// 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized(
@@ -486,19 +495,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
)
})?;
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_request_loopback = is_loopback_host(&parsed_request);
// 必须使用 HTTPS(允许 localhost 用于开发)
if parsed_request.scheme() != "https" && !is_request_loopback {
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized(
"usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
@@ -506,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 如果提供了 base_url(非空),则进行同源检查
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
if !base_url.is_empty() && !is_custom_template {
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
}
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
} else {
// 自定义模板模式:没有 base_url,需要额外的安全检查
// 禁止访问私有 IP 地址(SSRF 防护)
if let Some(host) = parsed_request.host_str() {
if is_private_ip(host) && !is_request_loopback {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址(localhost 除外)",
"Access to private IP addresses is blocked (localhost allowed)",
));
}
}
}
@@ -847,7 +873,7 @@ mod tests {
];
for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url);
let result = validate_request_url(request_url, base_url, false);
if should_match {
assert!(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.9.0-3",
"version": "3.10.2",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+1
View File
@@ -4,6 +4,7 @@
"windows": [
{
"label": "main",
"title": "CC Switch",
"titleBarStyle": "Visible",
"minWidth": 900,
"minHeight": 600

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