diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 7b5b21d95..0996f5311 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -80,6 +80,12 @@ pub async fn restart_app(app: AppHandle) -> Result { // 在后台延迟重启,让函数有时间返回响应 tauri::async_runtime::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + // app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接 + // 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于 + // app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的 + // Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环 + // 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。 + crate::cleanup_before_exit(&app).await; app.restart(); }); Ok(true) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 8a6b53807..e3ce46b83 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1133,6 +1133,22 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result crate::codex_config::read_codex_live_settings()?, AppType::Claude => { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 7918f5b9a..3423eed0b 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1632,7 +1632,7 @@ impl ProxyService { /// /// 返回值: /// - Ok(true):已成功写回 - /// - Ok(false):缺少当前供应商/供应商不存在,无法写回 + /// - Ok(false):缺少当前供应商/供应商不存在/供应商本身含占位符,无法写回 fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result { let current_id = crate::settings::get_effective_current_provider(&self.db, app_type) .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?; @@ -1650,6 +1650,16 @@ impl ProxyService { return Ok(false); }; + // 供应商配置本身含接管占位符时不可写回(历史异常:接管期间 Live 被 + // 误导入成了供应商)。写回只会把占位符固化进 Live;返回 Ok(false) + // 让调用方落到"清理占位符"兜底。 + if Self::live_has_proxy_placeholder_for_app(app_type, &provider.settings_config) { + log::warn!( + "{app_type:?} 当前供应商配置含代理接管占位符(疑似接管期间被导入的残留),跳过 SSOT 写回,改走占位符清理" + ); + return Ok(false); + } + write_live_with_common_config(self.db.as_ref(), app_type, provider) .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 4dc59459f..29eb199c5 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -588,3 +588,30 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() { "current provider should remain empty or be the attempted id on failure, got: {current_id:?}" ); } + +#[test] +fn import_refuses_live_config_under_proxy_takeover() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + ensure_test_home(); + + // 接管态 Codex Live:auth 是 PROXY_MANAGED 占位符,不是用户真实配置 + let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"}); + let config = r#"model = "gpt-5" +"#; + write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live"); + + let state = create_test_state().expect("create test state"); + + import_default_config_test_hook(&state, AppType::Codex) + .expect_err("importing a taken-over live config must fail"); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("get codex providers"); + assert!( + providers.is_empty(), + "taken-over live import must not create providers" + ); +} diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index f443f466b..5d4eb51a2 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -1911,3 +1911,62 @@ fn provider_service_delete_current_provider_returns_error() { other => panic!("expected Config/Message error, got {other:?}"), } } + +#[test] +fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景) + let taken_over_live = json!({ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", + "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED" + } + }); + let settings_path = get_claude_settings_path(); + std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir"); + std::fs::write( + &settings_path, + serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"), + ) + .expect("write taken over live"); + + let state = create_test_state().expect("create test state"); + + // 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染) + let provider = Provider::with_id( + "default".to_string(), + "default".to_string(), + taken_over_live.clone(), + None, + ); + state + .db + .save_provider(AppType::Claude.as_str(), &provider) + .expect("save placeholder provider"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "default") + .expect("set current provider"); + + futures::executor::block_on(state.proxy_service.recover_from_crash()) + .expect("recover from crash"); + + let live_after: serde_json::Value = + read_json_file(&settings_path).expect("read live settings after recovery"); + let env = live_after.get("env").cloned().unwrap_or_else(|| json!({})); + assert_ne!( + env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()), + Some("PROXY_MANAGED"), + "recovery must not write the placeholder back to live" + ); + assert!( + env.get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .map(|url| !url.starts_with("http://127.0.0.1")) + .unwrap_or(true), + "recovery must drop the local proxy base URL" + ); +}