From becb2a5291b9776b06fba18b229e4f03af07c0d0 Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Thu, 5 Feb 2026 22:33:16 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(profile):=20=E7=A8=B3=E5=AE=9A=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=B9=E6=A1=88=E5=88=97=E8=A1=A8=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/profile_manager/manager.rs | 214 +++++++++++++++++- 1 file changed, 208 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 821bc34..8278db3 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -173,12 +173,14 @@ impl ProfileManager { pub fn list_claude_profiles(&self) -> Result> { let store = self.load_profiles_store()?; - Ok(store + let mut names: Vec = store .claude_code .keys() .filter(|name| !name.starts_with(RESERVED_PREFIX)) .cloned() - .collect()) + .collect(); + names.sort(); + Ok(names) } // ==================== Codex ==================== @@ -274,12 +276,14 @@ impl ProfileManager { pub fn list_codex_profiles(&self) -> Result> { let store = self.load_profiles_store()?; - Ok(store + let mut names: Vec = store .codex .keys() .filter(|name| !name.starts_with(RESERVED_PREFIX)) .cloned() - .collect()) + .collect(); + names.sort(); + Ok(names) } // ==================== Gemini CLI ==================== @@ -377,12 +381,14 @@ impl ProfileManager { pub fn list_gemini_profiles(&self) -> Result> { let store = self.load_profiles_store()?; - Ok(store + let mut names: Vec = store .gemini_cli .keys() .filter(|name| !name.starts_with(RESERVED_PREFIX)) .cloned() - .collect()) + .collect(); + names.sort(); + Ok(names) } // ==================== 通用列表 ==================== @@ -419,6 +425,26 @@ impl ProfileManager { descriptors.push(ProfileDescriptor::from_gemini(name, profile, active_gemini)); } + // HashMap 遍历顺序不稳定:这里做显式排序,保证前端展示稳定 + fn tool_rank(tool_id: &str) -> u8 { + match tool_id { + "claude-code" => 0, + "codex" => 1, + "gemini-cli" => 2, + _ => 99, + } + } + + descriptors.sort_by(|a, b| { + tool_rank(&a.tool_id) + .cmp(&tool_rank(&b.tool_id)) + // 同工具内:激活的 Profile 置顶 + .then_with(|| b.is_active.cmp(&a.is_active)) + // 其余按创建时间倒序(新建的更靠前),再按名称稳定排序 + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(descriptors) } @@ -819,6 +845,182 @@ impl ProfileManager { } } +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use tempfile::TempDir; + + fn test_manager(temp_dir: &TempDir) -> ProfileManager { + ProfileManager { + data_manager: DataManager::new(), + profiles_path: temp_dir.path().join("profiles.json"), + active_path: temp_dir.path().join("active.json"), + } + } + + #[test] + fn test_list_profiles_sorted_and_filters_reserved() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let manager = test_manager(&temp_dir); + + let mut store = ProfilesStore::new(); + let created_at = Utc.timestamp_opt(1, 0).single().unwrap(); + let updated_at = created_at; + + store.claude_code.insert( + "b".to_string(), + ClaudeProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + source: ProfileSource::Custom, + created_at, + updated_at, + raw_settings: None, + raw_config_json: None, + pricing_template_id: None, + }, + ); + store.claude_code.insert( + "a".to_string(), + ClaudeProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + source: ProfileSource::Custom, + created_at, + updated_at, + raw_settings: None, + raw_config_json: None, + pricing_template_id: None, + }, + ); + store.claude_code.insert( + "dc_proxy_internal".to_string(), + ClaudeProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + source: ProfileSource::Custom, + created_at, + updated_at, + raw_settings: None, + raw_config_json: None, + pricing_template_id: None, + }, + ); + + manager.save_profiles_store(&store)?; + + let names = manager.list_claude_profiles()?; + assert_eq!(names, vec!["a".to_string(), "b".to_string()]); + Ok(()) + } + + #[test] + fn test_list_all_descriptors_sorted_stable() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let manager = test_manager(&temp_dir); + + let mut store = ProfilesStore::new(); + + let t1 = Utc.timestamp_opt(1, 0).single().unwrap(); + let t2 = Utc.timestamp_opt(2, 0).single().unwrap(); + let t3 = Utc.timestamp_opt(3, 0).single().unwrap(); + + // 同工具内:激活置顶,其他按 created_at 倒序 + store.claude_code.insert( + "p_middle".to_string(), + ClaudeProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + source: ProfileSource::Custom, + created_at: t2, + updated_at: t2, + raw_settings: None, + raw_config_json: None, + pricing_template_id: None, + }, + ); + store.claude_code.insert( + "p_new".to_string(), + ClaudeProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + source: ProfileSource::Custom, + created_at: t3, + updated_at: t3, + raw_settings: None, + raw_config_json: None, + pricing_template_id: None, + }, + ); + store.claude_code.insert( + "p_active_old".to_string(), + ClaudeProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + source: ProfileSource::Custom, + created_at: t1, + updated_at: t1, + raw_settings: None, + raw_config_json: None, + pricing_template_id: None, + }, + ); + + // 其他工具插入一个,验证 tool_id rank 的稳定性(claude -> codex -> gemini) + store.codex.insert( + "codex_one".to_string(), + CodexProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + wire_api: "responses".to_string(), + source: ProfileSource::Custom, + created_at: t2, + updated_at: t2, + raw_config_toml: None, + raw_auth_json: None, + pricing_template_id: None, + }, + ); + store.gemini_cli.insert( + "gemini_one".to_string(), + GeminiProfile { + api_key: "k".to_string(), + base_url: "u".to_string(), + model: None, + source: ProfileSource::Custom, + created_at: t2, + updated_at: t2, + raw_settings: None, + raw_env: None, + pricing_template_id: None, + }, + ); + + manager.save_profiles_store(&store)?; + + let mut active = ActiveStore::new(); + active.set_active("claude-code", "p_active_old".to_string()); + manager.save_active_store(&active)?; + + let descriptors = manager.list_all_descriptors()?; + let names: Vec = descriptors.iter().map(|d| d.name.clone()).collect(); + + assert_eq!( + names, + vec![ + "p_active_old".to_string(), + "p_new".to_string(), + "p_middle".to_string(), + "codex_one".to_string(), + "gemini_one".to_string() + ] + ); + + Ok(()) + } +} + impl Default for ProfileManager { fn default() -> Self { Self::new().expect("创建 ProfileManager 失败") From 3cdb012f51cf4f1f622d5f6e55b9e0568733cd87 Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Thu, 5 Feb 2026 22:51:19 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(ui):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=A2=9E=E5=8A=A0=E8=BF=87=E6=B8=A1=E5=8A=A8?= =?UTF-8?q?=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/MainLayout.tsx | 4 +- src/components/logic/AppContent.tsx | 74 ++++++++++++++++------------ 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 09fc65c..076f3f6 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -26,9 +26,7 @@ export function MainLayout({ children }: MainLayoutProps) { {/* Scrollable Content */}
-
- {children} -
+
{children}
diff --git a/src/components/logic/AppContent.tsx b/src/components/logic/AppContent.tsx index b1fc73b..1cee0af 100644 --- a/src/components/logic/AppContent.tsx +++ b/src/components/logic/AppContent.tsx @@ -21,35 +21,47 @@ export function AppContent() { // We will need to handle those interactions via context or a global event bus later. // For this step, I'll pass dummy or context-derived functions. - switch (activeTab) { - case 'dashboard': - return ; - case 'tool-management': - return ; - case 'install': - return ; - case 'balance': - return ; - case 'profile-management': - return ; - case 'transparent-proxy': - return ; - case 'token-statistics': - return ( - - ); - case 'settings': - return ; - case 'provider-management': - return ; - case 'help': - return ; - case 'about': - return ; - default: - return null; - } + const content = (() => { + switch (activeTab) { + case 'dashboard': + return ; + case 'tool-management': + return ; + case 'install': + return ; + case 'balance': + return ; + case 'profile-management': + return ; + case 'transparent-proxy': + return ; + case 'token-statistics': + return ( + + ); + case 'settings': + return ; + case 'provider-management': + return ; + case 'help': + return ; + case 'about': + return ; + default: + return null; + } + })(); + + return ( +
+ {content} +
+ ); } From cbe86edf47dbf48cf64aedaa396225c269a8ef89 Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Thu, 5 Feb 2026 22:51:49 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(rust):=20=E8=B0=83=E6=95=B4=20tests=20?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E4=BB=A5=E9=80=9A=E8=BF=87=20clippy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/profile_manager/manager.rs | 164 +++++++++--------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 8278db3..3cc820e 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -845,6 +845,88 @@ impl ProfileManager { } } +impl Default for ProfileManager { + fn default() -> Self { + Self::new().expect("创建 ProfileManager 失败") + } +} + +// ==================== AMP Profile Selection ==================== + +impl ProfileManager { + /// 获取 AMP Profile 选择的存储路径 + fn amp_selection_path(&self) -> PathBuf { + self.profiles_path + .parent() + .unwrap() + .join("amp_selection.json") + } + + /// 获取 AMP Profile 选择 + pub fn get_amp_selection(&self) -> Result { + let path = self.amp_selection_path(); + if !path.exists() { + return Ok(AmpProfileSelection::default()); + } + let value = self.data_manager.json().read(&path)?; + serde_json::from_value(value).context("反序列化 AmpProfileSelection 失败") + } + + /// 保存 AMP Profile 选择 + pub fn save_amp_selection(&self, selection: &AmpProfileSelection) -> Result<()> { + let path = self.amp_selection_path(); + + // 验证引用的 profile 存在 + let store = self.load_profiles_store()?; + + if let Some(ref claude) = selection.claude { + if !store.claude_code.contains_key(&claude.profile_name) { + return Err(anyhow!("Claude Profile 不存在: {}", claude.profile_name)); + } + } + + if let Some(ref codex) = selection.codex { + if !store.codex.contains_key(&codex.profile_name) { + return Err(anyhow!("Codex Profile 不存在: {}", codex.profile_name)); + } + } + + if let Some(ref gemini) = selection.gemini { + if !store.gemini_cli.contains_key(&gemini.profile_name) { + return Err(anyhow!("Gemini Profile 不存在: {}", gemini.profile_name)); + } + } + + let value = serde_json::to_value(selection)?; + self.data_manager.json().write(&path, &value)?; + Ok(()) + } + + /// 解析 AMP Profile 选择,返回实际的 Profile 数据 + pub fn resolve_amp_selection( + &self, + ) -> Result<( + Option, + Option, + Option, + )> { + let selection = self.get_amp_selection()?; + let store = self.load_profiles_store()?; + + let claude = selection + .claude + .and_then(|r| store.claude_code.get(&r.profile_name).cloned()); + let codex = selection + .codex + .and_then(|r| store.codex.get(&r.profile_name).cloned()); + let gemini = selection + .gemini + .and_then(|r| store.gemini_cli.get(&r.profile_name).cloned()); + + Ok((claude, codex, gemini)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1020,85 +1102,3 @@ mod tests { Ok(()) } } - -impl Default for ProfileManager { - fn default() -> Self { - Self::new().expect("创建 ProfileManager 失败") - } -} - -// ==================== AMP Profile Selection ==================== - -impl ProfileManager { - /// 获取 AMP Profile 选择的存储路径 - fn amp_selection_path(&self) -> PathBuf { - self.profiles_path - .parent() - .unwrap() - .join("amp_selection.json") - } - - /// 获取 AMP Profile 选择 - pub fn get_amp_selection(&self) -> Result { - let path = self.amp_selection_path(); - if !path.exists() { - return Ok(AmpProfileSelection::default()); - } - let value = self.data_manager.json().read(&path)?; - serde_json::from_value(value).context("反序列化 AmpProfileSelection 失败") - } - - /// 保存 AMP Profile 选择 - pub fn save_amp_selection(&self, selection: &AmpProfileSelection) -> Result<()> { - let path = self.amp_selection_path(); - - // 验证引用的 profile 存在 - let store = self.load_profiles_store()?; - - if let Some(ref claude) = selection.claude { - if !store.claude_code.contains_key(&claude.profile_name) { - return Err(anyhow!("Claude Profile 不存在: {}", claude.profile_name)); - } - } - - if let Some(ref codex) = selection.codex { - if !store.codex.contains_key(&codex.profile_name) { - return Err(anyhow!("Codex Profile 不存在: {}", codex.profile_name)); - } - } - - if let Some(ref gemini) = selection.gemini { - if !store.gemini_cli.contains_key(&gemini.profile_name) { - return Err(anyhow!("Gemini Profile 不存在: {}", gemini.profile_name)); - } - } - - let value = serde_json::to_value(selection)?; - self.data_manager.json().write(&path, &value)?; - Ok(()) - } - - /// 解析 AMP Profile 选择,返回实际的 Profile 数据 - pub fn resolve_amp_selection( - &self, - ) -> Result<( - Option, - Option, - Option, - )> { - let selection = self.get_amp_selection()?; - let store = self.load_profiles_store()?; - - let claude = selection - .claude - .and_then(|r| store.claude_code.get(&r.profile_name).cloned()); - let codex = selection - .codex - .and_then(|r| store.codex.get(&r.profile_name).cloned()); - let gemini = selection - .gemini - .and_then(|r| store.gemini_cli.get(&r.profile_name).cloned()); - - Ok((claude, codex, gemini)) - } -} From 19bc0637a326294fb298c223b898433dcbf3799f Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Sat, 7 Feb 2026 11:10:15 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(menu):=20=E6=B7=BB=E5=8A=A0=20macOS=20?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=20Profile=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=20Add=20?= =?UTF-8?q?implementation=20plan=20for=20macOS=20menu=20bar=20profile=20sw?= =?UTF-8?q?itching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 制定了在 macOS 标题栏实现 Profile 快捷切换的技术方案 Developed a technical plan for implementing profile switching in the macOS menu bar 采用 Tauri v2 原生菜单 API 构建层级菜单结构 Utilized Tauri v2 native Menu API to build hierarchical menu structures 设计了支持 Claude Code、Codex 和 Gemini CLI 的菜单项 Designed menu items supporting Claude Code, Codex, and Gemini CLI 规划了动态更新菜单状态和处理切换事件的逻辑 Planned logic for dynamic menu status updates and switching event handling --- PLAN.md | 214 +++++++++++++++ src-tauri/src/commands/window_commands.rs | 18 +- src-tauri/src/main.rs | 11 +- src-tauri/src/setup/menu.rs | 254 ++++++++++++++++++ src-tauri/src/setup/mod.rs | 4 + src-tauri/src/setup/tray.rs | 10 +- src/components/logic/AppEventsHandler.tsx | 24 ++ src/lib/tauri-commands/profile.ts | 9 + .../hooks/useProfileManagement.ts | 3 + 9 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 PLAN.md create mode 100644 src-tauri/src/setup/menu.rs diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..2256bef --- /dev/null +++ b/PLAN.md @@ -0,0 +1,214 @@ +# macOS 标题栏 Profile 快捷切换功能实现计划 + +## 需求概述 + +在 macOS 下点击顶部标题栏(应用菜单栏),显示快捷操作菜单: + +- 显示 Claude Code、Codex、Gemini CLI 三个工具的配置方案(Profile) +- 显示每个工具当前激活的配置 +- 支持从菜单栏直接切换激活的配置 + +## 技术方案 + +### 方案选择:Tauri v2 原生应用菜单栏 + +使用 Tauri v2 的 `Menu` API 创建 macOS 原生应用菜单栏(非托盘菜单),通过 `SubmenuBuilder` 构建层级菜单结构。 + +**优势**: + +- 原生 macOS 体验,符合用户习惯 +- 支持动态更新菜单项 +- 支持复选标记(CheckMenuItem)显示当前激活状态 +- 与现有托盘菜单系统架构一致 + +### 菜单结构设计 + +``` +DuckCoding (应用菜单) +├── 关于 DuckCoding +├── ───────────── +├── 配置方案 ← 新增子菜单 +│ ├── Claude Code +│ │ ├── ✓ profile-1 ← 当前激活(带复选标记) +│ │ ├── profile-2 +│ │ └── profile-3 +│ ├── ───────────── +│ ├── Codex +│ │ ├── codex-profile-1 +│ │ └── ✓ codex-profile-2 +│ ├── ───────────── +│ └── Gemini CLI +│ └── gemini-profile-1 +├── ───────────── +├── 设置... ← 可选:快捷跳转 +├── ───────────── +└── 退出 +``` + +## 实现步骤 + +### Phase 1: 后端菜单模块 + +#### 1.1 创建菜单模块 `src-tauri/src/setup/menu.rs` + +**职责**: + +- 创建应用菜单栏 +- 构建 Profile 子菜单 +- 处理菜单事件 + +**核心函数**: + +```rust +// 创建应用菜单栏 +pub fn create_app_menu(app: &AppHandle) -> tauri::Result> + +// 构建 Profile 子菜单 +fn build_profile_submenu( + app: &AppHandle, + profile_manager: &ProfileManager, +) -> tauri::Result> + +// 设置菜单事件处理 +pub fn setup_menu_handler(app: &tauri::App) -> tauri::Result<()> +``` + +#### 1.2 菜单项 ID 设计 + +使用结构化 ID 便于事件处理: + +- `profile:claude-code:profile-name` - Profile 切换 +- `menu:settings` - 打开设置 +- `menu:about` - 关于 + +#### 1.3 动态菜单更新 + +**触发时机**: + +- 应用启动时 +- Profile 创建/删除/激活后 +- 收到 `profile-changed` 事件时 + +**实现方式**: + +- 使用 `app.set_menu()` 重建整个菜单 +- 或使用 `CheckMenuItem::set_checked()` 仅更新复选状态 + +### Phase 2: 集成到应用启动流程 + +#### 2.1 修改 `setup/mod.rs` + +导出新的 menu 模块: + +```rust +pub mod menu; +``` + +#### 2.2 修改 `main.rs` + +在 `setup_app_hooks` 中添加菜单初始化: + +```rust +// 6. 创建应用菜单栏(仅 macOS) +#[cfg(target_os = "macos")] +setup::menu::setup_app_menu(app)?; +``` + +### Phase 3: 菜单事件处理 + +#### 3.1 Profile 切换逻辑 + +```rust +fn handle_profile_activation( + app: &AppHandle, + tool_id: &str, + profile_name: &str, +) -> Result<()> { + // 1. 调用 ProfileManager.activate_profile() + // 2. 更新菜单复选状态 + // 3. 发送事件通知前端 +} +``` + +#### 3.2 前端事件监听 + +监听 `profile-activated-from-menu` 事件,刷新 UI 状态。 + +### Phase 4: 菜单动态刷新 + +#### 4.1 新增 Tauri 命令 + +```rust +#[tauri::command] +pub fn refresh_app_menu(app: AppHandle) -> Result<(), String> +``` + +#### 4.2 前端触发刷新 + +在 Profile 操作后调用: + +- `pmSaveProfile` 后 +- `pmDeleteProfile` 后 +- `pmActivateProfile` 后 + +## 文件变更清单 + +### 新增文件 + +| 文件路径 | 说明 | +| ----------------------------- | -------------- | +| `src-tauri/src/setup/menu.rs` | 应用菜单栏模块 | + +### 修改文件 + +| 文件路径 | 变更内容 | +| ----------------------------------- | ---------------------------- | +| `src-tauri/src/setup/mod.rs` | 导出 menu 模块 | +| `src-tauri/src/main.rs` | 调用菜单初始化、注册刷新命令 | +| `src/lib/tauri-commands/profile.ts` | 添加 refreshAppMenu 命令包装 | +| `src/hooks/useProfileManagement.ts` | 操作后触发菜单刷新 | + +## 技术细节 + +### macOS 条件编译 + +所有菜单相关代码使用 `#[cfg(target_os = "macos")]` 条件编译,确保: + +- Windows/Linux 不受影响 +- 编译产物不包含无用代码 + +### 菜单项数量限制 + +考虑 Profile 数量可能较多的情况: + +- 每个工具最多显示 10 个 Profile +- 超出部分显示 "更多..." 跳转到 Profile 管理页面 + +### 错误处理 + +- Profile 加载失败时显示 "加载失败" 占位项 +- 激活失败时显示系统通知 + +## 测试计划 + +1. **单元测试**:菜单 ID 解析函数 +2. **集成测试**:Profile 激活流程 +3. **手动测试**: + - macOS 菜单栏显示正确 + - 复选标记正确反映激活状态 + - 切换后前端 UI 同步更新 + - Windows/Linux 编译不受影响 + +## 风险评估 + +| 风险 | 影响 | 缓解措施 | +| ---------------- | ---- | ------------------------------ | +| 菜单刷新性能 | 中 | 使用增量更新而非全量重建 | +| Profile 名称过长 | 低 | 截断显示,tooltip 显示完整名称 | +| 跨平台兼容性 | 低 | 条件编译隔离 macOS 代码 | + +## 后续扩展 + +- 添加键盘快捷键(如 ⌘1/⌘2/⌘3 切换工具) +- 支持 Touch Bar 快捷操作 +- 添加最近使用的 Profile 快捷列表 diff --git a/src-tauri/src/commands/window_commands.rs b/src-tauri/src/commands/window_commands.rs index 1be9bd6..0b1f2ef 100644 --- a/src-tauri/src/commands/window_commands.rs +++ b/src-tauri/src/commands/window_commands.rs @@ -1,6 +1,6 @@ use crate::commands::error::{AppError, AppResult}; use ::duckcoding::ui; -use tauri::{Manager, WebviewWindow}; +use tauri::{AppHandle, Manager, WebviewWindow}; /// 处理窗口关闭操作 /// @@ -25,3 +25,19 @@ pub fn handle_close_action(window: WebviewWindow, action: String) -> AppResult<( }), } } + +/// 刷新应用菜单栏(仅 macOS) +#[tauri::command] +pub fn refresh_app_menu(app: AppHandle) -> AppResult<()> { + #[cfg(target_os = "macos")] + { + crate::setup::menu::refresh_app_menu(&app).map_err(|e| AppError::Internal { + message: format!("刷新菜单失败: {}", e), + })?; + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + } + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d9a866f..54bcb30 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -139,13 +139,19 @@ fn setup_app_hooks(app: &mut tauri::App) -> tauri::Result<()> { // 3. 启动配置监听 start_config_watcher(app)?; - // 4. 创建系统托盘 + // 4. 创建系统托盘(非 macOS 平台) + #[cfg(not(target_os = "macos"))] setup::tray::setup_system_tray(app)?; // 5. 处理窗口关闭事件 + #[cfg(not(target_os = "macos"))] setup::tray::setup_window_close_handler(app)?; - // 6. 启动后检查更新 + // 6. 创建应用菜单栏(仅 macOS) + #[cfg(target_os = "macos")] + setup::menu::setup_app_menu(app)?; + + // 7. 启动后检查更新 schedule_update_check(app.handle().clone()); Ok(()) @@ -261,6 +267,7 @@ fn main() { migrate_balance_from_localstorage, // 窗口管理 handle_close_action, + refresh_app_menu, // 代理调试 get_current_proxy, apply_proxy_now, diff --git a/src-tauri/src/setup/menu.rs b/src-tauri/src/setup/menu.rs new file mode 100644 index 0000000..1d34887 --- /dev/null +++ b/src-tauri/src/setup/menu.rs @@ -0,0 +1,254 @@ +//! macOS 应用菜单栏模块 +//! +//! 提供 Profile 快捷切换功能,仅在 macOS 下启用 + +use tauri::{ + menu::{ + CheckMenuItem, Menu, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, + }, + tray::TrayIconBuilder, + AppHandle, Emitter, Manager, Runtime, +}; + +use crate::commands::ProfileManagerState; +use duckcoding::services::profile_manager::ProfileManager; + +/// Profile 菜单项 ID 前缀 +const PROFILE_MENU_PREFIX: &str = "profile:"; + +/// 工具显示名称 +fn tool_display_name(tool_id: &str) -> &'static str { + match tool_id { + "claude-code" => "Claude Code", + "codex" => "Codex", + "gemini-cli" => "Gemini CLI", + _ => "Unknown", + } +} + +/// 解析菜单项 ID,提取工具 ID 和 Profile 名称 +/// +/// 格式: `profile:{tool_id}:{profile_name}` +fn parse_profile_menu_id(id: &str) -> Option<(&str, &str)> { + if !id.starts_with(PROFILE_MENU_PREFIX) { + return None; + } + let rest = &id[PROFILE_MENU_PREFIX.len()..]; + let (tool_id, profile_name) = rest.split_once(':')?; + Some((tool_id, profile_name)) +} + +/// 构建单个工具的 Profile 子菜单 +fn build_tool_profile_submenu( + app: &AppHandle, + tool_id: &str, + profiles: &[String], + active_profile: Option<&str>, +) -> tauri::Result> { + let display_name = tool_display_name(tool_id); + let mut builder = SubmenuBuilder::new(app, display_name); + + if profiles.is_empty() { + let empty_item = MenuItem::with_id( + app, + format!("{}{}:empty", PROFILE_MENU_PREFIX, tool_id), + "(无配置方案)", + false, + None::<&str>, + )?; + builder = builder.item(&empty_item); + } else { + let max_display = 10; + for profile_name in profiles.iter().take(max_display) { + let is_active = active_profile == Some(profile_name.as_str()); + let menu_id = format!("{}{}:{}", PROFILE_MENU_PREFIX, tool_id, profile_name); + let display_text = if profile_name.len() > 30 { + format!("{}...", &profile_name[..27]) + } else { + profile_name.to_string() + }; + let item = CheckMenuItem::with_id( + app, + &menu_id, + &display_text, + true, + is_active, + None::<&str>, + )?; + builder = builder.item(&item); + } + if profiles.len() > max_display { + builder = builder.separator(); + let more_item = MenuItem::with_id( + app, + format!("{}{}:more", PROFILE_MENU_PREFIX, tool_id), + format!("更多... (共 {} 个)", profiles.len()), + true, + None::<&str>, + )?; + builder = builder.item(&more_item); + } + } + builder.build() +} + +/// 创建菜单栏图标菜单(工具 Profile 平铺展示) +fn create_tray_menu( + app: &AppHandle, + profile_manager: &ProfileManager, +) -> tauri::Result> { + let tools = ["claude-code", "codex", "gemini-cli"]; + let mut builder = MenuBuilder::new(app); + + builder = builder + .item(&MenuItem::with_id( + app, + "menu:show", + "显示主窗口", + true, + Some("CmdOrCtrl+M"), + )?) + .separator(); + + for (i, tool_id) in tools.iter().enumerate() { + let profiles = profile_manager.list_profiles(tool_id).unwrap_or_default(); + let active = profile_manager + .get_active_profile_name(tool_id) + .ok() + .flatten(); + let submenu = build_tool_profile_submenu(app, tool_id, &profiles, active.as_deref())?; + builder = builder.item(&submenu); + if i < tools.len() - 1 { + builder = builder.separator(); + } + } + + builder = builder + .separator() + .item(&MenuItem::with_id( + app, + "menu:settings", + "设置...", + true, + Some("CmdOrCtrl+,"), + )?) + .separator() + .item(&PredefinedMenuItem::quit(app, Some("退出 DuckCoding"))?); + + builder.build() +} + +/// 设置应用菜单栏(仅 macOS) +pub fn setup_app_menu(app: &tauri::App) -> tauri::Result<()> { + let state = app.state::(); + let manager = state.manager.blocking_read(); + + // 创建菜单栏图标(显示在右上角) + let tray_menu = create_tray_menu(app.handle(), &manager)?; + let app_handle = app.handle().clone(); + + let _tray = TrayIconBuilder::with_id("main") + .icon(app.default_window_icon().unwrap().clone()) + .menu(&tray_menu) + .show_menu_on_left_click(true) + .on_menu_event(move |app, event| { + let id = event.id.as_ref(); + tracing::debug!(menu_id = %id, "菜单栏图标菜单事件"); + + if let Some((tool_id, profile_name)) = parse_profile_menu_id(id) { + if profile_name == "empty" || profile_name == "more" { + if profile_name == "more" { + let _ = app.emit("navigate-to", "/profile"); + } + return; + } + handle_profile_activation(&app_handle, tool_id, profile_name); + return; + } + + match id { + "menu:settings" => { + let _ = app.emit("navigate-to", "/settings"); + } + "menu:show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + _ => {} + } + }) + .build(app)?; + + Ok(()) +} + +/// 处理 Profile 激活 +fn handle_profile_activation(app: &AppHandle, tool_id: &str, profile_name: &str) { + let state = app.state::(); + let manager = state.manager.blocking_read(); + match manager.activate_profile(tool_id, profile_name) { + Ok(()) => { + tracing::info!(tool_id = %tool_id, profile = %profile_name, "从菜单激活 Profile"); + if let Err(e) = refresh_app_menu_internal(app) { + tracing::error!(error = ?e, "刷新菜单失败"); + } + let _ = app.emit( + "profile-activated-from-menu", + serde_json::json!({ + "tool_id": tool_id, + "profile_name": profile_name, + }), + ); + } + Err(e) => { + tracing::error!(error = ?e, tool_id = %tool_id, profile = %profile_name, "激活 Profile 失败"); + } + } +} + +/// 刷新应用菜单栏(内部函数) +fn refresh_app_menu_internal(app: &AppHandle) -> tauri::Result<()> { + let state = app.state::(); + let manager = state.manager.blocking_read(); + let menu = create_tray_menu(app, &manager)?; + + // 获取托盘图标并更新菜单 + if let Some(tray) = app.tray_by_id("main") { + tray.set_menu(Some(menu))?; + } + Ok(()) +} + +/// 刷新应用菜单栏(公开函数) +pub fn refresh_app_menu(app: &AppHandle) -> Result<(), String> { + refresh_app_menu_internal(app).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_profile_menu_id() { + assert_eq!( + parse_profile_menu_id("profile:claude-code:my-profile"), + Some(("claude-code", "my-profile")) + ); + assert_eq!( + parse_profile_menu_id("profile:codex:test:with:colons"), + Some(("codex", "test:with:colons")) + ); + assert_eq!(parse_profile_menu_id("other:id"), None); + assert_eq!(parse_profile_menu_id("profile:"), None); + } + + #[test] + fn test_tool_display_name() { + assert_eq!(tool_display_name("claude-code"), "Claude Code"); + assert_eq!(tool_display_name("codex"), "Codex"); + assert_eq!(tool_display_name("gemini-cli"), "Gemini CLI"); + assert_eq!(tool_display_name("unknown"), "Unknown"); + } +} diff --git a/src-tauri/src/setup/mod.rs b/src-tauri/src/setup/mod.rs index 3ca21da..6a9e194 100644 --- a/src-tauri/src/setup/mod.rs +++ b/src-tauri/src/setup/mod.rs @@ -4,6 +4,10 @@ pub mod tray; // 启动初始化逻辑 pub mod initialization; +// macOS 应用菜单栏 +#[cfg(target_os = "macos")] +pub mod menu; + // 重新导出常用函数供 main.rs 使用 pub use initialization::initialize_app; pub use tray::focus_main_window; diff --git a/src-tauri/src/setup/tray.rs b/src-tauri/src/setup/tray.rs index f8b2082..09f30ae 100644 --- a/src-tauri/src/setup/tray.rs +++ b/src-tauri/src/setup/tray.rs @@ -1,10 +1,14 @@ +use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; + +#[cfg(not(target_os = "macos"))] use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, Runtime, WebviewWindow, + Emitter, }; /// 创建系统托盘菜单 +#[cfg(not(target_os = "macos"))] pub fn create_tray_menu(app: &AppHandle) -> tauri::Result> { let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; let check_update_item = MenuItem::with_id(app, "check_update", "检查更新", true, None::<&str>)?; @@ -85,6 +89,7 @@ pub fn restore_window_state(window: &WebviewWindow) { } /// 隐藏窗口到系统托盘 +#[cfg(not(target_os = "macos"))] pub fn hide_window_to_tray(window: &WebviewWindow) { tracing::info!("隐藏窗口到系统托盘"); if let Err(e) = window.hide() { @@ -110,6 +115,7 @@ pub fn hide_window_to_tray(window: &WebviewWindow) { } /// 设置系统托盘(包含事件处理) +#[cfg(not(target_os = "macos"))] pub fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> { let tray_menu = create_tray_menu(app.handle())?; let app_handle2 = app.handle().clone(); @@ -160,9 +166,11 @@ pub fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> { Ok(()) } +#[cfg(not(target_os = "macos"))] const CLOSE_CONFIRM_EVENT: &str = "duckcoding://request-close-action"; /// 设置窗口关闭处理(最小化到托盘而不是退出) +#[cfg(not(target_os = "macos"))] pub fn setup_window_close_handler(app: &tauri::App) -> tauri::Result<()> { if let Some(window) = app.get_webview_window("main") { let window_clone = window.clone(); diff --git a/src/components/logic/AppEventsHandler.tsx b/src/components/logic/AppEventsHandler.tsx index fa4dacb..bbc70d7 100644 --- a/src/components/logic/AppEventsHandler.tsx +++ b/src/components/logic/AppEventsHandler.tsx @@ -117,12 +117,36 @@ export function AppEventsHandler() { } }); + // 监听从菜单栏激活 Profile 的事件 + const unlistenProfileActivated = listen<{ tool_id: string; profile_name: string }>( + 'profile-activated-from-menu', + (event) => { + const { tool_id, profile_name } = event.payload; + toast({ + title: '配置已切换', + description: `${tool_id} 已切换到 "${profile_name}"`, + }); + }, + ); + + // 监听菜单栏导航事件 + const unlistenNavigateTo = listen('navigate-to', (event) => { + const path = event.payload; + if (path === '/profile') { + setActiveTab('profile-management'); + } else if (path === '/settings') { + setActiveTab('settings'); + } + }); + return () => { unlistenUpdateAvailable.then((fn) => fn()); unlistenRequestCheck.then((fn) => fn()); unlistenNotFound.then((fn) => fn()); unlistenOpenSettings.then((fn) => fn()); unlistenAppNavigate.then((fn) => fn()); + unlistenProfileActivated.then((fn) => fn()); + unlistenNavigateTo.then((fn) => fn()); }; }, [ setActiveTab, diff --git a/src/lib/tauri-commands/profile.ts b/src/lib/tauri-commands/profile.ts index 228fc1e..7b5cc17 100644 --- a/src/lib/tauri-commands/profile.ts +++ b/src/lib/tauri-commands/profile.ts @@ -111,3 +111,12 @@ export async function pmSaveAmpSelection(input: { }): Promise { return invoke('pm_save_amp_selection', { input }); } + +// ==================== 菜单管理 ==================== + +/** + * 刷新应用菜单栏(仅 macOS) + */ +export async function refreshAppMenu(): Promise { + return invoke('refresh_app_menu'); +} diff --git a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts index 278bd83..4feb8e6 100644 --- a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts +++ b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts @@ -12,6 +12,7 @@ import { pmActivateProfile, pmCaptureFromNative, getAllProxyStatus, + refreshAppMenu, type AllProxyStatus, } from '@/lib/tauri-commands'; import { TOOL_NAMES } from '@/types/profile'; @@ -83,6 +84,8 @@ export function useProfileManagement(): UseProfileManagementReturn { // 刷新 const refresh = useCallback(async () => { await loadProfiles(); + // 刷新 macOS 应用菜单栏 + refreshAppMenu().catch((e) => console.debug('refreshAppMenu:', e)); }, [loadProfiles]); // 加载所有透明代理状态 From d9c06cfc55424e9418f351e8ee36e9166efc9f24 Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Sun, 8 Feb 2026 18:42:19 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(menu):=20=E5=A2=9E=E5=BC=BA=20macOS=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=8F=E8=8F=9C=E5=8D=95=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=80=8F=E6=98=8E=E4=BB=A3=E7=90=86=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E4=B8=8E=E6=A3=80=E6=9F=A5=E6=9B=B4=E6=96=B0=20Enhance=20macOS?= =?UTF-8?q?=20status=20bar=20menu=20with=20proxy=20control=20and=20update?= =?UTF-8?q?=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 macOS 状态栏菜单中新增“检查更新”入口 Add 'Check for Updates' entry to the macOS status bar menu 新增“透明代理”一级分组,支持 claude-code、codex 和 gemini-cli Add 'Transparent Proxy' top-level group supporting claude-code, codex, and gemini-cli 子菜单支持显示运行状态、平铺展示配置选择及一键启停 Support status display, tiled profile selection, and one-click start/stop in submenus 从菜单选择配置时,若代理未运行则自动开启 Automatically start proxy when selecting a profile from the menu if it's not running 优化导航逻辑,支持通过路径直接跳转到特定工具的透明代理页面 Optimize navigation logic to support direct routing to specific tool proxy pages 重构后端代码,实现菜单项与 Tauri 命令之间的业务逻辑复用 Refactor backend code to share business logic between menu items and Tauri commands --- CLAUDE.md | 23 +- src-tauri/src/commands/proxy_commands.rs | 96 ++-- src-tauri/src/commands/update_commands.rs | 17 +- src-tauri/src/setup/menu.rs | 574 ++++++++++++++++++++-- src/components/logic/AppEventsHandler.tsx | 7 + 5 files changed, 641 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3df75d9..921aa23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,14 +259,23 @@ last-updated: 2025-12-16 - 新增 38 个测试(9 个 Processor 测试 + 10 个 Logger 测试 + 15 个数据库测试 + 4 个命令测试) - 代码量减少:manager.rs 从 626 行减少到 286 行(-54%) - 所有测试通过,编译 0 警告 - - **配置管理机制(2025-12-12)**: - - 代理启动时自动创建内置 Profile(`dc_proxy_*`),通过 `ProfileManager` 切换配置 - - 内置 Profile 在 UI 中不可见(列表查询时过滤 `dc_proxy_` 前缀) - - `dc_proxy_` 为系统保留前缀,用户无法创建同名 Profile +- **配置管理机制(2025-12-12)**: + - 代理启动时自动创建内置 Profile(`dc_proxy_*`),通过 `ProfileManager` 切换配置 + - 内置 Profile 在 UI 中不可见(列表查询时过滤 `dc_proxy_` 前缀) + - `dc_proxy_` 为系统保留前缀,用户无法创建同名 Profile - 代理关闭时自动还原到启动前激活的 Profile - - 运行时禁止修改 `ToolProxyConfig`,确保配置一致性 - - `original_active_profile` 字段记录启动前的 Profile 用于还原 - - Gemini CLI 的 model 字段为可选,允许不填(内置代理 Profile 不设置 model,保留用户原有配置) + - 运行时禁止修改 `ToolProxyConfig`,确保配置一致性 + - `original_active_profile` 字段记录启动前的 Profile 用于还原 + - Gemini CLI 的 model 字段为可选,允许不填(内置代理 Profile 不设置 model,保留用户原有配置) +- **macOS 状态栏菜单增强(2026-02-08)**: + - `src-tauri/src/setup/menu.rs` 增加“检查更新”入口,点击后走后端后台检查更新流程(不强制拉起窗口) + - 菜单新增“透明代理”一级分组,覆盖 `claude-code`、`codex`、`gemini-cli` 三工具(按需求不展示 `amp-code`) + - 每个工具子菜单支持:启动代理、停止代理、打开透明代理页面、平铺展示的代理配置选择(含当前配置勾选态和 `更多...`,不再二级展开) + - 从状态栏菜单选择代理配置时,如该工具透明代理未运行,会自动开启代理(必要时自动将 `enabled` 置为 true) + - 透明代理工具子菜单标题显示运行状态(`运行中`/`已停止`),并按状态自动切换“启动代理/停止代理”菜单项可用性 + - `更多...` 与配置缺失引导统一使用窗口前置 + 导航,修复窗口隐藏时点击菜单“无反应”的体验问题 + - 导航语义扩展:`navigate-to` 新增 `/transparent-proxy/` 路径,前端 `AppEventsHandler` 已兼容解析并自动选中对应工具 + - 后端命令复用:`proxy_commands.rs`、`update_commands.rs` 抽取 `pub(crate)` helper,菜单与 Tauri command 共享同一业务逻辑,避免分叉 - `ToolProxyConfig` 额外存储 `real_profile_name`、`auto_start`、工具级 `session_endpoint_config_enabled`,全局配置新增 `hide_transparent_proxy_tip` 控制设置页横幅显示 - `GlobalConfig.hide_session_config_hint` 持久化会话级端点提示的隐藏状态,`ProxyControlBar`/`ProxySettingsDialog`/`ClaudeContent` 通过 `open-proxy-settings` 与 `proxy-config-updated` 事件联动刷新视图 - 日志系统支持完整配置管理:`GlobalConfig.log_config` 存储级别/格式/输出目标;`log_commands.rs` 提供查询与更新命令,`LogSettingsTab` 可热重载级别、保存文件输出设置;`core/logger.rs` 通过 `update_log_level` reload 机制动态调整 diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index dadd813..3a13709 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -136,7 +136,7 @@ pub async fn test_proxy_request( // ==================== 多工具代理命令(新架构) ==================== /// 内部实现:尝试启动代理(支持回滚) -async fn try_start_proxy_internal( +pub(crate) async fn try_start_proxy_internal( tool_id: &str, manager_state: &ProxyManagerState, profile_state: &ProfileManagerState, @@ -271,27 +271,26 @@ async fn try_start_proxy_internal( } /// 启动指定工具的透明代理(带事务回滚) -#[tauri::command] -pub async fn start_tool_proxy( - tool_id: String, - manager_state: State<'_, ProxyManagerState>, - profile_state: State<'_, ProfileManagerState>, +pub(crate) async fn start_tool_proxy_internal( + tool_id: &str, + manager_state: &ProxyManagerState, + profile_state: &ProfileManagerState, ) -> Result { // 备份当前状态(用于回滚) let profile_mgr = profile_state.manager.read().await; let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; let backup_config = proxy_config_mgr - .get_config(&tool_id) + .get_config(tool_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; let backup_profile = profile_mgr - .get_active_profile_name(&tool_id) + .get_active_profile_name(tool_id) .map_err(|e| e.to_string())?; // 执行启动操作 - match try_start_proxy_internal(&tool_id, &manager_state, &profile_state).await { + match try_start_proxy_internal(tool_id, manager_state, profile_state).await { Ok((tool_id, proxy_port)) => Ok(format!( "✅ {} 透明代理已启动\n监听端口: {}\n已切换到代理配置", tool_id, proxy_port @@ -301,7 +300,7 @@ pub async fn start_tool_proxy( tracing::warn!("代理启动失败,开始回滚: {}", e); // 回滚代理配置 - if let Err(rollback_err) = proxy_config_mgr.update_config(&tool_id, backup_config) { + if let Err(rollback_err) = proxy_config_mgr.update_config(tool_id, backup_config) { tracing::error!("回滚代理配置失败: {}", rollback_err); } else { tracing::info!("已回滚代理配置"); @@ -311,7 +310,7 @@ pub async fn start_tool_proxy( drop(profile_mgr); // 释放读锁 let profile_mgr_write = profile_state.manager.write().await; if let Some(name) = backup_profile { - if let Err(rollback_err) = profile_mgr_write.activate_profile(&tool_id, &name) { + if let Err(rollback_err) = profile_mgr_write.activate_profile(tool_id, &name) { tracing::error!("回滚 Profile 失败: {}", rollback_err); } else { tracing::info!("已回滚 Profile 到: {}", name); @@ -323,19 +322,27 @@ pub async fn start_tool_proxy( } } -/// 停止指定工具的透明代理 #[tauri::command] -pub async fn stop_tool_proxy( +pub async fn start_tool_proxy( tool_id: String, manager_state: State<'_, ProxyManagerState>, profile_state: State<'_, ProfileManagerState>, +) -> Result { + start_tool_proxy_internal(&tool_id, &manager_state, &profile_state).await +} + +/// 停止指定工具的透明代理 +pub(crate) async fn stop_tool_proxy_internal( + tool_id: &str, + manager_state: &ProxyManagerState, + profile_state: &ProfileManagerState, ) -> Result { let profile_mgr = profile_state.manager.write().await; let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; // 读取代理配置 let mut tool_config = proxy_config_mgr - .get_config(&tool_id) + .get_config(tool_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; @@ -343,7 +350,7 @@ pub async fn stop_tool_proxy( manager_state .manager - .stop_proxy(&tool_id) + .stop_proxy(tool_id) .await .map_err(|e| format!("停止代理失败: {e}"))?; @@ -361,7 +368,7 @@ pub async fn stop_tool_proxy( // 清空备份字段 proxy_config_mgr - .update_config(&tool_id, tool_config) + .update_config(tool_id, tool_config) .map_err(|e| e.to_string())?; tracing::info!( @@ -382,7 +389,7 @@ pub async fn stop_tool_proxy( if let Some(profile_name) = original_profile { // 有原始 Profile,切回去 profile_mgr - .activate_profile(&tool_id, &profile_name) + .activate_profile(tool_id, &profile_name) .map_err(|e| format!("还原 Profile 失败: {}", e))?; tracing::info!( @@ -393,7 +400,7 @@ pub async fn stop_tool_proxy( // 清空 original_active_profile 字段 proxy_config_mgr - .update_config(&tool_id, tool_config) + .update_config(tool_id, tool_config) .map_err(|e| e.to_string())?; Ok(format!( @@ -411,6 +418,15 @@ pub async fn stop_tool_proxy( } } +#[tauri::command] +pub async fn stop_tool_proxy( + tool_id: String, + manager_state: State<'_, ProxyManagerState>, + profile_state: State<'_, ProfileManagerState>, +) -> Result { + stop_tool_proxy_internal(&tool_id, &manager_state, &profile_state).await +} + /// 获取所有工具的透明代理状态 #[tauri::command] pub async fn get_all_proxy_status( @@ -447,23 +463,20 @@ pub async fn get_all_proxy_status( } /// 从 Profile 更新代理配置(不激活 Profile) -#[tauri::command] -pub async fn update_proxy_from_profile( - tool_id: String, - profile_name: String, - manager_state: State<'_, ProxyManagerState>, - profile_state: State<'_, ProfileManagerState>, +pub(crate) async fn update_proxy_from_profile_internal( + tool_id: &str, + profile_name: &str, + manager_state: &ProxyManagerState, + profile_state: &ProfileManagerState, ) -> Result<(), String> { - use ::duckcoding::services::proxy_config_manager::ProxyConfigManager; - let profile_mgr = profile_state.manager.read().await; let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; // 根据工具类型读取 Profile - let (api_key, base_url, pricing_template_id) = match tool_id.as_str() { + let (api_key, base_url, pricing_template_id) = match tool_id { "claude-code" => { let profile = profile_mgr - .get_claude_profile(&profile_name) + .get_claude_profile(profile_name) .map_err(|e| e.to_string())?; ( profile.api_key, @@ -473,7 +486,7 @@ pub async fn update_proxy_from_profile( } "codex" => { let profile = profile_mgr - .get_codex_profile(&profile_name) + .get_codex_profile(profile_name) .map_err(|e| e.to_string())?; ( profile.api_key, @@ -483,7 +496,7 @@ pub async fn update_proxy_from_profile( } "gemini-cli" => { let profile = profile_mgr - .get_gemini_profile(&profile_name) + .get_gemini_profile(profile_name) .map_err(|e| e.to_string())?; ( profile.api_key, @@ -496,27 +509,27 @@ pub async fn update_proxy_from_profile( // 更新代理配置的 real_* 字段 let mut proxy_config = proxy_config_mgr - .get_config(&tool_id) + .get_config(tool_id) .map_err(|e| e.to_string())? .unwrap_or_else(|| { use ::duckcoding::models::proxy_config::ToolProxyConfig; - ToolProxyConfig::new(ToolProxyConfig::default_port(&tool_id)) + ToolProxyConfig::new(ToolProxyConfig::default_port(tool_id)) }); proxy_config.real_api_key = Some(api_key); proxy_config.real_base_url = Some(base_url); - proxy_config.real_profile_name = Some(profile_name.clone()); + proxy_config.real_profile_name = Some(profile_name.to_string()); proxy_config.pricing_template_id = pricing_template_id; // Phase 6: 价格模板 proxy_config_mgr - .update_config(&tool_id, proxy_config.clone()) + .update_config(tool_id, proxy_config.clone()) .map_err(|e| e.to_string())?; // 如果代理正在运行,通知 ProxyManager 重新加载 - if manager_state.manager.is_running(&tool_id).await { + if manager_state.manager.is_running(tool_id).await { manager_state .manager - .update_config(&tool_id, proxy_config) + .update_config(tool_id, proxy_config) .await .map_err(|e| e.to_string())?; tracing::info!("已更新运行中的代理配置: {} -> {}", tool_id, profile_name); @@ -525,6 +538,17 @@ pub async fn update_proxy_from_profile( Ok(()) } +#[tauri::command] +pub async fn update_proxy_from_profile( + tool_id: String, + profile_name: String, + manager_state: State<'_, ProxyManagerState>, + profile_state: State<'_, ProfileManagerState>, +) -> Result<(), String> { + update_proxy_from_profile_internal(&tool_id, &profile_name, &manager_state, &profile_state) + .await +} + /// 获取指定工具的代理配置 #[tauri::command] pub async fn get_proxy_config( diff --git a/src-tauri/src/commands/update_commands.rs b/src-tauri/src/commands/update_commands.rs index 12e9e9d..24efe3f 100644 --- a/src-tauri/src/commands/update_commands.rs +++ b/src-tauri/src/commands/update_commands.rs @@ -122,11 +122,9 @@ pub async fn get_recommended_package_format( Ok(state.service.get_recommended_package_format()) } -/// 主动触发检查更新(供托盘菜单和启动时调用) -#[tauri::command] -pub async fn trigger_check_update( - app: AppHandle, - state: State<'_, UpdateServiceState>, +pub(crate) async fn trigger_check_update_internal( + app: &AppHandle, + state: &UpdateServiceState, ) -> Result<(), String> { let update_info = state .service @@ -145,3 +143,12 @@ pub async fn trigger_check_update( Ok(()) } + +/// 主动触发检查更新(供托盘菜单和启动时调用) +#[tauri::command] +pub async fn trigger_check_update( + app: AppHandle, + state: State<'_, UpdateServiceState>, +) -> Result<(), String> { + trigger_check_update_internal(&app, &state).await +} diff --git a/src-tauri/src/setup/menu.rs b/src-tauri/src/setup/menu.rs index 1d34887..9004a56 100644 --- a/src-tauri/src/setup/menu.rs +++ b/src-tauri/src/setup/menu.rs @@ -1,7 +1,8 @@ //! macOS 应用菜单栏模块 //! -//! 提供 Profile 快捷切换功能,仅在 macOS 下启用 +//! 提供 Profile 快捷切换、透明代理控制和更新检查功能,仅在 macOS 下启用 +use std::collections::HashMap; use tauri::{ menu::{ CheckMenuItem, Menu, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, @@ -10,11 +11,32 @@ use tauri::{ AppHandle, Emitter, Manager, Runtime, }; -use crate::commands::ProfileManagerState; +use crate::commands::profile_commands::ProfileManagerState; +use crate::commands::proxy_commands::{ + start_tool_proxy_internal, stop_tool_proxy_internal, update_proxy_from_profile_internal, + ProxyManagerState, +}; +use crate::commands::update_commands::{trigger_check_update_internal, UpdateServiceState}; +use duckcoding::models::proxy_config::ToolProxyConfig; use duckcoding::services::profile_manager::ProfileManager; +use duckcoding::services::proxy_config_manager::ProxyConfigManager; /// Profile 菜单项 ID 前缀 const PROFILE_MENU_PREFIX: &str = "profile:"; +/// 透明代理菜单项 ID 前缀 +const PROXY_MENU_PREFIX: &str = "proxy:"; +/// 菜单中最多展示的 Profile 数 +const MAX_MENU_PROFILE_COUNT: usize = 10; +/// 支持在菜单栏展示的工具 +const SUPPORTED_MENU_TOOLS: [&str; 3] = ["claude-code", "codex", "gemini-cli"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ProxyMenuAction<'a> { + Start(&'a str), + Stop(&'a str), + Open(&'a str), + Config(&'a str, &'a str), +} /// 工具显示名称 fn tool_display_name(tool_id: &str) -> &'static str { @@ -26,6 +48,22 @@ fn tool_display_name(tool_id: &str) -> &'static str { } } +fn is_supported_proxy_tool(tool_id: &str) -> bool { + SUPPORTED_MENU_TOOLS.contains(&tool_id) +} + +fn proxy_page_path(tool_id: &str) -> String { + format!("/transparent-proxy/{tool_id}") +} + +fn proxy_tool_menu_label(tool_id: &str, is_running: bool) -> String { + if is_running { + format!("{} · 运行中", tool_display_name(tool_id)) + } else { + format!("{} · 已停止", tool_display_name(tool_id)) + } +} + /// 解析菜单项 ID,提取工具 ID 和 Profile 名称 /// /// 格式: `profile:{tool_id}:{profile_name}` @@ -38,6 +76,55 @@ fn parse_profile_menu_id(id: &str) -> Option<(&str, &str)> { Some((tool_id, profile_name)) } +/// 解析透明代理菜单项 ID +/// +/// 支持格式: +/// - `proxy:start:{tool_id}` +/// - `proxy:stop:{tool_id}` +/// - `proxy:open:{tool_id}` +/// - `proxy:config:{tool_id}:{profile_name}` +fn parse_proxy_menu_id(id: &str) -> Option> { + if !id.starts_with(PROXY_MENU_PREFIX) { + return None; + } + + let rest = &id[PROXY_MENU_PREFIX.len()..]; + let (action, payload) = rest.split_once(':')?; + + match action { + "start" => { + if is_supported_proxy_tool(payload) { + Some(ProxyMenuAction::Start(payload)) + } else { + None + } + } + "stop" => { + if is_supported_proxy_tool(payload) { + Some(ProxyMenuAction::Stop(payload)) + } else { + None + } + } + "open" => { + if is_supported_proxy_tool(payload) { + Some(ProxyMenuAction::Open(payload)) + } else { + None + } + } + "config" => { + let (tool_id, profile_name) = payload.split_once(':')?; + if is_supported_proxy_tool(tool_id) { + Some(ProxyMenuAction::Config(tool_id, profile_name)) + } else { + None + } + } + _ => None, + } +} + /// 构建单个工具的 Profile 子菜单 fn build_tool_profile_submenu( app: &AppHandle, @@ -58,8 +145,7 @@ fn build_tool_profile_submenu( )?; builder = builder.item(&empty_item); } else { - let max_display = 10; - for profile_name in profiles.iter().take(max_display) { + for profile_name in profiles.iter().take(MAX_MENU_PROFILE_COUNT) { let is_active = active_profile == Some(profile_name.as_str()); let menu_id = format!("{}{}:{}", PROFILE_MENU_PREFIX, tool_id, profile_name); let display_text = if profile_name.len() > 30 { @@ -77,7 +163,7 @@ fn build_tool_profile_submenu( )?; builder = builder.item(&item); } - if profiles.len() > max_display { + if profiles.len() > MAX_MENU_PROFILE_COUNT { builder = builder.separator(); let more_item = MenuItem::with_id( app, @@ -92,12 +178,137 @@ fn build_tool_profile_submenu( builder.build() } -/// 创建菜单栏图标菜单(工具 Profile 平铺展示) +/// 构建单个工具的透明代理子菜单 +fn build_proxy_tool_submenu( + app: &AppHandle, + tool_id: &str, + profiles: &[String], + selected_profile: Option<&str>, + is_running: bool, +) -> tauri::Result> { + let mut builder = SubmenuBuilder::new(app, proxy_tool_menu_label(tool_id, is_running)); + + let start_item = MenuItem::with_id( + app, + format!("{}start:{}", PROXY_MENU_PREFIX, tool_id), + "启动代理", + !is_running, + None::<&str>, + )?; + let stop_item = MenuItem::with_id( + app, + format!("{}stop:{}", PROXY_MENU_PREFIX, tool_id), + "停止代理", + is_running, + None::<&str>, + )?; + let open_item = MenuItem::with_id( + app, + format!("{}open:{}", PROXY_MENU_PREFIX, tool_id), + "打开透明代理页面", + true, + None::<&str>, + )?; + builder = builder + .item(&start_item) + .item(&stop_item) + .item(&open_item) + .separator(); + + let profile_header = MenuItem::with_id( + app, + format!("{}profile_title:{}", PROXY_MENU_PREFIX, tool_id), + "选择代理配置", + false, + None::<&str>, + )?; + builder = builder.item(&profile_header); + + if profiles.is_empty() { + let empty_item = MenuItem::with_id( + app, + format!("{}config:{}:empty", PROXY_MENU_PREFIX, tool_id), + "(无可选配置)", + false, + None::<&str>, + )?; + builder = builder.item(&empty_item); + } else { + for profile_name in profiles.iter().take(MAX_MENU_PROFILE_COUNT) { + let is_selected = selected_profile == Some(profile_name.as_str()); + let menu_id = format!("{}config:{}:{}", PROXY_MENU_PREFIX, tool_id, profile_name); + let display_text = if profile_name.len() > 30 { + format!("{}...", &profile_name[..27]) + } else { + profile_name.to_string() + }; + let item = CheckMenuItem::with_id( + app, + &menu_id, + &display_text, + true, + is_selected, + None::<&str>, + )?; + builder = builder.item(&item); + } + + if profiles.len() > MAX_MENU_PROFILE_COUNT { + builder = builder.separator(); + let more_item = MenuItem::with_id( + app, + format!("{}config:{}:more", PROXY_MENU_PREFIX, tool_id), + format!("更多... (共 {} 个)", profiles.len()), + true, + None::<&str>, + )?; + builder = builder.item(&more_item); + } + } + + builder.build() +} + +/// 构建透明代理总菜单 +fn build_proxy_submenu( + app: &AppHandle, + profile_manager: &ProfileManager, + proxy_config_mgr: Option<&ProxyConfigManager>, + running_states: &HashMap, +) -> tauri::Result> { + let mut builder = SubmenuBuilder::new(app, "透明代理"); + + for (idx, tool_id) in SUPPORTED_MENU_TOOLS.iter().enumerate() { + let profiles = profile_manager.list_profiles(tool_id).unwrap_or_default(); + let selected_profile = proxy_config_mgr + .and_then(|mgr| mgr.get_config(tool_id).ok().flatten()) + .and_then(|config| config.real_profile_name); + let is_running = running_states.get(*tool_id).copied().unwrap_or(false); + + let submenu = build_proxy_tool_submenu( + app, + tool_id, + &profiles, + selected_profile.as_deref(), + is_running, + )?; + builder = builder.item(&submenu); + + if idx < SUPPORTED_MENU_TOOLS.len() - 1 { + builder = builder.separator(); + } + } + + builder.build() +} + +/// 创建菜单栏图标菜单 fn create_tray_menu( app: &AppHandle, profile_manager: &ProfileManager, + running_states: &HashMap, ) -> tauri::Result> { - let tools = ["claude-code", "codex", "gemini-cli"]; + let proxy_config_mgr = ProxyConfigManager::new().ok(); let mut builder = MenuBuilder::new(app); builder = builder @@ -110,7 +321,7 @@ fn create_tray_menu( )?) .separator(); - for (i, tool_id) in tools.iter().enumerate() { + for (i, tool_id) in SUPPORTED_MENU_TOOLS.iter().enumerate() { let profiles = profile_manager.list_profiles(tool_id).unwrap_or_default(); let active = profile_manager .get_active_profile_name(tool_id) @@ -118,13 +329,25 @@ fn create_tray_menu( .flatten(); let submenu = build_tool_profile_submenu(app, tool_id, &profiles, active.as_deref())?; builder = builder.item(&submenu); - if i < tools.len() - 1 { + if i < SUPPORTED_MENU_TOOLS.len() - 1 { builder = builder.separator(); } } + let proxy_submenu = build_proxy_submenu( + app, + profile_manager, + proxy_config_mgr.as_ref(), + running_states, + )?; + let check_update_item = + MenuItem::with_id(app, "menu:check_update", "检查更新", true, None::<&str>)?; + builder = builder .separator() + .item(&proxy_submenu) + .separator() + .item(&check_update_item) .item(&MenuItem::with_id( app, "menu:settings", @@ -138,14 +361,242 @@ fn create_tray_menu( builder.build() } +fn focus_and_navigate(app: &AppHandle, path: &str) { + super::focus_main_window(app); + if let Err(err) = app.emit("navigate-to", path) { + tracing::error!(error = ?err, path = %path, "菜单导航事件发送失败"); + } +} + +async fn load_proxy_running_states(app: &AppHandle) -> HashMap { + let proxy_state = app.state::(); + let current_statuses = proxy_state.manager.get_all_status().await; + + SUPPORTED_MENU_TOOLS + .iter() + .map(|tool_id| { + ( + (*tool_id).to_string(), + current_statuses.get(*tool_id).copied().unwrap_or(false), + ) + }) + .collect() +} + +async fn build_tray_menu_for_app(app: &AppHandle) -> tauri::Result> { + let profile_state = app.state::(); + let profile_manager = profile_state.manager.read().await; + let running_states = load_proxy_running_states(app).await; + create_tray_menu(app, &profile_manager, &running_states) +} + +fn has_proxy_start_prerequisites(tool_id: &str) -> bool { + if !is_supported_proxy_tool(tool_id) { + return false; + } + + let proxy_config_mgr = match ProxyConfigManager::new() { + Ok(manager) => manager, + Err(error) => { + tracing::error!(error = ?error, "读取代理配置管理器失败"); + return false; + } + }; + + let config = match proxy_config_mgr.get_config(tool_id) { + Ok(Some(config)) => config, + Ok(None) => return false, + Err(error) => { + tracing::error!(error = ?error, tool_id = %tool_id, "读取工具代理配置失败"); + return false; + } + }; + + has_required_proxy_fields(&config) +} + +fn has_required_proxy_fields(config: &ToolProxyConfig) -> bool { + config.enabled + && config.local_api_key.is_some() + && config.real_api_key.is_some() + && config.real_base_url.is_some() + && config.real_profile_name.is_some() +} + +fn should_navigate_to_proxy_page_for_start_error(error: &str) -> bool { + error.contains("代理配置不存在") + || error.contains("透明代理未启用") + || error.contains("保护密钥未设置") + || error.contains("真实 API Key 或 Base URL 未设置") + || error.contains("内置 Profile 不存在") +} + +fn ensure_proxy_enabled_for_auto_start(tool_id: &str) -> Result<(), String> { + let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; + let mut config = proxy_config_mgr + .get_config(tool_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; + + if config.enabled { + return Ok(()); + } + + config.enabled = true; + proxy_config_mgr + .update_config(tool_id, config) + .map_err(|e| e.to_string())?; + + tracing::info!(tool_id = %tool_id, "从菜单选择配置时自动开启透明代理开关"); + Ok(()) +} + +fn handle_proxy_menu_action(app: &AppHandle, action: ProxyMenuAction<'_>) { + match action { + ProxyMenuAction::Open(tool_id) => { + focus_and_navigate(app, &proxy_page_path(tool_id)); + } + ProxyMenuAction::Config(tool_id, profile_name) => { + if profile_name == "empty" { + return; + } + if profile_name == "more" { + focus_and_navigate(app, &proxy_page_path(tool_id)); + return; + } + + let app_handle = app.clone(); + let tool_id_owned = tool_id.to_string(); + let profile_name_owned = profile_name.to_string(); + tauri::async_runtime::spawn(async move { + let proxy_state = app_handle.state::(); + let profile_state = app_handle.state::(); + if let Err(error) = update_proxy_from_profile_internal( + &tool_id_owned, + &profile_name_owned, + &proxy_state, + &profile_state, + ) + .await + { + tracing::error!( + error = %error, + tool_id = %tool_id_owned, + profile_name = %profile_name_owned, + "从菜单切换透明代理配置失败" + ); + } else { + tracing::info!( + tool_id = %tool_id_owned, + profile_name = %profile_name_owned, + "从菜单切换透明代理配置成功" + ); + + let is_running = proxy_state.manager.is_running(&tool_id_owned).await; + if !is_running { + if let Err(error) = ensure_proxy_enabled_for_auto_start(&tool_id_owned) { + tracing::error!( + error = %error, + tool_id = %tool_id_owned, + "从菜单自动开启透明代理开关失败" + ); + if should_navigate_to_proxy_page_for_start_error(&error) { + focus_and_navigate(&app_handle, &proxy_page_path(&tool_id_owned)); + } + } else { + match start_tool_proxy_internal( + &tool_id_owned, + &proxy_state, + &profile_state, + ) + .await + { + Ok(message) => { + tracing::info!( + tool_id = %tool_id_owned, + message = %message, + "从菜单选择配置后自动启动透明代理成功" + ); + } + Err(error) => { + tracing::error!( + error = %error, + tool_id = %tool_id_owned, + "从菜单选择配置后自动启动透明代理失败" + ); + if should_navigate_to_proxy_page_for_start_error(&error) { + focus_and_navigate( + &app_handle, + &proxy_page_path(&tool_id_owned), + ); + } + } + } + } + } + } + + if let Err(error) = refresh_app_menu_internal_async(&app_handle).await { + tracing::error!(error = ?error, "刷新菜单失败"); + } + }); + } + ProxyMenuAction::Start(tool_id) => { + if !has_proxy_start_prerequisites(tool_id) { + focus_and_navigate(app, &proxy_page_path(tool_id)); + return; + } + + let app_handle = app.clone(); + let tool_id_owned = tool_id.to_string(); + tauri::async_runtime::spawn(async move { + let proxy_state = app_handle.state::(); + let profile_state = app_handle.state::(); + match start_tool_proxy_internal(&tool_id_owned, &proxy_state, &profile_state).await + { + Ok(message) => { + tracing::info!(tool_id = %tool_id_owned, message = %message, "从菜单启动透明代理成功"); + } + Err(error) => { + tracing::error!(error = %error, tool_id = %tool_id_owned, "从菜单启动透明代理失败"); + if should_navigate_to_proxy_page_for_start_error(&error) { + focus_and_navigate(&app_handle, &proxy_page_path(&tool_id_owned)); + } + } + } + + if let Err(error) = refresh_app_menu_internal_async(&app_handle).await { + tracing::error!(error = ?error, "刷新菜单失败"); + } + }); + } + ProxyMenuAction::Stop(tool_id) => { + let app_handle = app.clone(); + let tool_id_owned = tool_id.to_string(); + tauri::async_runtime::spawn(async move { + let proxy_state = app_handle.state::(); + let profile_state = app_handle.state::(); + match stop_tool_proxy_internal(&tool_id_owned, &proxy_state, &profile_state).await { + Ok(message) => { + tracing::info!(tool_id = %tool_id_owned, message = %message, "从菜单停止透明代理成功"); + } + Err(error) => { + tracing::error!(error = %error, tool_id = %tool_id_owned, "从菜单停止透明代理失败"); + } + } + + if let Err(error) = refresh_app_menu_internal_async(&app_handle).await { + tracing::error!(error = ?error, "刷新菜单失败"); + } + }); + } + } +} + /// 设置应用菜单栏(仅 macOS) pub fn setup_app_menu(app: &tauri::App) -> tauri::Result<()> { - let state = app.state::(); - let manager = state.manager.blocking_read(); - // 创建菜单栏图标(显示在右上角) - let tray_menu = create_tray_menu(app.handle(), &manager)?; - let app_handle = app.handle().clone(); + let tray_menu = tauri::async_runtime::block_on(build_tray_menu_for_app(app.handle()))?; let _tray = TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) @@ -156,13 +607,20 @@ pub fn setup_app_menu(app: &tauri::App) -> tauri::Result<()> { tracing::debug!(menu_id = %id, "菜单栏图标菜单事件"); if let Some((tool_id, profile_name)) = parse_profile_menu_id(id) { - if profile_name == "empty" || profile_name == "more" { - if profile_name == "more" { - let _ = app.emit("navigate-to", "/profile"); - } + if profile_name == "empty" { + return; + } + if profile_name == "more" { + focus_and_navigate(app, "/profile"); return; } - handle_profile_activation(&app_handle, tool_id, profile_name); + + handle_profile_activation(app, tool_id, profile_name); + return; + } + + if let Some(action) = parse_proxy_menu_id(id) { + handle_proxy_menu_action(app, action); return; } @@ -170,11 +628,19 @@ pub fn setup_app_menu(app: &tauri::App) -> tauri::Result<()> { "menu:settings" => { let _ = app.emit("navigate-to", "/settings"); } + "menu:check_update" => { + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + let update_state = app_handle.state::(); + if let Err(error) = + trigger_check_update_internal(&app_handle, &update_state).await + { + tracing::error!(error = %error, "从菜单后台检查更新失败"); + } + }); + } "menu:show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + super::focus_main_window(app); } _ => {} } @@ -185,7 +651,7 @@ pub fn setup_app_menu(app: &tauri::App) -> tauri::Result<()> { } /// 处理 Profile 激活 -fn handle_profile_activation(app: &AppHandle, tool_id: &str, profile_name: &str) { +fn handle_profile_activation(app: &AppHandle, tool_id: &str, profile_name: &str) { let state = app.state::(); let manager = state.manager.blocking_read(); match manager.activate_profile(tool_id, profile_name) { @@ -209,10 +675,12 @@ fn handle_profile_activation(app: &AppHandle, tool_id: &str, profile_name: &str) } /// 刷新应用菜单栏(内部函数) -fn refresh_app_menu_internal(app: &AppHandle) -> tauri::Result<()> { - let state = app.state::(); - let manager = state.manager.blocking_read(); - let menu = create_tray_menu(app, &manager)?; +fn refresh_app_menu_internal(app: &AppHandle) -> tauri::Result<()> { + tauri::async_runtime::block_on(refresh_app_menu_internal_async(app)) +} + +async fn refresh_app_menu_internal_async(app: &AppHandle) -> tauri::Result<()> { + let menu = build_tray_menu_for_app(app).await?; // 获取托盘图标并更新菜单 if let Some(tray) = app.tray_by_id("main") { @@ -244,6 +712,56 @@ mod tests { assert_eq!(parse_profile_menu_id("profile:"), None); } + #[test] + fn test_parse_proxy_menu_id() { + assert_eq!( + parse_proxy_menu_id("proxy:start:claude-code"), + Some(ProxyMenuAction::Start("claude-code")) + ); + assert_eq!( + parse_proxy_menu_id("proxy:stop:codex"), + Some(ProxyMenuAction::Stop("codex")) + ); + assert_eq!( + parse_proxy_menu_id("proxy:open:gemini-cli"), + Some(ProxyMenuAction::Open("gemini-cli")) + ); + assert_eq!( + parse_proxy_menu_id("proxy:config:codex:test:with:colons"), + Some(ProxyMenuAction::Config("codex", "test:with:colons")) + ); + assert_eq!(parse_proxy_menu_id("proxy:start:amp-code"), None); + assert_eq!(parse_proxy_menu_id("proxy:unknown:codex"), None); + assert_eq!(parse_proxy_menu_id("other:config:codex:test"), None); + } + + #[test] + fn test_should_navigate_to_proxy_page_for_start_error() { + assert!(should_navigate_to_proxy_page_for_start_error( + "内置 Profile 不存在,请先保存代理配置: dc_proxy_codex" + )); + assert!(should_navigate_to_proxy_page_for_start_error( + "真实 API Key 或 Base URL 未设置" + )); + assert!(!should_navigate_to_proxy_page_for_start_error( + "codex 代理已在运行" + )); + } + + #[test] + fn test_has_required_proxy_fields() { + let mut config = ToolProxyConfig::new(8788); + assert!(!has_required_proxy_fields(&config)); + + config.enabled = true; + config.local_api_key = Some("local-key".to_string()); + config.real_api_key = Some("real-key".to_string()); + config.real_base_url = Some("https://api.example.com".to_string()); + config.real_profile_name = Some("default".to_string()); + + assert!(has_required_proxy_fields(&config)); + } + #[test] fn test_tool_display_name() { assert_eq!(tool_display_name("claude-code"), "Claude Code"); diff --git a/src/components/logic/AppEventsHandler.tsx b/src/components/logic/AppEventsHandler.tsx index bbc70d7..f43f869 100644 --- a/src/components/logic/AppEventsHandler.tsx +++ b/src/components/logic/AppEventsHandler.tsx @@ -136,6 +136,12 @@ export function AppEventsHandler() { setActiveTab('profile-management'); } else if (path === '/settings') { setActiveTab('settings'); + } else if (path.startsWith('/transparent-proxy/')) { + const toolId = path.replace('/transparent-proxy/', '').trim(); + if (toolId === 'claude-code' || toolId === 'codex' || toolId === 'gemini-cli') { + setSelectedProxyToolId(toolId); + } + setActiveTab('transparent-proxy'); } }); @@ -152,6 +158,7 @@ export function AppEventsHandler() { setActiveTab, setSettingsInitialTab, setSettingsRestrictToTab, + setSelectedProxyToolId, setIsUpdateDialogOpen, setUpdateInfo, setTokenStatsParams, From 1856cba89eefc27b05cd898809baaf3a7e5e2200 Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Thu, 12 Feb 2026 16:01:54 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(tray):=20=E4=BC=98=E5=8C=96=20macOS=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=8F=E9=80=8F=E6=98=8E=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=B8=83=E5=B1=80=20Optimize=20macOS=20tray?= =?UTF-8?q?=20menu=20layout=20for=20transparent=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除嵌套的透明代理总菜单函数,将各工具子菜单提升至顶层直接显示 Remove the nested proxy submenu function and promote tool submenus to the top level 更新 CLAUDE.md 文档以反映菜单层级结构的调整 Update CLAUDE.md documentation to reflect the changes in menu hierarchy 简化菜单深度,使 claude-code、codex 等工具子菜单更易于访问 Simplify menu depth to make tool submenus like claude-code and codex more accessible --- CLAUDE.md | 2 +- src-tauri/src/setup/menu.rs | 66 ++++++++++++++----------------------- 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 921aa23..f12662d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,7 +269,7 @@ last-updated: 2025-12-16 - Gemini CLI 的 model 字段为可选,允许不填(内置代理 Profile 不设置 model,保留用户原有配置) - **macOS 状态栏菜单增强(2026-02-08)**: - `src-tauri/src/setup/menu.rs` 增加“检查更新”入口,点击后走后端后台检查更新流程(不强制拉起窗口) - - 菜单新增“透明代理”一级分组,覆盖 `claude-code`、`codex`、`gemini-cli` 三工具(按需求不展示 `amp-code`) + - 菜单保留“透明代理”标题,`claude-code`、`codex`、`gemini-cli` 三工具子菜单在顶层直接可见(按需求不展示 `amp-code`) - 每个工具子菜单支持:启动代理、停止代理、打开透明代理页面、平铺展示的代理配置选择(含当前配置勾选态和 `更多...`,不再二级展开) - 从状态栏菜单选择代理配置时,如该工具透明代理未运行,会自动开启代理(必要时自动将 `enabled` 置为 true) - 透明代理工具子菜单标题显示运行状态(`运行中`/`已停止`),并按状态自动切换“启动代理/停止代理”菜单项可用性 diff --git a/src-tauri/src/setup/menu.rs b/src-tauri/src/setup/menu.rs index 9004a56..829c411 100644 --- a/src-tauri/src/setup/menu.rs +++ b/src-tauri/src/setup/menu.rs @@ -269,39 +269,6 @@ fn build_proxy_tool_submenu( builder.build() } -/// 构建透明代理总菜单 -fn build_proxy_submenu( - app: &AppHandle, - profile_manager: &ProfileManager, - proxy_config_mgr: Option<&ProxyConfigManager>, - running_states: &HashMap, -) -> tauri::Result> { - let mut builder = SubmenuBuilder::new(app, "透明代理"); - - for (idx, tool_id) in SUPPORTED_MENU_TOOLS.iter().enumerate() { - let profiles = profile_manager.list_profiles(tool_id).unwrap_or_default(); - let selected_profile = proxy_config_mgr - .and_then(|mgr| mgr.get_config(tool_id).ok().flatten()) - .and_then(|config| config.real_profile_name); - let is_running = running_states.get(*tool_id).copied().unwrap_or(false); - - let submenu = build_proxy_tool_submenu( - app, - tool_id, - &profiles, - selected_profile.as_deref(), - is_running, - )?; - builder = builder.item(&submenu); - - if idx < SUPPORTED_MENU_TOOLS.len() - 1 { - builder = builder.separator(); - } - } - - builder.build() -} - /// 创建菜单栏图标菜单 fn create_tray_menu( app: &AppHandle, @@ -334,18 +301,35 @@ fn create_tray_menu( } } - let proxy_submenu = build_proxy_submenu( - app, - profile_manager, - proxy_config_mgr.as_ref(), - running_states, - )?; + let proxy_title_item = + MenuItem::with_id(app, "menu:proxy_title", "透明代理", false, None::<&str>)?; let check_update_item = MenuItem::with_id(app, "menu:check_update", "检查更新", true, None::<&str>)?; + builder = builder.separator().item(&proxy_title_item); + + for (idx, tool_id) in SUPPORTED_MENU_TOOLS.iter().enumerate() { + let profiles = profile_manager.list_profiles(tool_id).unwrap_or_default(); + let selected_profile = proxy_config_mgr + .as_ref() + .and_then(|mgr| mgr.get_config(tool_id).ok().flatten()) + .and_then(|config| config.real_profile_name); + let is_running = running_states.get(*tool_id).copied().unwrap_or(false); + let submenu = build_proxy_tool_submenu( + app, + tool_id, + &profiles, + selected_profile.as_deref(), + is_running, + )?; + builder = builder.item(&submenu); + + if idx < SUPPORTED_MENU_TOOLS.len() - 1 { + builder = builder.separator(); + } + } + builder = builder - .separator() - .item(&proxy_submenu) .separator() .item(&check_update_item) .item(&MenuItem::with_id( From f64fb2890f9cfefd65c83cc461c95551af7f7a9a Mon Sep 17 00:00:00 2001 From: duanyongcheng <1171998654@qq.com> Date: Thu, 12 Feb 2026 16:21:14 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(config-watcher):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8F=98=E6=9B=B4=E6=8A=91=E5=88=B6=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E9=81=BF=E5=85=8D=E5=86=85=E9=83=A8=E5=86=99?= =?UTF-8?q?=E5=85=A5=E8=A7=A6=E5=8F=91=E5=A4=96=E9=83=A8=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=20Implement=20config=20suppression=20to=20av?= =?UTF-8?q?oid=20internal=20writes=20triggering=20external=20change=20even?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在配置监听器中引入抑制窗口机制(默认3秒) Introduce a suppression window mechanism (3 seconds) in the config watcher 添加 suppress_external_detection_for_tool 方法用于标记内部写入行为 Add suppress_external_detection_for_tool method to mark internal write actions 防止从状态栏切换代理配置时触发不必要的 external-config-changed 循环 Prevent unnecessary external-config-changed loops when switching proxy configs from the status bar 更新 CLAUDE.md 文档说明抑制机制及快照一致性维护逻辑 Update CLAUDE.md documentation regarding suppression mechanism and snapshot consistency --- CLAUDE.md | 1 + src-tauri/src/services/config/watcher.rs | 45 +++++++++++++++++++++++- src-tauri/src/setup/menu.rs | 8 +++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f12662d..e257a24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,6 +272,7 @@ last-updated: 2025-12-16 - 菜单保留“透明代理”标题,`claude-code`、`codex`、`gemini-cli` 三工具子菜单在顶层直接可见(按需求不展示 `amp-code`) - 每个工具子菜单支持:启动代理、停止代理、打开透明代理页面、平铺展示的代理配置选择(含当前配置勾选态和 `更多...`,不再二级展开) - 从状态栏菜单选择代理配置时,如该工具透明代理未运行,会自动开启代理(必要时自动将 `enabled` 置为 true) + - 从状态栏菜单切换代理配置前会调用配置监听抑制窗口(3 秒),避免内部写入触发 `external-config-changed`,并在抑制分支刷新快照保持一致性(2026-02-12) - 透明代理工具子菜单标题显示运行状态(`运行中`/`已停止`),并按状态自动切换“启动代理/停止代理”菜单项可用性 - `更多...` 与配置缺失引导统一使用窗口前置 + 导航,修复窗口隐藏时点击菜单“无反应”的体验问题 - 导航语义扩展:`navigate-to` 新增 `/transparent-proxy/` 路径,前端 `AppEventsHandler` 已兼容解析并自动选中对应工具 diff --git a/src-tauri/src/services/config/watcher.rs b/src-tauri/src/services/config/watcher.rs index 42610d1..f7e5286 100644 --- a/src-tauri/src/services/config/watcher.rs +++ b/src-tauri/src/services/config/watcher.rs @@ -12,11 +12,12 @@ use crate::models::Tool; use anyhow::{anyhow, Result}; use serde::Serialize; use serde_json::Value as JsonValue; +use std::collections::HashMap; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; // ========== 导出类型 ========== @@ -396,12 +397,42 @@ use std::sync::Mutex; static WATCHER_HANDLE: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| Mutex::new(None)); +/// 内部配置写入抑制窗口(tool_id -> 到期时间) +static INTERNAL_CHANGE_SUPPRESS_UNTIL: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); + /// Watcher 句柄 struct WatcherHandle { _watcher: RecommendedWatcher, stop_signal: Arc, } +/// 标记某个工具发生内部配置写入,在短时间内跳过外部变更检测。 +pub fn suppress_external_detection_for_tool(tool_id: &str, duration: Duration) { + let expire_at = Instant::now() + duration; + INTERNAL_CHANGE_SUPPRESS_UNTIL + .lock() + .unwrap() + .insert(tool_id.to_string(), expire_at); + + tracing::debug!( + tool_id = %tool_id, + duration_secs = duration.as_secs_f32(), + "标记内部配置写入,短时间跳过外部变更检测" + ); +} + +/// 检查工具是否在内部写入抑制窗口内,并清理已过期项。 +fn is_external_detection_suppressed(tool_id: &str) -> bool { + let now = Instant::now(); + let mut suppressed = INTERNAL_CHANGE_SUPPRESS_UNTIL.lock().unwrap(); + suppressed.retain(|_, expire_at| *expire_at > now); + + suppressed + .get(tool_id) + .is_some_and(|expire_at| *expire_at > now) +} + /// 启动配置文件监听 pub fn start_watcher(app_handle: AppHandle) -> Result<()> { // 读取配置判断是否启用 @@ -514,6 +545,18 @@ fn handle_file_change(path: &Path, app_handle: &AppHandle) -> Result<()> { }); if is_tool_config { + if is_external_detection_suppressed(&tool.id) { + tracing::debug!(tool_id = %tool.id, "检测到内部写入,跳过外部变更通知"); + if let Err(error) = save_snapshot_for_tool(&tool) { + tracing::warn!( + error = ?error, + tool_id = %tool.id, + "内部写入后刷新配置快照失败" + ); + } + break; + } + // 检测变更 if let Some(change) = detect_tool_change(&tool, watch_config)? { tracing::info!( diff --git a/src-tauri/src/setup/menu.rs b/src-tauri/src/setup/menu.rs index 829c411..82e5e17 100644 --- a/src-tauri/src/setup/menu.rs +++ b/src-tauri/src/setup/menu.rs @@ -18,6 +18,7 @@ use crate::commands::proxy_commands::{ }; use crate::commands::update_commands::{trigger_check_update_internal, UpdateServiceState}; use duckcoding::models::proxy_config::ToolProxyConfig; +use duckcoding::services::config::watcher::suppress_external_detection_for_tool; use duckcoding::services::profile_manager::ProfileManager; use duckcoding::services::proxy_config_manager::ProxyConfigManager; @@ -453,6 +454,11 @@ fn handle_proxy_menu_action(app: &AppHandle, action: ProxyMenuAct let tool_id_owned = tool_id.to_string(); let profile_name_owned = profile_name.to_string(); tauri::async_runtime::spawn(async move { + suppress_external_detection_for_tool( + &tool_id_owned, + std::time::Duration::from_secs(3), + ); + let proxy_state = app_handle.state::(); let profile_state = app_handle.state::(); if let Err(error) = update_proxy_from_profile_internal( @@ -636,6 +642,8 @@ pub fn setup_app_menu(app: &tauri::App) -> tauri::Result<()> { /// 处理 Profile 激活 fn handle_profile_activation(app: &AppHandle, tool_id: &str, profile_name: &str) { + suppress_external_detection_for_tool(tool_id, std::time::Duration::from_secs(3)); + let state = app.state::(); let manager = state.manager.blocking_read(); match manager.activate_profile(tool_id, profile_name) {