diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index ef9c1bb0e..c9f2b6ea3 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -117,6 +117,8 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result _ => {} } } + + ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?; } Ok(imported) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc02f4181..16550f49b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -613,6 +613,25 @@ pub fn run() { } } + // 5.1 Migrate legacy providers that relied on inferred common-config usage + for app_type in [ + crate::app_config::AppType::Claude, + crate::app_config::AppType::Codex, + crate::app_config::AppType::Gemini, + ] { + if let Err(e) = + crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed( + &app_state, + app_type.clone(), + ) + { + log::warn!( + "✗ Failed to migrate legacy common-config usage for {}: {e}", + app_type.as_str() + ); + } + } + // 迁移旧的 app_config_dir 配置到 Store if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { log::warn!("迁移 app_config_dir 失败: {e}"); diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index d096d96ea..b4b3aa0fd 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -461,19 +461,18 @@ fn apply_common_config_to_settings( } } -pub(crate) fn write_live_with_common_config( +pub(crate) fn build_effective_settings_with_common_config( db: &Database, app_type: &AppType, provider: &Provider, -) -> Result<(), AppError> { +) -> Result { let snippet = db.get_config_snippet(app_type.as_str())?; - let mut effective_provider = provider.clone(); + let mut effective_settings = provider.settings_config.clone(); if provider_uses_common_config(app_type, provider, snippet.as_deref()) { if let Some(snippet_text) = snippet.as_deref() { - match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text) - { - Ok(settings) => effective_provider.settings_config = settings, + match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) { + Ok(settings) => effective_settings = settings, Err(err) => { log::warn!( "Failed to apply common config for {} provider '{}': {err}", @@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config( } } + Ok(effective_settings) +} + +pub(crate) fn write_live_with_common_config( + db: &Database, + app_type: &AppType, + provider: &Provider, +) -> Result<(), AppError> { + let mut effective_provider = provider.clone(); + effective_provider.settings_config = + build_effective_settings_with_common_config(db, app_type, provider)?; + write_live_snapshot(app_type, &effective_provider) } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 1ecfd4e83..508c2a8d4 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -28,8 +28,9 @@ pub use live::{ // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{ - normalize_provider_common_config_for_storage, strip_common_config_from_live_settings, - sync_current_provider_for_app_to_live, write_live_with_common_config, + build_effective_settings_with_common_config, normalize_provider_common_config_for_storage, + strip_common_config_from_live_settings, sync_current_provider_for_app_to_live, + write_live_with_common_config, }; // Internal re-exports @@ -670,6 +671,25 @@ impl ProviderService { Ok(()) } + pub fn migrate_legacy_common_config_usage_if_needed( + state: &AppState, + app_type: AppType, + ) -> Result<(), AppError> { + if app_type.is_additive_mode() { + return Ok(()); + } + + let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else { + return Ok(()); + }; + + if snippet.trim().is_empty() { + return Ok(()); + } + + Self::migrate_legacy_common_config_usage(state, app_type, &snippet) + } + /// Extract common config snippet from current provider /// /// Extracts the current provider's configuration and removes provider-specific fields diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a3e5ff3f6..ccb755fc9 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -8,7 +8,9 @@ use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; -use crate::services::provider::write_live_with_common_config; +use crate::services::provider::{ + build_effective_settings_with_common_config, write_live_with_common_config, +}; use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; @@ -1528,21 +1530,37 @@ impl ProxyService { app_type: &str, provider: &Provider, ) -> Result<(), String> { - let backup_json = match app_type { - "claude" => { - // Claude: settings_config 直接作为备份 - serde_json::to_string(&provider.settings_config) - .map_err(|e| format!("序列化 Claude 配置失败: {e}"))? + let app_type_enum = + AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?; + let mut effective_settings = + build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider) + .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?; + + if matches!(app_type_enum, AppType::Codex) { + let existing_backup = self + .db + .get_live_backup(app_type) + .await + .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?; + + if let Some(existing_backup) = existing_backup { + let existing_value: Value = serde_json::from_str(&existing_backup.original_config) + .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?; + Self::preserve_codex_mcp_servers_in_backup( + &mut effective_settings, + &existing_value, + )?; } - "codex" => { - // Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用 - serde_json::to_string(&provider.settings_config) - .map_err(|e| format!("序列化 Codex 配置失败: {e}"))? - } - "gemini" => { - // Gemini: 只提取 env 字段(与原始备份格式一致) - // proxy.rs 的 read_gemini_live() 返回 {"env": {...}} - let env_backup = if let Some(env) = provider.settings_config.get("env") { + } + + let backup_json = match app_type_enum { + AppType::Claude => serde_json::to_string(&effective_settings) + .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?, + AppType::Codex => serde_json::to_string(&effective_settings) + .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?, + AppType::Gemini => { + // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。 + let env_backup = if let Some(env) = effective_settings.get("env") { json!({ "env": env }) } else { json!({ "env": {} }) @@ -1550,7 +1568,9 @@ impl ProxyService { serde_json::to_string(&env_backup) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? } - _ => return Err(format!("未知的应用类型: {app_type}")), + AppType::OpenCode | AppType::OpenClaw => { + return Err(format!("未知的应用类型: {app_type}")); + } }; self.db @@ -1562,6 +1582,47 @@ impl ProxyService { Ok(()) } + fn preserve_codex_mcp_servers_in_backup( + target_settings: &mut Value, + existing_backup: &Value, + ) -> Result<(), String> { + let target_obj = target_settings + .as_object_mut() + .ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?; + + let target_config = target_obj + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut target_doc = if target_config.trim().is_empty() { + toml_edit::DocumentMut::new() + } else { + target_config + .parse::() + .map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))? + }; + + let existing_config = existing_backup + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if existing_config.trim().is_empty() { + target_obj.insert("config".to_string(), json!(target_doc.to_string())); + return Ok(()); + } + + let existing_doc = existing_config + .parse::() + .map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?; + + if let Some(mcp_servers) = existing_doc.get("mcp_servers") { + target_doc["mcp_servers"] = mcp_servers.clone(); + } + + target_obj.insert("config".to_string(), json!(target_doc.to_string())); + Ok(()) + } + /// 代理模式下切换供应商(热切换,不写 Live) pub async fn switch_proxy_target( &self, @@ -1914,6 +1975,7 @@ impl ProxyService { #[cfg(test)] mod tests { use super::*; + use crate::provider::ProviderMeta; use serial_test::serial; use std::env; use tempfile::TempDir; @@ -2191,4 +2253,194 @@ model = "gpt-5.1-codex" let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected); } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_applies_claude_common_config() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + db.set_config_snippet( + "claude", + Some( + serde_json::json!({ + "includeCoAuthoredBy": false + }) + .to_string(), + ), + ) + .expect("set common config snippet"); + + let service = ProxyService::new(db.clone()); + + let mut provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token", + "ANTHROPIC_BASE_URL": "https://claude.example" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + + service + .update_live_backup_from_provider("claude", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("claude") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + + assert_eq!( + stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()), + Some(false), + "common config should be applied into Claude restore backup" + ); + } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_applies_codex_common_config() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + db.set_config_snippet( + "codex", + Some("disable_response_storage = true\n".to_string()), + ) + .expect("set common config snippet"); + + let service = ProxyService::new(db.clone()); + + let mut provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "token" + }, + "config": r#"model_provider = "any" +model = "gpt-5" + +[model_providers.any] +base_url = "https://codex.example/v1" +"# + }), + None, + ); + provider.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + + service + .update_live_backup_from_provider("codex", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + + assert!( + config.contains("disable_response_storage = true"), + "common config should be applied into Codex restore backup" + ); + } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_preserves_codex_mcp_servers() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": { + "OPENAI_API_KEY": "old-token" + }, + "config": r#"model_provider = "any" +model = "gpt-4" + +[model_providers.any] +base_url = "https://old.example/v1" + +[mcp_servers.echo] +command = "npx" +args = ["echo-server"] +"# + })) + .expect("serialize seed backup"), + ) + .await + .expect("seed live backup"); + + let provider = Provider::with_id( + "p2".to_string(), + "P2".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "new-token" + }, + "config": r#"model_provider = "any" +model = "gpt-5" + +[model_providers.any] +base_url = "https://new.example/v1" +"# + }), + None, + ); + + service + .update_live_backup_from_provider("codex", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + + assert!( + config.contains("[mcp_servers.echo]"), + "existing Codex MCP section should survive proxy hot-switch backup update" + ); + assert!( + config.contains("https://new.example/v1"), + "provider-specific base_url should still update to the new provider" + ); + } } diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 6288bddd0..903d86dfa 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -21,6 +21,80 @@ fn sanitize_provider_name(name: &str) -> String { .to_lowercase() } +#[test] +fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() { + 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 + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "legacy-provider".to_string(); + manager.providers.insert( + "legacy-provider".to_string(), + Provider::with_id( + "legacy-provider".to_string(), + "Legacy".to_string(), + json!({ + "includeCoAuthoredBy": false, + "env": { + "ANTHROPIC_API_KEY": "legacy-key" + } + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&config).expect("create test state"); + state + .db + .set_config_snippet( + AppType::Claude.as_str(), + Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()), + ) + .expect("set common config snippet"); + + ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude) + .expect("migrate legacy common config"); + + let providers = state + .db + .get_all_providers(AppType::Claude.as_str()) + .expect("get providers after migration"); + let provider = providers + .get("legacy-provider") + .expect("legacy provider exists"); + + assert_eq!( + provider + .meta + .as_ref() + .and_then(|meta| meta.common_config_enabled), + Some(true), + "historical provider should be explicitly marked as using common config" + ); + assert!( + provider + .settings_config + .get("includeCoAuthoredBy") + .is_none(), + "common config fields should be stripped from provider storage after migration" + ); + assert_eq!( + provider + .settings_config + .get("env") + .and_then(|v| v.get("ANTHROPIC_API_KEY")) + .and_then(|v| v.as_str()), + Some("legacy-key"), + "provider-specific auth should remain untouched" + ); +} + #[test] fn provider_service_switch_codex_updates_live_and_config() { let _guard = test_mutex().lock().expect("acquire test mutex");