electron 增量与全量更新

前景提要

公司使用 electron 构建 PC 和 MAC客户端项目,有个自动更新的需求,这里记录一下。
win 下可以实现增量和全量更新,MAC 实现全量更新。
这里全量更新有两种方式 ,一种是 electron-update 实现,一种是请求后端接口手动实现。
前者的局限在于判断是否更新由插件约定好,有一些插件约定的文件需要能够访问,如果服务器上可以提供除了安装包以外的文件访问,那就可以用这个方式。
后者是我目前的方式,由前端将打包好的增量、全量更新文件给后端,服务器上只能存更新文件,所以没法用前者的方式。
不过这两种更新方式我都实现了,可以参考下代码。

原理

  1. client 请求服务器查询是否更新
  2. win 下增量更新只需要替换 resource/app 内的代码即可,所以下载压缩包,解压到目录,重启窗口就可以了
  3. win 下全量更新弹出安装流程,下一步
  4. MAC 只有一个文件,问了用 MAC 的同事,大多数软件都是替换 APP 即可,所以只有全量更新
  5. 增量更新为仅更新页面,不能更新 electron ,所以如果对 electron 有修改,则只能全量更新

code

第一种实现方式 autoUpdata.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
const { app, ipcMain, } = require('electron');
const { autoUpdater, } = require('electron-updater');
const fs = require('fs-extra');
const request = require('request');
const AdmZip = require('adm-zip');
const os = require('os');
const packageJson = require('../../../package.json');

// 线上版本号
let onlineVersion = ''
const osPlatform = os.platform()
let mainWindow

// 通过main进程发送事件给renderer进程,提示更新信息
const sendUpdateMessage = (text, code) => mainWindow.webContents.send('update-message', code ? text : {
text,
code,
})

const fullQuantity = () => {
console.log('全量更新')
sendUpdateMessage('全量更新')

// 检测更新,在你想要检查更新的时候执行,renderer事件触发后的操作自行编写
autoUpdater.setFeedURL(process.env.VUE_APP_CHECK_UPDATE_URL);
autoUpdater.on('error', () => {
sendUpdateMessage('检查更新出错', 1)
});
autoUpdater.on('checking-for-update', () => {
sendUpdateMessage('正在检查更新……')
});
autoUpdater.on('update-available', () => {
sendUpdateMessage('检测到新版本,正在下载……')
});
autoUpdater.on('update-not-available', () => {
sendUpdateMessage('现在使用的就是最新版本,不用更新')
});

// 更新下载进度事件
autoUpdater.on('download-progress', progress => {
mainWindow.webContents.send('update-downloadProgress', Math.floor(progress.percent))
})

autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, releaseDate, updateUrl, quitAndUpdate) => {
sendUpdateMessage('update-message', '更新包下载完成')
mainWindow.webContents.send('update-downloadProgress', 100)
autoUpdater.quitAndInstall();
})

ipcMain.on('update-confirmDownloaded', (e, arg) => {
autoUpdater.checkForUpdates();
});
}

const update = win => {
const winUrl = process.env.VUE_APP_CHECK_UPDATE_URL + '/win-unpacked/resources/app/package.json'
const macUrl = process.env.VUE_APP_CHECK_UPDATE_URL + '/mac/pctv.app/Contents/Resources/app/package.json'
mainWindow = win
request({
method: 'GET',
uri: osPlatform === 'win32' ? winUrl : macUrl,
}, (_error, res, data) => {
data = JSON.parse(data)
onlineVersion = data.version
mainWindow.webContents.send('update-check', data)
const compareVersionResult = compareVersion(onlineVersion)
switch (compareVersionResult) {
case 0:
return
case 1:
fullQuantity()
break
case 2:
incremental()
break
default:
break;
}
})
}

module.exports = update

第二种实现方式
autoUpdate.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
const { app, ipcMain, } = require('electron');
const fs = require('fs-extra');
const request = require('request');
// 解压增量更新zip包
const AdmZip = require('adm-zip');
const os = require('os');
// 获取当前版本
const packageJson = require('../../../package.json');
const { spawn, } = require('child_process');

// 线上版本号
const osPlatform = os.platform()
let savePath = null
let mainWindow

// 版本对比 return 1: 全量 2: 增量 0: 无需更新
const compareVersion = newVersion => {
const vs1 = packageJson.version.toString().split('.');
const vs2 = newVersion.toString().split('.');
if (vs1.length !== vs2.length) {
// 版本格式不一致
return 1;
}
if (vs1[0] !== vs2[0] || vs1[1] !== vs2[1]) {
return 1
}
if (vs1[2] !== vs2[2]) {
// mac下直接全量更新
if (osPlatform !== 'win32') {
return 1
}
return 2
}
// 版本一致
return 0
};

// 通过main进程发送事件给renderer进程,提示更新信息
const sendUpdateMessage = (text, code) => mainWindow.webContents.send('update-message', code ? text : {
text,
code,
})

