症状:右键开始抽风
最近一周多,这台 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:01 | 02 02 00 00 …(按键位 = 0x02,右键)✅ | 零事件 ❌ |
| 13:13:04 – 13:13:13 | 02 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 层 | 事件层 | 结论 |
|---|---|---|
| 有 | 有 | 全链路正常 |
| 有 | 无 | 系统在指标层丢弃(本次案例)→ 查全局按键状态、多设备、设置 |
| 无 | 无 | 设备/链路没发出 → 查硬件、配对、电量、压住的按键 |
| 无 | 有 | 事件是软件合成的 → 查虚拟鼠标、自动化工具、远控 |
两个实现上的坑,都是我现场踩过的:
- 别把滚轮事件放进 mask,也别在回调里做重 I/O。回调一慢,系统会以
kCGEventTapDisabledByTimeout直接禁用你的 tap——我第一版就是这样,后半段记录全是空的,差点得出"右键从不产生事件"的错结论。 - 零报告不等于没发生。采样窗口里的空结果,只能说明"这段时间没有状态变化"。
排除清单
| 假设 | 结论 | 依据 |
|---|---|---|
| 触控板"辅助点按"被关 | 排除 | TrackpadRightClick = 1 |
| 某个修饰键卡住(Control 卡住会让左键变右键) | 排除 | 100ms 采样监视器全程无 Control 按下 |
| 鼠标增强软件吃事件 | 排除 | 退出该软件后仍复发,且它位于 tap 层之后,动不了 HID 层 |
| 罗技驱动/固件问题 | 排除 | 未装驱动;HID 报告证明固件输出正常 |
| 虚拟鼠标/远控软件 | 排除 | 无 IOHIDUserDevice |
| 第三方内核扩展 | 排除 | kmutil showloaded 无任何非 Apple kext |
| 重启能修好 | 证伪 | 当天重启过,症状依旧 |
我走错的两条路
坑一:把"没有报告"当成"没有按下"
探测那只"假鼠标"接口,12 秒拿到 0 条报告,我据此判定"它静默、无害",把嫌疑转向显示器。
错得很彻底:被按住的键恰恰是静默的——按下那一瞬间发一条报告,之后手指或重物不动,它就不再发了。所以"零报告"同时兼容"什么都没发生"和"一直按着"这两种完全相反的状态,它根本不构成证据。
坑二:先怀疑软件
排查中途我停过鼠标增强工具、停过还在跑的自动化运行时,都做了对照实验。实验设计没错,但都在错误的地图上找。
根因:按键状态机只认"状态",不认"哪个设备"
操作系统维护的按键是全局状态:右键从"抬起"变成"按下"时才产生事件;如果它已经是"按下",再来一次"按下"不会产生任何新事件。
于是链条是这样断的:
- 有线鼠标的右键被东西压住 → 状态表里右键常年"按下"
- 拿罗技再按右键 → 系统认为"已经在按着了" → 丢弃
- 挪开压力或拔掉那根线 → 状态复位 → 立刻恢复
两个旁证同时对上:
- Control + 左键始终可用——它走修饰键通路,不查按键状态机;
- 触控板"两指点按"也没反应——但这是另一个独立原因:「轻点来点按」是关着的,本该用两指按下(纯设置误会,与本 bug 无关)。

修复:30 秒
- 挪开压住右键的东西(书、线缆张力、支架压着,或者微动开关已经粘连);
- 不需要那只有线鼠标,就直接拔掉它。
完了。
六条可复用判据
- 某个键失效、另一个键正常 → 先怀疑状态机,别先怀疑硬件;
- 分层取证:HID 原始报告 +
kCGHIDEventTap两层对齐,才能定责到"硬件坏"还是"系统丢"; - 拔插法优先:改硬件拓扑(拔一根线)比翻三层设置快一个数量级,30 秒就能证伪;
- 把"现场"数清:显示器背后的 USB 口也算现场,不是只有直插主机的东西才算;
- 静默 ≠ 无事发生:采样几秒的"零报告"不能证明设备没在动作;
- 别在错误的地图上优化:先做成本最低、一次就能证伪的假设。
事后账
十分钟、两块钱 token,最后发现是一根线压着一只鼠标的右键。
这类问题的成本从来不在修,在定位;而定位最贵的一步,是承认"看起来最不可能参与的那台设备"也是嫌疑人。