视频渲染一次往 .app 里写了 142 MB 缓存,macOS 应用的签名随之失效
1265 字约 5 分钟
摘要一个 Electron 桌面应用里的视频生成功能,渲染一次就会往应用包内部写入约 142 MB 的 webpack 缓存,macOS 的代码签名校验随即失败。记录如何定位到写入位置、为什么不能靠配置改位置、怎样验证修复,以及关掉缓存的代价。
我参与的那个开源 Electron 桌面应用,有一个用 Remotion 生成视频的功能。我在做 macOS 真机验收的时候发现了一个现象:用过一次视频生成之后,再去校验应用的签名,就失败了。
macOS 的 .app 是个目录,签名会把里面每个文件都封进一份清单,叫“封印”(seal)。签名之后,只要往包里增加、删除或修改任何一个文件,封印就破了,codesign --verify 会报 a sealed resource is missing or invalid。所以只要运行时有任何东西往应用包内部写文件,签名就会被破坏。
这件事之前就被标成“已知问题”放着,那天我决定把它处理掉。
先看它到底往里写了什么
助手没有靠猜,先做了一个前后对照。它拿一份正式发布的包的副本,渲染之前先记下签名校验结果和包内的完整文件清单,再用应用自带的 Node 直接调用包里的渲染模块,连续渲染两次一个合成视频,渲染之后再对比。
渲染前签名校验通过,渲染后失败。包内多出了 7 个路径,全部在 node_modules/.cache/webpack/ 下面,加起来约 142 MB。
为什么会写在那里
Remotion 打包视频模板时,用的是 webpack,webpack 默认会把文件系统缓存写在项目根目录的 node_modules/.cache/webpack 里。而 Remotion 认为的“项目根”,是入口文件最近的那个 package.json 所在的目录,打包时还会切换到那个目录。
桌面版里,视频模板是随应用一起装进包里的,离它最近的 package.json 属于应用包内部的一个共享服务包。所以缓存就落在了应用包里面。
最自然的想法是让缓存换个位置,但这条路走不通:Remotion 是在应用 webpackOverride 之后才去设置 cache 的,我们在覆盖配置里改什么都会被它盖掉。能做的只有一件事,让它别缓存。
修复:只对包内的固定模板关缓存
改动很小:
- 渲染前准备合成的函数多了一个
enableCaching参数,默认是true,原样传给 Remotion 的bundle()。 - 打包内置模板时传
false。用户自己工作区里可以编辑的视频项目,仍然照常缓存,它们的项目根在用户工作区里,缓存也落在那里,不在应用包内。 - 补了一条回归测试,同时锁定两种行为:内置模板不缓存,默认调用仍然缓存。去掉修复再跑,结果会变成
[true, true],测试会失败,说明它能抓住回退。
验证
测试和类型检查通过之外,我更关心的是真的能把签名保住。助手用同一套对照方法,拿两个同样的正式包副本,一个原样,一个把改过的两个文件替换进去并用本机测试身份重新签名,分别连续渲染两次。
| 原包副本 | 修复后的副本 | |
|---|---|---|
| 渲染后包内新增的路径 | 7 个,约 142 MB | 0 |
| 渲染后签名校验 | 失败 | 通过 |
| 打包阶段耗时,第 1 次 / 第 2 次 | 5.4 秒 / 1.4 秒 | 4.5 秒 / 3.4 秒 |
两组渲染出来的视频帧数、时长、分辨率都一样,都是 90 帧、3.05 秒。助手又抽了一帧画面比对,完全一致,排除了关掉缓存让第一次渲染少了内容(比如字体没加载)的可能。
代价
关掉缓存,第二次起每次渲染的打包阶段多约 2 秒(1.4 秒变成 3.4 秒)。第一次渲染反而略快,因为不用再把缓存写盘。对一个渲染本身要几十秒的功能来说,这个代价可以接受。
没测到的
- Windows 上没有实机渲染。同一段代码路径也会把缓存写进安装目录,修复方式相同,但没有验证。
- 没有通过桌面界面走完整的视频生成流程,验证是直接调用包内渲染模块完成的。
- 验证用的是已发布的上一个版本的包,没有等新版本。因为 GitHub 的下载速度只有每秒 50 到 110 KB,一个包要好几个小时。助手先逐字节核对过,包内相关的三个文件和当前主干完全相同,使用的 Remotion 版本也一样,才用它做代表。
通用的排查办法怀疑运行时在往签名后的
.app里写东西,不需要读代码,对比运行前后的文件清单就够了,再配合codesign --verify --strict --verbose=2看签名是否还有效。这一招同样适合排查日志、缓存、自动生成的配置文件。缓存和日志应该写到用户数据目录,不要写进应用包。
测试里还有几项在 macOS 上本来就失败,比如路径比较时 /var 实际指向 /private/var,助手撤下改动前后各跑了一遍,失败集合完全相同,说明这些失败和这次改动无关。