fix(profiles): stop proxy server when profile switch leaves no takeovers active

This commit is contained in:
Jason
2026-07-04 23:01:31 +08:00
parent f05ed3dbac
commit 3ec83578f6
5 changed files with 89 additions and 22 deletions
+27 -3
View File
@@ -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<Vec<String>, 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::<AppState>() {
emit_profile_apply_events(
&app_handle,
app_state.inner(),
&profile_id,
scope,
);
}
});
} else {
emit_profile_apply_events(&app, &state, &id, scope);
}
Ok(warnings)
}
+17
View File
@@ -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健康状态
+11 -4
View File
@@ -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<Vec<String>, AppError> {
) -> Result<(Vec<String>, 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))
}
}
+26 -7
View File
@@ -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::<AppState>() {
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}");
+8 -8
View File
@@ -212,7 +212,7 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
// ---- 应用项目 AClaude 组):全部复原 ----
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:状态 Xp1 / 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(),