const downloadFile = (uri, fileName, callback) => {
const req = request({
method: 'GET',
uri,
encoding: null,
})

let totalBytes = 0
let receivedBytes = 0
req.on('response', data => {
totalBytes = data.headers['content-length'] - 0
if (totalBytes === 0) {
sendUpdateMessage(`找不到文件: ${savePath}`)
}
})

const out = fs.createWriteStream(savePath)
req.pipe(out)
let startTime = new Date().getTime()
// 获取当前下载进度
req.on('data', chunk => {
receivedBytes += chunk.length
const nowTime = new Date().getTime()
if (nowTime - startTime >= 1000) {
startTime = nowTime
const percentage = Math.floor(receivedBytes / totalBytes * 100)
mainWindow.webContents.send('update-downloadProgress', percentage)
}
})

// 下载完毕
req.on('end', () => {
mainWindow.webContents.send('update-downloadProgress', 100)
callback()
})
}

// 增量更新
const incremental = data => {
console.log('增量更新')
ipcMain.on('update-confirmDownloaded', () => {
downloadFile(data.update_uri, data.update_path, () => {
const zip = new AdmZip(`./${data.update_path}`)
zip.extractAllToAsync(`./resources/app`, true, () => {
app.relaunch({ args: process.argv.slice(1).concat(['--relaunch']), }); // 重启
app.exit(0);
})
})
});
}

// 全量更新
const fullQuantity = data => {
console.log('全量更新')
sendUpdateMessage('全量更新')
ipcMain.on('update-confirmDownloaded', () => {
// 本地下载测试用 http-server 启服务测试
// data.uri = 'http://127.0.0.1:8084/test.exe'
// data.path = 'test.exe'
savePath = osPlatform === 'win32' ? `./${data.path}` : `${os.homedir()}/Downloads/${data.path}`
downloadFile(data.uri, data.path, () => {
// 文件刚下载完成,可能被占用,延迟打开安装
setTimeout(() => {
if (osPlatform === 'win32') {
// 第二个参数写 /S && ./test.exe 静默安装,但是安装完没法自动启动,可能要开线程监听
spawn(savePath, [], {
detached: true,
})
app.quit();
} else {
spawn('open', [savePath], {
detached: true,
})
app.quit();
}
}, 1000);
})
})
}

const update = win => {
mainWindow = win
ipcMain.on('update-check', (e, data) => {
request({
method: 'POST',
json: true,
uri: process.env.VUE_APP_API + '/checkUpdate',
body: data,
}, (_error, res, data) => {
if (data.code !== 0) return
data = data.data
const compareVersionResult = compareVersion(data.app.version)
switch (compareVersionResult) {
case 0:
console.log('无更新')
return
case 1:
mainWindow.webContents.send('update-log', data.app.desc)
fullQuantity(data.app)
break
case 2:
mainWindow.webContents.send('update-log', data.app.desc)
incremental(data.app)
break
default:
break;
}
})
})
}

module.exports = update

background.js

1
2
3
4
5
6
7
8
9
import update from './utils/electron/autoUpdate.js'
const isDevelopment = process.env.NODE_ENV !== 'production'

function createWindow () {
if (!isDevelopment) {
update(win)
win.webContents.openDevTools()
}
}

打包完成后将增量更新资源压缩。由于 webpack 未提供打包完成后的生命周期,所以只能由命令行运行
updateZip.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* 压缩增量更新文件 */
const os = require('os');
const AdmZip = require('adm-zip');

if (os.platform() !== 'win32') {
console.warn('MAC 平台无增量更新')
return
}

let eleOutputDir = ''
// 打包的目录
if (process.argv[3] === 'development') {
eleOutputDir = "dist_electron_buider_dev"
} else {
eleOutputDir = "dist_electron_buider_pro"
}

// 编译好的版本号
const buiderDistVersion = require(`./${eleOutputDir}/win-unpacked/resources/app/package.json`).version
// 需要编译的版本号
const buiderVersion = require('./package.json').version
if (buiderDistVersion === buiderVersion) {
// 将源文件压缩
var zip = new AdmZip();
zip.addLocalFolder(`./${eleOutputDir}/win-unpacked/resources/app`);
zip.writeZip(`./${eleOutputDir}/app_${buiderDistVersion}.zip`);
} else {
console.warn(`编译版本号与当前项目版本号不匹配,请检查版本号配置. 编译版本号: ${buiderDistVersion} 项目版本号: ${buiderVersion}`)
console.warn('请检查以下目录中 version 字段')
console.warn(`/${eleOutputDir}/win-unpacked/resources/app/package.json`)
console.warn(`/package.json`)
}

这里在打包完的命令后面增加压缩文件的命令 package.json

1
2
3
4
5
6
{
"scripts": {
"ele:build:dev": "vue-cli-service electron:build --mode development && node ./updateZip.js --mode development",
"ele:build:dev:zip": "node ./updateZip.js --mode development",
},
}