macOS 27.2 全屏托盘菜单修复复盘:把状态项搬进独立 AppKit 进程
2026-11-08
Crossbow(我维护的开源代理客户端)在 macOS 27.2 上撞到一个很有代表性的系统兼容问题:任何应用全屏时,菜单栏里的托盘图标点不开菜单。最后用一个「独立 AppKit 托盘进程」的架构把它根治了(PR #346)。这篇把现象、根因、方案和可复用的经验完整记一遍。
现象
macOS 27.2 上,在其他应用的全屏空间里点击 Crossbow 的菜单栏图标或实时速率区域:图标会高亮,但左右键都无法展开菜单。普通桌面(非全屏)一切正常。问题 100% 复现,且只在 27.2 上出现。
排查:怎么圈定根因
修这个 bug 之前先要回答两个问题:点击到底有没有到达我们的进程?菜单窗口是谁的?
- 点击到达了。图标高亮说明状态项的 tracking 已经开始;旧代码里还挂着
menuDidBeginTracking观察器,日志证明事件链是通的。卡的不是事件,是菜单窗口的呈现。 - 对照实验定分界。在同一个全屏空间里,用一个独立的无窗口 AppKit 进程挂原生状态项——菜单能正常弹出;甚至用同版本
tray-iconcrate 的最小样例也能弹。而正式版里由托盘库在主进程内创建的菜单就是不显示。虽然正式版的完整触发条件没能在独立样例里 100% 复刻,但分界线已经足够清晰:问题出在「菜单窗口归哪个进程所有」。
根因
旧实现里,托盘状态项由 tray-icon crate 在 Crossbow 主进程内创建。菜单看起来是原生的,但它是一个由主进程持有的 NSMenu,弹出时依赖 AppKit 在当前空间里为它开一个临时窗口。
macOS 27.2 的行为变化:普通应用进程自建的这类菜单弹窗,不再出现在其他应用的全屏空间里。全屏空间有自己的菜单栏语境,系统只保证"系统级原生状态项"的菜单能跨空间呈现——由 AppKit 亲自管理的状态项菜单走的是完全不同的呈现路径。
所以症状是精确的:点击事件到达主进程(图标高亮、tracking 开始),但菜单窗口被留在空间之外,什么都看不见。
修复:状态项搬进独立 AppKit 进程
思路一句话:把"画菜单"交还给 AppKit,桌面进程退化为数据提供方。
双进程架构
Crossbow 主进程 tray helper 进程
(Tauri 桌面,业务所在地) (同一二进制,--crossbow-native-tray 参数)
────────────────────── ─────────────────────────────────────
菜单快照 entries + 状态数据 ── stdin ──▶ NSStatusItem 原生挂载
每秒速率 ↑↓ ────────────────▶ (NSApplication · Accessory 策略,
节点测速结果 ───────────────▶ 无 Dock、无窗口)
AppKit 原生绘制菜单/分隔线/勾选/子菜单
◀───────────── stdout ── Action(id) 点击、悬停由 AppKit 原生呈现
◀───────────── stdout ── RefreshNodes (全屏空间下系统照常弹出)两条 JSON-lines 管道(stdin/stdout)就是全部 IPC:
- 下行(主进程 → helper):
Menu(entries, data)全量菜单快照、Nodes(data)节点子菜单数据、Speed(text)速率文本; - 上行(helper → 主进程):
Action(id)用户点击了哪一项、RefreshNodes/RefreshMenu请求刷新。
业务逻辑零复制:helper 不懂任何代理/TUN/配置——它只把 Action(id) 发回主进程,由主进程 run_on_main_thread 分发到原有的 handle_menu 等处理链。菜单项的标题、禁用态、勾选态全部来自主进程的快照,helper 只是 AppKit 层的"渲染 + 事件转发"。
三个关键设计点
1. 版本门控用选择器检测,不用版本号比较。 helper 只在 macOS 27+ 启用。判据不是读系统版本号,而是运行时探测 NSStatusItem 是否响应 setExpandedInterfaceDelegate: 这个 27 的新选择器:
if NSStatusItem::class()
.instance_method(sel!(setExpandedInterfaceDelegate:))
.is_none()
{
return Ok(None); // 旧 macOS:走进程内实现
}选择器存在性检测比版本号更稳——它直接回答"这个 API 在不在",天然兼容未来的小版本调整。
2. 降级链完整,任何一环挂掉都有兜底。
- helper 进程拉不起来(spawn 失败)→
install_in_process:同一套原生菜单代码直接在主进程内安装(旧 macOS 的常规路径); - helper 意外退出 → 读管道的线程感知 EOF,主线程收到
helper_stopped→ 自动回退进程内实现 + 重刷菜单 + 同步速率; - 状态项重复安装(理论上不该发生)→
STATUS_MENUOnceCell 护栏拒绝并移除新项。
也就是说 helper 是增强路径,不是单点依赖——最坏情况退回老架构,功能完整只是全屏下点不开(回到 bug 的原状,而不是更糟)。
3. 生命周期与管道闭合绑定。 主进程退出 → stdin 管道关闭 → helper 读 stdin 的循环收到 EOF → std::process::exit(0) 自杀。状态项是进程级资源,进程死它自然消失——不需要任何"请删除图标"的协议。反过来,helper 死也会让主进程的写入端报错,触发上面的降级。用管道闭合表达进程生死,是这类伴生进程最省事的可靠性设计。
顺带删掉的两个旧 hack
重构过程中有两个历史补丁失去了存在理由,一并移除:
TrayTarget透明点击区扩宽:tray-icon库会在按钮上叠一个图标尺寸的透明子视图转发点击,双行速率让按钮变宽后这个子视图不跟着变,导致"标题区域看得见点不中"——旧代码每次改标题都要手动setFrame把它撑满。AppKit 接管按钮后,命中区域天然正确。menuDidBeginTracking观察器 + 跨线程速率去重缓存:菜单开始跟踪的通知本来是给"打开时刷新"用的;新架构里数据是主推的(快照推送到 helper 即生效,菜单打开时看到的就是最新状态),观察器和LAST_SPEED全局缓存一起下线。
验证
- 签名测试包装进 /Applications,在 macOS 27.2 原复现场景(其他应用全屏)确认正式图标能打开菜单,菜单样式与旧版一致,右键同样可展开;
- helper 进程正常启动,主进程关闭输入管道后正常退出(生命周期闭环实测);
- 桌面 Rust 单元测试 140 通过、1 忽略;CI 双平台全绿;
- 未实测项如实标注:其他 macOS 版本、真实联网菜单操作(切换节点等)留待实机。
可复用的经验
- 系统新版本打破隐式假设时,优先考虑"换宿主"而不是继续修补。这个 bug 的本质是"菜单弹窗由普通 App 进程持有"这个假设失效了。继续在主进程里调 z-order/空间/呈现策略大概率修不动——换到 AppKit 亲自管理的宿主(独立进程)之后,问题从根上消失,而且旧 hack 反而变多余。
- 特性检测用"API 存在性"而不是版本号。
responds(to:)/instance_method检测新选择器,天然表达"行为差异从哪个能力开始",避开版本号比较的各种边界。 - 伴生进程的可靠性设计三板斧:管道 EOF = 进程生死信号;父死子随(读端阻塞即退出);降级路径保功能不保优化。三者齐了,多进程架构的运维心智负担和单进程没区别。
- 对照实验定位分界线。修系统兼容 bug 时,最小独立样例不是用来"复现 bug"的,是用来圈定失效边界的——本项目里"独立进程能弹、主进程不能弹"这一条对照,直接把修复方向从"调窗口层级"扭到了"换宿主"。
- 平台差异路径要显式降级而不是硬分叉。
install()的三分支(helper / 进程内 / 失败兜底)让 macOS 27+ 和旧版本共享同一套菜单描述代码,只在"谁来挂载"上分叉——将来 macOS 28 再变,只需要换一个分支的实现。
实现 PR:Crossbow#346 · issue:
#344。代码全在
apps/desktop/src-tauri/src/tray_helper.rs / tray_menu.rs / tray_speed.rs。