diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs index ae076195c..3cd927e25 100644 --- a/src-tauri/src/services/profile.rs +++ b/src-tauri/src/services/profile.rs @@ -355,7 +355,16 @@ impl ProfileService { for app in scope.apps().iter() { let app_str = app.as_str(); - // 1. 供应商 + // 1. 切换项目前无条件关闭当前应用的代理接管。 + // 接管态下 live 文件属于代理;用户希望切换工作目录时总是退出当前 + // 代理环境,再按快照写入真实供应商配置。 + if let Err(e) = state.proxy_service.disable_takeover_for_app_sync(app) { + warnings.push(format!( + "[{app_str}] auto-disable proxy takeover before profile switch failed: {e}" + )); + } + + // 2. 供应商 if let Some(Some(target_pid)) = payload.providers.get(app) { let providers = state.db.get_all_providers(app_str)?; if !providers.contains_key(target_pid) { @@ -375,7 +384,7 @@ impl ProfileService { } } - // 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动) + // 3. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动) if let Some(Some(target_ids)) = payload.mcp.get(app) { let servers = state.db.get_all_mcp_servers()?; let current: Vec<(String, bool)> = servers @@ -395,7 +404,7 @@ impl ProfileService { } } - // 3. Skills diff(SkillService 返回 anyhow::Result,收进 warning) + // 4. Skills diff(SkillService 返回 anyhow::Result,收进 warning) if let Some(Some(target_ids)) = payload.skills.get(app) { let skills = state.db.get_all_installed_skills()?; let current: Vec<(String, bool)> = skills @@ -417,7 +426,7 @@ impl ProfileService { } } - // 4. Prompt(None = 不动;已激活则幂等跳过,避免无谓的文件写与备份) + // 5. Prompt(None = 不动;已激活则幂等跳过,避免无谓的文件写与备份) if let Some(Some(target_prompt)) = payload.prompts.get(app) { let prompts = state.db.get_prompts(app_str)?; match prompts.get(target_prompt) { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 72ef44b6f..36a65c03f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -799,6 +799,46 @@ impl ProxyService { Ok(()) } + /// 同步关闭指定应用的 Live 接管(恢复配置并清标志,不停止代理服务)。 + /// + /// 用于 `ProfileService::apply` 等 sync 路径:调用者所在线程可能没有 Tokio + /// runtime,无法执行 `set_takeover_for_app(false)` 里的停止服务/等待任务等 + /// Tokio IO。这里只恢复 Live 文件、删除备份、清除 DB 接管标志,让后续 + /// `ProviderService::switch` 能正常写入官方供应商配置。 + /// + /// 代理服务本身保持运行;当最后一个应用也关闭接管后,下次用户手动关闭 + /// 代理或程序退出时会自然停止。 + pub fn disable_takeover_for_app_sync(&self, app_type: &AppType) -> Result<(), String> { + let app_type_str = app_type.as_str(); + + // 1) 恢复原始 Live 配置(备份 → SSOT → 清理占位符 三层兜底) + futures::executor::block_on(self.restore_live_config_for_app_with_fallback_inner(app_type)) + .map_err(|e| format!("恢复 {app_type_str} Live 配置失败: {e}"))?; + + // 2) 删除该 app 的备份 + futures::executor::block_on(self.db.delete_live_backup(app_type_str)) + .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; + + // 3) 设置 proxy_config.enabled = false + let mut config = + futures::executor::block_on(self.db.get_proxy_config_for_app(app_type_str)) + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + if config.enabled { + config.enabled = false; + futures::executor::block_on(self.db.update_proxy_config_for_app(config)) + .map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?; + } + + // 4) 清除该应用的健康状态 + futures::executor::block_on(self.db.clear_provider_health_for_app(app_type_str)) + .map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?; + + // 5) 清旧标志 + let _ = futures::executor::block_on(self.db.set_live_takeover_active(false)); + + Ok(()) + } + /// 同步 Live 配置中的 Token 到数据库 /// /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。 @@ -1560,7 +1600,7 @@ impl ProxyService { .await } - async fn restore_live_config_for_app_with_fallback_inner( + pub(crate) async fn restore_live_config_for_app_with_fallback_inner( &self, app_type: &AppType, ) -> Result<(), String> { diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs index 76ea796c6..6f6d84814 100644 --- a/src-tauri/tests/profile_roundtrip.rs +++ b/src-tauri/tests/profile_roundtrip.rs @@ -639,3 +639,108 @@ fn switching_profile_autosaves_previous_profile_state() { assert_eq!(payload_b.mcp.claude, Some(vec!["m1".to_string()])); assert_eq!(payload_b.prompts.claude.as_deref(), Some("pr1")); } + +#[test] +fn profile_switch_auto_disables_takeover_before_apply() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + // 使用临时端口,避免测试机器端口冲突 + futures::executor::block_on(async { + let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config"); + proxy_config.listen_port = 0; + state + .db + .update_proxy_config(proxy_config) + .await + .expect("set ephemeral proxy port"); + }); + + // ---- 两个 Claude 供应商:custom1 与 custom2 ---- + let mut custom1 = claude_provider("custom1", "custom-key-1"); + custom1.category = Some("custom".to_string()); + state + .db + .save_provider(AppType::Claude.as_str(), &custom1) + .expect("save custom1 provider"); + + let mut custom2 = claude_provider("custom2", "custom-key-2"); + custom2.category = Some("custom".to_string()); + state + .db + .save_provider(AppType::Claude.as_str(), &custom2) + .expect("save custom2 provider"); + + // 初始状态:custom1 + 代理接管 + ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1"); + let rt = tokio::runtime::Runtime::new().expect("create tokio runtime"); + rt.block_on(state.proxy_service.set_takeover_for_app("claude", true)) + .expect("enable claude takeover"); + + let (proxy_enabled_before, _) = state.db.get_proxy_flags_sync("claude"); + assert!( + proxy_enabled_before, + "takeover should be active before apply" + ); + + // ---- 构造一个目标为 custom2 的项目快照 ---- + let project = ProfileService::create(&state, "Custom2 Project", ProfileScope::Claude) + .expect("create project"); + let mut project = state + .db + .get_profile(&project.id) + .expect("get project") + .expect("project exists"); + let mut payload: ProfilePayload = + serde_json::from_str(&project.payload).expect("parse project payload"); + payload.providers.claude = Some("custom2".to_string()); + project.payload = serde_json::to_string(&payload).expect("serialize payload"); + state + .db + .save_profile(&project) + .expect("save updated project"); + + // ---- 应用项目:应无条件自动关闭接管,再切换到 custom2 ---- + let warnings = ProfileService::apply(&state, &project.id, ProfileScope::Claude) + .expect("apply custom2 project"); + assert!( + warnings.is_empty(), + "switching project should not warn: {warnings:?}" + ); + + // 接管已关闭 + let (proxy_enabled_after, _) = state.db.get_proxy_flags_sync("claude"); + assert!( + !proxy_enabled_after, + "proxy takeover should be auto-disabled before applying profile" + ); + + // 当前供应商已切到 custom2 + assert_eq!( + state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get current provider") + .as_deref(), + Some("custom2"), + "current provider should be custom2" + ); + + // live 配置应指向 custom2 的真实 endpoint,而非代理地址 + let settings_path = home.join(".claude/settings.json"); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings_path).expect("read settings")) + .expect("parse settings"); + let base_url = settings + .get("env") + .and_then(|e| e.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()); + assert_eq!( + base_url, + Some("https://api.test"), + "live config should point to real endpoint after auto-disable" + ); +}