Compare commits

..

79 Commits

Author SHA1 Message Date
YoVinchen b69a052009 Merge branch 'main' into refactor/storage
# Conflicts:
#	src-tauri/src/database.rs
#	src/components/DeepLinkImportDialog.tsx
#	src/components/skills/SkillsPage.tsx
#	src/i18n/locales/en.json
#	src/i18n/locales/zh.json
2025-11-25 08:50:11 +08:00
YoVinchen 1c739b7841 refactor(ui): improve icon rendering consistency and spacing
Standardize icon sizes and improve rendering consistency across the
application interface.

Changes:
- ProviderIcon: Add fontSize sync with size prop for embedded SVG
  scaling, improve fallback text sizing calculation
- AppSwitcher: Replace brand icon components with unified ProviderIcon,
  standardize icon size to 20px across all app tabs
- ProviderCard: Reduce icon size from 26px to 20px for better visual
  balance in card layout
- DeepLinkImportDialog: Increase confirmation icon size from 64px to
  80px for better visibility
- BasicFormFields: Reduce icon picker button spacing from space-y-6 to
  space-y-2 for improved layout density

Icon rendering improvements:
- Embedded SVGs now properly scale with fontSize set to match size prop
- Fallback initials use responsive font sizing (50% of icon size,
  minimum 12px)
- Consistent 20px standard for navigation and card icons
- Better visual hierarchy with appropriate sizing for different contexts

This refactor improves visual consistency and makes icon rendering
more predictable across different components.
2025-11-24 23:55:46 +08:00
YoVinchen dca1d822da chore: add deeplink testing HTML page
Add a local HTML page for testing deeplink protocol functionality
during development.

This page allows developers to:
- Test different deeplink URL formats (provider/prompt/mcp/skill)
- Verify URL parsing and parameter encoding
- Quickly validate deeplink imports without external tools
- Debug protocol registration on different platforms

Not intended for production use, only for development testing.
2025-11-24 22:56:04 +08:00
YoVinchen 65f50a0a29 feat(i18n): add translations for deeplink and skills features
Add internationalization support for new deeplink import features
and skills page filtering.

Deeplink translations:
- Add "icon" field label for provider icon selection
- Both Chinese ("图标") and English ("Icon") translations

Skills page translations:
- Add filter placeholder and options
- Filter states: "all", "installed", "uninstalled"
- Chinese: "全部", "已安装", "未安装"
- English: "All", "Installed", "Not installed"

These translations ensure consistent multilingual support for the
new multi-resource deeplink import system and improved skills
management UI.
2025-11-24 22:46:27 +08:00
YoVinchen 3aa3f00251 test(deeplink): migrate tests to use in-memory database
Update deeplink import tests to use the new in-memory database
instead of filesystem-based configuration.

Changes:
- Replace MultiAppConfig with Database-based AppState
- Use Database::memory() for isolated test instances
- Update provider verification to query database directly
- Add icon field verification in test assertions
- Remove filesystem config.json validation (now DB-backed)

Test improvements:
- Faster execution (no disk I/O)
- Better isolation (each test gets fresh DB instance)
- No cleanup required (memory DB auto-discarded)
- Consistent with v3.8+ storage architecture

Updated tests:
- deeplink_import_claude_provider_persists_to_db
- deeplink_import_codex_provider_builds_auth_and_config

Both tests verify that deeplink imports correctly persist provider
data to the database with all expected fields.
2025-11-24 22:45:49 +08:00
YoVinchen 7749e325a2 feat(frontend): add deeplink import event listeners and UI improvements
Add event-driven refresh logic for deeplink imports and enhance
Skills page filtering capabilities.

PromptPanel changes:
- Add "prompt-imported" custom event listener
- Auto-reload prompts when deeplink import completes
- Filter events by app ID to avoid unnecessary refreshes
- Clean up event listener on component unmount

SkillsPage improvements:
- Add installation status filter (all/installed/uninstalled)
- Implement Select component for filter dropdown
- Combine status filter with existing search functionality
- Update filtered skills memo to include both filters
- Improve responsive layout for search and filter controls

Event flow:
1. DeepLinkImportDialog dispatches "prompt-imported" event
2. PromptPanel listens for event matching its app
3. Panel triggers reload to show newly imported prompt
4. Similar pattern can be used for other non-React-Query resources

These improvements enable seamless UI updates after deeplink imports
without requiring manual page refresh.
2025-11-24 22:45:05 +08:00
YoVinchen 3b500be525 feat(frontend): update DeepLinkImportDialog for multi-resource imports
Refactor the main deeplink import dialog to handle all resource types
with proper confirmation UI and post-import actions.

Key changes:
- Add resource-specific confirmation components (Prompt/MCP/Skill)
- Implement typed result handling with discriminated unions
- Add MCP result type guard for backward compatibility
- Implement resource-specific cache invalidation strategies
- Add custom event dispatching for non-React-Query resources

Import flow improvements:
- Provider: Invalidate provider queries, show success toast
- Prompt: Dispatch "prompt-imported" event, trigger manual refresh
- MCP: Aggressive cache invalidation with refetchQueries, handle
  partial success (show warning if some imports failed)
- Skill: Force refetch skills query with refetchType: "all"

Error handling:
- Graceful fallback for legacy backend responses (no type field)
- MCP-specific result detection via type guard
- Detailed error messages in toasts

UI improvements:
- Dynamic dialog title based on resource type
- Resource-specific confirmation content rendering
- Better visual feedback during import process
2025-11-24 22:44:02 +08:00
YoVinchen 1dc1f86560 feat(frontend): add resource-specific confirmation dialog components
Add specialized confirmation UI components for each deeplink import
resource type (Prompt, MCP, Skill).

Components:
- PromptConfirmation: Display prompt name, app, description, and
  content preview with markdown rendering
- McpConfirmation: Show MCP server list with target apps, supports
  batch import display
- SkillConfirmation: Display GitHub repository info with branch and
  directory details

Features:
- Consistent card-based layout with proper spacing
- Sensitive data masking (API keys shown as dots)
- Icon support for providers (ProviderIcon component)
- Badge components for visual status indicators
- Responsive design with proper text overflow handling

Each component focuses on displaying the most relevant information
for users to make informed import decisions.
2025-11-24 22:43:21 +08:00
YoVinchen 7574d049ff feat(frontend): extend deeplink API for multi-resource support
Update the frontend deeplink API to support importing multiple
resource types with proper TypeScript typing.

Changes:
- Add ResourceType union type: "provider" | "prompt" | "mcp" | "skill"
- Convert DeepLinkImportRequest fields to optional (matching backend)
- Add resource-specific field types (prompt, mcp, skill)
- Add ImportResult discriminated union for type-safe results
- Add McpImportResult interface for batch import results
- Update importFromDeeplink to use unified command

Type safety improvements:
- ImportResult discriminated union ensures proper type narrowing
- Each result type has its own specific return data structure
- Frontend can pattern match on result.type for correct handling

Breaking change:
- importFromDeeplink now returns ImportResult instead of string
- Callers must handle all resource types appropriately
2025-11-24 22:40:47 +08:00
YoVinchen d4487755bf feat(backend): add unified deeplink import command
Add a new unified command handler for importing all resource types
via deeplinks, replacing the legacy provider-only import command.

Changes:
- Add import_from_deeplink_unified command supporting all resource types
- Keep import_from_deeplink for backward compatibility (now marked legacy)
- Route imports based on request.resource field (provider/prompt/mcp/skill)
- Return typed ImportResult with resource-specific data

Return types:
- Provider: { type: "provider", id: string }
- Prompt: { type: "prompt", id: string }
- MCP: { type: "mcp", importedCount, importedIds, failed }
- Skill: { type: "skill", key: string }

The unified handler simplifies frontend logic by providing consistent
return types and error handling across all resource types.
2025-11-24 22:40:04 +08:00
YoVinchen 3264d71a4d refactor(deeplink): extend support for multi-resource imports
Extend the deeplink import system to support importing multiple
resource types beyond providers: prompts, MCP servers, and skills.

Breaking changes:
- DeepLinkImportRequest: Convert required fields to Optional to
  support different resource types (app, name, homepage, endpoint,
  apiKey are now Option<String>)
- Add resource-specific fields for prompt, mcp, and skill imports

New features:
- parse_prompt_deeplink: Parse prompt import URLs with base64 content
- parse_mcp_deeplink: Parse MCP server import URLs with config
- parse_skill_deeplink: Parse GitHub skill repository URLs
- import_prompt_from_deeplink: Import prompts to database
- import_mcp_from_deeplink: Batch import MCP servers with multi-app support
- import_skill_from_deeplink: Clone and install skill repositories

Data model additions:
- Prompt fields: content (base64), description, enabled
- MCP fields: apps (comma-separated), config, config_format
- Skill fields: repo, directory, branch, skills_path
- Common fields: icon (provider icon name)

McpImportResult type:
- imported_count: Number of successfully imported servers
- imported_ids: List of imported server IDs
- failed: List of failed imports with error messages

URL format examples:
- Prompt: ccswitch://v1/import?resource=prompt&app=claude&name=...&content=...
- MCP: ccswitch://v1/import?resource=mcp&apps=claude,codex&config=...
- Skill: ccswitch://v1/import?resource=skill&repo=owner/name&directory=...

This refactor enables one-click sharing of prompts, MCP configurations,
and skill repositories via deeplink URLs.
2025-11-24 22:38:35 +08:00
YoVinchen b20e121013 feat(backend): add in-memory database mode for testing
Add support for creating in-memory SQLite database instances to
improve test isolation and performance.

Changes:
- Add Database::memory() constructor for in-memory database
- Enable foreign key constraints for data integrity
- Export Database type from lib.rs for test usage
- Initialize tables automatically on memory database creation

This enables unit tests to run without filesystem dependencies and
provides faster test execution with proper cleanup.
2025-11-24 22:37:15 +08:00
YoVinchen e98acf94fc feat(utils): add base64 encoding utility functions
Add reusable base64 encoding/decoding utility functions for handling
binary data and string conversions in deeplink imports.

Features:
- encodeBase64: Encode string to base64
- decodeBase64: Decode base64 to string
- Uses browser-native btoa/atob with proper UTF-8 handling

This utility will be used for encoding prompt content and configuration
files in deeplink URLs.
2025-11-24 22:36:12 +08:00
YoVinchen 304d14b1ab feat(icons): add PackyCode provider icon support
Add PackyCode as a supported AI provider icon with proper metadata
and filtering configuration.

Changes:
- Add 'packycode' to icon filter whitelist in filter-icons.js
- Register PackyCode metadata with display name, category, and keywords
- Import PackyCode SVG icon file
- Export icon through index.ts for use in provider configurations

The PackyCode icon uses currentColor to adapt to theme styling.
2025-11-24 22:35:30 +08:00
YoVinchen 09a87c97b8 fix(backend): remove unnecessary dereference in backup operation
- Simplify Backup::new call by removing redundant dereference
- MutexGuard already implements DerefMut
2025-11-24 00:49:24 +08:00
YoVinchen d31531f88b feat(i18n): update translations for SQL backup feature
- Update Chinese translations for SQL import/export
- Update English translations for SQL import/export
- Change terminology from 'config file' to 'SQL backup'
- Update error messages and UI hints
2025-11-24 00:47:48 +08:00
YoVinchen ae21754d50 feat(frontend): update import/export UI for SQL backup
- Change default export filename from .json to .sql
- Update file format to timestamp format (YYYYMMDD_HHMMSS)
- Update error messages to reference SQL files
- Align with backend SQL export/import implementation
2025-11-24 00:46:58 +08:00
YoVinchen 7c1f13e4f3 refactor(backend): migrate settings storage to database
- Add bind_db function to initialize database-backed settings
- Implement load_initial_settings with database fallback
- Replace direct file save with settings store management
- Add SETTINGS_DB static for database binding
- Maintain backward compatibility with file-based settings
- Keep settings.json for legacy migration support
2025-11-24 00:46:32 +08:00
YoVinchen fd25c9949f refactor(backend): migrate import/export to use SQL backup
- Reimplement export_config_to_file to use database.export_sql
- Reimplement import_config_from_file to use database.import_sql
- Add sync_current_from_db to sync live configs after import
- Add settings database binding on app initialization
- Remove deprecated JSON-based config import logic
2025-11-24 00:46:00 +08:00
YoVinchen 6443dc897d feat(backend): add database SQL export/import with backup
- Enable rusqlite backup feature for SQL dump support
- Implement export_sql to generate SQLite-compatible SQL dumps
- Implement import_sql with automatic backup before import
- Add snapshot_to_memory to avoid long-held database locks
- Add backup rotation to retain latest 10 backups
- Support atomic import with rollback on failure
2025-11-24 00:45:34 +08:00
YoVinchen 7aa381cbb7 feat(deeplink): display all four Claude model fields in import dialog
- Show haiku/sonnet/opus/multiModel fields conditionally for Claude
- Maintain single model field display for Codex and Gemini
- Add i18n translations for new model field labels (zh/en)
2025-11-23 20:35:28 +08:00
YoVinchen 1de3f1b7f8 docs(frontend): add code comments and improve formatting
- Add explanatory comment in EditProviderDialog about config assembly
- Improve import formatting in SkillsPage for better readability
2025-11-23 16:30:30 +08:00
YoVinchen edc71efe4c fix(skills): auto-sync locally installed skills to database
Add automatic synchronization in get_skills command:
- Detect locally installed skills in ~/.claude/skills/
- Auto-add to database if not already tracked
- Ensures existing skills are recognized on first launch

This fixes the issue where user's existing skills were not
imported into the database on initial application run.
2025-11-23 16:25:55 +08:00
YoVinchen 3faf22f1c9 feat(init): implement automatic data import on first launch
Add comprehensive first-launch data import system:

Database layer:
- Add is_empty_for_first_import() to detect empty database
- Add init_default_skill_repos() to initialize 3 default skill repositories

Services layer:
- Implement McpService::import_from_claude/codex/gemini()
  to import MCP servers from existing config files
- Implement PromptService::import_from_file_on_first_launch()
  to import prompt files (CLAUDE.md, AGENTS.md, GEMINI.md)

Startup flow (lib.rs):
- Check if database is empty on startup
- Import existing configurations if detected:
  1. Initialize default skill repositories
  2. Import provider configurations from live settings
  3. Import MCP servers from config files
  4. Import prompt files
- All imports are fault-tolerant and logged

This ensures seamless migration from file-based configs to database.
2025-11-23 16:24:54 +08:00
YoVinchen 0cb8b30f15 refactor(backend): replace unsafe unwrap calls with proper error handling
- Add to_json_string helper for safe JSON serialization
- Add lock_conn macro for safe Mutex locking
- Replace 41 unwrap() calls with proper error handling:
  - database.rs: JSON serialization and Mutex operations (31 fixes)
  - lib.rs: macOS NSWindow and tray icon handling (3 fixes)
  - services/provider.rs: Claude model normalization (1 fix)
  - services/prompt.rs: timestamp generation (3 fixes)
  - services/skill.rs: directory name extraction (2 fixes)
  - mcp.rs: HashMap initialization and type conversions (5 fixes)
  - app_config.rs: timestamp fallback (1 fix)

This improves application stability and prevents potential panics.
2025-11-23 16:23:18 +08:00
YoVinchen be1c2ac76e feat(skills): add search functionality to Skills page
- Add search input with Search icon in SkillsPage component
- Implement useMemo-based filtering by skill name, description, and directory
- Display search results count when filtering is active
- Show "no results" message when no skills match the search query
- Add i18n translations for search UI (zh/en)
- Maintain responsive layout and consistent styling with existing UI
2025-11-23 00:10:07 +08:00
YoVinchen e7451bda22 Merge branch 'main' into refactor/storage 2025-11-22 23:29:48 +08:00
YoVinchen 5a3420932b refactor(frontend): update UI components for database migration
- Update UsageFooter component to handle new data structure
- Modify SkillsPage to work with database-backed skills management
- Ensure frontend compatibility with refactored backend
2025-11-22 23:28:35 +08:00
YoVinchen a2688603fb refactor(backend): update supporting modules for database compatibility
- Add DatabaseError variant to AppError enum
- Update provider module to support database-backed operations
- Modify codex_config to work with new database structure
- Ensure error handling covers database operations
2025-11-22 23:27:54 +08:00
YoVinchen 23a407544a refactor(commands): update command layer to use database API
- Update config commands to query database for providers and settings
- Modify provider commands to pass database handle to services
- Update MCP commands to use database-backed operations
- Refactor prompt and skill commands to leverage database storage
- Simplify import/export commands with database integration
2025-11-22 23:27:27 +08:00
YoVinchen 2b34dc4ec9 refactor(services): migrate service layer to use SQLite database
- Refactor ProviderService to use database queries instead of in-memory config
- Update McpService to fetch and store MCP servers in database
- Migrate PromptService to database-backed storage
- Simplify ConfigService by removing complex transaction logic
- Remove 648 lines of redundant code through database abstraction
2025-11-22 23:26:54 +08:00
YoVinchen 529051f0e8 refactor(core): integrate SQLite database into application core
- Initialize database on app startup with migration from JSON config
- Update AppState to include Database instance alongside MultiAppConfig
- Simplify store module by removing unused session management code
- Add database initialization to app setup flow
- Support both database and legacy config during transition
2025-11-22 23:26:41 +08:00
YoVinchen 5d1eed563d feat(database): add SQLite database infrastructure
- Add rusqlite dependency (v0.32.1) and r2d2 connection pooling
- Implement Database module with CRUD operations for providers, MCP servers, prompts, and skills
- Add schema initialization with proper indexes
- Include data migration utilities from JSON config to SQLite
- Support timestamp tracking (created_at, updated_at)
2025-11-22 23:23:56 +08:00
YoVinchen 6e7547ef6e Merge branch 'main' into refactor/storage 2025-11-22 19:57:01 +08:00
YoVinchen f02efbd2b7 Merge branch 'main' into refactor/ui
# Conflicts:
#	src/components/skills/SkillsPage.tsx
2025-11-22 18:46:32 +08:00
YoVinchen a39b1d8698 refactor(usage): improve footer layout with two-row design
Reorganize usage footer for better readability and space efficiency.
- Split into two rows: update time + refresh button (row 1), usage stats (row 2)
- Move refresh button to top row next to update time
- Remove card background for cleaner look
- Add fallback text when never updated
- Improve spacing and alignment
- Format template literals for consistency
2025-11-22 18:44:35 +08:00
YoVinchen 86255fe106 style(deeplink): format test page HTML for better readability
Improve HTML formatting in deeplink test page.
- Format multiline attributes for better readability
- Add consistent indentation to nested elements
- Break long lines in buttons and links
2025-11-22 18:09:52 +08:00
YoVinchen 4acd48adc9 i18n: add config merge error message
Add translation for config file merge error handling.
2025-11-22 18:06:25 +08:00
YoVinchen d4cd8105d1 feat(deeplink): merge and display config in import dialog
Enhance import dialog to fetch and display complete config.
- Call mergeDeeplinkConfig API when config is present
- Add UTF-8 base64 decoding support for config content
- Add scrollable content area with custom scrollbar styling
- Show complete configuration before user confirms import
2025-11-22 18:05:52 +08:00
YoVinchen 2b1ae2aa71 feat(deeplink): add config merge command for preview
Expose config merging functionality to frontend for preview.
- Add merge_deeplink_config Tauri command
- Make parse_and_merge_config public for reuse
- Enable frontend to display complete config before import
2025-11-22 17:58:18 +08:00
YoVinchen cf57fbed7b style: format template literals for better readability
Improve code formatting for conditional className expressions.
- Break long template literals across multiple lines
- Maintain consistent formatting in MCP form and endpoint test components
2025-11-22 17:22:36 +08:00
YoVinchen eefb764f72 style: unify list item styles with semantic colors
Apply consistent styling to list items across components.
- Replace hardcoded colors with semantic tokens in MCP and Prompt list items
- Add glass effect container to EndpointSpeedTest panel
- Format code for better readability
2025-11-22 16:50:35 +08:00
YoVinchen 4ed3e3bf84 refactor(mcp): improve form modal layout with adaptive height editor
Restructure MCP form modal for better space utilization.
- Split form into upper form fields and lower JSON editor sections
- Add full-height mode support for JsonEditor component
- Use flex layout for editor to fill available space
- Update PromptFormPanel to use full-height editor
- Fix locale text formatting
2025-11-22 16:11:06 +08:00
YoVinchen 99471f6706 style(providers): optimize card layout and action button sizes
Improve provider card visual density and action buttons.
- Reduce icon button sizes for compact layout
- Adjust drag handle and icon sizes
- Tighten spacing between action buttons
- Update hover translate values for better alignment
2025-11-22 15:37:52 +08:00
YoVinchen 4210b1547c refactor(settings): simplify settings page layout and auto-save
Reorganize settings page structure and integrate autoSaveSettings.
- Move save button inline within Advanced tab content
- Remove sticky footer for cleaner layout
- Use autoSaveSettings for General tab settings
- Simplify dialog close behavior
- Refactor ImportExportSection layout
2025-11-22 15:35:08 +08:00
YoVinchen cfee4d6fcc feat(settings): add autoSaveSettings for lightweight auto-save
Add optimized auto-save function for General tab settings.
- Add autoSaveSettings method for non-destructive auto-save
- Only trigger system APIs when values actually change
- Avoid unnecessary auto-launch and plugin config updates
- Update tests to cover new functionality
2025-11-22 15:27:32 +08:00
YoVinchen 24dc628130 feat(deeplink): enhance test page with v3.8 config file examples
Improve deeplink test page with comprehensive config file import examples.
- Add version badge for v3.8 features
- Add copy-to-clipboard functionality for all deep links
- Add Claude config file import examples (embedded/remote)
- Add Codex config file import examples (auth.json + config.toml)
- Add Gemini config file import examples (.env format)
- Add config generator tool for easy testing
- Update UI with better styling and layout
2025-11-22 14:07:55 +08:00
YoVinchen bf74620051 i18n: add deeplink config preview translations
Add missing translation keys for config file preview feature.
- Add configSource, configEmbedded, configRemote labels
- Add configDetails and configUrl display strings
- Support both Chinese and English versions
2025-11-22 14:05:43 +08:00
YoVinchen e8d4397b3a refactor(ui): unify dialog styles and improve layout consistency
Standardize dialog and panel components across the application.
- Update dialog background to use semantic color tokens
- Adjust FullScreenPanel max-width to 56rem for better alignment
- Add drag region and prevent body scroll in full-screen panels
- Optimize button sizes and spacing in panel headers
- Apply consistent styling to all dialog-based components
2025-11-22 14:03:09 +08:00
YoVinchen 2b0bc73276 feat(deeplink): enhance dialog with config file preview
Add config file parsing and preview in deep link import dialog.
- Support Base64 encoded config display
- Add config file source indicator (embedded/remote)
- Parse and display config fields by app type (Claude/Codex/Gemini)
- Mask sensitive values in config preview
- Improve dialog layout and content organization
2025-11-22 14:02:22 +08:00
YoVinchen 939a2e4f2b feat(deeplink): add config file support for deeplink import
Support importing provider configuration from embedded or remote config files.
- Add base64 dependency for config content encoding
- Support config, configFormat, and configUrl parameters
- Make homepage/endpoint/apiKey optional when config is provided
- Add config parsing and merging logic
2025-11-22 14:00:15 +08:00
YoVinchen 1a89267986 feat(deeplink): add Claude model fields support and enhance import dialog
- Add Claude-specific model field support in deeplink import:
  * Support model (ANTHROPIC_MODEL) - general default model
  * Support haikuModel (ANTHROPIC_DEFAULT_HAIKU_MODEL)
  * Support sonnetModel (ANTHROPIC_DEFAULT_SONNET_MODEL)
  * Support opusModel (ANTHROPIC_DEFAULT_OPUS_MODEL)
  * Backend: Update DeepLinkImportRequest struct to include optional model fields
  * Frontend: Add TypeScript type definitions for new model parameters

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

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

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

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

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

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

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

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

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

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

This improves performance, reduces external dependencies, and provides a cleaner UI experience.
2025-11-22 01:20:21 +08:00
YoVinchen e7545f8cdf Merge branch 'main' into refactor/ui
# Conflicts:
#	src-tauri/src/services/skill.rs
#	src/components/skills/SkillsPage.tsx
2025-11-22 00:04:28 +08:00
YoVinchen 838a99b5d2 chore(deps): add icon library and update preset configurations
Add dependencies and utility scripts for icon system:

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

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

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

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

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

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

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

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

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

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

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

These translations support the new icon picker UI components
and provider form icon selection interface.
2025-11-21 23:25:46 +08:00
YoVinchen 0c1d94e57b feat(backend): add icon fields to Provider model and default mappings
Extend Rust backend to support provider icon customization:

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

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

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

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

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

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

## Type System Updates

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

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

## UI Components

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

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

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

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

## New Components

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

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

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

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

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

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

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

