DIY a Rewind to Capture Searchable Screenhots on macOS at Intervals

2023-01-14

最近,一个名叫 Rewind 的新工具备受关注,号称能通过不停录屏实现「时光倒流」,但也存在很多问题:高昂的价格(每月 20 美元)、要求较新硬件(只支持 M 系列处理器机型)、暂不支持中文,以及潜在的隐私担忧等。本文中,我们将尝试「山寨」一个 Rewind,并介绍相关的一些 macOS 高级自动化技巧,包括使用 screencapture 在后台创建截图,使用 launchd 让任务定期执行和开机自启,以及使用 taskpolicy 节约处理器资源等。

最近,一个名叫 Rewind 的新工具成为了 macOS 平台受关注的对象。 简单来说,Rewind 会持续不断地录制你的 Mac 屏幕,对录像中的每一帧进行文字识别并建立索引,从而为用户的操作历史建立了一个可搜索的「时间机器」。(我们将单独发布 Rewind 的评测文章。)

尽管颇具潜力,Rewind 也并不适合所有人,主要制约因素包括高昂的价格(每月 20 美元)、要求较新硬件(只支持 M 系列处理器机型)、暂不支持中文,以及潜在的隐私担忧等。

因此,在试用 Rewind 期间,我也萌生出一种想法:能不能自己「山寨」一个 Rewind 呢?Rewind 录制的是视频,这在我看来稍微有点没必要,也不方便处理;不如将其简化为「如何以较高频率定期截图」,仍然可以满足备忘的需求,所得格式也更通用。

但这听起来还是比较复杂。我们不妨将其分解为以下几个具体问题,然后逐个解决:

  • 如何在后台捕获屏幕内容;
  • 如何对屏幕图像进行文字识别和压缩尺寸;
  • 如何实现循环运行和开机自启;以及
  • 如何在上述过程中节约处理器资源。

下面,我首先提供自己制作好的解决方案,然后具体解释制作流程和相关原理。即使你对于定期截图留档没有什么需求,了解本文涉及的技巧对于 macOS 的高级自动化也是有益的。

快速使用

  1. 下载 shell 脚本 rewind,然后:
    1. 将其放在任意固定目录。依照 Unix 惯例,这种自制脚本一般可以放在 ~/bin 目录;
    2. 在终端执行 chmod +x ~/bin/rewind 命令,为其增加执行权限;
  2. 下载 xyz.cyhsu.script.rewind.plist,然后:
    1. 将其放在 ~/Library/LaunchAgents/ 目录下。你可以在 Finder 中按 Command-Shift-G 后粘贴上述路径,直接到达这个隐藏目录;
    2. (重要)用 TextEdit 或其他文本编辑器打开这个 plist 文件,将第 9 行的内容改为你存放上述 rewind 脚本的实际路径。注意如果路径涉及 ~,需要扩写为完整的 /Users/[USERNAME],其中 [USERNAME] 为你的用户名;
  3. 使用 Homebrew 安装文字识别所依赖的 OCRmyPDF:brew install ocrmypdf,并从 GitHub 下载简体中文 OCR 识别所需数据集,然后根据你的 Mac 处理器类型将其放在下列目录中,其中 [VERSION] 指实际安装的版本号数字:
    • M 系列处理器:/opt/homebrew/Cellar/tesseract/[VERSION]/share/tessdata/
    • Intel 处理器:/usr/local/Cellar/tesseract/[VERSION]/share/tessdata/
  4. 点击苹果菜单 >「系统设置」,在「隐私和安全性」中选择「屏幕录制」,然后点按加号形的添加按钮,确保将下列每个项目添加到列表中(文件选择窗口仍然可以按 Command-Shift-G 快捷键后粘贴路径跳转):
    • /bin/bash
    • /System/Library/CoreServices/launchservicesd
    • /usr/sbin/screencapture
  5. 在终端执行 launchctl load ~/Library/LaunchAgents/xyz.cyhsu.script.rewind.plist

这样,你就可以在 ~/Pictures/Rewind 目录下看到带有时间戳的 PDF 格式截图了,频率为每半分钟一次。如果连接了外接显示器,每个屏幕会分别截图。

取决于机器性能和截图尺寸,每张截图会在创建后一两分钟左右完成压缩和文字识别,此后就可以通过 Spotlight 或者你习惯的工具搜索到其中的文字内容。

