diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d02a8073..d139edac7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ mod config; mod database; mod deeplink; mod error; -mod gemini_config; // 新增 +mod gemini_config; mod gemini_mcp; mod init_status; mod mcp; @@ -20,6 +20,7 @@ mod provider_defaults; mod services; mod settings; mod store; +mod tray; mod usage_script; pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; @@ -46,263 +47,11 @@ use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use std::sync::Arc; -use tauri::{ - menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}, - tray::{TrayIconBuilder, TrayIconEvent}, -}; +use tauri::tray::{TrayIconBuilder, TrayIconEvent}; #[cfg(target_os = "macos")] -use tauri::{ActivationPolicy, RunEvent}; +use tauri::RunEvent; use tauri::{Emitter, Manager}; -#[derive(Clone, Copy)] -struct TrayTexts { - show_main: &'static str, - no_provider_hint: &'static str, - quit: &'static str, -} - -impl TrayTexts { - fn from_language(language: &str) -> Self { - match language { - "en" => Self { - show_main: "Open main window", - no_provider_hint: " (No providers yet, please add them from the main window)", - quit: "Quit", - }, - "ja" => Self { - show_main: "メインウィンドウを開く", - no_provider_hint: - " (プロバイダーがまだありません。メイン画面から追加してください)", - quit: "終了", - }, - _ => Self { - show_main: "打开主界面", - no_provider_hint: " (无供应商,请在主界面添加)", - quit: "退出", - }, - } - } -} - -struct TrayAppSection { - app_type: AppType, - prefix: &'static str, - header_id: &'static str, - empty_id: &'static str, - header_label: &'static str, - log_name: &'static str, -} - -const TRAY_SECTIONS: [TrayAppSection; 3] = [ - TrayAppSection { - app_type: AppType::Claude, - prefix: "claude_", - header_id: "claude_header", - empty_id: "claude_empty", - header_label: "─── Claude ───", - log_name: "Claude", - }, - TrayAppSection { - app_type: AppType::Codex, - prefix: "codex_", - header_id: "codex_header", - empty_id: "codex_empty", - header_label: "─── Codex ───", - log_name: "Codex", - }, - TrayAppSection { - app_type: AppType::Gemini, - prefix: "gemini_", - header_id: "gemini_header", - empty_id: "gemini_empty", - header_label: "─── Gemini ───", - log_name: "Gemini", - }, -]; - -fn append_provider_section<'a>( - app: &'a tauri::AppHandle, - mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle>, - manager: Option<&crate::provider::ProviderManager>, - section: &TrayAppSection, - tray_texts: &TrayTexts, -) -> Result>, AppError> { - let Some(manager) = manager else { - return Ok(menu_builder); - }; - - let header = MenuItem::with_id( - app, - section.header_id, - section.header_label, - false, - None::<&str>, - ) - .map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?; - menu_builder = menu_builder.item(&header); - - if manager.providers.is_empty() { - let empty_hint = MenuItem::with_id( - app, - section.empty_id, - tray_texts.no_provider_hint, - false, - None::<&str>, - ) - .map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?; - return Ok(menu_builder.item(&empty_hint)); - } - - let mut sorted_providers: Vec<_> = manager.providers.iter().collect(); - sorted_providers.sort_by(|(_, a), (_, b)| { - match (a.sort_index, b.sort_index) { - (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), - (Some(_), None) => return std::cmp::Ordering::Less, - (None, Some(_)) => return std::cmp::Ordering::Greater, - _ => {} - } - - match (a.created_at, b.created_at) { - (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b), - (Some(_), None) => return std::cmp::Ordering::Greater, - (None, Some(_)) => return std::cmp::Ordering::Less, - _ => {} - } - - a.name.cmp(&b.name) - }); - - for (id, provider) in sorted_providers { - let is_current = manager.current == *id; - let item = CheckMenuItem::with_id( - app, - format!("{}{}", section.prefix, id), - &provider.name, - true, - is_current, - None::<&str>, - ) - .map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?; - menu_builder = menu_builder.item(&item); - } - - Ok(menu_builder) -} - -fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { - for section in TRAY_SECTIONS.iter() { - if let Some(provider_id) = event_id.strip_prefix(section.prefix) { - log::info!("切换到{}供应商: {provider_id}", section.log_name); - let app_handle = app.clone(); - let provider_id = provider_id.to_string(); - let app_type = section.app_type.clone(); - tauri::async_runtime::spawn_blocking(move || { - if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) { - log::error!("切换{}供应商失败: {e}", section.log_name); - } - }); - return true; - } - } - false -} - -/// 创建动态托盘菜单 -fn create_tray_menu( - app: &tauri::AppHandle, - app_state: &AppState, -) -> Result, AppError> { - let app_settings = crate::settings::get_settings(); - let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh")); - - let mut menu_builder = MenuBuilder::new(app); - - // 顶部:打开主界面 - let show_main_item = - MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>) - .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?; - menu_builder = menu_builder.item(&show_main_item).separator(); - - // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) - for section in TRAY_SECTIONS.iter() { - let app_type_str = section.app_type.as_str(); - let providers = app_state.db.get_all_providers(app_type_str)?; - - // 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID) - let current_id = - crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)? - .unwrap_or_default(); - - let manager = crate::provider::ProviderManager { - providers, - current: current_id, - }; - - menu_builder = - append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?; - } - - // 分隔符和退出菜单 - let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>) - .map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?; - - menu_builder = menu_builder.separator().item(&quit_item); - - menu_builder - .build() - .map_err(|e| AppError::Message(format!("构建菜单失败: {e}"))) -} - -#[cfg(target_os = "macos")] -fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) { - let desired_policy = if dock_visible { - ActivationPolicy::Regular - } else { - ActivationPolicy::Accessory - }; - - if let Err(err) = app.set_dock_visibility(dock_visible) { - log::warn!("设置 Dock 显示状态失败: {err}"); - } - - if let Err(err) = app.set_activation_policy(desired_policy) { - log::warn!("设置激活策略失败: {err}"); - } -} - -/// 处理托盘菜单事件 -fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { - log::info!("处理托盘菜单事件: {event_id}"); - - match event_id { - "show_main" => { - if let Some(window) = app.get_webview_window("main") { - #[cfg(target_os = "windows")] - { - let _ = window.set_skip_taskbar(false); - } - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - #[cfg(target_os = "macos")] - { - apply_tray_policy(app, true); - } - } - } - "quit" => { - log::info!("退出应用"); - app.exit(0); - } - _ => { - if handle_provider_tray_event(app, event_id) { - return; - } - log::warn!("未处理的菜单事件: {event_id}"); - } - } -} - /// 统一处理 ccswitch:// 深链接 URL /// /// - 解析 URL @@ -362,50 +111,13 @@ fn handle_deeplink_url( true } -// - -/// 内部切换供应商函数 -fn switch_provider_internal( - app: &tauri::AppHandle, - app_type: crate::app_config::AppType, - provider_id: String, -) -> Result<(), AppError> { - if let Some(app_state) = app.try_state::() { - // 在使用前先保存需要的值 - let app_type_str = app_type.as_str().to_string(); - let provider_id_clone = provider_id.clone(); - - crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id) - .map_err(AppError::Message)?; - - // 切换成功后重新创建托盘菜单 - if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { - if let Some(tray) = app.tray_by_id("main") { - if let Err(e) = tray.set_menu(Some(new_menu)) { - log::error!("更新托盘菜单失败: {e}"); - } - } - } - - // 发射事件到前端,通知供应商已切换 - let event_data = serde_json::json!({ - "appType": app_type_str, - "providerId": provider_id_clone - }); - if let Err(e) = app.emit("provider-switched", event_data) { - log::error!("发射供应商切换事件失败: {e}"); - } - } - Ok(()) -} - /// 更新托盘菜单的Tauri命令 #[tauri::command] async fn update_tray_menu( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { - match create_tray_menu(&app, state.inner()) { + match tray::create_tray_menu(&app, state.inner()) { Ok(new_menu) => { if let Some(tray) = app.tray_by_id("main") { tray.set_menu(Some(new_menu)) @@ -473,7 +185,7 @@ pub fn run() { } #[cfg(target_os = "macos")] { - apply_tray_policy(window.app_handle(), false); + tray::apply_tray_policy(window.app_handle(), false); } } else { window.app_handle().exit(0); @@ -773,7 +485,7 @@ pub fn run() { log::info!("✓ Deep-link URL handler registered"); // 创建动态托盘菜单 - let menu = create_tray_menu(app.handle(), &app_state)?; + let menu = tray::create_tray_menu(app.handle(), &app_state)?; // 构建托盘 let mut tray_builder = TrayIconBuilder::with_id("main") @@ -784,7 +496,7 @@ pub fn run() { }) .menu(&menu) .on_menu_event(|app, event| { - handle_tray_menu_event(app, &event.id.0); + tray::handle_tray_menu_event(app, &event.id.0); }) .show_menu_on_left_click(true); @@ -927,7 +639,7 @@ pub fn run() { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); - apply_tray_policy(app_handle, true); + tray::apply_tray_policy(app_handle, true); } } // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...) diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs new file mode 100644 index 000000000..e187fdd83 --- /dev/null +++ b/src-tauri/src/mcp/claude.rs @@ -0,0 +1,218 @@ +//! Claude MCP 同步和导入模块 + +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig}; +use crate::error::AppError; + +use super::validation::{extract_server_spec, validate_server_spec}; + +/// 返回已启用的 MCP 服务器(过滤 enabled==true) +fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { + let mut out = HashMap::new(); + for (id, entry) in cfg.servers.iter() { + let enabled = entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + continue; + } + match extract_server_spec(entry) { + Ok(spec) => { + out.insert(id.clone(), spec); + } + Err(err) => { + log::warn!("跳过无效的 MCP 条目 '{id}': {err}"); + } + } + } + out +} + +/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json +pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> { + let enabled = collect_enabled_servers(&config.mcp.claude); + crate::claude_mcp::set_mcp_servers_map(&enabled) +} + +/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+) +/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态 +pub fn import_from_claude(config: &mut MultiAppConfig) -> Result { + let text_opt = crate::claude_mcp::read_mcp_json()?; + let Some(text) = text_opt else { return Ok(0) }; + + let v: Value = serde_json::from_str(&text) + .map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?; + let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { + return Ok(0); + }; + + // 确保新结构存在 + let servers = config.mcp.servers.get_or_insert_with(HashMap::new); + + let mut changed = 0; + let mut errors = Vec::new(); + + for (id, spec) in map.iter() { + // 校验:单项失败不中止,收集错误继续处理 + if let Err(e) = validate_server_spec(spec) { + log::warn!("跳过无效 MCP 服务器 '{id}': {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(id) { + // 已存在:仅启用 Claude 应用 + if !existing.apps.claude { + existing.apps.claude = true; + changed += 1; + log::info!("MCP 服务器 '{id}' 已启用 Claude 应用"); + } + } else { + // 新建服务器:默认仅启用 Claude + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: spec.clone(), + apps: McpApps { + claude: true, + codex: false, + gemini: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("导入新 MCP 服务器 '{id}'"); + } + } + + if !errors.is_empty() { + log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors); + } + + Ok(changed) +} + +/// 将单个 MCP 服务器同步到 Claude live 配置 +pub fn sync_single_server_to_claude( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + // 读取现有的 MCP 配置 + let current = crate::claude_mcp::read_mcp_servers_map()?; + + // 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器 + let mut updated = current; + updated.insert(id.to_string(), server_spec.clone()); + + // 写回 + crate::claude_mcp::set_mcp_servers_map(&updated) +} + +/// 从 Claude live 配置中移除单个 MCP 服务器 +pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> { + // 读取现有的 MCP 配置 + let mut current = crate::claude_mcp::read_mcp_servers_map()?; + + // 移除指定服务器 + current.remove(id); + + // 写回 + crate::claude_mcp::set_mcp_servers_map(¤t) +} + +// ============================================================================ +// 旧版分应用 API(v3.7.0: 保留用于未来可能的迁移) +// ============================================================================ + +#[allow(dead_code)] +fn normalize_server_keys(map: &mut HashMap) -> usize { + let mut change_count = 0usize; + let mut renames: Vec<(String, String)> = Vec::new(); + + for (key_ref, value) in map.iter_mut() { + let key = key_ref.clone(); + let Some(obj) = value.as_object_mut() else { + continue; + }; + + let id_value = obj.get("id").cloned(); + + let target_id: String; + + match id_value { + Some(id_val) => match id_val.as_str() { + Some(id_str) => { + let trimmed = id_str.trim(); + if trimmed.is_empty() { + obj.insert("id".into(), json!(key.clone())); + change_count += 1; + target_id = key.clone(); + } else { + if trimmed != id_str { + obj.insert("id".into(), json!(trimmed)); + change_count += 1; + } + target_id = trimmed.to_string(); + } + } + None => { + obj.insert("id".into(), json!(key.clone())); + change_count += 1; + target_id = key.clone(); + } + }, + None => { + obj.insert("id".into(), json!(key.clone())); + change_count += 1; + target_id = key.clone(); + } + } + + if target_id != key { + renames.push((key, target_id)); + } + } + + for (old_key, new_key) in renames { + if old_key == new_key { + continue; + } + if map.contains_key(&new_key) { + log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键"); + if let Some(value) = map.get_mut(&old_key) { + if let Some(obj) = value.as_object_mut() { + if obj + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s != old_key) + .unwrap_or(true) + { + obj.insert("id".into(), json!(old_key.clone())); + change_count += 1; + } + } + } + continue; + } + if let Some(mut value) = map.remove(&old_key) { + if let Some(obj) = value.as_object_mut() { + obj.insert("id".into(), json!(new_key.clone())); + } + log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'"); + map.insert(new_key, value); + change_count += 1; + } + } + + change_count +} diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp/codex.rs similarity index 58% rename from src-tauri/src/mcp.rs rename to src-tauri/src/mcp/codex.rs index edee1d48a..a82ff75bf 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp/codex.rs @@ -1,201 +1,17 @@ +//! Codex MCP 同步和导入模块 +//! +//! 包含 Codex 的 MCP 配置管理: +//! - 从 ~/.codex/config.toml 导入 +//! - 同步到 ~/.codex/config.toml +//! - JSON 到 TOML 的转换逻辑 + use serde_json::{json, Value}; use std::collections::HashMap; -use crate::app_config::{AppType, McpConfig, MultiAppConfig}; +use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig}; use crate::error::AppError; -/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在 -fn validate_server_spec(spec: &Value) -> Result<(), AppError> { - if !spec.is_object() { - return Err(AppError::McpValidation( - "MCP 服务器连接定义必须为 JSON 对象".into(), - )); - } - let t_opt = spec.get("type").and_then(|x| x.as_str()); - // 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) - let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); - let is_http = t_opt.map(|t| t == "http").unwrap_or(false); - let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false); - - if !(is_stdio || is_http || is_sse) { - return Err(AppError::McpValidation( - "MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(), - )); - } - - if is_stdio { - let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); - if cmd.trim().is_empty() { - return Err(AppError::McpValidation( - "stdio 类型的 MCP 服务器缺少 command 字段".into(), - )); - } - } - if is_http { - let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); - if url.trim().is_empty() { - return Err(AppError::McpValidation( - "http 类型的 MCP 服务器缺少 url 字段".into(), - )); - } - } - if is_sse { - let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); - if url.trim().is_empty() { - return Err(AppError::McpValidation( - "sse 类型的 MCP 服务器缺少 url 字段".into(), - )); - } - } - Ok(()) -} - -#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移 -fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> { - let obj = entry - .as_object() - .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; - - let server = obj - .get("server") - .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?; - validate_server_spec(server)?; - - for key in ["name", "description", "homepage", "docs"] { - if let Some(val) = obj.get(key) { - if !val.is_string() { - return Err(AppError::McpValidation(format!( - "MCP 服务器 {key} 必须为字符串" - ))); - } - } - } - - if let Some(tags) = obj.get("tags") { - let arr = tags - .as_array() - .ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?; - if !arr.iter().all(|item| item.is_string()) { - return Err(AppError::McpValidation( - "MCP 服务器 tags 必须为字符串数组".into(), - )); - } - } - - if let Some(enabled) = obj.get("enabled") { - if !enabled.is_boolean() { - return Err(AppError::McpValidation( - "MCP 服务器 enabled 必须为布尔值".into(), - )); - } - } - - Ok(()) -} - -fn normalize_server_keys(map: &mut HashMap) -> usize { - let mut change_count = 0usize; - let mut renames: Vec<(String, String)> = Vec::new(); - - for (key_ref, value) in map.iter_mut() { - let key = key_ref.clone(); - let Some(obj) = value.as_object_mut() else { - continue; - }; - - let id_value = obj.get("id").cloned(); - - let target_id: String; - - match id_value { - Some(id_val) => match id_val.as_str() { - Some(id_str) => { - let trimmed = id_str.trim(); - if trimmed.is_empty() { - obj.insert("id".into(), json!(key.clone())); - change_count += 1; - target_id = key.clone(); - } else { - if trimmed != id_str { - obj.insert("id".into(), json!(trimmed)); - change_count += 1; - } - target_id = trimmed.to_string(); - } - } - None => { - obj.insert("id".into(), json!(key.clone())); - change_count += 1; - target_id = key.clone(); - } - }, - None => { - obj.insert("id".into(), json!(key.clone())); - change_count += 1; - target_id = key.clone(); - } - } - - if target_id != key { - renames.push((key, target_id)); - } - } - - for (old_key, new_key) in renames { - if old_key == new_key { - continue; - } - if map.contains_key(&new_key) { - log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键"); - if let Some(value) = map.get_mut(&old_key) { - if let Some(obj) = value.as_object_mut() { - if obj - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s != old_key) - .unwrap_or(true) - { - obj.insert("id".into(), json!(old_key.clone())); - change_count += 1; - } - } - } - continue; - } - if let Some(mut value) = map.remove(&old_key) { - if let Some(obj) = value.as_object_mut() { - obj.insert("id".into(), json!(new_key.clone())); - } - log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'"); - map.insert(new_key, value); - change_count += 1; - } - } - - change_count -} - -pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize { - let servers = &mut config.mcp_for_mut(app).servers; - normalize_server_keys(servers) -} - -fn extract_server_spec(entry: &Value) -> Result { - let obj = entry - .as_object() - .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; - let server = obj - .get("server") - .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?; - - if !server.is_object() { - return Err(AppError::McpValidation( - "MCP 服务器 server 字段必须为 JSON 对象".into(), - )); - } - - Ok(server.clone()) -} +use super::validation::{extract_server_spec, validate_server_spec}; /// 返回已启用的 MCP 服务器(过滤 enabled==true) fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { @@ -220,185 +36,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { out } -#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移 -pub fn get_servers_snapshot_for( - config: &mut MultiAppConfig, - app: &AppType, -) -> (HashMap, usize) { - let normalized = normalize_servers_for(config, app); - let mut snapshot = config.mcp_for(app).servers.clone(); - snapshot.retain(|id, value| { - let Some(obj) = value.as_object_mut() else { - log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象"); - return false; - }; - - obj.entry(String::from("id")).or_insert(json!(id)); - - match validate_mcp_entry(value) { - Ok(()) => true, - Err(err) => { - log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}"); - false - } - } - }); - (snapshot, normalized) -} - -#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移 -pub fn upsert_in_config_for( - config: &mut MultiAppConfig, - app: &AppType, - id: &str, - spec: Value, -) -> Result { - if id.trim().is_empty() { - return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); - } - normalize_servers_for(config, app); - validate_mcp_entry(&spec)?; - - let mut entry_obj = spec - .as_object() - .cloned() - .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; - if let Some(existing_id) = entry_obj.get("id") { - let Some(existing_id_str) = existing_id.as_str() else { - return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into())); - }; - if existing_id_str != id { - return Err(AppError::McpValidation(format!( - "MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致" - ))); - } - } else { - entry_obj.insert(String::from("id"), json!(id)); - } - - let value = Value::Object(entry_obj); - - let servers = &mut config.mcp_for_mut(app).servers; - let before = servers.get(id).cloned(); - servers.insert(id.to_string(), value); - - Ok(before.is_none()) -} - -#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移 -pub fn delete_in_config_for( - config: &mut MultiAppConfig, - app: &AppType, - id: &str, -) -> Result { - if id.trim().is_empty() { - return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); - } - normalize_servers_for(config, app); - let existed = config.mcp_for_mut(app).servers.remove(id).is_some(); - Ok(existed) -} - -#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移 -/// 设置启用状态(不执行落盘或文件同步) -pub fn set_enabled_flag_for( - config: &mut MultiAppConfig, - app: &AppType, - id: &str, - enabled: bool, -) -> Result { - if id.trim().is_empty() { - return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); - } - normalize_servers_for(config, app); - if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) { - // 写入 enabled 字段 - let mut obj = spec - .as_object() - .cloned() - .ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?; - obj.insert("enabled".into(), json!(enabled)); - *spec = Value::Object(obj); - } else { - // 若不存在则直接返回 false - return Ok(false); - } - - Ok(true) -} - -/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json -pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> { - let enabled = collect_enabled_servers(&config.mcp.claude); - crate::claude_mcp::set_mcp_servers_map(&enabled) -} - -/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+) -/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态 -pub fn import_from_claude(config: &mut MultiAppConfig) -> Result { - use crate::app_config::{McpApps, McpServer}; - - let text_opt = crate::claude_mcp::read_mcp_json()?; - let Some(text) = text_opt else { return Ok(0) }; - - let v: Value = serde_json::from_str(&text) - .map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?; - let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { - return Ok(0); - }; - - // 确保新结构存在 - let servers = config.mcp.servers.get_or_insert_with(HashMap::new); - - let mut changed = 0; - let mut errors = Vec::new(); - - for (id, spec) in map.iter() { - // 校验:单项失败不中止,收集错误继续处理 - if let Err(e) = validate_server_spec(spec) { - log::warn!("跳过无效 MCP 服务器 '{id}': {e}"); - errors.push(format!("{id}: {e}")); - continue; - } - - if let Some(existing) = servers.get_mut(id) { - // 已存在:仅启用 Claude 应用 - if !existing.apps.claude { - existing.apps.claude = true; - changed += 1; - log::info!("MCP 服务器 '{id}' 已启用 Claude 应用"); - } - } else { - // 新建服务器:默认仅启用 Claude - servers.insert( - id.clone(), - McpServer { - id: id.clone(), - name: id.clone(), - server: spec.clone(), - apps: McpApps { - claude: true, - codex: false, - gemini: false, - }, - description: None, - homepage: None, - docs: None, - tags: Vec::new(), - }, - ); - changed += 1; - log::info!("导入新 MCP 服务器 '{id}'"); - } - } - - if !errors.is_empty() { - log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors); - } - - Ok(changed) -} - /// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+) /// /// 格式支持: @@ -407,8 +44,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result { - use crate::app_config::{McpApps, McpServer}; - let text = crate::codex_config::read_and_validate_codex_config_text()?; if text.trim().is_empty() { return Ok(0); @@ -697,111 +332,95 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { Ok(()) } -/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json -pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> { - let enabled = collect_enabled_servers(&config.mcp.gemini); - crate::gemini_mcp::set_mcp_servers_map(&enabled) -} - -/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构(v3.7.0+) -/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态 -pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result { - use crate::app_config::{McpApps, McpServer}; - - let text_opt = crate::gemini_mcp::read_mcp_json()?; - let Some(text) = text_opt else { return Ok(0) }; - - let v: Value = serde_json::from_str(&text) - .map_err(|e| AppError::McpValidation(format!("解析 ~/.gemini/settings.json 失败: {e}")))?; - let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { - return Ok(0); - }; - - // 确保新结构存在 - let servers = config.mcp.servers.get_or_insert_with(HashMap::new); - - let mut changed = 0; - let mut errors = Vec::new(); - - for (id, spec) in map.iter() { - // 校验:单项失败不中止,收集错误继续处理 - if let Err(e) = validate_server_spec(spec) { - log::warn!("跳过无效 MCP 服务器 '{id}': {e}"); - errors.push(format!("{id}: {e}")); - continue; - } - - if let Some(existing) = servers.get_mut(id) { - // 已存在:仅启用 Gemini 应用 - if !existing.apps.gemini { - existing.apps.gemini = true; - changed += 1; - log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用"); - } - } else { - // 新建服务器:默认仅启用 Gemini - servers.insert( - id.clone(), - McpServer { - id: id.clone(), - name: id.clone(), - server: spec.clone(), - apps: McpApps { - claude: false, - codex: false, - gemini: true, - }, - description: None, - homepage: None, - docs: None, - tags: Vec::new(), - }, - ); - changed += 1; - log::info!("导入新 MCP 服务器 '{id}'"); - } - } - - if !errors.is_empty() { - log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors); - } - - Ok(changed) -} - -// ============================================================================ -// v3.7.0 新增:单个服务器同步和删除函数 -// ============================================================================ - -/// 将单个 MCP 服务器同步到 Claude live 配置 -pub fn sync_single_server_to_claude( +/// 将单个 MCP 服务器同步到 Codex live 配置 +/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers] +pub fn sync_single_server_to_codex( _config: &MultiAppConfig, id: &str, server_spec: &Value, ) -> Result<(), AppError> { - // 读取现有的 MCP 配置 - let current = crate::claude_mcp::read_mcp_servers_map()?; + use toml_edit::Item; - // 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器 - let mut updated = current; - updated.insert(id.to_string(), server_spec.clone()); + // 读取现有的 config.toml + let config_path = crate::codex_config::get_codex_config_path(); - // 写回 - crate::claude_mcp::set_mcp_servers_map(&updated) + let mut doc = if config_path.exists() { + let content = + std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; + content + .parse::() + .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))? + } else { + toml_edit::DocumentMut::new() + }; + + // 清理可能存在的错误格式 [mcp.servers] + if let Some(mcp_item) = doc.get_mut("mcp") { + if let Some(tbl) = mcp_item.as_table_like_mut() { + if tbl.contains_key("servers") { + log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]"); + tbl.remove("servers"); + } + } + } + + // 确保 [mcp_servers] 表存在 + if !doc.contains_key("mcp_servers") { + doc["mcp_servers"] = toml_edit::table(); + } + + // 将 JSON 服务器规范转换为 TOML 表 + let toml_table = json_server_to_toml_table(server_spec)?; + + // 使用唯一正确的格式:[mcp_servers] + doc["mcp_servers"][id] = Item::Table(toml_table); + + // 写回文件 + std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; + + Ok(()) } -/// 从 Claude live 配置中移除单个 MCP 服务器 -pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> { - // 读取现有的 MCP 配置 - let mut current = crate::claude_mcp::read_mcp_servers_map()?; +/// 从 Codex live 配置中移除单个 MCP 服务器 +/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据 +pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { + let config_path = crate::codex_config::get_codex_config_path(); - // 移除指定服务器 - current.remove(id); + if !config_path.exists() { + return Ok(()); // 文件不存在,无需删除 + } - // 写回 - crate::claude_mcp::set_mcp_servers_map(¤t) + let content = + std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; + + let mut doc = content + .parse::() + .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?; + + // 从正确的位置删除:[mcp_servers] + if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) { + mcp_servers.remove(id); + } + + // 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在) + if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) { + if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) { + if servers.remove(id).is_some() { + log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'"); + } + } + } + + // 写回文件 + std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; + + Ok(()) } +// ============================================================================ +// TOML 转换辅助函数 +// ============================================================================ + /// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套) /// /// 支持的类型转换: @@ -1030,117 +649,3 @@ fn json_server_to_toml_table(spec: &Value) -> Result Ok(t) } - -/// 将单个 MCP 服务器同步到 Codex live 配置 -/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers] -pub fn sync_single_server_to_codex( - _config: &MultiAppConfig, - id: &str, - server_spec: &Value, -) -> Result<(), AppError> { - use toml_edit::Item; - - // 读取现有的 config.toml - let config_path = crate::codex_config::get_codex_config_path(); - - let mut doc = if config_path.exists() { - let content = - std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - content - .parse::() - .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))? - } else { - toml_edit::DocumentMut::new() - }; - - // 清理可能存在的错误格式 [mcp.servers] - if let Some(mcp_item) = doc.get_mut("mcp") { - if let Some(tbl) = mcp_item.as_table_like_mut() { - if tbl.contains_key("servers") { - log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]"); - tbl.remove("servers"); - } - } - } - - // 确保 [mcp_servers] 表存在 - if !doc.contains_key("mcp_servers") { - doc["mcp_servers"] = toml_edit::table(); - } - - // 将 JSON 服务器规范转换为 TOML 表 - let toml_table = json_server_to_toml_table(server_spec)?; - - // 使用唯一正确的格式:[mcp_servers] - doc["mcp_servers"][id] = Item::Table(toml_table); - - // 写回文件 - std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; - - Ok(()) -} - -/// 从 Codex live 配置中移除单个 MCP 服务器 -/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据 -pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { - let config_path = crate::codex_config::get_codex_config_path(); - - if !config_path.exists() { - return Ok(()); // 文件不存在,无需删除 - } - - let content = - std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - - let mut doc = content - .parse::() - .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?; - - // 从正确的位置删除:[mcp_servers] - if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) { - mcp_servers.remove(id); - } - - // 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在) - if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) { - if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) { - if servers.remove(id).is_some() { - log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'"); - } - } - } - - // 写回文件 - std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; - - Ok(()) -} - -/// 将单个 MCP 服务器同步到 Gemini live 配置 -pub fn sync_single_server_to_gemini( - _config: &MultiAppConfig, - id: &str, - server_spec: &Value, -) -> Result<(), AppError> { - // 读取现有的 MCP 配置 - let current = crate::gemini_mcp::read_mcp_servers_map()?; - - // 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器 - let mut updated = current; - updated.insert(id.to_string(), server_spec.clone()); - - // 写回 - crate::gemini_mcp::set_mcp_servers_map(&updated) -} - -/// 从 Gemini live 配置中移除单个 MCP 服务器 -pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> { - // 读取现有的 MCP 配置 - let mut current = crate::gemini_mcp::read_mcp_servers_map()?; - - // 移除指定服务器 - current.remove(id); - - // 写回 - crate::gemini_mcp::set_mcp_servers_map(¤t) -} diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs new file mode 100644 index 000000000..fde8e119d --- /dev/null +++ b/src-tauri/src/mcp/gemini.rs @@ -0,0 +1,126 @@ +//! Gemini MCP 同步和导入模块 + +use serde_json::Value; +use std::collections::HashMap; + +use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig}; +use crate::error::AppError; + +use super::validation::{extract_server_spec, validate_server_spec}; + +/// 返回已启用的 MCP 服务器(过滤 enabled==true) +fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { + let mut out = HashMap::new(); + for (id, entry) in cfg.servers.iter() { + let enabled = entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + continue; + } + match extract_server_spec(entry) { + Ok(spec) => { + out.insert(id.clone(), spec); + } + Err(err) => { + log::warn!("跳过无效的 MCP 条目 '{id}': {err}"); + } + } + } + out +} + +/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置 +pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> { + let enabled = collect_enabled_servers(&config.mcp.gemini); + crate::gemini_mcp::set_mcp_servers_map(&enabled) +} + +/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+) +/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态 +pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result { + let map = crate::gemini_mcp::read_mcp_servers_map()?; + if map.is_empty() { + return Ok(0); + } + + // 确保新结构存在 + let servers = config.mcp.servers.get_or_insert_with(HashMap::new); + + let mut changed = 0; + let mut errors = Vec::new(); + + for (id, spec) in map.iter() { + // 校验:单项失败不中止,收集错误继续处理 + if let Err(e) = validate_server_spec(spec) { + log::warn!("跳过无效 MCP 服务器 '{id}': {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(id) { + // 已存在:仅启用 Gemini 应用 + if !existing.apps.gemini { + existing.apps.gemini = true; + changed += 1; + log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用"); + } + } else { + // 新建服务器:默认仅启用 Gemini + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: spec.clone(), + apps: McpApps { + claude: false, + codex: false, + gemini: true, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("导入新 MCP 服务器 '{id}'"); + } + } + + if !errors.is_empty() { + log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors); + } + + Ok(changed) +} + +/// 将单个 MCP 服务器同步到 Gemini live 配置 +pub fn sync_single_server_to_gemini( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + // 读取现有的 MCP 配置 + let mut current = crate::gemini_mcp::read_mcp_servers_map()?; + + // 添加/更新当前服务器 + current.insert(id.to_string(), server_spec.clone()); + + // 写回 + crate::gemini_mcp::set_mcp_servers_map(¤t) +} + +/// 从 Gemini live 配置中移除单个 MCP 服务器 +pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> { + // 读取现有的 MCP 配置 + let mut current = crate::gemini_mcp::read_mcp_servers_map()?; + + // 移除指定服务器 + current.remove(id); + + // 写回 + crate::gemini_mcp::set_mcp_servers_map(¤t) +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs new file mode 100644 index 000000000..55fee60da --- /dev/null +++ b/src-tauri/src/mcp/mod.rs @@ -0,0 +1,28 @@ +//! MCP (Model Context Protocol) 服务器管理模块 +//! +//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。 +//! +//! ## 模块结构 +//! +//! - `validation` - 服务器配置验证 +//! - `claude` - Claude MCP 同步和导入 +//! - `codex` - Codex MCP 同步和导入(含 TOML 转换) +//! - `gemini` - Gemini MCP 同步和导入 + +mod claude; +mod codex; +mod gemini; +mod validation; + +// 重新导出公共 API +pub use claude::{ + import_from_claude, remove_server_from_claude, sync_enabled_to_claude, + sync_single_server_to_claude, +}; +pub use codex::{ + import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex, +}; +pub use gemini::{ + import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini, + sync_single_server_to_gemini, +}; diff --git a/src-tauri/src/mcp/validation.rs b/src-tauri/src/mcp/validation.rs new file mode 100644 index 000000000..06e93c08f --- /dev/null +++ b/src-tauri/src/mcp/validation.rs @@ -0,0 +1,112 @@ +//! MCP 服务器配置验证模块 + +use serde_json::Value; + +use crate::error::AppError; + +/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在 +pub fn validate_server_spec(spec: &Value) -> Result<(), AppError> { + if !spec.is_object() { + return Err(AppError::McpValidation( + "MCP 服务器连接定义必须为 JSON 对象".into(), + )); + } + let t_opt = spec.get("type").and_then(|x| x.as_str()); + // 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) + let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); + let is_http = t_opt.map(|t| t == "http").unwrap_or(false); + let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false); + + if !(is_stdio || is_http || is_sse) { + return Err(AppError::McpValidation( + "MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(), + )); + } + + if is_stdio { + let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); + if cmd.trim().is_empty() { + return Err(AppError::McpValidation( + "stdio 类型的 MCP 服务器缺少 command 字段".into(), + )); + } + } + if is_http { + let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); + if url.trim().is_empty() { + return Err(AppError::McpValidation( + "http 类型的 MCP 服务器缺少 url 字段".into(), + )); + } + } + if is_sse { + let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); + if url.trim().is_empty() { + return Err(AppError::McpValidation( + "sse 类型的 MCP 服务器缺少 url 字段".into(), + )); + } + } + Ok(()) +} + +#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移 +pub fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> { + let obj = entry + .as_object() + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; + + let server = obj + .get("server") + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?; + validate_server_spec(server)?; + + for key in ["name", "description", "homepage", "docs"] { + if let Some(val) = obj.get(key) { + if !val.is_string() { + return Err(AppError::McpValidation(format!( + "MCP 服务器 {key} 必须为字符串" + ))); + } + } + } + + if let Some(tags) = obj.get("tags") { + let arr = tags + .as_array() + .ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?; + if !arr.iter().all(|item| item.is_string()) { + return Err(AppError::McpValidation( + "MCP 服务器 tags 必须为字符串数组".into(), + )); + } + } + + if let Some(enabled) = obj.get("enabled") { + if !enabled.is_boolean() { + return Err(AppError::McpValidation( + "MCP 服务器 enabled 必须为布尔值".into(), + )); + } + } + + Ok(()) +} + +/// 从 MCP 条目中提取服务器规范 +pub fn extract_server_spec(entry: &Value) -> Result { + let obj = entry + .as_object() + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?; + let server = obj + .get("server") + .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?; + + if !server.is_object() { + return Err(AppError::McpValidation( + "MCP 服务器 server 字段必须为 JSON 对象".into(), + )); + } + + Ok(server.clone()) +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 000000000..054574edc --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,300 @@ +//! 托盘菜单管理模块 +//! +//! 负责系统托盘图标和菜单的创建、更新和事件处理。 + +use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}; +use tauri::{Emitter, Manager}; + +use crate::app_config::AppType; +use crate::error::AppError; +use crate::store::AppState; + +/// 托盘菜单文本(国际化) +#[derive(Clone, Copy)] +pub struct TrayTexts { + pub show_main: &'static str, + pub no_provider_hint: &'static str, + pub quit: &'static str, +} + +impl TrayTexts { + pub fn from_language(language: &str) -> Self { + match language { + "en" => Self { + show_main: "Open main window", + no_provider_hint: " (No providers yet, please add them from the main window)", + quit: "Quit", + }, + "ja" => Self { + show_main: "メインウィンドウを開く", + no_provider_hint: + " (プロバイダーがまだありません。メイン画面から追加してください)", + quit: "終了", + }, + _ => Self { + show_main: "打开主界面", + no_provider_hint: " (无供应商,请在主界面添加)", + quit: "退出", + }, + } + } +} + +/// 托盘应用分区配置 +pub struct TrayAppSection { + pub app_type: AppType, + pub prefix: &'static str, + pub header_id: &'static str, + pub empty_id: &'static str, + pub header_label: &'static str, + pub log_name: &'static str, +} + +pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ + TrayAppSection { + app_type: AppType::Claude, + prefix: "claude_", + header_id: "claude_header", + empty_id: "claude_empty", + header_label: "─── Claude ───", + log_name: "Claude", + }, + TrayAppSection { + app_type: AppType::Codex, + prefix: "codex_", + header_id: "codex_header", + empty_id: "codex_empty", + header_label: "─── Codex ───", + log_name: "Codex", + }, + TrayAppSection { + app_type: AppType::Gemini, + prefix: "gemini_", + header_id: "gemini_header", + empty_id: "gemini_empty", + header_label: "─── Gemini ───", + log_name: "Gemini", + }, +]; + +/// 添加供应商分区到菜单 +fn append_provider_section<'a>( + app: &'a tauri::AppHandle, + mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle>, + manager: Option<&crate::provider::ProviderManager>, + section: &TrayAppSection, + tray_texts: &TrayTexts, +) -> Result>, AppError> { + let Some(manager) = manager else { + return Ok(menu_builder); + }; + + let header = MenuItem::with_id( + app, + section.header_id, + section.header_label, + false, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?; + menu_builder = menu_builder.item(&header); + + if manager.providers.is_empty() { + let empty_hint = MenuItem::with_id( + app, + section.empty_id, + tray_texts.no_provider_hint, + false, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?; + return Ok(menu_builder.item(&empty_hint)); + } + + let mut sorted_providers: Vec<_> = manager.providers.iter().collect(); + sorted_providers.sort_by(|(_, a), (_, b)| { + match (a.sort_index, b.sort_index) { + (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), + (Some(_), None) => return std::cmp::Ordering::Less, + (None, Some(_)) => return std::cmp::Ordering::Greater, + _ => {} + } + + match (a.created_at, b.created_at) { + (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b), + (Some(_), None) => return std::cmp::Ordering::Greater, + (None, Some(_)) => return std::cmp::Ordering::Less, + _ => {} + } + + a.name.cmp(&b.name) + }); + + for (id, provider) in sorted_providers { + let is_current = manager.current == *id; + let item = CheckMenuItem::with_id( + app, + format!("{}{}", section.prefix, id), + &provider.name, + true, + is_current, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?; + menu_builder = menu_builder.item(&item); + } + + Ok(menu_builder) +} + +/// 处理供应商托盘事件 +pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { + for section in TRAY_SECTIONS.iter() { + if let Some(provider_id) = event_id.strip_prefix(section.prefix) { + log::info!("切换到{}供应商: {provider_id}", section.log_name); + let app_handle = app.clone(); + let provider_id = provider_id.to_string(); + let app_type = section.app_type.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) { + log::error!("切换{}供应商失败: {e}", section.log_name); + } + }); + return true; + } + } + false +} + +/// 创建动态托盘菜单 +pub fn create_tray_menu( + app: &tauri::AppHandle, + app_state: &AppState, +) -> Result, AppError> { + let app_settings = crate::settings::get_settings(); + let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh")); + + let mut menu_builder = MenuBuilder::new(app); + + // 顶部:打开主界面 + let show_main_item = + MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>) + .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?; + menu_builder = menu_builder.item(&show_main_item).separator(); + + // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) + for section in TRAY_SECTIONS.iter() { + let app_type_str = section.app_type.as_str(); + let providers = app_state.db.get_all_providers(app_type_str)?; + + // 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID) + let current_id = + crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)? + .unwrap_or_default(); + + let manager = crate::provider::ProviderManager { + providers, + current: current_id, + }; + + menu_builder = + append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?; + } + + // 分隔符和退出菜单 + let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>) + .map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?; + + menu_builder = menu_builder.separator().item(&quit_item); + + menu_builder + .build() + .map_err(|e| AppError::Message(format!("构建菜单失败: {e}"))) +} + +#[cfg(target_os = "macos")] +pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) { + use tauri::ActivationPolicy; + + let desired_policy = if dock_visible { + ActivationPolicy::Regular + } else { + ActivationPolicy::Accessory + }; + + if let Err(err) = app.set_dock_visibility(dock_visible) { + log::warn!("设置 Dock 显示状态失败: {err}"); + } + + if let Err(err) = app.set_activation_policy(desired_policy) { + log::warn!("设置激活策略失败: {err}"); + } +} + +/// 处理托盘菜单事件 +pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { + log::info!("处理托盘菜单事件: {event_id}"); + + match event_id { + "show_main" => { + if let Some(window) = app.get_webview_window("main") { + #[cfg(target_os = "windows")] + { + let _ = window.set_skip_taskbar(false); + } + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + { + apply_tray_policy(app, true); + } + } + } + "quit" => { + log::info!("退出应用"); + app.exit(0); + } + _ => { + if handle_provider_tray_event(app, event_id) { + return; + } + log::warn!("未处理的菜单事件: {event_id}"); + } + } +} + +/// 内部切换供应商函数 +pub fn switch_provider_internal( + app: &tauri::AppHandle, + app_type: AppType, + provider_id: String, +) -> Result<(), AppError> { + if let Some(app_state) = app.try_state::() { + // 在使用前先保存需要的值 + let app_type_str = app_type.as_str().to_string(); + let provider_id_clone = provider_id.clone(); + + crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id) + .map_err(AppError::Message)?; + + // 切换成功后重新创建托盘菜单 + if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { + if let Some(tray) = app.tray_by_id("main") { + if let Err(e) = tray.set_menu(Some(new_menu)) { + log::error!("更新托盘菜单失败: {e}"); + } + } + } + + // 发射事件到前端,通知供应商已切换 + let event_data = serde_json::json!({ + "appType": app_type_str, + "providerId": provider_id_clone + }); + if let Err(e) = app.emit("provider-switched", event_data) { + log::error!("发射供应商切换事件失败: {e}"); + } + } + Ok(()) +}