macOS 的输入事件并不是直接送达前台应用,而是先经过 WindowServer 统一调度。想要在系统层面观察或拦截鼠标键盘事件,CGEventTap 是目前最直接的 API。它可以在事件流中插入一个监听点,当事件经过时触发回调,开发者可以决定是放行、修改还是吞掉事件。但这套机制和辅助功能权限绑定很紧,如果没有正确配置 TCC 授权,tap 创建后也收不到任何输入。

实际开发中很多工具需要全局热键、输入统计或者键鼠录制功能,都会用到 CGEventTap。下面我会从事件流模型、创建参数、回调运行循环、权限申请到常见坑位,把这条链路完整梳理一遍。注意文中的代码以 Objective-C 和 Swift 为主,因为它们能直接调用 CoreGraphics 和 ApplicationServices 框架。
CGEventTap 的事件流与权限模型
CGEventTap 本质上是在系统的 Quartz Event Services 事件队列里安插一个过滤器。事件从硬件驱动产生后,会先进入 WindowServer,再分发到各个会话。CGEventTap 可以挂在会话层级,也可以针对 HID 层。对于全局鼠标键盘监听,一般使用 kCGSessionEventTap,这个位置能捕捉到当前用户会话中的绝大多数输入事件,包括键盘按键、鼠标移动、点击、滚轮等。
事件流有先后顺序,kCGHeadInsertEventTap 表示把监听器插到头部,事件先经过你的回调再继续传给系统和其他应用;kCGTailAppendEventTap 则放在尾部。如果你想拦截或修改事件,必须用头部插入。如果只是想观察记录,尾部插入对系统影响更小。而 options 参数里的 kCGEventTapOptionListenOnly 可以声明只监听不拦截,即使没有辅助功能权限,某些事件也可能被观察到,但键盘事件通常仍然需要权限。
权限方面,macOS 的 TCC 体系对辅助功能和输入监控有严格限制。CGEventTap 监听键盘事件时,如果应用未被授予辅助功能权限,回调不会收到键盘和鼠标事件,或者直接无法创建 tap。部分系统版本还引入了输入监控权限,监听键盘事件时可能会同时要求该权限。实际开发中需要先请求辅助功能,再创建 tap,否则容易出现创建成功但收不到任何事件的假象。
创建事件监听:CGEventTapCreate 参数解析
CGEventTapCreate 的函数签名包含六个参数:tap 位置、插入位置、选项、事件掩码、回调函数和用户数据。事件掩码用 CGEventMaskBit 宏组合,例如 CGEventMaskBit(kCGEventKeyDown) 表示只关心按键按下事件。如果要同时监听鼠标移动和点击,需要把对应的事件类型都加入掩码。
下面是一个完整的 Objective-C 示例,它创建了一个同时监听键盘按下、释放和鼠标左键点击的 tap,并把回调挂到当前运行循环。
#include <ApplicationServices/ApplicationServices.h>
#include <CoreGraphics/CoreGraphics.h>
static CGEventRef eventTapCallback(CGEventTapProxy proxy,
CGEventType type,
CGEventRef event,
void *userInfo) {
// 只打印事件类型,实际项目里不要在这里做耗时操作
switch (type) {
case kCGEventKeyDown:
CGKeyCode keycode = (CGKeyCode)CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode);
printf("Key down: %d\n", keycode);
break;
case kCGEventMouseButtonDown:
CGPoint location = CGEventGetLocation(event);
printf("Mouse down at x=%.2f y=%.2f\n", location.x, location.y);
break;
default:
break;
}
return event;
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
CGEventMask eventMask =
CGEventMaskBit(kCGEventKeyDown) |
CGEventMaskBit(kCGEventKeyUp) |
CGEventMaskBit(kCGEventMouseButtonDown);
CFMachPortRef eventTap = CGEventTapCreate(
kCGSessionEventTap,
kCGHeadInsertEventTap,
kCGEventTapOptionDefault,
eventMask,
eventTapCallback,
NULL);
if (!eventTap) {
printf("创建 Event Tap 失败,请检查辅助功能权限\n");
return -1;
}
CFRunLoopSourceRef runLoopSource =
CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0);
CFRunLoopAddSource(CFRunLoopGetCurrent(),
runLoopSource,
kCFRunLoopCommonModes);
CGEventTapEnable(eventTap, true);
CFRunLoopRun();
}
return 0;
}
这段代码把 tap 插到了会话事件流的头部,默认选项会参与事件过滤。回调函数 return event 表示事件继续传递;如果 return NULL,事件会被丢掉,系统和其他应用都不会再收到这条事件。因此修改事件时要注意不要误吞快捷键,以免影响系统功能。CGEventTapCreate 返回 CFMachPortRef,如果为 NULL,多半是权限不足或者事件掩码有误。
需要注意的是,CFMachPortRef 和 CFRunLoopSourceRef 都需要手动管理内存。上面示例为了简洁省略了 release,但在真实项目中,退出前要调用 CFRelease 释放 tap 和 source。另外 eventTap 创建后默认是禁用的,需要调用 CGEventTapEnable 启用。
回调函数与运行循环
CGEventTap 的回调函数运行在运行循环所在的线程上。如果你把 tap 加到了主线程的 RunLoop,那么回调会在主线程执行,任何耗时操作都会卡住界面。更推荐的做法是创建一个专门的线程,在该线程里创建 tap 并运行 RunLoop,让事件回调独立处理。
回调函数里可以读取和修改事件属性。键盘事件可以通过 CGEventGetIntegerValueField 获取键码,再配合 CGEventKeyboardGetUnicodeString 得到字符。鼠标事件用 CGEventGetLocation 获取屏幕坐标,注意这个坐标原点在屏幕左上角,单位是点(point),与 AppKit 的坐标系一致,但和 Core Graphics 某些上下文的原点不同。如果需要更高精度,可以使用 CGEventGetDoubleValueField 读取增量值。
回调中的 event 引用在返回后可能被释放,如果需要异步处理或保存数据,必须调用 CFRetain 或 CGEventCreateCopy 复制事件。同样,不要在回调中直接修改事件以外的全局状态而忽略线程安全,因为回调频率可能非常高,鼠标移动事件每秒可触发上百次。
辅助功能权限申请与状态检测
辅助功能权限的检查依赖 ApplicationServices 框架中的 AXIsProcessTrustedWithOptions。这个函数可以传入一个字典,提示系统立即弹出授权对话框。Swift 中的写法如下:
import ApplicationServices
func requestAccessibilityPermission() -> Bool {
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options = [promptKey: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
func isAccessibilityEnabled() -> Bool {
return AXIsProcessTrusted()
}
当用户第一次点击请求时,系统会弹出辅助功能授权对话框,引导用户进入系统设置中的隐私与安全性面板,把当前应用勾选上。如果用户拒绝或者没有勾选,AXIsProcessTrusted 会返回 false,此时创建 tap 可能得到非空对象,但就是收不到键盘事件,这是很多人第一次踩坑的地方。
部分新系统还会单独要求输入监控权限,应用在尝试监听键盘时可能弹出第二个授权。建议在启动时检测两个权限,并提示用户授权后重新启动应用或重新创建事件监听。授权状态改变后,已经创建好的 tap 不一定会自动恢复,通常需要销毁旧的 tap 再重新创建。
常见问题与调试建议
第一个高频问题是重复创建 tap。每次调用 CGEventTapCreate 都会产生一个新的插槽,如果旧 tap 没有禁用和释放,事件会在多个回调之间重复触发,也可能导致系统事件处理变慢。建议在创建新 tap 前,先调用 CGEventTapEnable 禁用旧 tap 并 CFRelease 释放。
第二个问题是误吞事件。因为头部插入的 tap 返回 NULL 会阻止事件继续传递,如果过滤逻辑写错,比如把所有按键都吞掉,用户可能发现键盘失灵,甚至无法用快捷键强制退出应用。调试时可以先返回 event 而不是 NULL,确认过滤条件正确后再添加拦截逻辑。
第三个问题是 RunLoop 模式。把 source 加到 kCFRunLoopDefaultMode 时,在菜单跟踪或拖拽场景下 RunLoop 切换到 event tracking 模式,回调就不会执行。推荐使用 kCFRunLoopCommonModes 来覆盖大多数模式。
最后,如果 tap 创建失败但权限已经授予,可以检查应用是否以正确的 bundle ID 打包运行。开发阶段直接运行 Xcode 产物时,系统记住的授权是 Xcode 的调试进程,有时需要手动在隐私面板中重新勾选。此外,CGEventTap 是进程级监听,沙盒应用通常无法使用,适合非沙盒的桌面工具。
CGEventTap辅助功能权限全局事件监听修改时间:2026-09-18 13:00:28