DeskMate

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显示名。
bundleIdBundle 标识符,或 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请求字段回复字段
accessprompt?: bool,是否弹出系统的权限提示trusted: bool,是否已授予辅助功能权限
apps–apps: [{bundleId, name, pid?}],正在运行的常规(Dock 可见)应用,不含 --deny-pid
resolvename,人说出来的名字app: {bundleId, name, pid?};没有这个已安装的应用就报错
browser–app,默认的网页浏览器

读取

op请求字段回复字段
snapshotpidtext,前台窗口的大纲;site、title(或 null)
findpid、texttext,带 ref 的匹配控件;title、site
sitepidtitle、site,窗口是网页时的网站主机
pagebundleIdtitle、url,Safari 或基于 Chromium 的浏览器的前台标签页
inspectpid、refrole、label(按下去会点到什么),以及 own(这个元素自己的标签)
lengthpid、reflength,文本控件里的字符数

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请求字段效果
presspid、ref、label激活控件(辅助功能的 press)。label 是核心判断这个控件叫什么(inspect 返回的):如果按下时名字不同,这次 press 会失败,而不是点到别的东西。没有 press 动作的行会改为被选中。
setTextpid、ref、text替换文本控件的整个值。回复 value。
insertTextpid、ref、text、at("end" 为默认,或 "cursor")插入文字,不扰动已有的文字。回复 value。
keypid、keys只向那个进程发一个按键:cmd+n、tab、return、down。修饰键是 cmd、shift、alt/option、ctrl。
menupid、path: [string]按标题路径选一个菜单栏项。
openbundleId、front?: bool启动或找到一个应用;只有 front 为 true 时才把它带到前台。回复 app。
openUrlbundleId、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 下找。

最后更新于