diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs index e537d266e..ff8f996e1 100644 --- a/src-tauri/src/services/profile.rs +++ b/src-tauri/src/services/profile.rs @@ -19,14 +19,20 @@ use crate::error::AppError; use crate::services::{McpService, PromptService, ProviderService, SkillService}; use crate::store::AppState; -/// P1 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段) -pub const PROFILE_APPS: [AppType; 2] = [AppType::Claude, AppType::Codex]; +/// 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段) +/// +/// Claude Desktop 只有供应商一个活跃维度:MCP/Skills 的 `is_enabled_for` +/// 对它恒为 false(快照天然为空集)、prompt 无 live 文件(快照为 None), +/// apply 时空集 diff 与 None 都是 no-op,无需按维度特判。 +pub const PROFILE_APPS: [AppType; 3] = [AppType::Claude, AppType::ClaudeDesktop, AppType::Codex]; -/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 小写形式一致 +/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PerApp { pub claude: T, + #[serde(rename = "claude-desktop")] + pub claude_desktop: T, pub codex: T, } @@ -34,6 +40,7 @@ impl PerApp { pub fn get(&self, app: &AppType) -> Option<&T> { match app { AppType::Claude => Some(&self.claude), + AppType::ClaudeDesktop => Some(&self.claude_desktop), AppType::Codex => Some(&self.codex), _ => None, } @@ -42,6 +49,7 @@ impl PerApp { pub fn get_mut(&mut self, app: &AppType) -> Option<&mut T> { match app { AppType::Claude => Some(&mut self.claude), + AppType::ClaudeDesktop => Some(&mut self.claude_desktop), AppType::Codex => Some(&mut self.codex), _ => None, } @@ -310,24 +318,29 @@ mod tests { let payload = ProfilePayload { providers: PerApp { claude: Some("p1".into()), + claude_desktop: Some("d1".into()), codex: None, }, mcp: PerApp { claude: ids(&["m1", "m2"]), + claude_desktop: vec![], codex: vec![], }, skills: PerApp { claude: vec![], + claude_desktop: vec![], codex: ids(&["s1"]), }, prompts: PerApp { claude: None, + claude_desktop: None, codex: Some("pr1".into()), }, }; let json = serde_json::to_string(&payload).unwrap(); - // per-app key 必须是 AppType serde 的小写形式 + // per-app key 必须与 AppType 的 serde 形式一致(claude-desktop 是连字符) assert!(json.contains("\"claude\"")); + assert!(json.contains("\"claude-desktop\"")); assert!(json.contains("\"codex\"")); let back: ProfilePayload = serde_json::from_str(&json).unwrap(); assert_eq!(back, payload); @@ -336,11 +349,14 @@ mod tests { #[test] fn test_payload_tolerates_missing_fields() { // 前向兼容:旧版/部分字段缺失时应落到默认值而不是报错 + // (P1 存量 payload 没有 claude-desktop key,应用时对 Desktop 不动) let back: ProfilePayload = serde_json::from_str(r#"{"providers":{"claude":"p1"}}"#).unwrap(); assert_eq!(back.providers.claude, Some("p1".to_string())); + assert_eq!(back.providers.claude_desktop, None); assert_eq!(back.providers.codex, None); assert!(back.mcp.claude.is_empty()); + assert!(back.mcp.claude_desktop.is_empty()); assert_eq!(back.prompts.codex, None); let empty: ProfilePayload = serde_json::from_str("{}").unwrap(); @@ -351,9 +367,9 @@ mod tests { fn test_per_app_get_only_supports_profile_apps() { let per: PerApp> = PerApp::default(); assert!(per.get(&AppType::Claude).is_some()); + assert!(per.get(&AppType::ClaudeDesktop).is_some()); assert!(per.get(&AppType::Codex).is_some()); assert!(per.get(&AppType::Gemini).is_none()); - assert!(per.get(&AppType::ClaudeDesktop).is_none()); } #[test] diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ace88c5af..b61b19051 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -664,8 +664,11 @@ pub fn create_tray_menu( menu_builder = menu_builder.separator(); } - // 项目 Profile 子菜单(Claude/Codex 至少一个可见且存在项目时才显示) - if visible_apps.is_visible(&AppType::Claude) || visible_apps.is_visible(&AppType::Codex) { + // 项目 Profile 子菜单(受支持应用至少一个可见且存在项目时才显示) + if crate::services::profile::PROFILE_APPS + .iter() + .any(|app_type| visible_apps.is_visible(app_type)) + { let profiles = app_state.db.get_all_profiles()?; if !profiles.is_empty() { let current_profile_id = app_state.db.get_current_profile_id()?.unwrap_or_default(); diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs index 6a420f791..f17102b84 100644 --- a/src-tauri/tests/profile_roundtrip.rs +++ b/src-tauri/tests/profile_roundtrip.rs @@ -29,6 +29,21 @@ fn claude_provider(id: &str, token: &str) -> Provider { ) } +/// Claude Desktop 供应商:无 meta 时默认 Direct 模式,只要求 env 里有 token + base_url +fn desktop_provider(id: &str, token: &str) -> Provider { + Provider::with_id( + id.to_string(), + id.to_uppercase(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": token, + "ANTHROPIC_BASE_URL": "https://desktop.test" + } + }), + None, + ) +} + fn mcp_server(id: &str, claude_enabled: bool) -> McpServer { serde_json::from_value(json!({ "id": id, @@ -105,6 +120,26 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .set_current_provider(AppType::Claude.as_str(), "p1") .expect("set current provider p1"); + // Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用) + state + .db + .save_provider( + AppType::ClaudeDesktop.as_str(), + &desktop_provider("d1", "dk-1"), + ) + .expect("save desktop provider d1"); + state + .db + .save_provider( + AppType::ClaudeDesktop.as_str(), + &desktop_provider("d2", "dk-2"), + ) + .expect("save desktop provider d2"); + state + .db + .set_current_provider(AppType::ClaudeDesktop.as_str(), "d1") + .expect("set current desktop provider d1"); + // 让 live settings.json 与 p1 一致(switch_normal 回填需要) let claude_dir = home.join(".claude"); fs::create_dir_all(&claude_dir).expect("create .claude dir"); @@ -152,9 +187,19 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { "codex has no current provider" ); assert!(payload.mcp.codex.is_empty()); + assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1")); + assert!( + payload.mcp.claude_desktop.is_empty() && payload.skills.claude_desktop.is_empty(), + "desktop has no MCP/Skills dimension" + ); + assert_eq!(payload.prompts.claude_desktop, None); // ---- 改动全部四类配置(走真实切换路径)---- ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + // Desktop live 写入仅 macOS/Windows 可用;其他平台不动 d1, + // apply 时命中幂等跳过(这正是 Linux 上带 Desktop 槽的 payload 应有行为) + #[cfg(any(target_os = "macos", windows))] + ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2"); McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1"); McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2"); SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false) @@ -171,6 +216,16 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .expect("get current provider"); assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1"); + let current_desktop = state + .db + .get_current_provider(AppType::ClaudeDesktop.as_str()) + .expect("get current desktop provider"); + assert_eq!( + current_desktop.as_deref(), + Some("d1"), + "desktop provider restored to d1" + ); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled"); assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled"); diff --git a/src/components/profiles/ProfileSwitcher.tsx b/src/components/profiles/ProfileSwitcher.tsx index e7cb87fb1..4843490ef 100644 --- a/src/components/profiles/ProfileSwitcher.tsx +++ b/src/components/profiles/ProfileSwitcher.tsx @@ -42,7 +42,7 @@ import { import { ProfileManageDialog } from "./ProfileManageDialog"; /** 后端 services/profile.rs 的 PROFILE_APPS 前端镜像,扩展支持范围时两处同步 */ -const PROFILE_SUPPORTED_APPS: AppId[] = ["claude", "codex"]; +const PROFILE_SUPPORTED_APPS: AppId[] = ["claude", "claude-desktop", "codex"]; interface ProfileSwitcherProps { activeApp: AppId; @@ -51,8 +51,8 @@ interface ProfileSwitcherProps { /** * 项目 Profile 切换器(header 左侧入口) * - * Profile 是跨应用的配置快照(Claude Code + Codex 的供应商/MCP/Skills/记忆文件), - * 与右侧 AppSwitcher(仅切换查看的应用)语义不同。 + * Profile 是跨应用的配置快照(Claude Code + Codex 的供应商/MCP/Skills/记忆文件, + * 以及 Claude Desktop 的供应商),与右侧 AppSwitcher(仅切换查看的应用)语义不同。 */ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) { const { t } = useTranslation(); @@ -66,7 +66,7 @@ export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) { const clearMutation = useClearProfileMutation(); const createMutation = useCreateProfileMutation(); - // Profile 仅作用于 Claude Code + Codex——在其他应用的标签页展示会误导用户 + // Profile 仅作用于受支持的应用——在其他应用的标签页展示会误导用户 // 以为当前应用也被切换了,因此只在受支持应用的页面渲染 if (!PROFILE_SUPPORTED_APPS.includes(activeApp)) { return null; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cd8c28fb7..e0f018665 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1867,7 +1867,7 @@ } }, "profiles": { - "switcherTooltip": "Projects: switch providers / MCP / Skills / memory files for Claude Code and Codex in one click", + "switcherTooltip": "Projects: switch providers / MCP / Skills / memory files for Claude Code and Codex, plus the Claude Desktop provider, in one click", "none": "No project", "searchPlaceholder": "Search projects", "empty": "No projects yet", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 449788aa7..5a88f820f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1867,7 +1867,7 @@ } }, "profiles": { - "switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え", + "switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え", "none": "プロジェクトを使用しない", "searchPlaceholder": "プロジェクトを検索", "empty": "プロジェクトはまだありません", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index efb66be80..705d3debe 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1839,7 +1839,7 @@ } }, "profiles": { - "switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案", + "switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商", "none": "不使用專案", "searchPlaceholder": "搜尋專案", "empty": "尚無專案", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a3b5d116f..fa02290db 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1867,7 +1867,7 @@ } }, "profiles": { - "switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件", + "switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商", "none": "不使用项目", "searchPlaceholder": "搜索项目", "empty": "暂无项目", diff --git a/src/lib/api/profiles.ts b/src/lib/api/profiles.ts index 6526f2897..1dc90be45 100644 --- a/src/lib/api/profiles.ts +++ b/src/lib/api/profiles.ts @@ -5,6 +5,7 @@ import { invoke } from "@tauri-apps/api/core"; */ export interface PerApp { claude: T; + "claude-desktop": T; codex: T; } diff --git a/src/lib/query/profiles.ts b/src/lib/query/profiles.ts index dae856c23..6488ba094 100644 --- a/src/lib/query/profiles.ts +++ b/src/lib/query/profiles.ts @@ -118,6 +118,9 @@ export const useApplyProfileMutation = () => { await queryClient.invalidateQueries({ queryKey: ["providers", "claude"], }); + await queryClient.invalidateQueries({ + queryKey: ["providers", "claude-desktop"], + }); await queryClient.invalidateQueries({ queryKey: ["providers", "codex"] }); await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); await queryClient.invalidateQueries({ queryKey: ["skills"] });