Skip to content

macOS 27.2 全屏托盘菜单修复复盘:把状态项搬进独立 AppKit 进程 ​

2026-11-08

Crossbow(我维护的开源代理客户端)在 macOS 27.2 上撞到一个很有代表性的系统兼容问题:任何应用全屏时,菜单栏里的托盘图标点不开菜单。最后用一个「独立 AppKit 托盘进程」的架构把它根治了(PR #346)。这篇把现象、根因、方案和可复用的经验完整记一遍。

现象 ​

macOS 27.2 上,在其他应用的全屏空间里点击 Crossbow 的菜单栏图标或实时速率区域:图标会高亮,但左右键都无法展开菜单。普通桌面(非全屏)一切正常。问题 100% 复现,且只在 27.2 上出现。

排查:怎么圈定根因 ​

修这个 bug 之前先要回答两个问题:点击到底有没有到达我们的进程?菜单窗口是谁的?

  1. 点击到达了。图标高亮说明状态项的 tracking 已经开始;旧代码里还挂着 menuDidBeginTracking 观察器,日志证明事件链是通的。卡的不是事件,是菜单窗口的呈现。
  2. 对照实验定分界。在同一个全屏空间里,用一个独立的无窗口 AppKit 进程挂原生状态项——菜单能正常弹出;甚至用同版本 tray-icon crate 的最小样例也能弹。而正式版里由托盘库在主进程内创建的菜单就是不显示。虽然正式版的完整触发条件没能在独立样例里 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 的新选择器:

rust
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_MENU OnceCell 护栏拒绝并移除新项。

也就是说 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 版本、真实联网菜单操作(切换节点等)留待实机。

可复用的经验 ​

  1. 系统新版本打破隐式假设时,优先考虑"换宿主"而不是继续修补。这个 bug 的本质是"菜单弹窗由普通 App 进程持有"这个假设失效了。继续在主进程里调 z-order/空间/呈现策略大概率修不动——换到 AppKit 亲自管理的宿主(独立进程)之后,问题从根上消失,而且旧 hack 反而变多余。
  2. 特性检测用"API 存在性"而不是版本号。responds(to:) / instance_method 检测新选择器,天然表达"行为差异从哪个能力开始",避开版本号比较的各种边界。
  3. 伴生进程的可靠性设计三板斧:管道 EOF = 进程生死信号;父死子随(读端阻塞即退出);降级路径保功能不保优化。三者齐了,多进程架构的运维心智负担和单进程没区别。
  4. 对照实验定位分界线。修系统兼容 bug 时,最小独立样例不是用来"复现 bug"的,是用来圈定失效边界的——本项目里"独立进程能弹、主进程不能弹"这一条对照,直接把修复方向从"调窗口层级"扭到了"换宿主"。
  5. 平台差异路径要显式降级而不是硬分叉。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。

Released under the MIT License.