mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 11:01:22 +08:00
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:
@@ -121,6 +121,9 @@ impl ConfigService {
|
||||
match app_type {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::ClaudeDesktop => {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user