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 MIMO_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]"; const MIMO_TOOL_CALL_THINKING_PLACEHOLDER: &str = "tool call"; /// Claude Desktop 模型菜单识别的 route ID 前缀。 pub const CLAUDE_ROUTE_PREFIX: &str = "claude-"; /// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。 pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-"; /// Claude Code env 中通过 `[1M]` 后缀声明 1M 上下文能力(匹配用 `eq_ignore_ascii_case`)。 /// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。 pub const ONE_M_CONTEXT_MARKER: &str = "[1m]"; const NON_ANTHROPIC_ROUTE_MARKERS: &[&str] = &[ "ark-code", "astron", "command-r", "deepseek", "doubao", "gemini", "gemma", "glm", "gpt", "grok", "hermes", "hy3", "kimi", "lfm", "llama", "longcat", "mimo", "minimax", "mistral", "mixtral", "moonshot", "nemotron", "openai", "qianfan", "qwen", "stepfun", "seed-", "hunyuan", "nova-", "ernie", "codex", "abab", "jamba", "arctic", "solar", "mercury", ]; #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClaudeDesktopDefaultRoute { pub route_id: &'static str, pub env_key: &'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", supports_1m: true, }, ClaudeDesktopDefaultRoute { route_id: "claude-opus-4-7", env_key: "ANTHROPIC_DEFAULT_OPUS_MODEL", supports_1m: true, }, ClaudeDesktopDefaultRoute { route_id: "claude-haiku-4-5", env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", 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 label_override: Option, pub supports_1m: bool, } #[derive(Debug, Clone, PartialEq, Eq)] struct InferenceModelSpec { name: String, label_override: Option, 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 = model.trim().to_ascii_lowercase(); let has_allowed_shape = (normalized.starts_with(CLAUDE_ROUTE_PREFIX) && normalized.len() > CLAUDE_ROUTE_PREFIX.len()) || (normalized.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX) && normalized.len() > ANTHROPIC_CLAUDE_ROUTE_PREFIX.len()) || matches!(normalized.as_str(), "sonnet" | "opus" | "haiku") || (normalized.starts_with("sonnet-") && normalized.len() > "sonnet-".len()) || (normalized.starts_with("opus-") && normalized.len() > "opus-".len()) || (normalized.starts_with("haiku-") && normalized.len() > "haiku-".len()); has_allowed_shape && !normalized.contains(ONE_M_CONTEXT_MARKER) && !NON_ANTHROPIC_ROUTE_MARKERS .iter() .any(|marker| normalized.contains(marker)) } fn inference_model_json(spec: &InferenceModelSpec) -> Value { if spec.supports_1m || spec.label_override.is_some() { let mut item = json!({ "name": spec.name }); if let Some(label_override) = spec.label_override.as_deref() { item["labelOverride"] = json!(label_override); } if spec.supports_1m { item["supports1m"] = json!(true); } item } else { Value::String(spec.name.clone()) } } 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_specs(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 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()); if is_managed_oauth_proxy_provider(provider) { return has_base_url; } 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 } fn is_managed_oauth_proxy_provider(provider: &Provider) -> bool { provider .meta .as_ref() .and_then(|meta| meta.provider_type.as_deref()) .is_some_and(|provider_type| matches!(provider_type, "github_copilot" | "codex_oauth")) } 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), } } fn direct_inference_model_specs(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); let route_id = route_id.trim(); 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}" ), )); } let upstream_model = route.model.trim(); if !upstream_model.is_empty() && upstream_model != route_id { return Err(AppError::localized( "claude_desktop.provider.direct_mapping_unsupported", format!( "Claude Desktop 直连模式不能映射模型: {route_id} -> {upstream_model};非 Claude 官方模型请使用本地路由模式" ), format!( "Claude Desktop direct mode cannot map models: {route_id} -> {upstream_model}; use proxy mode for non-Claude official models" ), )); } result.push(InferenceModelSpec { name: route_id.to_string(), label_override: route .label_override .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), supports_1m, }); } // Sort supports_1m=true first within each name so the subsequent dedup_by // (which keeps the first occurrence) preserves the 1M-capable variant. result.sort_by(|a, b| { a.name .cmp(&b.name) .then_with(|| b.supports_1m.cmp(&a.supports_1m)) }); result.dedup_by(|a, b| a.name == b.name); 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 reserved_route_ids = routes .keys() .map(|route_id| route_id.trim()) .filter(|route_id| is_claude_safe_model_id(route_id)) .map(str::to_string) .collect::>(); let mut result = Vec::new(); let mut entries = routes.iter().collect::>(); entries.sort_by_key(|(left, _)| *left); for (route_id, route) in entries { let supports_1m = route.supports_1m.unwrap_or(false); let route_id = route_id.trim(); let upstream_model = route.model.trim(); if route_id.is_empty() || upstream_model.is_empty() { continue; } let repaired_route_id = if is_claude_safe_model_id(route_id) { route_id.to_string() } else { next_catalog_safe_route_id(&result, &reserved_route_ids) }; result.push(ResolvedModelRoute { route_id: repaired_route_id, upstream_model: upstream_model.to_string(), label_override: route .label_override .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| { (!is_claude_safe_model_id(route_id)).then(|| upstream_model.to_string()) }), 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) } fn next_catalog_safe_route_id( existing: &[ResolvedModelRoute], reserved: &std::collections::HashSet, ) -> String { if let Some(default_route) = DEFAULT_PROXY_ROUTES .iter() .map(|route| route.route_id) .find(|route_id| { !reserved.contains(*route_id) && !existing.iter().any(|route| route.route_id == *route_id) }) { return default_route.to_string(); } let mut index = 2usize; loop { let route_id = format!("{}-r{index}", DEFAULT_PROXY_ROUTES[0].route_id); if !reserved.contains(&route_id) && !existing.iter().any(|route| route.route_id == route_id) { return route_id; } index += 1; } } pub fn model_list_response(provider: &Provider) -> Result { let routes = proxy_model_routes(provider)?; let data: Vec = routes .iter() .map(|route| { let model_id = route.route_id.clone(); let mut item = json!({ "type": "model", "id": model_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); 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); if should_normalize_mimo_anthropic_thinking_history(provider, &route.upstream_model) { normalize_mimo_anthropic_thinking_history(&mut body); } Ok(body) } fn should_normalize_mimo_anthropic_thinking_history( provider: &Provider, upstream_model: &str, ) -> bool { if !provider_uses_anthropic_messages_format(provider) { return false; } is_mimo_identifier(upstream_model) || provider_has_mimo_endpoint(provider) } fn provider_uses_anthropic_messages_format(provider: &Provider) -> bool { let api_format = provider .meta .as_ref() .and_then(|meta| meta.api_format.as_deref()) .or_else(|| { provider .settings_config .get("api_format") .and_then(Value::as_str) }) .map(str::trim) .unwrap_or("anthropic"); api_format.is_empty() || api_format == "anthropic" } fn provider_has_mimo_endpoint(provider: &Provider) -> bool { let settings = &provider.settings_config; [ settings .get("env") .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(Value::as_str), settings.get("base_url").and_then(Value::as_str), settings.get("baseURL").and_then(Value::as_str), settings.get("apiEndpoint").and_then(Value::as_str), ] .into_iter() .flatten() .any(is_mimo_identifier) } fn is_mimo_identifier(value: &str) -> bool { let value = value.to_ascii_lowercase(); value.contains("mimo") || value.contains("xiaomimimo") } fn normalize_mimo_anthropic_thinking_history(body: &mut Value) { let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return; }; for message in messages { if message.get("role").and_then(Value::as_str) != Some("assistant") { continue; } let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else { continue; }; if !content .iter() .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) { continue; } let mut has_thinking = false; for block in content.iter_mut() { match block.get("type").and_then(Value::as_str) { Some("thinking") => { let has_non_empty_thinking = block .get("thinking") .and_then(Value::as_str) .is_some_and(|value| !value.trim().is_empty()); if let Some(obj) = block.as_object_mut() { obj.remove("signature"); } if has_non_empty_thinking { has_thinking = true; } else if let Some(obj) = block.as_object_mut() { obj.insert( "thinking".to_string(), json!(MIMO_TOOL_CALL_THINKING_PLACEHOLDER), ); has_thinking = true; } } Some("redacted_thinking") => { *block = json!({ "type": "thinking", "thinking": MIMO_REDACTED_THINKING_PLACEHOLDER }); has_thinking = true; } _ => {} } } if !has_thinking { content.insert( 0, json!({ "type": "thinking", "thinking": MIMO_TOOL_CALL_THINKING_PLACEHOLDER }), ); } } } 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_specs = direct_inference_model_specs(provider)?; build_gateway_profile( &credentials.base_url, &credentials.api_key, (!model_specs.is_empty()).then_some(model_specs.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_specs = routes .iter() .map(|route| InferenceModelSpec { name: route.route_id.clone(), label_override: route.label_override.clone(), supports_1m: route.supports_1m, }) .collect::>(); build_gateway_profile(&base_url, &api_key, Some(model_specs.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_specs: Option<&[InferenceModelSpec]>, ) -> Value { let mut profile = json!({ "coworkEgressAllowedHosts": ["*"], "disableDeploymentModeChooser": true, "inferenceGatewayApiKey": api_key, "inferenceGatewayAuthScheme": "bearer", "inferenceGatewayBaseUrl": base_url, "inferenceProvider": "gateway" }); if let Some(model_specs) = model_specs { profile["inferenceModels"] = Value::Array(model_specs.iter().map(inference_model_json).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(), label_override: Some("Kimi K2".to_string()), supports_1m: Some(true), }, )]), ..Default::default() }); provider } fn mimo_anthropic_proxy_provider(id: &str) -> Provider { let mut provider = direct_provider(id); provider.name = "MiMo Proxy".to_string(); provider.settings_config = json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.xiaomimimo.com/anthropic", "ANTHROPIC_AUTH_TOKEN": "test-token" } }); provider.meta = Some(ProviderMeta { claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), api_format: Some("anthropic".to_string()), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".to_string(), ClaudeDesktopModelRoute { model: "mimo-v2.5-pro".to_string(), label_override: Some("MiMo v2.5 Pro".to_string()), supports_1m: Some(true), }, )]), ..Default::default() }); provider } fn oauth_proxy_provider(id: &str, provider_type: &str, api_format: &str) -> Provider { let mut provider = Provider::with_id( id.to_string(), "OAuth Proxy".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://oauth-upstream.example.com" } }), Some("https://example.com".to_string()), ); provider.meta = Some(ProviderMeta { claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), api_format: Some(api_format.to_string()), provider_type: Some(provider_type.to_string()), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".to_string(), ClaudeDesktopModelRoute { model: "gpt-5.4".to_string(), label_override: Some("GPT-5.4".to_string()), supports_1m: Some(false), }, )]), ..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-sonnet-4-6".to_string(), ClaudeDesktopModelRoute { model: "claude-sonnet-4-6".to_string(), label_override: None, 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_eq!(profile["coworkEgressAllowedHosts"], json!(["*"])); 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!([{ "name": "claude-sonnet-4-6", "supports1m": true }]) ); } #[test] fn claude_desktop_direct_rejects_model_mapping_to_non_claude_upstream() { let mut provider = direct_provider_with_models("direct-non-claude"); provider .meta .as_mut() .expect("meta") .claude_desktop_model_routes .get_mut("claude-sonnet-4-6") .expect("route") .model = "mimo-v2.5-pro".to_string(); let err = validate_provider(&provider).expect_err("direct mapping should fail"); assert!(err.to_string().contains("本地路由模式")); } #[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_eq!(profile["coworkEgressAllowedHosts"], json!(["*"])); assert_ne!(profile["inferenceGatewayApiKey"], json!("test-token")); assert!(profile["inferenceGatewayApiKey"] .as_str() .expect("gateway token") .starts_with("ccs-")); assert_eq!( profile["inferenceModels"], json!([{ "name": "claude-sonnet-4-6", "labelOverride": "Kimi K2", "supports1m": true }]) ); assert!(!profile.to_string().contains("kimi-k2")); } #[test] fn claude_desktop_proxy_accepts_managed_oauth_providers_without_static_key() { for (provider_type, api_format) in [ ("github_copilot", "openai_chat"), ("codex_oauth", "openai_responses"), ] { let provider = oauth_proxy_provider(provider_type, provider_type, api_format); validate_proxy_provider(&provider).expect("oauth proxy provider should validate"); let temp = TempDir::new().expect("tempdir"); let paths = test_paths(temp.path()); let db = test_db(); apply_provider_to_paths(&db, &provider, &paths).expect("apply oauth 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["inferenceModels"], json!([{ "name": "claude-sonnet-4-6", "labelOverride": "GPT-5.4" }]) ); } } #[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", "messages": []}), &provider, ) .expect("map route"); assert_eq!(mapped["model"], json!("kimi-k2")); let models = model_list_response(&provider).expect("model list"); assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6")); assert_eq!(models["data"][0]["supports1m"], json!(true)); 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_mimo_anthropic_rewrites_redacted_thinking_for_tool_history() { let provider = mimo_anthropic_proxy_provider("mimo"); let mapped = map_proxy_request_model( json!({ "model": "claude-sonnet-4-6", "messages": [{ "role": "assistant", "content": [ {"type": "redacted_thinking", "data": "opaque"}, {"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "README.md"}} ] }] }), &provider, ) .expect("map MiMo route"); assert_eq!(mapped["model"], json!("mimo-v2.5-pro")); assert_eq!( mapped["messages"][0]["content"][0]["type"], json!("thinking") ); assert_eq!( mapped["messages"][0]["content"][0]["thinking"], json!("[redacted thinking]") ); assert_eq!( mapped["messages"][0]["content"][1]["type"], json!("tool_use") ); } #[test] fn claude_desktop_mimo_anthropic_injects_thinking_for_tool_history_without_one() { let provider = mimo_anthropic_proxy_provider("mimo"); let mapped = map_proxy_request_model( json!({ "model": "claude-sonnet-4-6", "messages": [{ "role": "assistant", "content": [ {"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "README.md"}} ] }] }), &provider, ) .expect("map MiMo route"); assert_eq!( mapped["messages"][0]["content"][0]["type"], json!("thinking") ); assert_eq!( mapped["messages"][0]["content"][0]["thinking"], json!("tool call") ); assert_eq!( mapped["messages"][0]["content"][1]["type"], json!("tool_use") ); } #[test] fn claude_desktop_mimo_anthropic_keeps_thinking_text_but_drops_signature() { let provider = mimo_anthropic_proxy_provider("mimo"); let mapped = map_proxy_request_model( json!({ "model": "claude-sonnet-4-6", "messages": [{ "role": "assistant", "content": [ {"type": "thinking", "thinking": "Need to inspect the file.", "signature": "anthropic-signature"}, {"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "README.md"}} ] }] }), &provider, ) .expect("map MiMo route"); assert_eq!( mapped["messages"][0]["content"][0]["thinking"], json!("Need to inspect the file.") ); assert!(mapped["messages"][0]["content"][0] .get("signature") .is_none()); } #[test] fn claude_desktop_proxy_repairs_legacy_unsafe_route_without_colliding() { let mut provider = proxy_provider("proxy"); 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-deepseek-v4-pro".to_string(), ClaudeDesktopModelRoute { model: "deepseek-v4-pro".to_string(), label_override: None, supports_1m: Some(true), }, ), ( "claude-sonnet-4-6".to_string(), ClaudeDesktopModelRoute { model: "claude-sonnet-4-6".to_string(), label_override: None, supports_1m: Some(false), }, ), ]), ..Default::default() }); let routes = proxy_model_routes(&provider).expect("routes"); assert_eq!(routes.len(), 2); let repaired = routes .iter() .find(|route| route.upstream_model == "deepseek-v4-pro") .expect("repaired route"); assert_eq!(repaired.route_id, "claude-opus-4-7"); assert_eq!(repaired.label_override.as_deref(), Some("deepseek-v4-pro")); assert!(repaired.supports_1m); let mapped = map_proxy_request_model( json!({"model": "claude-opus-4-7", "messages": []}), &provider, ) .expect("map repaired route"); assert_eq!(mapped["model"], json!("deepseek-v4-pro")); } #[test] fn claude_desktop_proxy_rejects_1m_suffix_route() { let provider = proxy_provider("proxy"); let err = map_proxy_request_model( json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}), &provider, ) .expect_err("1M suffix route should not be accepted"); assert!(err.to_string().contains("claude-sonnet-4-6 [1M]")); } #[test] fn claude_desktop_rejects_1m_suffix_as_model_id() { assert!(!is_claude_safe_model_id("claude-sonnet-4-6 [1m]")); assert!(!is_claude_safe_model_id(" claude-sonnet-4-6 [1M] ")); assert!(!is_claude_safe_model_id("claude-deepseek-v4-pro")); assert!(!is_claude_safe_model_id("claude-gpt-5-4")); assert!(!is_claude_safe_model_id("claude-")); assert!(!is_claude_safe_model_id("anthropic/claude-")); assert!(!is_claude_safe_model_id("sonnet-")); assert!(is_claude_safe_model_id(" claude-sonnet-4-6 ")); } #[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)); } }