症状:右键开始抽风

最近一周多,这台 Mac 的右键一直在抽风:点了没反应是常态,偶尔左键反而弹出右键菜单,过一会儿又自己好了。换鼠标没用,注销没用,重启也没用。

这类"时好时坏"的问题最费时间,因为它每次都给你一点希望。

转折:拔掉 USB-C 就好了

真正的转折只有一句话:把显示器那根 USB-C 拔掉,右键立刻恢复;插回去就坏。

于是问题从"Mac 的右键功能坏了"收缩成"这根线带来的东西"。顺着查,插上时机器上多出这几只 HID 设备:

设备VID声明的 HID 用途
罗技 ERGO M575(蓝牙)0x046D鼠标
联想有线键盘0x17EF键盘 + 消费控制 + 鼠标
三星 LS32D80xU 显示器0x04E8厂商自定义

注意第二行:一只键盘同时声明了一个完整的"三键鼠标"接口——因为它其实是一套键鼠,鼠标就插在显示器背后的 USB 口上。

分层取证:HID 报告 vs 系统事件

要判定"硬件坏"还是"软件吃事件",必须在两个不同层同时取证:

  • HID 原始报告:用 IOHIDManager 直接读设备的输入报告,绕开 macOS 的全部解析逻辑;
  • 系统事件层:用 CGEventTap 挂在 kCGHIDEventTap。这是用户态能观察到的最早一环——任何第三方鼠标增强工具的事件拦截都在它之后。

同一时段做"右键 5 下 → 停 → 左键 5 下",两层记录对齐:

时刻HID 原始报告系统事件层
13:12:58 / :59 / 13:13:0102 02 00 00 …(按键位 = 0x02,右键)✅零事件 ❌
13:13:04 – 13:13:1302 01 00 00 …(0x01,左键)✅LEFT-down/up 全部正常 ✅

整段捕获里,左键产生 26 次 LEFT-down,右键 0 次 RIGHT-down。

结论很干脆:鼠标硬件完全正常,是系统在指标这一层把右键丢掉了。

两个取证工具(伪代码)

两层各一个最小程序,加起来不到一百行。关键点不在代码量,在挂载的位置。

① HID 原始报告:设备到底发了什么

manager = IOHIDManagerCreate()

# 按 VID/PID 精确匹配,避免抓到别的设备
IOHIDManagerSetDeviceMatching(manager, {
    "vendorID":  0x046D,
    "productID": 0xB027,
})

def on_input_report(report_id, data):
    # 按键位就在 data[1]
    log(now(), report_id, hex(data))

IOHIDManagerRegisterInputReportCallback(manager, on_input_report)
IOHIDManagerScheduleWithRunLoop(manager, current_run_loop())
IOHIDManagerOpen(manager)          # 非独占打开:不抢设备,不影响正常使用
run_loop.run()

输出的就是我们看到的那串 02 02 00 00 00 00 00 00:02 是 report id,第二个字节是按键位。

② 系统事件层:App 到底收到了什么

tap = CGEventTapCreate(
    location  = kCGHIDEventTap,               # 用户态能观察到的最早一环
    placement = kCGHeadInsertEventTap,        # 插在队列最前
    options   = kCGEventTapOptionListenOnly,  # 只读:不拦截、不修改、不吞事件
    mask      = [LEFT_DOWN,  LEFT_UP,
                 RIGHT_DOWN, RIGHT_UP,
                 OTHER_DOWN, OTHER_UP],
)

def on_event(event_type, event):
    if event_type in (TAP_DISABLED_BY_TIMEOUT, TAP_DISABLED_BY_USER_INPUT):
        tap = rebuild()      # 回调慢会被系统静默禁用,必须自愈
        return
    log(now(), type_name(event_type), button_number(event))

kCGHIDEventTap 这个位置是整套判断成立的前提:所有第三方鼠标增强工具、自动化框架的事件拦截都在它之后,所以在这一层看不到的右键,不可能是哪个软件吃掉的。

