文章解释 macOS Accessibility API 如何支持跨应用文本辅助,以及不同应用间兼容性差异的背景。给出 Swift 权限检查代码,并说明辅助功能权限必须由用户在系统设置中授予。
利益披露:我开发了 DraftKey,这是一款在 Mac 本地运行的写作助手,基于下文介绍的 API 构建。文末会提到它一次。本文讨论的是平台,而非产品。
Grammarly、自动补全工具、文本扩展工具、窗口管理器和屏幕阅读器,在 macOS 上都面临同一个问题:它们需要在并非自己开发的 App 中工作。几乎所有这类工具都通过 Accessibility API 解决这个问题,也就是 VoiceOver 使用的那套接口。如果你曾好奇,为什么某个工具在 Mail 中能用,在某些编辑器里却不行,或者为什么它会申请一个听起来让人不太放心的权限,下面就来讲清楚这些环节是如何串起来的。
只有用户在 System Settings → Privacy & Security → Accessibility 中添加你的 App 后,它才能读取其他 App 的 UI。检查权限并请求授权,只需调用一次:
import ApplicationServices
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
let trusted = AXIsProcessTrustedWithOptions(opts) // shows the system prompt if needed
AXIsProcessTrustedWithOptions 返回一个结果,表示你的进程是否为受信任的 Accessibility 客户端。启用 prompt 选项后,macOS 会显示系统自己的对话框,引导用户前往 System Settings。你无法通过代码授予这项权限,这是刻意设计的。
这项权限的范围很广。受信任的客户端可以读取几乎所有 App 窗口的内容和结构。因此,在向某个工具授予权限前,值得先了解它是谁开发的。
Accessibility API 中的所有对象都是 AXUIElement。它是指向另一个进程中 UI 对象的不透明引用,带有可读取的具名属性。先从系统级元素入手,查询当前哪个元素获得了焦点:
func focusedElement() -> AXUIElement? {
let system = AXUIElementCreateSystemWide()
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString,
&value) == .success else { return nil }
return (value as! AXUIElement)
}
拿到元素后,写作工具需要关注以下属性:
有了文本内容和插入光标的位置,你就拿到了模型需要的上下文:光标前的文本,以及可能需要的少量光标后文本。
要在光标旁绘制灰色补全文本或弹出窗口,你需要屏幕坐标。这正是参数化属性的用途。kAXBoundsForRangeParameterizedAttribute 接收一个范围,返回这段文本在屏幕上显示时的边界矩形:
func caretRect(_ el: AXUIElement, location: Int) -> CGRect? {
var range = CFRange(location: location, length: 0)
guard let axRange = AXValueCreate(.cfRange, &range) else { return nil }
var out: CFTypeRef?
guard AXUIElementCopyParameterizedAttributeValue(
el, kAXBoundsForRangeParameterizedAttribute as CFString, axRange, &out) == .success
else { return nil }
var rect = CGRect.zero
AXValueGetValue(out as! AXValue, .cgRect, &rect)
return rect
}
有些 App 会为长度为零的范围返回空矩形。一种常见的兜底办法是,查询插入光标前一个字符的边界,再取它的右边缘作为光标位置。
这个矩形使用以左上角为原点的屏幕坐标,而 AppKit 窗口使用以左下角为原点的坐标。在定位叠加层之前,需要根据主屏幕的高度翻转 y 值。
你肯定不想每秒读取整个文本框 60 次。AXObserver 可以让你订阅另一个进程发出的通知:
var observer: AXObserver?
AXObserverCreate(pid, { _, element, notification, _ in
// debounce, then re-read value + selection and request a suggestion
}, &observer)
let app = AXUIElementCreateApplication(pid)
AXObserverAddNotification(observer!, app, kAXFocusedUIElementChangedNotification as CFString, nil)
AXObserverAddNotification(observer!, app, kAXValueChangedNotification as CFString, nil)
AXObserverAddNotification(observer!, app, kAXSelectedTextChangedNotification as CFString, nil)
CFRunLoopAddSource(CFRunLoopGetCurrent(),
AXObserverGetRunLoopSource(observer!), .defaultMode)
每个 App 进程都需要单独的 observer,因此要通过 NSWorkspace 监听 App 激活事件,并在用户切换 App 时调整 observer 的监听对象。文本值变化通知告诉你用户输入了内容;选中文本变化通知告诉你插入光标移动了,这通常意味着应该丢弃已经过时的建议。
用户接受建议后,通常有两种插入方式:
许多工具会先尝试第一种方式,失败后再回退到第二种。
这才是用户真正能感受到的部分。
不同 App 暴露的信息量不同。 原生 AppKit 文本视图实现了上面提到的全部能力。自行绘制界面的编辑器,比如某些代码编辑器、基于 canvas 的 Web App 和游戏,可能只暴露很少的信息,甚至完全不暴露。你的工具只能看到 App 愿意报告的内容。
Electron 和 Chromium 按需构建 Accessibility 树。 根据 Electron 的 Accessibility 文档,当 VoiceOver 等辅助技术运行时,Electron App 会自动启用 Accessibility 支持。第三方工具可以在 App 元素上将 AXManualAccessibility 属性设为 true,主动请求启用。在此之前,你要找的文本可能根本不在树中。
安全输入框不允许访问,也理应如此。 密码框的 subrole 是 kAXSecureTextFieldSubrole。Apple 将其描述为用于敏感数据、以圆点显示输入内容的文本框。跳过这些字段。另外,当 App 启用 Secure Event Input 时,键盘事件 tap 就不再能接收到按键,Apple 的 TN2150 对此有说明。NSSecureTextField 会自动启用 Secure Event Input,而 Terminal 和 iTerm 允许你让它持续开启。你可以通过 IsSecureEventInputEnabled() 检查这一状态,并向用户说明建议功能为何暂停。
基本上无法通过 Mac App Store 分发。 Apple 的 App Sandbox 文档将“辅助 App 使用 Accessibility API”列为沙盒禁止的行为之一,而 Mac App Store 中的 App 必须启用沙盒。因此,大多数系统级写作工具都是使用 Developer ID 签名后供用户下载,而不是通过 App Store 分发。(仅需 Input Monitoring 权限的事件 tap 是适用范围较窄的例外。)
Accessibility Inspector(Xcode → Open Developer Tool)可以准确显示某个 App 暴露了哪些属性。在怀疑自己的代码之前,先用它检查一下。
开发期间,macOS 会将权限与你的代码签名绑定,因此重新构建后的二进制文件如果签名发生变化,可能会悄悄失去信任。运行 tccutil reset Accessibility <bundle-id>,可以清除已有授权状态,从头开始。
每一次 AX 调用,都是向目标 App 发送一条跨进程消息。不要在主线程上执行这些调用,并通过 AXUIElementSetMessagingTimeout 设置超时,避免某个 App 卡死时把你的 App 也拖住。
以上这些都是底层基础工作。模型看到的只是一个上下文字符串,返回的也只是一个字符串。正是这种分离,让本地运行的建议功能变得切实可行。Accessibility 层在本地收集文本,如果推理也在本地运行,文本就完全不必离开这台 Mac。
这就是我为 DraftKey 采用的设计。它通过 Accessibility 在兼容的文本框中工作,跳过安全输入框,支持针对各个 App 单独控制,并在 Mac 本地运行写作模型。它同样受到上述限制,因此无法在不暴露文本的编辑器中工作。
如果你也在开发这类工具,不妨从 Accessibility Inspector 和一个 AXObserver 开始。观察哪些通知实际触发,就能学到大部分需要掌握的东西。
如果要采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。