news 2026/10/2 17:12:13

BongoCat 托盘与模型窗口右键菜单复用架构:单一 popup 根、强类型 action 与生命周期不变量

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BongoCat 托盘与模型窗口右键菜单复用架构:单一 popup 根、强类型 action 与生命周期不变量
  • 桌面应用

【免费下载链接】BongoCat

🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!

项目地址:https://gitcode.com/gh_mirrors/bong/BongoCat
点击查看免费下载

导读

本文基于 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):

ActionMenuId
打开设置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
退出 BongoCatbongocat.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 定义了四条生命周期不变量,源码逐一印证:

  1. 字段析构顺序:TrayIcon先于菜单根/子菜单/菜单项声明(已在决策一说明);显式 shutdown 先set_visible(false)隐藏托盘,再按既定顺序停止 input/runtime/frame/renderer/overlay(system_menu_native.rs)。

  2. 右键只使用真实窗口句柄:overlay 右键弹出只使用真实 Windows HWND 或 macOS contentNSView,不借用托盘隐藏窗口,也不在 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 期间保持窗口存活。

  3. 主线程约束:菜单根、模型窗口子菜单和所有更新/文字/勾选操作都在平台 UI 主线程执行;macOS 侧用MainThreadMarker::new().ok_or(SystemMenuError::WrongThread)?在start_with_presentation与set_presentation入口强校验(system_menu_native.rs)。

  4. 第三方类型不泄漏: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 的验证分四层,全部有仓库证据:

  1. 平台 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。
  2. 菜单布局 contract:断言托盘与模型窗口右键共用同一组根项,且模型窗口子菜单包含四个 check item(即上表两棵MenuEntry常量数组)。
  3. locale 双向 key 守门:确保navigation.model_window.title、偏好设置已有的显隐/穿透/置顶/悬停鼠标文案,以及 About 已有的update.about.label可解析;菜单不新增重复文案 key。
  4. 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 缺陷(见决策一的已知限制);
  • 主线程约束(macOSMainThreadMarker);
  • macOSNSView生命周期;
  • 两个平台的实机菜单层级。

这套约束同时呼应 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!

项目地址:https://gitcode.com/gh_mirrors/bong/BongoCat
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 17:11:26

上海企业员工班车租赁深度测评:指标与核验

上海企业员工班车租赁常被当成一次比价采购&#xff0c;真正的风险却不在报价单上。线路临时调整、司机更换、车辆年检到期、旺季调不到备用车&#xff0c;这些问题往往在合同签订后才暴露。所谓深度测评&#xff0c;不是给服务商打分排名&#xff0c;而是把采购指标变成可验证…

作者头像 李华
网站建设 2026/10/2 17:11:23

具身智能最性感的生意,藏在“边角料”里?

作者&#xff1a;Evin编辑&#xff1a;刘致呈审核&#xff1a;徐徐出品&#xff1a;互联网江湖最近&#xff0c;国内具身智能赛道迎来了一场意外风波。先是上市公司梅卡曼德的CEO邵天兰&#xff0c;在朋友圈公开炮轰当前行业盛行的一种“攒局型”具身智能创业——套路就是靠各种…

作者头像 李华
网站建设 2026/10/2 17:08:55

AI应用开发平台工程化底座:Agent编排与多供应商接入实战

1. 为什么我们需要重新审视 AI 应用开发平台过去一年&#xff0c;我接触了不下二十个团队在搞 AI 应用落地&#xff0c;从几个人小团队到大厂创新部门都有。一个非常普遍的困境是&#xff1a;Demo 跑通只要两天&#xff0c;但真要上线一个能稳定服务几百上千用户的 AI 应用&…

作者头像 李华
网站建设 2026/10/2 17:07:34

河北信誉好的会议音视频系统销售服务商有哪些,服务质量评选与用户力荐

河北信誉好的会议音视频系统销售服务商有哪些&#xff0c;服务质量评选与用户力荐 优质服务商推荐&#xff1a;天津优展科技有限公司在河北地区挑选会议音视频系统销售服务商时&#xff0c;天津优展科技有限公司是值得重点关注的本地化企业。这家公司深耕津冀音视频集成行业二十…

作者头像 李华
网站建设 2026/10/2 17:05:50

强化学习数学原理:从Bellman方程到策略梯度,读法与避坑指南

简介&#xff1a;《强化学习的数学原理》是西湖大学赵世钰教授撰写的一部英文原著PDF&#xff0c;面向具备一定数学基础的强化学习学习者与研究者&#xff0c;旨在从数学角度系统揭示强化学习的本质原理。全书从零开始&#xff0c;结合大量网格世界等直观例子&#xff0c;循序渐…

作者头像 李华