Compare commits

..

23 Commits

Author SHA1 Message Date
YoVinchen 38bac36960 feat(skill): implement recursive scanning for skill repositories
Add recursive directory scanning to discover SKILL.md files in nested
directories. When a SKILL.md is found, treat sibling directories as
functional folders rather than separate skills.
2025-11-28 11:43:31 +08:00
farion1231 2a6980417b fix(auto-launch): use platform-specific API for AutoLaunch::new
Windows version of auto-launch crate takes 3 arguments while
Linux/macOS takes 4 (includes hidden parameter). Use conditional
compilation to handle the difference.
2025-11-28 09:58:47 +08:00
Jason 6a9a0a7a7e feat(preset): add icon configuration to provider presets
- Add icon/iconColor fields to CodexProviderPreset and GeminiProviderPreset interfaces
- Configure icons for Claude presets (DeepSeek, Zhipu, Qwen, Kimi, MiniMax, DouBao, etc.)
- Configure icons for Codex presets (OpenAI, Azure, PackyCode)
- Configure icons for Gemini presets (Google, PackyCode)
- Fix handlePresetChange to pass icon fields when resetting form

This ensures preset icons are displayed in the icon picker when selecting a provider preset.
2025-11-27 23:28:11 +08:00
Jason fcb090dd15 fix(css): downgrade Tailwind CSS from v4 to v3.4 for better browser compatibility
Tailwind CSS v4 requires modern CSS features (@layer, @property, color-mix)
that are not supported in older WebView2 versions, causing styles to fail
on some Windows 11 systems.

Changes:
- Replace @tailwindcss/vite with postcss + autoprefixer
- Downgrade tailwindcss from 4.1.13 to 3.4.17
- Convert CSS imports from v4 syntax to v3 @tailwind directives
- Add darkMode: "selector" to tailwind.config.js
- Create postcss.config.js for PostCSS pipeline

This improves compatibility with older browsers and WebView2 runtimes
while maintaining the same visual appearance.
2025-11-27 22:55:14 +08:00
Jason ce4f0c02cb fix(linux): resolve WebKitGTK DMA-BUF rendering issue and preserve user .desktop customizations
- Set WEBKIT_DISABLE_DMABUF_RENDERER=1 at startup to fix blank screen on Debian 13.2 and Nvidia GPUs
- Only register deep-link handler on first run to avoid overwriting user customizations
- Use correct Tauri path API (app.path().data_dir()) instead of dirs::data_dir() to match plugin's actual .desktop file location
2025-11-27 19:37:18 +08:00
Jason 4ca4ad6bca feat(migration): add error dialog for config.json loading failure
- Show system dialog when config.json fails to load during migration
- User can retry loading or exit the program
- Exit before database creation ensures clean retry on next launch
- Support Chinese/English localization based on system locale
2025-11-27 16:03:50 +08:00
Jason 964ead5d0d fix(provider): validate current provider ID existence before use
Add get_effective_current_provider() to validate local settings ID
against database, with automatic cleanup and fallback to DB is_current.

This fixes edge cases in multi-device cloud sync scenarios where local
settings may contain stale provider IDs:

- current(): now returns validated effective provider ID
- update(): correctly syncs live config when local ID differs from DB
- delete(): checks both local settings and DB to prevent deletion
- switch(): backfill logic now targets valid provider
- sync_current_to_live(): uses validated ID with auto-fallback
- tray menu: displays correct checkmark on startup

Also fixes test issues:
- Add missing test setup calls (mutex, reset_test_fs, ensure_test_home)
- Correct Gemini security settings path to ~/.gemini/settings.json
2025-11-27 15:01:24 +08:00
Jason 2c90ae3509 refactor(provider): use local settings for current provider selection
Complete the device-level settings separation for cloud sync support.

Backend changes:
- Modify switch() to update both local settings and database is_current
- Modify current() to read from local settings first, fallback to database
- Rename sync_current_from_db() to sync_current_to_live()
- Update tray menu to read current provider from local settings

Frontend changes:
- Update Settings interface: remove legacy fields (customEndpoints*, security)
- Add currentProviderClaude/Codex/Gemini fields
- Update settings schema accordingly

Test fixes:
- Update Gemini security tests to check ~/.gemini/settings.json
  instead of ~/.cc-switch/settings.json (security field was never
  stored in CC Switch settings)

