use serde::Serialize; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; #[cfg(any(target_os = "macos", windows))] use crate::config::get_home_dir; use crate::config::{atomic_write, delete_file, read_json_file, write_json_file}; use crate::database::Database; use crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID; use crate::error::AppError; use crate::provider::{ClaudeDesktopMode, Provider}; pub const PROFILE_ID: &str = "00000000-0000-4000-8000-000000157210"; pub const PROFILE_NAME: &str = "CC Switch"; #[cfg(any(target_os = "macos", windows, test))] const CONFIG_FILE: &str = "claude_desktop_config.json"; #[cfg(any(target_os = "macos", windows, test))] const CONFIG_LIBRARY_DIR: &str = "configLibrary"; const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token"; const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop"; const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z"; const ONE_M_CONTEXT_SUFFIX: &str = " [1M]"; #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClaudeDesktopDefaultRoute { pub route_id: &'static str, pub env_key: &'static str, pub display_name: &'static str, #[serde(rename = "supports1m")] pub supports_1m: bool, } pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[ ClaudeDesktopDefaultRoute { route_id: "claude-sonnet-4-6", env_key: "ANTHROPIC_DEFAULT_SONNET_MODEL", display_name: "Sonnet", supports_1m: true, }, ClaudeDesktopDefaultRoute { route_id: "claude-opus-4-7", env_key: "ANTHROPIC_DEFAULT_OPUS_MODEL", display_name: "Opus", supports_1m: true, }, ClaudeDesktopDefaultRoute { route_id: "claude-haiku-4-5", env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", display_name: "Haiku", supports_1m: true, }, ]; #[derive(Debug, Clone)] struct ClaudeDesktopPaths { normal_config_path: PathBuf, threep_config_path: PathBuf, config_library_path: PathBuf, profile_path: PathBuf, meta_path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirectGatewayCredentials { pub base_url: String, pub api_key: String, } #[derive(Debug, Clone)] struct FileSnapshot { path: PathBuf, content: Option>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClaudeDesktopStatus { pub supported: bool, pub configured: bool, pub applied_id: Option, pub profile_path: Option, pub config_library_path: Option, pub mode: Option, pub expected_base_url: Option, pub actual_base_url: Option, pub proxy_running: bool, pub stale_raw_models: bool, pub missing_route_mappings: bool, pub gateway_token_configured: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedModelRoute { pub route_id: String, pub upstream_model: String, pub display_name: Option, pub supports_1m: bool, } pub fn apply_provider(db: &Database, provider: &Provider) -> Result<(), AppError> { let paths = current_platform_paths()?; apply_provider_to_paths(db, provider, &paths) } pub fn get_status(db: &Database, proxy_running: bool) -> Result { if !is_supported_platform() { return Ok(ClaudeDesktopStatus { supported: false, configured: false, applied_id: None, profile_path: None, config_library_path: None, mode: None, expected_base_url: None, actual_base_url: None, proxy_running, stale_raw_models: false, missing_route_mappings: false, gateway_token_configured: false, }); } let paths = current_platform_paths()?; let applied_id = read_applied_id(&paths.meta_path); let configured = paths.profile_path.exists() || meta_has_profile_entry(&paths.meta_path); let profile = read_json_or_empty(&paths.profile_path).unwrap_or_else(|_| json!({})); let actual_base_url = profile .get("inferenceGatewayBaseUrl") .and_then(Value::as_str) .map(str::to_string); let stale_raw_models = profile .get("inferenceModels") .and_then(Value::as_array) .map(|models| { models.iter().any(|item| { item.as_str() .or_else(|| item.get("name").and_then(Value::as_str)) .is_some_and(|model| !is_claude_safe_model_id(model)) }) }) .unwrap_or(false); let gateway_token_configured = db .get_setting(GATEWAY_TOKEN_SETTING_KEY) .ok() .flatten() .is_some_and(|token| !token.trim().is_empty()); let current_provider = crate::settings::get_effective_current_provider( db, &crate::app_config::AppType::ClaudeDesktop, ) .ok() .flatten() .and_then(|id| db.get_provider_by_id(&id, "claude-desktop").ok().flatten()); let mode = current_provider.as_ref().map(provider_mode); let expected_base_url = match mode { Some(ClaudeDesktopMode::Proxy) => proxy_gateway_base_url_from_db(db).ok(), Some(ClaudeDesktopMode::Direct) => current_provider .as_ref() .and_then(|provider| direct_gateway_credentials(provider).ok()) .map(|credentials| credentials.base_url), None => None, }; let missing_route_mappings = current_provider.as_ref().is_some_and(|provider| { matches!(provider_mode(provider), ClaudeDesktopMode::Proxy) && proxy_model_routes(provider).is_err() }); Ok(ClaudeDesktopStatus { supported: true, configured, applied_id, profile_path: Some(paths.profile_path.display().to_string()), config_library_path: Some(paths.config_library_path.display().to_string()), mode, expected_base_url, actual_base_url, proxy_running, stale_raw_models, missing_route_mappings, gateway_token_configured, }) } pub fn get_config_library_path() -> Result { Ok(current_platform_paths()?.config_library_path) } pub fn default_proxy_routes() -> Vec { DEFAULT_PROXY_ROUTES.to_vec() } pub fn is_compatible_direct_provider(provider: &Provider) -> bool { validate_direct_provider(provider).is_ok() } pub fn is_official_provider(provider: &Provider) -> bool { provider.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID } pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode { provider .meta .as_ref() .and_then(|meta| meta.claude_desktop_mode.clone()) .unwrap_or(ClaudeDesktopMode::Direct) } pub fn is_claude_safe_model_id(model: &str) -> bool { let normalized = strip_one_m_context_suffix(model).to_ascii_lowercase(); normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-") } fn strip_one_m_context_suffix(model: &str) -> String { let trimmed = model.trim(); let lower = trimmed.to_ascii_lowercase(); if lower.ends_with("[1m]") { trimmed[..trimmed.len().saturating_sub("[1M]".len())] .trim_end() .to_string() } else { trimmed.to_string() } } fn has_one_m_context_suffix(model: &str) -> bool { model.trim().to_ascii_lowercase().ends_with("[1m]") } fn desktop_model_id(model_id: &str, supports_1m: bool) -> String { let normalized = strip_one_m_context_suffix(model_id); if supports_1m { format!("{normalized}{ONE_M_CONTEXT_SUFFIX}") } else { normalized } } fn upstream_model_id(model_id: &str, supports_1m: bool) -> String { desktop_model_id(model_id, supports_1m) } pub fn get_or_create_gateway_token(db: &Database) -> Result { if let Some(token) = db.get_setting(GATEWAY_TOKEN_SETTING_KEY)? { let trimmed = token.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } } let token = format!("ccs-{}", uuid::Uuid::new_v4().simple()); db.set_setting(GATEWAY_TOKEN_SETTING_KEY, &token)?; Ok(token) } pub fn direct_gateway_credentials( provider: &Provider, ) -> Result { let env = provider .settings_config .get("env") .and_then(Value::as_object) .ok_or_else(|| { AppError::localized( "claude_desktop.provider.env_missing", "Claude Desktop 直连供应商缺少 env 配置", "Claude Desktop direct provider is missing env configuration", ) })?; let base_url = env .get("ANTHROPIC_BASE_URL") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::localized( "claude_desktop.provider.base_url_missing", "Claude Desktop 直连供应商缺少 ANTHROPIC_BASE_URL", "Claude Desktop direct provider is missing ANTHROPIC_BASE_URL", ) })? .to_string(); let api_key = env .get("ANTHROPIC_AUTH_TOKEN") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::localized( "claude_desktop.provider.auth_token_missing", "Claude Desktop 直连供应商缺少 ANTHROPIC_AUTH_TOKEN(Bearer Token)", "Claude Desktop direct provider is missing ANTHROPIC_AUTH_TOKEN (Bearer Token)", ) })? .to_string(); Ok(DirectGatewayCredentials { base_url, api_key }) } pub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> { if is_official_provider(provider) { return Ok(()); } if !provider.settings_config.is_object() { return Err(AppError::localized( "claude_desktop.provider.settings_not_object", "Claude Desktop 直连供应商配置必须是 JSON 对象", "Claude Desktop direct provider configuration must be a JSON object", )); } if let Some(meta) = provider.meta.as_ref() { if let Some(api_format) = meta.api_format.as_deref() { if !api_format.trim().is_empty() && api_format != "anthropic" { return Err(AppError::localized( "claude_desktop.provider.api_format_unsupported", "Claude Desktop 第一阶段只支持原生 Anthropic Messages API", "Claude Desktop phase 1 only supports native Anthropic Messages API", )); } } if matches!( meta.claude_desktop_mode.as_ref(), Some(ClaudeDesktopMode::Proxy) ) { return Err(AppError::localized( "claude_desktop.provider.mode_unsupported", "该供应商是 Claude Desktop 本地路由模式,不能按直连模式写入", "This Claude Desktop provider uses proxy mode and cannot be written as direct mode", )); } if matches!( meta.provider_type.as_deref(), Some("github_copilot") | Some("codex_oauth") ) { return Err(AppError::localized( "claude_desktop.provider.type_unsupported", "Claude Desktop 直连模式不支持需要本地代理转换的供应商", "Claude Desktop direct mode does not support providers that require local proxy conversion", )); } if meta.is_full_url == Some(true) { return Err(AppError::localized( "claude_desktop.provider.full_url_unsupported", "Claude Desktop 直连模式不支持完整 URL 端点配置", "Claude Desktop direct mode does not support full URL endpoint configuration", )); } } direct_inference_model_ids(provider)?; direct_gateway_credentials(provider)?; Ok(()) } pub fn validate_proxy_provider(provider: &Provider) -> Result<(), AppError> { if is_official_provider(provider) { return Ok(()); } if !provider.settings_config.is_object() { return Err(AppError::localized( "claude_desktop.provider.settings_not_object", "Claude Desktop 本地路由供应商配置必须是 JSON 对象", "Claude Desktop proxy provider configuration must be a JSON object", )); } if let Some(meta) = provider.meta.as_ref() { if matches!( meta.provider_type.as_deref(), Some("github_copilot") | Some("codex_oauth") ) { return Err(AppError::localized( "claude_desktop.provider.type_unsupported", "Claude Desktop 本地路由模式暂不支持 Copilot 或 Codex OAuth 供应商", "Claude Desktop proxy mode does not support Copilot or Codex OAuth providers yet", )); } if let Some(api_format) = meta.api_format.as_deref() { if !matches!( api_format, "" | "anthropic" | "openai_chat" | "openai_responses" | "gemini_native" ) { return Err(AppError::localized( "claude_desktop.provider.api_format_unsupported", format!("Claude Desktop 本地路由模式不支持 API 格式: {api_format}"), format!("Claude Desktop proxy mode does not support API format: {api_format}"), )); } } } proxy_model_routes(provider)?; if !has_proxy_base_url_and_key(provider) { return Err(AppError::localized( "claude_desktop.provider.credentials_missing", "Claude Desktop 本地路由供应商缺少 Base URL 或 API Key", "Claude Desktop proxy provider is missing Base URL or API key", )); } Ok(()) } fn has_proxy_base_url_and_key(provider: &Provider) -> bool { let env = provider.settings_config.get("env"); let has_base_url = env .and_then(|value| value.get("ANTHROPIC_BASE_URL")) .or_else(|| provider.settings_config.get("base_url")) .or_else(|| provider.settings_config.get("baseURL")) .or_else(|| provider.settings_config.get("apiEndpoint")) .and_then(Value::as_str) .map(str::trim) .is_some_and(|value| !value.is_empty()); let has_key = env .and_then(|value| { [ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", ] .into_iter() .find_map(|key| value.get(key)) }) .or_else(|| provider.settings_config.get("apiKey")) .or_else(|| provider.settings_config.get("api_key")) .and_then(Value::as_str) .map(str::trim) .is_some_and(|value| !value.is_empty()); has_base_url && has_key } pub fn validate_provider(provider: &Provider) -> Result<(), AppError> { if is_official_provider(provider) { return Ok(()); } match provider_mode(provider) { ClaudeDesktopMode::Direct => validate_direct_provider(provider), ClaudeDesktopMode::Proxy => validate_proxy_provider(provider), } } pub fn direct_inference_model_ids(provider: &Provider) -> Result, AppError> { let Some(routes) = provider .meta .as_ref() .map(|meta| &meta.claude_desktop_model_routes) else { return Ok(Vec::new()); }; let mut result = Vec::new(); for (route_id, route) in routes { let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id); let route_id = strip_one_m_context_suffix(route_id); if route_id.is_empty() { continue; } if !is_claude_safe_model_id(&route_id) { return Err(AppError::localized( "claude_desktop.provider.route_invalid", format!("Claude Desktop 直连模型必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"), format!("Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}"), )); } result.push(desktop_model_id(&route_id, supports_1m)); } result.sort(); result.dedup(); Ok(result) } pub fn proxy_model_routes(provider: &Provider) -> Result, AppError> { let routes = provider .meta .as_ref() .map(|meta| &meta.claude_desktop_model_routes) .ok_or_else(|| { AppError::localized( "claude_desktop.provider.routes_missing", "Claude Desktop 本地路由模式缺少模型路由映射", "Claude Desktop proxy mode is missing model route mappings", ) })?; let mut result = Vec::new(); for (route_id, route) in routes { let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id); let route_id = strip_one_m_context_suffix(route_id); let upstream_model = route.model.trim(); if route_id.is_empty() || upstream_model.is_empty() { continue; } if !is_claude_safe_model_id(&route_id) { return Err(AppError::localized( "claude_desktop.provider.route_invalid", format!("Claude Desktop 模型路由必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"), format!("Claude Desktop model route must use a claude-* or anthropic/claude-* name: {route_id}"), )); } result.push(ResolvedModelRoute { route_id: desktop_model_id(&route_id, supports_1m), upstream_model: upstream_model_id(upstream_model, supports_1m), display_name: route.display_name.clone(), supports_1m, }); } result.sort_by(|a, b| a.route_id.cmp(&b.route_id)); result.dedup_by(|a, b| a.route_id == b.route_id); if result.is_empty() { return Err(AppError::localized( "claude_desktop.provider.routes_missing", "Claude Desktop 本地路由模式至少需要一个模型路由映射", "Claude Desktop proxy mode requires at least one model route mapping", )); } Ok(result) } pub fn model_list_response(provider: &Provider) -> Result { let routes = proxy_model_routes(provider)?; let data: Vec = routes .iter() .map(|route| { let model_id = desktop_model_id(&route.route_id, route.supports_1m); let mut item = json!({ "type": "model", "id": model_id, "display_name": route.display_name.as_deref().unwrap_or(&route.route_id), "created_at": DEFAULT_CREATED_AT, }); if route.supports_1m { item["supports1m"] = json!(true); } item }) .collect(); let first_id = data .first() .and_then(|item| item.get("id")) .and_then(Value::as_str) .map(str::to_string); let last_id = data .last() .and_then(|item| item.get("id")) .and_then(Value::as_str) .map(str::to_string); Ok(json!({ "data": data, "has_more": false, "first_id": first_id, "last_id": last_id, })) } pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result { let requested = body .get("model") .and_then(Value::as_str) .map(str::trim) .map(str::to_string) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::localized( "claude_desktop.provider.model_missing", "Claude Desktop 请求缺少 model 字段", "Claude Desktop request is missing the model field", ) })?; let routes = proxy_model_routes(provider)?; let route = routes.iter().find(|r| r.route_id == requested).or_else(|| { let base = strip_one_m_context_suffix(&requested); routes .iter() .find(|r| strip_one_m_context_suffix(&r.route_id) == base) }); let Some(route) = route else { return Err(AppError::localized( "claude_desktop.provider.route_unknown", format!("Claude Desktop 模型路由未配置: {requested}"), format!("Claude Desktop model route is not configured: {requested}"), )); }; body["model"] = json!(route.upstream_model); Ok(body) } pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result { // get_proxy_config is async-tagged but its body is fully synchronous (rusqlite // under a Mutex), so block_on cannot deadlock the calling thread. let config = futures::executor::block_on(db.get_proxy_config())?; Ok(format!( "{}{}", proxy_origin_from_parts(&config.listen_address, config.listen_port), CLAUDE_DESKTOP_PROXY_PREFIX )) } fn apply_provider_to_paths( db: &Database, provider: &Provider, paths: &ClaudeDesktopPaths, ) -> Result<(), AppError> { if is_official_provider(provider) { return restore_official_at_paths(paths); } validate_provider(provider)?; with_rollback(paths, |paths| { apply_provider_to_paths_inner(db, provider, paths) }) } fn restore_official_at_paths(paths: &ClaudeDesktopPaths) -> Result<(), AppError> { with_rollback(paths, restore_official_at_paths_inner) } fn with_rollback(paths: &ClaudeDesktopPaths, op: F) -> Result<(), AppError> where F: FnOnce(&ClaudeDesktopPaths) -> Result<(), AppError>, { let snapshots = snapshot_files(paths)?; match op(paths) { Ok(()) => Ok(()), Err(err) => match restore_snapshots(&snapshots) { Ok(()) => Err(err), Err(rollback_err) => { log::error!("Failed to rollback Claude Desktop config after error: {rollback_err}"); Err(AppError::Message(format!( "{err}; rollback failed: {rollback_err}" ))) } }, } } fn apply_provider_to_paths_inner( db: &Database, provider: &Provider, paths: &ClaudeDesktopPaths, ) -> Result<(), AppError> { let profile = match provider_mode(provider) { ClaudeDesktopMode::Direct => { let credentials = direct_gateway_credentials(provider)?; let model_ids = direct_inference_model_ids(provider)?; build_gateway_profile( &credentials.base_url, &credentials.api_key, (!model_ids.is_empty()).then_some(model_ids.as_slice()), ) } ClaudeDesktopMode::Proxy => { let base_url = proxy_gateway_base_url_from_db(db)?; let api_key = get_or_create_gateway_token(db)?; let routes = proxy_model_routes(provider)?; let model_ids = routes .iter() .map(|route| desktop_model_id(&route.route_id, route.supports_1m)) .collect::>(); build_gateway_profile(&base_url, &api_key, Some(model_ids.as_slice())) } }; write_deployment_mode(&paths.normal_config_path, "3p")?; write_deployment_mode(&paths.threep_config_path, "3p")?; write_json_file(&paths.profile_path, &profile)?; write_meta(&paths.meta_path, Some(PROFILE_ID))?; Ok(()) } fn restore_official_at_paths_inner(paths: &ClaudeDesktopPaths) -> Result<(), AppError> { write_deployment_mode(&paths.normal_config_path, "1p")?; write_deployment_mode(&paths.threep_config_path, "1p")?; remove_cc_switch_enterprise_config(&paths.threep_config_path)?; if paths.profile_path.exists() { delete_file(&paths.profile_path)?; } write_meta(&paths.meta_path, None)?; Ok(()) } fn build_gateway_profile(base_url: &str, api_key: &str, model_ids: Option<&[String]>) -> Value { let mut profile = json!({ "disableDeploymentModeChooser": true, "inferenceGatewayApiKey": api_key, "inferenceGatewayAuthScheme": "bearer", "inferenceGatewayBaseUrl": base_url, "inferenceProvider": "gateway" }); if let Some(model_ids) = model_ids { profile["inferenceModels"] = Value::Array( model_ids .iter() .map(|model_id| Value::String(model_id.clone())) .collect(), ); } profile } fn read_json_or_empty(path: &Path) -> Result { let value = if path.exists() { read_json_file(path)? } else { json!({}) }; if value.is_object() { Ok(value) } else { Ok(json!({})) } } fn snapshot_files(paths: &ClaudeDesktopPaths) -> Result, AppError> { [ &paths.normal_config_path, &paths.threep_config_path, &paths.profile_path, &paths.meta_path, ] .into_iter() .map(|path| { let content = if path.exists() { Some(fs::read(path).map_err(|e| AppError::io(path, e))?) } else { None }; Ok(FileSnapshot { path: path.clone(), content, }) }) .collect() } fn restore_snapshots(snapshots: &[FileSnapshot]) -> Result<(), AppError> { for snapshot in snapshots { match &snapshot.content { Some(content) => { if let Some(parent) = snapshot.path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } atomic_write(&snapshot.path, content)?; } None => { delete_file(&snapshot.path)?; } } } Ok(()) } fn write_deployment_mode(path: &Path, mode: &str) -> Result<(), AppError> { let mut value = read_json_or_empty(path)?; if !value.is_object() { value = json!({}); } if let Some(obj) = value.as_object_mut() { obj.insert( "deploymentMode".to_string(), Value::String(mode.to_string()), ); } write_json_file(path, &value) } fn remove_cc_switch_enterprise_config(path: &Path) -> Result<(), AppError> { if !path.exists() { return Ok(()); } let mut value = read_json_or_empty(path)?; let Some(obj) = value.as_object_mut() else { return Ok(()); }; let Some(enterprise) = obj .get_mut("enterpriseConfig") .and_then(Value::as_object_mut) else { return Ok(()); }; for key in [ "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", "inferenceGatewayBaseUrl", "inferenceProvider", ] { enterprise.remove(key); } if enterprise.is_empty() { obj.remove("enterpriseConfig"); } write_json_file(path, &value) } fn write_meta(path: &Path, applied_profile_id: Option<&str>) -> Result<(), AppError> { let mut value = read_json_or_empty(path)?; if !value.is_object() { value = json!({}); } let obj = value.as_object_mut().expect("just normalized to object"); let mut entries = obj .get("entries") .and_then(Value::as_array) .cloned() .unwrap_or_default(); entries.retain(|entry| entry.get("id").and_then(Value::as_str) != Some(PROFILE_ID)); match applied_profile_id { Some(id) => { entries.push(json!({ "id": PROFILE_ID, "name": PROFILE_NAME })); obj.insert("appliedId".to_string(), Value::String(id.to_string())); } None => { let should_clear_applied = obj .get("appliedId") .and_then(Value::as_str) .is_some_and(|id| id == PROFILE_ID); if should_clear_applied { if let Some(next_id) = entries .iter() .find_map(|entry| entry.get("id").and_then(Value::as_str)) { obj.insert("appliedId".to_string(), Value::String(next_id.to_string())); } else { obj.remove("appliedId"); } } } } obj.insert("entries".to_string(), Value::Array(entries)); write_json_file(path, &value) } fn read_applied_id(path: &Path) -> Option { read_json_or_empty(path).ok().and_then(|value| { value .get("appliedId") .and_then(Value::as_str) .map(str::to_string) }) } fn meta_has_profile_entry(path: &Path) -> bool { read_json_or_empty(path) .ok() .and_then(|value| value.get("entries").and_then(Value::as_array).cloned()) .is_some_and(|entries| { entries .iter() .any(|entry| entry.get("id").and_then(Value::as_str) == Some(PROFILE_ID)) }) } fn is_supported_platform() -> bool { cfg!(any(target_os = "macos", windows)) } #[allow(clippy::needless_return)] fn current_platform_paths() -> Result { #[cfg(target_os = "macos")] { return Ok(macos_paths_from_home(&get_home_dir())); } #[cfg(windows)] { let local_app_data = windows_local_app_data_dir(); return Ok(windows_paths_from_local_app_data(&local_app_data)); } #[cfg(not(any(target_os = "macos", windows)))] { Err(unsupported_platform_error()) } } #[cfg(target_os = "macos")] fn macos_paths_from_home(home: &Path) -> ClaudeDesktopPaths { let app_support = home.join("Library").join("Application Support"); paths_from_dirs(app_support.join("Claude"), app_support.join("Claude-3p")) } #[cfg(windows)] fn windows_local_app_data_dir() -> PathBuf { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .unwrap_or_else(|| get_home_dir().join("AppData").join("Local")) } #[cfg(windows)] fn windows_paths_from_local_app_data(local_app_data: &Path) -> ClaudeDesktopPaths { let normal_dir = pick_windows_claude_dir(local_app_data, false) .unwrap_or_else(|| local_app_data.join("Claude")); let threep_dir = pick_windows_claude_dir(local_app_data, true) .unwrap_or_else(|| local_app_data.join("Claude-3p")); paths_from_dirs(normal_dir, threep_dir) } #[cfg(windows)] fn pick_windows_claude_dir(local_app_data: &Path, threep: bool) -> Option { let exact_name = if threep { "Claude-3p" } else { "Claude" }; let exact = local_app_data.join(exact_name); if exact.exists() { return Some(exact); } let mut candidates: Vec = std::fs::read_dir(local_app_data) .ok()? .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.is_dir()) .filter(|path| { let Some(name) = path.file_name().and_then(|value| value.to_str()) else { return false; }; let starts = name.starts_with("Claude"); let is_threep = name.contains("-3p"); starts && is_threep == threep }) .collect(); candidates.sort(); candidates.into_iter().next() } #[cfg(any(target_os = "macos", windows, test))] fn paths_from_dirs(normal_dir: PathBuf, threep_dir: PathBuf) -> ClaudeDesktopPaths { let config_library_path = threep_dir.join(CONFIG_LIBRARY_DIR); let profile_path = config_library_path.join(format!("{PROFILE_ID}.json")); let meta_path = config_library_path.join("_meta.json"); ClaudeDesktopPaths { normal_config_path: normal_dir.join(CONFIG_FILE), threep_config_path: threep_dir.join(CONFIG_FILE), config_library_path, profile_path, meta_path, } } fn proxy_origin_from_parts(listen_address: &str, listen_port: u16) -> String { let connect_host = match listen_address { "0.0.0.0" => "127.0.0.1", "::" => "::1", value => value, }; let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') { format!("[{connect_host}]") } else { connect_host.to_string() }; format!("http://{}:{}", connect_host_for_url, listen_port) } #[cfg(not(any(target_os = "macos", windows)))] fn unsupported_platform_error() -> AppError { AppError::localized( "claude_desktop.unsupported_platform", "当前平台暂不支持 Claude Desktop 3P 配置。第一阶段仅支持 macOS 和 Windows。", "Claude Desktop 3P configuration is not supported on this platform yet. Phase 1 only supports macOS and Windows.", ) } #[cfg(test)] mod tests { use super::*; use crate::database::Database; use crate::provider::{ClaudeDesktopModelRoute, ProviderMeta}; use serde_json::json; use tempfile::TempDir; fn test_paths(home: &Path) -> ClaudeDesktopPaths { paths_from_dirs( home.join("Library") .join("Application Support") .join("Claude"), home.join("Library") .join("Application Support") .join("Claude-3p"), ) } fn test_db() -> Database { Database::memory().expect("memory db") } fn direct_provider(id: &str) -> Provider { let mut provider = Provider::with_id( id.to_string(), "Direct".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://gateway.example.com", "ANTHROPIC_AUTH_TOKEN": "test-token", "ANTHROPIC_MODEL": "ignored-by-desktop" } }), Some("https://example.com".to_string()), ); provider.meta = Some(ProviderMeta { api_format: Some("anthropic".to_string()), ..Default::default() }); provider } fn official_provider() -> Provider { let mut provider = Provider::with_id( CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID.to_string(), "Claude Desktop Official".to_string(), json!({"env": {}}), Some("https://claude.ai/download".to_string()), ); provider.category = Some("official".to_string()); provider } fn proxy_provider(id: &str) -> Provider { let mut provider = direct_provider(id); provider.name = "Proxy".to_string(); provider.meta = Some(ProviderMeta { claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), api_format: Some("openai_chat".to_string()), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".to_string(), ClaudeDesktopModelRoute { model: "kimi-k2".to_string(), display_name: Some("Kimi".to_string()), supports_1m: Some(true), }, )]), ..Default::default() }); provider } fn direct_provider_with_models(id: &str) -> Provider { let mut provider = direct_provider(id); provider.meta = Some(ProviderMeta { claude_desktop_mode: Some(ClaudeDesktopMode::Direct), api_format: Some("anthropic".to_string()), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-deepseek-chat".to_string(), ClaudeDesktopModelRoute { model: "claude-deepseek-chat".to_string(), display_name: Some("DeepSeek".to_string()), supports_1m: Some(true), }, )]), ..Default::default() }); provider } #[test] fn claude_desktop_apply_writes_3p_profile_and_meta() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let provider = direct_provider("direct"); let db = test_db(); apply_provider_to_paths(&db, &provider, &paths).expect("apply provider"); let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); assert_eq!(normal["deploymentMode"], json!("3p")); assert_eq!(threep["deploymentMode"], json!("3p")); assert_eq!(profile["inferenceProvider"], json!("gateway")); assert_eq!( profile["inferenceGatewayBaseUrl"], json!("https://gateway.example.com") ); assert_eq!(profile["inferenceGatewayApiKey"], json!("test-token")); assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); assert_eq!(profile["disableDeploymentModeChooser"], json!(true)); assert!(profile.get("inferenceModels").is_none()); assert_eq!(meta["appliedId"], json!(PROFILE_ID)); assert!(meta["entries"] .as_array() .expect("entries") .iter() .any(|entry| entry["id"] == json!(PROFILE_ID) && entry["name"] == json!(PROFILE_NAME))); } #[test] fn claude_desktop_direct_can_write_optional_safe_model_ids() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let provider = direct_provider_with_models("direct-models"); let db = test_db(); apply_provider_to_paths(&db, &provider, &paths).expect("apply provider"); let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); assert_eq!( profile["inferenceGatewayBaseUrl"], json!("https://gateway.example.com") ); assert_eq!( profile["inferenceModels"], json!(["claude-deepseek-chat [1M]"]) ); } #[test] fn claude_desktop_proxy_apply_writes_local_gateway_profile_with_safe_models() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let provider = proxy_provider("proxy"); let db = test_db(); apply_provider_to_paths(&db, &provider, &paths).expect("apply proxy provider"); let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); assert_eq!( profile["inferenceGatewayBaseUrl"], json!("http://127.0.0.1:15721/claude-desktop") ); assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); assert_ne!(profile["inferenceGatewayApiKey"], json!("test-token")); assert!(profile["inferenceGatewayApiKey"] .as_str() .expect("gateway token") .starts_with("ccs-")); assert_eq!( profile["inferenceModels"], json!(["claude-sonnet-4-6 [1M]"]) ); assert!(!profile.to_string().contains("kimi-k2")); } #[test] fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() { let provider = proxy_provider("proxy"); let mapped = map_proxy_request_model( json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}), &provider, ) .expect("map route"); assert_eq!(mapped["model"], json!("kimi-k2 [1M]")); let models = model_list_response(&provider).expect("model list"); assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6 [1M]")); let err = map_proxy_request_model(json!({"model": "claude-opus-4-7"}), &provider) .expect_err("unknown route should fail"); assert!(err.to_string().contains("claude-opus-4-7")); } #[test] fn claude_desktop_proxy_maps_route_without_1m_suffix() { let provider = proxy_provider("proxy"); let mapped = map_proxy_request_model( json!({"model": "claude-sonnet-4-6", "messages": []}), &provider, ) .expect("base name should fallback-match the [1M] route"); assert_eq!(mapped["model"], json!("kimi-k2 [1M]")); } #[test] fn claude_desktop_one_m_suffix_normalization_is_case_and_space_tolerant() { assert!(is_claude_safe_model_id("claude-sonnet-4-6 [1m]")); assert!(is_claude_safe_model_id(" claude-sonnet-4-6 [1M] ")); assert_eq!( strip_one_m_context_suffix(" claude-sonnet-4-6 [1m] "), "claude-sonnet-4-6" ); assert_eq!( desktop_model_id(" claude-sonnet-4-6 [1m] ", true), "claude-sonnet-4-6 [1M]" ); } #[test] fn claude_desktop_apply_rolls_back_when_profile_write_fails() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let provider = direct_provider("direct"); let db = test_db(); write_json_file( &paths.normal_config_path, &json!({"deploymentMode": "1p", "normal": true}), ) .expect("write normal"); write_json_file( &paths.threep_config_path, &json!({"deploymentMode": "1p", "threep": true}), ) .expect("write 3p"); fs::write(&paths.config_library_path, "not a directory").expect("block profile parent"); apply_provider_to_paths(&db, &provider, &paths).expect_err("apply should fail"); let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); assert_eq!(normal, json!({"deploymentMode": "1p", "normal": true})); assert_eq!(threep, json!({"deploymentMode": "1p", "threep": true})); assert!(!paths.profile_path.exists()); } #[test] fn claude_desktop_write_meta_recovers_non_object_meta_file() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); if let Some(parent) = paths.meta_path.parent() { fs::create_dir_all(parent).expect("create parent"); } fs::write(&paths.meta_path, "[]").expect("write invalid meta shape"); write_meta(&paths.meta_path, Some(PROFILE_ID)).expect("write meta"); let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); assert_eq!(meta["appliedId"], json!(PROFILE_ID)); assert!(meta["entries"].as_array().is_some()); } #[test] fn claude_desktop_restore_switches_to_1p_and_removes_cc_switch_profile() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let provider = direct_provider("direct"); let db = test_db(); apply_provider_to_paths(&db, &provider, &paths).expect("apply provider"); restore_official_at_paths(&paths).expect("restore official"); let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); assert_eq!(normal["deploymentMode"], json!("1p")); assert_eq!(threep["deploymentMode"], json!("1p")); assert!(!paths.profile_path.exists()); assert!(meta.get("appliedId").is_none()); assert!(!meta["entries"] .as_array() .expect("entries") .iter() .any(|entry| entry["id"] == json!(PROFILE_ID))); } #[test] fn claude_desktop_official_provider_restores_1p_mode() { let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let direct = direct_provider("direct"); let db = test_db(); apply_provider_to_paths(&db, &direct, &paths).expect("apply direct provider"); apply_provider_to_paths(&db, &official_provider(), &paths) .expect("restore official provider"); let normal: Value = read_json_file(&paths.normal_config_path).expect("read normal config"); let threep: Value = read_json_file(&paths.threep_config_path).expect("read 3p config"); let meta: Value = read_json_file(&paths.meta_path).expect("read meta"); assert_eq!(normal["deploymentMode"], json!("1p")); assert_eq!(threep["deploymentMode"], json!("1p")); assert!(!paths.profile_path.exists()); assert!(meta.get("appliedId").is_none()); } #[test] fn claude_desktop_compatibility_filters_non_direct_providers() { let direct = direct_provider("direct"); assert!(is_compatible_direct_provider(&direct)); let claude_official = Provider::with_id( "claude-official".to_string(), "Claude Official".to_string(), json!({"env": {}}), Some("https://www.anthropic.com/claude-code".to_string()), ); assert!(!is_compatible_direct_provider(&claude_official)); let mut openai_format = direct_provider("openai"); openai_format.meta = Some(ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }); assert!(!is_compatible_direct_provider(&openai_format)); let mut copilot = direct_provider("copilot"); copilot.meta = Some(ProviderMeta { provider_type: Some("github_copilot".to_string()), ..Default::default() }); assert!(!is_compatible_direct_provider(&copilot)); let mut full_url = direct_provider("full_url"); full_url.meta = Some(ProviderMeta { is_full_url: Some(true), ..Default::default() }); assert!(!is_compatible_direct_provider(&full_url)); let missing_bearer = Provider::with_id( "x-api-key".to_string(), "x-api-key".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://gateway.example.com", "ANTHROPIC_API_KEY": "sk-ant" } }), None, ); assert!(!is_compatible_direct_provider(&missing_bearer)); } }