Helper 协议
核心与两个 macOS 原生 helper(frontmost 和 computer)之间按行传输的 JSON 协议,给想把 DeskMate 移植到其他平台的人。
DeskMate 和平台相关的工作都在 native/ 里的两个小 Swift 程序中。其余一切(判断什么能做、授权、确认、模型看到什么)都是平台无关的 TypeScript。移植到另一个平台,就是写两个遵守下面协议的程序,再替换主进程里 macOS 专用的权限处理。
两个 helper 的形状相同:
- 它们是子进程。stdin 和 stdout 上每行一个 JSON 对象,UTF-8。stdout 上不能写任何别的东西。
- stdin 的 EOF 表示退出。 helper 不能比应用活得更久。
- 错误以数据形式返回,而不是崩溃。一个卡死的目标应用不能让 helper 跟着卡死:macOS 实现把辅助功能的消息超时设成 3 秒。
- 核心按需启动 helper,它退出后再重新启动。
参考实现是 native/frontmost/main.swift 和 native/computer/main.swift;TypeScript 客户端是 src/main/screen-watcher.ts 和 src/core/actions/computer-helper.ts。
frontmost
告诉主进程哪个应用和窗口在最前面,并按需给某一个窗口截图。它只在用户允许角色看屏幕时运行。
输出
前台应用或它的窗口标题变化时输出一行(启动时也输出一次),变化由激活通知或每秒一次的轮询发现:
{"kind":"front","pid":123,"app":"Safari","bundleId":"com.apple.Safari",
"windowId":456,"title":"…","fullscreen":false,"screenAccess":true} | 字段 | 含义 |
|---|---|
pid | 前台应用的进程 id。 |
app | 显示名。 |
bundleId | Bundle 标识符,或 null。 |
windowId | 它最前面的、屏幕上可见的普通窗口的 id,或 null。 |
title | 该窗口的标题;没有标题或没有屏幕访问权限时为 null。 |
fullscreen | 窗口盖满了它所在的整块屏幕,包括菜单栏。 |
screenAccess | 是否已授予屏幕录制权限。 |
输入
| 行 | 效果 |
|---|---|
request-access | 向系统请求屏幕捕获权限,然后输出一行 front。 |
look <id> <windowId> | 捕获那一个窗口,不管它被什么盖住。 |
look 的回复
{"kind":"look","id":"<id>","jpeg":"<base64>","title":"…","error":null} 捕获失败时 jpeg 为 null,error 有内容。捕获图会缩放到长边最多 1280 px,以 0.7 的质量编码成 JPEG。title 是捕获时的窗口标题。id 会原样带回,用来匹配回复;它们相对于 front 行可能乱序到达。
computer
为角色的任务读取和操作应用。它刻意做得笨:核心决定什么被允许,helper 只负责去做。
helper 遵守的设计规则,移植时也应该遵守:
- 它不移动指针。 控件通过平台的辅助功能接口激活和填写,按键只发给一个进程。用户可以继续在前台干自己的事。
- 不执行任意脚本。 唯一的脚本操作是固定的、只读的(读浏览器的页面标题和地址)。模型从不提供脚本文本。
- 它以
--deny-pid <pid>启动:这是 DeskMate 自己的进程,批准按钮就在它的窗口里。helper 拒绝读取或碰它。
帧格式
请求:
{"id":"1","op":"snapshot","pid":123} 回复按 id 匹配,带 ok,以及额外字段或一条 error 消息:
{"id":"1","ok":true,"text":"…"}
{"id":"1","ok":false,"error":"…"} 核心的调用在 20 秒后超时。未知的 op 回复 ok: false,"Unknown op <op>"。
操作
pid 是来自 apps、resolve 或 frontmost 的进程 id。ref 值(e12)由 snapshot 和 find 发出,保存在内存里,界面变化后可能失效;用了失效的 ref 是一个错误。
权限与发现
op | 请求字段 | 回复字段 |
|---|---|---|
access | prompt?: bool,是否弹出系统的权限提示 | trusted: bool,是否已授予辅助功能权限 |
apps | – | apps: [{bundleId, name, pid?}],正在运行的常规(Dock 可见)应用,不含 --deny-pid |
resolve | name,人说出来的名字 | app: {bundleId, name, pid?};没有这个已安装的应用就报错 |
browser | – | app,默认的网页浏览器 |
读取
op | 请求字段 | 回复字段 |
|---|---|---|
snapshot | pid | text,前台窗口的大纲;site、title(或 null) |
find | pid、text | text,带 ref 的匹配控件;title、site |
site | pid | title、site,窗口是网页时的网站主机 |
page | bundleId | title、url,Safari 或基于 Chromium 的浏览器的前台标签页 |
inspect | pid、ref | role、label(按下去会点到什么),以及 own(这个元素自己的标签) |
length | pid、ref | length,文本控件里的字符数 |
snapshot 的 text 是给模型读的。它是缩进的大纲,每行的方括号里有一个 ref,上限 7,000 个字符(被截断时末尾会有一行说明),长这样:
Window "Notes"
[e1] text field "Search" = ""
[e2] button "New Note"
[e3] text area "Body" = "Shopping list…" (focused)
Menus: Notes, File, Edit, View 每一行是一个角色词(button、text field、menu item 等),引号里的标签,然后是状态,如 = "value"、(focused)、(selected) 或 (disabled)。密码框里的文字从不包含,那一行写的是 = (hidden)。长的值会被截断,所以盲目替换会丢掉其余部分;核心知道在这种情况下先问。网页会多一行 Page: <url>。
执行
op | 请求字段 | 效果 |
|---|---|---|
press | pid、ref、label | 激活控件(辅助功能的 press)。label 是核心判断这个控件叫什么(inspect 返回的):如果按下时名字不同,这次 press 会失败,而不是点到别的东西。没有 press 动作的行会改为被选中。 |
setText | pid、ref、text | 替换文本控件的整个值。回复 value。 |
insertText | pid、ref、text、at("end" 为默认,或 "cursor") | 插入文字,不扰动已有的文字。回复 value。 |
key | pid、keys | 只向那个进程发一个按键:cmd+n、tab、return、down。修饰键是 cmd、shift、alt/option、ctrl。 |
menu | pid、path: [string] | 按标题路径选一个菜单栏项。 |
open | bundleId、front?: bool | 启动或找到一个应用;只有 front 为 true 时才把它带到前台。回复 app。 |
openUrl | bundleId、url | 在那个浏览器里于后台打开一个 http 或 https URL。其他协议被拒绝。 |
pid == --deny-pid 一律失败,回复 "That app cannot be used."
核心在此之上加了什么
给移植者:上面的 helper 不需要这些,但这也说明了它为什么能这么小。核心用工具与行动提供方里的闸门包住每一次调用:按应用授权、对按钮名做风险判断、按场所防泄露、确认卡片。helper 绝不能自己决定某个行动可以做。开发时可以用 DESKMATE_FAKE_COMPUTER=<可执行文件> 把真实 helper 换成一个替身,这样不需要任何辅助功能权限就能跑通整条链路;移植时,在 helper 写完之前也可以用它先把别的部分跑起来。
构建与打包
在 macOS 上,helper 由 forge.config.mts 用 swiftc 编译(开发时放进 native/build/,打包后放进应用的 resources),并和应用一起签名,这样升级后权限仍然有效。主进程在打包后到 process.resourcesPath 里找它们,否则到 native/build 下找。
最后更新于