This ensures each device maintains its own current provider selection
independently when database is synced across devices.
2025-11-27 11:42:19 +08:00
Jason ea7169abc0 refactor(settings): move device-level settings to local file storage
Separate device-level settings from database to support cloud sync scenarios
where multiple devices share the same database but need independent settings.

Changes:
- Remove database storage logic (bind_db, save_to_db, load_from_db)
- Remove legacy custom_endpoints_claude/codex fields (actual data in provider.meta)
- Add current_provider_claude/codex/gemini fields for device-level provider selection
- Add get_current_provider() and set_current_provider() helper functions
- Settings now stored only in ~/.cc-switch/settings.json

This ensures that when database is synced across devices, each device
maintains its own current provider selection independently.
2025-11-27 11:16:43 +08:00
Jason 120ac92e77 refactor(gemini): improve config handling and code consistency
- Fix envObjToString to preserve custom environment variables
- Add bidirectional MCP format conversion (httpUrl <-> url)
- Merge provider config with existing settings.json instead of overwriting
- Remove redundant is_packycode_gemini function
- Simplify is_google_official_gemini using detect_gemini_auth_type
- Add handleGeminiModelChange for consistent field handling
2025-11-27 10:21:01 +08:00
Jason a1e7961af3 fix(gemini): correctly write custom provider env to .env file
write_live_snapshot was incorrectly passing the already-extracted env
sub-field to json_to_env, which expects the full settings_config object.
This caused json_to_env to look for env.env (nested), returning an empty
HashMap and writing an empty .env file.

