不改上游源码做跨平台打包:overlay 隔离层与 FreeBSD 移植

有个常见需求:上游桌面应用只发 macOS 和 Windows,但你需要在 Linux(以及 FreeBSD)上用。直接 fork 改源码最省事,代价是每次上游发版都要解一遍冲突,很快就不想维护了。

本文记录另一种做法:把平台支持做成一层 overlay 补丁,上游源码一个字节都不改,构建时拉官方发布包、叠上补丁再打包。顺带记录 FreeBSD 移植里几个完全出乎意料的问题。

一、overlay 的核心:让上游保持”纯净”

思路很朴素:

  1. fork 里不保存上游源码,只保存”要覆盖哪些文件”的清单和补丁内容;
  2. CI 每次从上游 release 下载源码压缩包,解包;
  3. 把清单里的文件逐个覆盖进去;
  4. 走正常的构建流程。

好处是上游发新版时不存在合并冲突——因为根本没有合并,只有”新版本 + 同一层补丁”。

代价是:上游改了被覆盖的文件时,你的补丁会静默失效(新代码被旧补丁顶掉)。所以必须有基线校验。

二、清单 + 哈希基线

清单里给每个文件记录两项:路径和打补丁时对应的上游 blob 哈希。

构建时先做一次校验:拿上游压缩包里该文件的哈希和清单里的基线比对。不一致就说明上游动过这个文件,直接失败,而不是继续构建。

1
2
3
4
5
6
7
{
"files": [
{ "path": "apps/desktop/scripts/package-target.ts",
"base": "<上游 blob sha>",
"merge": false }
]
}

失败信息要写清楚”哪个文件漂移了、旧基线是什么、新哈希是什么”,因为处理它的人通常是三个月后的自己。

这个校验救过好几次:上游重构了打包脚本,如果没拦住,构建产物会是”新脚本 + 旧覆盖文件”的缝合怪,而且大概率能编译通过、运行时才炸。

三、merge 模式:只增不改

有些文件不能整文件覆盖,比如 package.json——上游会往里加依赖和脚本,你的 fork 也要加自己的键。整文件替换会把上游新增的全抹掉。

所以加了 merge 模式:以上游的新文件为基底,只把自己的键叠上去。

这里踩过最贵的一个坑:最初的实现是”三方合并”(base=旧上游 / ours=fork / theirs=新上游)。听起来很标准,但它会让上游已经删除的键被 fork 副本原样带回来。

后果不是报错,而是 lockfile 对不上:

1
ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile"

排查了很久才想明白:merge 的语义应该是”只增加,不恢复“。

四、三方合并的两个坑

如果确实需要对代码文件做三方合并,有两个问题必须先知道。

第一,git merge-file -p 不写文件。

1
git merge-file -p ours base theirs > merged   # 必须重定向

它的 -p 是”把结果写到标准输出”,不加重定向的话目标文件纹丝不动。当时一度以为 12 个文件已经”干净合并”了,后来抽查才发现全是原样——冲突判定是有效的,但内容根本没落盘。

第二,覆盖文件会和”未被覆盖的文件”脱节。

这是 overlay 架构最隐蔽的问题:你只维护了 20 多个文件,但上游有几千个。一旦上游重构,那些没有被覆盖的文件可能 import 了被覆盖文件的新增导出。

实际案例:上游在 prepare-runtime.ts 里新增了三个导出,而另外两个未被覆盖的脚本直接 import 它们。这时”只修当前报错的那一处”必然还会失败——必须让整层 overlay 和上游新版本对齐。

判断方法:改完后 grep 一遍”未被覆盖的文件有没有引用被覆盖文件的符号”,比逐个试错快。

五、FreeBSD:六个没想到

同一个 Electron 应用,Linux 打包跑通后,FreeBSD 又踩了一轮。

1. pkg 数据库已经不是目录结构了。

