feat(profiles): unconditionally disable proxy takeover before applying profile

This commit is contained in:
Jason
2026-07-04 22:41:24 +08:00
parent 4cf6f175a7
commit 4f45601f9f
3 changed files with 159 additions and 5 deletions
+13 -4
View File
@@ -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 diffSkillService 返回 anyhow::Result,收进 warning
// 4. Skills diffSkillService 返回 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) {
+41 -1
View File
@@ -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> {
+105
View File
@@ -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"
);
}