这个自动化流程会保持后台运行和开机自启。如果需要停止,可以在终端执行 launchctl unload ~/Library/LaunchAgents/xyz.cyhsu.script.rewind.plist 来解除加载。如果不再需要这个功能,直接删除上面步骤中下载的两个文件即可。

原理和讨论

方便阅览起见,这里附上 rewind 脚本内容:

#!/bin/bash

outpath="$HOME/Pictures/Rewind"
mkdir -p "$outpath"
nDisplay=$(system_profiler SPDisplaysDataType | grep -c Resolution)
ts=$(date +%Y%m%d%H%M%S)

# Detect whether ocrmypdf is installed
if ! command -v ocrmypdf &>/dev/null; then
    echo "ocrmypdf could not be found"
    exit
else
    omp=$(command -v ocrmypdf)
fi

# Capture screenshots
echo "Capturing at $ts"
capture=$(
    for ((i = 1; i <= nDisplay; i++)); do
        echo "$outpath/capture.$ts.$i.pdf"
    done
)
echo "$capture" | xargs screencapture -x -t pdf 2>&1 && echo "Captured"

# OCR output files
for ((i = 1; i <= nDisplay; i++)); do
    taskpolicy -b\
    "$omp" "$outpath/capture.$ts.$i.pdf" "$outpath/capture.$ts.$i.pdf" \
        -l chi_sim+eng\
        --output-type pdf\
        --optimize 3
done

在后台创建截图:使用 screencapture

这是最简单的环节:macOS 自带了一个 screencapture 命令用来截图,它的基本用法是 screencapture FILE,其中 FILE 为输出截图文件名。

此外,可以用 -x 选项来禁用截图提示音,用 -t [jpg|png|pdf|tiff] 选项来指定输出格式。(更多选项用法可以参考手册页面 man screencapture,请一定不要错过里面苹果工程师对于文档写得太烂的吐槽——快二十年过去至今无人搭理)。

这里,我们选择用 PDF 格式保存截图,原因主要是是 PDF 可以通过文字叠加层的形式,直接在文件内部保存接下来识别出的文字内容,非常通用且容易检索;而 PNG 格式只能写在 Spotlight 注释等位置,第三方工具支持有限,且容易在跨系统传输中丢失。

PDF 可以通过文字叠加层的形式,直接在文件内部保存接下来识别出的文字内容

还需要考虑的一个细节是外接显示器的场景。screencapture 支持多显示器,但笨拙的语法要求在参数中写下每个显示器截图的文件名。为此:

  1. 我们先用 system_profiler SPDisplaysDataType 获取当前的显示信息,用 grep 数一数里面提到了几次「分辨率」(Resolution),就知道了总共有几个屏幕(第 5 行);
  2. 然后,用一个 for 循环构造出带有显示器序号和时间戳(用 date 实现,第 6 行)的多个文件名,用 xargs 拼在一起喂给 screencapture 即可(第 23 行)。

另一个比较烦人的地方在于,随着 macOS 对权限管理的收紧,屏幕录制也成为了重点打击的对象。如果不经设置,在后台通过脚本静默执行的截图操作,只能截到一个空空荡荡的桌面。

为此,我们需要在「系统设置」>「隐私和安全性」>「屏幕录制」中,将脚本中涉及截图操作的所有程序都加入白名单中。

这里,我们的 rewind 脚本:

  • 以 bash 作为运行环境;
  • 通过 macOS 的 Launch Services 实现自启和循环(详见下节);
  • 通过 screencapture 命令实现截图。

因此,需要将上述三项对应的可执行文件都添加到白名单,才能正常截图。这就是开头所述步骤中需要做第 4 步的原因。

识别文字和压缩截图尺寸:使用 OCRmyPDF

少数派过去曾有一篇文章介绍如何通过 OCRmyPDF 在扫描版 PDF 中检索文字。本文沿用那篇文章所介绍的用法,唯一多用到的选项是 --optimize 3;根据文档,这是指对图片进行比较激进的有损压缩,特别适合截图留档这种「能看清就行」的场景。

实际的空间占用情况如何呢?我的工作环境是一台 16 英寸 MacBook Pro 搭配一台 4K 分辨率的显示器,经过 OCRmyPDF 压缩,内外置显示器的截图 PDF 尺寸一般在 200KB 和 400KB 以内,加在一起一般不超过 600KB。而即使经过压缩,截图画质也是很不错的,除了一些色彩较多的画面会出现色阶,完全不影响查看。

