jrainlau.github.io
jrainlau.github.io copied to clipboard
探究 electron-updater 的动态更新原理
最近的工作内容,是开发基于 Electron 的桌面应用。在应用开发完成后,即面临着如何进行版本迭代的问题。如果按照传统桌面应用的思路,只能在发布新版本的时候同步上架到官网和商城,通过在应用内设置引导等方式让用户手动去进行版本更新。这种方式对于新版本的覆盖率增长来说是非常被动的,绝大多数用户都会下意识地觉得,我当前版本用得好好的,为什么要这么麻烦地升级呢?还要自己下载、自己安装,麻烦死了。
万幸的是,Electron 官方自带升级模块,也就是今天要研究的 electron-updater
。该模块允许应用自己更新自己,无需依靠用户手动下载和安装。为了探究这个模块是如何运行的,我们首先来做一个简单的 demo。
完成的 demo 地址在这里
简单的 demo 实现
新建一个空目录,命名为 /wonderland
,然后进行项目初始化:
yarn init -y
yarn add electron electron-builder -D
yarn add electron-log electron-updater
接下来我们新建一个主进程文件 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
并写入如下内容:
<!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-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 指令即可:
"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 平台的展示):
yarn build: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
去执行应用的安装,在内部规避了应用运行时无法修改的问题。
待续……
hello