给一个没有显示器的桌面应用截图

有一个约束,听起来像会让截图这件事变得不可能:产生截图的那台机器没有显示器、没人登录、应用也根本没在运行。然而我们文档里的每一张产品图——以及你正在读的这本工程博客里的每一张配图——都是真实界面的真实渲染。不是设计工具里画的 mockup,不是谁在自己笔记本上截了图顺手提交上来的。是真正的 React UI,由真正的浏览器按需渲染出来。

诀窍在于:「应用没在运行」只是说后端没在跑。而前端——截图所对准的那部分——不过是一个网页。网页,是可以由一台没有屏幕的浏览器渲染出来的。

截图工具的流水线:场景文件、真实 React UI、无头 Chrome、PNG/MP4

四步:场景文件描述要做什么,真实 UI 对着 mock 数据渲染,无头 Chrome 用 CDP 驱动,产出 PNG 或 MP4。

三样原料

这套工具放在 scripts/screenshots/,只需要三样东西。

一个场景文件。 一次截图就是一小段 JSON 脚本:导航到哪、等一下、点这个、滚一下、截图。步骤刻意做得很「笨」——按可见文本 tap、按偏移 scrolleval 一小段、shot 到某个路径。一个设置弹窗的场景只有五步。笨步骤是优点:它让一次截图可复现,让一次失败可读。

一个 mock 后端。 桌面 UI 本来要和 Tauri 后端通信;工具把它换成一个页面内的 mock,返回预设数据——会话、线程、设置、沙箱可用性。正是它让 UI 能在没有 agent、没有数据库、没有网络的情况下渲染出真实的、有内容的界面。也正是它让我们能注入那些现场很难复现的状态:一个待审批的请求、一个不可用的沙箱、一个配对码。mock 是整个工具里唯一需要懂产品的地方。

无头 Chrome,用 CDP 驱动。 工具要么接一个你已经在调试端口上监听的 Chrome,要么——这是常态——自己起一个带一次性 profile 的无头 Chrome。拥有自己的浏览器,是整套东西能在 CI runner 或一台无头 Mac mini 上跑起来的关键。然后它通过 Chrome DevTools Protocol 驱动页面:真实的输入事件来点击、真实的滚动,以及用 Page.captureScreenshot 取图。

这就是全部设计。serve-desktop 起一个指向 harness 配置的 Vite dev server(外加一个充当终端的服务);capture-desktop 遍历场景、执行步骤、写出 PNG。视频是同一件事,只是把帧编码成 MP4。

为什么用真实 UI 而不是 mockup

维护一个 Figma 文件会省事得多。我们刻意不这么做,原因只有一个:UI 一变,mockup 就开始撒谎,而且是悄无声息地撒谎。一张从真实界面渲染出来的截图不可能和界面脱节,因为它就是界面。当有人改了个设置名、挪了个按钮,场景要么仍然能跑——于是产出一张更新后的图——要么因为一个 tap 找不到目标而响亮地失败。两种结果都有用。而一张过时的 mockup 会产生第三种、更糟的结果:一张自信的、错误的图。

这也是为什么这套工具能兼任一个轻量 UI 回归工具。那些步骤隐式地断言了它们所触碰的元素存在且可达。它替代不了真正的测试,但作为「设置弹窗打不开了」这种问题的绊线,它已经不止一次证明了自己的价值。

真正难的部分

设计很简单,细节不然。

活在浏览器里的状态。 UI 在模块加载时就从 localStorage 读语言,早于任何场景步骤。所以「用英文渲染」不是一个步骤——它是一个两阶段的舞步:先设好键、刷新、然后才能交互。顺序搞错,你会截出一张完美的、语言却错了的图。

会点空的点击。 按可见文本驱动对布局变化很稳健,但对重复元素是盲的,而且移动端仿真会让坐标偏移。我们学会了用 CDP 去测量结果来验证一次截图——弹窗真的打开了吗、开关真的在我们以为的位置吗——而不是轻信一个步骤跑完没报错。一个「够不到目标」的步骤是信号;一个悄悄点错元素的步骤是地雷。

叫不醒的浏览器。 在 macOS 上,一个被杀掉的无头 Chrome 有时不肯在同一个调试端口上重启。解法很无聊但值得记下:换个新端口、换个新 profile 目录,别跟僵尸进程较劲。

保持 demo 数据干净。 为了给一个 demo 数据是中文的应用截一张英文图,我们临时把 mock 翻译成英文、截完、再还原。mock 是共享的 fixture;一次截图如果把它改了就跑,会污染之后每一次运行。「把你碰过的东西还原」是工具无法强制的规则,只能靠习惯。

拿它来干什么

显而易见的用途是不过时的文档图和营销图。不那么显而易见的、也是这篇文章所依赖的用途是:带插图的工程写作。当我们写「如何把一部手机通过加密通道配对到桌面」时,那张配对二维码的图不是素材网站找来的——它是真实的桌面应用,由这套工具渲染,mock 被说服返回了一个合法的配对邀请。那张图是证据,不是装饰。

说到底,这才是重点。技术文章里的截图应该展示事物真实的样子。如果产出它们需要一台显示器、一个人、一双稳当的手,它们就会被很少地产出、悄悄地过时。如果产出它们只是一条脚本,它们就会随着 UI 的每次变化被重新生成——而图,就能和代码一样诚实。


这套工具在 [scripts/screenshots/](https://github.com/futuregene/future-os/tree/main/scripts/screenshots)——capture.py 驱动浏览器和服务,cdp.mjs 说 DevTools Protocol,scenarios.json 存放截图脚本。截图产物被 gitignore:它们是构建产物,每次重新生成,从不提交。