Electron 应用 macOS 自动更新的正确姿势 —— 没有 Apple Developer Program 也能用

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 更新四步流程

  1. 检查(check) :app 启动后几秒,electron-updater 向服务器请求 latest-mac.yml,比对本地和远程版本号
  2. 下载(download):如果远程版本更新,下载清单中指定的安装包。macOS 上是 ZIP 文件
  3. 安装(install) :这一步依赖 ShipIt------Squirrel 框架的辅助程序,随 electron-updater 一起分发。ShipIt 负责解压 ZIP,验证代码签名,然后替换旧的 .app
  4. 重启(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 经过正式签名。验证代码签名可以确保:

  1. 更新包没有被篡改
  2. 更新包来自同一个开发者(Team ID 匹配)
  3. 二进制文件结构完整、资源齐全

它调用的是 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 为空),让签名链看起来一致。

但有两个问题:

  1. 对 Gatekeeper 无效:ad-hoc 签名不被系统信任,首次启动仍然提示"已损坏"或"无法验证开发者"。需要用右键打开或手动移除 quarantine 标记来绕过。
  2. 对 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 上的更新安装

  1. 下载完成后,写入一个 shell 脚本到临时目录
  2. 先杀掉应用拉起的后台子进程(Molio 架构里是一个常驻 daemon 进程,承担 HTTP 服务和 AI runtime 调度------它持有 .app 内文件句柄,不先杀掉会锁住替换操作),释放文件锁
  3. 退出 Electron
  4. 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 上。独立开发者的路,就是在这种限制下找到出路。

相关推荐
新知图书1 小时前
智能体基础架构
人工智能·agent·ai agent·智能体·langgraph
zzz_23683 小时前
从 200 行规则到一条好渠——Agent 工程化的踩坑与解法
人工智能·agent
熊猫钓鱼>_>5 小时前
智能革命的巨浪——AI时代的社会重构与生存之道
大数据·人工智能·重构·架构·llm·agent·ai-native
ifenxi爱分析6 小时前
爱分析:中国企业智能体市场规模分析,数字劳动力交易是时代拐点
人工智能·大模型·agent·智能体
leeyi9 小时前
可观测性:Langfuse、Langsmith 集成
aigc·agent·ai编程
L3S9 小时前
你的 Agent 为什么总失忆?—— Memory 设计从入门到 Claude Code
agent·claude
user4465117917919 小时前
XAgent ReACT 框架深度解析:实现细节、提示词设计与通用方案对比
agent
ch_09189 小时前
从0构建SDK第4节:实现 ReflectionAgent 的自我反思循环
typescript·agent·ai编程