绕过 macOS 27 beta 的菜单栏多区域点击问题

昨天把macOS从26升级到了27beta。结果发现,菜单栏的常驻图标发生了很大的变化。首先是隐藏菜单项的工具失效了。其次,就是多按钮的菜单项也没法逐一点击每个按钮了。为此,我今早趁着OpenAI 6.0可以使用的机会,Astra来研究这件事。我让ChatGPT 5.6 Sol High先生成了一份计划。A、B、C三个选项。然后用Astra Medium审核这个计划,然后用Light来执行。结果这A、B、C全都失败了。上一首、播放、下一首和打开窗口,四个图标排在同一个状态项里,无论点击哪个位置,响应的都是“下一首”。

旧方案

这种交互的原理很简单:一个 NSStatusItem 占据一整块菜单栏空间,里面划分几个区域。可以让 AppKit 把事件送到对应的子按钮,也可以自己读取点击的局部坐标,判断应该执行哪个动作。两种做法都依赖事件能够反映用户实际点击的位置。

新方案(失败版)

但在我的测试环境——macOS 27.0 beta,build 26A5425a——这个前提出了问题。我让 Codex 建了一个宽 120pt、每区 30pt 的最小项目,依次尝试了三种方式:

  • 四个真正的 NSButton,分别绑定 action:失败。(A)
  • 四个普通 NSView,各自安装点击手势:失败。(B)
  • 一个自绘视图,通过手势的 location(in:) 获取坐标,再按区域判断:失败。(C)

三次都只有 Next 响应。第三次的日志给出了线索:可见的两次完整回调,局部坐标都是 (60, 11),正好是 120×22 视图的中心。而 x=60 属于第三个区域,所以计算结果总是 Next。这说明在当前测试里,收到的事件位置没有反映不同的点击位置。

新方案(成功版)

后来,Codex 查到了 Stats 的原始问题报告:Combined modules: per-module popups don't open on macOS 27 beta(#3456)。报告者遇到的是合并模式下点击 CPU 却打开 GPU,并提出了绕过方式:统一通过状态栏按钮的 action 接收点击,再用实际鼠标屏幕位置判断点击了哪个区域。 报告者称在另一个 macOS 27 beta build 上验证有效。我们接下来的实现采用了这个思路,并不是自己想到的。

我保留了单个状态项,把四个符号合成为一张图片,显示在标准 NSStatusBarButton 上。整个按钮只绑定一个 action。收到 action 后,立即读取 NSEvent.mouseLocation,再把按钮范围转换成屏幕矩形,两者在同一个坐标系中比较。

核心代码如下,放在已有的 AppDelegate 中。图标绘制和具体播放动作省略:

private var statusItem: NSStatusItem!

private func createStatusItem() {
    statusItem = NSStatusBar.system.statusItem(withLength: 120)
    statusItem.button?.target = self
    statusItem.button?.action = #selector(statusClicked(_:))
    // button.image 为包含四个固定排列符号的合成图片。
}

@objc private func statusClicked(_ sender: NSStatusBarButton) {
    let mouse = NSEvent.mouseLocation
    guard let window = sender.window else { return }

    let screenRect = window.convertToScreen(
        sender.convert(sender.bounds, to: nil)
    )

    // 本实验只处理鼠标点击,避免按鼠标位置解释键盘激活。
    guard let type = NSApp.currentEvent?.type,
          type == .leftMouseUp || type == .leftMouseDown else {
        return
    }

    guard mouse.x.isFinite, mouse.y.isFinite,
          screenRect.width > 0,
          screenRect.contains(mouse) else {
        return
    }

    let x = (mouse.x - screenRect.minX) * 120 / screenRect.width
    let region = min(3, Int(x / 30))
    let actions = ["Previous", "Play", "Next", "Window"]
    print(actions[region])
}

这里的转换分两步:convert(_:to: nil) 把按钮内部范围转换到窗口坐标,convertToScreen 再转换到屏幕坐标。减去屏幕矩形的左边界,就得到鼠标在按钮内的横向位置,随后按四个区域分配动作。如果实际界面的区域不等宽,就应该使用各自的布局范围。

这次四个图标终于能够分别响应。截图里的四项计数各为 1,总计 4。日志中,Play 对应的 x 约为 43.91,Previous 为 9.18,Window 为 102.70,都落在正确区域。

它能成功的关键,是不再依赖事件自带的位置来分区。 可见日志中,currentEvent 的窗口坐标仍然是 (68, 15),但 NSEvent.mouseLocation 取得的屏幕鼠标位置会随着点击变化。标准按钮负责告诉应用“发生了点击”,实际鼠标位置负责告诉应用“点在哪里”。这就绕过了本次测试中失效的子视图分发和事件坐标路径,也保留了播放器作为一个整体的固定排列。

这仍然是一个经过基础鼠标点击验证的 beta 绕过方案。我们没有确定系统内部为什么会给出固定坐标;多屏、菜单栏自动隐藏和快速移开鼠标也还需要测试,因为这里采样的是回调时的鼠标位置。至少在当前 build 上,菜单栏播放器不必拆成几个可以各自移动的状态项,原来的整体交互可以保留下来。