最近,一个名叫 Rewind 的新工具成为了 macOS 平台受关注的对象。 简单来说,Rewind 会持续不断地录制你的 Mac 屏幕,对录像中的每一帧进行文字识别并建立索引,从而为用户的操作历史建立了一个可搜索的「时间机器」。(我们将单独发布 Rewind 的评测文章。)
尽管颇具潜力,Rewind 也并不适合所有人,主要制约因素包括高昂的价格(每月 20 美元)、要求较新硬件(只支持 M 系列处理器机型)、暂不支持中文,以及潜在的隐私担忧等。
因此,在试用 Rewind 期间,我也萌生出一种想法:能不能自己「山寨」一个 Rewind 呢?Rewind 录制的是视频,这在我看来稍微有点没必要,也不方便处理;不如将其简化为「如何以较高频率定期截图」,仍然可以满足备忘的需求,所得格式也更通用。
但这听起来还是比较复杂。我们不妨将其分解为以下几个具体问题,然后逐个解决:
- 如何在后台捕获屏幕内容;
- 如何对屏幕图像进行文字识别和压缩尺寸;
- 如何实现循环运行和开机自启;以及
- 如何在上述过程中节约处理器资源。
下面,我首先提供自己制作好的解决方案,然后具体解释制作流程和相关原理。即使你对于定期截图留档没有什么需求,了解本文涉及的技巧对于 macOS 的高级自动化也是有益的。
快速使用
- 下载 shell 脚本
rewind
,然后:- 将其放在任意固定目录。依照 Unix 惯例,这种自制脚本一般可以放在
~/bin
目录; - 在终端执行
chmod +x ~/bin/rewind
命令,为其增加执行权限;
- 将其放在任意固定目录。依照 Unix 惯例,这种自制脚本一般可以放在
- 下载
xyz.cyhsu.script.rewind.plist
,然后:- 将其放在
~/Library/LaunchAgents/
目录下。你可以在 Finder 中按Command
-Shift
-G
后粘贴上述路径,直接到达这个隐藏目录; - (重要)用 TextEdit 或其他文本编辑器打开这个 plist 文件,将第 9 行的内容改为你存放上述
rewind
脚本的实际路径。注意如果路径涉及~
,需要扩写为完整的/Users/[USERNAME]
,其中[USERNAME]
为你的用户名;
- 将其放在
- 使用 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/
- M 系列处理器:
- 点击苹果菜单 >「系统设置」,在「隐私和安全性」中选择「屏幕录制」,然后点按加号形的添加按钮,确保将下列每个项目添加到列表中(文件选择窗口仍然可以按
Command
-Shift
-G
快捷键后粘贴路径跳转):/bin/bash
/System/Library/CoreServices/launchservicesd
/usr/sbin/screencapture
- 在终端执行
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 注释等位置,第三方工具支持有限,且容易在跨系统传输中丢失。
还需要考虑的一个细节是外接显示器的场景。screencapture
支持多显示器,但笨拙的语法要求在参数中写下每个显示器截图的文件名。为此:
- 我们先用
system_profiler SPDisplaysDataType
获取当前的显示信息,用grep
数一数里面提到了几次「分辨率」(Resolution
),就知道了总共有几个屏幕(第 5 行); - 然后,用一个 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 的用法,可以参考这些资源:
launchctl
和 launchd 配置文件的手册页,可以通过运行man launchctl
和 manlaunchd.plist
阅读;- 苹果官方(和年久失修)的 Daemons and Services Programming Guide;
- 由第三方编写的 A launchd Tutorial,作者还开发了优秀的 launchd 维护工具 LaunchControl。
节约处理器资源:使用 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 等工具将其包装为一键执行的捷径。