Electron 应用 macOS 自动更新的正确姿势 ------ 没有 Apple Developer Program 也能用
一、背景
Molio 是一款基于 Electron 的本地知识管理 + AI 写作桌面应用,使用 electron-updater 处理版本更新。Windows 端一切正常,但 macOS 端始终无法自动更新------检查更新直接报错,下载安装更无从谈起。
问题拖了很久,直到最近集中排查,发现不是"一个 bug",而是三个独立 bug 层层叠加,外加 macOS 一个安全机制在绕过 ShipIt 后又拦了一道。每一个都跟 macOS 的安全机制深度绑定。这篇文章把排查过程和背后的原理讲清楚。
二、electron-updater 是怎么工作的
在讲 bug 之前,先梳理一下 electron-updater 的工作流程。这对理解后面的问题至关重要。
2.1 关键角色
electron-updater 是 electron-builder 生态的一部分,负责自动更新的整个生命周期。它依赖两个外部资源:
更新清单(manifest) :一个 YAML 文件,记录了最新版本号、安装包的下载地址、文件大小和 SHA512 校验值。macOS 上的清单文件叫 latest-mac.yml,由 electron-builder 在打包时自动生成。
文件服务器:可以是 GitHub Releases,也可以是任意 HTTP 服务器(generic provider)。清单和安装包都存放在这里。
2.2 更新四步流程
- 检查(check) :app 启动后几秒,electron-updater 向服务器请求
latest-mac.yml,比对本地和远程版本号 - 下载(download):如果远程版本更新,下载清单中指定的安装包。macOS 上是 ZIP 文件
- 安装(install) :这一步依赖 ShipIt------Squirrel 框架的辅助程序,随 electron-updater 一起分发。ShipIt 负责解压 ZIP,验证代码签名,然后替换旧的 .app
- 重启(relaunch):ShipIt 启动新版本
整个流程看似简单,但每一步都有 macOS 的安全机制在把关。
Channel(发布通道)和双 Provider 机制跟 CI 发版策略强相关,放到第五节讲 CI 时一起说,这里先不展开。
三、三个 Bug 的排查与修复
Bug 1:更新清单根本没上传到 OSS
现象 :macOS 客户端点击检查更新直接报错------HTTP 404,找不到 latest-mac.yml。
排查 :GitHub Release 页面有安装包,但国内 OSS 镜像上没有对应文件。检查 CI 发版脚本,发现它只把 Windows 的 latest.yml 推送到了 OSS 根目录,macOS 的 latest-mac.yml 和 Linux 的 latest-linux.yml 被漏掉了。electron-builder 给每个平台都生成了清单文件,但 CI 脚本只上传了其中一个。
修复 :CI 脚本改为遍历所有三个平台的清单文件,逐个上传到 OSS 根目录。同时加入 channel 机制:正式版清单保持原名,预发布版改名为对应 channel 名(如 beta-mac.yml)。
Bug 2:安装逻辑没分平台
现象:Bug 1 修了之后,客户端能检测和下载了。但点击安装后报错------系统尝试把下载的 ZIP 当作可执行程序来运行。
排查 :原来的安装代码没有做平台判断,所有平台都走同一个 spawn() 逻辑,把下载的文件当 exe 启动。这是为 Windows NSIS 安装器准备的------NSIS 安装程序本身是一个 .exe,可以直接 spawn。但在 macOS 上,electron-builder 默认产物是 ZIP,不能被执行。
修复 :加入 process.platform 判断,分三条路径:
js
function installUpdate(downloadedFile) {
if (process.platform === 'win32') {
// Windows:NSIS 安装器本身就是 exe,直接拉起
spawn(downloadedFile, { detached: true, stdio: 'ignore' }).unref();
} else if (process.platform === 'darwin') {
// macOS:先试官方推荐方式(见 Bug 3,会失败再退回手写脚本)
autoUpdater.quitAndInstall();
} else {
// Linux:交给 electron-updater 内置流程
autoUpdater.quitAndInstall();
}
}
- Windows:保持不变,spawn NSIS 安装器
- macOS :先尝试官方
quitAndInstall()------这一步会失败,引出 Bug 3 - Linux :委托给 electron-updater 内置的
quitAndInstall()
Bug 3:ShipIt 代码签名验证拒绝安装
现象 :macOS 路由改为调用 quitAndInstall()------这是 electron-updater 官方推荐的 macOS 安装方式。但测试时出现了新的错误:
css
Code signature at URL ... did not pass validation:
代码未能满足指定的代码要求
下载完成了,点击安装,ShipIt 验证代码签名,拒绝。
这就引出了本文最核心的话题:macOS 的代码签名机制,以及它对没有 Apple Developer Program 的独立开发者的影响。
四、macOS 代码签名机制深度解析
4.1 为什么 ShipIt 要验证签名
ShipIt 的设计假设开发者有 Apple Developer ID 证书,构建的 .app 经过正式签名。验证代码签名可以确保:
- 更新包没有被篡改
- 更新包来自同一个开发者(Team ID 匹配)
- 二进制文件结构完整、资源齐全
它调用的是 macOS 的底层 API ------ SecStaticCodeCheckValidity。这个 API 比命令行 codesign --verify 更严格。
4.2 签名链与 Team ID
macOS 的代码签名不是"整个 .app 一个签名",而是层层签名的树状结构:
- 最外层:
Molio.app主 bundle - 内层:
Molio.app/Contents/MacOS/Molio主二进制 - 再内层:
Electron Framework.framework及其 Helper 应用 - 还有各种
.dylib动态库
每个组件都有独立的代码签名,包含一个 Team Identifier(团队标识符)。正常情况下,同一个 app 内所有组件的 Team ID 应该一致。
问题是:Electron 框架自带的二进制(Framework、Helper 等)是 Electron 团队用 Apple 的证书 签名的,Team ID 属于 Electron 团队。而我们的主二进制在 CI 上构建时没有任何证书,是未签名的。框架组件有 Apple 的签名,主二进制没有签名------签名链断裂。ShipIt 验签时发现不一致,拒绝安装。
4.3 Ad-hoc 签名的局限
macOS 提供一种"自签名"(ad-hoc signing),使用 codesign --sign - 命令。它不需要证书,任何 Mac 都能执行,会给所有组件打上一个共同标记(Team ID 为空),让签名链看起来一致。
但有两个问题:
- 对 Gatekeeper 无效:ad-hoc 签名不被系统信任,首次启动仍然提示"已损坏"或"无法验证开发者"。需要用右键打开或手动移除 quarantine 标记来绕过。
- 对 ShipIt 也无效 :经过实测,即使所有二进制都用 ad-hoc 签名统一了,ShipIt 仍然拒绝。因为
SecStaticCodeCheckValidity除了检查一致性,还会检查代码要求(Code Requirements),ad-hoc 签名无法满足某些内置的验证规则。
4.4 App Translocation------另一个安全机制
决定放弃 quitAndInstall() 后,又撞上了另一个 macOS 安全机制。
用户从网上下载 ZIP,解压到 ~/Downloads/,然后直接双击运行 .app。macOS 的安全策略会把这个 .app 从原始位置"转移"(translocate)到一个只读的临时路径:
typescript
/private/var/folders/.../AppTranslocation/<UUID>/d/Molio.app
app 实际从这个临时路径运行,原始路径被锁定。这个机制的目的是防止用户从不受信任的位置运行应用。但它有一个副作用:更新脚本试图替换 .app 时,拿到的是只读的 translocation 路径,替换操作会失败。
解决方案:检测路径中是否包含 AppTranslocation,如果是,就在 ~/Downloads/、~/Desktop/、/Applications/ 等常见位置搜索原始的 .app,直接更新原始路径。
js
const appPath = app.getAppPath();
let targetPath = appPath;
if (appPath.includes('AppTranslocation')) {
// app 被转译到只读临时路径,必须找到用户实际安装的原始位置
const candidates = [
path.join(homedir(), 'Downloads'),
path.join(homedir(), 'Desktop'),
'/Applications',
];
for (const dir of candidates) {
const real = path.join(dir, 'Molio.app');
if (fs.existsSync(real)) {
targetPath = real; // 用原始路径作为替换目标
break;
}
}
}
4.5 最终方案:完全绕过 ShipIt
既然 ShipIt 的签名验证无法满足,最终选择了跟 Windows 类似的策略------完全绕过 ShipIt,手动处理 macOS 上的更新安装:
- 下载完成后,写入一个 shell 脚本到临时目录
- 先杀掉应用拉起的后台子进程(Molio 架构里是一个常驻 daemon 进程,承担 HTTP 服务和 AI runtime 调度------它持有 .app 内文件句柄,不先杀掉会锁住替换操作),释放文件锁
- 退出 Electron
- Shell 脚本在 app 退出后执行:解压 ZIP → 删除旧 .app → 复制新 .app → 移除 quarantine 隔离标记 → 启动新版本
脚本骨架(精简版):
bash
#!/bin/bash
set -e
APP_PATH="$1" # 目标安装路径(已处理 App Translocation)
ZIP_PATH="$2" # 下载完成的更新包
TMP_DIR="$(mktemp -d)"
LOG="$HOME/.molio/update.log"
echo "$(date) start update" >> "$LOG"
# 1. 解压下载的 ZIP
unzip -o -q "$ZIP_PATH" -d "$TMP_DIR"
# 2. 替换旧 .app(先删再复制,避免覆盖时的文件锁冲突)
rm -rf "$APP_PATH"
cp -R "$TMP_DIR/Molio.app" "$APP_PATH"
# 3. 移除 quarantine 隔离标记,避免 Gatekeeper 拦截首次启动
xattr -rd com.apple.quarantine "$APP_PATH"
# 4. 启动新版本
open "$APP_PATH"
echo "$(date) update done" >> "$LOG"
整个过程不经过 ShipIt,不触发任何代码签名验证。代价是每次更新后需要重新处理 Gatekeeper 隔离(脚本中已自动执行 xattr -rd com.apple.quarantine)。
这个方案虽然绕了三个弯(绕过 ShipIt → 处理 App Translocation → 移除 quarantine),但每一层都有明确的 root cause 和解决方案。它不优雅,但它能用。
五、CI 集成与测试策略
5.1 双 Provider 与双通道上传
electron-updater 的发布配置可以同时指定两个 provider,两者并存,客户端按顺序尝试:
- github:GitHub Releases API,自动处理版本比对和资产下载,海外用户走这条
- generic:普通 HTTP 服务器,适合国内 OSS 镜像加速
发版 CI 将构建产物同时上传到这两处:GitHub Releases 作为 github provider 的数据源,阿里云 OSS 作为 generic provider,国内用户走镜像加速。
清单文件中的下载路径需要做前缀替换:electron-builder 生成的是相对路径,需要替换为带版本目录的完整路径,确保客户端能正确拼接下载 URL。
5.2 Channel 隔离与 Beta 先行验证
electron-updater 支持 release channel(发布通道):正式版(如 0.3.25)客户端请求 latest-mac.yml,预发布版(如 0.3.25-beta.1)请求 beta-mac.yml。两者各自独立------beta 用户只收到 beta 更新,稳定用户只收到稳定更新。CI 在发版时根据 tag 是否包含 beta/alpha/rc 决定清单文件归属哪个 channel。
利用这套隔离机制,在发布正式版之前先在 beta 通道上完整验证更新链路:打 v0.3.25-beta.1 标签 → CI 构建 → beta-mac.yml 更新 → beta 客户端检查更新 → 下载 → 安装 → 重启。beta 通道通不过,正式版绝不会发。
注:Molio 的 Bug 1+2 修复正是走这条 beta 链路验证的(v0.3.25-beta.1 ~ beta.3);而 Bug 3 的 ShipIt 绕过修复较紧急,直接进了 v0.3.26 正式版,没走 beta。这是例外,不是常规。
5.3 更新日志
每个操作步骤都写入日志文件(update.log),包含时间戳和操作详情。出问题时有据可查,不需要盲猜。
六、后续展望
加入 Apple Developer Program($99/年)能带来:
- 正式代码签名:Developer ID 证书签名后,ShipIt 的验证能正常通过
- 公证(Notarization):苹果自动化安全扫描,公证通过后 Gatekeeper 不再拦截
- 无缝更新:用户更新后不需要再次处理安全提示
好消息是,代码已经为公证通过后的切换做好了准备。届时只需把 macOS 的安装逻辑从"手动 shell 脚本"改为 quitAndInstall()------一行改动,删掉约 80 行 workaround,回归 electron-updater 的标准流程。
七、总结
| 问题 | 根因 | 解决 |
|---|---|---|
| Bug 1:检查更新 404 | CI 只推送了 Windows 清单 | 遍历三个平台清单,全部上传 |
| Bug 2:安装失败(macOS 走 NSIS 路径) | 安装逻辑没分平台 | process.platform 判断,三路分发 |
| Bug 3:ShipIt 拒绝安装 | 无 Apple 证书,代码签名验证失败 | 绕过 ShipIt,shell 脚本手动替换 .app |
| 附:App Translocation 阻止替换 | macOS 对未认证应用的临时只读挂载 | 检测并回退到原始路径 |
前三个是层层叠加的 bug,App Translocation 是绕过 ShipIt 后撞上的附加安全机制------它本身不算 bug,是 macOS 的"按设计行为"。
三个 bug 修完,加上对 macOS 安全机制的深入理解,Molio 的 macOS 自动更新终于完整跑通------从检测到下载、从安装到重启。而这一切,没有花一分钱在 Apple Developer Program 上。独立开发者的路,就是在这种限制下找到出路。