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:
Jason
2026-07-04 18:07:43 +08:00
parent 6179c18805
commit 65a5464fcc
10 changed files with 93 additions and 15 deletions
+21 -5
View File
@@ -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]
+5 -2
View File
@@ -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();
+55
View File
@@ -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");