diff --git a/CLAUDE.md b/CLAUDE.md index 3df75d9..e257a24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,14 +259,24 @@ 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) + - 从状态栏菜单切换代理配置前会调用配置监听抑制窗口(3 秒),避免内部写入触发 `external-config-changed`,并在抑制分支刷新快照保持一致性(2026-02-12) + - 透明代理工具子菜单标题显示运行状态(`运行中`/`已停止`),并按状态自动切换“启动代理/停止代理”菜单项可用性 + - `更多...` 与配置缺失引导统一使用窗口前置 + 导航,修复窗口隐藏时点击菜单“无反应”的体验问题 + - 导航语义扩展:`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/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/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/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/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/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 821bc34..3cc820e 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) } @@ -900,3 +926,179 @@ impl ProfileManager { Ok((claude, codex, gemini)) } } + +#[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(()) + } +} diff --git a/src-tauri/src/setup/menu.rs b/src-tauri/src/setup/menu.rs new file mode 100644 index 0000000..82e5e17 --- /dev/null +++ b/src-tauri/src/setup/menu.rs @@ -0,0 +1,764 @@ +//! macOS 应用菜单栏模块 +//! +//! 提供 Profile 快捷切换、透明代理控制和更新检查功能,仅在 macOS 下启用 + +use std::collections::HashMap; +use tauri::{ + menu::{ + CheckMenuItem, Menu, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, + }, + tray::TrayIconBuilder, + AppHandle, Emitter, Manager, Runtime, +}; + +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::config::watcher::suppress_external_detection_for_tool; +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 { + match tool_id { + "claude-code" => "Claude Code", + "codex" => "Codex", + "gemini-cli" => "Gemini CLI", + _ => "Unknown", + } +} + +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}` +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)) +} + +/// 解析透明代理菜单项 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, + 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 { + 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 { + 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_MENU_PROFILE_COUNT { + 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() +} + +/// 构建单个工具的透明代理子菜单 +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 create_tray_menu( + app: &AppHandle, + profile_manager: &ProfileManager, + running_states: &HashMap, +) -> tauri::Result> { + let proxy_config_mgr = ProxyConfigManager::new().ok(); + 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 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) + .ok() + .flatten(); + let submenu = build_tool_profile_submenu(app, tool_id, &profiles, active.as_deref())?; + builder = builder.item(&submenu); + if i < SUPPORTED_MENU_TOOLS.len() - 1 { + builder = builder.separator(); + } + } + + 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(&check_update_item) + .item(&MenuItem::with_id( + app, + "menu:settings", + "设置...", + true, + Some("CmdOrCtrl+,"), + )?) + .separator() + .item(&PredefinedMenuItem::quit(app, Some("退出 DuckCoding"))?); + + 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 { + 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( + &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 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()) + .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" { + return; + } + if profile_name == "more" { + focus_and_navigate(app, "/profile"); + return; + } + + 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; + } + + match id { + "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" => { + super::focus_main_window(app); + } + _ => {} + } + }) + .build(app)?; + + Ok(()) +} + +/// 处理 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) { + 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<()> { + 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") { + 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_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"); + 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/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} +
+ ); } diff --git a/src/components/logic/AppEventsHandler.tsx b/src/components/logic/AppEventsHandler.tsx index fa4dacb..f43f869 100644 --- a/src/components/logic/AppEventsHandler.tsx +++ b/src/components/logic/AppEventsHandler.tsx @@ -117,17 +117,48 @@ 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'); + } 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'); + } + }); + 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, setSettingsInitialTab, setSettingsRestrictToTab, + setSelectedProxyToolId, setIsUpdateDialogOpen, setUpdateInfo, setTokenStatsParams, 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]); // 加载所有透明代理状态