以一天使用 10 小时、半分钟截图一次计算,这相当于每天 700MB 左右、每月 21GB 左右,与 Rewind 宣传的 14—39GB 相仿。考虑到当前 Mac 至少也是 256GB 起步,只要定期清理,我认为这个占用情况是可以接受的。

让任务定期执行和开机自启:使用 launchd

这是本文解决方案中的关键环节,也是我建议读者无论有无本文需求,都不妨做些了解的技巧。

说到定期执行任务,有一定 Linux 基础的读者可能会想到经典的 cron。macOS 确实保留了 cron,但它也有一个更原生的「升级版」,那就是 launchd。如苹果在 crontab 的手册页所说,macOS 上的 launchd 已经完全吸收了 cron 的功能,并且更加灵活。

那么 launchd 是何方神圣?作为一个 init 程序,launchd 对于 macOS 意义重大,是系统启动后载入的第一个进程,负责初始化系统、启动各项进程和服务,可以说是扮演了「旗手」和「总指挥」的角色。自然地,开机自启和计划任务也是 launchd 职能的一部分。

launchd 的行为通过称为「属性列表」(plist)的 XML 格式配置文件指定,这种配置文件安装到几个系统指定位置后,就成为 LaunchAgents 或 LaunchDaemons。而就本文目的而言,需要做的就是制作一个 LaunchAgent 文件,将其安装到 ~/Library/LaunchAgents 下,意即为当前登录用户启动和控制特定操作。

这个 LaunchAgent 就是我们在开头步骤中提供的 plist 文件。下面,我们简要解释一下它的结构和功能。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>Label</key>
        <string>xyz.cyhsu.script.rewind</string>
        <key>ProgramArguments</key>
        <array>
            <string>/Users/platyhsu/bin/rewind</string>
        </array>
        <key>EnvironmentVariables</key>
        <dict>
            <key>PATH</key>
            <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
        </dict>
        <key>StartInterval</key>
        <integer>30</integer>
        <key>RunAtLoad</key>
        <true/>
        <key>StandardOutPath</key>
        <string>/tmp/rewind.log</string>
    </dict>
</plist>

如果你想让别人替你按时完成某项任务,显然需要交代清楚这些问题:要做什么、什么时候做,以及怎么做。LaunchAgent 的内容大体也就是在回答这些问题。

具体来说,这个 XML 文件包含一个字典,其下的字键分门别类地说明所要执行任务(job)的各项属性。比较关键的属性包括:

Label 任务的标签,只起识别和区分目的,理论上可以随便填写,但惯例是采用反向 DNS 方式命名。如果你有自己的域名,不妨将像我在示例文件里那样,把它倒过来作为标签的开头。

ProgramArguments 最核心的配置项,指要执行的命令。注意这是一个数组型,数组中的每一个字符串对应一个参数,而要运行的命令本身是第一个参数。

例如,如果要运行 ls -a /etc 命令,注意到它被空格分成命令名称、选项和路径参数三个部分,因此应该拆成三个元素来写进 ProgramArguments

<key>ProgramArguments</key>
<array>
 <string>ls</string>
 <string>-a</string>
 <string>/etc</string>
</array>

这里,我们要执行的是一个现成的脚本文件 ~/bin/rewind,因此直接用一个字符串存放其路径即可。如开头所述,需要注意这里不支持变量,也不支持 ~ 之类的简写,只能填写绝对路径。(在 Finder 里按住 Option 键后右键单击一个目录,就能看到复制路径的选项。)

EnvironmentVariables 指运行程序的环境变量。launchd 加载程序的环境与我们通过终端工具访问的命令行环境是不同的。就本文目的而言,这种不同最主要的影响是其默认搜索路径(即 PATH 变量的值)不包括 Homebrew 安装软件的目录,因此无法直接运行 OCRmyPDF 等工具。这就需要通过 EnvironmentVariables 键来调整。

EnvironmentVariables 也是一个词典型的键,其下每个键值对用来赋值一个变量。这里,我们将 Homebrew 安装路径 /opt/homebrew/bin(M 系列处理器版本)和 /usr/local/bin(Intel 处理器版本)添加到 PATH 变量的开头(第 11—15 行)。