This foundation enables automatic icon assignment when users add
providers, improving visual identification in the provider list.
2025-11-21 23:19:48 +08:00
YoVinchen 8f218057f3 Merge branch 'fix/third-party-skills-installation' into refactor/ui 2025-11-21 12:35:13 +08:00
YoVinchen 81a6c08673 fix(skills): resolve third-party skills installation failure
- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories
2025-11-21 12:33:12 +08:00
YoVinchen 988ea326d9 style(ui): improve window dragging and provider card styles 2025-11-21 11:44:33 +08:00
YoVinchen f1b0fa2985 test: update test suites to match component refactoring
Comprehensive test updates to align with recent component refactoring
and new auto-launch functionality.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

These components establish the foundation for the upcoming settings
page migration from dialog-based to full-screen layout.
2025-11-21 09:27:20 +08:00
82 changed files with 5779 additions and 8096 deletions
+3 -4
View File
@@ -4,7 +4,7 @@
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文、中文、日文翻译文件
3. **翻译文件**:创建了英文和中文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
@@ -16,7 +16,6 @@ src/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ ├── ja.json # 日文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
@@ -25,7 +24,7 @@ src/
## 默认语言设置
- **默认语言**文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
- **默认语言**文 (en)
- **回退语言**:英文 (en)
## 使用方式
@@ -58,7 +57,7 @@ src/
## 测试功能
应用已添加了语言切换按钮,支持中文、英文、日文三种语言切换,验证国际化功能是否正常工作。
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
## 已更新的组件
+3 -4
View File
@@ -32,10 +32,7 @@
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
"vitest": "^2.0.5"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -59,6 +56,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
@@ -78,6 +76,7 @@
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
+255 -578
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-7
View File
@@ -7,14 +7,7 @@ fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// Windows 平台的 AutoLaunch::new 只接受 3 个参数
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数)
#[cfg(target_os = "windows")]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]);
#[cfg(not(target_os = "windows"))]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
Ok(auto_launch)
}
+2 -7
View File
@@ -44,15 +44,10 @@ pub async fn import_config_from_file(
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
if let Err(err) = ProviderService::sync_current_from_db(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
// 重新加载设置到内存缓存,确保导入的设置生效
if let Err(err) = crate::settings::reload_settings() {
log::warn!("导入后重载设置失败: {err}");
}
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL imported successfully",
@@ -69,7 +64,7 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
ProviderService::sync_current_from_db(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
-7
View File
@@ -51,10 +51,3 @@ pub async fn is_portable_mode() -> Result<bool, String> {
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
Ok(crate::init_status::get_init_error())
}
/// 获取 JSON→SQLite 迁移结果(若有)。
/// 只返回一次 true,之后返回 false,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
+5 -3
View File
@@ -86,7 +86,7 @@ pub fn switch_provider(
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
ProviderService::import_default_config(state, app_type)
}
@@ -94,7 +94,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<bool, AppError> {
) -> Result<(), AppError> {
import_default_config_internal(state, app_type)
}
@@ -102,7 +102,9 @@ pub fn import_default_config_test_hook(
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
import_default_config_internal(&state, app_type)
.map(|_| true)
.map_err(Into::into)
}
/// 查询供应商用量
+1 -6
View File
@@ -18,12 +18,7 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
app.restart();
});
Ok(true)
app.restart();
}
/// 获取 app_config_dir 覆盖配置 (从 Store)
+1
View File
@@ -90,6 +90,7 @@ pub async fn install_skill(
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service
File diff suppressed because it is too large Load Diff
-324
View File
@@ -1,324 +0,0 @@
//! 数据库备份和恢复
//!
//! 提供 SQL 导出/导入和二进制快照备份功能。
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
use crate::config::get_app_config_dir;
use crate::error::AppError;
use chrono::Utc;
use rusqlite::backup::Backup;
use rusqlite::types::ValueRef;
use rusqlite::Connection;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
let snapshot = self.snapshot_to_memory()?;
let dump = Self::dump_sql(&snapshot)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
crate::config::atomic_write(target_path, dump.as_bytes())
}
/// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
if !source_path.exists() {
return Err(AppError::InvalidInput(format!(
"SQL 文件不存在: {}",
source_path.display()
)));
}
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = Self::sanitize_import_sql(&sql_raw);
// 导入前备份现有数据库
let backup_path = self.backup_database_file()?;
// 在临时数据库执行导入,确保失败不会污染主库
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
context: "创建临时数据库文件失败".to_string(),
source: e,
})?;
let temp_path = temp_file.path().to_path_buf();
let temp_conn =
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(&sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
Self::create_tables_on_conn(&temp_conn)?;
Self::apply_schema_migrations_on_conn(&temp_conn)?;
Self::validate_basic_state(&temp_conn)?;
// 使用 Backup 将临时库原子写回主库
{
let mut main_conn = lock_conn!(self.conn);
let backup = Backup::new(&temp_conn, &mut main_conn)
.map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
let backup_id = backup_path
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_default();
Ok(backup_id)
}
/// 创建内存快照以避免长时间持有数据库锁
pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
let conn = lock_conn!(self.conn);
let mut snapshot =
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
{
let backup =
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(snapshot)
}
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
fn sanitize_import_sql(sql: &str) -> String {
let mut cleaned = String::new();
let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
}
cleaned
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
if !db_path.exists() {
return Ok(None);
}
let backup_dir = db_path
.parent()
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
.join("backups");
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let backup_path = backup_dir.join(format!("{backup_id}.db"));
{
let conn = lock_conn!(self.conn);
let mut dest_conn =
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
let backup = Backup::new(&conn, &mut dest_conn)
.map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Self::cleanup_db_backups(&backup_dir)?;
Ok(Some(backup_path))
}
/// 清理旧的数据库备份,保留最新的 N 个
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
let entries = match fs::read_dir(dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "db")
.unwrap_or(false)
})
.collect::<Vec<_>>(),
Err(_) => return Ok(()),
};
if entries.len() <= DB_BACKUP_RETAIN {
return Ok(());
}
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
let mut sorted = entries;
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
for entry in sorted.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
}
}
Ok(())
}
/// 基础状态校验
fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
let mcp_count: i64 = conn
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
if provider_count == 0 && mcp_count == 0 {
return Err(AppError::Config(
"导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(),
));
}
Ok(())
}
/// 导出数据库为 SQL 文本
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
let mut output = String::new();
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let user_version: i64 = conn
.query_row("PRAGMA user_version;", [], |row| row.get(0))
.unwrap_or(0);
output.push_str(&format!(
"-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
));
output.push_str("PRAGMA foreign_keys=OFF;\n");
output.push_str(&format!("PRAGMA user_version={user_version};\n"));
output.push_str("BEGIN TRANSACTION;\n");
// 导出 schema
let mut stmt = conn
.prepare(
"SELECT type, name, tbl_name, sql
FROM sqlite_master
WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
ORDER BY type='table' DESC, name",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let mut tables = Vec::new();
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
// 跳过 SQLite 内部对象(如 sqlite_sequence
if name.starts_with("sqlite_") {
continue;
}
output.push_str(&sql);
output.push_str(";\n");
if obj_type == "table" && !name.starts_with("sqlite_") {
tables.push(name);
}
}
// 导出数据
for table in tables {
let columns = Self::get_table_columns(conn, &table)?;
if columns.is_empty() {
continue;
}
let mut stmt = conn
.prepare(&format!("SELECT * FROM \"{table}\""))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let mut values = Vec::with_capacity(columns.len());
for idx in 0..columns.len() {
let value = row
.get_ref(idx)
.map_err(|e| AppError::Database(e.to_string()))?;
values.push(Self::format_sql_value(value)?);
}
let cols = columns
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
output.push_str(&format!(
"INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
values.join(", ")
));
}
}
output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
Ok(output)
}
/// 获取表的列名列表
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
.map_err(|e| AppError::Database(e.to_string()))?;
let iter = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut columns = Vec::new();
for col in iter {
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(columns)
}
/// 格式化 SQL 值
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
match value {
ValueRef::Null => Ok("NULL".to_string()),
ValueRef::Integer(i) => Ok(i.to_string()),
ValueRef::Real(f) => Ok(f.to_string()),
ValueRef::Text(t) => {
let text = std::str::from_utf8(t)
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
let escaped = text.replace('\'', "''");
Ok(format!("'{escaped}'"))
}
ValueRef::Blob(bytes) => {
let mut s = String::from("X'");
for b in bytes {
use std::fmt::Write;
let _ = write!(&mut s, "{b:02X}");
}
s.push('\'');
Ok(s)
}
}
}
}
-97
View File
@@ -1,97 +0,0 @@
//! MCP 服务器数据访问对象
//!
//! 提供 MCP 服务器的 CRUD 操作。
use crate::app_config::{McpApps, McpServer};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 MCP 服务器
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
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
let server_iter = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let server_config_str: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let homepage: Option<String> = row.get(4)?;
let docs: Option<String> = row.get(5)?;
let tags_str: String = row.get(6)?;
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
Ok((
id.clone(),
McpServer {
id,
name,
server,
apps: McpApps {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
},
description,
homepage,
docs,
tags,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut servers = IndexMap::new();
for server_res in server_iter {
let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?;
servers.insert(id, server);
}
Ok(servers)
}
/// 保存 MCP 服务器
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
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)",
params![
server.id,
server.name,
serde_json::to_string(&server.server).unwrap(),
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
server.apps.claude,
server.apps.codex,
server.apps.gemini,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 MCP 服务器
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
-11
View File
@@ -1,11 +0,0 @@
//! 数据访问对象 (DAO) 模块
//!
//! 提供各类数据的 CRUD 操作。
mod mcp;
mod prompts;
mod providers;
mod settings;
mod skills;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
-88
View File
@@ -1,88 +0,0 @@
//! 提示词数据访问对象
//!
//! 提供提示词(Prompt)的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::prompt::Prompt;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取指定应用类型的所有提示词
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ?1
ORDER BY created_at ASC, id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let prompt_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let content: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let enabled: bool = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let updated_at: Option<i64> = row.get(6)?;
Ok((
id.clone(),
Prompt {
id,
name,
content,
description,
enabled,
created_at,
updated_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut prompts = IndexMap::new();
for prompt_res in prompt_iter {
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
prompts.insert(id, prompt);
}
Ok(prompts)
}
/// 保存提示词
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
prompt.id,
app_type,
prompt.name,
prompt.content,
prompt.description,
prompt.enabled,
prompt.created_at,
prompt.updated_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除提示词
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
-290
View File
@@ -1,290 +0,0 @@
//! 供应商数据访问对象
//!
//! 提供供应商(Provider)的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
use indexmap::IndexMap;
use rusqlite::params;
use std::collections::HashMap;
impl Database {
/// 获取指定应用类型的所有供应商
pub fn get_all_providers(
&self,
app_type: &str,
) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
let provider_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let settings_config_str: String = row.get(2)?;
let website_url: Option<String> = row.get(3)?;
let category: Option<String> = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let sort_index: Option<usize> = row.get(6)?;
let notes: Option<String> = row.get(7)?;
let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?;
let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
Ok((
id,
Provider {
id: "".to_string(), // Placeholder, set below
name,
settings_config,
website_url,
category,
created_at,
sort_index,
notes,
meta: Some(meta),
icon,
icon_color,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut providers = IndexMap::new();
for provider_res in provider_iter {
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
provider.id = id.clone();
// 加载 endpoints
let mut stmt_endpoints = conn.prepare(
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
let endpoints_iter = stmt_endpoints
.query_map(params![id, app_type], |row| {
let url: String = row.get(0)?;
let added_at: Option<i64> = row.get(1)?;
Ok((
url,
crate::settings::CustomEndpoint {
url: "".to_string(),
added_at: added_at.unwrap_or(0),
last_used: None,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut custom_endpoints = HashMap::new();
for ep_res in endpoints_iter {
let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?;
ep.url = url.clone();
custom_endpoints.insert(url, ep);
}
if let Some(meta) = &mut provider.meta {
meta.custom_endpoints = custom_endpoints;
}
providers.insert(id, provider);
}
Ok(providers)
}
/// 获取当前激活的供应商 ID
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
Ok(Some(
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
))
} else {
Ok(None)
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta:取出 endpoints 以便单独处理
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
)
.ok();
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
tx.execute(
"UPDATE providers SET
name = ?1,
settings_config = ?2,
website_url = ?3,
category = ?4,
created_at = ?5,
sort_index = ?6,
notes = ?7,
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
provider.id,
app_type,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.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![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![provider.id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除供应商
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 设置当前供应商
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 重置所有为 0
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的当前供应商
tx.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
app_type: &str,
provider_id: &str,
url: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let added_at = chrono::Utc::now().timestamp_millis();
conn.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
params![provider_id, app_type, url, added_at],
).map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 移除自定义端点
pub fn remove_custom_endpoint(
&self,
app_type: &str,
provider_id: &str,
url: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
params![provider_id, app_type, url],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
-65
View File
@@ -1,65 +0,0 @@
//! 通用设置数据访问对象
//!
//! 提供键值对形式的通用设置存储。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::params;
impl Database {
/// 获取设置值
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
Ok(Some(
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
))
} else {
Ok(None)
}
}
/// 设置值
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// --- Config Snippets 辅助方法 ---
/// 获取通用配置片段
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
self.get_setting(&format!("common_config_{app_type}"))
}
/// 设置通用配置片段
pub fn set_config_snippet(
&self,
app_type: &str,
snippet: Option<String>,
) -> Result<(), AppError> {
let key = format!("common_config_{app_type}");
if let Some(value) = snippet {
self.set_setting(&key, &value)
} else {
// 如果为 None 则删除
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
-125
View File
@@ -1,125 +0,0 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState};
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let key: String = row.get(0)?;
let installed: bool = row.get(1)?;
let installed_at_ts: i64 = row.get(2)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
Ok((
key,
SkillState {
installed,
installed_at,
},
))
})
.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);
}
Ok(skills)
}
/// 更新 Skill 状态
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.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(e.to_string()))?;
Ok(())
}
/// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let repo_iter = stmt
.query_map([], |row| {
Ok(SkillRepo {
owner: row.get(0)?,
name: row.get(1)?,
branch: row.get(2)?,
enabled: row.get(3)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut repos = Vec::new();
for repo_res in repo_iter {
repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(repos)
}
/// 保存 Skill 仓库
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
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()))?;
Ok(())
}
/// 删除 Skill 仓库
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
params![owner, name],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化默认的 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 default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
for repo in &default_store.repos {
self.save_skill_repo(repo)?;
count += 1;
}
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
Ok(count)
}
}
-242
View File
@@ -1,242 +0,0 @@
//! JSON → SQLite 数据迁移
//!
//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。
use super::{lock_conn, to_json_string, Database};
use crate::app_config::MultiAppConfig;
use crate::error::AppError;
use rusqlite::{params, Connection};
impl Database {
/// 从 MultiAppConfig 迁移数据到数据库
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
Self::migrate_from_json_tx(&tx, config)?;
tx.commit()
.map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?;
Ok(())
}
/// 运行迁移的 dry-run 模式(在内存数据库中验证,不写入磁盘)
///
/// 用于部署前验证迁移逻辑是否正确。
pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {
let mut conn =
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
Self::create_tables_on_conn(&conn)?;
Self::apply_schema_migrations_on_conn(&conn)?;
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
Self::migrate_from_json_tx(&tx, config)?;
// 显式 drop transaction 而不提交(内存数据库会被丢弃)
drop(tx);
Ok(())
}
/// 在事务中执行迁移
fn migrate_from_json_tx(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
// 1. 迁移 Providers
Self::migrate_providers(tx, config)?;
// 2. 迁移 MCP Servers
Self::migrate_mcp_servers(tx, config)?;
// 3. 迁移 Prompts
Self::migrate_prompts(tx, config)?;
// 4. 迁移 Skills
Self::migrate_skills(tx, config)?;
// 5. 迁移 Common Config
Self::migrate_common_config(tx, config)?;
Ok(())
}
/// 迁移供应商数据
fn migrate_providers(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
for (app_key, manager) in &config.apps {
let app_type = app_key;
let current_id = &manager.current;
for (id, provider) in &manager.providers {
let is_current = if id == current_id { 1 } else { 0 };
// 处理 meta 和 endpoints
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
tx.execute(
"INSERT OR REPLACE 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![
id,
app_type,
provider.name,
to_json_string(&provider.settings_config)?,
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
to_json_string(&meta_clone)?,
is_current,
],
)
.map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?;
// 迁移 Endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?;
}
}
}
Ok(())
}
/// 迁移 MCP 服务器数据
fn migrate_mcp_servers(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
if let Some(servers) = &config.mcp.servers {
for (id, server) in servers {
tx.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)",
params![
id,
server.name,
to_json_string(&server.server)?,
server.description,
server.homepage,
server.docs,
to_json_string(&server.tags)?,
server.apps.claude,
server.apps.codex,
server.apps.gemini,
],
)
.map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?;
}
}
Ok(())
}
/// 迁移提示词数据
fn migrate_prompts(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
String,
crate::prompt::Prompt,
>,
app_type: &str|
-> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
id,
app_type,
prompt.name,
prompt.content,
prompt.description,
prompt.enabled,
prompt.created_at,
prompt.updated_at,
],
)
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
}
Ok(())
};
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
migrate_app_prompts(&config.prompts.gemini.prompts, "gemini")?;
Ok(())
}
/// 迁移 Skills 数据
fn migrate_skills(
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}")))?;
}
for repo in &config.skills.repos {
tx.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(format!("Migrate skill repo failed: {e}")))?;
}
Ok(())
}
/// 迁移通用配置片段
fn migrate_common_config(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
if let Some(snippet) = &config.common_config_snippets.claude {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_claude", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.codex {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_codex", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.gemini {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_gemini", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
Ok(())
}
}
-135
View File
@@ -1,135 +0,0 @@
//! 数据库模块 - SQLite 数据持久化
//!
//! 此模块提供应用的核心数据存储功能,包括:
//! - 供应商配置管理
//! - MCP 服务器配置
//! - 提示词管理
//! - Skills 管理
//! - 通用设置存储
//!
//! ## 架构设计
//!
//! ```text
//! database/
//! ├── mod.rs - Database 结构体 + 初始化
//! ├── schema.rs - 表结构定义 + Schema 迁移
//! ├── backup.rs - SQL 导入导出 + 快照备份
//! ├── migration.rs - JSON → SQLite 数据迁移
//! └── dao/ - 数据访问对象
//! ├── providers.rs
//! ├── mcp.rs
//! ├── prompts.rs
//! ├── skills.rs
//! └── settings.rs
//! ```
mod backup;
mod dao;
mod migration;
mod schema;
#[cfg(test)]
mod tests;
use crate::config::get_app_config_dir;
use crate::error::AppError;
use rusqlite::Connection;
use serde::Serialize;
use std::sync::Mutex;
// DAO 方法通过 impl Database 提供,无需额外导出
/// 数据库备份保留数量
const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 1;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
serde_json::to_string(value)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))
}
/// 安全地获取 Mutex 锁,避免 unwrap panic
macro_rules! lock_conn {
($mutex:expr) => {
$mutex
.lock()
.map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))?
};
}
// 导出宏供子模块使用
pub(crate) use lock_conn;
/// 数据库连接封装
///
/// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享。
/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。
pub struct Database {
pub(crate) conn: Mutex<Connection>,
}
impl Database {
/// 初始化数据库连接并创建表
///
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
pub fn init() -> Result<Self, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
// 确保父目录存在
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.apply_schema_migrations()?;
Ok(db)
}
/// 创建内存数据库(用于测试)
pub fn memory() -> Result<Self, AppError> {
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
Ok(db)
}
/// 检查 MCP 服务器表是否为空
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count == 0)
}
/// 检查提示词表是否为空
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count == 0)
}
}
-339
View File
@@ -1,339 +0,0 @@
//! Schema 定义和迁移
//!
//! 负责数据库表结构的创建和版本迁移。
use super::{lock_conn, Database, SCHEMA_VERSION};
use crate::error::AppError;
use rusqlite::Connection;
impl Database {
/// 创建所有数据库表
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
Self::create_tables_on_conn(&conn)
}
/// 在指定连接上创建表(供迁移和测试使用)
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
// 1. Providers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 2. Provider Endpoints 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 3. MCP Servers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 4. Prompts 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 6. Skill Repos 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 7. Settings 表 (通用配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 应用 Schema 迁移
pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
Self::apply_schema_migrations_on_conn(&conn)
}
/// 在指定连接上应用 Schema 迁移
pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
conn.execute("SAVEPOINT schema_migration;", [])
.map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
let mut version = Self::get_user_version(conn)?;
if version > SCHEMA_VERSION {
conn.execute("ROLLBACK TO schema_migration;", []).ok();
conn.execute("RELEASE schema_migration;", []).ok();
return Err(AppError::Database(format!(
"数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。"
)));
}
let result = (|| {
while version < SCHEMA_VERSION {
match version {
0 => {
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
Self::migrate_v0_to_v1(conn)?;
Self::set_user_version(conn, SCHEMA_VERSION)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
)));
}
}
version = Self::get_user_version(conn)?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("RELEASE schema_migration;", [])
.map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK TO schema_migration;", []).ok();
conn.execute("RELEASE schema_migration;", []).ok();
Err(e)
}
}
}
/// v0 -> v1 迁移:补齐所有缺失列
fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
// providers 表
Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
Self::add_column_if_missing(
conn,
"providers",
"is_current",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// provider_endpoints 表
Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
// mcp_servers 表
Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_codex",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_gemini",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// prompts 表
Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
// skills 表
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
// skill_repos 表
Self::add_column_if_missing(
conn,
"skill_repos",
"branch",
"TEXT NOT NULL DEFAULT 'main'",
)?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
Ok(())
}
// --- 辅助方法 ---
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
}
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
if version < 0 {
return Err(AppError::Database("user_version 不能为负数".to_string()));
}
let sql = format!("PRAGMA user_version = {version};");
conn.execute(&sql, [])
.map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
Ok(())
}
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(AppError::Database(format!(
"非法{kind}: {s},仅允许字母、数字和下划线"
)));
}
Ok(())
}
pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let name: String = row
.get(0)
.map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
if name.eq_ignore_ascii_case(table) {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn has_column(
conn: &Connection,
table: &str,
column: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
let sql = format!("PRAGMA table_info(\"{table}\");");
let mut stmt = conn
.prepare(&sql)
.map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let name: String = row
.get(1)
.map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
if name.eq_ignore_ascii_case(column) {
return Ok(true);
}
}
Ok(false)
}
fn add_column_if_missing(
conn: &Connection,
table: &str,
column: &str,
definition: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
if !Self::table_exists(conn, table)? {
return Err(AppError::Database(format!(
"{table} 不存在,无法添加列 {column}"
)));
}
if Self::has_column(conn, table, column)? {
return Ok(false);
}
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
conn.execute(&sql, [])
.map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
log::info!("已为表 {table} 添加缺失列 {column}");
Ok(true)
}
}
-274
View File
@@ -1,274 +0,0 @@
//! 数据库模块测试
//!
//! 包含 Schema 迁移和基本功能的测试。
use super::*;
use crate::app_config::MultiAppConfig;
use crate::provider::{Provider, ProviderManager};
use indexmap::IndexMap;
use rusqlite::Connection;
use serde_json::json;
use std::collections::HashMap;
const LEGACY_SCHEMA_SQL: &str = r#"
CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
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
);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL
);
CREATE TABLE prompts (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
PRIMARY KEY (id, app_type)
);
CREATE TABLE skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (owner, name)
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
);
"#;
#[derive(Debug)]
struct ColumnInfo {
name: String,
r#type: String,
notnull: i64,
default: Option<String>,
}
fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info(\"{table}\");"))
.expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma");
while let Some(row) = rows.next().expect("read row") {
let name: String = row.get(1).expect("name");
if name.eq_ignore_ascii_case(column) {
return ColumnInfo {
name,
r#type: row.get::<_, String>(2).expect("type"),
notnull: row.get::<_, i64>(3).expect("notnull"),
default: row.get::<_, Option<String>>(4).ok().flatten(),
};
}
}
panic!("column {table}.{column} not found");
}
fn normalize_default(default: &Option<String>) -> Option<String> {
default
.as_ref()
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
}
#[test]
fn migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
assert_eq!(
Database::get_user_version(&conn).expect("read version before"),
0
);
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
assert_eq!(
Database::get_user_version(&conn).expect("read version after"),
SCHEMA_VERSION
);
}
#[test]
fn migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err =
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
);
}
#[test]
fn migration_adds_missing_columns_for_providers() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
// 验证关键新增列已补齐
for (table, column) in [
("providers", "meta"),
("providers", "is_current"),
("provider_endpoints", "added_at"),
("mcp_servers", "enabled_gemini"),
("prompts", "updated_at"),
("skills", "installed_at"),
("skill_repos", "enabled"),
] {
assert!(
Database::has_column(&conn, table, column).expect("check column"),
"{table}.{column} should exist after migration"
);
}
// 验证 meta 列约束保持一致
let meta = get_column_info(&conn, "providers", "meta");
assert_eq!(meta.notnull, 1, "meta should be NOT NULL");
assert_eq!(
normalize_default(&meta.default).as_deref(),
Some("{}"),
"meta default should be '{{}}'"
);
assert_eq!(
Database::get_user_version(&conn).expect("version after migration"),
SCHEMA_VERSION
);
}
#[test]
fn migration_aligns_column_defaults_and_types() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
let is_current = get_column_info(&conn, "providers", "is_current");
assert_eq!(is_current.r#type, "BOOLEAN");
assert_eq!(is_current.notnull, 1);
assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
let tags = get_column_info(&conn, "mcp_servers", "tags");
assert_eq!(tags.r#type, "TEXT");
assert_eq!(tags.notnull, 1);
assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]"));
let enabled = get_column_info(&conn, "prompts", "enabled");
assert_eq!(enabled.r#type, "BOOLEAN");
assert_eq!(enabled.notnull, 1);
assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
let installed_at = get_column_info(&conn, "skills", "installed_at");
assert_eq!(installed_at.r#type, "INTEGER");
assert_eq!(installed_at.notnull, 1);
assert_eq!(
normalize_default(&installed_at.default).as_deref(),
Some("0")
);
let branch = get_column_info(&conn, "skill_repos", "branch");
assert_eq!(branch.r#type, "TEXT");
assert_eq!(normalize_default(&branch.default).as_deref(), Some("main"));
let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled");
assert_eq!(skill_repo_enabled.r#type, "BOOLEAN");
assert_eq!(skill_repo_enabled.notnull, 1);
assert_eq!(
normalize_default(&skill_repo_enabled.default).as_deref(),
Some("1")
);
}
#[test]
fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
let config = MultiAppConfig {
version: 2,
apps,
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should succeed without any file I/O errors
let result = Database::migrate_from_json_dry_run(&config);
assert!(
result.is_ok(),
"Dry-run should succeed with valid config: {result:?}"
);
}
#[test]
fn dry_run_validates_schema_compatibility() {
// Create config with actual provider data
let mut providers = IndexMap::new();
providers.insert(
"test-provider".to_string(),
Provider {
id: "test-provider".to_string(),
name: "Test Provider".to_string(),
settings_config: json!({
"anthropicApiKey": "sk-test-123",
}),
website_url: None,
category: None,
created_at: Some(1234567890),
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
},
);
let mut manager = ProviderManager::default();
manager.providers = providers;
manager.current = "test-provider".to_string();
let mut apps = HashMap::new();
apps.insert("claude".to_string(), manager);
let config = MultiAppConfig {
version: 2,
apps,
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should validate the full migration path
let result = Database::migrate_from_json_dry_run(&config);
assert!(
result.is_ok(),
"Dry-run should succeed with provider data: {result:?}"
);
}
File diff suppressed because it is too large Load Diff
-191
View File
@@ -1,191 +0,0 @@
//! MCP server import from deep link
//!
//! Handles batch import of MCP server configurations via ccswitch:// URLs.
use super::utils::decode_base64_param;
use super::DeepLinkImportRequest;
use crate::app_config::{McpApps, McpServer};
use crate::error::AppError;
use crate::services::McpService;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// MCP import result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpImportResult {
/// Number of successfully imported MCP servers
pub imported_count: usize,
/// IDs of successfully imported MCP servers
pub imported_ids: Vec<String>,
/// Failed imports with error messages
pub failed: Vec<McpImportError>,
}
/// MCP import error
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpImportError {
/// MCP server ID
pub id: String,
/// Error message
pub error: String,
}
/// Import MCP servers from deep link request
///
/// This function handles batch import of MCP servers from standard MCP JSON format.
/// If a server already exists, only the apps flags are merged (existing config preserved).
pub fn import_mcp_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<McpImportResult, AppError> {
// Verify this is an MCP request
if request.resource != "mcp" {
return Err(AppError::InvalidInput(format!(
"Expected mcp resource, got '{}'",
request.resource
)));
}
// Extract and validate apps parameter
let apps_str = request
.apps
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?;
// Parse apps into McpApps struct
let target_apps = parse_mcp_apps(apps_str)?;
// Extract config
let config_b64 = request
.config
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?;
// Decode Base64 config
let decoded = decode_base64_param("config", config_b64)?;
let config_str = String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?;
// Parse JSON
let config_json: Value = serde_json::from_str(&config_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON in MCP config: {e}")))?;
// Extract mcpServers object
let mcp_servers = config_json
.get("mcpServers")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::InvalidInput("MCP config must contain 'mcpServers' object".to_string())
})?;
if mcp_servers.is_empty() {
return Err(AppError::InvalidInput(
"No MCP servers found in config".to_string(),
));
}
// Get existing servers to check for duplicates
let existing_servers = state.db.get_all_mcp_servers()?;
// Import each MCP server
let mut imported_ids = Vec::new();
let mut failed = Vec::new();
for (id, server_spec) in mcp_servers.iter() {
// Check if server already exists
let server = if let Some(existing) = existing_servers.get(id) {
// Server exists - merge apps only, keep other fields unchanged
log::info!("MCP server '{id}' already exists, merging apps only");
let mut merged_apps = existing.apps.clone();
// Merge new apps into existing apps
if target_apps.claude {
merged_apps.claude = true;
}
if target_apps.codex {
merged_apps.codex = true;
}
if target_apps.gemini {
merged_apps.gemini = true;
}
McpServer {
id: existing.id.clone(),
name: existing.name.clone(),
server: existing.server.clone(), // Keep existing server config
apps: merged_apps, // Merged apps
description: existing.description.clone(),
homepage: existing.homepage.clone(),
docs: existing.docs.clone(),
tags: existing.tags.clone(),
}
} else {
// New server - create with provided config
log::info!("Creating new MCP server: {id}");
McpServer {
id: id.clone(),
name: id.clone(),
server: server_spec.clone(),
apps: target_apps.clone(),
description: None,
homepage: None,
docs: None,
tags: vec!["imported".to_string()],
}
};
match McpService::upsert_server(state, server) {
Ok(_) => {
imported_ids.push(id.clone());
log::info!("Successfully imported/updated MCP server: {id}");
}
Err(e) => {
failed.push(McpImportError {
id: id.clone(),
error: format!("{e}"),
});
log::warn!("Failed to import MCP server '{id}': {e}");
}
}
}
Ok(McpImportResult {
imported_count: imported_ids.len(),
imported_ids,
failed,
})
}
/// Parse apps string into McpApps struct
pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
let mut apps = McpApps {
claude: false,
codex: false,
gemini: false,
};
for app in apps_str.split(',') {
match app.trim() {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
)))
}
}
}
if apps.is_empty() {
return Err(AppError::InvalidInput(
"At least one app must be specified in 'apps'".to_string(),
));
}
Ok(apps)
}
-116
View File
@@ -1,116 +0,0 @@
//! Deep link import functionality for CC Switch
//!
//! This module implements the ccswitch:// protocol for importing configurations
//! via deep links. Supports importing:
//! - Provider configurations (Claude/Codex/Gemini)
//! - MCP server configurations
//! - Prompts
//! - Skills
//!
//! See docs/ccswitch-deeplink-design.md for detailed design.
mod mcp;
mod parser;
mod prompt;
mod provider;
mod skill;
mod utils;
#[cfg(test)]
mod tests;
use serde::{Deserialize, Serialize};
// Re-export public API
pub use mcp::import_mcp_from_deeplink;
pub use parser::parse_deeplink_url;
pub use prompt::import_prompt_from_deeplink;
pub use provider::{import_provider_from_deeplink, parse_and_merge_config};
pub use skill::import_skill_from_deeplink;
/// Deep link import request model
///
/// Represents a parsed ccswitch:// URL ready for processing.
/// This struct contains all possible fields for all resource types.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepLinkImportRequest {
/// Protocol version (e.g., "v1")
pub version: String,
/// Resource type to import: "provider" | "prompt" | "mcp" | "skill"
pub resource: String,
// ============ Common fields ============
/// Target application (claude/codex/gemini) - for provider, prompt, skill
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
/// Resource name
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Whether to enable after import (default: false)
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
// ============ Provider-specific fields ============
/// Provider homepage URL
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
/// API endpoint/base URL
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// API key
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// Optional provider icon name (maps to built-in SVG)
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// Optional model name
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// Optional Haiku model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub haiku_model: Option<String>,
/// Optional Sonnet model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub sonnet_model: Option<String>,
/// Optional Opus model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub opus_model: Option<String>,
// ============ Prompt-specific fields ============
/// Base64 encoded Markdown content
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Prompt description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
// ============ MCP-specific fields ============
/// Target applications for MCP (comma-separated: "claude,codex,gemini")
#[serde(skip_serializing_if = "Option::is_none")]
pub apps: Option<String>,
// ============ Skill-specific fields ============
/// GitHub repository (format: "owner/name")
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
/// Skill directory name
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
// ============ Config file fields (v3.8+) ============
/// Base64 encoded config content
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<String>,
/// Config format (json/toml)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_format: Option<String>,
/// Remote config URL
#[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>,
}
-313
View File
@@ -1,313 +0,0 @@
//! Deep link URL parser
//!
//! Parses ccswitch:// URLs into DeepLinkImportRequest structures.
use super::utils::validate_url;
use super::DeepLinkImportRequest;
use crate::error::AppError;
use std::collections::HashMap;
use url::Url;
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
///
/// Expected format:
/// ccswitch://v1/import?resource={type}&...
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
// Parse URL
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
// Validate scheme
let scheme = url.scheme();
if scheme != "ccswitch" {
return Err(AppError::InvalidInput(format!(
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
)));
}
// Extract version from host
let version = url
.host_str()
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
.to_string();
// Validate version
if version != "v1" {
return Err(AppError::InvalidInput(format!(
"Unsupported protocol version: {version}"
)));
}
// Extract path (should be "/import")
let path = url.path();
if path != "/import" {
return Err(AppError::InvalidInput(format!(
"Invalid path: expected '/import', got '{path}'"
)));
}
// Parse query parameters
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
// Extract and validate resource type
let resource = params
.get("resource")
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
.clone();
// Dispatch to appropriate parser based on resource type
match resource.as_str() {
"provider" => parse_provider_deeplink(&params, version, resource),
"prompt" => parse_prompt_deeplink(&params, version, resource),
"mcp" => parse_mcp_deeplink(&params, version, resource),
"skill" => parse_skill_deeplink(&params, version, resource),
_ => Err(AppError::InvalidInput(format!(
"Unsupported resource type: {resource}"
))),
}
}
/// Parse provider deep link parameters
fn parse_provider_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let app = params
.get("app")
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
.clone();
// Validate app type
if app != "claude" && app != "codex" && app != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
)));
}
let name = params
.get("name")
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
.clone();
// Make these optional for config file auto-fill (v3.8+)
let homepage = params.get("homepage").cloned();
let endpoint = params.get("endpoint").cloned();
let api_key = params.get("apiKey").cloned();
// Validate URLs only if provided
if let Some(ref hp) = homepage {
if !hp.is_empty() {
validate_url(hp, "homepage")?;
}
}
if let Some(ref ep) = endpoint {
if !ep.is_empty() {
validate_url(ep, "endpoint")?;
}
}
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
let opus_model = params.get("opusModel").cloned();
let icon = params
.get("icon")
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty());
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
app: Some(app),
name: Some(name),
enabled,
homepage,
endpoint,
api_key,
icon,
model,
notes,
haiku_model,
sonnet_model,
opus_model,
content: None,
description: None,
apps: None,
repo: None,
directory: None,
branch: None,
config,
config_format,
config_url,
})
}
/// Parse prompt deep link parameters
fn parse_prompt_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let app = params
.get("app")
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))?
.clone();
// Validate app type
if app != "claude" && app != "codex" && app != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
)));
}
let name = params
.get("name")
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter for prompt".to_string()))?
.clone();
let content = params
.get("content")
.ok_or_else(|| {
AppError::InvalidInput("Missing 'content' parameter for prompt".to_string())
})?
.clone();
let description = params.get("description").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
app: Some(app),
name: Some(name),
enabled,
content: Some(content),
description,
icon: None,
homepage: None,
endpoint: None,
api_key: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
apps: None,
repo: None,
directory: None,
branch: None,
config: None,
config_format: None,
config_url: None,
})
}
/// Parse MCP deep link parameters
fn parse_mcp_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let apps = params
.get("apps")
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?
.clone();
// Validate apps format
for app in apps.split(',') {
let trimmed = app.trim();
if trimmed != "claude" && trimmed != "codex" && trimmed != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', or 'gemini', got '{trimmed}'"
)));
}
}
let config = params
.get("config")
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?
.clone();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
apps: Some(apps),
enabled,
config: Some(config),
config_format: Some("json".to_string()), // MCP config is always JSON
app: None,
name: None,
icon: None,
homepage: None,
endpoint: None,
api_key: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
content: None,
description: None,
repo: None,
directory: None,
branch: None,
config_url: None,
})
}
/// Parse skill deep link parameters
fn parse_skill_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let repo = params
.get("repo")
.ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))?
.clone();
// Validate repo format (should be "owner/name")
if !repo.contains('/') || repo.split('/').count() != 2 {
return Err(AppError::InvalidInput(format!(
"Invalid repo format: expected 'owner/name', got '{repo}'"
)));
}
let directory = params.get("directory").cloned();
let branch = params.get("branch").cloned();
Ok(DeepLinkImportRequest {
version,
resource,
repo: Some(repo),
directory,
branch,
icon: None,
app: Some("claude".to_string()), // Skills are Claude-only
name: None,
enabled: None,
homepage: None,
endpoint: None,
api_key: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
content: None,
description: None,
apps: None,
config: None,
config_format: None,
config_url: None,
})
}
-86
View File
@@ -1,86 +0,0 @@
//! Prompt import from deep link
//!
//! Handles importing prompt configurations via ccswitch:// URLs.
use super::utils::decode_base64_param;
use super::DeepLinkImportRequest;
use crate::error::AppError;
use crate::prompt::Prompt;
use crate::services::PromptService;
use crate::store::AppState;
use crate::AppType;
use std::str::FromStr;
/// Import a prompt from deep link request
pub fn import_prompt_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Verify this is a prompt request
if request.resource != "prompt" {
return Err(AppError::InvalidInput(format!(
"Expected prompt resource, got '{}'",
request.resource
)));
}
// Extract required fields
let app_str = request
.app
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for prompt".to_string()))?;
let name = request
.name
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for prompt".to_string()))?;
// Parse app type
let app_type = AppType::from_str(app_str)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
// Decode content
let content_b64 = request
.content
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'content' field for prompt".to_string()))?;
let content = decode_base64_param("content", content_b64)?;
let content = String::from_utf8(content)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in content: {e}")))?;
// Generate ID
let timestamp = chrono::Utc::now().timestamp_millis();
let sanitized_name = name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_lowercase();
let id = format!("{sanitized_name}-{timestamp}");
// Check if we should enable this prompt
let should_enable = request.enabled.unwrap_or(false);
// Create Prompt (initially disabled)
let prompt = Prompt {
id: id.clone(),
name: name.clone(),
content,
description: request.description,
enabled: false, // Always start as disabled, will be enabled later if needed
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
// Save using PromptService
PromptService::upsert_prompt(state, app_type.clone(), &id, prompt)?;
// If enabled flag is set, enable this prompt (which will disable others)
if should_enable {
PromptService::enable_prompt(state, app_type, &id)?;
log::info!("Successfully imported and enabled prompt '{name}' for {app_str}");
} else {
log::info!("Successfully imported prompt '{name}' for {app_str} (disabled)");
}
Ok(id)
}
-510
View File
@@ -1,510 +0,0 @@
//! Provider import from deep link
//!
//! Handles importing provider configurations via ccswitch:// URLs.
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
use serde_json::json;
use std::str::FromStr;
/// Import a provider from a deep link request
///
/// This function:
/// 1. Validates the request
/// 2. Merges config file if provided (v3.8+)
/// 3. Converts it to a Provider structure
/// 4. Delegates to ProviderService for actual import
/// 5. Optionally sets as current provider if enabled=true
pub fn import_provider_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Verify this is a provider request
if request.resource != "provider" {
return Err(AppError::InvalidInput(format!(
"Expected provider resource, got '{}'",
request.resource
)));
}
// Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?;
// Extract required fields (now as Option)
let app_str = merged_request
.app
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
AppError::InvalidInput("API key is required (either in URL or config file)".to_string())
})?;
if api_key.is_empty() {
return Err(AppError::InvalidInput(
"API key cannot be empty".to_string(),
));
}
let endpoint = 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(),
));
}
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string())
})?;
if homepage.is_empty() {
return Err(AppError::InvalidInput(
"Homepage cannot be empty".to_string(),
));
}
let name = merged_request
.name
.as_ref()
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
// Parse app type
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
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
// Generate a unique ID for the provider using timestamp + sanitized name
let timestamp = chrono::Utc::now().timestamp_millis();
let sanitized_name = name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_lowercase();
provider.id = format!("{sanitized_name}-{timestamp}");
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider)?;
// If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
log::info!("Provider '{provider_id}' set as current for {app_type:?}");
}
Ok(provider_id)
}
/// Build a Provider structure from a deep link request
pub(crate) fn build_provider_from_request(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
let settings_config = match app_type {
AppType::Claude => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
};
let provider = Provider {
id: String::new(), // Will be generated by caller
name: request.name.clone().unwrap_or_default(),
settings_config,
website_url: request.homepage.clone(),
category: None,
created_at: None,
sort_index: None,
notes: request.notes.clone(),
meta: None,
icon: request.icon.clone(),
icon_color: None,
};
Ok(provider)
}
/// Build Claude settings configuration
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let mut env = serde_json::Map::new();
env.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(),
json!(request.api_key.clone().unwrap_or_default()),
);
env.insert(
"ANTHROPIC_BASE_URL".to_string(),
json!(request.endpoint.clone().unwrap_or_default()),
);
// Add default model if provided
if let Some(model) = &request.model {
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
}
// Add Claude-specific model fields (v3.7.1+)
if let Some(haiku_model) = &request.haiku_model {
env.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
json!(haiku_model),
);
}
if let Some(sonnet_model) = &request.sonnet_model {
env.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
json!(sonnet_model),
);
}
if let Some(opus_model) = &request.opus_model {
env.insert(
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
json!(opus_model),
);
}
json!({ "env": env })
}
/// Build Codex settings configuration
fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
// Generate a safe provider name identifier
let clean_provider_name = {
let raw: String = request
.name
.clone()
.unwrap_or_else(|| "custom".to_string())
.chars()
.filter(|c| !c.is_control())
.collect();
let lower = raw.to_lowercase();
let mut key: String = lower
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' | '_' => c,
_ => '_',
})
.collect();
// Remove leading/trailing underscores
while key.starts_with('_') {
key.remove(0);
}
while key.ends_with('_') {
key.pop();
}
if key.is_empty() {
"custom".to_string()
} else {
key
}
};
// Model name: use deeplink model or default
let model_name = request
.model
.as_deref()
.unwrap_or("gpt-5-codex")
.to_string();
// Endpoint: normalize trailing slashes
let endpoint = request
.endpoint
.as_deref()
.unwrap_or("")
.trim()
.trim_end_matches('/')
.to_string();
// Build config.toml content
let config_toml = format!(
r#"model_provider = "{clean_provider_name}"
model = "{model_name}"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.{clean_provider_name}]
name = "{clean_provider_name}"
base_url = "{endpoint}"
wire_api = "responses"
requires_openai_auth = true
"#
);
json!({
"auth": {
"OPENAI_API_KEY": request.api_key,
},
"config": config_toml
})
}
/// Build Gemini settings configuration
fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let mut env = serde_json::Map::new();
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(),
json!(request.endpoint),
);
// Add model if provided
if let Some(model) = &request.model {
env.insert("GEMINI_MODEL".to_string(), json!(model));
}
json!({ "env": env })
}
// =============================================================================
// Config Merge Logic
// =============================================================================
/// Parse and merge configuration from Base64 encoded config or remote URL
///
/// Priority: URL params > inline config > remote config
pub fn parse_and_merge_config(
request: &DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, AppError> {
// If no config provided, return original request
if request.config.is_none() && request.config_url.is_none() {
return Ok(request.clone());
}
// Step 1: Get config content
let config_content = if let Some(config_b64) = &request.config {
// Decode Base64 inline config
let decoded = decode_base64_param("config", config_b64)?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
} else if let Some(_config_url) = &request.config_url {
// Fetch remote config (TODO: implement remote fetching in next phase)
return Err(AppError::InvalidInput(
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
));
} else {
return Ok(request.clone());
};
// Step 2: Parse config based on format
let format = request.config_format.as_deref().unwrap_or("json");
let config_value: serde_json::Value = match format {
"json" => serde_json::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
"toml" => {
let toml_value: toml::Value = toml::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
// Convert TOML to JSON for uniform processing
serde_json::to_value(toml_value)
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
}
_ => {
return Err(AppError::InvalidInput(format!(
"Unsupported config format: {format}"
)))
}
};
// Step 3: Extract values from config based on app type and merge with URL params
let mut merged = request.clone();
// MCP, Skill and other resource types don't need config merging
if request.resource != "provider" {
return Ok(merged);
}
match request.app.as_deref().unwrap_or("") {
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
"" => {
// No app specified, skip merging
return Ok(merged);
}
_ => {
return Err(AppError::InvalidInput(format!(
"Invalid app type: {:?}",
request.app
)))
}
}
Ok(merged)
}
/// Merge Claude configuration from config file
fn merge_claude_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let env = config
.get("env")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::InvalidInput("Claude config must have 'env' object".to_string())
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_none() || request.api_key.as_ref().unwrap().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 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());
}
}
// Auto-fill model fields (URL params take priority)
if request.model.is_none() {
request.model = env
.get("ANTHROPIC_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.haiku_model.is_none() {
request.haiku_model = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.sonnet_model.is_none() {
request.sonnet_model = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.opus_model.is_none() {
request.opus_model = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
Ok(())
}
/// Merge Codex configuration from config file
fn merge_codex_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
{
request.api_key = Some(api_key.to_string());
}
}
// Auto-fill endpoint and model from config string
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = Some(base_url);
}
}
// Extract model
if request.model.is_none() {
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
request.model = Some(model.to_string());
}
}
}
}
// Auto-fill homepage from endpoint
if (request.homepage.is_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());
}
}
Ok(())
}
/// Merge Gemini configuration from config file
fn merge_gemini_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_none() || request.api_key.as_ref().unwrap().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()) {
request.endpoint = Some(base_url.to_string());
}
}
if request.model.is_none() {
request.model = config
.get("GEMINI_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
// Auto-fill homepage from endpoint
if (request.homepage.is_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());
}
}
Ok(())
}
/// Extract base_url from Codex TOML config
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
// Try to find base_url in model_providers section
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
for (_key, provider) in providers.iter() {
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
return Some(base_url.to_string());
}
}
}
None
}
-51
View File
@@ -1,51 +0,0 @@
//! Skill import from deep link
//!
//! Handles importing skill repository configurations via ccswitch:// URLs.
use super::DeepLinkImportRequest;
use crate::error::AppError;
use crate::services::skill::SkillRepo;
use crate::store::AppState;
/// Import a skill from deep link request
pub fn import_skill_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Verify this is a skill request
if request.resource != "skill" {
return Err(AppError::InvalidInput(format!(
"Expected skill resource, got '{}'",
request.resource
)));
}
// Parse repo
let repo_str = request
.repo
.ok_or_else(|| AppError::InvalidInput("Missing 'repo' field for skill".to_string()))?;
let parts: Vec<&str> = repo_str.split('/').collect();
if parts.len() != 2 {
return Err(AppError::InvalidInput(format!(
"Invalid repo format: expected 'owner/name', got '{repo_str}'"
)));
}
let owner = parts[0].to_string();
let name = parts[1].to_string();
// Create SkillRepo
let repo = SkillRepo {
owner: owner.clone(),
name: name.clone(),
branch: request.branch.unwrap_or_else(|| "main".to_string()),
enabled: request.enabled.unwrap_or(true),
};
// Save using Database
state.db.save_skill_repo(&repo)?;
log::info!("Successfully added skill repo '{owner}/{name}'");
Ok(format!("{owner}/{name}"))
}
-378
View File
@@ -1,378 +0,0 @@
//! Deep link module tests
use super::mcp::parse_mcp_apps;
use super::parser::parse_deeplink_url;
use super::prompt::import_prompt_from_deeplink;
use super::provider::parse_and_merge_config;
use super::utils::{infer_homepage_from_endpoint, validate_url};
use super::DeepLinkImportRequest;
use crate::AppType;
use crate::{store::AppState, Database};
use base64::prelude::*;
use std::sync::Arc;
// =============================================================================
// Parser Tests
// =============================================================================
#[test]
fn test_parse_valid_claude_deeplink() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123&icon=claude";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.version, "v1");
assert_eq!(request.resource, "provider");
assert_eq!(request.app, Some("claude".to_string()));
assert_eq!(request.name, Some("Test Provider".to_string()));
assert_eq!(request.homepage, Some("https://example.com".to_string()));
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
assert_eq!(request.api_key, Some("sk-test-123".to_string()));
assert_eq!(request.icon, Some("claude".to_string()));
}
#[test]
fn test_parse_deeplink_with_notes() {
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123&notes=Test%20notes";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
}
#[test]
fn test_parse_unsupported_version() {
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported protocol version"));
}
#[test]
fn test_parse_missing_required_field() {
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
let url = "ccswitch://v1/import?resource=provider&app=claude";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing 'name' parameter"));
}
// =============================================================================
// Utils Tests
// =============================================================================
#[test]
fn test_validate_invalid_url() {
let result = validate_url("not-a-url", "test");
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_scheme() {
let result = validate_url("ftp://example.com", "test");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must be http or https"));
}
#[test]
fn test_infer_homepage() {
assert_eq!(
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
Some("https://anthropic.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
Some("https://test.company.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://example.com"),
Some("https://example.com".to_string())
);
}
// =============================================================================
// Provider Tests
// =============================================================================
#[test]
fn test_build_gemini_provider_with_model() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("gemini".to_string()),
name: Some("Test Gemini".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("test-api-key".to_string()),
icon: None,
model: Some("gemini-2.0-flash".to_string()),
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// Verify provider basic info
assert_eq!(provider.name, "Test Gemini");
assert_eq!(
provider.website_url,
Some("https://example.com".to_string())
);
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
}
#[test]
fn test_build_gemini_provider_without_model() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("gemini".to_string()),
name: Some("Test Gemini".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("test-api-key".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
#[test]
fn test_parse_and_merge_config_claude() {
// Prepare Base64 encoded Claude config
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test".to_string()),
homepage: None,
endpoint: None,
api_key: None,
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// Should auto-fill from config
assert_eq!(merged.api_key, Some("sk-ant-xxx".to_string()));
assert_eq!(
merged.endpoint,
Some("https://api.anthropic.com/v1".to_string())
);
assert_eq!(merged.homepage, Some("https://anthropic.com".to_string()));
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
}
#[test]
fn test_parse_and_merge_config_url_override() {
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test".to_string()),
homepage: None,
endpoint: None,
api_key: Some("sk-new".to_string()), // URL param should override
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// URL param should take priority
assert_eq!(merged.api_key, Some("sk-new".to_string()));
// Config file value should be used
assert_eq!(
merged.endpoint,
Some("https://api.anthropic.com/v1".to_string())
);
}
// =============================================================================
// Prompt Tests
// =============================================================================
#[test]
fn test_import_prompt_allows_space_in_base64_content() {
let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+";
let request = parse_deeplink_url(url).unwrap();
// URL decoded content may have "+" become space
assert_eq!(request.content.as_deref(), Some("Pj4 "));
let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db.clone());
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompts = state.db.get_prompts("codex").expect("get prompts");
let prompt = prompts.get(&prompt_id).expect("prompt saved");
assert_eq!(prompt.content, ">>>");
assert_eq!(prompt.name, request.name.unwrap());
}
// =============================================================================
// MCP Tests
// =============================================================================
#[test]
fn test_parse_mcp_apps() {
let apps = parse_mcp_apps("claude,codex").unwrap();
assert!(apps.claude);
assert!(apps.codex);
assert!(!apps.gemini);
let apps = parse_mcp_apps("gemini").unwrap();
assert!(!apps.claude);
assert!(!apps.codex);
assert!(apps.gemini);
let err = parse_mcp_apps("invalid").unwrap_err();
assert!(err.to_string().contains("Invalid app"));
}
#[test]
fn test_parse_prompt_deeplink() {
let content = "Hello World";
let content_b64 = BASE64_STANDARD.encode(content);
let url = format!(
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
content_b64
);
let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "prompt");
assert_eq!(request.app.unwrap(), "claude");
assert_eq!(request.name.unwrap(), "test");
assert_eq!(request.content.unwrap(), content_b64);
assert_eq!(request.description.unwrap(), "desc");
assert_eq!(request.enabled.unwrap(), true);
}
#[test]
fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
let url = format!(
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
config_b64
);
let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "mcp");
assert_eq!(request.apps.unwrap(), "claude,codex");
assert_eq!(request.config.unwrap(), config_b64);
assert_eq!(request.enabled.unwrap(), true);
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo");
assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev");
}
-99
View File
@@ -1,99 +0,0 @@
//! Deep link utility functions
//!
//! Common helpers for URL validation, Base64 decoding, etc.
use crate::error::AppError;
use base64::prelude::*;
use url::Url;
/// Validate that a string is a valid HTTP(S) URL
pub fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?;
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return Err(AppError::InvalidInput(format!(
"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'"
)));
}
Ok(())
}
/// Decode a Base64 parameter from deep link URL
///
/// This function handles common issues with Base64 in URLs:
/// - `+` being decoded as space
/// - Missing padding `=`
/// - Both standard and URL-safe Base64 variants
pub fn decode_base64_param(field: &str, raw: &str) -> Result<Vec<u8>, AppError> {
let mut candidates: Vec<String> = Vec::new();
// Keep spaces (to restore `+`), but remove newlines
let trimmed = raw.trim_matches(|c| c == '\r' || c == '\n');
// First try restoring spaces to "+"
if trimmed.contains(' ') {
let replaced = trimmed.replace(' ', "+");
if !replaced.is_empty() && !candidates.contains(&replaced) {
candidates.push(replaced);
}
}
// Original value
if !trimmed.is_empty() && !candidates.contains(&trimmed.to_string()) {
candidates.push(trimmed.to_string());
}
// Add padding variants
let existing = candidates.clone();
for candidate in existing {
let mut padded = candidate.clone();
let remainder = padded.len() % 4;
if remainder != 0 {
padded.extend(std::iter::repeat_n('=', 4 - remainder));
}
if !candidates.contains(&padded) {
candidates.push(padded);
}
}
let mut last_error: Option<String> = None;
for candidate in candidates {
for engine in [
&BASE64_STANDARD,
&BASE64_STANDARD_NO_PAD,
&BASE64_URL_SAFE,
&BASE64_URL_SAFE_NO_PAD,
] {
match engine.decode(&candidate) {
Ok(bytes) => return Ok(bytes),
Err(err) => last_error = Some(err.to_string()),
}
}
}
Err(AppError::InvalidInput(format!(
"{field} 参数 Base64 解码失败:{}。请确认链接参数已用 Base64 编码并经过 URL 转义(尤其是将 '+' 编码为 %2B,或使用 URL-safe Base64)。",
last_error.unwrap_or_else(|| "未知错误".to_string())
)))
}
/// Infer homepage URL from API endpoint
///
/// Examples:
/// - https://api.anthropic.com/v1 → https://anthropic.com
/// - https://api.openai.com/v1 → https://openai.com
/// - https://api-test.company.com/v1 → https://company.com
pub fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {
let url = Url::parse(endpoint).ok()?;
let host = url.host_str()?;
// Remove common API prefixes
let clean_host = host
.strip_prefix("api.")
.or_else(|| host.strip_prefix("api-"))
.unwrap_or(host);
Some(format!("https://{clean_host}"))
}
+1 -19
View File
@@ -49,11 +49,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -61,25 +56,12 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
}
let root = read_json_value(&path)?;
let mut servers: std::collections::HashMap<String, Value> = root
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
// 反向格式转换:Gemini 特有格式 → 统一 MCP 格式
for (_, spec) in servers.iter_mut() {
if let Some(obj) = spec.as_object_mut() {
// httpUrl → url + type: "http"
if let Some(http_url) = obj.remove("httpUrl") {
obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string()));
}
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio
}
}
Ok(servers)
}
-27
View File
@@ -25,33 +25,6 @@ pub fn get_init_error() -> Option<InitErrorPayload> {
cell().read().ok()?.clone()
}
// ============================================================
// 迁移结果状态
// ============================================================
static MIGRATION_SUCCESS: OnceLock<RwLock<bool>> = OnceLock::new();
fn migration_cell() -> &'static RwLock<bool> {
MIGRATION_SUCCESS.get_or_init(|| RwLock::new(false))
}
pub fn set_migration_success() {
if let Ok(mut guard) = migration_cell().write() {
*guard = true;
}
}
/// 获取并消费迁移成功状态(只返回一次 true,之后返回 false)
pub fn take_migration_success() -> bool {
if let Ok(mut guard) = migration_cell().write() {
let val = *guard;
*guard = false;
val
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
+97 -209
View File
@@ -43,7 +43,6 @@ pub use services::{
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::{
@@ -69,11 +68,6 @@ impl TrayTexts {
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint: " (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
@@ -226,11 +220,10 @@ fn create_tray_menu(
for section in TRAY_SECTIONS.iter() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let current_id = app_state
.db
.get_current_provider(app_type_str)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
@@ -549,115 +542,86 @@ pub fn run() {
let db_path = app_config_dir.join("cc-switch.db");
let json_path = app_config_dir.join("config.json");
// 检查是否需要从 config.json 迁移到 SQLite
let has_json = json_path.exists();
let has_db = db_path.exists();
// Check if migration is needed (DB doesn't exist but JSON does)
let migration_needed = !db_path.exists() && json_path.exists();
// 如果需要迁移,先验证 config.json 是否可以加载(在创建数据库之前)
// 这样如果加载失败用户选择退出,数据库文件还没被创建,下次可以正常重试
let migration_config = if !has_db && has_json {
log::info!("检测到旧版配置文件,验证配置文件...");
// 循环:支持用户重试加载配置文件
loop {
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
log::info!("✓ 配置文件加载成功");
break Some(config);
}
Err(e) => {
log::error!("加载旧配置文件失败: {e}");
// 弹出系统对话框让用户选择
if !show_migration_error_dialog(app.handle(), &e.to_string()) {
// 用户选择退出(此时数据库还没创建,下次启动可以重试)
log::info!("用户选择退出程序");
std::process::exit(1);
}
// 用户选择重试,继续循环
log::info!("用户选择重试加载配置文件");
}
}
}
} else {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
// 这里的错误处理比较棘手,因为 setup 返回 Result<Box<dyn Error>>
// 我们暂时记录日志并让应用继续运行(可能会崩溃)或者返回错误
return Err(Box::new(e));
}
};
// 如果有预加载的配置,执行迁移
if let Some(config) = migration_config {
log::info!("开始执行数据迁移...");
match db.migrate_from_json(&config) {
Ok(_) => {
log::info!("✓ 配置迁移成功");
// 标记迁移成功,供前端显示 Toast
crate::init_status::set_migration_success();
// 归档旧配置文件(重命名而非删除,便于用户恢复)
let archive_path = json_path.with_extension("json.migrated");
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
log::warn!("归档旧配置文件失败: {e}");
if migration_needed {
log::info!("Starting migration from config.json to SQLite...");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {
log::error!("Migration failed: {e}");
} else {
log::info!("✓ 旧配置已归档为 config.json.migrated");
log::info!("Migration successful");
// Optional: Rename config.json
// let _ = std::fs::rename(&json_path, json_path.with_extension("json.bak"));
}
}
Err(e) => {
// 配置加载成功但迁移失败的情况极少(磁盘满等),仅记录日志
log::error!("配置迁移失败: {e},将从现有配置导入");
}
Err(e) => log::error!("Failed to load config.json for migration: {e}"),
}
}
crate::settings::bind_db(db.clone());
let app_state = AppState::new(db);
// ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
// ============================================================
// 检查是否需要首次导入(数据库为空)
let need_first_import = app_state
.db
.is_empty_for_first_import()
.unwrap_or_else(|e| {
log::warn!("Failed to check if database is empty: {e}");
false
});
// 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(_) => {} // 表非空,静默跳过
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
if need_first_import {
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置
log::info!(
"Empty database detected, importing existing configurations and initializing defaults..."
);
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
// 1. 初始化默认 Skills 仓库(3个
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
Ok(_) => log::debug!("No default skill repositories to initialize"),
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 2. 导入供应商配置(从 live 配置文件)
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(_) => {
log::info!("✓ Imported default provider for {}", app.as_str());
}
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
// 3. 导入 MCP 服务器配置
match crate::services::mcp::McpService::import_from_claude(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Claude");
@@ -681,28 +645,42 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
}
// 4. 导入提示词文件(表空时触发)
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
log::info!("Prompts table empty, importing from live configurations...");
for app in [
// 4. 导入提示词文件
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
app.clone(),
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) for {}", app.as_str());
}
Ok(_) => log::debug!("○ No prompt file found for {}", app.as_str()),
Err(e) => log::warn!("✗ Failed to import prompt for {}: {e}", app.as_str()),
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Claude");
}
Ok(_) => log::debug!("○ No Claude prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Claude prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Codex,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Codex");
}
Ok(_) => log::debug!("○ No Codex prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Codex prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Gemini,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Gemini");
}
Ok(_) => log::debug!("○ No Gemini prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini prompt: {e}"),
}
log::info!("First-time import completed");
}
// 迁移旧的 app_config_dir 配置到 Store
@@ -718,35 +696,10 @@ pub fn run() {
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
#[cfg(target_os = "linux")]
{
// Use Tauri's path API to get correct path (includes app identifier)
// tauri-plugin-deep-link writes to: ~/.local/share/com.ccswitch.desktop/applications/cc-switch-handler.desktop
// Only register if .desktop file doesn't exist to avoid overwriting user customizations
let should_register = app
.path()
.data_dir()
.map(|d| !d.join("applications/cc-switch-handler.desktop").exists())
.unwrap_or(true);
if should_register {
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux)");
}
} else {
log::info!("⊘ Deep link handler already exists, skipping registration");
}
}
#[cfg(all(debug_assertions, windows))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Windows debug)");
}
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux/Windows)");
}
}
@@ -825,7 +778,6 @@ pub fn run() {
commands::pick_directory,
commands::open_external,
commands::get_init_error,
commands::get_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
@@ -990,67 +942,3 @@ pub fn run() {
}
});
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
/// 检测是否为中文环境
fn is_chinese_locale() -> bool {
std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.or_else(|_| std::env::var("LC_MESSAGES"))
.map(|lang| lang.starts_with("zh"))
.unwrap_or(false)
}
/// 显示迁移错误对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
let title = if is_chinese_locale() {
"配置迁移失败"
} else {
"Migration Failed"
};
let message = if is_chinese_locale() {
format!(
"从旧版本迁移配置时发生错误:\n\n{error}\n\n\
您的数据尚未丢失,旧配置文件仍然保留。\n\
建议回退到旧版本 CC Switch 以保护数据。\n\n\
点击「重试」重新尝试迁移\n\
点击「退出」关闭程序(可回退版本后重新打开)"
)
} else {
format!(
"An error occurred while migrating configuration:\n\n{error}\n\n\
Your data is NOT lost - the old config file is still preserved.\n\
Consider rolling back to an older CC Switch version.\n\n\
Click 'Retry' to attempt migration again\n\
Click 'Exit' to close the program"
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
// 使用 blocking_show 同步等待用户响应
// OkCancelCustom: 第一个按钮(重试)返回 true,第二个按钮(退出)返回 false
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
-10
View File
@@ -2,15 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// 在 Linux 上设置 WebKit 环境变量以解决 DMA-BUF 渲染问题
// 某些 Linux 系统(如 Debian 13.2、Nvidia GPU)上 WebKitGTK 的 DMA-BUF 渲染器可能导致白屏/黑屏
// 参考: https://github.com/tauri-apps/tauri/issues/9394
#[cfg(target_os = "linux")]
{
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
cc_switch_lib::run();
}
+48
View File
@@ -2,6 +2,7 @@ use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
use crate::store::AppState;
use chrono::Utc;
use serde_json::Value;
use std::fs;
@@ -83,6 +84,53 @@ impl ConfigService {
Ok(())
}
/// 将当前 config.json 拷贝到目标路径。
pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> {
let config_path = crate::config::get_app_config_path();
let config_content =
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e))
}
/// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。
pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> {
let import_content =
fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?;
let new_config: MultiAppConfig =
serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?;
let config_path = crate::config::get_app_config_path();
let backup_id = Self::create_backup(&config_path)?;
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
Ok((new_config, backup_id))
}
/// 将外部配置文件内容加载并写入应用状态。
/// TODO: 需要重构以使用数据库而不是 JSON 配置
pub fn import_config_from_path(
_file_path: &Path,
_state: &AppState,
) -> Result<String, AppError> {
// TODO: 实现基于数据库的导入逻辑
Err(AppError::Message(
"配置导入功能正在重构中,暂时不可用".to_string(),
))
/* 旧的实现,需要重构:
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
{
let mut guard = state.config.write().map_err(AppError::from)?;
*guard = new_config;
}
Ok(backup_id)
*/
}
/// 同步当前供应商到对应的 live 配置。
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
-6
View File
@@ -176,12 +176,6 @@ impl PromptService {
state: &AppState,
app: AppType,
) -> Result<usize, AppError> {
// 幂等性保护:该应用已有提示词则跳过
let existing = state.db.get_prompts(app.as_str())?;
if !existing.is_empty() {
return Ok(0);
}
let file_path = prompt_file_path(&app)?;
// 检查文件是否存在
File diff suppressed because it is too large Load Diff
@@ -1,98 +0,0 @@
//! Custom endpoints management
//!
//! Handles CRUD operations for provider custom endpoints.
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::settings::CustomEndpoint;
use crate::store::AppState;
/// Get custom endpoints list for a provider
pub fn get_custom_endpoints(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<Vec<CustomEndpoint>, AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(provider_id) else {
return Ok(vec![]);
};
let Some(meta) = provider.meta.as_ref() else {
return Ok(vec![]);
};
if meta.custom_endpoints.is_empty() {
return Ok(vec![]);
}
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
Ok(result)
}
/// Add a custom endpoint to a provider
pub fn add_custom_endpoint(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
if normalized.is_empty() {
return Err(AppError::localized(
"provider.endpoint.url_required",
"URL 不能为空",
"URL cannot be empty",
));
}
state
.db
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
Ok(())
}
/// Remove a custom endpoint from a provider
pub fn remove_custom_endpoint(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
state
.db
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
Ok(())
}
/// Update endpoint last used timestamp
pub fn update_endpoint_last_used(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
// Get provider, update last_used, save back
let mut providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get_mut(provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
endpoint.last_used = Some(now_millis());
state.db.save_provider(app_type.as_str(), provider)?;
}
}
}
Ok(())
}
/// Get current timestamp in milliseconds
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
@@ -1,142 +0,0 @@
//! Gemini authentication type detection
//!
//! Detects whether a Gemini provider uses PackyCode API Key, Google OAuth, or generic API Key.
use crate::error::AppError;
use crate::provider::Provider;
/// Gemini authentication type enumeration
///
/// Used to optimize performance by avoiding repeated provider type detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GeminiAuthType {
/// PackyCode provider (uses API Key)
Packycode,
/// Google Official (uses OAuth)
GoogleOfficial,
/// Generic Gemini provider (uses API Key)
Generic,
}
// Partner Promotion Key constants
const PACKYCODE_PARTNER_KEY: &str = "packycode";
const GOOGLE_OFFICIAL_PARTNER_KEY: &str = "google-official";
// PackyCode keyword constants
const PACKYCODE_KEYWORDS: [&str; 3] = ["packycode", "packyapi", "packy"];
/// Detect Gemini provider authentication type
///
/// One-time detection to avoid repeated calls to `is_packycode_gemini` and `is_google_official_gemini`.
///
/// # Returns
///
/// - `GeminiAuthType::GoogleOfficial`: Google official, uses OAuth
/// - `GeminiAuthType::Packycode`: PackyCode provider, uses API Key
/// - `GeminiAuthType::Generic`: Other generic providers, uses API Key
pub(crate) fn detect_gemini_auth_type(provider: &Provider) -> GeminiAuthType {
// Priority 1: Check partner_promotion_key (most reliable)
if let Some(key) = provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
{
if key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY) {
return GeminiAuthType::GoogleOfficial;
}
if key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY) {
return GeminiAuthType::Packycode;
}
}
// Priority 2: Check Google Official (name matching)
let name_lower = provider.name.to_ascii_lowercase();
if name_lower == "google" || name_lower.starts_with("google ") {
return GeminiAuthType::GoogleOfficial;
}
// Priority 3: Check PackyCode keywords
if contains_packycode_keyword(&provider.name) {
return GeminiAuthType::Packycode;
}
if let Some(site) = provider.website_url.as_deref() {
if contains_packycode_keyword(site) {
return GeminiAuthType::Packycode;
}
}
if let Some(base_url) = provider
.settings_config
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
.and_then(|v| v.as_str())
{
if contains_packycode_keyword(base_url) {
return GeminiAuthType::Packycode;
}
}
GeminiAuthType::Generic
}
/// Check if string contains PackyCode related keywords (case-insensitive)
///
/// Keyword list: ["packycode", "packyapi", "packy"]
fn contains_packycode_keyword(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
PACKYCODE_KEYWORDS
.iter()
.any(|keyword| lower.contains(keyword))
}
/// Detect if provider is Google Official Gemini (uses OAuth authentication)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// This is a convenience wrapper around `detect_gemini_auth_type`.
pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {
detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial
}
/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// # What it does
///
/// Writes to **`~/.gemini/settings.json`** (Gemini client config).
///
/// # Value set
///
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "oauth-personal"
/// }
/// }
/// }
/// ```
///
/// # OAuth authentication flow
///
/// 1. User switches to Google Official provider
/// 2. CC-Switch sets `selectedType = "oauth-personal"`
/// 3. User's first use of Gemini CLI will auto-open browser for OAuth login
/// 4. After successful login, credentials saved in Gemini credential store
/// 5. Subsequent requests auto-use saved credentials
///
/// # Error handling
///
/// If provider is not Google Official, function returns `Ok(())` immediately without any operation.
pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> {
if !is_google_official_gemini(provider) {
return Ok(());
}
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_google_oauth_settings;
write_google_oauth_settings()?;
Ok(())
}
-392
View File
@@ -1,392 +0,0 @@
//! Live configuration operations
//!
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
use std::collections::HashMap;
use serde_json::{json, Value};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::mcp::McpService;
use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
};
use super::normalize_claude_models_in_value;
/// Live configuration snapshot for backup/restore
#[derive(Clone)]
#[allow(dead_code)]
pub(crate) enum LiveSnapshot {
Claude {
settings: Option<Value>,
},
Codex {
auth: Option<Value>,
config: Option<String>,
},
Gemini {
env: Option<HashMap<String, String>>,
config: Option<Value>,
},
}
impl LiveSnapshot {
#[allow(dead_code)]
pub(crate) fn restore(&self) -> Result<(), AppError> {
match self {
LiveSnapshot::Claude { settings } => {
let path = get_claude_settings_path();
if let Some(value) = settings {
write_json_file(&path, value)?;
} else if path.exists() {
delete_file(&path)?;
}
}
LiveSnapshot::Codex { auth, config } => {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
if let Some(value) = auth {
write_json_file(&auth_path, value)?;
} else if auth_path.exists() {
delete_file(&auth_path)?;
}
if let Some(text) = config {
crate::config::write_text_file(&config_path, text)?;
} else if config_path.exists() {
delete_file(&config_path)?;
}
}
LiveSnapshot::Gemini { env, .. } => {
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic,
};
let path = get_gemini_env_path();
if let Some(env_map) = env {
write_gemini_env_atomic(env_map)?;
} else if path.exists() {
delete_file(&path)?;
}
let settings_path = get_gemini_settings_path();
match self {
LiveSnapshot::Gemini {
config: Some(cfg), ..
} => {
write_json_file(&settings_path, cfg)?;
}
LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => {
delete_file(&settings_path)?;
}
_ => {}
}
}
}
Ok(())
}
}
/// Write live configuration snapshot for a provider
pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
write_json_file(&path, &provider.settings_config)?;
}
AppType::Codex => {
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
}
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
McpService::sync_all_enabled(state)?;
Ok(())
}
/// Read current live settings for an app type
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.auth.missing",
"Codex 配置文件不存在:缺少 auth.json",
"Codex configuration missing: auth.json not found",
));
}
let auth: Value = read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(json!({ "auth": auth, "config": cfg_text }))
}
AppType::Claude => {
let path = get_claude_settings_path();
if !path.exists() {
return Err(AppError::localized(
"claude.live.missing",
"Claude Code 配置文件不存在",
"Claude settings file is missing",
));
}
read_json_file(&path)
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
// Read .env file (environment variables)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.env.missing",
"Gemini .env 文件不存在",
"Gemini .env file not found",
));
}
let env_map = read_gemini_env()?;
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// Read settings.json file (MCP config etc.)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// Return complete structure: { "env": {...}, "config": {...} }
Ok(json!({
"env": env_obj,
"config": config_obj
}))
}
}
}
/// Import default configuration from live files
///
/// Returns `Ok(true)` if a provider was actually imported,
/// `Ok(false)` if skipped (providers already exist for this app).
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(false); // 已有供应商,跳过
}
}
let settings_config = match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
"Codex configuration file is missing",
));
}
let auth: Value = read_json_file(&auth_path)?;
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
json!({ "auth": auth, "config": config_str })
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err(AppError::localized(
"claude.live.missing",
"Claude Code 配置文件不存在",
"Claude settings file is missing",
));
}
let mut v = read_json_file::<Value>(&settings_path)?;
let _ = normalize_claude_models_in_value(&mut v);
v
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
// Read .env file (environment variables)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.live.missing",
"Gemini 配置文件不存在",
"Gemini configuration file is missing",
));
}
let env_map = read_gemini_env()?;
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// Read settings.json file (MCP config etc.)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// Return complete structure: { "env": {...}, "config": {...} }
json!({
"env": env_obj,
"config": config_obj
})
}
};
let mut provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
provider.category = Some("custom".to_string());
state.db.save_provider(app_type.as_str(), &provider)?;
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
Ok(true) // 真正导入了
}
/// Write Gemini live configuration with authentication handling
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{
get_gemini_settings_path, json_to_env, validate_gemini_settings_strict,
write_gemini_env_atomic,
};
// One-time auth type detection to avoid repeated detection
let auth_type = detect_gemini_auth_type(provider);
let mut env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json
// Behavior:
// - config is object: use it (merge with existing to preserve mcpServers etc.)
// - config is null or absent: preserve existing file content
let settings_path = get_gemini_settings_path();
let mut config_to_write: Option<Value> = None;
if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_object() {
// Merge with existing settings to preserve mcpServers and other fields
let mut merged = if settings_path.exists() {
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Merge provider config into existing settings
if let (Some(merged_obj), Some(config_obj)) =
(merged.as_object_mut(), config_value.as_object())
{
for (k, v) in config_obj {
merged_obj.insert(k.clone(), v.clone());
}
}
config_to_write = Some(merged);
} else if !config_value.is_null() {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
}
// If no config specified or config is null, preserve existing file
if config_to_write.is_none() && settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google official uses OAuth, clear env
env_map.clear();
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode => {
// PackyCode provider, uses API Key (strict validation on switch)
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Generic => {
// Generic provider, uses API Key (strict validation on switch)
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
}
if let Some(config_value) = config_to_write {
write_json_file(&settings_path, &config_value)?;
}
// Set security.auth.selectedType based on auth type
// - Google Official: OAuth mode
// - All others: API Key mode
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
}
Ok(())
}
-650
View File
@@ -1,650 +0,0 @@
//! Provider service module
//!
//! Handles provider CRUD operations, switching, and configuration management.
mod endpoints;
mod gemini_auth;
mod live;
mod usage;
use indexmap::IndexMap;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::codex_config::write_codex_live_atomic;
use crate::config::{get_claude_settings_path, write_json_file};
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
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};
// Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use live::write_gemini_live;
use usage::validate_usage_script;
/// Provider business logic service
pub struct ProviderService;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn validate_provider_settings_rejects_missing_auth() {
let provider = Provider::with_id(
"codex".into(),
"Codex".into(),
json!({ "config": "base_url = \"https://example.com\"" }),
None,
);
let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)
.expect_err("missing auth should be rejected");
assert!(
err.to_string().contains("auth"),
"expected auth error, got {err:?}"
);
}
#[test]
fn extract_credentials_returns_expected_values() {
let provider = Provider::with_id(
"claude".into(),
"Claude".into(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
let (api_key, base_url) =
ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();
assert_eq!(api_key, "token");
assert_eq!(base_url, "https://claude.example");
}
}
impl ProviderService {
fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {
if matches!(app_type, AppType::Claude) {
let mut v = provider.settings_config.clone();
if normalize_claude_models_in_value(&mut v) {
provider.settings_config = v;
}
}
}
/// List all providers for an app type
pub fn list(
state: &AppState,
app_type: AppType,
) -> Result<IndexMap<String, Provider>, AppError> {
state.db.get_all_providers(app_type.as_str())
}
/// Get current provider ID
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
/// Add a new provider
pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result<bool, AppError> {
let mut provider = provider;
// Normalize Claude model keys
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// 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)
let current = state.db.get_current_provider(app_type.as_str())?;
if current.is_none() {
// No current provider, set as current and sync
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_snapshot(&app_type, &provider)?;
}
Ok(true)
}
/// Update a provider
pub fn update(
state: &AppState,
app_type: AppType,
provider: Provider,
) -> Result<bool, AppError> {
let mut provider = provider;
// Normalize Claude model keys
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)
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 {
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
Ok(true)
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// 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())?;
if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
/// Switch to a provider
///
/// Switch flow:
/// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. Update local settings current_provider_xxx (device-level)
/// 4. Update database is_current (as default for new devices)
/// 5. Write target provider config to live files
/// 6. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
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);
}
}
}
}
// 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)?;
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(())
}
/// Sync current provider to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state)
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
import_default_config(state, app_type)
}
/// Read current live settings (re-export)
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
read_live_settings(app_type)
}
/// Get custom endpoints list (re-export)
pub fn get_custom_endpoints(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<Vec<CustomEndpoint>, AppError> {
endpoints::get_custom_endpoints(state, app_type, provider_id)
}
/// Add custom endpoint (re-export)
pub fn add_custom_endpoint(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
endpoints::add_custom_endpoint(state, app_type, provider_id, url)
}
/// Remove custom endpoint (re-export)
pub fn remove_custom_endpoint(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
endpoints::remove_custom_endpoint(state, app_type, provider_id, url)
}
/// Update endpoint last used timestamp (re-export)
pub fn update_endpoint_last_used(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
endpoints::update_endpoint_last_used(state, app_type, provider_id, url)
}
/// Update provider sort order
pub fn update_sort_order(
state: &AppState,
app_type: AppType,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, AppError> {
let mut providers = state.db.get_all_providers(app_type.as_str())?;
for update in updates {
if let Some(provider) = providers.get_mut(&update.id) {
provider.sort_index = Some(update.sort_index);
state.db.save_provider(app_type.as_str(), provider)?;
}
}
Ok(true)
}
/// Query provider usage (re-export)
pub async fn query_usage(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
usage::query_usage(state, app_type, provider_id).await
}
/// Test usage script (re-export)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script(
state: &AppState,
app_type: AppType,
provider_id: &str,
script_code: &str,
timeout: u64,
api_key: Option<&str>,
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<UsageResult, AppError> {
usage::test_usage_script(
state,
app_type,
provider_id,
script_code,
timeout,
api_key,
base_url,
access_token,
user_id,
)
.await
}
#[allow(dead_code)]
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
let auth = settings
.get("auth")
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 auth 必须是对象",
provider.id
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
write_codex_live_atomic(auth, cfg_text)?;
Ok(())
}
#[allow(dead_code)]
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
let settings_path = get_claude_settings_path();
let mut content = provider.settings_config.clone();
let _ = normalize_claude_models_in_value(&mut content);
write_json_file(&settings_path, &content)?;
Ok(())
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
write_gemini_live(provider)
}
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
if !provider.settings_config.is_object() {
return Err(AppError::localized(
"provider.claude.settings.not_object",
"Claude 配置必须是 JSON 对象",
"Claude configuration must be a JSON object",
));
}
}
AppType::Codex => {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
"provider.codex.settings.not_object",
"Codex 配置必须是 JSON 对象",
"Codex configuration must be a JSON object",
)
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::localized(
"provider.codex.auth.missing",
format!("供应商 {} 缺少 auth 配置", provider.id),
format!("Provider {} is missing auth configuration", provider.id),
)
})?;
if !auth.is_object() {
return Err(AppError::localized(
"provider.codex.auth.not_object",
format!("供应商 {} 的 auth 配置必须是 JSON 对象", provider.id),
format!(
"Provider {} auth configuration must be a JSON object",
provider.id
),
));
}
if let Some(config_value) = settings.get("config") {
if !(config_value.is_string() || config_value.is_null()) {
return Err(AppError::localized(
"provider.codex.config.invalid_type",
"Codex config 字段必须是字符串",
"Codex config field must be a string",
));
}
if let Some(cfg_text) = config_value.as_str() {
crate::codex_config::validate_config_toml(cfg_text)?;
}
}
}
AppType::Gemini => {
use crate::gemini_config::validate_gemini_settings;
validate_gemini_settings(&provider.settings_config)?
}
}
// Validate and clean UsageScript configuration (common for all app types)
if let Some(meta) = &provider.meta {
if let Some(usage_script) = &meta.usage_script {
validate_usage_script(usage_script)?;
}
}
Ok(())
}
#[allow(dead_code)]
fn extract_credentials(
provider: &Provider,
app_type: &AppType,
) -> Result<(String, String), AppError> {
match app_type {
AppType::Claude => {
let env = provider
.settings_config
.get("env")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::localized(
"provider.claude.env.missing",
"配置格式错误: 缺少 env",
"Invalid configuration: missing env section",
)
})?;
let api_key = env
.get("ANTHROPIC_AUTH_TOKEN")
.or_else(|| env.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.claude.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let base_url = env
.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.claude.base_url.missing",
"缺少 ANTHROPIC_BASE_URL 配置",
"Missing ANTHROPIC_BASE_URL configuration",
)
})?
.to_string();
Ok((api_key, base_url))
}
AppType::Codex => {
let auth = provider
.settings_config
.get("auth")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::localized(
"provider.codex.auth.missing",
"配置格式错误: 缺少 auth",
"Invalid configuration: missing auth section",
)
})?;
let api_key = auth
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.codex.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let config_toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let base_url = if config_toml.contains("base_url") {
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
AppError::localized(
"provider.regex_init_failed",
format!("正则初始化失败: {e}"),
format!("Failed to initialize regex: {e}"),
)
})?;
re.captures(config_toml)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.ok_or_else(|| {
AppError::localized(
"provider.codex.base_url.invalid",
"config.toml 中 base_url 格式错误",
"base_url in config.toml has invalid format",
)
})?
} else {
return Err(AppError::localized(
"provider.codex.base_url.missing",
"config.toml 中缺少 base_url 配置",
"base_url is missing from config.toml",
));
};
Ok((api_key, base_url))
}
AppType::Gemini => {
use crate::gemini_config::json_to_env;
let env_map = json_to_env(&provider.settings_config)?;
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
AppError::localized(
"gemini.missing_api_key",
"缺少 GEMINI_API_KEY",
"Missing GEMINI_API_KEY",
)
})?;
let base_url = env_map
.get("GOOGLE_GEMINI_BASE_URL")
.cloned()
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
Ok((api_key, base_url))
}
}
}
#[allow(dead_code)]
fn app_not_found(app_type: &AppType) -> AppError {
AppError::localized(
"provider.app_not_found",
format!("应用类型不存在: {app_type:?}"),
format!("App type not found: {app_type:?}"),
)
}
}
/// Normalize Claude model keys in a JSON value
///
/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.
pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
let mut changed = false;
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
Some(obj) => obj,
None => return changed,
};
let model = env
.get("ANTHROPIC_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let small_fast = env
.get("ANTHROPIC_SMALL_FAST_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_haiku = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_sonnet = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_opus = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let target_haiku = current_haiku
.or_else(|| small_fast.clone())
.or_else(|| model.clone());
let target_sonnet = current_sonnet
.or_else(|| model.clone())
.or_else(|| small_fast.clone());
let target_opus = current_opus
.or_else(|| model.clone())
.or_else(|| small_fast.clone());
if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() {
if let Some(v) = target_haiku {
env.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
Value::String(v),
);
changed = true;
}
}
if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() {
if let Some(v) = target_sonnet {
env.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
Value::String(v),
);
changed = true;
}
}
if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() {
if let Some(v) = target_opus {
env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v));
changed = true;
}
}
if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() {
changed = true;
}
changed
}
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderSortUpdate {
pub id: String,
#[serde(rename = "sortIndex")]
pub sort_index: usize,
}
-180
View File
@@ -1,180 +0,0 @@
//! Usage script execution
//!
//! Handles executing and formatting usage query results.
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::{UsageData, UsageResult, UsageScript};
use crate::settings;
use crate::store::AppState;
use crate::usage_script;
/// Execute usage script and format result (private helper method)
pub(crate) async fn execute_and_format_usage_result(
script_code: &str,
api_key: &str,
base_url: &str,
timeout: u64,
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script(
script_code,
api_key,
base_url,
timeout,
access_token,
user_id,
)
.await
{
Ok(data) => {
let usage_list: Vec<UsageData> = if data.is_array() {
serde_json::from_value(data).map_err(|e| {
AppError::localized(
"usage_script.data_format_error",
format!("数据格式错误: {e}"),
format!("Data format error: {e}"),
)
})?
} else {
let single: UsageData = serde_json::from_value(data).map_err(|e| {
AppError::localized(
"usage_script.data_format_error",
format!("数据格式错误: {e}"),
format!("Data format error: {e}"),
)
})?;
vec![single]
};
Ok(UsageResult {
success: true,
data: Some(usage_list),
error: None,
})
}
Err(err) => {
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
let msg = match err {
AppError::Localized { zh, en, .. } => {
if lang == "en" {
en
} else {
zh
}
}
other => other.to_string(),
};
Ok(UsageResult {
success: false,
data: None,
error: Some(msg),
})
}
}
}
/// Query provider usage (using saved script configuration)
pub async fn query_usage(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
let usage_script = provider
.meta
.as_ref()
.and_then(|m| m.usage_script.as_ref())
.ok_or_else(|| {
AppError::localized(
"provider.usage.script.missing",
"未配置用量查询脚本",
"Usage script is not configured",
)
})?;
if !usage_script.enabled {
return Err(AppError::localized(
"provider.usage.disabled",
"用量查询未启用",
"Usage query is disabled",
));
}
// Get credentials directly from UsageScript, no longer extract from provider config
(
usage_script.code.clone(),
usage_script.timeout.unwrap_or(10),
usage_script.api_key.clone().unwrap_or_default(),
usage_script.base_url.clone().unwrap_or_default(),
usage_script.access_token.clone(),
usage_script.user_id.clone(),
)
};
execute_and_format_usage_result(
&script_code,
&api_key,
&base_url,
timeout,
access_token.as_deref(),
user_id.as_deref(),
)
.await
}
/// Test usage script (using temporary script content, not saved)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script(
_state: &AppState,
_app_type: AppType,
_provider_id: &str,
script_code: &str,
timeout: u64,
api_key: Option<&str>,
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing
execute_and_format_usage_result(
script_code,
api_key.unwrap_or(""),
base_url.unwrap_or(""),
timeout,
access_token,
user_id,
)
.await
}
/// Validate UsageScript configuration (boundary checks)
pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError> {
// Validate auto query interval (0-1440 minutes, max 24 hours)
if let Some(interval) = script.auto_query_interval {
if interval > 1440 {
return Err(AppError::localized(
"usage_script.interval_too_large",
format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
),
));
}
}
Ok(())
}
+122 -159
View File
@@ -34,6 +34,9 @@ pub struct Skill {
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
@@ -47,6 +50,9 @@ pub struct SkillRepo {
pub branch: String,
/// 是否启用
pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 技能安装状态
@@ -78,18 +84,21 @@ impl Default for SkillStore {
name: "awesome-claude-skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
},
],
}
@@ -185,11 +194,76 @@ impl SkillService {
})??;
let mut skills = Vec::new();
// 扫描仓库根目录(支持全仓库递归扫描)
let scan_dir = temp_dir.clone();
// 确定要扫描的目录路径
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skillsPath,则扫描该子目录
let subdir = temp_dir.join(skills_path.trim_matches('/'));
if !subdir.exists() {
log::warn!(
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
repo.owner,
repo.name,
skills_path
);
let _ = fs::remove_dir_all(&temp_dir);
return Ok(skills);
}
subdir
} else {
// 否则扫描仓库根目录
temp_dir.clone()
};
// 递归扫描目录查找所有技能
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
// 遍历目标目录
for entry in fs::read_dir(&scan_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 构建 README URL(考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.clone()
};
skills.push(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory,
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
});
}
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
}
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
@@ -197,85 +271,6 @@ impl SkillService {
Ok(skills)
}
/// 递归扫描目录查找 SKILL.md
///
/// 规则:
/// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹)
/// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录
fn scan_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
repo: &SkillRepo,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 根目录的 SKILL.md,使用仓库名
repo.name.clone()
} else {
// 子目录的 SKILL.md,使用相对路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
skills.push(skill);
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
}
}
Ok(())
}
/// 从 SKILL.md 构建技能对象
fn build_skill_from_metadata(
&self,
skill_md: &Path,
directory: &str,
repo: &SkillRepo,
) -> Result<Skill> {
let meta = self.parse_skill_metadata(skill_md)?;
// 构建 README URL
let readme_path = directory.to_string();
Ok(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
})
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
@@ -307,18 +302,25 @@ impl SkillService {
return Ok(());
}
// 收集所有本地技能
let mut local_skills = Vec::new();
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
// 处理找到的本地技能
for local_skill in local_skills {
let directory = &local_skill.directory;
if !path.is_dir() {
continue;
}
// 更新已安装状态(匹配远程技能)
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 更新已安装状态
let mut found = false;
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(directory) {
if skill.directory.eq_ignore_ascii_case(&directory) {
skill.installed = true;
found = true;
break;
@@ -327,68 +329,23 @@ impl SkillService {
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
skills.push(local_skill);
}
}
Ok(())
}
/// 递归扫描本地目录查找 SKILL.md
fn scan_local_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 如果是 install_dir 本身,使用最后一段路径名
current_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
// 使用相对于 install_dir 的路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
// 解析元数据并创建本地技能对象
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
});
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_local_dir_recursive(&path, base_dir, skills)?;
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
}
}
}
@@ -396,13 +353,11 @@ impl SkillService {
}
/// 去重技能列表
/// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
// 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
true
} else {
@@ -542,8 +497,16 @@ impl SkillService {
))
})??;
// 确定源目录路径(技能相对于仓库根目录的路径)
let source = temp_dir.join(&directory);
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
};
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
+114 -105
View File
@@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, OnceLock, RwLock};
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
@@ -16,14 +17,24 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
/// 这确保了云同步场景下多设备可以独立运作。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecurityAuthSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<SecurityAuthSettings>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
// ===== 设备级 UI 设置 =====
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
@@ -31,30 +42,25 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
// ===== 设备级目录覆盖 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_claude: Option<String>,
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_codex: Option<String>,
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
@@ -71,14 +77,14 @@ impl Default for AppSettings {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
launch_on_startup: false,
language: None,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
@@ -118,7 +124,7 @@ impl AppSettings {
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
.filter(|s| matches!(*s, "en" | "zh"))
.map(|s| s.to_string());
}
@@ -163,7 +169,60 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
static SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
fn settings_store() -> &'static RwLock<AppSettings> {
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
SETTINGS_STORE.get_or_init(|| RwLock::new(load_initial_settings()))
}
static SETTINGS_DB: OnceLock<Arc<Database>> = OnceLock::new();
const APP_SETTINGS_KEY: &str = "app_settings";
pub fn bind_db(db: Arc<Database>) {
if SETTINGS_DB.set(db).is_err() {
return;
}
if let Some(store) = SETTINGS_STORE.get() {
let mut guard = store.write().expect("写入设置锁失败");
*guard = load_initial_settings();
}
}
fn load_initial_settings() -> AppSettings {
if let Some(db) = SETTINGS_DB.get() {
if let Some(from_db) = load_from_db(db.as_ref()) {
return from_db;
}
// 从文件迁移一次并写入数据库
let file_settings = AppSettings::load_from_file();
if let Err(e) = save_to_db(db.as_ref(), &file_settings) {
log::warn!("迁移设置到数据库失败,将继续使用内存副本: {e}");
}
return file_settings;
}
AppSettings::load_from_file()
}
fn load_from_db(db: &Database) -> Option<AppSettings> {
let raw = db.get_setting(APP_SETTINGS_KEY).ok()??;
match serde_json::from_str::<AppSettings>(&raw) {
Ok(mut settings) => {
settings.normalize_paths();
Some(settings)
}
Err(err) => {
log::warn!("解析数据库中 app_settings 失败: {err}");
None
}
}
}
fn save_to_db(db: &Database, settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let json =
serde_json::to_string(&normalized).map_err(|e| AppError::JsonSerialize { source: e })?;
db.set_setting(APP_SETTINGS_KEY, &json)
}
fn resolve_override_path(raw: &str) -> PathBuf {
@@ -190,20 +249,36 @@ pub fn get_settings() -> AppSettings {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
if let Some(db) = SETTINGS_DB.get() {
save_to_db(db, &new_settings)?;
} else {
save_settings_file(&new_settings)?;
}
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file();
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = fresh_settings;
Ok(())
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings();
let current = settings
.security
.as_ref()
.and_then(|sec| sec.auth.as_ref())
.and_then(|auth| auth.selected_type.as_deref());
if current == Some(selected_type) {
return Ok(());
}
let mut security = settings.security.unwrap_or_default();
let mut auth = security.auth.unwrap_or_default();
auth.selected_type = Some(selected_type.to_string());
security.auth = Some(auth);
settings.security = Some(security);
update_settings(settings)
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
@@ -229,69 +304,3 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
///
/// 这是设备级别的设置,不随数据库同步。
/// 如果本地没有设置,调用者应该 fallback 到数据库的 `is_current` 字段。
pub fn get_current_provider(app_type: &AppType) -> Option<String> {
let settings = settings_store().read().ok()?;
match app_type {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
}
}
/// 设置指定应用类型的当前供应商 ID(保存到本地 settings
///
/// 这是设备级别的设置,不随数据库同步。
/// 传入 `None` 会清除当前供应商设置。
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
let mut settings = get_settings();
match app_type {
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()),
}
update_settings(settings)
}
/// 获取有效的当前供应商 ID(验证存在性)
///
/// 逻辑:
/// 1. 从本地 settings 读取当前供应商 ID
/// 2. 验证该 ID 在数据库中存在
/// 3. 如果不存在则清理本地 settingsfallback 到数据库的 is_current
///
/// 这确保了返回的 ID 一定是有效的(在数据库中存在)。
/// 多设备云同步场景下,配置导入后本地 ID 可能失效,此函数会自动修复。
pub fn get_effective_current_provider(
db: &crate::database::Database,
app_type: &AppType,
) -> Result<Option<String>, AppError> {
// 1. 从本地 settings 读取
if let Some(local_id) = get_current_provider(app_type) {
// 2. 验证该 ID 在数据库中存在
let providers = db.get_all_providers(app_type.as_str())?;
if providers.contains_key(&local_id) {
// 存在,直接返回
return Ok(Some(local_id));
}
// 3. 不存在,清理本地 settings
log::warn!(
"本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库",
local_id,
app_type.as_str()
);
let _ = set_current_provider(app_type, None);
}
// Fallback 到数据库的 is_current
db.get_current_provider(app_type.as_str())
}
+189 -77
View File
@@ -1,17 +1,15 @@
use serde_json::json;
use std::fs;
use std::path::PathBuf;
use std::{fs, path::Path, sync::RwLock};
use tauri::async_runtime;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
Provider, ProviderMeta,
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
MultiAppConfig, Provider, ProviderMeta,
};
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn sync_claude_provider_writes_live_settings() {
@@ -814,6 +812,135 @@ fn create_backup_retains_only_latest_entries() {
);
}
#[test]
fn import_config_from_path_overwrites_state_and_creates_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let config_dir = home.join(".cc-switch");
fs::create_dir_all(&config_dir).expect("create config dir");
let config_path = config_dir.join("config.json");
fs::write(&config_path, r#"{"version":1}"#).expect("seed original config");
let import_payload = serde_json::json!({
"version": 2,
"claude": {
"providers": {
"p-new": {
"id": "p-new",
"name": "Test Claude",
"settingsConfig": {
"env": { "ANTHROPIC_API_KEY": "new-key" }
}
}
},
"current": "p-new"
},
"codex": {
"providers": {},
"current": ""
},
"mcp": {
"claude": { "servers": {} },
"codex": { "servers": {} }
}
});
let import_path = config_dir.join("import.json");
fs::write(
&import_path,
serde_json::to_string_pretty(&import_payload).expect("serialize import payload"),
)
.expect("write import file");
let app_state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let backup_id = ConfigService::import_config_from_path(&import_path, &app_state)
.expect("import should succeed");
assert!(
!backup_id.is_empty(),
"expected backup id when original config exists"
);
let backup_path = config_dir.join("backups").join(format!("{backup_id}.json"));
assert!(
backup_path.exists(),
"backup file should exist at {}",
backup_path.display()
);
let updated_content = fs::read_to_string(&config_path).expect("read updated config");
let parsed: serde_json::Value =
serde_json::from_str(&updated_content).expect("parse updated config");
assert_eq!(
parsed
.get("claude")
.and_then(|c| c.get("current"))
.and_then(|c| c.as_str()),
Some("p-new"),
"saved config should record new current provider"
);
let guard = app_state.config.read().expect("lock state after import");
let claude_manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager in state");
assert_eq!(
claude_manager.current, "p-new",
"state should reflect new current provider"
);
assert!(
claude_manager.providers.contains_key("p-new"),
"new provider should exist in state"
);
}
#[test]
fn import_config_from_path_invalid_json_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let config_dir = home.join(".cc-switch");
fs::create_dir_all(&config_dir).expect("create config dir");
let invalid_path = config_dir.join("broken.json");
fs::write(&invalid_path, "{ not-json ").expect("write invalid json");
let app_state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = ConfigService::import_config_from_path(&invalid_path, &app_state)
.expect_err("import should fail");
match err {
AppError::Json { .. } => {}
other => panic!("expected json error, got {other:?}"),
}
}
#[test]
fn import_config_from_path_missing_file_produces_io_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let missing_path = Path::new("/nonexistent/import.json");
let app_state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = ConfigService::import_config_from_path(missing_path, &app_state)
.expect_err("import should fail for missing file");
match err {
AppError::Io { .. } => {}
other => panic!("expected io error, got {other:?}"),
}
}
#[test]
fn sync_gemini_packycode_sets_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -845,22 +972,21 @@ fn sync_gemini_packycode_sets_security_selected_type() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
"syncing PackyCode Gemini should enforce security.auth.selectedType"
);
}
@@ -896,7 +1022,22 @@ fn sync_gemini_google_official_sets_oauth_security() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let cc_settings = home.join(".cc-switch").join("settings.json");
assert!(
cc_settings.exists(),
"app settings should exist at {}",
cc_settings.display()
);
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
assert_eq!(
cc_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"syncing Google official should set oauth-personal in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -911,85 +1052,56 @@ fn sync_gemini_google_official_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should record oauth-personal for Google Official"
"Gemini settings should also record oauth-personal"
);
}
#[test]
fn export_sql_writes_to_target_path() {
fn export_config_to_file_writes_target_path() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create test state with some data
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
let config_dir = home.join(".cc-switch");
fs::create_dir_all(&config_dir).expect("create config dir");
let config_path = config_dir.join("config.json");
fs::write(&config_path, r#"{"version":42,"flag":true}"#).expect("write config");
let export_path = home.join("exported-config.json");
if export_path.exists() {
fs::remove_file(&export_path).expect("cleanup export target");
}
let state = create_test_state_with_config(&config).expect("create test state");
let result = async_runtime::block_on(cc_switch_lib::export_config_to_file(
export_path.to_string_lossy().to_string(),
))
.expect("export should succeed");
assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true));
// Export to SQL file
let export_path = home.join("test-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Verify file exists and contains data
assert!(export_path.exists(), "export file should exist");
let content = fs::read_to_string(&export_path).expect("read exported file");
let exported = fs::read_to_string(&export_path).expect("read exported file");
assert!(
content.contains("INSERT INTO") && content.contains("providers"),
"exported SQL should contain INSERT statements for providers"
);
assert!(
content.contains("test-provider"),
"exported SQL should contain test data"
exported.contains(r#""version":42"#) && exported.contains(r#""flag":true"#),
"exported file should mirror source config content"
);
}
#[test]
fn export_sql_returns_error_for_invalid_path() {
fn export_config_to_file_returns_error_when_source_missing() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
// Try to export to an invalid path (parent directory doesn't exist)
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
let err = state
.db
.export_sql(&invalid_path)
.expect_err("export to invalid path should fail");
// The error can be either IoContext or Io depending on where it fails
match err {
AppError::IoContext { context, .. } => {
assert!(
context.contains("原子写入失败") || context.contains("写入失败"),
"expected IO error message about atomic write failure, got: {context}"
);
}
AppError::Io { path, .. } => {
assert!(
path.starts_with("/nonexistent"),
"expected error for /nonexistent path, got: {path:?}"
);
}
other => panic!("expected IoContext or Io error, got {other:?}"),
let export_path = home.join("export-missing.json");
if export_path.exists() {
fs::remove_file(&export_path).expect("cleanup export target");
}
let err = async_runtime::block_on(cc_switch_lib::export_config_to_file(
export_path.to_string_lossy().to_string(),
))
.expect_err("export should fail when config.json missing");
assert!(
err.contains("IO 错误"),
"expected IO error message, got {err}"
);
}
+57 -49
View File
@@ -1,16 +1,15 @@
use std::collections::HashMap;
use std::fs;
use std::{collections::HashMap, fs, sync::RwLock};
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppType, McpApps, McpServer, McpService, MultiAppConfig,
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn import_default_config_claude_persists_provider() {
@@ -36,44 +35,43 @@ fn import_default_config_claude_persists_provider() {
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Claude);
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
import_default_config_test_hook(&state, AppType::Claude)
.expect("import default config succeeds");
// 验证内存状态
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("default"));
let default_provider = providers.get("default").expect("default provider");
let guard = state.config.read().expect("lock config");
let manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager present");
assert_eq!(manager.current, "default");
let default_provider = manager.providers.get("default").expect("default provider");
assert_eq!(
default_provider.settings_config, settings,
"default provider should capture live settings"
);
drop(guard);
// 验证数据已持久化到数据库(v3.7.0+ 使用 SQLite 而非 config.json
let db_path = home.join(".cc-switch").join("cc-switch.db");
// 验证配置已持久化
let config_path = home.join(".cc-switch").join("config.json");
assert!(
db_path.exists(),
"importing default config should persist to cc-switch.db"
config_path.exists(),
"importing default config should persist config.json"
);
}
#[test]
fn import_default_config_without_live_file_returns_error() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = import_default_config_test_hook(&state, AppType::Claude)
.expect_err("missing live file should error");
@@ -89,15 +87,10 @@ fn import_default_config_without_live_file_returns_error() {
other => panic!("unexpected error variant: {other:?}"),
}
// 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let config_path = home.join(".cc-switch").join("config.json");
assert!(
providers.is_empty(),
"failed import should not create any providers in database"
!config_path.exists(),
"failed import should not create config.json"
);
}
@@ -122,8 +115,9 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
)
.expect("seed ~/.claude.json");
let config = MultiAppConfig::default();
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
assert!(
@@ -131,7 +125,13 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
"import should report inserted or normalized entries"
);
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let guard = state.config.read().expect("lock config");
// v3.7.0: 检查统一结构
let servers = guard
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
@@ -139,28 +139,28 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
entry.apps.claude,
"imported server should have Claude app enabled"
);
drop(guard);
// 验证数据已持久化到数据库
let db_path = home.join(".cc-switch").join("cc-switch.db");
let config_path = home.join(".cc-switch").join("config.json");
assert!(
db_path.exists(),
"state.save should persist to cc-switch.db when changes detected"
config_path.exists(),
"state.save should persist config.json when changes detected"
);
}
#[test]
fn import_mcp_from_claude_invalid_json_preserves_state() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let home = ensure_test_home();
let mcp_path = get_claude_mcp_path();
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
.expect("seed invalid ~/.claude.json");
let state = create_test_state().expect("create test state");
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err =
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
@@ -172,11 +172,10 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
other => panic!("unexpected error variant: {other:?}"),
}
// 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let config_path = home.join(".cc-switch").join("config.json");
assert!(
servers.is_empty(),
"failed import should not persist any MCP servers to database"
!config_path.exists(),
"failed import should not persist config.json"
);
}
@@ -222,18 +221,27 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
},
);
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
// v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("codex-server").expect("codex server exists");
let guard = state.config.read().expect("lock config");
let entry = guard
.mcp
.servers
.as_ref()
.unwrap()
.get("codex-server")
.expect("codex server exists");
assert!(
entry.apps.codex,
"server should have Codex app enabled after toggle"
);
drop(guard);
let toml_path = cc_switch_lib::get_codex_config_path();
assert!(
+71 -102
View File
@@ -1,14 +1,14 @@
use serde_json::json;
use std::sync::RwLock;
use cc_switch_lib::{
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
write_codex_live_atomic, AppError, AppType, McpApps, McpServer, MultiAppConfig, Provider,
write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider,
};
#[path = "support.rs"]
mod support;
use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn switch_provider_updates_codex_live_and_state() {
@@ -59,30 +59,21 @@ command = "say"
);
}
// v3.7.0+: 使用统一的 MCP 结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
config.mcp.codex.servers.insert(
"echo-server".into(),
McpServer {
id: "echo-server".to_string(),
name: "Echo Server".to_string(),
server: json!({
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: true, // 启用 Codex
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
}
}),
);
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
@@ -104,39 +95,28 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let locked = app_state.config.read().expect("lock config after switch");
let manager = locked
.get_manager(&AppType::Codex)
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
// 供应商配置应该包含在 live 文件中
// 注意:live 文件还会包含 MCP 同步后的内容
assert!(
config_text.contains("mcp_servers.latest"),
"live file should contain provider's original config"
);
assert!(
new_config_text.contains("mcp_servers.latest"),
"provider snapshot should contain provider's original config"
assert_eq!(
new_config_text, config_text,
"provider config snapshot should match live file"
);
let legacy = providers
let legacy = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
let legacy_auth_value = legacy
@@ -145,8 +125,6 @@ command = "say"
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
.unwrap_or("");
// 回填机制:切换前会将 live 配置回填到当前供应商
// 这保护了用户在 live 文件中的手动修改
assert_eq!(
legacy_auth_value, "legacy-key",
"previous provider should be backfilled with live auth"
@@ -164,17 +142,16 @@ fn switch_provider_missing_provider_returns_error() {
.expect("claude manager")
.current = "does-not-exist".to_string();
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
.expect_err("switching to a missing provider should fail");
let err_str = err.to_string();
assert!(
err_str.contains("供应商不存在")
|| err_str.contains("Provider not found")
|| err_str.contains("missing-provider"),
"error message should mention missing provider, got: {err_str}"
err.to_string().contains("供应商不存在"),
"error message should mention missing provider"
);
}
@@ -233,7 +210,9 @@ fn switch_provider_updates_claude_live_and_state() {
);
}
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
switch_provider_test_hook(&app_state, AppType::Claude, "new-provider")
.expect("switch provider should succeed");
@@ -249,32 +228,25 @@ fn switch_provider_updates_claude_live_and_state() {
"live settings.json should reflect new provider auth"
);
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let locked = app_state.config.read().expect("lock config after switch");
let manager = locked
.get_manager(&AppType::Claude)
.expect("claude manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let legacy_provider = providers
let legacy_provider = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
// 回填机制:切换前会将 live 配置回填到当前供应商
// 这保护了用户在 live 文件中的手动修改
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should be backfilled with live config"
"previous provider should receive backfilled live config"
);
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
assert_eq!(
new_provider
.settings_config
@@ -285,26 +257,26 @@ fn switch_provider_updates_claude_live_and_state() {
"new provider snapshot should retain fresh auth"
);
// v3.7.0+ 使用 SQLite 数据库而非 config.json
// 验证数据已持久化到数据库
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
let db_path = std::path::Path::new(&home_dir)
.join(".cc-switch")
.join("cc-switch.db");
assert!(
db_path.exists(),
"switching provider should persist to cc-switch.db"
);
drop(locked);
// 验证当前供应商已更新
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
let config_path = std::path::Path::new(&home_dir)
.join(".cc-switch")
.join("config.json");
assert!(
config_path.exists(),
"switching provider should persist config.json"
);
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config"))
.expect("parse saved config");
assert_eq!(
current_id.as_deref(),
persisted
.get("claude")
.and_then(|claude| claude.get("current"))
.and_then(|current| current.as_str()),
Some("new-provider"),
"database should record the new current provider"
"saved config.json should record the new current provider"
);
}
@@ -332,7 +304,9 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
);
}
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
.expect_err("switching should fail when auth missing");
@@ -344,15 +318,10 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
other => panic!("expected config error, got {other:?}"),
}
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
// 但由于 live 配置写入失败,状态应该回滚
// 注意:这个行为取决于 switch_provider 的具体实现
let locked = app_state.config.read().expect("lock config after failure");
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
assert!(
current_id.is_none() || current_id.as_deref() == Some("invalid"),
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
manager.current.is_empty(),
"current provider should remain empty on failure"
);
}
+112 -130
View File
@@ -1,15 +1,14 @@
use serde_json::json;
use std::sync::RwLock;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
use support::{ensure_test_home, reset_test_fs, test_mutex};
fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -70,33 +69,21 @@ command = "say"
);
}
// 使用新的统一 MCP 结构(v3.7.0+
let servers = initial_config
.mcp
.servers
.get_or_insert_with(Default::default);
servers.insert(
initial_config.mcp.codex.servers.insert(
"echo-server".into(),
McpServer {
id: "echo-server".into(),
name: "Echo Server".into(),
server: json!({
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: true,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
}
}),
);
let state = create_test_state_with_config(&initial_config).expect("create test state");
let state = AppState {
config: RwLock::new(initial_config),
};
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
@@ -116,39 +103,28 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("read current provider after switch");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let guard = state.config.read().expect("read config after switch");
let manager = guard
.get_manager(&AppType::Codex)
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
// provider 存储的是原始配置,不包含 MCP 同步后的内容
assert!(
new_config_text.contains("mcp_servers.latest"),
"provider config should contain original MCP servers"
);
// live 文件额外包含同步的 MCP 服务器
assert!(
config_text.contains("mcp_servers.echo-server"),
"live config should include synced MCP servers"
assert_eq!(
new_config_text, config_text,
"provider config snapshot should match live file"
);
let legacy = providers
let legacy = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
let legacy_auth_value = legacy
@@ -191,21 +167,22 @@ fn switch_packycode_gemini_updates_security_selected_type() {
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
@@ -246,21 +223,22 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
manager.providers.insert("packy-meta".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
@@ -300,12 +278,30 @@ fn switch_google_official_gemini_sets_oauth_security() {
.insert("google-official".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -321,7 +317,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should reflect oauth-personal for Google Official"
"Gemini settings json should also reflect oauth-personal"
);
}
@@ -380,7 +376,9 @@ fn provider_service_switch_claude_updates_live_and_state() {
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Claude, "new-provider")
.expect("switch provider should succeed");
@@ -396,21 +394,17 @@ fn provider_service_switch_claude_updates_live_and_state() {
"live settings.json should reflect new provider auth"
);
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let guard = state
.config
.read()
.expect("read claude config after switch");
let manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let legacy_provider = providers
let legacy_provider = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
assert_eq!(
@@ -421,31 +415,20 @@ fn provider_service_switch_claude_updates_live_and_state() {
#[test]
fn provider_service_switch_missing_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = ProviderService::switch(&state, AppType::Claude, "missing")
.expect_err("switching missing provider should fail");
match err {
AppError::Message(msg) => {
assert!(
msg.contains("不存在") || msg.contains("not found"),
"expected provider not found message, got {msg}"
);
}
other => panic!("expected Message error for provider not found, got {other:?}"),
AppError::Localized { key, .. } => assert_eq!(key, "provider.not_found"),
other => panic!("expected Localized error for provider not found, got {other:?}"),
}
}
#[test]
fn provider_service_switch_codex_missing_auth_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
@@ -464,7 +447,9 @@ fn provider_service_switch_codex_missing_auth_returns_error() {
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let state = AppState {
config: RwLock::new(config),
};
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
.expect_err("switching should fail without auth");
@@ -523,21 +508,23 @@ fn provider_service_delete_codex_removes_provider_and_files() {
std::fs::write(&auth_path, "{}").expect("seed auth file");
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed");
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
let locked = app_state.config.read().expect("lock config after delete");
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
assert!(
!providers.contains_key("to-delete"),
!manager.providers.contains_key("to-delete"),
"provider entry should be removed"
);
// v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml
// 删除供应商只影响数据库记录,不清理这些旧格式文件
assert!(
!auth_path.exists() && !cfg_path.exists(),
"provider-specific files should be deleted"
);
}
#[test]
@@ -584,28 +571,28 @@ fn provider_service_delete_claude_removes_provider_files() {
std::fs::write(&by_name, "{}").expect("seed settings by name");
std::fs::write(&by_id, "{}").expect("seed settings by id");
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let locked = app_state.config.read().expect("lock config after delete");
let manager = locked
.get_manager(&AppType::Claude)
.expect("claude manager");
assert!(
!providers.contains_key("delete"),
!manager.providers.contains_key("delete"),
"claude provider should be removed"
);
// v3.7.0+ 不再使用供应商特定文件(如 settings-*.json
// 删除供应商只影响数据库记录,不清理这些旧格式文件
assert!(
!by_name.exists() && !by_id.exists(),
"provider config files should be deleted"
);
}
#[test]
fn provider_service_delete_current_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
@@ -625,26 +612,21 @@ fn provider_service_delete_current_provider_returns_error() {
);
}
let app_state = create_test_state_with_config(&config).expect("create test state");
let app_state = AppState {
config: RwLock::new(config),
};
let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
.expect_err("deleting current provider should fail");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商")
|| zh.contains("无法删除当前正在使用的供应商"),
zh.contains("不能删除当前正在使用的供应商"),
"unexpected message: {zh}"
),
AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config/Message error, got {other:?}"),
other => panic!("expected Config error, got {other:?}"),
}
}
+2 -17
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Mutex, OnceLock};
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
use cc_switch_lib::{update_settings, AppSettings};
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path {
@@ -45,18 +45,3 @@ pub fn test_mutex() -> &'static Mutex<()> {
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
MUTEX.get_or_init(|| Mutex::new(()))
}
/// 创建测试用的 AppState,包含一个空的数据库
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Database::init()?;
Ok(AppState { db: Arc::new(db) })
}
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
pub fn create_test_state_with_config(
config: &MultiAppConfig,
) -> Result<AppState, Box<dyn std::error::Error>> {
let db = Database::init()?;
db.migrate_from_json(config)?;
Ok(AppState { db: Arc::new(db) })
}
+26 -50
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core";
import {
Plus,
Settings,
ArrowLeft,
// Bot, // TODO: Agents 功能开发中,暂时不需要
Bot,
Book,
Wrench,
Server,
@@ -24,7 +23,6 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -125,24 +123,6 @@ function App() {
checkEnvOnStartup();
}, []);
// 应用启动时检查是否刚完成了配置迁移
useEffect(() => {
const checkMigration = async () => {
try {
const migrated = await invoke<boolean>("get_migration_result");
if (migrated) {
toast.success(
t("migration.success", { defaultValue: "配置迁移成功" }),
);
}
} catch (error) {
console.error("[App] Failed to check migration result:", error);
}
};
checkMigration();
}, [t]);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
@@ -218,8 +198,6 @@ function App() {
meta: provider.meta
? JSON.parse(JSON.stringify(provider.meta))
: undefined, // 深拷贝
icon: provider.icon,
iconColor: provider.iconColor,
};
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
@@ -405,6 +383,7 @@ function App() {
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
@@ -470,34 +449,9 @@ function App() {
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
isClaudeApp
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="h-4 w-4 flex-shrink-0" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)} */}
<Button
variant="ghost"
size="sm"
@@ -507,6 +461,17 @@ function App() {
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -516,6 +481,17 @@ function App() {
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
+3 -7
View File
@@ -40,12 +40,8 @@ export function ConfirmDialog({
}
}}
>
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogContent className="max-w-sm">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
@@ -54,7 +50,7 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<DialogFooter className="flex gap-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
+14 -23
View File
@@ -117,17 +117,12 @@ export function DeepLinkImportDialog() {
});
if (summary.failed.length > 0) {
toast.warning(t("deeplink.mcpPartialSuccess"), {
description: t("deeplink.mcpPartialSuccessDescription", {
success: summary.importedCount,
failed: summary.failed.length,
}),
toast.warning(`部分导入成功`, {
description: `成功: ${summary.importedCount}, 失败: ${summary.failed.length}`,
});
} else {
toast.success(t("deeplink.mcpImportSuccess"), {
description: t("deeplink.mcpImportSuccessDescription", {
count: summary.importedCount,
}),
toast.success("MCP Servers 导入成功", {
description: `成功导入 ${summary.importedCount} 个服务器`,
});
}
};
@@ -150,10 +145,8 @@ export function DeepLinkImportDialog() {
detail: { app: request.app },
}),
);
toast.success(t("deeplink.promptImportSuccess"), {
description: t("deeplink.promptImportSuccessDescription", {
name: request.name,
}),
toast.success("提示词导入成功", {
description: `已导入提示词: ${request.name}`,
});
} else if (result.type === "mcp") {
await refreshMcp(result);
@@ -167,10 +160,8 @@ export function DeepLinkImportDialog() {
queryKey: ["skills"],
type: "all",
});
toast.success(t("deeplink.skillImportSuccess"), {
description: t("deeplink.skillImportSuccessDescription", {
repo: request.repo,
}),
toast.success("Skill 仓库添加成功", {
description: `已添加仓库: ${request.repo}`,
});
}
} else if (isMcpImportResult(result)) {
@@ -291,11 +282,11 @@ export function DeepLinkImportDialog() {
if (!request) return t("deeplink.confirmImport");
switch (request.resource) {
case "prompt":
return t("deeplink.importPrompt");
return "导入提示词";
case "mcp":
return t("deeplink.importMcp");
return "导入 MCP Servers";
case "skill":
return t("deeplink.importSkill");
return "添加 Skill 仓库";
default:
return t("deeplink.confirmImport");
}
@@ -305,11 +296,11 @@ export function DeepLinkImportDialog() {
if (!request) return t("deeplink.confirmImportDescription");
switch (request.resource) {
case "prompt":
return t("deeplink.importPromptDescription");
return "请确认是否导入此系统提示词";
case "mcp":
return t("deeplink.importMcpDescription");
return "请确认是否导入这些 MCP Servers";
case "skill":
return t("deeplink.importSkillDescription");
return "请确认是否添加此 Skill 仓库";
default:
return t("deeplink.confirmImportDescription");
}
+147 -39
View File
@@ -176,16 +176,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API;
}
// 检测 GENERAL 模板(有 apiKey 或 baseUrl
if (existingScript?.apiKey || existingScript?.baseUrl) {
return TEMPLATE_KEYS.GENERAL;
}
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_KEYS.GENERAL;
return null;
},
);
@@ -355,9 +349,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
footer={footer}
>
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<p className="text-base font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<div className="space-y-1">
<p className="text-sm font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
@@ -371,9 +370,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<span className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</span>
</div>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
@@ -443,9 +447,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
<div className="space-y-2">
<Label htmlFor="usage-base-url">
{t("usageScript.baseUrl")}
</Label>
<Label htmlFor="usage-base-url">Base URL</Label>
<Input
id="usage-base-url"
type="text"
@@ -464,9 +466,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">
{t("usageScript.baseUrl")}
</Label>
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
<Input
id="usage-newapi-base-url"
type="text"
@@ -545,36 +545,141 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
)}
</div>
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
{/* 脚本配置 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<h4 className="text-base font-medium text-foreground">
{t("usageScript.scriptConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</p>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
<Label htmlFor="usage-request-url">
{t("usageScript.requestUrl")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
id="usage-request-url"
type="text"
value={script.request?.url || ""}
onChange={(e) => {
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
request: { ...script.request, url: e.target.value },
});
}}
placeholder={t("usageScript.requestUrlPlaceholder")}
className="border-white/10"
/>
</div>
{/* 自动查询间隔 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usage-method">
{t("usageScript.method")}
</Label>
<Input
id="usage-method"
type="text"
value={script.request?.method || "GET"}
onChange={(e) => {
setScript({
...script,
request: {
...script.request,
method: e.target.value.toUpperCase(),
},
});
}}
placeholder="GET / POST"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-headers">
{t("usageScript.headers")}
</Label>
<JsonEditor
id="usage-headers"
value={
script.request?.headers
? JSON.stringify(script.request.headers, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed = JSON.parse(value || "{}");
setScript({
...script,
request: { ...script.request, headers: parsed },
});
} catch (error) {
console.error("Invalid headers JSON", error);
}
}}
height={180}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
<JsonEditor
id="usage-body"
value={
script.request?.body
? JSON.stringify(script.request.body, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed =
value?.trim() === "" ? undefined : JSON.parse(value);
setScript({
...script,
request: { ...script.request, body: parsed },
});
} catch (error) {
toast.error(
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
);
}
}}
height={220}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")}
@@ -603,6 +708,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
</div>
+4 -8
View File
@@ -1,5 +1,4 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
import { decodeBase64Utf8 } from "../../lib/utils/base64";
@@ -8,8 +7,6 @@ export function McpConfirmation({
}: {
request: DeepLinkImportRequest;
}) {
const { t } = useTranslation();
const mcpServers = useMemo(() => {
if (!request.config) return null;
try {
@@ -23,15 +20,14 @@ export function McpConfirmation({
}, [request.config]);
const targetApps = request.apps?.split(",") || [];
const serverCount = Object.keys(mcpServers || {}).length;
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("deeplink.mcp.title")}</h3>
<h3 className="text-lg font-semibold"> MCP Servers</h3>
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.mcp.targetApps")}
</label>
<div className="mt-1 flex gap-2 flex-wrap">
{targetApps.map((app) => (
@@ -47,7 +43,7 @@ export function McpConfirmation({
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.mcp.serverCount", { count: serverCount })}
MCP Servers ({Object.keys(mcpServers || {}).length} )
</label>
<div className="mt-1 space-y-2 max-h-64 overflow-auto border rounded p-2 bg-muted/30">
{mcpServers &&
@@ -67,7 +63,7 @@ export function McpConfirmation({
{request.enabled && (
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
<span></span>
<span>{t("deeplink.mcp.enabledWarning")}</span>
<span></span>
</div>
)}
</div>
@@ -1,5 +1,4 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
import { decodeBase64Utf8 } from "../../lib/utils/base64";
@@ -8,8 +7,6 @@ export function PromptConfirmation({
}: {
request: DeepLinkImportRequest;
}) {
const { t } = useTranslation();
const decodedContent = useMemo(() => {
if (!request.content) return "";
return decodeBase64Utf8(request.content);
@@ -17,18 +14,18 @@ export function PromptConfirmation({
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("deeplink.prompt.title")}</h3>
<h3 className="text-lg font-semibold"></h3>
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.prompt.app")}
</label>
<div className="mt-1 text-sm capitalize">{request.app}</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.prompt.name")}
</label>
<div className="mt-1 text-sm">{request.name}</div>
</div>
@@ -36,7 +33,7 @@ export function PromptConfirmation({
{request.description && (
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.prompt.description")}
</label>
<div className="mt-1 text-sm">{request.description}</div>
</div>
@@ -44,7 +41,7 @@ export function PromptConfirmation({
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.prompt.contentPreview")}
</label>
<pre className="mt-1 max-h-48 overflow-auto bg-muted/50 p-2 rounded text-xs whitespace-pre-wrap border">
{decodedContent.substring(0, 500)}
@@ -55,7 +52,7 @@ export function PromptConfirmation({
{request.enabled && (
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
<span></span>
<span>{t("deeplink.prompt.enabledWarning")}</span>
<span></span>
</div>
)}
</div>
+23 -13
View File
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
export function SkillConfirmation({
@@ -6,15 +5,13 @@ export function SkillConfirmation({
}: {
request: DeepLinkImportRequest;
}) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("deeplink.skill.title")}</h3>
<h3 className="text-lg font-semibold"> Claude Skill </h3>
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.repo")}
GitHub
</label>
<div className="mt-1 text-sm font-mono bg-muted/50 p-2 rounded border">
{request.repo}
@@ -23,23 +20,36 @@ export function SkillConfirmation({
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.directory")}
</label>
<div className="mt-1 text-sm font-mono bg-muted/50 p-2 rounded border">
{request.directory}
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.branch")}
</label>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground">
</label>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
</div>
{request.skillsPath && (
<div>
<label className="block text-sm font-medium text-muted-foreground">
Skills
</label>
<div className="mt-1 text-sm">{request.skillsPath}</div>
</div>
)}
</div>
<div className="text-blue-600 dark:text-blue-400 text-sm bg-blue-50 dark:bg-blue-950/30 p-3 rounded border border-blue-200 dark:border-blue-800">
<p> {t("deeplink.skill.hint")}</p>
<p className="mt-1">{t("deeplink.skill.hintDetail")}</p>
<p> Skill </p>
<p className="mt-1">
Skills Skill
</p>
</div>
</div>
);
@@ -140,24 +140,15 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点(来自预设,isCustom 可能为 false
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 合并从后端加载的自定义端点
// 关键:如果 URL 已存在(与预设重合),需要将 isCustom 更新为 true
// 因为它存在于数据库中,需要在 handleSave 时被正确识别
// 添加从后端加载的自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) {
// URL 已存在,更新 isCustom 为 true(因为它在数据库中)
existing.isCustom = true;
} else {
// URL 不存在,添加新条目
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
+18 -94
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
@@ -327,11 +326,11 @@ export function ProviderForm({
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -369,22 +368,6 @@ export function ProviderForm({
[originalHandleGeminiBaseUrlChange, form],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[originalHandleGeminiModelChange, form],
);
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
const {
useCommonConfig: useGeminiCommonConfigFlag,
@@ -405,82 +388,17 @@ export function ProviderForm({
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
toast.error(
t("providerForm.fillParameter", {
form.setError("settingsConfig", {
type: "manual",
message: t("providerForm.fillParameter", {
label: validation.missingField.label,
defaultValue: `请填写 ${validation.missingField.label}`,
}),
);
});
return;
}
}
// 供应商名称必填校验
if (!values.name.trim()) {
toast.error(
t("providerForm.fillSupplierName", {
defaultValue: "请填写供应商名称",
}),
);
return;
}
// 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
if (appId === "claude") {
if (!baseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!apiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
} else if (appId === "codex") {
if (!codexBaseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!codexApiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
} else if (appId === "gemini") {
if (!geminiBaseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!geminiApiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
}
}
let settingsConfig: string;
// Codex: 组合 auth 和 config
@@ -698,8 +616,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -717,8 +633,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -733,8 +647,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
@@ -846,7 +758,19 @@ export function ProviderForm({
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
onModelChange={(model) => {
// 同时更新 form.settingsConfig 和 geminiEnv
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
// 同步更新 geminiEnv,确保提交时不丢失
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = model.trim();
const newEnv = envObjToString(envObj);
handleGeminiEnvChange(newEnv);
}}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -22,32 +22,18 @@ export function useGeminiConfigState({
const [configError, setConfigError] = useState("");
// 将 JSON env 对象转换为 .env 格式字符串
// 保留所有环境变量,已知 key 优先显示
const envObjToString = useCallback(
(envObj: Record<string, unknown>): string => {
const priorityKeys = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_MODEL",
];
const lines: string[] = [];
const addedKeys = new Set<string>();
// 先添加已知 key(按顺序)
for (const key of priorityKeys) {
if (typeof envObj[key] === "string" && envObj[key]) {
lines.push(`${key}=${envObj[key]}`);
addedKeys.add(key);
}
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
}
// 再添加其他自定义 key(保留用户添加的环境变量)
for (const [key, value] of Object.entries(envObj)) {
if (!addedKeys.has(key) && typeof value === "string") {
lines.push(`${key}=${value}`);
}
if (typeof envObj.GEMINI_API_KEY === "string") {
lines.push(`GEMINI_API_KEY=${envObj.GEMINI_API_KEY}`);
}
if (typeof envObj.GEMINI_MODEL === "string") {
lines.push(`GEMINI_MODEL=${envObj.GEMINI_MODEL}`);
}
return lines.join("\n");
},
[],
@@ -178,20 +164,6 @@ export function useGeminiConfigState({
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 Gemini Model 变化
const handleGeminiModelChange = useCallback(
(model: string) => {
const trimmed = model.trim();
setGeminiModel(trimmed);
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = trimmed;
const newEnv = envObjToString(envObj);
setGeminiEnv(newEnv);
},
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 env 变化
const handleGeminiEnvChange = useCallback(
(value: string) => {
@@ -251,7 +223,6 @@ export function useGeminiConfigState({
setGeminiConfig,
handleGeminiApiKeyChange,
handleGeminiBaseUrlChange,
handleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
+2 -7
View File
@@ -2,11 +2,9 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type LanguageOption = "zh" | "en" | "ja";
interface LanguageSettingsProps {
value: LanguageOption;
onChange: (value: LanguageOption) => void;
value: "zh" | "en";
onChange: (value: "zh" | "en") => void;
}
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
@@ -27,9 +25,6 @@ export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
<LanguageButton active={value === "en"} onClick={() => onChange("en")}>
{t("settings.languageOptionEnglish")}
</LanguageButton>
<LanguageButton active={value === "ja"} onClick={() => onChange("ja")}>
{t("settings.languageOptionJapanese")}
</LanguageButton>
</div>
</section>
);
+16
View File
@@ -34,6 +34,7 @@ export function RepoManager({
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
@@ -79,10 +80,12 @@ export function RepoManager({
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
@@ -127,6 +130,13 @@ export function RepoManager({
onChange={(e) => setBranch(e.target.value)}
className="flex-1"
/>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleAdd}
className="w-full sm:w-auto sm:px-4"
@@ -161,6 +171,12 @@ export function RepoManager({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
+34 -11
View File
@@ -26,6 +26,7 @@ export function RepoManagerPanel({
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
@@ -66,10 +67,12 @@ export function RepoManagerPanel({
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined,
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
@@ -107,17 +110,31 @@ export function RepoManagerPanel({
className="mt-2"
/>
</div>
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="skills-path" className="text-foreground">
{t("skills.repo.path")}
</Label>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="mt-2"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
@@ -157,6 +174,12 @@ export function RepoManagerPanel({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
+3 -30
View File
@@ -76,8 +76,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
},
{
name: "Zhipu GLM",
@@ -96,8 +94,6 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Z.ai GLM",
@@ -116,8 +112,6 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Qwen Coder",
@@ -134,8 +128,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
},
{
name: "Kimi k2",
@@ -151,8 +143,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "Kimi For Coding",
@@ -168,8 +158,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "ModelScope",
@@ -185,8 +173,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "KAT-Coder",
@@ -234,7 +220,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
@@ -248,14 +234,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "DouBaoSeed",
@@ -273,8 +251,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "doubao",
iconColor: "#3370FF",
},
{
name: "BaiLing",
@@ -290,8 +266,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "AiHubMix",
@@ -316,11 +290,11 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_API_KEY: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
category: "aggregator",
},
{
@@ -341,6 +315,5 @@ export const providerPresets: ProviderPreset[] = [
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
-8
View File
@@ -20,9 +20,6 @@ export interface CodexProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
/**
@@ -74,8 +71,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
backgroundColor: "#1F2937", // gray-800
textColor: "#FFFFFF",
},
icon: "openai",
iconColor: "#00A67E",
},
{
name: "Azure OpenAI",
@@ -102,8 +97,6 @@ requires_openai_auth = true`,
backgroundColor: "#0078D4",
textColor: "#FFFFFF",
},
icon: "azure",
iconColor: "#0078D4",
},
{
name: "AiHubMix",
@@ -149,6 +142,5 @@ requires_openai_auth = true`,
],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
-6
View File
@@ -25,9 +25,6 @@ export interface GeminiProviderPreset {
partnerPromotionKey?: string;
endpointCandidates?: string[];
theme?: GeminiPresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const geminiProviderPresets: GeminiProviderPreset[] = [
@@ -46,8 +43,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
backgroundColor: "#4285F4",
textColor: "#FFFFFF",
},
icon: "gemini",
iconColor: "#4285F4",
},
{
name: "PackyCode",
@@ -69,7 +64,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
"https://api-slb.packyapi.com",
"https://www.packyapi.com",
],
icon: "packycode",
},
{
name: "自定义",
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from "./useDirectorySettings";
import { useSettingsMetadata } from "./useSettingsMetadata";
type Language = "zh" | "en" | "ja";
type Language = "zh" | "en";
interface SaveResult {
requiresRestart: boolean;
+4 -5
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useSettingsQuery } from "@/lib/query";
import type { Settings } from "@/types";
type Language = "zh" | "en" | "ja";
type Language = "zh" | "en";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
@@ -11,8 +11,7 @@ export type SettingsFormState = Omit<Settings, "language"> & {
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
const normalized = lang.toLowerCase();
return normalized === "en" || normalized === "ja" ? normalized : "zh";
return lang === "en" ? "en" : "zh";
};
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -52,8 +51,8 @@ export function useSettingsForm(): UseSettingsFormResult {
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh" || stored === "ja") {
return stored as Language;
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
+3 -13
View File
@@ -2,18 +2,15 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import ja from "./locales/ja.json";
import zh from "./locales/zh.json";
type Language = "zh" | "en" | "ja";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
const DEFAULT_LANGUAGE: Language = "zh";
const getInitialLanguage = (): Language => {
const getInitialLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en" || stored === "ja") {
if (stored === "zh" || stored === "en") {
return stored;
}
} catch (error) {
@@ -31,10 +28,6 @@ const getInitialLanguage = (): Language => {
return "zh";
}
if (navigatorLang?.startsWith("ja")) {
return "ja";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
@@ -46,9 +39,6 @@ const resources = {
en: {
translation: en,
},
ja: {
translation: ja,
},
zh: {
translation: zh,
},
+3 -45
View File
@@ -165,7 +165,6 @@
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "Window Behavior",
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup",
@@ -262,8 +261,7 @@
"getApiKey": "Get API Key",
"partnerPromotion": {
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -277,8 +275,6 @@
"fillConfigContent": "Please fill in configuration content",
"fillParameter": "Please fill in {{label}}",
"fillTemplateValue": "Please fill in {{label}}",
"endpointRequired": "API endpoint is required for non-official providers",
"apiKeyRequired": "API Key is required for non-official providers",
"configJsonError": "Config JSON format error, please check syntax",
"authJsonRequired": "auth.json must be a JSON object",
"authJsonError": "auth.json format error, please check JSON syntax",
@@ -377,7 +373,6 @@
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "Credentials",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Generate in 'Security Settings'",
"userId": "User ID",
@@ -391,7 +386,7 @@
"timeoutHint": "Range: 2-30 seconds",
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
"timeoutCannotBeNegative": "Timeout cannot be negative",
"autoIntervalMinutes": "Auto query interval (minutes, 0 to disable)",
"autoIntervalMinutes": "Auto query interval (minutes)",
"autoQueryInterval": "Auto Query Interval (minutes)",
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
@@ -754,20 +749,6 @@
"deeplink": {
"confirmImport": "Confirm Import Provider",
"confirmImportDescription": "The following configuration will be imported from deep link into CC Switch",
"importPrompt": "Import Prompt",
"importPromptDescription": "Please confirm whether to import this system prompt",
"importMcp": "Import MCP Servers",
"importMcpDescription": "Please confirm whether to import these MCP Servers",
"importSkill": "Add Skill Repository",
"importSkillDescription": "Please confirm whether to add this Skill repository",
"promptImportSuccess": "Prompt imported successfully",
"promptImportSuccessDescription": "Imported prompt: {{name}}",
"mcpImportSuccess": "MCP Servers imported successfully",
"mcpImportSuccessDescription": "Successfully imported {{count}} server(s)",
"mcpPartialSuccess": "Partial import success",
"mcpPartialSuccessDescription": "Success: {{success}}, Failed: {{failed}}",
"skillImportSuccess": "Skill repository added successfully",
"skillImportSuccessDescription": "Added repository: {{repo}}",
"app": "App Type",
"providerName": "Provider Name",
"homepage": "Homepage",
@@ -792,30 +773,7 @@
"configRemote": "Remote Config",
"configDetails": "Config Details",
"configUrl": "Config File URL",
"configMergeError": "Failed to merge configuration file",
"mcp": {
"title": "Batch Import MCP Servers",
"targetApps": "Target Apps",
"serverCount": "MCP Servers ({{count}})",
"enabledWarning": "After import, configurations will be written to all specified apps immediately"
},
"prompt": {
"title": "Import System Prompt",
"app": "App",
"name": "Name",
"description": "Description",
"contentPreview": "Content Preview",
"enabledWarning": "After import, this prompt will be enabled immediately and other prompts will be disabled"
},
"skill": {
"title": "Add Claude Skill Repository",
"repo": "GitHub Repository",
"directory": "Target Directory",
"branch": "Branch",
"skillsPath": "Skills Path",
"hint": "This will add the Skill repository to the list.",
"hintDetail": "After adding, you can install specific Skills from the Skills management page."
}
"configMergeError": "Failed to merge configuration file"
},
"iconPicker": {
"search": "Search Icons",
-837
View File
@@ -1,837 +0,0 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code・Codex・Gemini CLI のためのオールインワンアシスタント"
},
"common": {
"add": "追加",
"edit": "編集",
"delete": "削除",
"save": "保存",
"saving": "保存中...",
"cancel": "キャンセル",
"confirm": "確認",
"close": "閉じる",
"done": "完了",
"settings": "設定",
"about": "バージョン情報",
"version": "バージョン",
"loading": "読み込み中...",
"success": "成功",
"error": "エラー",
"unknown": "不明",
"enterValidValue": "有効な値を入力してください",
"clear": "クリア",
"toggleTheme": "テーマを切り替え",
"format": "フォーマット",
"formatSuccess": "整形しました",
"formatError": "整形に失敗しました: {{error}}",
"copy": "コピー",
"view": "表示",
"back": "戻る"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
"show": "API Key を表示",
"hide": "API Key を隠す"
},
"jsonEditor": {
"mustBeObject": "設定はオブジェクト形式の JSON で入力してください(配列や他の型は不可)",
"invalidJson": "JSON 形式が正しくありません"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
"fullSettingsHint": "Claude Code の settings.json 全文"
},
"header": {
"viewOnGithub": "GitHub で見る",
"toggleDarkMode": "ダークモードに切り替え",
"toggleLightMode": "ライトモードに切り替え",
"addProvider": "プロバイダーを追加",
"switchToChinese": "中国語に切り替え",
"switchToEnglish": "英語に切り替え",
"enterEditMode": "編集モードに入る",
"exitEditMode": "編集モードを終了"
},
"provider": {
"noProviders": "まだプロバイダーがありません",
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
"currentlyUsing": "現在使用中",
"enable": "有効化",
"inUse": "使用中",
"editProvider": "プロバイダーを編集",
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
"deleteProvider": "プロバイダーを削除",
"addNewProvider": "新しいプロバイダーを追加",
"addClaudeProvider": "Claude Code プロバイダーを追加",
"addCodexProvider": "Codex プロバイダーを追加",
"addGeminiProvider": "Gemini プロバイダーを追加",
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
"editClaudeProvider": "Claude Code プロバイダーを編集",
"editCodexProvider": "Codex プロバイダーを編集",
"configError": "設定エラー",
"notConfigured": "公式サイト用に未設定",
"applyToClaudePlugin": "Claude プラグインに適用",
"removeFromClaudePlugin": "Claude プラグインから解除",
"dragToReorder": "ドラッグで並べ替え",
"dragHandle": "ドラッグで並べ替え",
"duplicate": "複製",
"sortUpdateFailed": "並び順の更新に失敗しました",
"configureUsage": "利用状況を設定",
"name": "プロバイダー名",
"namePlaceholder": "例: Claude Official",
"websiteUrl": "Web サイト URL",
"notes": "メモ",
"notesPlaceholder": "例: 会社用アカウント",
"configJson": "Config JSON",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfigButton": "共通設定を編集",
"configJsonHint": "Claude Code の設定をすべて入力してください",
"editCommonConfigTitle": "共通設定スニペットを編集",
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
"addProvider": "プロバイダーを追加",
"sortUpdated": "並び順を更新しました",
"usageSaved": "利用状況の設定を保存しました",
"usageSaveFailed": "利用状況設定の保存に失敗しました",
"geminiConfig": "Gemini 設定",
"geminiConfigHint": ".env 形式で Gemini を設定してください",
"form": {
"gemini": {
"model": "モデル",
"oauthTitle": "OAuth 認証モード",
"oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。",
"apiKeyPlaceholder": "Gemini API Key を入力"
}
}
},
"notifications": {
"providerAdded": "プロバイダーを追加しました",
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功! {{appName}} ターミナルを再起動すると反映されます",
"switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
"addFailed": "プロバイダーの追加に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"saveFailedGeneric": "保存に失敗しました。もう一度お試しください",
"appliedToClaudePlugin": "Claude プラグインに適用しました",
"removedFromClaudePlugin": "Claude プラグインから削除しました",
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
"updateSuccess": "プロバイダーを更新しました",
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
"deleteSuccess": "プロバイダーを削除しました",
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
},
"settings": {
"title": "設定",
"general": "一般",
"tabGeneral": "一般",
"tabAdvanced": "詳細",
"language": "言語",
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
"theme": "テーマ",
"themeHint": "アプリのテーマを選択します。すぐに反映されます。",
"themeLight": "ライト",
"themeDark": "ダーク",
"themeSystem": "システム",
"importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。",
"exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。",
"import": "インポート",
"importing": "インポート中...",
"importSuccess": "インポート成功!",
"importFailed": "インポート失敗",
"syncLiveFailed": "インポートしましたが、現在のプロバイダーへの同期に失敗しました。手動で再選択してください。",
"importPartialSuccess": "設定はインポートされましたが、現在のプロバイダーへの同期に失敗しました。",
"importPartialHint": "ライブ設定を更新するため、もう一度プロバイダーを選択してください。",
"configExported": "設定をエクスポートしました:",
"exportFailed": "エクスポートに失敗しました",
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID",
"autoReload": "2 秒後に自動で再読み込みします...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "ウィンドウ動作",
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
"launchOnStartup": "起動時に自動実行",
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
"autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
"appConfigDirDescription": "CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)",
"browsePlaceholderApp": "例: C:\\\\Users\\\\Administrator\\\\.cc-switch",
"claudeConfigDir": "Claude Code 設定ディレクトリ",
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きし、claude.json(MCP)も同じ場所に置きます。",
"codexConfigDir": "Codex 設定ディレクトリ",
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認",
"updateTo": "v{{version}} に更新",
"updating": "更新中...",
"checking": "確認中...",
"upToDate": "最新バージョンです",
"aboutHint": "バージョン情報と更新状況を表示します。",
"portableMode": "ポータブルモード: 更新は手動ダウンロードが必要です。",
"updateAvailable": "新しいバージョンがあります: {{version}}",
"updateFailed": "更新のインストールに失敗しました。ダウンロードページを開こうとしました。",
"checkUpdateFailed": "更新の確認に失敗しました。時間をおいて再試行してください。",
"openReleaseNotesFailed": "リリースノートの表示に失敗しました",
"releaseNotes": "リリースノート",
"viewReleaseNotes": "このバージョンのリリースノートを見る",
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
"importFailedError": "設定のインポートに失敗しました: {{message}}",
"exportFailedError": "設定のエクスポートに失敗しました:",
"restartRequired": "再起動が必要です",
"restartRequiredMessage": "CC Switch の設定ディレクトリを変更すると再起動が必要です。今すぐ再起動しますか?",
"restartNow": "今すぐ再起動",
"restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中..."
},
"apps": {
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
"setupListenerFailed": "プロバイダー切り替えリスナーの設定に失敗:",
"updateProviderFailed": "プロバイダー更新に失敗:",
"autoImportFailed": "デフォルト設定の自動インポートに失敗:",
"openLinkFailed": "リンクを開けませんでした:",
"getVersionFailed": "バージョン情報の取得に失敗:",
"loadSettingsFailed": "設定の読み込みに失敗:",
"getConfigPathFailed": "設定パスの取得に失敗:",
"getConfigDirFailed": "設定ディレクトリの取得に失敗:",
"detectPortableFailed": "ポータブルモードの検出に失敗:",
"saveSettingsFailed": "設定の保存に失敗:",
"updateFailed": "更新に失敗:",
"checkUpdateFailed": "更新確認に失敗:",
"openConfigFolderFailed": "設定フォルダを開けませんでした:",
"selectConfigDirFailed": "設定ディレクトリの選択に失敗:",
"getDefaultConfigDirFailed": "デフォルト設定ディレクトリの取得に失敗:",
"openReleaseNotesFailed": "リリースノートを開けませんでした:"
},
"providerForm": {
"supplierName": "プロバイダー名",
"supplierNameRequired": "プロバイダー名 *",
"supplierNamePlaceholder": "例: Anthropic Official",
"websiteUrl": "Web サイト URL",
"websiteUrlPlaceholder": "https://example.com(任意)",
"apiEndpoint": "API エンドポイント",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "管理・テスト",
"configContent": "設定内容",
"officialNoApiKey": "公式ログインは API Key 不要です。そのまま保存できます",
"codexOfficialNoApiKey": "公式は API Key 不要です。そのまま保存してください",
"codexApiKeyAutoFill": "ここに入力すれば auth.json も自動で埋まります",
"apiKeyAutoFill": "ここに入力すれば下の設定も自動で埋まります",
"cnOfficialApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
"mainModelPlaceholder": "例: GLM-4.6",
"fastModel": "高速モデル(任意)",
"fastModelPlaceholder": "例: GLM-4.5-Air",
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください",
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
"fillSupplierName": "プロバイダー名を入力してください",
"fillConfigContent": "設定内容を入力してください",
"fillParameter": "{{label}} を入力してください",
"fillTemplateValue": "{{label}} を入力してください",
"endpointRequired": "公式以外は API エンドポイントが必須です",
"apiKeyRequired": "公式以外は API Key が必須です",
"configJsonError": "Config JSON の形式が正しくありません。構文を確認してください",
"authJsonRequired": "auth.json は JSON オブジェクトで入力してください",
"authJsonError": "auth.json の形式が正しくありません。JSON を確認してください",
"fillAuthJson": "auth.json の設定を入力してください",
"fillApiKey": "OPENAI_API_KEY を入力してください",
"visitWebsite": "{{url}} を開く",
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
"modelPlaceholder": "",
"smallModelPlaceholder": "",
"haikuModelPlaceholder": "",
"modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。",
"categoryOfficial": "公式",
"categoryCnOfficial": "オープンソース公式",
"categoryAggregation": "アグリゲーター",
"categoryThirdParty": "サードパーティ"
},
"endpointTest": {
"title": "API エンドポイント管理",
"endpoints": "エンドポイント",
"autoSelect": "自動選択",
"testSpeed": "テスト",
"testing": "テスト中",
"addEndpointPlaceholder": "https://api.example.com",
"done": "完了",
"noEndpoints": "エンドポイントがありません",
"failed": "失敗",
"enterValidUrl": "有効な URL を入力してください",
"invalidUrlFormat": "URL 形式が正しくありません",
"onlyHttps": "HTTP/HTTPS のみサポートします",
"urlExists": "この URL はすでに存在します",
"saveFailed": "保存に失敗しました。もう一度お試しください",
"loadEndpointsFailed": "カスタムエンドポイントの読み込みに失敗:",
"addEndpointFailed": "カスタムエンドポイントの追加に失敗:",
"removeEndpointFailed": "カスタムエンドポイントの削除に失敗:",
"removeFailed": "削除に失敗しました: {{error}}",
"updateLastUsedFailed": "エンドポイントの最終使用時間の更新に失敗しました",
"pleaseAddEndpoint": "まずエンドポイントを追加してください",
"testUnavailable": "速度テストを実行できません",
"noResult": "結果がありません",
"testFailed": "速度テストに失敗しました: {{error}}",
"status": "ステータス: {{code}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex の auth.json 設定内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex の config.toml 設定内容",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"apiUrlLabel": "API リクエスト URL"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
"envFileHint": ".env 形式で Gemini の環境変数を設定",
"configJson": "設定ファイル (config.json)",
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
"commonConfigHint": "共通設定スニペットは、この機能をオンにしたすべての Gemini プロバイダーへマージされます"
},
"providerPreset": {
"label": "プロバイダータイプ",
"custom": "カスタム設定",
"other": "その他",
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
},
"usage": {
"queryFailed": "照会に失敗しました",
"refreshUsage": "利用状況を更新",
"planUsage": "プラン利用状況",
"invalid": "期限切れ",
"total": "合計:",
"used": "使用:",
"remaining": "残り:",
"justNow": "たった今",
"minutesAgo": "{{count}} 分前",
"hoursAgo": "{{count}} 時間前",
"daysAgo": "{{count}} 日前"
},
"usageScript": {
"title": "利用状況を設定",
"enableUsageQuery": "利用状況照会を有効にする",
"presetTemplate": "プリセットテンプレート",
"requestUrl": "リクエスト URL",
"requestUrlPlaceholder": "例: https://api.example.com",
"method": "HTTP メソッド",
"templateCustom": "カスタム",
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "認証情報",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "「Security Settings」で生成",
"userId": "ユーザー ID",
"userIdPlaceholder": "例: 114514",
"defaultPlan": "デフォルトプラン",
"queryFailedMessage": "照会に失敗しました",
"queryScript": "照会スクリプト (JavaScript)",
"timeoutSeconds": "タイムアウト(秒)",
"headers": "ヘッダー",
"body": "ボディ",
"timeoutHint": "範囲: 2〜30 秒",
"timeoutMustBeInteger": "タイムアウトは整数で入力してください(小数は切り捨て)",
"timeoutCannotBeNegative": "タイムアウトは負の値にできません",
"autoIntervalMinutes": "自動照会間隔(分、0 で無効)",
"autoQueryInterval": "自動照会間隔(分)",
"autoQueryIntervalHint": "0 で無効。推奨 5〜60 分",
"intervalMustBeInteger": "間隔は整数で入力してください(小数は切り捨て)",
"intervalCannotBeNegative": "間隔は負の値にできません",
"intervalAdjusted": "間隔を {{value}} 分に調整しました",
"scriptHelp": "スクリプトの書き方:",
"configFormat": "設定の形式:",
"commentOptional": "任意",
"commentResponseIsJson": "response は API から返る JSON データです",
"extractorFormat": "抽出関数の返却形式(すべて任意):",
"tips": "💡 ヒント:",
"testing": "テスト中...",
"testScript": "スクリプトをテスト",
"format": "整形",
"saveConfig": "設定を保存",
"scriptEmpty": "スクリプト設定は空にできません",
"mustHaveReturn": "スクリプトには return 文が必要です",
"testSuccess": "テスト成功!",
"testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード",
"extractorHint": "戻り値のオブジェクトに残り枠の項目を含めてください",
"fieldIsValid": "• isValid: Boolean。プランが有効かどうか",
"fieldInvalidMessage": "• invalidMessage: String。無効時の理由(isValid が false のとき表示)",
"fieldRemaining": "• remaining: Number。残り枠",
"fieldUnit": "• unit: String。単位(例: \"USD\"",
"fieldPlanName": "• planName: String。プラン名",
"fieldTotal": "• total: Number。総枠",
"fieldUsed": "• used: Number。使用量",
"fieldExtra": "• extra: String。自由記述の追加テキスト",
"tip1": "• 変数 {{apiKey}} と {{baseUrl}} は自動で置換されます",
"tip2": "• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます",
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
},
"errors": {
"usage_query_failed": "利用状況の取得に失敗しました"
},
"presetSelector": {
"title": "設定タイプを選択",
"custom": "カスタム",
"customDescription": "手動で設定。完全な構成が必要",
"officialDescription": "公式ログイン。API Key 不要",
"presetDescription": "プリセットを使用。API Key だけ入力すれば OK"
},
"mcp": {
"title": "MCP 管理",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
"unifiedPanel": {
"title": "MCP サーバー管理",
"addServer": "サーバーを追加",
"editServer": "サーバーを編集",
"deleteServer": "サーバーを削除",
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"userLevelPath": "ユーザーレベルの MCP パス",
"serverList": "サーバー一覧",
"loading": "読み込み中...",
"empty": "MCP サーバーがありません",
"emptyDescription": "右上のボタンから最初の MCP サーバーを追加してください",
"add": "MCP を追加",
"addServer": "MCP を追加",
"editServer": "MCP を編集",
"addClaudeServer": "Claude Code MCP を追加",
"editClaudeServer": "Claude Code MCP を編集",
"addCodexServer": "Codex MCP を追加",
"editCodexServer": "Codex MCP を編集",
"configPath": "設定パス",
"serverCount": "MCP サーバー: {{count}} 件",
"enabledCount": "{{count}} 件が有効",
"template": {
"fetch": "クイックテンプレート: mcp-fetch"
},
"form": {
"title": "MCP ID(ユニーク)",
"titlePlaceholder": "my-mcp-server",
"name": "表示名",
"namePlaceholder": "例: @modelcontextprotocol/server-time",
"enabledApps": "適用するアプリ",
"noAppsWarning": "少なくとも 1 つ選択してください",
"description": "説明",
"descriptionPlaceholder": "任意の説明",
"tags": "タグ(カンマ区切り)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "ホームページ",
"homepagePlaceholder": "https://example.com",
"docs": "ドキュメント",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "追加情報",
"jsonConfig": "JSON 全設定",
"jsonConfigOrPrefix": "JSON 全設定、または",
"tomlConfigOrPrefix": "TOML 全設定、または",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 全設定",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "設定ウィザード",
"syncOtherSide": "{{target}} にも反映",
"syncOtherSideHint": "{{target}} に同じ設定を書き込みます。既存の同一 ID は上書きされます。",
"willOverwriteWarning": "{{target}} の既存設定を上書きします"
},
"wizard": {
"title": "MCP 設定ウィザード",
"hint": "MCP サーバーを素早く設定し JSON を自動生成します",
"type": "タイプ",
"typeStdio": "stdio",
"typeHttp": "http",
"typeSse": "sse",
"command": "コマンド",
"commandPlaceholder": "npx または uvx",
"args": "引数",
"argsPlaceholder": "arg1\narg2",
"env": "環境変数",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "URL を入力してください",
"headers": "ヘッダー(任意)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "設定プレビュー",
"apply": "設定を反映"
},
"id": "識別子(ユニーク)",
"type": "タイプ",
"command": "コマンド",
"validateCommand": "コマンドを検証",
"args": "引数",
"argsPlaceholder": "例: mcp-server-fetch --help",
"env": "環境変数(1 行に 1 件、KEY=VALUE",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "リセット",
"notice": {
"restartClaude": "書き込みました。Claude を再起動すると反映されます。"
},
"msg": {
"saved": "保存しました",
"deleted": "削除しました",
"enabled": "有効化しました",
"disabled": "無効化しました",
"templateAdded": "テンプレートを追加しました"
},
"error": {
"idRequired": "識別子を入力してください",
"idExists": "この識別子は既に存在します。別のものを選んでください。",
"jsonInvalid": "JSON 形式が無効です",
"tomlInvalid": "TOML 形式が無効です",
"commandRequired": "コマンドを入力してください",
"singleServerObjectRequired": "単一の MCP サーバーオブジェクトを貼り付けてください(トップレベルの mcpServers は不要)",
"saveFailed": "保存に失敗しました",
"deleteFailed": "削除に失敗しました"
},
"validation": {
"ok": "コマンドが見つかりました",
"fail": "コマンドが見つかりません"
},
"confirm": {
"deleteTitle": "MCP サーバーを削除",
"deleteMessage": "MCP サーバー「{{id}}」を削除してもよろしいですか?この操作は元に戻せません。"
},
"presets": {
"title": "MCP タイプを選択",
"enable": "有効化",
"enabled": "有効",
"installed": "インストール済み",
"docs": "ドキュメント",
"requiresEnv": "環境変数が必要",
"fetch": {
"name": "mcp-server-fetch",
"description": "汎用 HTTP リクエストツール。GET/POST などに対応し、API テストや Web データ取得に最適です"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "現在時刻、タイムゾーン変換、日付計算を提供する時間クエリツール"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "エンティティ・関係・観測を扱うナレッジグラフ型メモリ。会話の重要情報を AI に記憶させます"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "複雑な問題をステップに分解して深く考えるためのシーケンシャル思考ツール"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "最新のライブラリドキュメントとコード例を提供する Context7 ドキュメント検索ツール。キー設定で上限が拡張されます"
}
}
},
"prompts": {
"manage": "プロンプト",
"title": "{{appName}} プロンプト管理",
"claudeTitle": "Claude プロンプト管理",
"codexTitle": "Codex プロンプト管理",
"add": "プロンプトを追加",
"edit": "プロンプトを編集",
"addTitle": "{{appName}} プロンプトを追加",
"editTitle": "{{appName}} プロンプトを編集",
"import": "既存をインポート",
"count": "{{count}} 件のプロンプト",
"enabled": "有効",
"enable": "有効化",
"enabledName": "有効: {{name}}",
"noneEnabled": "有効なプロンプトがありません",
"currentFile": "現在の {{filename}} の内容",
"empty": "まだプロンプトがありません",
"emptyDescription": "上のボタンからプロンプトを追加またはインポートしてください",
"loading": "読み込み中...",
"name": "名前",
"namePlaceholder": "例: デフォルトプロジェクトプロンプト",
"description": "説明",
"descriptionPlaceholder": "任意の説明",
"content": "内容",
"contentPlaceholder": "# {{filename}}\n\nここにプロンプト内容を入力...",
"loadFailed": "プロンプトの読み込みに失敗しました",
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"deleteSuccess": "削除しました",
"deleteFailed": "削除に失敗しました",
"enableSuccess": "有効化しました",
"enableFailed": "有効化に失敗しました",
"disableSuccess": "無効化しました",
"disableFailed": "無効化に失敗しました",
"importSuccess": "インポートしました",
"importFailed": "インポートに失敗しました",
"confirm": {
"deleteTitle": "削除の確認",
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
}
},
"env": {
"warning": {
"title": "競合する環境変数を検出しました",
"description": "設定を上書きする可能性のある環境変数を {{count}} 件見つけました"
},
"actions": {
"expand": "詳細を表示",
"collapse": "折りたたむ",
"selectAll": "すべて選択",
"clearSelection": "選択を解除",
"deleteSelected": "選択 {{count}} 件を削除",
"deleting": "削除中..."
},
"field": {
"value": "値",
"source": "ソース"
},
"source": {
"userRegistry": "ユーザー環境変数(レジストリ)",
"systemRegistry": "システム環境変数(レジストリ)",
"systemEnv": "システム環境変数"
},
"delete": {
"success": "環境変数を削除しました",
"error": "環境変数の削除に失敗しました"
},
"backup": {
"location": "バックアップ場所: {{path}}"
},
"confirm": {
"title": "環境変数を削除しますか?",
"message": "{{count}} 件の環境変数を削除してもよろしいですか?",
"backupNotice": "削除前に自動バックアップを作成します。後で復元できます。再起動またはターミナル再起動後に反映されます。",
"confirm": "削除を確認"
},
"error": {
"noSelection": "削除する環境変数を選択してください"
}
},
"skills": {
"manage": "Skills",
"title": "Claude スキル管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
"refresh": "更新",
"refreshing": "更新中...",
"repoManager": "リポジトリ管理",
"count": "{{count}} 個のスキル",
"empty": "スキルがありません",
"emptyDescription": "スキルリポジトリを追加して探索してください",
"addRepo": "スキルリポジトリを追加",
"loading": "読み込み中...",
"installed": "インストール済み",
"install": "インストール",
"installing": "インストール中...",
"uninstall": "アンインストール",
"uninstalling": "アンインストール中...",
"view": "表示",
"noDescription": "説明なし",
"loadFailed": "読み込みに失敗しました",
"installSuccess": "スキル {{name}} をインストールしました",
"installFailed": "インストールに失敗しました",
"uninstallSuccess": "スキル {{name}} をアンインストールしました",
"uninstallFailed": "アンインストールに失敗しました",
"error": {
"skillNotFound": "スキルが見つかりません: {{directory}}",
"missingRepoInfo": "リポジトリ情報(owner または name)が不足しています",
"downloadTimeout": "リポジトリ {{owner}}/{{name}} のダウンロードがタイムアウトしました({{timeout}} 秒)",
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
"emptyArchive": "ダウンロードしたアーカイブが空です",
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
"httpError": "HTTP エラー {{status}}",
"http403": "GitHub へのアクセスが制限されています(レート制限の可能性)",
"http404": "リポジトリまたはブランチが見つかりません。URL を確認してください",
"http429": "リクエストが多すぎます。時間をおいて再試行してください",
"parseMetadataFailed": "スキルメタデータの解析に失敗しました",
"getHomeDirFailed": "ユーザーのホームディレクトリを取得できません",
"networkError": "ネットワークエラー",
"fsError": "ファイルシステムエラー",
"unknownError": "不明なエラー",
"suggestion": {
"checkNetwork": "ネットワーク接続を確認してください",
"checkProxy": "HTTP プロキシの設定を検討してください",
"retryLater": "時間をおいて再試行してください",
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
"checkDiskSpace": "ディスク容量を確認してください",
"checkPermission": "ディレクトリの権限を確認してください"
}
},
"repo": {
"title": "スキルリポジトリを管理",
"description": "GitHub のスキルリポジトリソースを追加または削除します",
"url": "リポジトリ URL",
"urlPlaceholder": "owner/name または https://github.com/owner/name",
"branch": "ブランチ",
"branchPlaceholder": "main",
"path": "スキルパス",
"pathPlaceholder": "skills(任意。空欄はルート)",
"add": "リポジトリを追加",
"list": "追加済みリポジトリ",
"empty": "リポジトリがありません",
"invalidUrl": "リポジトリ URL の形式が無効です",
"addSuccess": "リポジトリ {{owner}}/{{name}} を追加しました。検出スキル: {{count}} 件",
"addFailed": "追加に失敗しました",
"removeSuccess": "リポジトリ {{owner}}/{{name}} を削除しました",
"removeFailed": "削除に失敗しました",
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
"searchPlaceholder": "スキル名または説明で検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
"installed": "インストール済み",
"uninstalled": "未インストール"
},
"noResults": "一致するスキルが見つかりませんでした"
},
"deeplink": {
"confirmImport": "プロバイダーのインポートを確認",
"confirmImportDescription": "次の設定をディープリンクから CC Switch へインポートします",
"importPrompt": "プロンプトをインポート",
"importPromptDescription": "このシステムプロンプトをインポートするか確認してください",
"importMcp": "MCP サーバーをインポート",
"importMcpDescription": "これらの MCP サーバーをインポートするか確認してください",
"importSkill": "スキルリポジトリを追加",
"importSkillDescription": "このスキルリポジトリを追加するか確認してください",
"promptImportSuccess": "プロンプトをインポートしました",
"promptImportSuccessDescription": "インポートされたプロンプト: {{name}}",
"mcpImportSuccess": "MCP サーバーをインポートしました",
"mcpImportSuccessDescription": "{{count}} 件のサーバーをインポートしました",
"mcpPartialSuccess": "一部のみインポート成功",
"mcpPartialSuccessDescription": "成功: {{success}}、失敗: {{failed}}",
"skillImportSuccess": "スキルリポジトリを追加しました",
"skillImportSuccessDescription": "追加したリポジトリ: {{repo}}",
"app": "アプリ種別",
"providerName": "プロバイダー名",
"homepage": "ホームページ",
"endpoint": "API エンドポイント",
"apiKey": "API Key",
"icon": "アイコン",
"model": "モデル",
"haikuModel": "Haiku モデル",
"sonnetModel": "Sonnet モデル",
"opusModel": "Opus モデル",
"multiModel": "マルチモーダルモデル",
"notes": "メモ",
"import": "インポート",
"importing": "インポート中...",
"warning": "インポート前に内容を確認してください。後から一覧で編集・削除できます。",
"parseError": "ディープリンクの解析に失敗しました",
"importSuccess": "インポート成功",
"importSuccessDescription": "プロバイダー「{{name}}」をインポートしました",
"importError": "インポートに失敗しました",
"configSource": "設定ソース",
"configEmbedded": "埋め込み設定",
"configRemote": "リモート設定",
"configDetails": "設定の詳細",
"configUrl": "設定ファイル URL",
"configMergeError": "設定ファイルのマージに失敗しました",
"mcp": {
"title": "MCP サーバーを一括インポート",
"targetApps": "ターゲットアプリ",
"serverCount": "MCP サーバー({{count}} 件)",
"enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます"
},
"prompt": {
"title": "システムプロンプトをインポート",
"app": "アプリ",
"name": "名前",
"description": "説明",
"contentPreview": "内容プレビュー",
"enabledWarning": "インポート後すぐにこのプロンプトが有効になり、他は無効になります"
},
"skill": {
"title": "Claude スキルリポジトリを追加",
"repo": "GitHub リポジトリ",
"directory": "対象ディレクトリ",
"branch": "ブランチ",
"skillsPath": "スキルパス",
"hint": "この操作でスキルリポジトリが一覧に追加されます。",
"hintDetail": "追加後、スキル管理ページから個別のスキルをインストールできます。"
}
},
"iconPicker": {
"search": "アイコンを検索",
"searchPlaceholder": "アイコン名を入力...",
"noResults": "一致するアイコンが見つかりません",
"category": {
"aiProvider": "AI プロバイダー",
"cloud": "クラウドプラットフォーム",
"tool": "開発ツール",
"other": "その他"
}
},
"providerIcon": {
"label": "アイコン",
"colorLabel": "アイコンカラー",
"selectIcon": "アイコンを選択",
"preview": "プレビュー"
}
}
+4 -46
View File
@@ -165,7 +165,6 @@
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "窗口行为",
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
@@ -262,8 +261,7 @@
"getApiKey": "获取 API Key",
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -277,8 +275,6 @@
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"fillTemplateValue": "请填写 {{label}}",
"endpointRequired": "非官方供应商请填写 API 端点",
"apiKeyRequired": "非官方供应商请填写 API Key",
"configJsonError": "配置JSON格式错误,请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误,请检查JSON语法",
@@ -377,8 +373,7 @@
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
"credentialsConfig": "凭证配置",
"baseUrl": "请求地址",
"accessToken": "访问令牌(在个人安全设置里获取)",
"accessToken": "访问令牌",
"accessTokenPlaceholder": "在'安全设置'里生成",
"userId": "用户 ID",
"userIdPlaceholder": "例如:114514",
@@ -391,7 +386,7 @@
"timeoutHint": "范围: 2-30 秒",
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
"timeoutCannotBeNegative": "超时时间不能为负数",
"autoIntervalMinutes": "自动查询间隔(分钟0 表示不自动查询",
"autoIntervalMinutes": "自动查询间隔(分钟)",
"autoQueryInterval": "自动查询间隔(分钟)",
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
@@ -754,20 +749,6 @@
"deeplink": {
"confirmImport": "确认导入供应商配置",
"confirmImportDescription": "以下配置将导入到 CC Switch",
"importPrompt": "导入提示词",
"importPromptDescription": "请确认是否导入此系统提示词",
"importMcp": "导入 MCP Servers",
"importMcpDescription": "请确认是否导入这些 MCP Servers",
"importSkill": "添加 Skill 仓库",
"importSkillDescription": "请确认是否添加此 Skill 仓库",
"promptImportSuccess": "提示词导入成功",
"promptImportSuccessDescription": "已导入提示词: {{name}}",
"mcpImportSuccess": "MCP Servers 导入成功",
"mcpImportSuccessDescription": "成功导入 {{count}} 个服务器",
"mcpPartialSuccess": "部分导入成功",
"mcpPartialSuccessDescription": "成功: {{success}}, 失败: {{failed}}",
"skillImportSuccess": "Skill 仓库添加成功",
"skillImportSuccessDescription": "已添加仓库: {{repo}}",
"app": "应用类型",
"providerName": "供应商名称",
"homepage": "官网地址",
@@ -792,30 +773,7 @@
"configRemote": "远程配置",
"configDetails": "配置详情",
"configUrl": "配置文件 URL",
"configMergeError": "合并配置文件失败",
"mcp": {
"title": "批量导入 MCP Servers",
"targetApps": "目标应用",
"serverCount": "MCP Servers ({{count}} 个)",
"enabledWarning": "导入后将立即写入所有指定应用的配置文件"
},
"prompt": {
"title": "导入系统提示词",
"app": "应用",
"name": "名称",
"description": "描述",
"contentPreview": "内容预览",
"enabledWarning": "导入后将立即启用此提示词,其他提示词将被禁用"
},
"skill": {
"title": "添加 Claude Skill 仓库",
"repo": "GitHub 仓库",
"directory": "目标目录",
"branch": "分支",
"skillsPath": "Skills 路径",
"hint": "此操作将添加 Skill 仓库到列表。",
"hintDetail": "添加后,您可以在 Skills 管理界面中选择安装具体的 Skill。"
}
"configMergeError": "合并配置文件失败"
},
"iconPicker": {
"search": "搜索图标",
+5 -6
View File
@@ -1,7 +1,8 @@
/* Tailwind CSS v3 指令 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为"类选择器"模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* shadcn/ui 主题变量 - 蓝色主题 */
@layer base {
@@ -138,8 +139,6 @@ html {
line-height: 1.5;
/* 让原生控件与滚动条随主题切换配色 */
color-scheme: light;
/* 禁用 overscroll 回弹效果,防止下拉时顶部边框被拉下来 */
overscroll-behavior: none;
}
body {
+1
View File
@@ -33,6 +33,7 @@ export interface DeepLinkImportRequest {
repo?: string;
directory?: string;
branch?: string;
skillsPath?: string;
// Config file fields
config?: string;
+2
View File
@@ -10,6 +10,7 @@ export interface Skill {
repoOwner?: string;
repoName?: string;
repoBranch?: string;
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
}
export interface SkillRepo {
@@ -17,6 +18,7 @@ export interface SkillRepo {
name: string;
branch: string;
enabled: boolean;
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
}
export const skillsApi = {
+1 -9
View File
@@ -95,13 +95,6 @@ export const useUsageQuery = (
) => {
const { enabled = true, autoQueryInterval = 0 } = options || {};
// 计算 staleTime:如果有自动刷新间隔,使用该间隔;否则默认 5 分钟
// 这样可以避免切换 app 页面时重复触发查询
const staleTime =
autoQueryInterval > 0
? autoQueryInterval * 60 * 1000 // 与刷新间隔保持一致
: 5 * 60 * 1000; // 默认 5 分钟
const query = useQuery<UsageResult>({
queryKey: ["usage", providerId, appId],
queryFn: async () => usageApi.query(providerId, appId),
@@ -113,8 +106,7 @@ export const useUsageQuery = (
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
retry: false,
staleTime, // 使用动态计算的缓存时间
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
staleTime: 0, // 使用缓存策略,确保 refetchInterval 准确执行
});
return {
+1 -1
View File
@@ -36,7 +36,7 @@ function parseJsonError(error: unknown): string {
}
export const providerSchema = z.object({
name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示
name: z.string().min(1, "请填写供应商名称"),
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
notes: z.string().optional(),
settingsConfig: z
+3 -11
View File
@@ -8,22 +8,14 @@ const directorySchema = z
.or(z.literal(""));
export const settingsSchema = z.object({
// 设备级 UI 设置
showInTray: z.boolean(),
minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
language: z.enum(["en", "zh", "ja"]).optional(),
// 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(),
geminiConfigDir: directorySchema.nullable().optional(),
// 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
language: z.enum(["en", "zh"]).optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+14 -16
View File
@@ -97,35 +97,33 @@ export interface ProviderMeta {
}
// 应用设置类型(用于设置对话框与 Tauri API)
// 存储在本地 ~/.cc-switch/settings.json,不随数据库同步
export interface Settings {
// ===== 设备级 UI 设置 =====
// 是否在系统托盘(macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
// ===== 设备级目录覆盖 =====
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
currentProviderClaude?: string;
// 当前 Codex 供应商 ID(优先于数据库 is_current
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// 是否开机自启
launchOnStartup?: boolean;
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
// 安全设置(兼容未来扩展)
security?: {
auth?: {
selectedType?: string;
};
};
}
// MCP 服务器连接参数(宽松:允许扩展字段)
-1
View File
@@ -4,7 +4,6 @@ export default {
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "selector",
theme: {
extend: {
colors: {
-23
View File
@@ -58,29 +58,6 @@ describe("useSettingsForm Hook", () => {
expect(changeLanguageSpy).toHaveBeenCalledWith("en");
});
it("should support japanese language preference from server data", async () => {
useSettingsQueryMock.mockReturnValue({
data: {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: "/Users/demo",
codexConfigDir: null,
language: "ja",
},
isLoading: false,
});
const { result } = renderHook(() => useSettingsForm());
await waitFor(() => {
expect(result.current.settings?.language).toBe("ja");
});
expect(result.current.initialLanguage).toBe("ja");
expect(changeLanguageSpy).toHaveBeenCalledWith("ja");
});
it("should prioritize reading language from local storage in readPersistedLanguage", () => {
useSettingsQueryMock.mockReturnValue({
data: null,
+2 -1
View File
@@ -1,10 +1,11 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "src",
plugins: [react()],
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "../dist",