From 754af2cc310fde2d46b754241f997c5dd79a80ee Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Jul 2026 23:18:20 +0800 Subject: [PATCH] feat(profiles): split Claude Desktop into independent profile scope --- src-tauri/src/commands/profile.rs | 5 ++ src-tauri/src/services/profile.rs | 51 ++++++++++------- src-tauri/src/tray.rs | 1 + src-tauri/tests/profile_roundtrip.rs | 84 ++++++++++++++++++++++++---- src/App.tsx | 3 + src/components/profiles/scope.ts | 6 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh-TW.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/api/profiles.ts | 6 +- 11 files changed, 137 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs index 85a2bc5e0..210fed053 100644 --- a/src-tauri/src/commands/profile.rs +++ b/src-tauri/src/commands/profile.rs @@ -44,6 +44,7 @@ impl From for ProfileDto { #[serde(rename_all = "camelCase")] pub struct CurrentProfileIds { pub claude: Option, + pub claude_desktop: Option, pub codex: Option, } @@ -99,6 +100,10 @@ pub fn list_profiles(state: State<'_, AppState>) -> Result &'static str { match self { ProfileScope::Claude => "claude", + ProfileScope::ClaudeDesktop => "claude-desktop", ProfileScope::Codex => "codex", } } @@ -51,6 +56,7 @@ impl ProfileScope { pub fn parse(value: &str) -> Result { match value { "claude" => Ok(ProfileScope::Claude), + "claude-desktop" => Ok(ProfileScope::ClaudeDesktop), "codex" => Ok(ProfileScope::Codex), other => Err(AppError::InvalidInput(format!( "Unknown profile scope: {other}" @@ -59,13 +65,10 @@ impl ProfileScope { } /// 组内受管应用(快照与 apply 只作用于这些 app 的槽位) - /// - /// Claude Desktop 只有供应商一个活跃维度:MCP/Skills 的 `is_enabled_for` - /// 对它恒为 false(快照天然为空集)、prompt 无 live 文件(快照为 None), - /// apply 时空集 diff 与 None 都是 no-op,无需按维度特判。 pub fn apps(&self) -> &'static [AppType] { match self { - ProfileScope::Claude => &[AppType::Claude, AppType::ClaudeDesktop], + ProfileScope::Claude => &[AppType::Claude], + ProfileScope::ClaudeDesktop => &[AppType::ClaudeDesktop], ProfileScope::Codex => &[AppType::Codex], } } @@ -73,7 +76,8 @@ impl ProfileScope { /// 应用页 → 所属分组(Profile 不支持的应用返回 None) pub fn for_app(app: &AppType) -> Option { match app { - AppType::Claude | AppType::ClaudeDesktop => Some(ProfileScope::Claude), + AppType::Claude => Some(ProfileScope::Claude), + AppType::ClaudeDesktop => Some(ProfileScope::ClaudeDesktop), AppType::Codex => Some(ProfileScope::Codex), _ => None, } @@ -554,7 +558,11 @@ mod tests { payload.merge_scope_from(&fresh, ProfileScope::Claude); assert_eq!(payload.providers.claude, Some("p2".to_string())); - assert_eq!(payload.providers.claude_desktop, None); + assert_eq!( + payload.providers.claude_desktop, + Some("d1".to_string()), + "claude-desktop slot is in its own scope, untouched by claude merge" + ); assert_eq!(payload.mcp.claude, Some(ids(&["m2"]))); // codex 侧完好:既没被覆盖也没被 fresh 的值污染 assert_eq!(payload.providers.codex, Some("c1".to_string())); @@ -565,17 +573,20 @@ mod tests { fn test_scope_captured_detects_per_scope_snapshot() { let mut payload = ProfilePayload::default(); assert!(!payload.scope_captured(ProfileScope::Claude)); + assert!(!payload.scope_captured(ProfileScope::ClaudeDesktop)); assert!(!payload.scope_captured(ProfileScope::Codex)); // 只拍过 claude 组(哪怕拍到的是空集) payload.mcp.claude = Some(vec![]); assert!(payload.scope_captured(ProfileScope::Claude)); + assert!(!payload.scope_captured(ProfileScope::ClaudeDesktop)); assert!(!payload.scope_captured(ProfileScope::Codex)); - // Desktop 槽位属于 claude 组 + // Desktop 槽位属于独立的 claude-desktop 组 let mut desktop_only = ProfilePayload::default(); desktop_only.providers.claude_desktop = Some("d1".into()); - assert!(desktop_only.scope_captured(ProfileScope::Claude)); + assert!(desktop_only.scope_captured(ProfileScope::ClaudeDesktop)); + assert!(!desktop_only.scope_captured(ProfileScope::Claude)); } #[test] @@ -603,10 +614,12 @@ mod tests { #[test] fn test_scope_app_grouping() { - // Desktop 并入 Claude 组;组内应用与 for_app 反向映射必须一致 + // Claude Code 与 Claude Desktop 各自独立成组; + // 组内应用与 for_app 反向映射必须一致 + assert_eq!(ProfileScope::Claude.apps(), &[AppType::Claude]); assert_eq!( - ProfileScope::Claude.apps(), - &[AppType::Claude, AppType::ClaudeDesktop] + ProfileScope::ClaudeDesktop.apps(), + &[AppType::ClaudeDesktop] ); assert_eq!(ProfileScope::Codex.apps(), &[AppType::Codex]); for scope in ProfileScope::ALL { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 096b1ef1c..da11fa289 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -738,6 +738,7 @@ pub fn create_tray_menu( // 分组标签用产品名,不进 i18n let scope_label = match scope { ProfileScope::Claude => "Claude Code", + ProfileScope::ClaudeDesktop => "Claude Desktop", ProfileScope::Codex => "Codex", }; let mut scope_builder = SubmenuBuilder::with_id( diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs index 19797dc4e..bc0931ce1 100644 --- a/src-tauri/tests/profile_roundtrip.rs +++ b/src-tauri/tests/profile_roundtrip.rs @@ -174,7 +174,7 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .save_prompt(AppType::Claude.as_str(), &prompt("pr2", false)) .expect("save prompt pr2"); - // ---- 保存项目 A(在 Claude 页新建:只拍 Claude + Desktop 当前状态)---- + // ---- 保存项目 A(在 Claude 页新建:只拍 Claude 当前状态)---- let profile_a = ProfileService::create(&state, "Project A", ProfileScope::Claude) .expect("create profile A"); let payload: ProfilePayload = @@ -191,18 +191,14 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { "codex side not captured when creating from the claude group" ); assert_eq!(payload.mcp.codex, None, "uncaptured side stays None"); - assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1")); assert_eq!( - (payload.mcp.claude_desktop, payload.skills.claude_desktop), - (Some(vec![]), Some(vec![])), - "desktop has no MCP/Skills dimension (captured as empty)" + payload.providers.claude_desktop, None, + "claude desktop has its own profile scope" ); - 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 应有行为) + // Desktop 现在有自己的项目分组;Claude 分组 apply 不应再影响 Desktop #[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"); @@ -211,7 +207,7 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .expect("disable skill"); PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2"); - // ---- 应用项目 A(Claude 组):全部复原 ---- + // ---- 应用项目 A(Claude 组):只复原 Claude 侧 ---- let (warnings, _) = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude) .expect("apply profile A"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); @@ -222,14 +218,15 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() { .expect("get current provider"); assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1"); + // Claude 分组不再管理 Desktop:Desktop 保持 d2 不变 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" + Some("d2"), + "desktop provider stays at d2 after claude-scope apply" ); let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); @@ -744,3 +741,68 @@ fn profile_switch_auto_disables_takeover_before_apply() { "live config should point to real endpoint after auto-disable" ); } + +#[cfg(any(target_os = "macos", windows))] +#[test] +fn claude_desktop_profile_scope_is_independent() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + 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"); + + // 在 Desktop 页新建项目:只拍 Desktop 供应商 + let project = ProfileService::create(&state, "Desktop Project", ProfileScope::ClaudeDesktop) + .expect("create desktop profile"); + let payload: ProfilePayload = + serde_json::from_str(&project.payload).expect("parse desktop payload"); + assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1")); + assert_eq!(payload.providers.claude, None, "claude slot untouched"); + assert_eq!(payload.providers.codex, None, "codex slot untouched"); + + // 切到 d2 + ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2"); + + // 应用 Desktop 项目:恢复 d1 + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::ClaudeDesktop) + .expect("apply desktop profile"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + assert_eq!( + state + .db + .get_current_provider(AppType::ClaudeDesktop.as_str()) + .expect("get current desktop provider") + .as_deref(), + Some("d1"), + "desktop provider restored by desktop-scope apply" + ); + assert_eq!( + state + .db + .get_current_profile_id(ProfileScope::ClaudeDesktop.as_str()) + .expect("get desktop current profile id") + .as_deref(), + Some(project.id.as_str()), + "desktop scope marker set" + ); +} diff --git a/src/App.tsx b/src/App.tsx index d79b62e49..877db16e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -390,6 +390,9 @@ function App() { await queryClient.invalidateQueries({ queryKey: ["skills"] }); await queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + await queryClient.invalidateQueries({ + queryKey: ["providers", "claude-desktop"], + }); }); useTauriEvent( diff --git a/src/components/profiles/scope.ts b/src/components/profiles/scope.ts index 5e2d394cf..7c8dab822 100644 --- a/src/components/profiles/scope.ts +++ b/src/components/profiles/scope.ts @@ -8,19 +8,21 @@ import type { PerApp, Profile, ProfileScope } from "@/lib/api/profiles"; */ export const APP_PROFILE_SCOPE: Partial> = { claude: "claude", - "claude-desktop": "claude", + "claude-desktop": "claude-desktop", codex: "codex", }; /** 分组显示名(产品名,不进 i18n;与后端托盘子菜单标签一致) */ export const PROFILE_SCOPE_LABELS: Record = { claude: "Claude Code", + "claude-desktop": "Claude Desktop", codex: "Codex", }; /** 分组内的 payload 槽位 key(后端 ProfileScope::apps 的前端镜像) */ const SCOPE_SLOT_KEYS: Record)[]> = { - claude: ["claude", "claude-desktop"], + claude: ["claude"], + "claude-desktop": ["claude-desktop"], codex: ["codex"], }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 26de81ce0..f7492bf9f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1868,7 +1868,8 @@ }, "profiles": { "switcherTooltip": { - "claude": "Projects: switch the Claude Code provider / MCP / Skills / memory files, plus the Claude Desktop provider, in one click", + "claude": "Projects: switch the Claude Code provider / MCP / Skills / memory files in one click", + "claude-desktop": "Projects: switch the Claude Desktop provider in one click", "codex": "Projects: switch the Codex provider / MCP / Skills / memory files in one click" }, "none": "No project", @@ -1876,7 +1877,8 @@ "empty": "No projects yet", "createFromCurrent": "New project", "createDescription": { - "claude": "Save the current Claude Code provider, MCP, Skills and memory file (plus the Claude Desktop provider) as a project you can switch to later.", + "claude": "Save the current Claude Code provider, MCP, Skills and memory file as a project you can switch to later.", + "claude-desktop": "Save the current Claude Desktop provider as a project you can switch to later.", "codex": "Save the current Codex provider, MCP, Skills and memory file configuration as a project you can switch to later." }, "manage": "Manage projects…", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 45c9b33d7..9f163b6d2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1868,7 +1868,8 @@ }, "profiles": { "switcherTooltip": { - "claude": "プロジェクト:Claude Code のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え", + "claude": "プロジェクト:Claude Code のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え", + "claude-desktop": "プロジェクト:Claude Desktop のプロバイダーをワンクリックで切り替え", "codex": "プロジェクト:Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え" }, "none": "プロジェクトを使用しない", @@ -1876,7 +1877,8 @@ "empty": "プロジェクトはまだありません", "createFromCurrent": "新規プロジェクト", "createDescription": { - "claude": "Claude Code の現在のプロバイダー、MCP、Skills、メモリファイル(および Claude Desktop のプロバイダー)をプロジェクトとして保存し、後からワンクリックで切り替えられます。", + "claude": "Claude Code の現在のプロバイダー、MCP、Skills、メモリファイルをプロジェクトとして保存し、後からワンクリックで切り替えられます。", + "claude-desktop": "Claude Desktop の現在のプロバイダーをプロジェクトとして保存し、後からワンクリックで切り替えられます。", "codex": "Codex の現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。" }, "manage": "プロジェクトを管理…", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 3f2412eb9..e098ba003 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1840,7 +1840,8 @@ }, "profiles": { "switcherTooltip": { - "claude": "專案:一鍵切換 Claude Code 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商", + "claude": "專案:一鍵切換 Claude Code 的供應商 / MCP / Skills / 記憶檔案", + "claude-desktop": "專案:一鍵切換 Claude Desktop 的供應商", "codex": "專案:一鍵切換 Codex 的供應商 / MCP / Skills / 記憶檔案" }, "none": "不使用專案", @@ -1848,7 +1849,8 @@ "empty": "尚無專案", "createFromCurrent": "新建專案", "createDescription": { - "claude": "把 Claude Code 目前的供應商、MCP、Skills、記憶檔案(及 Claude Desktop 的供應商)儲存為一個專案,之後可一鍵切換。", + "claude": "把 Claude Code 目前的供應商、MCP、Skills、記憶檔案儲存為一個專案,之後可一鍵切換。", + "claude-desktop": "把 Claude Desktop 目前的供應商儲存為一個專案,之後可一鍵切換。", "codex": "把 Codex 目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。" }, "manage": "管理專案…", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6dfb7a921..1a5d87aaa 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1868,7 +1868,8 @@ }, "profiles": { "switcherTooltip": { - "claude": "项目:一键切换 Claude Code 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商", + "claude": "项目:一键切换 Claude Code 的供应商 / MCP / Skills / 记忆文件", + "claude-desktop": "项目:一键切换 Claude Desktop 的供应商", "codex": "项目:一键切换 Codex 的供应商 / MCP / Skills / 记忆文件" }, "none": "不使用项目", @@ -1876,7 +1877,8 @@ "empty": "暂无项目", "createFromCurrent": "新建项目", "createDescription": { - "claude": "把 Claude Code 当前的供应商、MCP、Skills、记忆文件(及 Claude Desktop 的供应商)保存为一个项目,之后可一键切换。", + "claude": "把 Claude Code 当前的供应商、MCP、Skills、记忆文件保存为一个项目,之后可一键切换。", + "claude-desktop": "把 Claude Desktop 当前的供应商保存为一个项目,之后可一键切换。", "codex": "把 Codex 当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。" }, "manage": "管理项目…", diff --git a/src/lib/api/profiles.ts b/src/lib/api/profiles.ts index 7b52496d1..0bf03327f 100644 --- a/src/lib/api/profiles.ts +++ b/src/lib/api/profiles.ts @@ -3,10 +3,10 @@ import { invoke } from "@tauri-apps/api/core"; /** * Profile 操作的应用分组(与后端 services/profile.rs 的 ProfileScope 严格对应) * - * 项目实体全应用共享,但快照/应用/当前指针按组进行;Claude Desktop 并入 - * claude 组(它只有聊天侧供应商一个受管维度,Code 标签页天然跟随 Claude Code)。 + * 项目实体全应用共享,但快照/应用/当前指针按组进行;Claude Code 与 + * Claude Desktop 的供应商独立切换,因此各自有独立分组。 */ -export type ProfileScope = "claude" | "codex"; +export type ProfileScope = "claude" | "claude-desktop" | "codex"; /** * 按 app 分槽的载荷容器(与后端 services/profile.rs 的 PerApp 严格对应)