Open jrainlau opened 2 years ago
最近的工作内容,是开发基于 Electron 的桌面应用。在应用开发完成后,即面临着如何进行版本迭代的问题。如果按照传统桌面应用的思路,只能在发布新版本的时候同步上架到官网和商城,通过在应用内设置引导等方式让用户手动去进行版本更新。这种方式对于新版本的覆盖率增长来说是非常被动的,绝大多数用户都会下意识地觉得,我当前版本用得好好的,为什么要这么麻烦地升级呢?还要自己下载、自己安装,麻烦死了。
万幸的是,Electron 官方自带升级模块,也就是今天要研究的 electron-updater。该模块允许应用自己更新自己,无需依靠用户手动下载和安装。为了探究这个模块是如何运行的,我们首先来做一个简单的 demo。
electron-updater
完成的 demo 地址在这里
新建一个空目录,命名为 /wonderland,然后进行项目初始化:
/wonderland
yarn init -y yarn add electron electron-builder -D yarn add electron-log electron-updater
接下来我们新建一个主进程文件 main.js:
main.js
const { app, BrowserWindow, ipcMain } = require('electron'); const log = require('electron-log'); const { autoUpdater } = require('electron-updater'); autoUpdater.logger = log; autoUpdater.logger.transports.file.level = 'info'; autoUpdater.allowDowngrade = true; // 允许降级 autoUpdater.allowPrerelease = true; // 允许升级到 pre-release 版本 let mainWindow; function createWindow () { mainWindow = new BrowserWindow({ width: 1200, height: 900, webPreferences: { nodeIntegration: true, // 为了让渲染进程能够 require electron 的模块 contextIsolation:false, // 为了让渲染进程能够 require electron 的模块 }, }); mainWindow.loadFile('index.html'); mainWindow.on('closed', function () { mainWindow = null; }); mainWindow.once('ready-to-show', () => { // autoUpdater.checkForUpdatesAndNotify(); // 应用启动时,不自动检查更新,而是手动进行 }); } app.on('ready', () => { createWindow(); mainWindow.webContents.openDevTools(); }); app.on('window-all-closed', function () { if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', function () { if (mainWindow === null) { createWindow(); } }); ipcMain.on('check_update', async (event, version) => { log.info('Querying version:', version) // 根据渲染进程传入的版本号,去设置 electron-updater 所请求的资源路径 autoUpdater.setFeedURL(`http://localhost:8081/${version}`) // 检查更新 const updateInfo = await autoUpdater.checkForUpdates(); log.info('updateInfo: ', updateInfo) }) ipcMain.on('app_version', (event) => { event.sender.send('app_version', { version: app.getVersion() }); }); ipcMain.on('restart_app', () => { autoUpdater.quitAndInstall(); }); // 当检查到更新时,通知渲染进程进行展示 autoUpdater.on('update-available', (info) => { mainWindow.webContents.send('update_available', info); }); // 当更新下载完成时,通知渲染进程进行展示 autoUpdater.on('update-downloaded', (info) => { mainWindow.webContents.send('update_downloaded', info); });
主进程的代码写完后,我们接着来写渲染进程的代码。在 /wonderland 目录下新建 index.html 并写入如下内容:
index.html
<!DOCTYPE html> <head> <title>Electron Auto Update Example</title> <style> body { box-sizing: border-box; margin: 0; padding: 20px; font-family: sans-serif; background-color: #eaeaea; text-align: center; } #notification { position: fixed; bottom: 20px; left: 20px; width: 200px; padding: 20px; border-radius: 5px; background-color: white; box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); } .hidden { display: none; } </style> </head> <body> <h1>Electron Auto Update Example</h1> <p id="version"></p> <input type="text" id="version-input" placeholder="Input version"> <button onClick="checkUpdate()">Check update</button> <div id="notification" class="hidden"> <p id="message"></p> <button id="close-button" onClick="closeNotification()"> Close </button> <button id="restart-button" onClick="restartApp()" class="hidden"> Restart </button> </div> <script> const { ipcRenderer } = require('electron'); const version = document.getElementById('version'); ipcRenderer.send('app_version'); ipcRenderer.on('app_version', (event, arg) => { ipcRenderer.removeAllListeners('app_version'); version.innerText = 'Version ' + arg.version; }); const notification = document.getElementById('notification'); const message = document.getElementById('message'); const restartButton = document.getElementById('restart-button'); ipcRenderer.on('update_available', (event, arg) => { console.log('update_available', arg) message.innerText = 'A new update is available. Downloading now...'; notification.classList.remove('hidden'); }); ipcRenderer.on('update_downloaded', (event, arg) => { console.log('update_downloaded', arg) message.innerText = 'Update Downloaded. It will be installed on restart. Restart now?'; restartButton.classList.remove('hidden'); notification.classList.remove('hidden'); }); function closeNotification() { notification.classList.add('hidden'); } function restartApp() { ipcRenderer.send('restart_app'); } function checkUpdate() { const version = document.querySelector('#version-input').value console.log(`Querying version:`, version) ipcRenderer.send('check_update', version) } </script> </body>
渲染进程的代码写完后,就可以准备构建了。electron-updater 最适合搭配 electron-builder 去使用。接下来我们来新建一个 electron-builder 的配置文件 build.config.js:
electron-builder
build.config.js
electron-updater 生效的应用必须经过数字签名,这里不展开。有需要的同学请自行查阅相关资料。
const { version } = require('./package.json') const fs = require('fs-extra') fs.ensureDirSync('./dist') module.exports = { publish: [{ provider: 'generic', url: '' }], asar: false, directories: { output: `./dist/${version}` // 输出到对应版本号的目录里 }, }
配置文件写好了,最后只需要在 package.json 里面添加几行 scripts 指令即可:
package.json
"scripts": { "start": "electron .", "build:mac": "electron-builder -c ./build.config.js build --mac", "build:win": "electron-builder -c ./build.config.js build --win", "serve": "npx http-server ./dist --port=8081" },
首先修改一下 pacakge.json 的版本号为 1.0.1,接下来我们来构建这个版本(我的设备是 Macbook Pro M1,所以本文的例子都是基于 Mac 平台的展示):
pacakge.json
yarn build:mac
构建完成后,会在 /dist/1.0.1/mac-arm64 里找到 wonderland.app,双击打开即可。
/dist/1.0.1/mac-arm64
wonderland.app
1.0.1 版本构建出来并顺利运行了。为了检查它的更新能力,我们需要构建一个新版本 1.0.2,并尝试从当前版本升级过去。
回到 package.json 文件,把版本号修改为 1.0.2,然后重新执行 build:mac 指令,即可在 /dist/1.0.2 目录里看到产物。
build:mac
/dist/1.0.2
接下来我们需要开启一个静态资源服务器,去托管整个 /dist 目录,以提供给 electron-updater 去拉取更新资源。在这个例子里,我使用了 http-server,并把它的开启指令集成到了 npm scripts 里面。
/dist
http-server
在项目根目录执行 yarn serve,即可在本地开启一个 8081 端口的静态资源服务器。
yarn serve
回到刚刚打开的 1.0.1 应用,在输入框内输入 1.0.2 并点击”Check update“ 按钮,从 http-server 的控制台可以看到,它请求了两个文件,分别是 /1.0.2/latest-mac.yml 和 /1.0.2/wonderland-1.0.2-arm64-mac.zip。前者是 1.0.2 的版本信息文件,后者是 1.0.2 的版本资源。
/1.0.2/latest-mac.yml
/1.0.2/wonderland-1.0.2-arm64-mac.zip
在完成资源下载后,可以在应用内看到更新信息。
点击”Restart“即可重启应用。重启后,版本已然更新为 1.0.2。
回到构建目录 /dist,可以看到两个版本的目录结构是一样的:
最关键的地方在于目录里的 latest-mac.yml 文件:
latest-mac.yml
这个文件记录了对应版本的信息,electron-updater 正是因为从静态资源服务器上读取了它,才能判断是否允许更新。
更新资源的缓存目录,默认位于 ~/Library/Application Support/Caches/wonderland-updater 内。
~/Library/Application Support/Caches/wonderland-updater
该目录里的 update-info.json,其内容和 /dist/1.0.2/latest-mac.yml 里的 files 字段一致:
update-info.json
/dist/1.0.2/latest-mac.yml
在实际的更新中,electron-updater 就是在完成更新资源的下载以后,把它解压缩并替换原安装目录内的应用(/dist/1.0.1/mac-arm64/wonderland.app)本体来实现的。
/dist/1.0.1/mac-arm64/wonderland.app
有趣的地方来了。electron-updater 所执行的”替换“操作,是在应用运行时进行的。如果在应用运行时,我们手动尝试把另一个 wonderland.app 替换当前的这个,会出现报错:
类似的,当一个应用在运行时,默认是不允许对它进行修改的。那 electron-updater 是运用了什么黑科技,能够在应用运行时就把它替换掉呢?
当我点击”Restart“按钮的时候,实际上是调用了 autoUpdater.quitAndInstall() 方法,接下来我们就来看看这个方法到底做了些什么。
autoUpdater.quitAndInstall()
我下载了 electron-updater 的源码,在 BaseUpdater.ts 里找到了这个方法的实现:
BaseUpdater.ts
这个方法里又间接引用了一个叫做 doInstall() 的方法,它的实现才是整个更新原理的关键。
doInstall()
结合注释所提到的链接 https://stackoverflow.com/a/1712051/1910191,真相水落石出。这里并没有使用任何的黑科技,而是通过系统命令调用的方式,用 unlinkSync 方法直接把应用本体给删除,再把缓存目录内的新应用给移动过去。
unlinkSync
要验证也很简单,我们可以在应用运行的时候,尝试 rm -rf ./wonderland.app。你会发现删除是成功的,没有任何的报错;接下来只需要拖一个别的版本的应用进去,退出重启后其可看到版本已经更新。
rm -rf ./wonderland.app
对于 Windows 系统,其原理却不同。我们可以在 NsisUpdater.ts 里找到 doInstall() 的实现:
NsisUpdater.ts
它会通过一个第三方的 elevate.exe 去执行应用的安装,在内部规避了应用运行时无法修改的问题。
elevate.exe
hello
最近的工作内容,是开发基于 Electron 的桌面应用。在应用开发完成后,即面临着如何进行版本迭代的问题。如果按照传统桌面应用的思路,只能在发布新版本的时候同步上架到官网和商城,通过在应用内设置引导等方式让用户手动去进行版本更新。这种方式对于新版本的覆盖率增长来说是非常被动的,绝大多数用户都会下意识地觉得,我当前版本用得好好的,为什么要这么麻烦地升级呢?还要自己下载、自己安装,麻烦死了。
万幸的是,Electron 官方自带升级模块,也就是今天要研究的
electron-updater
。该模块允许应用自己更新自己,无需依靠用户手动下载和安装。为了探究这个模块是如何运行的,我们首先来做一个简单的 demo。简单的 demo 实现
新建一个空目录,命名为
/wonderland
,然后进行项目初始化:接下来我们新建一个主进程文件
main.js
:主进程的代码写完后,我们接着来写渲染进程的代码。在
/wonderland
目录下新建index.html
并写入如下内容:渲染进程的代码写完后,就可以准备构建了。
electron-updater
最适合搭配electron-builder
去使用。接下来我们来新建一个electron-builder
的配置文件build.config.js
:配置文件写好了,最后只需要在
package.json
里面添加几行 scripts 指令即可:首先修改一下
pacakge.json
的版本号为 1.0.1,接下来我们来构建这个版本(我的设备是 Macbook Pro M1,所以本文的例子都是基于 Mac 平台的展示):构建完成后,会在
/dist/1.0.1/mac-arm64
里找到wonderland.app
,双击打开即可。版本切换功能
1.0.1 版本构建出来并顺利运行了。为了检查它的更新能力,我们需要构建一个新版本 1.0.2,并尝试从当前版本升级过去。
回到
package.json
文件,把版本号修改为 1.0.2,然后重新执行build:mac
指令,即可在/dist/1.0.2
目录里看到产物。接下来我们需要开启一个静态资源服务器,去托管整个
/dist
目录,以提供给electron-updater
去拉取更新资源。在这个例子里,我使用了http-server
,并把它的开启指令集成到了 npm scripts 里面。在项目根目录执行
yarn serve
,即可在本地开启一个 8081 端口的静态资源服务器。回到刚刚打开的 1.0.1 应用,在输入框内输入 1.0.2 并点击”Check update“ 按钮,从
http-server
的控制台可以看到,它请求了两个文件,分别是/1.0.2/latest-mac.yml
和/1.0.2/wonderland-1.0.2-arm64-mac.zip
。前者是 1.0.2 的版本信息文件,后者是 1.0.2 的版本资源。在完成资源下载后,可以在应用内看到更新信息。
点击”Restart“即可重启应用。重启后,版本已然更新为 1.0.2。
原理分析
回到构建目录
/dist
,可以看到两个版本的目录结构是一样的:最关键的地方在于目录里的
latest-mac.yml
文件:这个文件记录了对应版本的信息,
electron-updater
正是因为从静态资源服务器上读取了它,才能判断是否允许更新。更新资源的缓存目录,默认位于
~/Library/Application Support/Caches/wonderland-updater
内。该目录里的
update-info.json
,其内容和/dist/1.0.2/latest-mac.yml
里的 files 字段一致:在实际的更新中,
electron-updater
就是在完成更新资源的下载以后,把它解压缩并替换原安装目录内的应用(/dist/1.0.1/mac-arm64/wonderland.app
)本体来实现的。有趣的地方来了。
electron-updater
所执行的”替换“操作,是在应用运行时进行的。如果在应用运行时,我们手动尝试把另一个wonderland.app
替换当前的这个,会出现报错:类似的,当一个应用在运行时,默认是不允许对它进行修改的。那
electron-updater
是运用了什么黑科技,能够在应用运行时就把它替换掉呢?当我点击”Restart“按钮的时候,实际上是调用了
autoUpdater.quitAndInstall()
方法,接下来我们就来看看这个方法到底做了些什么。运行时替换本体
我下载了
electron-updater
的源码,在BaseUpdater.ts
里找到了这个方法的实现:这个方法里又间接引用了一个叫做
doInstall()
的方法,它的实现才是整个更新原理的关键。结合注释所提到的链接 https://stackoverflow.com/a/1712051/1910191,真相水落石出。这里并没有使用任何的黑科技,而是通过系统命令调用的方式,用
unlinkSync
方法直接把应用本体给删除,再把缓存目录内的新应用给移动过去。要验证也很简单,我们可以在应用运行的时候,尝试
rm -rf ./wonderland.app
。你会发现删除是成功的,没有任何的报错;接下来只需要拖一个别的版本的应用进去,退出重启后其可看到版本已经更新。对于 Windows 系统,其原理却不同。我们可以在
NsisUpdater.ts
里找到doInstall()
的实现:它会通过一个第三方的
elevate.exe
去执行应用的安装,在内部规避了应用运行时无法修改的问题。待续……