mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(profiles): include Claude Desktop provider in project profiles
Claude Desktop's only dimension managed by cc-switch is its provider (MCP/Skills are hardcoded unsupported and prompts have no live file), so snapshots capture just the current desktop provider while the empty MCP/Skills sets and None prompt make apply a natural no-op for the other dimensions - no per-dimension special casing needed. - Add claude-desktop slot to PROFILE_APPS and PerApp (serde key uses the hyphenated app id); old payloads without the key deserialize to None and leave Claude Desktop untouched on apply - Gate the tray Projects submenu by iterating PROFILE_APPS instead of hardcoding Claude/Codex visibility - Show the profile switcher on the Claude Desktop tab, mirror the PerApp type, invalidate the claude-desktop providers cache on apply, and extend the switcher tooltip in all four locales - Extend the roundtrip integration test with the desktop provider dimension; the desktop switch is cfg-gated to macOS/Windows because desktop live writes error on Linux where CI runs cargo test
This commit is contained in:
@@ -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<T> {
|
||||
pub claude: T,
|
||||
#[serde(rename = "claude-desktop")]
|
||||
pub claude_desktop: T,
|
||||
pub codex: T,
|
||||
}
|
||||
|
||||
@@ -34,6 +40,7 @@ impl<T> PerApp<T> {
|
||||
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<T> PerApp<T> {
|
||||
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<Option<String>> = 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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1867,7 +1867,7 @@
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え",
|
||||
"switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイル、および Claude Desktop のプロバイダーをワンクリックで切り替え",
|
||||
"none": "プロジェクトを使用しない",
|
||||
"searchPlaceholder": "プロジェクトを検索",
|
||||
"empty": "プロジェクトはまだありません",
|
||||
|
||||
@@ -1839,7 +1839,7 @@
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案",
|
||||
"switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案,及 Claude Desktop 的供應商",
|
||||
"none": "不使用專案",
|
||||
"searchPlaceholder": "搜尋專案",
|
||||
"empty": "尚無專案",
|
||||
|
||||
@@ -1867,7 +1867,7 @@
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件",
|
||||
"switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件,及 Claude Desktop 的供应商",
|
||||
"none": "不使用项目",
|
||||
"searchPlaceholder": "搜索项目",
|
||||
"empty": "暂无项目",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
*/
|
||||
export interface PerApp<T> {
|
||||
claude: T;
|
||||
"claude-desktop": T;
|
||||
codex: T;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
Reference in New Issue
Block a user