From 2c90ae35090e018e7b87c691dd35c617d0fa45c5 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 27 Nov 2025 11:42:19 +0800 Subject: [PATCH] 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. --- src-tauri/src/commands/import_export.rs | 4 +-- src-tauri/src/lib.rs | 13 +++++++--- src-tauri/src/services/provider/live.rs | 23 ++++++++++++++--- src-tauri/src/services/provider/mod.rs | 33 +++++++++++++++++------- src-tauri/tests/import_export_sync.rs | 34 ++++++++----------------- src/lib/schemas/settings.ts | 14 +++++++--- src/types.ts | 30 ++++++++++++---------- 7 files changed, 92 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 86ff4298..78bcc713 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -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(json!({ "success": true, "message": "Live configuration synchronized" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64c8ae06..841c5baf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -220,9 +220,16 @@ 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)? + + // 优先从本地 settings 读取当前供应商,fallback 到数据库 + let current_id = crate::settings::get_current_provider(§ion.app_type) + .or_else(|| { + app_state + .db + .get_current_provider(app_type_str) + .ok() + .flatten() + }) .unwrap_or_default(); let manager = crate::provider::ProviderManager { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 10942e76..529e5e13 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -124,13 +124,28 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re 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 +/// +/// 从本地 settings 获取当前供应商 ID,fallback 到数据库的 is_current 字段。 +/// 这确保了云同步场景下多设备可以独立选择供应商。 +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())? { + // Priority: local settings > database is_current + let current_id = match crate::settings::get_current_provider(&app_type) { Some(id) => id, - None => continue, + None => { + // Fallback: get from database and update local settings + match state.db.get_current_provider(app_type.as_str())? { + Some(id) => { + // Update local settings for future use + let _ = crate::settings::set_current_provider(&app_type, Some(&id)); + id + } + None => continue, + } + } }; + let providers = state.db.get_all_providers(app_type.as_str())?; if let Some(provider) = providers.get(¤t_id) { write_live_snapshot(&app_type, provider)?; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index fd9c6afb..5900bd4f 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -22,7 +22,7 @@ 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; @@ -94,7 +94,15 @@ impl ProviderService { } /// Get current provider ID + /// + /// 优先从本地 settings 读取当前供应商,fallback 到数据库的 is_current 字段。 + /// 这确保了云同步场景下多设备可以独立选择供应商。 pub fn current(state: &AppState, app_type: AppType) -> Result { + // 优先从本地 settings 读取 + if let Some(id) = crate::settings::get_current_provider(&app_type) { + return Ok(id); + } + // Fallback 到数据库的默认供应商 state .db .get_current_provider(app_type.as_str()) @@ -167,9 +175,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 @@ -179,7 +187,11 @@ 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 local settings first, fallback to database + let current_id = crate::settings::get_current_provider(&app_type) + .or_else(|| state.db.get_current_provider(app_type.as_str()).ok().flatten()); + + 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()) { @@ -192,7 +204,10 @@ 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 (write_gemini_live handles security flag internally for Gemini) @@ -204,9 +219,9 @@ impl ProviderService { 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) diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 97fb5470..7edf7378 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -843,21 +843,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 +894,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 +909,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" ); } diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index a818fae1..e754e34e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 3fc6b186..ad8e59d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; - // Codex 自定义端点列表 - customEndpointsCodex?: Record; - // 安全设置(兼容未来扩展) - security?: { - auth?: { - selectedType?: string; - }; - }; + + // ===== 当前供应商 ID(设备级)===== + // 当前 Claude 供应商 ID(优先于数据库 is_current) + currentProviderClaude?: string; + // 当前 Codex 供应商 ID(优先于数据库 is_current) + currentProviderCodex?: string; + // 当前 Gemini 供应商 ID(优先于数据库 is_current) + currentProviderGemini?: string; } // MCP 服务器连接参数(宽松:允许扩展字段)