feat(claude-desktop): add 3P provider switching with proxy gateway

Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.

Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
  ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
  presenting only claude-* route names to Claude Desktop and mapping
  them to real upstream models. Required after Anthropic restricted
  Claude Desktop to claude-family ids.

Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
  bypass, /claude-desktop/v1/{models,messages} routes, and a single
  source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
  missing routes, proxy stopped, base URL mismatch, missing token).

Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
  controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
  renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
  gateway without touching takeover state.
- Three-locale i18n synchronised.
This commit is contained in:
Jason
2026-05-08 11:50:01 +08:00
parent e15bfbfe7a
commit 8b3ad9caf9
64 changed files with 3328 additions and 109 deletions
+3
View File
@@ -121,6 +121,9 @@ impl ConfigService {
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::ClaudeDesktop => {
// Claude Desktop 3P profiles are managed by claude_desktop_config.
}
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
+7 -1
View File
@@ -112,6 +112,9 @@ impl McpService {
AppType::Claude => {
mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?;
}
AppType::ClaudeDesktop => {
log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping");
}
AppType::Codex => {
// Codex uses TOML format, must use the correct function
mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?;
@@ -154,6 +157,9 @@ impl McpService {
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::ClaudeDesktop => {
log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping");
}
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::OpenCode => {
@@ -175,7 +181,7 @@ impl McpService {
let servers = Self::get_all_servers(state)?;
for app in AppType::all() {
if matches!(app, AppType::OpenClaw) {
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
continue;
}
+36 -3
View File
@@ -349,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
}
_ => false,
},
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
}
}
@@ -419,7 +419,9 @@ pub(crate) fn remove_common_config_from_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
}
}
@@ -474,7 +476,9 @@ fn apply_common_config_to_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
}
}
@@ -513,6 +517,16 @@ pub(crate) fn write_live_with_common_config(
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
log::info!(
"Claude Desktop 3P profile '{}' written for provider '{}'",
crate::claude_desktop_config::PROFILE_ID,
effective_provider.id
);
return Ok(());
}
write_live_snapshot(app_type, &effective_provider)
}
@@ -699,6 +713,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&path, &settings)?;
}
AppType::ClaudeDesktop => {
return Err(AppError::localized(
"claude_desktop.live.requires_db_context",
"Claude Desktop 配置写入需要通过供应商切换流程执行",
"Claude Desktop configuration must be written through the provider switch flow",
));
}
AppType::Codex => {
let obj = provider
.settings_config
@@ -953,6 +974,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
}
read_json_file(&path)
}
AppType::ClaudeDesktop => Err(AppError::localized(
"claude_desktop.live.read_unsupported",
"Claude Desktop 3P 配置不支持作为通用 live 配置导入,请使用“从 Claude 导入兼容供应商”。",
"Claude Desktop 3P configuration cannot be imported as a generic live config. Use 'Import compatible providers from Claude' instead.",
)),
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
@@ -1078,6 +1104,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
let _ = normalize_claude_models_in_value(&mut v);
v
}
AppType::ClaudeDesktop => {
return Err(AppError::localized(
"claude_desktop.import_unsupported",
"Claude Desktop 3P 配置不能通过通用导入读取,请使用“从 Claude 导入兼容供应商”。",
"Claude Desktop 3P config cannot be imported through the generic import flow. Use 'Import compatible providers from Claude' instead.",
));
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
+14
View File
@@ -1397,6 +1397,10 @@ impl ProviderService {
return Self::switch_normal(state, app_type, id, &providers);
}
if matches!(app_type, AppType::ClaudeDesktop) {
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
@@ -1730,6 +1734,7 @@ impl ProviderService {
match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::ClaudeDesktop => Ok(String::new()),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
@@ -1745,6 +1750,7 @@ impl ProviderService {
) -> Result<String, AppError> {
match app_type {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::ClaudeDesktop => Ok(String::new()),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
@@ -2056,6 +2062,9 @@ impl ProviderService {
));
}
}
AppType::ClaudeDesktop => {
crate::claude_desktop_config::validate_provider(provider)?;
}
AppType::Codex => {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
@@ -2190,6 +2199,11 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::ClaudeDesktop => {
let credentials =
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
Ok((credentials.api_key, credentials.base_url))
}
AppType::Codex => {
let auth = provider
.settings_config
+10 -10
View File
@@ -466,7 +466,7 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_live()?,
AppType::Gemini => self.read_gemini_live()?,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features
return Err("该应用不支持代理功能".to_string());
}
@@ -683,7 +683,7 @@ impl ProxyService {
}
}
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features, skip silently
}
}
@@ -864,7 +864,7 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features
return Err("该应用不支持代理功能".to_string());
}
@@ -1008,7 +1008,7 @@ impl ProxyService {
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features
return Err("该应用不支持代理功能".to_string());
}
@@ -1061,7 +1061,7 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config);
}
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features, skip silently
}
}
@@ -1101,7 +1101,7 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复");
}
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features, skip silently
}
}
@@ -1192,7 +1192,7 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features
Err("该应用不支持代理功能".to_string())
}
@@ -1213,7 +1213,7 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy takeover
false
}
@@ -1256,7 +1256,7 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
// These apps don't support proxy features
Ok(())
}
@@ -1520,7 +1520,7 @@ impl ProxyService {
serde_json::to_string(&env_backup)
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
return Err(format!("未知的应用类型: {app_type}"));
}
};
+14
View File
@@ -509,6 +509,7 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::ClaudeDesktop => {}
AppType::Codex => {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return Ok(custom.join("skills"));
@@ -545,6 +546,7 @@ impl SkillService {
Ok(match app {
AppType::Claude => home.join(".claude").join("skills"),
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
@@ -1581,6 +1583,10 @@ impl SkillService {
/// - Symlink: 仅使用 symlink
/// - Copy: 仅使用文件复制
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let ssot_dir = Self::get_ssot_dir()?;
let source = ssot_dir.join(directory);
@@ -1686,6 +1692,10 @@ impl SkillService {
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let app_dir = Self::get_app_skills_dir(app)?;
let skill_path = app_dir.join(directory);
@@ -1699,6 +1709,10 @@ impl SkillService {
/// 同步所有已启用的 Skills 到指定应用
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
let app_dir = Self::get_app_skills_dir(app)?;
+10 -4
View File
@@ -215,7 +215,11 @@ impl StreamCheckService {
return Self::check_once_without_adapter(app_type, provider, config, start).await;
}
let adapter = get_adapter(app_type);
let adapter: Box<dyn ProviderAdapter> = if matches!(app_type, AppType::ClaudeDesktop) {
Box::new(ClaudeAdapter::new())
} else {
get_adapter(app_type)
};
let base_url = match base_url_override {
Some(base_url) => base_url,
@@ -236,7 +240,7 @@ impl StreamCheckService {
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::Claude => {
AppType::Claude | AppType::ClaudeDesktop => {
Self::check_claude_stream(
&client,
&base_url,
@@ -1361,8 +1365,10 @@ impl StreamCheckService {
config: &StreamCheckConfig,
) -> String {
match app_type {
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone()),
AppType::Claude | AppType::ClaudeDesktop => {
Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone())
}
AppType::Codex => {
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
}