Fix by delegating to write_gemini_live which correctly handles env file
writing and security flag configuration in one place.
2025-11-27 09:48:37 +08:00
Jason dc79e3a3da fix(gemini): write security auth config only to Gemini settings file
- Remove redundant security.auth.selectedType from CC Switch settings
- Fix Generic provider type not writing security flag on switch
- All non-Google Official providers now correctly write "gemini-api-key"
- Delete unused ensure_packycode_security_flag function
- Clean up SecuritySettings and SecurityAuthSettings types from AppSettings
2025-11-27 09:31:54 +08:00
Jason 7adc38eb53 feat(preset): add MiniMax as official partner with Black Friday promotion
- Mark MiniMax as partner with custom theme color (#f64551)
- Add Black Friday promotional text for Starter plan ($2/mo, 80% OFF)
- Update apiKeyUrl to MiniMax coding plan subscription page
2025-11-27 08:51:54 +08:00
Jason 3f28648931 fix(query): prevent redundant usage queries when switching apps
Add staleTime and gcTime to useUsageQuery to avoid triggering
unnecessary API calls when switching between app tabs. The staleTime
is set dynamically based on the auto-refresh interval (or 5 minutes
by default), and gcTime is set to 10 minutes to preserve cache after
component unmount.
2025-11-26 19:34:00 +08:00
Jason 90d530fa7a fix(ui): improve ConfirmDialog styling for better visual consistency
- Remove header/footer borders and backgrounds for compact alert style
- Use theme-aware overlay color (bg-background/80) instead of black
- Add zIndex="alert" to ensure dialog appears above other dialogs
2025-11-26 15:36:53 +08:00
Jason 5cd2e9fb88 refactor(provider): unify validation errors to use toast notifications
Change template parameter validation from form.setError to toast.error
for consistent UX across all required field validations.
2025-11-26 12:35:33 +08:00
Jason 8bf6ce2c25 feat(provider): add required field validation with toast notifications
- Add validation for provider name (required for all providers)
- Add validation for API endpoint and API Key (required for non-official providers)
- Use toast notifications instead of inline form errors for better visibility
- Move name validation from zod schema to handleSubmit for consistent UX
- Add i18n keys: endpointRequired, apiKeyRequired
2025-11-26 12:26:02 +08:00
Jason c41f3dcccb fix(preset): use ANTHROPIC_AUTH_TOKEN for DMXAPI and correct endpoint candidates
- Change DMXAPI preset from ANTHROPIC_API_KEY to ANTHROPIC_AUTH_TOKEN
- Fix endpointCandidates that were incorrectly copied from AiHubMix
2025-11-26 11:57:47 +08:00
Jason 1caa240d6c refactor(config): remove dead code from JSON-era import/export
Remove obsolete methods that were superseded by SQLite-based
import/export in v3.7.0:

- export_config_to_path: replaced by Database::export_sql
- load_config_for_import: no longer used
- import_config_from_path: stub that only returned error

Also remove unused AppState import.
2025-11-26 11:18:23 +08:00
Jason 7ffd3ba165 fix(provider): preserve custom endpoints when updating provider
- Replace INSERT OR REPLACE with UPDATE for existing providers to avoid
  triggering ON DELETE CASCADE on provider_endpoints table
- Fix endpoint merging logic to correctly mark database endpoints as
  isCustom when they overlap with preset endpoints

The root cause was that INSERT OR REPLACE performs DELETE + INSERT under
the hood, which triggered the foreign key cascade and deleted all
associated endpoints from provider_endpoints table.
2025-11-26 10:27:07 +08:00
YoVinchen ad131486d2 fix(skill): use full key for deduplication to allow same-name skills from different repos (#299) 2025-11-26 09:39:05 +08:00
Jason 15c6e3aec8 refactor(ui): improve header toolbar layout and transitions
- Reorder toolbar buttons to keep Prompts and MCP icons aligned right
  (consistent position between Claude and Codex apps)
- Add smooth fade-in/out transition for Skills button with opacity,
  width, scale, and padding animations
- Hide Agents button temporarily (feature in development)
- Remove two divider lines for cleaner appearance
2025-11-25 23:36:48 +08:00
YoVinchen 6783a8a183 fix(provider): include icon fields when duplicating provider (#297) 2025-11-25 23:18:54 +08:00
41 changed files with 1635 additions and 1045 deletions
+4 -3
View File
@@ -32,7 +32,10 @@
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5"
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -56,7 +59,6 @@
"@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",
@@ -76,7 +78,6 @@
"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"
+580 -257
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+7
View File
@@ -7,7 +7,14 @@ 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 -2
View File
@@ -44,7 +44,7 @@ pub async fn import_config_from_file(
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_from_db(&app_state) {
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
@@ -69,7 +69,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_from_db(&app_state)?;
ProviderService::sync_current_to_live(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
+73 -37
View File
@@ -122,62 +122,98 @@ impl Database {
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 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
// 处理 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 is_current: bool = tx
// 检查是否存在(用于判断新增/更新,以及保留 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),
)
.unwrap_or(false);
.ok();
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![
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()))?;
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
// 同步 endpoints:删除全部后重新插入
tx.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
params![provider.id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
for (url, endpoint) in endpoints {
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
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],
"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()))?;
+11 -9
View File
@@ -153,12 +153,14 @@ impl Database {
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(
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)",
@@ -174,9 +176,9 @@ impl Database {
],
)
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
}
Ok(())
};
}
Ok(())
};
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
+10 -11
View File
@@ -226,13 +226,13 @@ impl Database {
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",
"branch",
"TEXT NOT NULL DEFAULT 'main'",
)?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
Ok(())
@@ -247,9 +247,7 @@ impl Database {
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
if version < 0 {
return Err(AppError::Database(
"user_version 不能为负数".to_string(),
));
return Err(AppError::Database("user_version 不能为负数".to_string()));
}
let sql = format!("PRAGMA user_version = {version};");
conn.execute(&sql, [])
@@ -261,10 +259,7 @@ impl Database {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(AppError::Database(format!(
"非法{kind}: {s},仅允许字母、数字和下划线"
)));
@@ -292,7 +287,11 @@ impl Database {
Ok(false)
}
pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, AppError> {
pub(crate) fn has_column(
conn: &Connection,
table: &str,
column: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
+4 -10
View File
@@ -108,8 +108,8 @@ fn migration_rejects_future_version() {
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");
let err =
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
@@ -168,10 +168,7 @@ fn migration_aligns_column_defaults_and_types() {
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")
);
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");
@@ -181,10 +178,7 @@ fn migration_aligns_column_defaults_and_types() {
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")
);
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");
+1 -2
View File
@@ -307,8 +307,7 @@ fn test_import_prompt_allows_space_in_base64_content() {
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 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");
+19 -1
View File
@@ -49,6 +49,11 @@ 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() {
@@ -56,12 +61,25 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
}
let root = read_json_value(&path)?;
let servers = root
let mut servers: std::collections::HashMap<String, Value> = 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)
}
+147 -29
View File
@@ -43,6 +43,7 @@ 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::{
@@ -220,10 +221,11 @@ 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)?;
let current_id = app_state
.db
.get_current_provider(app_type_str)?
.unwrap_or_default();
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
@@ -546,41 +548,68 @@ pub fn run() {
let has_json = json_path.exists();
let has_db = db_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));
}
};
// 静默迁移:检测到旧版 config.json 且数据库不存在时自动迁移
if !has_db && has_json {
log::info!("检测到旧版配置文件,开始自动迁移到数据库...");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {
log::error!("配置迁移失败: {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}");
} else {
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}");
} else {
log::info!("✓ 旧配置已归档为 config.json.migrated");
}
log::info!("配置已归档为 config.json.migrated");
}
}
Err(e) => log::error!("加载旧配置文件失败: {e},将从现有配置导入"),
Err(e) => {
// 配置加载成功但迁移失败的情况极少(磁盘满等),仅记录日志
log::error!("配置迁移失败: {e},将从现有配置导入");
}
}
}
crate::settings::bind_db(db.clone());
let app_state = AppState::new(db);
// 检查是否需要首次导入(数据库为空)
@@ -705,10 +734,35 @@ pub fn run() {
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", 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 (Linux/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)");
}
}
}
@@ -952,3 +1006,67 @@ 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,5 +2,15 @@
#![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,7 +2,6 @@ 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;
@@ -84,53 +83,6 @@ 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)?;
+4 -149
View File
@@ -4,7 +4,6 @@
use crate::error::AppError;
use crate::provider::Provider;
use crate::settings;
/// Gemini authentication type enumeration
///
@@ -19,10 +18,6 @@ pub(crate) enum GeminiAuthType {
Generic,
}
// Authentication type constants
const PACKYCODE_SECURITY_SELECTED_TYPE: &str = "gemini-api-key";
const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &str = "oauth-personal";
// Partner Promotion Key constants
const PACKYCODE_PARTNER_KEY: &str = "packycode";
const GOOGLE_OFFICIAL_PARTNER_KEY: &str = "google-official";
@@ -94,159 +89,22 @@ fn contains_packycode_keyword(value: &str) -> bool {
.any(|keyword| lower.contains(keyword))
}
/// Detect if provider is PackyCode Gemini (uses API Key authentication)
///
/// PackyCode is an official partner requiring special security configuration.
///
/// # Detection Rules (priority from high to low)
///
/// 1. **Partner Promotion Key** (most reliable):
/// - `provider.meta.partner_promotion_key == "packycode"`
///
/// 2. **Provider name**:
/// - Name contains "packycode", "packyapi" or "packy" (case-insensitive)
///
/// 3. **Website URL**:
/// - `provider.website_url` contains keywords
///
/// 4. **Base URL**:
/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` contains keywords
///
/// # Why multiple detection methods
///
/// - Users may manually create providers without `partner_promotion_key`
/// - Meta fields may be modified after copying from presets
/// - Ensure all PackyCode providers get correct security flags
#[allow(dead_code)]
pub(crate) fn is_packycode_gemini(provider: &Provider) -> bool {
// Strategy 1: Check partner_promotion_key (most reliable)
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY))
{
return true;
}
// Strategy 2: Check provider name
if contains_packycode_keyword(&provider.name) {
return true;
}
// Strategy 3: Check website URL
if let Some(site) = provider.website_url.as_deref() {
if contains_packycode_keyword(site) {
return true;
}
}
// Strategy 4: Check Base URL
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 true;
}
}
false
}
/// Detect if provider is Google Official Gemini (uses OAuth authentication)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// # Detection Rules (priority from high to low)
///
/// 1. **Partner Promotion Key** (most reliable):
/// - `provider.meta.partner_promotion_key == "google-official"`
///
/// 2. **Provider name**:
/// - Name equals "google" (case-insensitive)
/// - Or name starts with "google " (e.g., "Google Official")
///
/// # OAuth vs API Key
///
/// - **OAuth mode**: `security.auth.selectedType = "oauth-personal"`
/// - User needs to login via browser with Google account
/// - No API Key needed in `.env` file
///
/// - **API Key mode**: `security.auth.selectedType = "gemini-api-key"`
/// - Used for third-party relay services (like PackyCode)
/// - Requires `GEMINI_API_KEY` in `.env` file
#[allow(dead_code)]
/// This is a convenience wrapper around `detect_gemini_auth_type`.
pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {
// Strategy 1: Check partner_promotion_key (most reliable)
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY))
{
return true;
}
// Strategy 2: Check name matching (fallback)
let name_lower = provider.name.to_ascii_lowercase();
name_lower == "google" || name_lower.starts_with("google ")
}
/// Ensure PackyCode Gemini provider security flag is correctly set
///
/// PackyCode is an official partner using API Key authentication mode.
///
/// # Why write to two settings.json files
///
/// 1. **`~/.cc-switch/settings.json`** (application-level config):
/// - CC-Switch application global settings
/// - Ensures app knows current authentication type
/// - Used for UI display and other app logic
///
/// 2. **`~/.gemini/settings.json`** (Gemini client config):
/// - Configuration file read by Gemini CLI client
/// - Directly affects Gemini client authentication behavior
/// - Ensures Gemini uses correct authentication method to connect API
///
/// # Value set
///
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "gemini-api-key"
/// }
/// }
/// }
/// ```
///
/// # Error handling
///
/// If provider is not PackyCode, function returns `Ok(())` immediately without any operation.
pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> {
if !is_packycode_gemini(provider) {
return Ok(());
}
// Write to application-level settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(PACKYCODE_SECURITY_SELECTED_TYPE)?;
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_packycode_settings;
write_packycode_settings()?;
Ok(())
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.
///
/// # Why write to two settings.json files
/// # What it does
///
/// Same as `ensure_packycode_security_flag`, need to configure both app-level and client-level settings.
/// Writes to **`~/.gemini/settings.json`** (Gemini client config).
///
/// # Value set
///
@@ -276,9 +134,6 @@ pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(
return Ok(());
}
// Write to application-level settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_google_oauth_settings;
write_google_oauth_settings()?;
+63 -59
View File
@@ -15,8 +15,7 @@ use crate::services::mcp::McpService;
use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
};
use super::normalize_claude_models_in_value;
@@ -101,12 +100,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
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 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())
})?;
@@ -114,51 +114,36 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
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))?;
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
}
AppType::Gemini => {
use crate::gemini_config::{
get_gemini_settings_path, json_to_env, write_gemini_env_atomic,
};
// Extract env and config from provider settings
let env_value = provider.settings_config.get("env");
let config_value = provider.settings_config.get("config");
// Write env file
if let Some(env) = env_value {
let env_map = json_to_env(env)?;
write_gemini_env_atomic(&env_map)?;
}
// Write settings file
if let Some(config) = config_value {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, config)?;
}
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
}
Ok(())
}
/// Sync current provider from database to live configuration
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
/// 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] {
let current_id = match state.db.get_current_provider(app_type.as_str())? {
Some(id) => id,
None => continue,
};
// 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)?;
} else {
log::warn!(
"无法同步 live 配置: 当前供应商 {} ({}) 未找到",
current_id,
app_type.as_str()
);
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
@@ -328,28 +313,44 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
let mut env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json (preserve existing file content when absent)
let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_null() {
Some(json!({}))
} else if config_value.is_object() {
Some(config_value.clone())
} else {
// 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",
));
}
} else {
None
};
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
}
if config_to_write.is_none() {
let settings_path = get_gemini_settings_path();
if settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
// 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 {
@@ -371,14 +372,17 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
}
if let Some(config_value) = config_to_write {
let settings_path = get_gemini_settings_path();
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 => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
}
Ok(())
+33 -32
View File
@@ -22,16 +22,12 @@ 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_from_db};
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 gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
};
use live::write_gemini_live;
use usage::validate_usage_script;
@@ -98,10 +94,12 @@ impl ProviderService {
}
/// Get current provider ID
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
state
.db
.get_current_provider(app_type.as_str())
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
@@ -139,9 +137,10 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider
let current_id = state.db.get_current_provider(app_type.as_str())?;
let is_current = current_id.as_deref() == Some(provider.id.as_str());
// 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)?;
@@ -156,13 +155,19 @@ impl ProviderService {
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
let current = state.db.get_current_provider(app_type.as_str())?;
if current.as_deref() == Some(id) {
// 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)
}
@@ -171,9 +176,9 @@ impl ProviderService {
/// Switch flow:
/// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. Set new current provider
/// 4. Write target provider config to live files
/// 5. Gemini additional security flag handling
/// 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
@@ -183,7 +188,10 @@ impl ProviderService {
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider
if let Some(current_id) = state.db.get_current_provider(app_type.as_str())? {
// 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()) {
@@ -196,31 +204,24 @@ impl ProviderService {
}
}
// Set current
// 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
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
// Gemini needs additional security flag handling (PackyCode or Google OAuth)
if matches!(app_type, AppType::Gemini) {
let auth_type = detect_gemini_auth_type(provider);
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
}
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(())
}
/// Sync current provider from database to live configuration (re-export)
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
sync_current_from_db(state)
/// 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)
+1 -3
View File
@@ -168,9 +168,7 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
if interval > 1440 {
return Err(AppError::localized(
"usage_script.interval_too_large",
format!(
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
),
format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
),
+161 -84
View File
@@ -214,56 +214,8 @@ impl SkillService {
temp_dir.clone()
};
// 遍历目标目录
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),
}
}
// 递归扫描目录查找所有技能
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
@@ -271,6 +223,90 @@ 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 = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
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()),
skills_path: repo.skills_path.clone(),
})
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
@@ -302,25 +338,18 @@ impl SkillService {
return Ok(());
}
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
// 收集所有本地技能
let mut local_skills = Vec::new();
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
if !path.is_dir() {
continue;
}
// 处理找到的本地技能
for local_skill in local_skills {
let directory = &local_skill.directory;
// 安全地获取目录名
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;
@@ -329,23 +358,69 @@ impl SkillService {
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
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,
});
}
}
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,
skills_path: 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)?;
}
}
@@ -353,11 +428,13 @@ impl SkillService {
}
/// 去重技能列表
/// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
// 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
e.insert(true);
true
} else {
+100 -118
View File
@@ -1,13 +1,12 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use std::sync::{OnceLock, RwLock};
use crate::database::Database;
use crate::app_config::AppType;
use crate::error::AppError;
/// 自定义端点配置
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
@@ -17,24 +16,14 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
#[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>,
}
/// 应用设置结构,允许覆盖默认配置目录
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
/// 这确保了云同步场景下多设备可以独立运作。
#[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")]
@@ -42,25 +31,30 @@ 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 language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
pub current_provider_claude: Option<String>,
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
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>,
pub current_provider_codex: Option<String>,
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -77,14 +71,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,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
}
}
}
@@ -169,60 +163,7 @@ 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(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)
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
}
fn resolve_override_path(raw: &str) -> PathBuf {
@@ -249,47 +190,22 @@ pub fn get_settings() -> AppSettings {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
if let Some(db) = SETTINGS_DB.get() {
save_to_db(db, &new_settings)?;
} else {
save_settings_file(&new_settings)?;
}
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 = load_initial_settings();
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> {
let settings = settings_store().read().ok()?;
settings
@@ -313,3 +229,69 @@ 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())
}
+15 -27
View File
@@ -3,13 +3,15 @@ use std::fs;
use std::path::PathBuf;
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, 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::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
#[test]
fn sync_claude_provider_writes_live_settings() {
@@ -843,21 +845,22 @@ fn sync_gemini_packycode_sets_security_selected_type() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.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");
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");
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"
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
);
}
@@ -893,22 +896,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
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"
);
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -923,7 +911,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should also record oauth-personal"
"Gemini settings should record oauth-personal for Google Official"
);
}
+13 -12
View File
@@ -42,9 +42,13 @@ fn import_default_config_claude_persists_provider() {
.expect("import default config succeeds");
// 验证内存状态
let providers = state.db.get_all_providers(AppType::Claude.as_str())
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())
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");
@@ -87,7 +91,9 @@ fn import_default_config_without_live_file_returns_error() {
// 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商
let providers = state.db.get_all_providers(AppType::Claude.as_str())
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
providers.is_empty(),
@@ -125,8 +131,7 @@ 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 servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
@@ -168,8 +173,7 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
}
// 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
assert!(
servers.is_empty(),
"failed import should not persist any MCP servers to database"
@@ -224,11 +228,8 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
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 servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("codex-server").expect("codex server exists");
assert!(
entry.apps.codex,
"server should have Codex app enabled after toggle"
+34 -16
View File
@@ -7,8 +7,8 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn switch_provider_updates_codex_live_and_state() {
@@ -104,16 +104,22 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
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");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
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 = providers.get("new-provider").expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
@@ -165,7 +171,9 @@ fn switch_provider_missing_provider_returns_error() {
let err_str = err.to_string();
assert!(
err_str.contains("供应商不存在") || err_str.contains("Provider not found") || err_str.contains("missing-provider"),
err_str.contains("供应商不存在")
|| err_str.contains("Provider not found")
|| err_str.contains("missing-provider"),
"error message should mention missing provider, got: {err_str}"
);
}
@@ -241,11 +249,19 @@ 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())
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");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let legacy_provider = providers
@@ -258,9 +274,7 @@ fn switch_provider_updates_claude_live_and_state() {
"previous provider should be backfilled with live config"
);
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
let new_provider = providers.get("new-provider").expect("new provider exists");
assert_eq!(
new_provider
.settings_config
@@ -283,7 +297,9 @@ fn switch_provider_updates_claude_live_and_state() {
);
// 验证当前供应商已更新
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
@@ -328,7 +344,9 @@ 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())
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
// 但由于 live 配置写入失败,状态应该回滚
+60 -44
View File
@@ -1,13 +1,15 @@
use serde_json::json;
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, AppType, McpApps,
McpServer, 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::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -69,7 +71,10 @@ command = "say"
}
// 使用新的统一 MCP 结构(v3.7.0+
let servers = initial_config.mcp.servers.get_or_insert_with(Default::default);
let servers = initial_config
.mcp
.servers
.get_or_insert_with(Default::default);
servers.insert(
"echo-server".into(),
McpServer {
@@ -111,16 +116,22 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = state.db.get_current_provider(AppType::Codex.as_str())
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");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = state.db.get_all_providers(AppType::Codex.as_str())
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 = providers.get("new-provider").expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
@@ -185,15 +196,16 @@ fn switch_packycode_gemini_updates_security_selected_type() {
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
@@ -239,15 +251,16 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
@@ -292,23 +305,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
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"
);
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -324,7 +321,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 also reflect oauth-personal"
"Gemini settings json should reflect oauth-personal for Google Official"
);
}
@@ -399,11 +396,19 @@ 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())
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())
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");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let legacy_provider = providers
.get("old-provider")
@@ -523,7 +528,9 @@ fn provider_service_delete_codex_removes_provider_and_files() {
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())
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("to-delete"),
@@ -581,7 +588,9 @@ fn provider_service_delete_claude_removes_provider_files() {
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("delete"),
@@ -593,6 +602,10 @@ fn provider_service_delete_claude_removes_provider_files() {
#[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
@@ -618,15 +631,18 @@ fn provider_service_delete_current_provider_returns_error() {
.expect_err("deleting current provider should fail");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"),
zh.contains("不能删除当前正在使用的供应商")
|| zh.contains("无法删除当前正在使用的供应商"),
"unexpected message: {zh}"
),
AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config/Message error, got {other:?}"),
+33 -30
View File
@@ -6,7 +6,7 @@ import {
Plus,
Settings,
ArrowLeft,
Bot,
// Bot, // TODO: Agents 功能开发中,暂时不需要
Book,
Wrench,
Server,
@@ -24,6 +24,7 @@ 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";
@@ -404,7 +405,6 @@ function App() {
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
@@ -470,39 +470,24 @@ 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("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
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")}
>
<Book className="h-4 w-4" />
<Wrench className="h-4 w-4 flex-shrink-0" />
</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"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
@@ -512,7 +497,25 @@ function App() {
>
<Bot className="h-4 w-4" />
</Button>
)}
)} */}
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
>
<Server className="h-4 w-4" />
</Button>
</div>
<Button
+7 -3
View File
@@ -40,8 +40,12 @@ export function ConfirmDialog({
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader className="space-y-3">
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
@@ -50,7 +54,7 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
@@ -140,15 +140,24 @@ 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 && !map.has(sanitized)) {
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) {
// URL 已存在,更新 isCustom 为 true(因为它在数据库中)
existing.isCustom = true;
} else {
// URL 不存在,添加新条目
map.set(sanitized, {
id: randomId(),
url: sanitized,
+94 -18
View File
@@ -2,6 +2,7 @@ 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";
@@ -326,11 +327,11 @@ export function ProviderForm({
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -368,6 +369,22 @@ 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,
@@ -388,17 +405,82 @@ export function ProviderForm({
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
form.setError("settingsConfig", {
type: "manual",
message: t("providerForm.fillParameter", {
toast.error(
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
@@ -616,6 +698,8 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -633,6 +717,8 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -647,6 +733,8 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
@@ -758,19 +846,7 @@ export function ProviderForm({
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
model={geminiModel}
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);
}}
onModelChange={handleGeminiModelChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -22,18 +22,32 @@ 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[] = [];
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
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.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}`);
// 再添加其他自定义 key(保留用户添加的环境变量)
for (const [key, value] of Object.entries(envObj)) {
if (!addedKeys.has(key) && typeof value === "string") {
lines.push(`${key}=${value}`);
}
}
return lines.join("\n");
},
[],
@@ -164,6 +178,20 @@ 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) => {
@@ -223,6 +251,7 @@ export function useGeminiConfigState({
setGeminiConfig,
handleGeminiApiKeyChange,
handleGeminiBaseUrlChange,
handleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
+30 -3
View File
@@ -76,6 +76,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
},
{
name: "Zhipu GLM",
@@ -94,6 +96,8 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Z.ai GLM",
@@ -112,6 +116,8 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Qwen Coder",
@@ -128,6 +134,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
},
{
name: "Kimi k2",
@@ -143,6 +151,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "Kimi For Coding",
@@ -158,6 +168,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "ModelScope",
@@ -173,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "KAT-Coder",
@@ -220,7 +234,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
@@ -234,6 +248,14 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "DouBaoSeed",
@@ -251,6 +273,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "doubao",
iconColor: "#3370FF",
},
{
name: "BaiLing",
@@ -266,6 +290,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "AiHubMix",
@@ -290,11 +316,11 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
category: "aggregator",
},
{
@@ -315,5 +341,6 @@ export const providerPresets: ProviderPreset[] = [
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
+8
View File
@@ -20,6 +20,9 @@ export interface CodexProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
/**
@@ -71,6 +74,8 @@ export const codexProviderPresets: CodexProviderPreset[] = [
backgroundColor: "#1F2937", // gray-800
textColor: "#FFFFFF",
},
icon: "openai",
iconColor: "#00A67E",
},
{
name: "Azure OpenAI",
@@ -97,6 +102,8 @@ requires_openai_auth = true`,
backgroundColor: "#0078D4",
textColor: "#FFFFFF",
},
icon: "azure",
iconColor: "#0078D4",
},
{
name: "AiHubMix",
@@ -142,5 +149,6 @@ requires_openai_auth = true`,
],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
+6
View File
@@ -25,6 +25,9 @@ export interface GeminiProviderPreset {
partnerPromotionKey?: string;
endpointCandidates?: string[];
theme?: GeminiPresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const geminiProviderPresets: GeminiProviderPreset[] = [
@@ -43,6 +46,8 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
backgroundColor: "#4285F4",
textColor: "#FFFFFF",
},
icon: "gemini",
iconColor: "#4285F4",
},
{
name: "PackyCode",
@@ -64,6 +69,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
"https://api-slb.packyapi.com",
"https://www.packyapi.com",
],
icon: "packycode",
},
{
name: "自定义",
+4 -1
View File
@@ -261,7 +261,8 @@
"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"
"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!)"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -275,6 +276,8 @@
"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",
+4 -1
View File
@@ -261,7 +261,8 @@
"getApiKey": "获取 API Key",
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠"
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -275,6 +276,8 @@
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"fillTemplateValue": "请填写 {{label}}",
"endpointRequired": "非官方供应商请填写 API 端点",
"apiKeyRequired": "非官方供应商请填写 API Key",
"configJsonError": "配置JSON格式错误,请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误,请检查JSON语法",
+4 -5
View File
@@ -1,8 +1,7 @@
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为"类选择器"模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* Tailwind CSS v3 指令 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* shadcn/ui 主题变量 - 蓝色主题 */
@layer base {
+9 -1
View File
@@ -95,6 +95,13 @@ 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),
@@ -106,7 +113,8 @@ export const useUsageQuery = (
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
retry: false,
staleTime: 0, // 使用缓存策略,确保 refetchInterval 准确执行
staleTime, // 使用动态计算的缓存时间
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
});
return {
+1 -1
View File
@@ -36,7 +36,7 @@ function parseJsonError(error: unknown): string {
}
export const providerSchema = z.object({
name: z.string().min(1, "请填写供应商名称"),
name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
notes: z.string().optional(),
settingsConfig: z
+11 -3
View File
@@ -8,14 +8,22 @@ 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"]).optional(),
// 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(),
language: z.enum(["en", "zh"]).optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
geminiConfigDir: directorySchema.nullable().optional(),
// 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+16 -14
View File
@@ -97,33 +97,35 @@ 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";
// ===== 设备级目录覆盖 =====
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// 是否开机自启
launchOnStartup?: boolean;
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
// 安全设置(兼容未来扩展)
security?: {
auth?: {
selectedType?: string;
};
};
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
currentProviderClaude?: string;
// 当前 Codex 供应商 ID(优先于数据库 is_current
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
+1
View File
@@ -4,6 +4,7 @@ export default {
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "selector",
theme: {
extend: {
colors: {
+1 -2
View File
@@ -1,11 +1,10 @@
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(), tailwindcss()],
plugins: [react()],
base: "./",
build: {
outDir: "../dist",