| tags: [ Electron macOS Windows Linux ] categories: [ Development ]
在 Linux 上构建 macOS 和 Windows 版 Electron 应用
为什么要在 Linux 上构建全平台
传统做法是"用什么平台就在什么平台上打包":macOS 打 mac 包、Windows 打 win 包。这意味着 CI 里要养一台 macOS runner——贵、慢、还稀缺。
其实除了运行这一步,打包的绝大部分工作都是平台无关的:
electron-builder本身能在 Linux 上产出 mac 的.app、win 的.exe/安装包;- macOS 的签名和公证过去必须用
codesign/notarytool(仅 macOS),但rcodesign(Rust 实现的 apple-codesign)让这两步能在 Linux 上跑; - Windows 打包需要的
rcedit依赖wine,Linux 上装一个即可; - 唯一 Linux 上没有的是
hdiutil(造.dmg),用libdmg-hfsplus+ 内核 HFS+ 驱动替代。
补齐这几块,一台 Linux runner 就能出全平台产物。下面按平台拆开讲。
整体流程
┌─────────────── GitHub-hosted Linux runner ───────────────┐
│ │
matrix target │ electron-builder (跨平台打包) │
mac-x64 │ ├─ mac: 产出 .app (zip-only) ──┐ │
mac-arm64 │ │ afterPack → rcodesign 签名 + 公证 + staple │
win-x64 │ │ afterAll → mkdmg 造 .dmg + 签名 + 公证 │
win-arm64 │ ├─ win: wine + rcedit → 安装包 │
│ └─ 产出自动更新元数据 (latest*.yml + .blockmap) │
│ │
│ release guard: 独立校验签名 / 公证 / 差分包完整性 │
└────────────────────────────────────────────────────────────┘
关键点:签名不放在打包之后单独做,而是塞进 electron-builder 的 hook 里。这样 .zip 打出来时里面已经是签好名的 .app,自动更新用的 sha512/size/blockmap 由 electron-builder 原生算出,不需要事后重新压缩、重新生成元数据。
在 Linux 上,先强制 mac 目标只打 zip、并关掉 electron-builder 自带的签名/公证(交给 hook):
# dist-signed.sh:Linux host 上构建 mac target
export CSC_IDENTITY_AUTO_DISCOVERY=false
export EXTRA_EB_ARGS="-c.mac.notarize=false -c.mac.target=zip"
Windows:只差一个 wine
Windows 部分几乎没有坑。electron-builder 打 win 包时会调用 rcedit(改 exe 图标/版本信息),它跑在 wine 上。
唯一要注意的是:electron-builder spawn 的是 wine 这个命令,不是 wine64。只装 wine64/wine32 会得到两个 loader,但没有 /usr/bin/wine wrapper,直接 spawn wine ENOENT:
apt-get install -y --no-install-recommends wine wine64 wine32 \
|| apt-get install -y --no-install-recommends wine \
|| apt-get install -y --no-install-recommends wine64
# 兜底:确保 `wine` 命令一定存在
command -v wine >/dev/null 2>&1 \
|| { w="$(command -v wine64 || true)"; [ -n "$w" ] && sudo ln -sf "$w" /usr/local/bin/wine; }
wine --version || true # 打包前先冒烟验证
macOS:重头戏
mac 产物需要四件事:签名 → 打 .dmg → 公证 → staple(装订公证票据),再加上自动更新元数据。逐个看。
用 rcodesign 签名——entitlements 必须逐个 scope
在 macOS 上,codesign 会把主 bundle 的 entitlements “继承"给内嵌的二进制。rcodesign(≥0.22)不会:未 scope 的 --entitlements-xml-file 只作用于 .app 的主可执行文件。
这是最隐蔽的坑。如果内嵌了别的 Mach-O(我们的场景里有 sandbox 运行时 podman、microsandbox、vfkit,还有 Electron 自己的 helper apps),而你只给了一份全局 entitlements,那么:
--for-notarization会用空 entitlements 加固(hardened runtime)每一个内嵌二进制;- 运行时它 dlopen 一个签名不同的 dylib 时,library validation 直接拒绝;
- 更坑的是 Electron 的 helper(Renderer/GPU/Plugin)拿不到
allow-jit,V8 在Isolate::Initialize处 SIGTRAP,应用启动即白屏——而公证不会报错,因为缺 entitlement 不是公证检查的东西。
正确做法:枚举 .app 里每一个内嵌 Mach-O,用 路径:plist 的形式逐个 scope。
// rcodesign 的 scope 语法:--entitlements-xml-file <bundle 相对路径>:<plist>
export function buildSignArgs(appPath, secrets, buildDir, nestedEntitlements = []) {
const scoped = nestedEntitlements.flatMap(({ relPath, plist }) => [
"--entitlements-xml-file",
`${relPath}:${plist}`,
]);
return [
"sign",
"--p12-file", secrets.p12Path,
"--p12-password", secrets.p12Password,
"--for-notarization",
// 这份 unscoped 的只覆盖 .app 主可执行文件
"--entitlements-xml-file", path.join(buildDir, "entitlements.mac.plist"),
...scoped, // 每个内嵌二进制一条
appPath,
];
}
枚举内嵌二进制时,别忘了 Contents/Frameworks/*.app 里的 Electron helper——它们是独立的嵌套 bundle,同样需要 JIT entitlements:
// 每个 helper 的主可执行文件都要 scope 上带 allow-jit 的 mac plist,
// 否则渲染进程白屏
for (const bin of findHelperExecutables(appPath)) {
out.push({ relPath: rel(appPath, bin), plist: entitlements.mac });
}
教训:用魔数(magic bytes)发现 Mach-O,而不是硬编码文件名列表。将来多打一个 helper,也能自动被签到,不会静默漏签。
打自动更新用的 .zip——别让 7-Zip 解引用符号链接
Electron 的 .app 里,framework 用符号链接组织版本目录(Electron Framework.framework/Versions/Current → A)。压缩成 .zip 时这些符号链接必须保留。
electron-builder 在 macOS 上会用原生 zip 保留符号链接,但在 Linux 上默认走 7-Zip,而 7-Zip 会解引用符号链接,把 .framework 结构压坏。装出来的包能用,但自动更新时 Squirrel/ShipIt 会报 bundle format is ambiguous (could be app or framework)。
修复:用 pnpm patch 把 use7z 判断里的平台从只判 darwin 扩展到也判 linux,让 Linux 也走 Info-ZIP 的 zip -y(它同样保留符号链接):
- const use7z = !(process.platform === "darwin" && format === "zip" && options.preserveSymlinks);
+ const use7z = !((process.platform === "darwin" || process.platform === "linux")
+ && format === "zip" && options.preserveSymlinks);
造 .dmg——内核 HFS+ 挂载 + cp -a
Linux 没有 hdiutil。方案:建一个 HFS+ 空镜像,用内核 hfsplus 驱动挂载,把 .app 拷进去,再用 libdmg-hfsplus 转成压缩 UDIF。
两个反直觉的细节:
- 必须用
cp -a,不能用rsync -aHAX。 rsync 在内核 hfsplus 上同步com.apple.FinderInfoxattr 会硬失败(lremovexattr ENOENT,exit 23)。而cp -a容忍它,且完整保留符号链接、执行权限、字节内容(代码签名嵌在 Mach-O 里,所以内容不变签名就不变)。 - 不能用 libdmg 的
hfsplus addall写文件——它会破坏内嵌的 Mach-O 签名和 framework 符号链接,公证报 “The signature of the binary is invalid”。libdmg 只用来做只读的dmg dmg转换(压缩)。
# mkdmg.sh 核心
raw="$tmp/image.hfs"
du_mb="$(du -sm "$APP" | cut -f1)"
size_mb=$(( du_mb + du_mb / 2 + 256 )) # HFS+ block 对齐 + 大量小文件,留足余量
dd if=/dev/zero of="$raw" bs=1M count="$size_mb" status=none
mkfs.hfsplus -v "$VOL" "$raw" >/dev/null
# 内核挂载 + cp -a 保签名/符号链接
sudo mount -t hfsplus -o loop,rw "$raw" "$mnt"
sudo cp -a "$APP" "$mnt/"
sudo ln -s /Applications "$mnt/Applications" || true
sync && sudo umount "$mnt"
# libdmg 只做只读压缩转换
dmg dmg "$raw" "$OUT"
依赖:hfsprogs(提供 mkfs.hfsplus)、内核 hfsplus 模块(可能要装 linux-modules-extra-$(uname -r) 再 modprobe)、以及自己编译的 libdmg-hfsplus(Mozilla fork,能在 OpenSSL 3 上编)。
公证 + staple
签好的 .app 和 .dmg 都要提交 Apple 公证并 staple(把票据装订进产物,离线也能过 Gatekeeper)。rcodesign 一条命令搞定,注意公证是网络往返,加一次重试:
export function notarizeStaple(target, ascKeyPath) {
const submit = () =>
execFileSync("rcodesign",
["notary-submit", "--api-key-file", ascKeyPath, "--staple", target],
{ stdio: "inherit" });
try { submit(); }
catch { submit(); } // 5xx / timeout 常见,重试一次;硬拒绝重试也会再失败,只多一次往返
}
.dmg是容器不是 Mach-O,所以签它时不要加--for-notarization/hardened runtime,只做普通 Developer ID 签名。
自动更新元数据——别把 .dmg 弄丢了
强制 -c.mac.target=zip 有个副作用:electron-builder 不再把 .dmg 写进 latest-<channel>-mac.yml。而 .dmg 是给用户手动下载的产物,必须在更新清单里列出。
想在 afterAllArtifactBuild hook 里补写 yml 是不行的——这个 hook 在 electron-builder 写 yml 的 finalize 阶段之前触发,那时 yml 还不存在。正确做法是等 electron-builder 完全退出后,在构建脚本里补写:
# electron-builder 退出后,所有 *-mac.yml 已落盘,再把 .dmg 追加进去
node "$SHELL_DIR/scripts/add-dmg-to-mac-yml.mjs" "$SHELL_DIR/dist"
差分更新的机制也要理解对:mac 的 .zip 靠旁挂的 .zip.blockmap 做差分(electron-updater 会拉 <url>.blockmap 跟本地 diff)。electron-builder 只有在 blockmap 内嵌进产物时(NSIS-web / AppImage)才往 yml 写 blockMapSize;旁挂 blockmap 时故意不写。所以校验差分包别去查 blockMapSize,而是查:旁挂的 .blockmap 存在 + yml 里引用了 .zip + 有 sha512。
一道 release guard 兜底
上面每一步都可能静默退化(漏签、丢 dmg、符号链接被压坏)。加一道独立的守卫,用 rcodesign 从产物反向验证,任何一条不满足就 fail:
.app主 Mach-O 有CMS Signature(确实签了 Developer ID);.dmg同时有CMS Signature和Ticket(签了名 + 公证票据已 staple);.zip里至少有一个.framework/Versions/Current是符号链接(证明没被 7z 解引用);- 旁挂
.blockmap存在,且每个*-mac.yml都引用了.zip(带 sha512)和.dmg。
zipinfo "$zip_path" | grep -qE '^l.*\.framework/Versions/Current' \
|| guard_fail ".zip 解引用了 framework 符号链接 —— 自动更新会报 bundle format is ambiguous"
注意事项清单
- rcodesign 不继承 entitlements——每个内嵌 Mach-O(含 Electron helper)都要逐个 scope,否则轻则运行时 library validation 失败,重则白屏,且公证不会报错。
- Linux 上 electron-builder 默认用 7z 压 mac zip,会毁掉 framework 符号链接 → 自动更新 “bundle format is ambiguous”。patch 成走原生
zip。 - 造 .dmg 用
cp -a+ 内核 HFS+ 挂载,别用rsync -X(内核 hfsplus xattr 硬失败),别用 libdmgaddall(毁签名)。 .dmg不加 hardened runtime;.app及内嵌二进制才加。- 签名塞进 hook(afterPack 签 .app、afterAll 造/签 .dmg),让
.zip和更新元数据原生正确,免去事后重压 / 重算。 - 补写 yml 要在 electron-builder 退出之后,不能在 afterAll hook 里(那时 yml 还没写)。
- 公证加一次重试;签名材料(p12 / ASC key)从 base64 环境变量落到临时文件,用完即删,永远不要提交进仓库或走 argv。
- wine 要装 wrapper 命令本身,不只是 loader。
一个改不了的局限
这套流水线是纯构建的:Linux runner 造得出 mac/win 产物,但跑不起来。像"白屏"“bundle format ambiguous"这类问题,都是升级到真机才暴露的——CI 全绿不代表产物能启动。
所以发布 stable 之前,务必在真 macOS 硬件上挂载 .dmg、启动、过一遍 Gatekeeper。这一步机器代替不了。