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
+56 -2
View File
@@ -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
+23 -2
View File
@@ -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(),
+1 -1
View File
@@ -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,
};
+149 -1
View File
@@ -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(
+18
View File
@@ -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> {
+2 -8
View File
@@ -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())
}
// ========== 统一管理命令 ==========
+29 -1
View File
@@ -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));
}
}
+1
View File
@@ -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;
+7 -3
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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))
+32
View File
@@ -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>,
+14 -2
View File
@@ -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);
+77 -4
View File
@@ -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 两种格式的转换
+31
View File
@@ -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();
+2 -2
View File
@@ -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 => {
+9
View File
@@ -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(
+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())
}
+52
View File
@@ -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));
}
}
+3 -7
View File
@@ -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()) {