From 3ec83578f6c27974e8ea9ea75fa91b2b3c149617 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Jul 2026 23:01:31 +0800 Subject: [PATCH] fix(profiles): stop proxy server when profile switch leaves no takeovers active --- src-tauri/src/commands/profile.rs | 30 ++++++++++++++++++++++--- src-tauri/src/database/dao/proxy.rs | 17 ++++++++++++++ src-tauri/src/services/profile.rs | 15 +++++++++---- src-tauri/src/tray.rs | 33 ++++++++++++++++++++++------ src-tauri/tests/profile_roundtrip.rs | 16 +++++++------- 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs index 4e82e88b7..85a2bc5e0 100644 --- a/src-tauri/src/commands/profile.rs +++ b/src-tauri/src/commands/profile.rs @@ -1,7 +1,7 @@ //! 项目 Profile 管理命令 use serde::Serialize; -use tauri::{Emitter, State}; +use tauri::{Emitter, Manager, State}; use crate::database::Profile; use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService}; @@ -165,7 +165,31 @@ pub fn apply_profile( scope: String, ) -> Result, String> { let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; - let warnings = ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?; - emit_profile_apply_events(&app, &state, &id, scope); + let (warnings, should_stop_proxy) = + ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?; + + if should_stop_proxy { + // sync 命令线程没有 Tokio runtime,无法直接 await stop(); + // 把停止服务放到 Tauri async runtime,停止后再补发事件刷新 UI。 + let app_handle = app.clone(); + let profile_id = id.clone(); + let proxy_service = state.proxy_service.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = proxy_service.stop().await { + log::warn!("切换项目后停止代理服务失败: {e}"); + } + if let Some(app_state) = app_handle.try_state::() { + emit_profile_apply_events( + &app_handle, + app_state.inner(), + &profile_id, + scope, + ); + } + }); + } else { + emit_profile_apply_events(&app, &state, &id, scope); + } + Ok(warnings) } diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index c3c31cf7e..4c44f89cd 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -494,6 +494,23 @@ impl Database { Ok(count > 0) } + /// 同步版本:检查是否有任一 app 的 enabled = true + /// + /// 用于 `ProfileService::apply` 等 sync 路径判断是否需要停止代理服务。 + pub fn is_live_takeover_active_sync(&self) -> bool { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return false, + }; + conn.query_row( + "SELECT COUNT(*) FROM proxy_config WHERE enabled = 1", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0 + } + // ==================== Provider Health ==================== /// 获取Provider健康状态 diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs index 3cd927e25..6c0847b33 100644 --- a/src-tauri/src/services/profile.rs +++ b/src-tauri/src/services/profile.rs @@ -317,13 +317,16 @@ impl ProfileService { /// 旧项目仍保留离开时的配置,回来时状态一致。自动保存失败时作为 warning /// 继续,不阻塞切换。 /// - /// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP - /// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。 + /// 应用指定项目的快照到当前分组内的所有应用。 + /// + /// 返回 `(warnings, should_stop_proxy)`:当当前分组内所有接管都被关闭、且 + /// 其它应用也没有接管时,建议调用者停止代理服务,以便 Claude Desktop 的 + /// "本地路由"总开关同步显示为关闭。 pub fn apply( state: &AppState, profile_id: &str, scope: ProfileScope, - ) -> Result, AppError> { + ) -> Result<(Vec, bool), AppError> { let mut warnings = Vec::new(); // 自动保存旧项目当前状态(仅当前分组),失败不阻塞切换 @@ -450,7 +453,11 @@ impl ProfileService { state .db .set_current_profile_id(scope.as_str(), Some(profile_id))?; - Ok(warnings) + + // 当前分组内所有接管已关闭;若其它应用也无接管,可停止代理服务。 + let should_stop_proxy = !state.db.is_live_takeover_active_sync(); + + Ok((warnings, should_stop_proxy)) } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 1d34a7d31..096b1ef1c 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -386,16 +386,35 @@ pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool }; match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id, scope) { - Ok(warnings) => { + Ok((warnings, should_stop_proxy)) => { for warning in &warnings { log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}"); } - crate::commands::emit_profile_apply_events( - &app_handle, - app_state.inner(), - &profile_id, - scope, - ); + + if should_stop_proxy { + let app_handle2 = app_handle.clone(); + let proxy_service = app_state.proxy_service.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = proxy_service.stop().await { + log::warn!("托盘切换项目后停止代理服务失败: {e}"); + } + if let Some(state) = app_handle2.try_state::() { + crate::commands::emit_profile_apply_events( + &app_handle2, + state.inner(), + &profile_id, + scope, + ); + } + }); + } else { + crate::commands::emit_profile_apply_events( + &app_handle, + app_state.inner(), + &profile_id, + scope, + ); + } } Err(e) => { log::error!("应用项目 {profile_id} 失败: {e}"); diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs index 6f6d84814..19797dc4e 100644 --- a/src-tauri/tests/profile_roundtrip.rs +++ b/src-tauri/tests/profile_roundtrip.rs @@ -212,7 +212,7 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2"); // ---- 应用项目 A(Claude 组):全部复原 ---- - let warnings = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude) .expect("apply profile A"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); @@ -319,7 +319,7 @@ fn shared_profile_sides_are_isolated_and_mergeable() { assert_eq!(payload.mcp.codex, Some(vec![]), "codex side captured"); // 按 Codex 组应用:只动 codex 组的 current 标记,Claude 侧原样不动 - let warnings = ProfileService::apply(&state, &project.id, ProfileScope::Codex) + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Codex) .expect("apply project on codex side"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); @@ -355,7 +355,7 @@ fn shared_profile_sides_are_isolated_and_mergeable() { ); // 同一共享项目在 Claude 页应用:该侧未拍过快照 → 不动配置、标记 current、返回提示 - let warnings = ProfileService::apply(&state, &project.id, ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude) .expect("apply project on claude side"); assert_eq!(warnings.len(), 1, "uncaptured side yields one hint"); assert!(warnings[0].contains("no claude configuration captured")); @@ -419,7 +419,7 @@ fn profile_apply_reports_dangling_references_and_continues() { }; state.db.save_profile(&profile).expect("save profile"); - let warnings = ProfileService::apply(&state, "dangling-test", ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, "dangling-test", ProfileScope::Claude) .expect("apply succeeds"); assert_eq!( warnings.len(), @@ -536,7 +536,7 @@ fn switching_profile_autosaves_previous_profile_state() { // ---- Project A:状态 X(p1 / m1 / pr1)---- let project_a = ProfileService::create(&state, "Project A", ProfileScope::Claude) .expect("create project A"); - let warnings = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude) .expect("apply project A"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); @@ -550,7 +550,7 @@ fn switching_profile_autosaves_previous_profile_state() { .expect("create project B"); // ---- 从 A 切换到 B:自动把当前状态 Y 保存到 A,再加载 B 的 Y ---- - let warnings = ProfileService::apply(&state, &project_b.id, ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, &project_b.id, ProfileScope::Claude) .expect("switch to project B"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); @@ -591,7 +591,7 @@ fn switching_profile_autosaves_previous_profile_state() { McpService::toggle_app(&state, "m2", AppType::Claude, false).expect("disable m2"); PromptService::enable_prompt(&state, AppType::Claude, "pr1").expect("enable pr1"); - let warnings = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude) .expect("switch back to project A"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); @@ -704,7 +704,7 @@ fn profile_switch_auto_disables_takeover_before_apply() { .expect("save updated project"); // ---- 应用项目:应无条件自动关闭接管,再切换到 custom2 ---- - let warnings = ProfileService::apply(&state, &project.id, ProfileScope::Claude) + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude) .expect("apply custom2 project"); assert!( warnings.is_empty(),