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.
This commit is contained in:
Jason
2025-11-27 11:42:19 +08:00
parent ea7169abc0
commit 2c90ae3509
7 changed files with 92 additions and 59 deletions
+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"
+10 -3
View File
@@ -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(&section.app_type)
.or_else(|| {
app_state
.db
.get_current_provider(app_type_str)
.ok()
.flatten()
})
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
+19 -4
View File
@@ -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 获取当前供应商 IDfallback 到数据库的 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(&current_id) {
write_live_snapshot(&app_type, provider)?;
+24 -9
View File
@@ -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<String, AppError> {
// 优先从本地 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)
+10 -24
View File
@@ -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"
);
}
+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 服务器连接参数(宽松:允许扩展字段)