欢迎光临
我们一直在努力

Electron autoUpdater在鸿蒙PC上翻车实录:签名验证+增量更新全踩坑

Electron autoUpdater在鸿蒙PC上翻车实录:签名验证+增量更新全踩坑

上个月给 Electron 应用加了自动更新。Windows 和 macOS 测了两轮,流程跑得挺顺,弹窗、下载、安装、重启,一气呵成。我信心满满地把包推到鸿蒙 PC 上,结果用户反馈过来:“点了更新按钮,进度条走到一半就卡住了,再打开还是旧版本。”

排查了整整两天,翻了不下二十次日志,最后发现鸿蒙 PC 上的自动更新,根本不是简单地把 Windows 那套逻辑搬过来就能跑的。下面把趟过的坑按顺序记下来,省得后来者再浪费时间。

坑一:asar 解压到临时目录,鸿蒙的路径和想象中完全不同

Electron 的自动更新机制里有个隐藏步骤:下载完更新包之后,需要把 asar 或者完整的安装包解压到临时目录,再做替换。Windows 上这个临时目录通常是 %TEMP% 下面的某个子文件夹,macOS 是 /tmp,这些都没问题。

鸿蒙 PC 上我一开始想当然地用了 app.getPath('temp'),拿到的是 /data/local/tmp/…。看起来和 Linux 差不多,但实际往里写文件的时候,大一点的 asar(超过 100MB)会直接报 ENOSPC。我一开始以为是磁盘满了,df 一看还剩几十个 G。

后来翻了鸿蒙的开发者文档才搞清楚,鸿蒙对 /data/local/tmp 有单进程写入配额限制,默认好像是 200MB 左右。Electron 应用本体加上依赖,打个包轻松过 150MB,解压过程中临时文件一膨胀,配额直接打满。

我的 workaround 是绕开系统临时目录,改写到应用的 userData 目录下自己管理的一个 updates 子文件夹:

const path = require('path');
const { app } = require('electron');
const os = require('os');

function getUpdateTempDir() {
const platform = os.platform();
if (platform === 'linux' && process.env.HARMONYOS_DESKTOP) {
// 鸿蒙 PC 上别用系统 tmp,用自己的目录
return path.join(app.getPath('userData'), 'updates', 'temp');
}
return path.join(app.getPath('temp'), 'myapp-updater');
}

这段代码本身没什么技术含量,但问题在于——如果不专门判断鸿蒙环境,根本意识不到这里会栽跟头。而且鸿蒙的 os.platform() 返回的是 linux,因为鸿蒙 PC 版确实兼容了 Linux ABI。我加了一个环境变量 HARMONYOS_DESKTOP 作为辅助判断,是在应用启动时通过读取 /etc/os-release 里的 ID=harmonyos 来注入的。

坑二:签名验证这关,直接把 electron-builder 的默认逻辑卡死了

Windows 上的 Electron 更新包,通常用 electron-builder 打出 .exe 或者 .nupkg,里面自带了 Authenticode 签名验证。macOS 是 .dmg + .zip,也有 Gatekeeper 的签名检查。这些在鸿蒙 PC 上全部失效。

鸿蒙的应用分发体系不用 .exe,也不认 .dmg。鸿蒙 PC 上的原生安装包是 .hap(HarmonyOS Ability Package),有自己的签名证书体系和 profile.json 校验规则。electron-builder 目前根本不输出 .hap,我们只能把 Electron 应用打成一个压缩包,让用户手动解压或者通过自建更新服务器推送。

这意味着什么?意味着 autoUpdater 默认的签名验证管道完全跑不通。electron-updater 库在校验更新包时,会尝试读取 Windows 的证书链或者 macOS 的 Code Signature,到鸿蒙上直接抛异常,日志里一长串 Error: Cannot verify signature on this platform。

我一开始想绕过签名验证,直接把 autoUpdater.autoDownload = true 配上 autoUpdater.checkForUpdatesAndNotify(),强制跳过校验。但这样搞心里没底,万一中间被人劫持了更新包,后果不堪设想。

最后我们自己写了一套基于 SHA-256 的校验流程。更新服务器在返回下载链接的同时,附带一个 checksum 字段:

const crypto = require('crypto');
const fs = require('fs');

async function verifyUpdatePackage(filePath, expectedHash) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => {
const actual = hash.digest('hex');
if (actual === expectedHash) {
resolve(true);
} else {
reject(new Error(`校验失败: ${actual} !== ${expectedHash}`));
}
});
stream.on('error', reject);
});
}

下载完成后先跑一遍 verifyUpdatePackage,校验通过才允许进入安装流程。虽然比不上操作系统级的签名验证,但至少比裸跑强得多。说句实话,如果 electron-builder 哪天正式支持鸿蒙打包,我希望他们能把这个平台差异处理掉,在此之前只能自己补锅。

坑三:增量更新在鸿蒙上基本是个摆设

我们的应用体积不小,每次全量更新都要下 150MB+,用户抱怨流量扛不住。Windows 上我配了 electron-builder 的 differentialUpdater,利用 blockmap 做增量补丁,通常一次更新只需要下 5-10MB,体验很好。

