diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 395ce307..b10ce6b2 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -6,6 +6,14 @@ use crate::proxy::types::*; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::store::AppState; +/// 启动代理服务器(仅启动服务,不接管 Live 配置) +#[tauri::command] +pub async fn start_proxy_server( + state: tauri::State<'_, AppState>, +) -> Result { + state.proxy_service.start(true).await +} + /// 启动代理服务器(带 Live 配置接管) #[tauri::command] pub async fn start_proxy_with_takeover( @@ -20,6 +28,27 @@ pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Resul state.proxy_service.stop_with_restore().await } +/// 获取各应用接管状态 +#[tauri::command] +pub async fn get_proxy_takeover_status( + state: tauri::State<'_, AppState>, +) -> Result { + state.proxy_service.get_takeover_status().await +} + +/// 为指定应用开启/关闭接管 +#[tauri::command] +pub async fn set_proxy_takeover_for_app( + state: tauri::State<'_, AppState>, + app_type: String, + enabled: bool, +) -> Result<(), String> { + state + .proxy_service + .set_takeover_for_app(&app_type, enabled) + .await +} + /// 获取代理服务器状态 #[tauri::command] pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result { diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 5aacc11d..ef3dc36f 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -85,15 +85,8 @@ impl Database { /// 检查是否处于 Live 接管模式 pub async fn is_live_takeover_active(&self) -> Result { - let conn = lock_conn!(self.conn); - let active: i32 = conn - .query_row( - "SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1", - [], - |row| row.get(0), - ) - .unwrap_or(0); - Ok(active != 0) + // v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管) + self.has_any_live_backup().await } // ==================== Provider Health ==================== diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21c3fa60..15ec5b5d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -574,7 +574,7 @@ pub fn run() { Ok(config) => { if config.enabled { log::info!("代理服务配置为启用,正在启动..."); - match state.proxy_service.start_with_takeover().await { + match state.proxy_service.start(true).await { Ok(info) => log::info!( "代理服务器自动启动成功: {}:{}", info.address, @@ -690,8 +690,11 @@ pub fn run() { commands::set_auto_launch, commands::get_auto_launch_status, // Proxy server management + commands::start_proxy_server, commands::start_proxy_with_takeover, commands::stop_proxy_with_restore, + commands::get_proxy_takeover_status, + commands::set_proxy_takeover_for_app, commands::get_proxy_status, commands::get_proxy_config, commands::update_proxy_config, diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 9a521126..0b1c73aa 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -86,6 +86,14 @@ pub struct ProxyServerInfo { pub started_at: String, } +/// 各应用的接管状态(是否改写该应用的 Live 配置指向本地代理) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProxyTakeoverStatus { + pub claude: bool, + pub codex: bool, + pub gemini: bool, +} + /// API 格式类型(预留,当前不需要格式转换) #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 6fd8c9fe..7790ca41 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -147,10 +147,13 @@ impl ProviderService { // 如果代理接管模式处于激活状态,并且代理服务正在运行: // - 不写 Live 配置(否则会破坏接管) // - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置) - let is_takeover_flag = - futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false); + let is_app_taken_over = + futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) + .ok() + .flatten() + .is_some(); let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); - let should_skip_live_write = is_takeover_flag && is_proxy_running; + let should_skip_live_write = is_app_taken_over && is_proxy_running; if should_skip_live_write { futures::executor::block_on( @@ -160,12 +163,6 @@ impl ProviderService { ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } else { - // 如果检测到接管标志残留但代理未运行,清理标志后再执行正常写入 - if is_takeover_flag && !is_proxy_running { - log::warn!("检测到代理接管标志残留(代理已停止),清除标志并写入 Live 配置"); - let _ = futures::executor::block_on(state.db.set_live_takeover_active(false)); - } - write_live_snapshot(&app_type, &provider)?; // Sync MCP McpService::sync_all_enabled(state)?; @@ -214,12 +211,15 @@ impl ProviderService { // Check if proxy takeover mode is active AND proxy server is actually running // Both conditions must be true to use hot-switch mode // Use blocking wait since this is a sync function - let is_takeover_flag = - futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false); + let is_app_taken_over = + futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) + .ok() + .flatten() + .is_some(); let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); - // Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running - let should_hot_switch = is_takeover_flag && is_proxy_running; + // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running + let should_hot_switch = is_app_taken_over && is_proxy_running; if should_hot_switch { // Proxy takeover mode: hot-switch only, don't write Live config @@ -254,13 +254,6 @@ impl ProviderService { } // Normal mode: full switch with Live config write - // Also clear stale takeover flag if proxy is not running but flag was set - if is_takeover_flag && !is_proxy_running { - log::warn!("检测到代理接管标志残留(代理已停止),清除标志并执行正常切换"); - // Clear stale takeover flag - let _ = futures::executor::block_on(state.db.set_live_takeover_active(false)); - } - Self::switch_normal(state, app_type, id, &providers) } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 7d8d1661..8b867f7c 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -41,7 +41,10 @@ impl ProxyService { } /// 启动代理服务器 - pub async fn start(&self) -> Result { + /// + /// - `persist_enabled = true`:将 `proxy_config.enabled` 持久化为启用(用于“总开关”) + /// - `persist_enabled = false`:仅在当前进程启动代理服务(用于“按 App 接管”自动启动) + pub async fn start(&self, persist_enabled: bool) -> Result { // 1. 获取配置 let mut config = self .db @@ -49,12 +52,27 @@ impl ProxyService { .await .map_err(|e| format!("获取代理配置失败: {e}"))?; - // 2. 确保配置启用(用户通过UI启动即表示希望启用) - config.enabled = true; + // 2. 仅在需要时持久化 enabled(避免“按 App 接管”自动启动时误打开总开关) + if persist_enabled { + config.enabled = true; + } - // 3. 检查是否已在运行 - if self.server.read().await.is_some() { - return Err("代理服务已在运行中".to_string()); + // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息 + if let Some(server) = self.server.read().await.as_ref() { + if persist_enabled { + self.db + .update_proxy_config(config) + .await + .map_err(|e| format!("保存代理配置失败: {e}"))?; + } + + let status = server.get_status().await; + return Ok(ProxyServerInfo { + address: status.address, + port: status.port, + // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可 + started_at: chrono::Utc::now().to_rfc3339(), + }); } // 4. 创建并启动服务器 @@ -68,11 +86,13 @@ impl ProxyService { // 5. 保存服务器实例 *self.server.write().await = Some(server); - // 6. 持久化 enabled 状态 - self.db - .update_proxy_config(config) - .await - .map_err(|e| format!("保存代理配置失败: {e}"))?; + // 6. 持久化 enabled 状态(仅总开关) + if persist_enabled { + self.db + .update_proxy_config(config) + .await + .map_err(|e| format!("保存代理配置失败: {e}"))?; + } log::info!("代理服务器已启动: {}:{}", info.address, info.port); Ok(info) @@ -118,7 +138,7 @@ impl ProxyService { } // 5. 启动代理服务器 - match self.start().await { + match self.start(true).await { Ok(info) => Ok(info), Err(e) => { // 启动失败,恢复原始配置 @@ -137,81 +157,305 @@ impl ProxyService { } } + /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理) + pub async fn get_takeover_status(&self) -> Result { + let claude = self + .db + .get_live_backup("claude") + .await + .map_err(|e| format!("获取 Claude 接管状态失败: {e}"))? + .is_some(); + let codex = self + .db + .get_live_backup("codex") + .await + .map_err(|e| format!("获取 Codex 接管状态失败: {e}"))? + .is_some(); + let gemini = self + .db + .get_live_backup("gemini") + .await + .map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))? + .is_some(); + + Ok(ProxyTakeoverStatus { + claude, + codex, + gemini, + }) + } + + /// 为指定应用开启/关闭 Live 接管 + /// + /// - 开启:自动启动代理服务(不影响总开关持久化),仅接管当前 app 的 Live 配置 + /// - 关闭:仅恢复当前 app 的 Live 配置;若总开关未开启且无其它接管,则自动停止代理服务 + pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> { + let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; + let app_type_str = app.as_str(); + + if enabled { + // 1) 代理服务未运行则自动启动(不持久化总开关) + if !self.is_running().await { + self.start(false).await?; + } + + // 2) 已接管则直接返回(幂等) + if self + .db + .get_live_backup(app_type_str) + .await + .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))? + .is_some() + { + return Ok(()); + } + + // 3) 备份 Live 配置(严格:目标 app 不存在则报错) + self.backup_live_config_strict(&app).await?; + + // 4) 同步 Live Token 到数据库(仅当前 app) + if let Err(e) = self.sync_live_to_provider(&app).await { + let _ = self.db.delete_live_backup(app_type_str).await; + return Err(e); + } + + // 5) 写入接管配置(仅当前 app) + if let Err(e) = self.takeover_live_config_strict(&app).await { + log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); + let _ = self.restore_live_config_for_app(&app).await; + let _ = self.db.delete_live_backup(app_type_str).await; + return Err(e); + } + + // 6) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) + let _ = self.db.set_live_takeover_active(true).await; + return Ok(()); + } + + // 关闭接管:无备份则视为未接管(幂等) + let has_backup = self + .db + .get_live_backup(app_type_str) + .await + .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))? + .is_some(); + if !has_backup { + return Ok(()); + } + + // 1) 恢复 Live 配置 + self.restore_live_config_for_app(&app).await?; + + // 2) 删除该 app 的备份(避免长期存储敏感 Token) + self.db + .delete_live_backup(app_type_str) + .await + .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; + + // 3) 若无其它接管,更新旧标志,并在总开关未开启时停止代理服务 + let has_any_backup = self + .db + .has_any_live_backup() + .await + .map_err(|e| format!("检查 Live 备份失败: {e}"))?; + if !has_any_backup { + let _ = self.db.set_live_takeover_active(false).await; + + let master_enabled = self + .db + .get_proxy_config() + .await + .map_err(|e| format!("获取代理配置失败: {e}"))? + .enabled; + if !master_enabled && self.is_running().await { + // 此时没有任何 app 处于接管状态,停止服务即可 + let _ = self.stop().await; + } + } + + Ok(()) + } + /// 同步 Live 配置中的 Token 到数据库 /// /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。 /// 这样代理才能从数据库读取到正确的认证信息。 - async fn sync_live_to_providers(&self) -> Result<(), String> { - // Claude: 同步 Token(Live 属于本机配置,因此优先使用设备级 effective current) - if let Ok(live_config) = self.read_claude_live() { - let provider_id = - crate::settings::get_effective_current_provider(&self.db, &AppType::Claude) - .map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?; + async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> { + let live_config = match app_type { + AppType::Claude => self.read_claude_live()?, + AppType::Codex => self.read_codex_live()?, + AppType::Gemini => self.read_gemini_live()?, + }; - if let Some(provider_id) = provider_id { - if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "claude") { - if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) { - let token_pair = [ - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - ] - .into_iter() - .find_map(|key| { - env.get(key) - .and_then(|v| v.as_str()) - .map(|s| (key, s.trim())) - }) - .filter(|(_, token)| { - !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER - }); + self.sync_live_config_to_provider(app_type, &live_config) + .await + } - if let Some((token_key, token)) = token_pair { - let env_obj = provider + async fn sync_live_config_to_provider( + &self, + app_type: &AppType, + live_config: &Value, + ) -> Result<(), String> { + match app_type { + AppType::Claude => { + let provider_id = + crate::settings::get_effective_current_provider(&self.db, &AppType::Claude) + .map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?; + + if let Some(provider_id) = provider_id { + if let Ok(Some(mut provider)) = + self.db.get_provider_by_id(&provider_id, "claude") + { + if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) { + let token_pair = [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + ] + .into_iter() + .find_map(|key| { + env.get(key) + .and_then(|v| v.as_str()) + .map(|s| (key, s.trim())) + }) + .filter(|(_, token)| { + !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER + }); + + if let Some((token_key, token)) = token_pair { + let env_obj = provider + .settings_config + .get_mut("env") + .and_then(|v| v.as_object_mut()); + + match env_obj { + Some(obj) => { + obj.insert(token_key.to_string(), json!(token)); + // ANTHROPIC_AUTH_TOKEN 与 ANTHROPIC_API_KEY 视为同义字段,保持一致 + if token_key == "ANTHROPIC_AUTH_TOKEN" + || token_key == "ANTHROPIC_API_KEY" + { + obj.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(token), + ); + obj.insert( + "ANTHROPIC_API_KEY".to_string(), + json!(token), + ); + } + } + None => { + // 至少写入一份可用的 Token + provider.settings_config["env"] = json!({ + token_key: token + }); + if token_key == "ANTHROPIC_AUTH_TOKEN" + || token_key == "ANTHROPIC_API_KEY" + { + provider.settings_config["env"] + ["ANTHROPIC_AUTH_TOKEN"] = json!(token); + provider.settings_config["env"]["ANTHROPIC_API_KEY"] = + json!(token); + } + } + } + + if let Err(e) = self.db.update_provider_settings_config( + "claude", + &provider_id, + &provider.settings_config, + ) { + log::warn!("同步 Claude Token 到数据库失败: {e}"); + } else { + log::info!( + "已同步 Claude Token 到数据库 (provider: {provider_id})" + ); + } + } + } + } + } + } + AppType::Codex => { + let provider_id = + crate::settings::get_effective_current_provider(&self.db, &AppType::Codex) + .map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?; + + if let Some(provider_id) = provider_id { + if let Ok(Some(mut provider)) = + self.db.get_provider_by_id(&provider_id, "codex") + { + if let Some(token) = live_config + .get("auth") + .and_then(|v| v.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER) + { + if let Some(auth_obj) = provider .settings_config - .get_mut("env") - .and_then(|v| v.as_object_mut()); - - match env_obj { - Some(obj) => { - obj.insert(token_key.to_string(), json!(token)); - // ANTHROPIC_AUTH_TOKEN 与 ANTHROPIC_API_KEY 视为同义字段,保持一致 - if token_key == "ANTHROPIC_AUTH_TOKEN" - || token_key == "ANTHROPIC_API_KEY" - { - obj.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - json!(token), - ); - obj.insert("ANTHROPIC_API_KEY".to_string(), json!(token)); - } - } - None => { - // 至少写入一份可用的 Token - provider.settings_config["env"] = json!({ - token_key: token - }); - if token_key == "ANTHROPIC_AUTH_TOKEN" - || token_key == "ANTHROPIC_API_KEY" - { - provider.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = - json!(token); - provider.settings_config["env"]["ANTHROPIC_API_KEY"] = - json!(token); - } - } + .get_mut("auth") + .and_then(|v| v.as_object_mut()) + { + auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token)); + } else { + provider.settings_config["auth"] = json!({ + "OPENAI_API_KEY": token + }); } if let Err(e) = self.db.update_provider_settings_config( - "claude", + "codex", &provider_id, &provider.settings_config, ) { - log::warn!("同步 Claude Token 到数据库失败: {e}"); + log::warn!("同步 Codex Token 到数据库失败: {e}"); + } else { + log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})"); + } + } + } + } + } + AppType::Gemini => { + let provider_id = + crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini) + .map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?; + + if let Some(provider_id) = provider_id { + if let Ok(Some(mut provider)) = + self.db.get_provider_by_id(&provider_id, "gemini") + { + if let Some(token) = live_config + .get("env") + .and_then(|v| v.get("GEMINI_API_KEY")) + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER) + { + if let Some(env_obj) = provider + .settings_config + .get_mut("env") + .and_then(|v| v.as_object_mut()) + { + env_obj.insert("GEMINI_API_KEY".to_string(), json!(token)); + } else { + provider.settings_config["env"] = json!({ + "GEMINI_API_KEY": token + }); + } + + if let Err(e) = self.db.update_provider_settings_config( + "gemini", + &provider_id, + &provider.settings_config, + ) { + log::warn!("同步 Gemini Token 到数据库失败: {e}"); } else { log::info!( - "已同步 Claude Token 到数据库 (provider: {provider_id})" + "已同步 Gemini Token 到数据库 (provider: {provider_id})" ); } } @@ -220,86 +464,23 @@ impl ProxyService { } } - // Codex: 同步 OPENAI_API_KEY(忽略占位符) - if let Ok(live_config) = self.read_codex_live() { - let provider_id = - crate::settings::get_effective_current_provider(&self.db, &AppType::Codex) - .map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?; + Ok(()) + } - if let Some(provider_id) = provider_id { - if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "codex") { - if let Some(token) = live_config - .get("auth") - .and_then(|v| v.get("OPENAI_API_KEY")) - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER) - { - if let Some(auth_obj) = provider - .settings_config - .get_mut("auth") - .and_then(|v| v.as_object_mut()) - { - auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token)); - } else { - provider.settings_config["auth"] = json!({ - "OPENAI_API_KEY": token - }); - } - - if let Err(e) = self.db.update_provider_settings_config( - "codex", - &provider_id, - &provider.settings_config, - ) { - log::warn!("同步 Codex Token 到数据库失败: {e}"); - } else { - log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})"); - } - } - } - } + async fn sync_live_to_providers(&self) -> Result<(), String> { + if let Ok(live_config) = self.read_claude_live() { + self.sync_live_config_to_provider(&AppType::Claude, &live_config) + .await?; + } + + if let Ok(live_config) = self.read_codex_live() { + self.sync_live_config_to_provider(&AppType::Codex, &live_config) + .await?; } - // Gemini: 同步 GEMINI_API_KEY(忽略占位符) if let Ok(live_config) = self.read_gemini_live() { - let provider_id = - crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini) - .map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?; - - if let Some(provider_id) = provider_id { - if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "gemini") { - if let Some(token) = live_config - .get("env") - .and_then(|v| v.get("GEMINI_API_KEY")) - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER) - { - if let Some(env_obj) = provider - .settings_config - .get_mut("env") - .and_then(|v| v.as_object_mut()) - { - env_obj.insert("GEMINI_API_KEY".to_string(), json!(token)); - } else { - provider.settings_config["env"] = json!({ - "GEMINI_API_KEY": token - }); - } - - if let Err(e) = self.db.update_provider_settings_config( - "gemini", - &provider_id, - &provider.settings_config, - ) { - log::warn!("同步 Gemini Token 到数据库失败: {e}"); - } else { - log::info!("已同步 Gemini Token 到数据库 (provider: {provider_id})"); - } - } - } - } + self.sync_live_config_to_provider(&AppType::Gemini, &live_config) + .await?; } log::info!("Live 配置 Token 同步完成"); @@ -329,8 +510,19 @@ impl ProxyService { /// 停止代理服务器(恢复 Live 配置) pub async fn stop_with_restore(&self) -> Result<(), String> { - // 1. 停止代理服务器 - self.stop().await?; + // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) + if let Err(e) = self.stop().await { + log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); + + // stop() 只有在 server 实例存在时才会把 enabled 设为 false; + // 这里兜底确保“总开关关闭”能落盘关闭状态。 + if let Ok(mut config) = self.db.get_proxy_config().await { + if config.enabled { + config.enabled = false; + let _ = self.db.update_proxy_config(config).await; + } + } + } // 2. 恢复原始 Live 配置 self.restore_live_configs().await?; @@ -393,22 +585,33 @@ impl ProxyService { Ok(()) } - /// 接管各应用的 Live 配置(写入代理地址) - /// - /// 代理服务器的路由已经根据 API 端点自动区分应用类型: - /// - `/v1/messages` → Claude - /// - `/v1/chat/completions`, `/v1/responses` → Codex - /// - `/v1beta/*` → Gemini - /// - /// 因此不需要在 URL 中添加应用前缀。 - async fn takeover_live_configs(&self) -> Result<(), String> { + /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) + async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { + let (app_type_str, config) = match app_type { + AppType::Claude => ("claude", self.read_claude_live()?), + AppType::Codex => ("codex", self.read_codex_live()?), + AppType::Gemini => ("gemini", self.read_gemini_live()?), + }; + + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?; + self.db + .save_live_backup(app_type_str, &json_str) + .await + .map_err(|e| format!("备份 {app_type_str} 配置失败: {e}"))?; + + Ok(()) + } + + /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况) + async fn build_proxy_urls(&self) -> Result<(String, String), String> { let config = self .db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}"))?; - // 注意:listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接; + // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接; // 因此写回到各应用配置时,优先使用本机回环地址。 let connect_host = match config.listen_address.as_str() { "0.0.0.0" => "127.0.0.1".to_string(), @@ -425,6 +628,20 @@ impl ProxyService { let proxy_url = proxy_origin.clone(); let proxy_codex_base_url = format!("{}/v1", proxy_origin.trim_end_matches('/')); + Ok((proxy_url, proxy_codex_base_url)) + } + + /// 接管各应用的 Live 配置(写入代理地址) + /// + /// 代理服务器的路由已经根据 API 端点自动区分应用类型: + /// - `/v1/messages` → Claude + /// - `/v1/chat/completions`, `/v1/responses` → Codex + /// - `/v1beta/*` → Gemini + /// + /// 因此不需要在 URL 中添加应用前缀。 + async fn takeover_live_configs(&self) -> Result<(), String> { + let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_claude_live() { if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { @@ -500,6 +717,196 @@ impl ProxyService { Ok(()) } + /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) + async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { + let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + + match app_type { + AppType::Claude => { + let mut live_config = self.read_claude_live()?; + if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url)); + + let token_keys = [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + ]; + + let mut replaced_any = false; + for key in token_keys { + if env.contains_key(key) { + env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + replaced_any = true; + } + } + + if !replaced_any { + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(PROXY_TOKEN_PLACEHOLDER), + ); + } + } else { + live_config["env"] = json!({ + "ANTHROPIC_BASE_URL": &proxy_url, + "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER + }); + } + + self.write_claude_live(&live_config)?; + log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); + } + AppType::Codex => { + let mut live_config = self.read_codex_live()?; + + if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { + auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + } + + let config_str = live_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url); + live_config["config"] = json!(updated_config); + + self.write_codex_live(&live_config)?; + log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); + } + AppType::Gemini => { + let mut live_config = self.read_gemini_live()?; + + if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url)); + env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + } else { + live_config["env"] = json!({ + "GOOGLE_GEMINI_BASE_URL": &proxy_url, + "GEMINI_API_KEY": PROXY_TOKEN_PLACEHOLDER + }); + } + + self.write_gemini_live(&live_config)?; + log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); + } + } + + Ok(()) + } + + /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过) + async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> { + let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + + match app_type { + AppType::Claude => { + if let Ok(mut live_config) = self.read_claude_live() { + if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url)); + + let token_keys = [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + ]; + + let mut replaced_any = false; + for key in token_keys { + if env.contains_key(key) { + env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + replaced_any = true; + } + } + + if !replaced_any { + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(PROXY_TOKEN_PLACEHOLDER), + ); + } + } else { + live_config["env"] = json!({ + "ANTHROPIC_BASE_URL": &proxy_url, + "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER + }); + } + + let _ = self.write_claude_live(&live_config); + } + } + AppType::Codex => { + if let Ok(mut live_config) = self.read_codex_live() { + if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) + { + auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + } + + let config_str = live_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let updated_config = + Self::update_toml_base_url(config_str, &proxy_codex_base_url); + live_config["config"] = json!(updated_config); + + let _ = self.write_codex_live(&live_config); + } + } + AppType::Gemini => { + if let Ok(mut live_config) = self.read_gemini_live() { + if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url)); + env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + } else { + live_config["env"] = json!({ + "GOOGLE_GEMINI_BASE_URL": &proxy_url, + "GEMINI_API_KEY": PROXY_TOKEN_PLACEHOLDER + }); + } + + let _ = self.write_gemini_live(&live_config); + } + } + } + + Ok(()) + } + + /// 恢复指定应用的 Live 配置(若无备份则不做任何操作) + async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> { + match app_type { + AppType::Claude => { + if let Ok(Some(backup)) = self.db.get_live_backup("claude").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Claude 备份失败: {e}"))?; + self.write_claude_live(&config)?; + log::info!("Claude Live 配置已恢复"); + } + } + AppType::Codex => { + if let Ok(Some(backup)) = self.db.get_live_backup("codex").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Codex 备份失败: {e}"))?; + self.write_codex_live(&config)?; + log::info!("Codex Live 配置已恢复"); + } + } + AppType::Gemini => { + if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?; + self.write_gemini_live(&config)?; + log::info!("Gemini Live 配置已恢复"); + } + } + } + + Ok(()) + } + /// 恢复原始 Live 配置 async fn restore_live_configs(&self) -> Result<(), String> { // Claude @@ -886,12 +1293,30 @@ impl ProxyService { *server_guard = Some(new_server); log::info!("代理配置已更新,服务器已自动重启应用最新配置"); - // 如果当前处于 Live 接管模式,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口) + // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口) drop(server_guard); - if previous.live_takeover_active { - // takeover_live_configs 只会写入代理地址与占位符,不会破坏备份 - self.takeover_live_configs().await?; - log::info!("已同步更新 Live 配置中的代理地址"); + if let Ok(takeover) = self.get_takeover_status().await { + let mut updated_any = false; + + if takeover.claude { + self.takeover_live_config_best_effort(&AppType::Claude) + .await?; + updated_any = true; + } + if takeover.codex { + self.takeover_live_config_best_effort(&AppType::Codex) + .await?; + updated_any = true; + } + if takeover.gemini { + self.takeover_live_config_best_effort(&AppType::Gemini) + .await?; + updated_any = true; + } + + if updated_any { + log::info!("已同步更新 Live 配置中的代理地址"); + } } return Ok(()); diff --git a/src/App.tsx b/src/App.tsx index 88477b23..3564f03f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -491,7 +491,7 @@ function App() { )} {currentView === "providers" && ( <> - + diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 17300e57..20385c58 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -9,40 +9,54 @@ import { Radio, Loader2 } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import type { AppId } from "@/lib/api"; interface ProxyToggleProps { className?: string; + activeApp: AppId; } -export function ProxyToggle({ className }: ProxyToggleProps) { +export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { + const { t } = useTranslation(); const { isRunning, - isTakeoverActive, - startWithTakeover, - stopWithRestore, + takeoverStatus, + setTakeoverForApp, isPending, status, } = useProxyStatus(); const handleToggle = async (checked: boolean) => { - if (checked) { - await startWithTakeover(); - } else { - await stopWithRestore(); - } + await setTakeoverForApp({ appType: activeApp, enabled: checked }); }; - const isActive = isRunning && isTakeoverActive; + const takeoverEnabled = takeoverStatus?.[activeApp] || false; - const tooltipText = isActive - ? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换` - : "开启代理模式\n启用后自动接管 Live 配置"; + const appLabel = + activeApp === "claude" + ? "Claude" + : activeApp === "codex" + ? "Codex" + : "Gemini"; + + const tooltipText = takeoverEnabled + ? isRunning + ? t("proxy.takeover.tooltip.active", { + defaultValue: `${appLabel} 已接管 - ${status?.address}:${status?.port}\n切换该应用供应商为热切换`, + }) + : t("proxy.takeover.tooltip.broken", { + defaultValue: `${appLabel} 已接管,但代理服务未运行`, + }) + : t("proxy.takeover.tooltip.inactive", { + defaultValue: `接管 ${appLabel} 的 Live 配置,让该应用请求走本地代理`, + }); return (
{ try { if (!checked) { - await stopProxy(); + await stopWithRestore(); } else { - await startProxy(); + await startProxyServer(); } } catch (error) { console.error("Toggle proxy failed:", error); diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index 1f13547b..98aeeb45 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -6,7 +6,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; -import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy"; +import type { ProxyStatus, ProxyServerInfo, ProxyTakeoverStatus } from "@/types/proxy"; import { extractErrorMessage } from "@/utils/errorUtils"; /** @@ -26,47 +26,47 @@ export function useProxyStatus() { placeholderData: (previousData) => previousData, }); - // 查询接管状态 - const { data: isTakeoverActive } = useQuery({ - queryKey: ["proxyTakeoverActive"], - queryFn: () => invoke("is_live_takeover_active"), + // 查询各应用接管状态 + const { data: takeoverStatus } = useQuery({ + queryKey: ["proxyTakeoverStatus"], + queryFn: () => invoke("get_proxy_takeover_status"), + placeholderData: (previousData) => previousData, }); - // 启动服务器(带 Live 配置接管) - const startWithTakeoverMutation = useMutation({ - mutationFn: () => invoke("start_proxy_with_takeover"), + // 启动服务器(总开关:仅启动服务,不接管) + const startProxyServerMutation = useMutation({ + mutationFn: () => invoke("start_proxy_server"), onSuccess: (info) => { toast.success( - t("proxy.startedWithTakeover", { - defaultValue: `代理模式已启用 - ${info.address}:${info.port}`, + t("proxy.server.started", { + defaultValue: `代理服务已启动 - ${info.address}:${info.port}`, }), { closeButton: true }, ); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); - queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] }); }, onError: (error: Error) => { const detail = extractErrorMessage(error) || "未知错误"; toast.error( - t("proxy.startWithTakeoverFailed", { - defaultValue: `启动失败: ${detail}`, + t("proxy.server.startFailed", { + defaultValue: `启动代理服务失败: ${detail}`, }), ); }, }); - // 停止服务器(恢复 Live 配置) + // 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置) const stopWithRestoreMutation = useMutation({ mutationFn: () => invoke("stop_proxy_with_restore"), onSuccess: () => { toast.success( t("proxy.stoppedWithRestore", { - defaultValue: "代理模式已关闭,配置已恢复", + defaultValue: "代理服务已关闭,已恢复所有接管配置", }), { closeButton: true }, ); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); - queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] }); + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); // 清除所有供应商健康状态缓存(后端已清空数据库记录) queryClient.invalidateQueries({ queryKey: ["providerHealth"] }); }, @@ -80,6 +80,47 @@ export function useProxyStatus() { }, }); + // 按应用开启/关闭接管 + const setTakeoverForAppMutation = useMutation({ + mutationFn: ({ + appType, + enabled, + }: { + appType: string; + enabled: boolean; + }) => invoke("set_proxy_takeover_for_app", { appType, enabled }), + onSuccess: (_data, variables) => { + const appLabel = + variables.appType === "claude" + ? "Claude" + : variables.appType === "codex" + ? "Codex" + : "Gemini"; + + toast.success( + variables.enabled + ? t("proxy.takeover.enabled", { + defaultValue: `已接管 ${appLabel} 配置(请求将走本地代理)`, + }) + : t("proxy.takeover.disabled", { + defaultValue: `已恢复 ${appLabel} 配置`, + }), + { closeButton: true }, + ); + + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || "未知错误"; + toast.error( + t("proxy.takeover.failed", { + defaultValue: `操作失败: ${detail}`, + }), + ); + }, + }); + // 代理模式切换供应商(热切换) const switchProxyProviderMutation = useMutation({ mutationFn: ({ @@ -120,12 +161,20 @@ export function useProxyStatus() { status, isLoading, isRunning: status?.running || false, - isTakeoverActive: isTakeoverActive || false, + takeoverStatus, + isTakeoverActive: + takeoverStatus?.claude || + takeoverStatus?.codex || + takeoverStatus?.gemini || + false, - // 启动/停止(接管模式) - startWithTakeover: startWithTakeoverMutation.mutateAsync, + // 启动/停止(总开关) + startProxyServer: startProxyServerMutation.mutateAsync, stopWithRestore: stopWithRestoreMutation.mutateAsync, + // 按应用接管开关 + setTakeoverForApp: setTakeoverForAppMutation.mutateAsync, + // 代理模式下切换供应商 switchProxyProvider: switchProxyProviderMutation.mutateAsync, @@ -134,9 +183,11 @@ export function useProxyStatus() { checkTakeoverActive, // 加载状态 - isStarting: startWithTakeoverMutation.isPending, + isStarting: startProxyServerMutation.isPending, isStopping: stopWithRestoreMutation.isPending, isPending: - startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending, + startProxyServerMutation.isPending || + stopWithRestoreMutation.isPending || + setTakeoverForAppMutation.isPending, }; } diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 393e0e66..b4eec165 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -38,6 +38,12 @@ export interface ProxyServerInfo { started_at: string; } +export interface ProxyTakeoverStatus { + claude: boolean; + codex: boolean; + gemini: boolean; +} + export interface ProviderHealth { provider_id: string; app_type: string; diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 95937534..3d5461d5 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -258,6 +258,14 @@ export const handlers = [ }), ), + http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () => + success({ + claude: false, + codex: false, + gemini: false, + }), + ), + http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)), // Failover / circuit breaker defaults