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 <farion1231@gmail.com>
This commit is contained in:
MelorTang
2026-05-26 23:01:33 +08:00
committed by GitHub
parent 707a5593e5
commit 05ba28016c
3 changed files with 204 additions and 6 deletions
+169 -6
View File
@@ -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<String>,
#[cfg(windows)]
original_local_app_data: Option<String>,
original_userprofile: Option<String>,
original_test_home: Option<String>,
}
@@ -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<Mutex<()>> = 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
@@ -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",
+19
View File
@@ -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",