mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 19:22:15 +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:
@@ -29,6 +29,7 @@ impl McpApps {
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ impl McpApps {
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ impl SkillApps {
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +111,7 @@ impl SkillApps {
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +259,14 @@ pub struct McpRoot {
|
||||
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub claude: McpConfig,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claudeDesktop",
|
||||
alias = "claude_desktop",
|
||||
default,
|
||||
skip_serializing_if = "McpConfig::is_empty"
|
||||
)]
|
||||
pub claude_desktop: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
@@ -277,6 +289,7 @@ impl Default for McpRoot {
|
||||
servers: Some(HashMap::new()),
|
||||
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
||||
claude: McpConfig::default(),
|
||||
claude_desktop: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
@@ -298,6 +311,13 @@ pub struct PromptConfig {
|
||||
pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub claude: PromptConfig,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claudeDesktop",
|
||||
alias = "claude_desktop",
|
||||
default
|
||||
)]
|
||||
pub claude_desktop: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub codex: PromptConfig,
|
||||
#[serde(default)]
|
||||
@@ -320,6 +340,12 @@ use crate::provider::ProviderManager;
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claude_desktop",
|
||||
alias = "claudeDesktop"
|
||||
)]
|
||||
ClaudeDesktop,
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode,
|
||||
@@ -331,6 +357,7 @@ impl AppType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::ClaudeDesktop => "claude-desktop",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
@@ -354,6 +381,7 @@ impl AppType {
|
||||
pub fn all() -> impl Iterator<Item = AppType> {
|
||||
[
|
||||
AppType::Claude,
|
||||
AppType::ClaudeDesktop,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
@@ -371,6 +399,7 @@ impl FromStr for AppType {
|
||||
let normalized = s.trim().to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
@@ -378,8 +407,8 @@ impl FromStr for AppType {
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -412,6 +441,7 @@ impl CommonConfigSnippets {
|
||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||
match app {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::ClaudeDesktop => None,
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
@@ -424,6 +454,7 @@ impl CommonConfigSnippets {
|
||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::ClaudeDesktop => {}
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
@@ -466,6 +497,7 @@ impl Default for MultiAppConfig {
|
||||
fn default() -> Self {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("claude-desktop".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
@@ -627,6 +659,7 @@ impl MultiAppConfig {
|
||||
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
||||
match app {
|
||||
AppType::Claude => &self.mcp.claude,
|
||||
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
@@ -639,6 +672,7 @@ impl MultiAppConfig {
|
||||
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
||||
match app {
|
||||
AppType::Claude => &mut self.mcp.claude,
|
||||
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
@@ -677,6 +711,7 @@ impl MultiAppConfig {
|
||||
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
||||
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
||||
if !self.prompts.claude.prompts.is_empty()
|
||||
|| !self.prompts.claude_desktop.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
@@ -763,6 +798,7 @@ impl MultiAppConfig {
|
||||
// 插入到对应的应用配置中
|
||||
let prompts = match app {
|
||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
@@ -804,6 +840,7 @@ impl MultiAppConfig {
|
||||
] {
|
||||
let old_servers = match app {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
@@ -924,6 +961,23 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn app_type_parses_claude_desktop_aliases() {
|
||||
assert_eq!(
|
||||
"claude-desktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(
|
||||
"claude_desktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(
|
||||
"claudeDesktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop");
|
||||
}
|
||||
|
||||
struct TempHome {
|
||||
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||||
dir: TempDir,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
@@ -62,9 +63,23 @@ fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), S
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
pub async fn get_config_status(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
AppType::Claude => Ok(config::get_claude_config_status()),
|
||||
AppType::ClaudeDesktop => {
|
||||
let status = crate::claude_desktop_config::get_status(
|
||||
state.db.as_ref(),
|
||||
state.proxy_service.is_running().await,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(ConfigStatus {
|
||||
exists: status.configured,
|
||||
path: status.config_library_path.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
let exists = auth_path.exists();
|
||||
@@ -122,6 +137,9 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::ClaudeDesktop => {
|
||||
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
@@ -136,6 +154,9 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
||||
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::ClaudeDesktop => {
|
||||
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
|
||||
@@ -784,7 +784,7 @@ fn extract_env_vars_from_config(
|
||||
|
||||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||
let base_url_key = match app_type {
|
||||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ use tauri::{Emitter, State};
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||
use crate::services::{
|
||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||
};
|
||||
@@ -150,6 +150,154 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
|
||||
import_default_config_internal(&state, app_type).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_desktop_status(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
|
||||
let proxy_running = state.proxy_service.is_running().await;
|
||||
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_claude_desktop_default_routes(
|
||||
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
|
||||
crate::claude_desktop_config::default_proxy_routes()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_claude_desktop_providers_from_claude(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
let claude_providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let existing_ids = state
|
||||
.db
|
||||
.get_provider_ids(AppType::ClaudeDesktop.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported = 0usize;
|
||||
for provider in claude_providers.values() {
|
||||
if existing_ids.contains(&provider.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref()),
|
||||
Some("github_copilot") | Some("codex_oauth")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut desktop_provider = provider.clone();
|
||||
desktop_provider.in_failover_queue = false;
|
||||
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
|
||||
|
||||
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
|
||||
&& claude_provider_models_are_claude_safe(provider)
|
||||
{
|
||||
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
|
||||
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
|
||||
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
|
||||
meta.claude_desktop_model_routes = routes;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
||||
.map_err(|e| e.to_string())?;
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
||||
let Some(env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
[
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.all(crate::claude_desktop_config::is_claude_safe_model_id)
|
||||
}
|
||||
|
||||
fn suggested_claude_desktop_routes(
|
||||
provider: &Provider,
|
||||
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
|
||||
let env = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())?;
|
||||
let mut routes = std::collections::HashMap::new();
|
||||
|
||||
fn add_route(
|
||||
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
|
||||
env: &serde_json::Map<String, serde_json::Value>,
|
||||
route_id: &str,
|
||||
env_key: &str,
|
||||
display_name: &str,
|
||||
) {
|
||||
if let Some(model) = env
|
||||
.get(env_key)
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
routes.insert(
|
||||
route_id.to_string(),
|
||||
crate::provider::ClaudeDesktopModelRoute {
|
||||
model: model.to_string(),
|
||||
display_name: Some(display_name.to_string()),
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
|
||||
add_route(
|
||||
&mut routes,
|
||||
env,
|
||||
spec.route_id,
|
||||
spec.env_key,
|
||||
spec.display_name,
|
||||
);
|
||||
}
|
||||
|
||||
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
|
||||
if !routes.contains_key(primary_route.route_id) {
|
||||
add_route(
|
||||
&mut routes,
|
||||
env,
|
||||
primary_route.route_id,
|
||||
"ANTHROPIC_MODEL",
|
||||
primary_route.display_name,
|
||||
);
|
||||
}
|
||||
|
||||
(!routes.is_empty()).then_some(routes)
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn queryProviderUsage(
|
||||
|
||||
@@ -15,6 +15,24 @@ pub async fn start_proxy_server(
|
||||
state.proxy_service.start().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(仅停止服务,不恢复/清理 Live 接管状态)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
let takeover = state.proxy_service.get_takeover_status().await?;
|
||||
if takeover.claude
|
||||
|| takeover.codex
|
||||
|| takeover.gemini
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
{
|
||||
return Err(
|
||||
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
state.proxy_service.stop().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::services::skill::{
|
||||
SkillsShSearchResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -20,14 +21,7 @@ pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
AppType::from_str(app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
|
||||
|
||||
/// 单条官方供应商种子定义。
|
||||
pub(crate) struct OfficialProviderSeed {
|
||||
pub id: &'static str,
|
||||
@@ -22,7 +24,7 @@ pub(crate) struct OfficialProviderSeed {
|
||||
pub settings_config_json: &'static str,
|
||||
}
|
||||
|
||||
/// Claude / Codex / Gemini 三个应用的官方预设。
|
||||
/// Claude / Claude Desktop / Codex / Gemini 的官方预设。
|
||||
///
|
||||
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
|
||||
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
@@ -36,6 +38,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
// 空 env 让用户走 Claude CLI 默认认证流程
|
||||
settings_config_json: r#"{"env":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
app_type: AppType::ClaudeDesktop,
|
||||
name: "Claude Desktop Official",
|
||||
website_url: "https://claude.ai/download",
|
||||
icon: "anthropic",
|
||||
icon_color: "#D4915D",
|
||||
// 空 env 只是占位;切换该 provider 时会恢复 Claude Desktop 1P 模式
|
||||
settings_config_json: r#"{"env":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: "codex-official",
|
||||
app_type: AppType::Codex,
|
||||
@@ -64,3 +76,19 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
pub(crate) fn is_official_seed_id(id: &str) -> bool {
|
||||
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn official_seeds_include_claude_desktop() {
|
||||
let seed = OFFICIAL_SEEDS
|
||||
.iter()
|
||||
.find(|seed| seed.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID)
|
||||
.expect("claude desktop official seed");
|
||||
|
||||
assert_eq!(seed.app_type, AppType::ClaudeDesktop);
|
||||
assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ mod schema;
|
||||
mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
|
||||
pub use dao::FailoverQueueItem;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||||
use super::DeepLinkImportRequest;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
||||
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMeta, UsageScript};
|
||||
use crate::services::ProviderService;
|
||||
use crate::store::AppState;
|
||||
use crate::AppType;
|
||||
@@ -142,7 +142,7 @@ pub(crate) fn build_provider_from_request(
|
||||
request: &DeepLinkImportRequest,
|
||||
) -> Result<Provider, AppError> {
|
||||
let settings_config = match app_type {
|
||||
AppType::Claude => build_claude_settings(request),
|
||||
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
@@ -151,7 +151,11 @@ pub(crate) fn build_provider_from_request(
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
let meta = build_provider_meta(request)?;
|
||||
let mut meta = build_provider_meta(request)?;
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
meta.get_or_insert_with(ProviderMeta::default)
|
||||
.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
|
||||
}
|
||||
|
||||
let provider = Provider {
|
||||
id: String::new(), // Will be generated by caller
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod app_config;
|
||||
mod app_store;
|
||||
mod auto_launch;
|
||||
mod claude_desktop_config;
|
||||
mod claude_mcp;
|
||||
mod claude_plugin;
|
||||
mod codex_config;
|
||||
@@ -1051,6 +1052,9 @@ pub fn run() {
|
||||
commands::remove_provider_from_live_config,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_desktop_status,
|
||||
commands::get_claude_desktop_default_routes,
|
||||
commands::import_claude_desktop_providers_from_claude,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
@@ -1192,6 +1196,7 @@ pub fn run() {
|
||||
commands::get_auto_launch_status,
|
||||
// Proxy server management
|
||||
commands::start_proxy_server,
|
||||
commands::stop_proxy_server,
|
||||
commands::stop_proxy_with_restore,
|
||||
commands::get_proxy_takeover_status,
|
||||
commands::set_proxy_takeover_for_app,
|
||||
|
||||
@@ -10,6 +10,14 @@ use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.prompts_unsupported",
|
||||
"Claude Desktop 暂不支持 Prompts",
|
||||
"Claude Desktop does not support Prompts",
|
||||
));
|
||||
}
|
||||
|
||||
let base_dir: PathBuf = match app {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
@@ -17,6 +25,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
@@ -24,6 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -216,6 +216,28 @@ pub struct AuthBinding {
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Claude Desktop 3P 写入模式。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ClaudeDesktopMode {
|
||||
Direct,
|
||||
Proxy,
|
||||
}
|
||||
|
||||
/// Claude Desktop 本地路由模式下暴露给 Desktop 的安全模型路由。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClaudeDesktopModelRoute {
|
||||
/// 真实上游模型名,只保存在 CC Switch 内部,不写入 Claude Desktop profile。
|
||||
pub model: String,
|
||||
/// Desktop /v1/models 中显示的名称。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
/// Claude Desktop 3P 识别的 1M 上下文能力标记。
|
||||
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
|
||||
pub supports_1m: Option<bool>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -228,6 +250,16 @@ pub struct ProviderMeta {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// Claude Desktop 3P 写入模式:direct(直连)或 proxy(预留)
|
||||
#[serde(rename = "claudeDesktopMode", skip_serializing_if = "Option::is_none")]
|
||||
pub claude_desktop_mode: Option<ClaudeDesktopMode>,
|
||||
/// Claude Desktop proxy 模式的模型路由映射:Claude-safe route -> upstream model。
|
||||
#[serde(
|
||||
default,
|
||||
rename = "claudeDesktopModelRoutes",
|
||||
skip_serializing_if = "HashMap::is_empty"
|
||||
)]
|
||||
pub claude_desktop_model_routes: HashMap<String, ClaudeDesktopModelRoute>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
|
||||
@@ -195,6 +195,7 @@ impl RequestForwarder {
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -325,6 +326,7 @@ impl RequestForwarder {
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -524,6 +526,7 @@ impl RequestForwarder {
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -763,6 +766,7 @@ impl RequestForwarder {
|
||||
/// 转发单个请求(使用适配器)
|
||||
async fn forward(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
@@ -780,8 +784,16 @@ impl RequestForwarder {
|
||||
.unwrap_or(false);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
|
||||
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
|
||||
let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider)
|
||||
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?
|
||||
} else {
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
mapped_body
|
||||
};
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
@@ -69,6 +69,49 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
validate_claude_desktop_gateway_auth(&state, request.headers())?;
|
||||
handle_messages_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::ClaudeDesktop,
|
||||
"Claude Desktop",
|
||||
"claude-desktop",
|
||||
Some("/claude-desktop"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_models(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Json<Value>, ProxyError> {
|
||||
validate_claude_desktop_gateway_auth(&state, &headers)?;
|
||||
let providers = state
|
||||
.provider_router
|
||||
.select_providers("claude-desktop")
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?;
|
||||
let response = crate::claude_desktop_config::model_list_response(provider)
|
||||
.map_err(|e| ProxyError::ConfigError(e.to_string()))?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn handle_messages_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
strip_prefix: Option<&'static str>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
@@ -83,12 +126,15 @@ pub async fn handle_messages(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
|
||||
let endpoint = uri
|
||||
let raw_endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|path_and_query| path_and_query.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
let endpoint = strip_prefix
|
||||
.and_then(|prefix| raw_endpoint.strip_prefix(prefix))
|
||||
.unwrap_or(raw_endpoint);
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -99,7 +145,7 @@ pub async fn handle_messages(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
&app_type,
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
@@ -127,7 +173,7 @@ pub async fn handle_messages(
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let adapter = get_adapter(&app_type);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
@@ -140,6 +186,33 @@ pub async fn handle_messages(
|
||||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
fn validate_claude_desktop_gateway_auth(
|
||||
state: &ProxyState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<(), ProxyError> {
|
||||
let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref())
|
||||
.map_err(|e| ProxyError::AuthError(e.to_string()))?;
|
||||
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||
return Err(ProxyError::AuthError(
|
||||
"Claude Desktop gateway 缺少 Authorization 头".to_string(),
|
||||
));
|
||||
};
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?;
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if token != expected {
|
||||
return Err(ProxyError::AuthError(
|
||||
"Claude Desktop gateway token 无效".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
|
||||
@@ -380,6 +380,16 @@ impl ClaudeAdapter {
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// Gemini Native key
|
||||
if let Some(key) = env
|
||||
.get("GEMINI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 GEMINI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
@@ -904,6 +914,27 @@ mod tests {
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_api_key() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"GEMINI_API_KEY": "gemini-test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "gemini-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
@@ -105,7 +105,7 @@ impl ProviderType {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
AppType::Claude | AppType::ClaudeDesktop => {
|
||||
if get_claude_api_format(provider) == "gemini_native" {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
|
||||
@@ -226,7 +226,7 @@ impl std::str::FromStr for ProviderType {
|
||||
/// 根据 AppType 获取对应的适配器
|
||||
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
|
||||
@@ -285,6 +285,15 @@ impl ProxyServer {
|
||||
// Claude API (支持带前缀和不带前缀两种格式)
|
||||
.route("/v1/messages", post(handlers::handle_messages))
|
||||
.route("/claude/v1/messages", post(handlers::handle_messages))
|
||||
// Claude Desktop 3P 本地 gateway(独立 provider namespace)
|
||||
.route(
|
||||
"/claude-desktop/v1/models",
|
||||
get(handlers::handle_claude_desktop_models),
|
||||
)
|
||||
.route(
|
||||
"/claude-desktop/v1/messages",
|
||||
post(handlers::handle_claude_desktop_messages),
|
||||
)
|
||||
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
|
||||
.route("/chat/completions", post(handlers::handle_chat_completions))
|
||||
.route(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ fn default_true() -> bool {
|
||||
pub struct VisibleApps {
|
||||
#[serde(default = "default_true")]
|
||||
pub claude: bool,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claudeDesktop",
|
||||
alias = "claude_desktop",
|
||||
default = "default_true"
|
||||
)]
|
||||
pub claude_desktop: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub codex: bool,
|
||||
#[serde(default = "default_true")]
|
||||
@@ -44,6 +51,7 @@ impl Default for VisibleApps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
claude: true,
|
||||
claude_desktop: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
@@ -58,6 +66,7 @@ impl VisibleApps {
|
||||
pub fn is_visible(&self, app: &AppType) -> bool {
|
||||
match app {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::ClaudeDesktop => self.claude_desktop,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
@@ -242,6 +251,9 @@ pub struct AppSettings {
|
||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_claude: Option<String>,
|
||||
/// 当前 Claude Desktop 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_claude_desktop: Option<String>,
|
||||
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_codex: Option<String>,
|
||||
@@ -326,6 +338,7 @@ impl Default for AppSettings {
|
||||
openclaw_config_dir: None,
|
||||
hermes_config_dir: None,
|
||||
current_provider_claude: None,
|
||||
current_provider_claude_desktop: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
@@ -613,6 +626,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
match app_type {
|
||||
AppType::Claude => settings.current_provider_claude.clone(),
|
||||
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
@@ -629,6 +643,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
let id_owned = id.map(|s| s.to_string());
|
||||
mutate_settings(|settings| match app_type {
|
||||
AppType::Claude => settings.current_provider_claude = id_owned.clone(),
|
||||
AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(),
|
||||
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
|
||||
@@ -768,3 +783,40 @@ pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppErro
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_config::AppType;
|
||||
|
||||
#[test]
|
||||
fn visible_apps_old_settings_default_claude_desktop_visible() {
|
||||
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
|
||||
"claude": true,
|
||||
"codex": true,
|
||||
"gemini": true,
|
||||
"opencode": true,
|
||||
"openclaw": true,
|
||||
"hermes": true
|
||||
}))
|
||||
.expect("visible apps");
|
||||
|
||||
assert!(visible.is_visible(&AppType::ClaudeDesktop));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_apps_accepts_claude_desktop_aliases() {
|
||||
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
|
||||
"claude": true,
|
||||
"claudeDesktop": false,
|
||||
"codex": true,
|
||||
"gemini": true,
|
||||
"opencode": true,
|
||||
"openclaw": true,
|
||||
"hermes": true
|
||||
}))
|
||||
.expect("visible apps");
|
||||
|
||||
assert!(!visible.is_visible(&AppType::ClaudeDesktop));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,13 +429,9 @@ fn handle_provider_click(
|
||||
.db
|
||||
.set_proxy_flags_sync(app_type_str, proxy_enabled, false)?;
|
||||
|
||||
// 切换供应商
|
||||
crate::commands::switch_provider(
|
||||
app_state.clone(),
|
||||
app_type_str.to_string(),
|
||||
provider_id.to_string(),
|
||||
)
|
||||
.map_err(AppError::Message)?;
|
||||
// 切换供应商。需要本地路由的供应商也不在这里自动启动代理,
|
||||
// 由用户在页面/设置中手动开启。
|
||||
crate::services::ProviderService::switch(app_state.inner(), app_type.clone(), provider_id)?;
|
||||
|
||||
// 更新托盘菜单
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
|
||||
Reference in New Issue
Block a user