StartInterval 指运行任务的时间间隔,以秒为单位。我在这里填写了 30 秒,对于回忆和打捞操作记录,应该是一个足够细的粒度了。当然,你可以根据自己的偏好随意调整。

RunAtLoad 一个布尔值,如果为真,则任务会在这个 LaunchAgent 被加载时立刻开始运行。否则,任务会等到运行条件满足(对于本例就是加载后经过一个 StartInterval 的时间)才开始运行。

StandardOutPath 指运行任务所得的命令行输出(STDOUT)保存到何处,非常适合用来记录日志。这里,我指定 /tmp/rewind.log 作为日志路径,并在 rewind 脚本中通过 2>&1(第 23 行),将可能遇到的错误信息也重定向到标准输出。这个路径也没有什么讲究,但 /tmp 的好处是会随着重启自动清空,省得手动清理日志。

如果不需要日志,也可以把这个键删掉。

做好 LaunchAgent 文件后,将它放到 ~/Library/LaunchAgent 目录就完成了安装。如果你使用 macOS Ventura 或更新的系统,还会看到弹出的通知,告诉你新增了一个后台项目(尽管措辞非常令人困惑)。

最后,如开头所述,我们需要通过 launchd 的控制程序 launchctl 来载入这个 LaunchAgent,也就是在终端执行 launchctl load ~/Library/LaunchAgents/xyz.cyhsu.script.rewind.plist

对于本文目的,了解到上述程度就够了。如果想了解更多 launchd 的用法,可以参考这些资源:

节约处理器资源:使用 taskpolicy

Rewind 重点宣传的一项特性就是针对 Apple silicon 优化、占用系统资源少。毕竟,没有人会愿意为了一个备份性质的功能影响日常工作。如何在我们的山寨版里模拟这一点呢?

你可能知道,macOS 和其他 Unix 阵营系统一样,支持通过 nice 命令来设置进程优先级。nice 值是一个 -20 到 19 之间的整数,0 是默认值,数值越大,优先级越低(因为越是 nice 的进程,当然是越懂「文明礼让」的)。查阅 launchd 手册,其中也确实有个叫做 Nice 的选项,其作用正是设置优先级。

然而,在被苹果高度改造的当代 macOS 上,nice 的实际意义已经很小了。(参见 Edward Hoakley 的解释。)特别是在采用了大小核架构的 Apple silicon 上,无法通过设置较高的 nice 值来将进程绑定在节能的小核上运行。

因此,我们需要一个更适配当代 macOS 的工具——taskpolicy。查询手册(man taskpolicy)可知,在一个命令之前加上 taskpolicy -b,就可以让进程运行在名为 PRIO_DARWIN_BG 的「后台」优先级上。对于 Apple silicon 而言,这是少数能确保进程在小核上运行的方法之一。(仍推荐阅读 Edward Hoakley 的研究。)这就是 rewind 脚本第 27 行的来源。

那么,强制使用小核对于脚本执行效率的影响有多大?在我配置为 M1 Max 的 MacBook Pro 上,如果不做设置,OCRmyPDF 处理内、外置两个屏幕的截图大约合计需要 20 秒;强制使用小核后,这个过程延长到大约 100 秒。考虑到我们很少需要立即「回忆」刚刚截下的屏幕画面,延迟两分钟是可以接受的。

延伸应用

通过命令行更快找到截图

开头的步骤演示了用系统自带搜索查找截图内容的效果。但如果你习惯使用命令行,我推荐通过 ripgrep-all 结合 fzf 来快速(冒烟一般地快)检索 PDF 文件,具体方法参见其文档

批量删除一定时间之前的截图

尽管我们的脚本已经通过压缩截图控制了体积,并在截图文件名中加入了时间戳方便检索,但定期清理用不到的截图也是有必要的。为此,我们可以用 find 命令的 -newerXY 选项来查找特定时间段之间或前后创建的文件,然后用 -delete 选项批量删除。

参考以下例子,语法可谓都是大白话,一看便知含义:

# 列出并删除一个月之前创建的截图
find ~/Pictures/Rewind -not -newerct "1 month ago" -delete
# 列出最近 10 小时创建的截图
find ~/Pictures/Rewind -newerct "10 hours ago"

你可以根据偏好,预先写好一些常用的清理命令,然后用快捷指令、Alfred 等工具将其包装为一键执行的捷径。