老版本 FreeBSD 的 pkg 数据库是 /var/db/pkg/<包名>/ 一堆目录;新版迁移到了 SQLite(/var/db/pkg/local.sqlite)。还在按目录读元数据,会得到”found 0”。

2. node_modules/.bin 会遮蔽系统工具。

PATH 里 node_modules/.bin 排在前面,于是 pkg 被解析成了 npm 上的 @yao-pkg/pkg(Node 打包工具),而不是系统的 pkg(8)。症状非常迷惑:退出码 2、错误信息写到 stdout、参数不被识别。

修法:所有系统工具调用改绝对路径(/usr/sbin/pkg、/usr/bin/tar)。

3. 静态链接需要 musl-gcc。

某些安全组件(如 landlock)要静态链接,FreeBSD 上也一样需要 musl 工具链,apt/pkg 安装列表里漏了会直接编译失败。

4. node-pty 的平台宏只覆盖 Linux。

原生模块里那个”关闭继承的 fd”函数用 #ifdef __linux__ 包着,FreeBSD 走不到,链接时找不到符号。补丁要拓宽成 __linux__ || __FreeBSD__,并给 FreeBSD 走 fcntl 回退(它没有 close_range)。

5. 源码压缩包不含 .git。

构建脚本里有一句 git rev-parse HEAD 用来记录构建来源。从 release 下载的 tar.gz 里没有 .git,这句直接失败。

修法:在源码目录 git init,然后 git fetch 上游那个 tag 对象,让 HEAD 指向真实的上游提交——这样产物里记录的 commit 是可追溯的,而不是一个伪造的哈希。

6. Office 引擎在 FreeBSD 上不存在。

某些平台的能力是”有原生引擎就用,没有就回退 WASM”。FreeBSD 两个都没有,但检测逻辑只判断了”原生引擎是否缺失”,于是在打包后的环境里误判、抛错、不回退。这类问题只有真机装完才暴露。

六、Electron 打包的几个通用细节

即使不涉及 FreeBSD,这些也值得记一下。

ASAR 对不存在的路径返回 null,而 Node 返回 undefined。

代码里常见的写法是:

1
if (fs.lstatSync(p) !== undefined) { /* 认为已安装 */ }

在 Node 里,路径不存在会抛异常或返回 undefined;而在 ASAR 里返回的是 null。null !== undefined 恒为真——于是打包后的应用永远认为”原生引擎已存在”,永远不回退。

这个坑只在”第一个必须依赖回退的平台”上暴露(macOS/Windows 有真引擎,走不到这个分支)。

图标按原始尺寸安装会被主题忽略。

给了一个 1104×1104 的 PNG,打包工具会把它装到 hicolor/1104x1104/,而图标主题只认 8/16/24/…/512 这些标准尺寸——图标文件在,但永远显示不出来。必须按标准尺寸集重新栅格化。

可执行名默认推导可能是非法的。

包名带 scope(@org/app)时,Linux 可执行名会被推导成带 @ 和 / 的字符串,必须显式指定。

七、CI 里必须补的一课

overlay 方案的最后一步是”每次上游发 rc 就自动打包”。这里有两个容易踩的点:

  • 从 release 下载源码时不要用 git clone:目标就是”不碰上游仓库历史”,用官方源码压缩包更干净;
  • 发布前必须做产物自检:解包、检查可执行文件、跑一次 --version。只验证”CI 绿了”不够——文件少一个、权限不对、启动器路径写错,CI 都不会红。

总结

用 overlay 做跨平台支持,本质是用”每次构建时校验”换”每次发版时合并”:

  • 清单记哈希,漂移就失败,不要静默继续;
  • merge 语义是”只增不改”,别把上游删掉的键加回来;
  • 覆盖文件和未被覆盖文件之间存在隐式接口,改一处要看全局;
  • 目标平台的包管理器、路径解析、原生模块宏,是最容易出意外的地方。

最后一条:跨平台打包的验证必须以”装完能跑”为准——编译通过、生成产物、CI 全绿,这三件事没有一件能证明用户装上去能用。