搬到鸿蒙上之后,我照例配了 differential update,结果更新包体积一点没降,还是全量。翻了一下 electron-updater 的源码,发现它的增量逻辑严重依赖目标平台的包格式:Windows 用 .nupkg 的 blockmap,macOS 用 .zip 的 asar 差异计算。鸿蒙既不出 .nupkg 也不出 .zip(或者说出的 zip 包结构不符合它的预期),增量逻辑直接 fallback 到全量下载。

我尝试了几种方案。第一种是用开源的 bsdiff 算法自己做二进制差分,理论上可行,但实际编译 bsdiff 到鸿蒙的 x86_64/arm64 交叉环境上,花了大半天时间解决 CMake 工具链的问题,最后能跑是能跑,生成补丁的过程在服务器上要消耗将近 30 秒,用户体验反而更差了。

第二种方案务实很多:不再做二进制差分,而是把应用拆成"主框架 + 动态模块"。主框架(Electron 运行时 + 壳子)变动少,打一次全量包;业务代码(渲染进程的 HTML/JS/CSS)更新频繁,单独打包成一个小的 app.asar.patch,更新时只替换这个 patch 文件。实现起来也不复杂:

const { execSync } = require('child_process');
const path = require('path');

function applyAsarPatch(appDir, patchFile) {
const asarPath = path.join(appDir, 'resources', 'app.asar');
const backupPath = `${asarPath}.backup`;

// 先备份,更新失败能回滚
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(asarPath, backupPath);
}

try {
// 我们的 patch 是一个压缩包,里面是要替换的文件
execSync(`unzip -o "${patchFile}" -d "${appDir}/resources/"`, { timeout: 30000 });
fs.unlinkSync(patchFile);
return { success: true };
} catch (err) {
// 回滚
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, asarPath);
}
return { success: false, error: err.message };
}
}

这个方案不是银弹,如果 Electron 本身版本升级了,还是得走全量更新。但日常的小迭代基本能控制在 3-5MB 以内,对用户体验的提升是实实在在的。我个人更推荐这种"分层更新"的思路,比在鸿蒙上死磕二进制差分要现实得多。

坑四:更新完重启,沙箱权限全丢了

这是最让我崩溃的一个坑,排查到最后才发现是鸿蒙的安全机制在搞鬼。

Electron 应用更新完之后,理论上调用 app.relaunch() 然后 app.quit() 就能启动新版本。鸿蒙 PC 上确实能重启,但重启之后用户之前授权过的文件读写权限、网络权限、通知权限,全部变成了"未授权"状态。应用弹出一堆权限申请窗口,用户体验直接炸裂。

问题出在鸿蒙的沙箱机制。鸿蒙给每个应用分配了一个独立的沙箱目录,权限绑定的是应用的"签名身份"。我们的更新流程是把旧版本的目录内容替换掉(或者解压到新目录再替换),但鸿蒙系统认为这个目录里的应用"身份"变了,或者说文件完整性校验没通过,于是把之前授予的权限全部收回。

我试过两种绕过方式。第一种是把更新包直接解压到当前应用的安装目录,覆盖旧文件。这种方式在 Windows 上行得通,在鸿蒙上触发沙箱重新校验,权限必丢。

第二种方式稍微绕了一点:不直接覆盖原目录,而是在 userData 下维护一个"新版本暂存区",应用启动时先检查暂存区有没有待激活的新版本,如果有,就把当前进程的工作目录切过去,通过子进程的方式启动新版本的可执行文件。这样从鸿蒙系统的视角看,应用的安装目录没变,沙箱身份保持一致,权限也就不会丢。

const { spawn } = require('child_process');
const path = require('path');

function launchNewVersion(stagingDir) {
const newExecutable = path.join(stagingDir, 'myapp');

// 在鸿蒙上通过子进程启动新版本,保持沙箱身份
const child = spawn(newExecutable, process.argv.slice(1), {
detached: true,
stdio: 'ignore',
cwd: stagingDir
});

child.unref();
app.quit();
}

这个方案有个副作用:用户看到的是一个"旧进程退出、新进程启动"的过程,如果中间有短暂的黑屏或者窗口闪烁,体验不太好。我目前的 workaround 是在退出旧进程前先把窗口隐藏(win.hide()),新进程启动后再把窗口显示出来,中间间隔大概 200-300ms,肉眼基本感知不到。

写在最后

鸿蒙 PC 上的 Electron 自动更新,目前没有一个开箱即用的方案。electron-updater 没适配,electron-builder 也不输出鸿蒙原生包格式,几乎所有环节都要自己补。我上面写的这几段代码,放在 Windows 或 macOS 上可能显得多余,但在鸿蒙上都是实打实的刚需。

如果你也在做类似的事情,我的建议是:别指望把现有的更新逻辑原封不动搬过去,先花点时间理解鸿蒙的沙箱、权限和包管理机制,再重新设计更新流程。我踩的这些坑,说到底都是"想当然"造成的——以为 Linux 兼容层能搞定一切,结果处处碰壁。好在现在趟过来了,应用更新已经稳定跑了小半个月,没有再收到用户投诉。希望这篇记录能帮你少走点弯路。


本文代码示例遵循 MIT 协议开源,可自由转载、修改,转载时请注明出处。

赞(0)
未经允许不得转载:171主机测评 » Electron autoUpdater在鸿蒙PC上翻车实录:签名验证+增量更新全踩坑
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址