两层怎么判读

HID 层事件层结论
有有全链路正常
有无系统在指标层丢弃(本次案例)→ 查全局按键状态、多设备、设置
无无设备/链路没发出 → 查硬件、配对、电量、压住的按键
无有事件是软件合成的 → 查虚拟鼠标、自动化工具、远控

两个实现上的坑,都是我现场踩过的:

  1. 别把滚轮事件放进 mask,也别在回调里做重 I/O。回调一慢,系统会以 kCGEventTapDisabledByTimeout 直接禁用你的 tap——我第一版就是这样,后半段记录全是空的,差点得出"右键从不产生事件"的错结论。
  2. 零报告不等于没发生。采样窗口里的空结果,只能说明"这段时间没有状态变化"。

排除清单

假设结论依据
触控板"辅助点按"被关排除TrackpadRightClick = 1
某个修饰键卡住(Control 卡住会让左键变右键)排除100ms 采样监视器全程无 Control 按下
鼠标增强软件吃事件排除退出该软件后仍复发,且它位于 tap 层之后,动不了 HID 层
罗技驱动/固件问题排除未装驱动;HID 报告证明固件输出正常
虚拟鼠标/远控软件排除无 IOHIDUserDevice
第三方内核扩展排除kmutil showloaded 无任何非 Apple kext
重启能修好证伪当天重启过,症状依旧

我走错的两条路

坑一:把"没有报告"当成"没有按下"

探测那只"假鼠标"接口,12 秒拿到 0 条报告,我据此判定"它静默、无害",把嫌疑转向显示器。

错得很彻底:被按住的键恰恰是静默的——按下那一瞬间发一条报告,之后手指或重物不动,它就不再发了。所以"零报告"同时兼容"什么都没发生"和"一直按着"这两种完全相反的状态,它根本不构成证据。

坑二:先怀疑软件

排查中途我停过鼠标增强工具、停过还在跑的自动化运行时,都做了对照实验。实验设计没错,但都在错误的地图上找。

根因:按键状态机只认"状态",不认"哪个设备"

操作系统维护的按键是全局状态:右键从"抬起"变成"按下"时才产生事件;如果它已经是"按下",再来一次"按下"不会产生任何新事件。

于是链条是这样断的:

  1. 有线鼠标的右键被东西压住 → 状态表里右键常年"按下"
  2. 拿罗技再按右键 → 系统认为"已经在按着了" → 丢弃
  3. 挪开压力或拔掉那根线 → 状态复位 → 立刻恢复

两个旁证同时对上:

  1. Control + 左键始终可用——它走修饰键通路,不查按键状态机;
  2. 触控板"两指点按"也没反应——但这是另一个独立原因:「轻点来点按」是关着的,本该用两指按下(纯设置误会,与本 bug 无关)。

两只鼠标共用一张全局按键状态表:一只压着不放,另一只就再也按不出右键

修复:30 秒

  1. 挪开压住右键的东西(书、线缆张力、支架压着,或者微动开关已经粘连);
  2. 不需要那只有线鼠标,就直接拔掉它。

完了。

六条可复用判据

  1. 某个键失效、另一个键正常 → 先怀疑状态机,别先怀疑硬件;
  2. 分层取证:HID 原始报告 + kCGHIDEventTap 两层对齐,才能定责到"硬件坏"还是"系统丢";
  3. 拔插法优先:改硬件拓扑(拔一根线)比翻三层设置快一个数量级,30 秒就能证伪;
  4. 把"现场"数清:显示器背后的 USB 口也算现场,不是只有直插主机的东西才算;
  5. 静默 ≠ 无事发生:采样几秒的"零报告"不能证明设备没在动作;
  6. 别在错误的地图上优化:先做成本最低、一次就能证伪的假设。

事后账

十分钟、两块钱 token,最后发现是一根线压着一只鼠标的右键。

这类问题的成本从来不在修,在定位;而定位最贵的一步,是承认"看起来最不可能参与的那台设备"也是嫌疑人。