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 配置 // 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state); 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}"); 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(); let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db); let app_state = AppState::new(db);
ProviderService::sync_current_from_db(&app_state)?; ProviderService::sync_current_to_live(&app_state)?;
Ok::<_, AppError>(json!({ Ok::<_, AppError>(json!({
"success": true, "success": true,
"message": "Live configuration synchronized" "message": "Live configuration synchronized"
+10 -3
View File
@@ -220,9 +220,16 @@ fn create_tray_menu(
for section in TRAY_SECTIONS.iter() { for section in TRAY_SECTIONS.iter() {
let app_type_str = section.app_type.as_str(); let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?; let providers = app_state.db.get_all_providers(app_type_str)?;
let current_id = app_state
.db // 优先从本地 settings 读取当前供应商,fallback 到数据库
.get_current_provider(app_type_str)? 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(); .unwrap_or_default();
let manager = crate::provider::ProviderManager { 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(()) Ok(())
} }
/// Sync current provider from database to live configuration /// Sync current provider to live configuration
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> { ///
/// 从本地 settings 获取当前供应商 IDfallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商。
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { 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, 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())?; let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) { if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?; write_live_snapshot(&app_type, provider)?;
+24 -9
View File
@@ -22,7 +22,7 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState; use crate::store::AppState;
// Re-export sub-module functions for external access // 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)) // Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot; pub(crate) use live::write_live_snapshot;
@@ -94,7 +94,15 @@ impl ProviderService {
} }
/// Get current provider ID /// Get current provider ID
///
/// 优先从本地 settings 读取当前供应商,fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> { 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 state
.db .db
.get_current_provider(app_type.as_str()) .get_current_provider(app_type.as_str())
@@ -167,9 +175,9 @@ impl ProviderService {
/// Switch flow: /// Switch flow:
/// 1. Validate target provider exists /// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications /// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. Set new current provider /// 3. Update local settings current_provider_xxx (device-level)
/// 4. Write target provider config to live files /// 4. Update database is_current (as default for new devices)
/// 5. Gemini additional security flag handling /// 5. Write target provider config to live files
/// 6. Sync MCP configuration /// 6. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists // Check if provider exists
@@ -179,7 +187,11 @@ impl ProviderService {
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider // 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 { if current_id != id {
// Only backfill when switching to a different provider // Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) { 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)?; state.db.set_current_provider(app_type.as_str(), id)?;
// Sync to live (write_gemini_live handles security flag internally for Gemini) // Sync to live (write_gemini_live handles security flag internally for Gemini)
@@ -204,9 +219,9 @@ impl ProviderService {
Ok(()) Ok(())
} }
/// Sync current provider from database to live configuration (re-export) /// Sync current provider to live configuration (re-export)
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> { pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_from_db(state) sync_current_to_live(state)
} }
/// Import default configuration from live files (re-export) /// 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) ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed"); .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!( assert!(
settings_path.exists(), gemini_settings.exists(),
"settings.json should exist at {}", "Gemini settings.json should exist at {}",
settings_path.display() gemini_settings.display()
); );
let raw = std::fs::read_to_string(&settings_path).expect("read 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 settings.json"); let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
assert_eq!( assert_eq!(
value value
.pointer("/security/auth/selectedType") .pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()), .and_then(|v| v.as_str()),
Some("gemini-api-key"), 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) ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed"); .expect("syncing google official gemini should succeed");
let cc_settings = home.join(".cc-switch").join("settings.json"); // security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
assert!(
cc_settings.exists(),
"app settings should exist at {}",
cc_settings.display()
);
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
assert_eq!(
cc_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"syncing Google official should set oauth-personal in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json"); let gemini_settings = home.join(".gemini").join("settings.json");
assert!( assert!(
gemini_settings.exists(), gemini_settings.exists(),
@@ -923,7 +909,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
.pointer("/security/auth/selectedType") .pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()), .and_then(|v| v.as_str()),
Some("oauth-personal"), 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("")); .or(z.literal(""));
export const settingsSchema = z.object({ export const settingsSchema = z.object({
// 设备级 UI 设置
showInTray: z.boolean(), showInTray: z.boolean(),
minimizeToTrayOnClose: z.boolean(), minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(), enableClaudePluginIntegration: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
language: z.enum(["en", "zh"]).optional(),
// 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(), claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(), codexConfigDir: directorySchema.nullable().optional(),
language: z.enum(["en", "zh"]).optional(), geminiConfigDir: directorySchema.nullable().optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(), // 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
}); });
export type SettingsFormData = z.infer<typeof settingsSchema>; export type SettingsFormData = z.infer<typeof settingsSchema>;
+16 -14
View File
@@ -97,33 +97,35 @@ export interface ProviderMeta {
} }
// 应用设置类型(用于设置对话框与 Tauri API) // 应用设置类型(用于设置对话框与 Tauri API)
// 存储在本地 ~/.cc-switch/settings.json,不随数据库同步
export interface Settings { export interface Settings {
// ===== 设备级 UI 设置 =====
// 是否在系统托盘(macOS 菜单栏)显示图标 // 是否在系统托盘(macOS 菜单栏)显示图标
showInTray: boolean; showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用 // 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean; minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey // 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean; enableClaudePluginIntegration?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// ===== 设备级目录覆盖 =====
// 覆盖 Claude Code 配置目录(可选) // 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string; claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选) // 覆盖 Codex 配置目录(可选)
codexConfigDir?: string; codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选) // 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string; geminiConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh"; // ===== 当前供应商 ID(设备级)=====
// 是否开机自启 // 当前 Claude 供应商 ID(优先于数据库 is_current
launchOnStartup?: boolean; currentProviderClaude?: string;
// Claude 自定义端点列表 // 当前 Codex 供应商 ID(优先于数据库 is_current
customEndpointsClaude?: Record<string, CustomEndpoint>; currentProviderCodex?: string;
// Codex 自定义端点列表 // 当前 Gemini 供应商 ID(优先于数据库 is_current
customEndpointsCodex?: Record<string, CustomEndpoint>; currentProviderGemini?: string;
// 安全设置(兼容未来扩展)
security?: {
auth?: {
selectedType?: string;
};
};
} }
// MCP 服务器连接参数(宽松:允许扩展字段) // MCP 服务器连接参数(宽松:允许扩展字段)