From 05ba28016c23f1556a2a369b819bc92b40474343 Mon Sep 17 00:00:00 2001 From: MelorTang Date: Tue, 26 May 2026 23:01:33 +0800 Subject: [PATCH] fix: sync Claude Desktop profile during proxy takeover (#3157) * fix: sync Claude Desktop profile during proxy takeover * fix(provider): skip Claude Desktop backup refresh during takeover - Route Claude Desktop takeover updates directly through the 3P profile writer. - Keep takeover startup backup state unchanged when provider metadata changes. - Narrow platform-specific test helpers and environment setup with cfg gates. * fix(provider): restore PathBuf import for CI tests - Restore an unconditional PathBuf import for provider tests. - Keep Linux cargo test builds compiling while preserving Claude Desktop cfg-gated helpers. --------- Co-authored-by: Jason --- src-tauri/src/services/provider/mod.rs | 175 ++++++++++++++++++++- src/config/claudeDesktopProviderPresets.ts | 16 ++ src/config/claudeProviderPresets.ts | 19 +++ 3 files changed, 204 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 0c6d61dc5..6978bb641 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -55,9 +55,13 @@ pub struct SwitchResult { #[cfg(test)] mod tests { use super::*; + #[cfg(any(target_os = "macos", windows))] + use crate::claude_desktop_config::PROFILE_ID; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::provider::ProviderMeta; + #[cfg(any(target_os = "macos", windows))] + use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute}; use crate::proxy::types::ProxyConfig; use crate::store::AppState; use serde_json::json; @@ -72,6 +76,8 @@ mod tests { #[allow(dead_code)] dir: TempDir, original_home: Option, + #[cfg(windows)] + original_local_app_data: Option, original_userprofile: Option, original_test_home: Option, } @@ -80,16 +86,22 @@ mod tests { fn new() -> Self { let dir = TempDir::new().expect("failed to create temp home"); let original_home = env::var("HOME").ok(); + #[cfg(windows)] + let original_local_app_data = env::var("LOCALAPPDATA").ok(); let original_userprofile = env::var("USERPROFILE").ok(); let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok(); env::set_var("HOME", dir.path()); + #[cfg(windows)] + env::set_var("LOCALAPPDATA", dir.path().join("AppData").join("Local")); env::set_var("USERPROFILE", dir.path()); env::set_var("CC_SWITCH_TEST_HOME", dir.path()); Self { dir, original_home, + #[cfg(windows)] + original_local_app_data, original_userprofile, original_test_home, } @@ -103,6 +115,14 @@ mod tests { None => env::remove_var("HOME"), } + #[cfg(windows)] + { + match &self.original_local_app_data { + Some(value) => env::set_var("LOCALAPPDATA", value), + None => env::remove_var("LOCALAPPDATA"), + } + } + match &self.original_userprofile { Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), @@ -115,6 +135,24 @@ mod tests { } } + #[cfg(windows)] + fn claude_desktop_profile_path(home: &Path) -> PathBuf { + home.join("AppData") + .join("Local") + .join("Claude-3p") + .join("configLibrary") + .join(format!("{PROFILE_ID}.json")) + } + + #[cfg(target_os = "macos")] + fn claude_desktop_profile_path(home: &Path) -> PathBuf { + home.join("Library") + .join("Application Support") + .join("Claude-3p") + .join("configLibrary") + .join(format!("{PROFILE_ID}.json")) + } + fn test_guard() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -475,6 +513,122 @@ base_url = "http://localhost:8080" ); } + #[cfg(any(target_os = "macos", windows))] + #[tokio::test] + #[serial] + async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() { + let home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = AppState::new(db.clone()); + + let mut original = Provider::with_id( + "p1".into(), + "Desktop A".into(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token-a", + "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go" + } + }), + None, + ); + original.meta = Some(ProviderMeta { + api_format: Some("openai_chat".into()), + claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), + claude_desktop_model_routes: std::collections::HashMap::from([( + "claude-sonnet-4-6".into(), + ClaudeDesktopModelRoute { + model: "deepseek-v4-flash".into(), + label_override: Some("DeepSeek V4 Flash".into()), + supports_1m: None, + }, + )]), + ..Default::default() + }); + db.save_provider("claude-desktop", &original) + .expect("save provider"); + db.set_current_provider("claude-desktop", "p1") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some("p1")) + .expect("set local current provider"); + + // Claude Desktop keeps backup state from takeover startup; this sentinel only + // marks takeover as active so provider updates rewrite the 3P profile. + db.save_live_backup("claude-desktop", "{}") + .await + .expect("seed live backup"); + { + let mut config = db + .get_proxy_config_for_app("claude-desktop") + .await + .expect("get app proxy config"); + config.enabled = true; + db.update_proxy_config_for_app(config) + .await + .expect("update app proxy config"); + } + + state + .proxy_service + .start() + .await + .expect("start proxy service"); + + let mut updated = Provider::with_id( + "p1".into(), + "Desktop A".into(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token-updated", + "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go" + } + }), + None, + ); + updated.meta = Some(ProviderMeta { + api_format: Some("openai_chat".into()), + claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), + claude_desktop_model_routes: std::collections::HashMap::from([( + "claude-sonnet-4-6".into(), + ClaudeDesktopModelRoute { + model: "deepseek-v4-flash".into(), + label_override: Some("DeepSeek V4 Flash Updated".into()), + supports_1m: Some(true), + }, + )]), + ..Default::default() + }); + + ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone()) + .expect("update current provider"); + + let backup = db + .get_live_backup("claude-desktop") + .await + .expect("get live backup") + .expect("backup exists"); + assert_eq!( + backup.original_config, "{}", + "Claude Desktop provider edits should not rewrite takeover backup" + ); + + let profile_path = claude_desktop_profile_path(home.dir.path()); + let profile: Value = read_json_file(&profile_path).expect("read desktop profile"); + assert_eq!( + profile["inferenceGatewayBaseUrl"], + json!("http://127.0.0.1:15721/claude-desktop"), + "desktop profile should stay pointed at the local gateway during takeover" + ); + assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); + assert_eq!( + profile["inferenceModels"], + json!([{ "name": "claude-sonnet-4-6", "labelOverride": "DeepSeek V4 Flash Updated", "supports1m": true }]), + "provider edits should propagate into the Claude Desktop 3P profile during takeover" + ); + } + #[test] #[serial] fn rename_rejects_missing_original_provider() { @@ -1244,12 +1398,16 @@ impl ProviderService { let should_sync_via_proxy = is_proxy_running && (has_live_backup || live_taken_over); if should_sync_via_proxy { - futures::executor::block_on( - state - .proxy_service - .update_live_backup_from_provider(app_type.as_str(), &provider), - ) - .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + if matches!(app_type, AppType::ClaudeDesktop) { + write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; + } else { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(app_type.as_str(), &provider), + ) + .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + } if matches!(app_type, AppType::Claude) { futures::executor::block_on( @@ -1658,6 +1816,11 @@ impl ProviderService { .detect_takeover_in_live_config_for_app(&app_type); if takeover_enabled && (has_live_backup || live_taken_over) { + if matches!(app_type, AppType::ClaudeDesktop) { + write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; + return Ok(()); + } + futures::executor::block_on( state .proxy_service diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index d7558d30e..38e235ff5 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -299,6 +299,22 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ icon: "deepseek", iconColor: "#1E88E5", }, + { + name: "OpenCode Go (DeepSeek V4 Flash)", + websiteUrl: "https://opencode.ai", + category: "third_party", + baseUrl: "https://opencode.ai/zen/go", + mode: "proxy", + apiFormat: "openai_chat", + modelRoutes: brandedRoutes( + "deepseek-v4-flash", + "deepseek-v4-flash", + "deepseek-v4-flash", + ), + endpointCandidates: ["https://opencode.ai/zen/go"], + icon: "opencode", + iconColor: "#211E1E", + }, { name: "Zhipu GLM", websiteUrl: "https://open.bigmodel.cn", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index dddbc67f8..f2bcbf73e 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -229,6 +229,25 @@ export const providerPresets: ProviderPreset[] = [ icon: "deepseek", iconColor: "#1E88E5", }, + { + name: "OpenCode Go (DeepSeek V4 Flash)", + websiteUrl: "https://opencode.ai", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://opencode.ai/zen/go", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "deepseek-v4-flash", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek-v4-flash", + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-v4-flash", + ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek-v4-flash", + }, + }, + category: "third_party", + apiFormat: "openai_chat", + endpointCandidates: ["https://opencode.ai/zen/go"], + icon: "opencode", + iconColor: "#211E1E", + }, { name: "Zhipu GLM", websiteUrl: "https://open.bigmodel.cn",