- 桌面应用
【免费下载链接】BongoCat
🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!
导读
本文基于 BongoCat 的架构决策记录 ADR-0068,系统讲解桌面宠物应用如何让系统托盘菜单与模型窗口右键菜单共用同一棵原生菜单树,从而消除两个入口在层级与状态上的不一致。文章覆盖菜单树的单一 owner 设计(bongocat-platform::SystemMenu)、强类型SystemMenuAction的动作边界、由 settings snapshot 驱动的菜单 presentation、显隐/穿透/置顶/悬停隐藏四项 check item 的状态语义,以及托盘图标先销毁、菜单项父级关系和主线程约束等生命周期不变量。读完本文,你将理解这套“一个 popup 根、一个 owner、一套 action id”的实现原理,并能依据 crates/bongocat-platform/src/system_menu_native.rs 与 crates/bongocat-platform/src/system_menu.rs 快速定位菜单相关代码与测试。
背景:为什么要统一托盘与右键菜单
BongoCat 的托盘菜单和模型窗口右键菜单都代表同一个“模型窗口控制面”。早期的方案尝试按入口拆成两棵不同的菜单树,但同一组模型窗口操作在两个入口会出现不同的层级和状态,既增加维护成本,也让验收测试难以收敛。
ADR-0068(docs/adr/0068-tray-and-overlay-context-menu-layering.md,2026-09-25 修订为“托盘与模型窗口右键共用同一套菜单”)明确了本次调整的边界:
- 只改变菜单树的复用方式,不改变应用状态的所有权;
- 平台层仍只产生强类型
SystemMenuAction,overlay 只发送右键请求; - runtime、settings service 和 renderer不感知菜单树,菜单的呈现与动作分发被隔离在平台层与应用协调层。
换句话说,这是一次“结构收敛”而非“功能扩展”:菜单少了,但语义更稳定。
决策一:一棵 popup 根,一个 owner
SystemMenu是唯一的菜单 owner
在crates/bongocat-platform/src/lib.rs中,平台 crate 导出SystemMenuAction、SystemMenuError与SystemMenuPresentation(lib.rs),而实际的 native 实现位于system_menu_native.rs。
SystemMenu结构体的字段顺序本身就是一条设计约束(system_menu_native.rs):
pub struct SystemMenu { // Keep the tray icon first: the native menu handles must be released after // the status item stops using them. tray_icon: TrayIcon, menu: Menu, model_window: Submenu, items: NativeMenuItems, #[cfg(target_os = "windows")] tray_icon_id: TrayIconId, sender: Sender<SystemMenuAction>, receiver: Receiver<SystemMenuAction>, visible: bool, }关键点:
TrayIcon字段必须先于菜单根、Submenu和菜单项声明。Rust 的字段析构顺序与声明顺序一致,因此托盘图标总是先于菜单句柄释放,避免“状态栏还在引用已被释放的菜单”这一经典资源顺序问题。- 托盘图标和模型窗口右键都展示同一棵菜单树:
menu根被同时挂到TrayIcon的.with_menu(...)上,并在右键请求时通过show_context_menu_for_window呈现到 overlay 的真实窗口句柄。
菜单树结构(ADR-0068 原文,可完整复现):
设置 ──────── 模型窗口 ├─ □ 隐藏模型窗口 ├─ □ 鼠标穿透 ├─ □ 始终置顶 └─ □ 鼠标悬停时隐藏 ──────── 检查更新(仅在更新能力可用时创建) ──────── 退出 BongoCat这一布局在源码中由两个常量数组描述(system_menu_native.rs):
const MENU_ENTRIES: &[MenuEntry] = &[ MenuEntry::OpenSettings, MenuEntry::Separator, MenuEntry::ModelWindow, MenuEntry::Separator, MenuEntry::CheckForUpdates, MenuEntry::Separator, MenuEntry::Quit, ]; const MODEL_WINDOW_ENTRIES: &[MenuEntry] = &[ MenuEntry::ToggleOverlay, MenuEntry::ToggleClickThrough, MenuEntry::ToggleAlwaysOnTop, MenuEntry::ToggleHideOnPointerHover, ];MENU_ENTRIES定义根级项(含“模型窗口”子菜单的挂载点),MODEL_WINDOW_ENTRIES定义子菜单内的四个 check item。append_menu_entries在拼接时会做一项细节处理:更新行可选,当check_for_updates为None时跳过该行,并折叠它两侧的分隔符(previous_was_separator逻辑),避免在无法更新的 channel 上留下一个空段(system_menu_native.rs)。
菜单不再提供哪些行
ADR-0068 明确:菜单不再提供源码、重启、版本、缩放或透明度行。
- 源码与版本已由设置页的 About 页面提供;
- 缩放由设置页和右键拖动提供;
- 透明度留在设置页;
- 检查更新由构建/channel 事实决定是否创建,不显示永久禁用的空行。实现上,
check_for_updates字段是Option<MenuItem>,仅在presentation.update_check_available时构造(system_menu_native.rs)。
Windows 托盘图标细节:GUID 与 tooltip
Windows 平台上托盘图标使用固定 GUID 注册(TRAY_ICON_GUID: u128 = 0x123f3c6f_7d2a_4ca3_b8cb_9b1d1eaf2f10,system_menu_native.rs)。源码注释明确指出tray-icon 0.25.0的一个已知缺陷:对 GUID 注册的图标调用set_tooltip会发出不带NIF_GUID的NIM_MODIFY,shell 会忽略uID而要求后续每次调用都带同一个 GUID,导致该调用总是失败。因此tooltip 只能在 owner 创建时设置一次,生命周期内保持不变量;修改 tooltip 需要替换整个托盘 owner,而 ADR-0031 禁止在应用运行期间做这种替换(system_menu_native.rs)。
决策二:动作与状态边界
强类型SystemMenuAction
两个入口共用同一批 native item 实例和同一组稳定 action id,由action_for_menu_id把muda::MenuId映射为强类型SystemMenuAction(system_menu_native.rs):
fn action_for_menu_id(id: &MenuId) -> Option<SystemMenuAction> { Some(match id.0.as_str() { OPEN_SETTINGS_ID => SystemMenuAction::OpenSettings, TOGGLE_OVERLAY_VISIBILITY_ID => SystemMenuAction::ToggleOverlayVisibility, TOGGLE_CLICK_THROUGH_ID => SystemMenuAction::ToggleClickThrough, TOGGLE_ALWAYS_ON_TOP_ID => SystemMenuAction::ToggleAlwaysOnTop, TOGGLE_HIDE_ON_POINTER_HOVER_ID => SystemMenuAction::ToggleHideOnPointerHover, CHECK_FOR_UPDATES_ID => SystemMenuAction::CheckForUpdates, QUIT_ID => SystemMenuAction::Quit, _ => return None, }) }SystemMenuAction枚举定义在平台层(system_menu.rs),是Copy + Eq的纯值类型,动作集合刻意保持小且稳定:
pub enum SystemMenuAction { OpenSettings, ToggleOverlayVisibility, // 会话级显隐,check 状态表示“已隐藏” ToggleClickThrough, ToggleAlwaysOnTop, ToggleHideOnPointerHover, CheckForUpdates, Quit, }对应的稳定 id 字符串常量(system_menu_native.rs):
| Action | MenuId |
|---|---|
| 打开设置 | bongocat.open-settings |
| 隐藏/显示模型窗口 | bongocat.toggle-overlay |
| 鼠标穿透 | bongocat.toggle-click-through |
| 始终置顶 | bongocat.toggle-always-on-top |
| 鼠标悬停时隐藏 | bongocat.toggle-hide-on-pointer-hover |
| 检查更新 | bongocat.check-for-updates |
| 退出 BongoCat | bongocat.quit |
单测menu_action_mapping_keeps_only_the_current_action_set断言上述 7 个 id 都能映射为对应 action,同时断言被删除的bongocat.open-source、bongocat.restart、bongocat.version甚至子菜单 idbongocat.model-window均返回None,从测试层面锁死“已删除的 id 不会产生 action”(system_menu_native.rs)。
事件接收:同一MenuEventreceiver,一个有界队列
SystemMenu::try_recv统一轮询muda::MenuEvent::receiver(),把命中action_for_menu_id的事件送入自身mpsc队列;Windows 上还会额外轮询TrayIconEvent,左键单击托盘图标被映射为OpenSettings(system_menu_native.rs):
pub fn try_recv(&self) -> Option<SystemMenuAction> { while let Ok(event) = MenuEvent::receiver().try_recv() { if let Some(action) = action_for_menu_id(&event.id) { let _ = self.sender.send(action); } } #[cfg(target_os = "windows")] while let Ok(event) = TrayIconEvent::receiver().try_recv() { if let TrayIconEvent::Click { id, button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event && id == self.tray_icon_id { let _ = self.sender.send(SystemMenuAction::OpenSettings); } } self.receiver.try_recv().ok() }注意:菜单 tracking 期间只允许把事件送入既有有界队列,不能在 callback 内销毁SystemMenu或 overlay。应用侧对 action 的分发同样不增加菜单专用弱类型协议——动作直接对应到 settings command 与 revisioned 配置写入。
presentation 由 settings snapshot 驱动
平台层只持有 native 菜单句柄,绝不成为配置或本地化的第二来源。SystemMenuPresentation由应用层从 settings snapshot 投影生成(crates/bongocat-app/src/system_menu.rs):
pub(crate) fn system_menu_presentation(snapshot: &SettingsSnapshot) -> SystemMenuPresentation { let locale = snapshot.resolved_language.catalog_locale(); let text = |key| bongocat_i18n::text(locale, key).to_owned(); SystemMenuPresentation { title: text("system_menu.title"), tooltip: text("system_menu.title"), open_settings: text("system_menu.open_settings"), model_window: text("navigation.model_window.title"), hide_overlay: text("settings.overlay.hide_model_window.label"), click_through: text("settings.overlay.click_through.label"), always_on_top: text("settings.overlay.always_on_top.label"), hide_on_pointer_hover: text("settings.overlay.hide_on_mouse_hover.label"), check_for_updates: text("update.about.label"), quit: text("system_menu.quit"), overlay_visible: snapshot.overlay_visible, click_through_enabled: snapshot.overlay.click_through, always_on_top_enabled: snapshot.overlay.always_on_top, hide_on_pointer_hover_enabled: snapshot.overlay.hide_on_pointer_hover, update_check_available: /* 见下文 */, } }要点拆解:
- 文案复用偏好设置已有的 key:显隐用
settings.overlay.hide_model_window.label,穿透/置顶/悬停隐藏分别复用settings.overlay.click_through.label、settings.overlay.always_on_top.label、settings.overlay.hide_on_mouse_hover.label,检查更新复用 About 已有的update.about.label。菜单不新增重复文案 key——这正是 ADR 中“locale 双向 key 守门”约束的来源。 - “已隐藏”为选中值:
visibility_checked(overlay_visible)返回!overlay_visible,即模型窗口隐藏时 check item 打勾;启动默认未选中,因此模型窗口默认可见(system_menu_native.rs 与单测visibility_is_rendered_as_a_check_state_meaning_hidden)。 - 显隐是 runtime 会话状态,不写入
config.json;菜单与设置页共享同一个 runtime snapshot 投影。其余三个开关(穿透/置顶/悬停隐藏)走set_overlay_settings写回配置。
check item 失败后的回写
ADR 规定:若 check item 的 command 失败,应用重新读取当前 snapshot 并回写 presentation,避免 native menu 保留用户点击产生的乐观勾选状态。refresh_system_menu_presentation(crates/bongocat-app/src/system_menu.rs)正是这套回写机制的入口:它读取最新 snapshot、生成 presentation,再通过ProductCoordinator上的system_menu.set_presentation(...)应用到两个菜单面。set_presentation只更新 item 文本与 check 状态(items.update),不重新创建菜单树(system_menu_native.rs)。
决策三:生命周期不变量
ADR-0068 定义了四条生命周期不变量,源码逐一印证:
字段析构顺序:
TrayIcon先于菜单根/子菜单/菜单项声明(已在决策一说明);显式 shutdown 先set_visible(false)隐藏托盘,再按既定顺序停止 input/runtime/frame/renderer/overlay(system_menu_native.rs)。右键只使用真实窗口句柄:overlay 右键弹出只使用真实 Windows HWND 或 macOS content
NSView,不借用托盘隐藏窗口,也不在 overlay 内创建第二个 owner。show_context_menu_for_window通过HasWindowHandle取得句柄后按平台分发(system_menu_native.rs):- Windows:
menu.show_context_menu_for_hwnd(handle.hwnd.get(), None); - macOS:要求
MainThreadMarker后调用menu.show_context_menu_for_nsview(handle.ns_view.as_ptr(), None); - 其他句柄类型返回
SystemMenuError::UnsupportedWindowHandle。
overlay 的
HasWindowHandle实现在 product_session.rs(macOS 另有 macos/session.rs、Windows 在 windows/session.rs),安全注释保证 overlay 的句柄实现会在同步 popup 期间保持窗口存活。- Windows:
主线程约束:菜单根、模型窗口子菜单和所有更新/文字/勾选操作都在平台 UI 主线程执行;macOS 侧用
MainThreadMarker::new().ok_or(SystemMenuError::WrongThread)?在start_with_presentation与set_presentation入口强校验(system_menu_native.rs)。第三方类型不泄漏:
muda与tray-icon的版本、features 与替换边界继续由 ADR-0031 固定(docs/adr/0031-tray-icon-boundary.md),第三方类型不进入公共业务 API——平台 crate 只导出SystemMenuAction、SystemMenuError、SystemMenuPresentation三个自有类型。
应用侧的右键调用链
右键请求在应用主循环中处理。macOS 分支(crates/bongocat-app/src/main.rs):
if context_menu_requested && let Some(menu) = coordinator.system_menu.as_ref() && let Some(overlay) = coordinator.overlay.as_ref() && let Err(error) = menu.show_context_menu_for_window(overlay) { // 记录 ServiceFailed 日志 + record_failure }Windows 分支在cx.update中先借用coordinator.overlay.borrow()取得 overlay 再调用同一方法(main.rs),失败路径统一走frame_application_log的ApplicationLogEvent::new(ApplicationLogCode::ServiceFailed)并附带"context_menu_failed"原因。两平台共用menu.show_context_menu_for_window(overlay)这一入口,验证了“overlay 只发送右键请求,不感知菜单树”的边界。
验证体系
ADR-0068 的验证分四层,全部有仓库证据:
- 平台 crate 单测(system_menu_native.rs):
visibility_is_rendered_as_a_check_state_meaning_hidden:断言显隐勾选语义(隐藏=勾选);both_menu_surfaces_use_the_same_layout:断言根级项含ModelWindow/CheckForUpdates,子菜单含四个 check item;menu_action_mapping_keeps_only_the_current_action_set:断言 7 个 action id 均映射、4 个已删除 id 不产生 action。
- 菜单布局 contract:断言托盘与模型窗口右键共用同一组根项,且模型窗口子菜单包含四个 check item(即上表两棵
MenuEntry常量数组)。 - locale 双向 key 守门:确保
navigation.model_window.title、偏好设置已有的显隐/穿透/置顶/悬停鼠标文案,以及 About 已有的update.about.label可解析;菜单不新增重复文案 key。 - macOS/Windows release system-menu smoke:覆盖托盘显隐、设置恢复、显隐 action、runtime snapshot 变化和有序退出。它不替代真实 popup 展开与点击验证——ADR 明确列出发布前必须在两个目标平台实机确认的清单:托盘与模型窗口右键展示同一层级、子菜单展开、cursor 定位、DPI/Retina、点击外部关闭、菜单项 action 派发、Explorer/菜单栏恢复和 shutdown 清理。
另外,system_menu_smoke是一个 opt-in 的构建选项(见 crates/bongocat-app/src/binary_tests/product_options.rs 与 smoke_status.rs),release 构建才启用,避免开发构建误跑依赖原生菜单环境的冒烟测试。
替换边界
ADR-0068 把变更面压到最小:替换点只有crates/bongocat-platform/src/system_menu_native.rs与 overlay 的HasWindowHandle实现。升级muda/tray-icon时必须重新验证以下事项,且不能退回“由 overlay 持有菜单”或“把第三方类型泄漏到 runtime/UI”:
- 共用菜单根与菜单项父级关系;
- 析构顺序(托盘图标先于菜单句柄释放);
- 同一个
MenuEventreceiver 的事件分发; - Windows GUID/tooltip 缺陷(见决策一的已知限制);
- 主线程约束(macOS
MainThreadMarker); - macOS
NSView生命周期; - 两个平台的实机菜单层级。
这套约束同时呼应 ADR-0032(启动权限提示边界,docs/adr/0032-startup-permission-prompt-boundary.md)与 ADR-0035(更新 worker 与更新窗口,docs/adr/0035-update-worker-and-window.md)中“平台能力只在平台层落地、业务侧只见强类型接口”的一贯原则。
小结
ADR-0068 的落地方案可以概括为三句话:
- 结构上,一棵
Menu根同时服务托盘图标与模型窗口右键,唯一的 owner 是bongocat-platform::SystemMenu,字段顺序保证析构安全; - 语义上,7 个稳定
MenuId通过action_for_menu_id映射到强类型SystemMenuAction,菜单文本与勾选状态完全由 settings snapshot 投影而来,不产生第二份配置或本地化状态; - 边界上,overlay 只贡献真实窗口句柄,runtime/settings/renderer 不感知菜单树,
muda/tray-icon的升级风险被收口到单一 native 文件。
对维护者而言,若要在未来调整菜单(增删项、改层级),核心改动点就是MENU_ENTRIES/MODEL_WINDOW_ENTRIES两个常量、NativeMenuItems::new的 item 构造,以及action_for_menu_id的映射表,三者必须同步更新,并让menu_action_mapping_keeps_only_the_current_action_set等单测继续锁定新集合。
- 桌面应用
【免费下载链接】BongoCat
🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!
相关推荐
用 `PhantomData` 为生命周期"打品牌":Comprehensive Rust 中生命周期子类型与不变量变型(Token Types 系列 2/4)
用 PhantomData 为生命周期"打品牌":Comprehensive Rust 中生命周期子类型与不变量变型(Token Types 系列 2/4) 本
文档教程LobeHub Desktop 菜单体系实战指南:App 菜单、右键菜单与托盘菜单的配置原理
LobeHub Desktop 菜单体系实战指南:App 菜单、右键菜单与托盘菜单的配置原理 本篇基于 LobeHub 仓库中的 Desktop 菜单配置指南
人工智能AI 应用大模型AI Agent多智能体工具调用前端后端BongoCat 托盘图标与系统菜单边界设计:tray-icon 与 muda 的单一 Owner 架构实践
BongoCat 托盘图标与系统菜单边界设计:tray icon 与 muda 的单一 Owner 架构实践 导读 本文基于 BongoCat 项目的架构决策记
桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考