electron-egg实现全量更新和增量更新(上)
electron-egg 是一个 业务框架
。
就好比 Spring 之于 JAVA,Thinkphp 之于 PHP,Nuxt.js 之于 VUE ......
electron提供了基础的函数和api,当你写项目的时候,业务和代码工程化是需要自己实现的,electron-egg
就提供了这个工程化能力
在 Electron 应用中,更新机制是维护应用健康的重要部分。主要有两种更新方式:全量更新和增量更新。好,在最近的开发中,也需要做这样的全量更新和增量更新的功能,这里就分享一下关于全量更新和增量更新的业务代码,首先还是要介绍一下什么是全量更新和增量更新:
全量更新 是指每次更新时下载完整的应用程序包(通常是整个应用的压缩包或安装包),替换掉当前安装的版本。
特点
-
下载量大:每次更新都需要下载完整的应用包
-
实现简单:不需要复杂的差异计算和补丁应用逻辑
-
可靠性高:全新安装,避免因增量更新导致的文件不一致问题
-
适合场景:
-
应用体积不大
-
更新频率低
-
网络条件良好
缺点:下载量大,electron 打包生成的桌面软件,小则50-80多MB,多则几百多MB,所以当我们的软件放在应用市场或者云空间(如:七牛云)上面供用户下载安装,如果我们平时软件需要更新动不动就走全量更新,那对流量的消耗也是比较大的,相对于成本也就上来了,所以对于一些小的版本更新我们没必要走全量更新
增量更新 是指只下载新旧版本之间的差异部分(delta/diff),然后在本地合并这些差异来生成新版本。
特点
-
下载量小:只传输变更部分,节省带宽
-
实现复杂:需要服务端计算差异,客户端应用补丁
-
潜在风险:补丁应用失败可能导致应用损坏
-
适合场景:
-
应用体积大
-
更新频繁
-
用户网络条件参差不齐
缺点:开发比较复杂
接下来我们就用代码来实现 electron-egg 的全量更新和增量更新
全量更新:
// 首先安装electron-egg git clone https://gitee.com/dromara/electron-egg.git # 进入目录 ./electron-egg/ npm install //如果下载慢:设置国内镜像源(加速) //在根目录添加 .npmrc 文件,添加如下内容 registry=https://registry.npmmirror.com/ disturl=https://registry.npmmirror.com/-/binary/node electron_mirror=https://npmmirror.com/mirrors/electron/ electron-builder-binaries_mirror=https://registry.npmmirror.com/-/binary/electron-builder-binaries/
//进入frontend目录安装
cd frontend
npm i
//这里演示我是使用element-plus 来做ui界面,可根据自己喜好选择
npm i element-plus
npm i
安装好后,进入electron/service/os目录下创建 updater.js, 添加如下代码:
const {is} = require("ee-core/utils"); const {app: electronApp} = require("electron"); const { logger } = require('ee-core/log'); const { autoUpdater } = require("electron-updater"); const { getMainWindow, setCloseAndQuit} = require("ee-core/electron"); class UpdaterService { constructor() { this.config = { windows: true, macOS: true, linux: false, options: { provider: 'generic', url: is.windows() ? 'http://cos.xxxxxx.com/software/windows/xxxxxx/':'http://cos.xxxxxx.com/software/macos/xxxxxx', autoDownload: false }, } this.versionType = { largeUpdate: 'largeUpdate', smallUpdate: 'smallUpdate', noAvailable: 'noAvailable' } } checkForUpdates() { const cfg = this.config; if ((is.windows() && cfg.windows) || (is.macOS() && cfg.macOS) || (is.linux() && cfg.linux)) { // continue } else { return } const status = { error: -1, available: 1, //检测更新状态 noAvailable: 2, downloading: 3, downloaded: 4 } // 获取当前软件版本 const currentVersion = electronApp.getVersion(); // 设置下载服务器地址 let server = cfg.options.url; let lastChar = server.substring(server.length - 1); server = lastChar === '/' ? server : server + "/"; cfg.options.url = server; try { autoUpdater.autoDownload = cfg.options.autoDownload; autoUpdater.setFeedURL(cfg.options); } catch (error) { logger.error('[IncrementUpdater] setFeedURL error : ', error); } /** * { * version: '5.0.2', * files: [ * { * url: 'ee-win-5.0.2-x64.exe', * sha512: 'BXkvlEdSVMVm+wJNOF2zoTLj/nMbqm+00LXwuh4Jv/MEpKbQJtlZRG5h+neGN+WkqsBK/CHpMlnk2Bza4tCGzA==', * size: 84662061 * } * ], * path: 'ee-win-5.0.2-x64.exe', * sha512: 'BXkvlEdSVMVm+wJNOF2zoTLj/nMbqm+00LXwuh4Jv/MEpKbQJtlZRG5h+neGN+WkqsBK/CHpMlnk2Bza4tCGzA==', * releaseDate: '2025-01-18T07:10:02.522Z' * } */ autoUpdater.on('update-available', (res) => { logger.info('[IncrementUpdater] update-available : ', res); //有可用更新 const lastVersion = res.version; const versionResult = this.compareSemVer(currentVersion,lastVersion) const data = { status: status.available, desc: '有可用更新', versionType: versionResult } this.sendStatusToWindow(data) }) autoUpdater.on('download-progress', (progressObj) => { const percentNumber = parseInt(progressObj.percent); const totalSize = this.bytesChange(progressObj.total); const transferredSize = this.bytesChange(progressObj.transferred); let text = '已下载 ' + percentNumber + '%'; text = text + ' (' + transferredSize + "/" + totalSize + ')'; const data = { status: status.downloading, desc: text, percentNumber, totalSize, transferredSize } // logger.info('[autoUpdater] progress: ', text); this.sendStatusToWindow(data); }) autoUpdater.on('update-downloaded', () => { const data = { status: status.downloaded, desc: '下载完成' } this.sendStatusToWindow(data); // logger.info('[autoUpdater] 下载完成,正在退出应用,请稍后...'); // 托盘插件里面设置了阻止窗口关闭,这里设置允许关闭窗口 setCloseAndQuit(true); // Install updates and exit the application autoUpdater.quitAndInstall(); }); autoUpdater.on('update-not-available', () => { const data = { status: status.noAvailable, desc: '没有可用更新' } // this.sendStatusToWindow(data); }) autoUpdater.on('error', (err) => { const data = { status: status.error, desc: err } this.sendStatusToWindow(data); }) } /** * 下载更新 */ download () { autoUpdater.downloadUpdate(); } /** * 单位转换 */ bytesChange (limit) { let size = ""; if(limit < 0.1 * 1024){ size = limit.toFixed(2) + "B"; }else if(limit < 0.1 * 1024 * 1024){ size = (limit/1024).toFixed(2) + "KB"; }else if(limit < 0.1 * 1024 * 1024 * 1024){ size = (limit/(1024 * 1024)).toFixed(2) + "MB"; }else{ size = (limit/(1024 * 1024 * 1024)).toFixed(2) + "GB"; } let sizeStr = size + ""; let index = sizeStr.indexOf("."); let dou = sizeStr.substring(index + 1 , index + 3); if(dou == "00"){ return sizeStr.substring(0, index) + sizeStr.substring(index + 3, index + 5); } return size; } /** * 检查是否有新版本 */ checkForUpdater() { autoUpdater.checkForUpdates() return null; } /** * 向前端发消息 */ sendStatusToWindow(content = {}) { const textJson = JSON.stringify(content); const channel = 'custom/app/updater'; const win = getMainWindow(); win.webContents.send(channel, textJson); } /** * 获取配置 * @returns {*|{linux: boolean, options: {provider: string, autoDownload: boolean, url: string}, windows: boolean, macOS: boolean}} */ getConfig(){ return this.config; } /** * 比较版本,这里用了一个小技巧,就是获取版本号的时候通过主版本号判断是否是全量更新还是增量更新 * @param version1 当前软件的版本号 * @param version2 最新软件版本号 * @returns {string|string} */ compareSemVer(version1, version2) { logger.info('[compareSemVer] version1: ', version1, ' version2: ', version2); // 将版本号字符串拆分为主版本、次版本和修订号数组 const [major1, minor1, patch1] = version1.split('.').map(Number); const [major2, minor2, patch2] = version2.split('.').map(Number); // 比较主版本 if (major1 !== major2 && major1 < major2) { return this.versionType.largeUpdate; } // 主版本相同,比较次版本 if (minor1 !== minor2 || patch1 !== patch2) { return this.versionType.smallUpdate; } // 版本号完全相同 不需要更新 return this.versionType.noAvailable } } const updaterService = new UpdaterService(); UpdaterService.toString = () => '[class UpdaterService]' module.exports = { updaterService: updaterService }
进入electron/service 目录下创建 LocalStorage.js,添加代码:
const { app: electronApp } = require('electron');
const path = require('path');
const fs = require('fs');
const {getMainWindow} = require("ee-core/electron");
// 本地配置文件
const localConfigFile = path.join(electronApp.getPath('userData'), 'config.json')
const localStorageFile = path.join(electronApp.getPath('userData'), 'storage.json')
class LocalStorageService {
constructor() {
let storageData = {
updateHandleModel: false
}
if(!fs.existsSync(localStorageFile)){
fs.writeFileSync(localStorageFile, JSON.stringify(storageData), 'utf8')
}
}
getLocalConfigFile(){
return localConfigFile;
}
getLocalStorageFile(){
return localConfigFile;
}
getLocalStorage(){
let data = fs.readFileSync(localStorageFile, 'utf8')
return JSON.parse(data)
}
setLocalStorage(data){
fs.writeFileSync(localStorageFile, JSON.stringify(data), 'utf8')
}
getLocalConfig(){
let data = fs.readFileSync(localConfigFile, 'utf8')
return JSON.parse(data)
}
/**
*
* @param type
*/
setLocalConfig(type = 'notSuccess'){
let localConfig = this.getLocalConfig()
if(localConfig){
localConfig.downloadSuccess = type
fs.writeFileSync(localConfigFile, JSON.stringify(localConfig), 'utf8')
}
}
/**
* 向前端发消息
*/
sendStatusToWindow(content = {}) {
const textJson = JSON.stringify(content);
const channel = 'custom/app/updater';
const win = getMainWindow();
win.webContents.send(channel, textJson);
}
}
LocalStorageService.toString = () => '[class LocalStorageService]';
const localStorageService = new LocalStorageService();
module.exports = {
LocalStorageService: localStorageService
};
进入electron/controller目录下创建 framework.js,添加代码:
'use strict';
const path = require('path');
const fs = require('fs');
const { exec } = require('child_process');
const { getExtraResourcesDir } = require('ee-core/ps');
const { logger } = require('ee-core/log');
const { updaterService } = require('../service/os/updater');
const { LocalStorageService } = require('../service/LocalStorage');
/**
* framework - demo
* @class
*/
class FrameworkController {
/**
* 调用其它程序(exe、bash等可执行程序)
*
*/
openSoftware(args) {
const { softName } = args;
const softwarePath = path.join(getExtraResourcesDir(), softName);
logger.info('[openSoftware] softwarePath:', softwarePath);
// 检查程序是否存在
if (!fs.existsSync(softwarePath)) {
return false;
}
// 命令行字符串 并 执行, start 命令后面的路径要加双引号
const cmdStr = `start "${softwarePath}"`;
exec(cmdStr);
// 方法二
// 使用cross模块
return true;
}
getLocalStorage(){
const localStorageMsg = LocalStorageService.getLocalStorage()
const data = {
status: 5,
desc: '有更新提示信息',
localStorageMsg: localStorageMsg.updateHandleModel
}
LocalStorageService.sendStatusToWindow(data)
return null;
}
setLocalStorage(){
LocalStorageService.setLocalStorage({
updateHandleModel: false
})
return null;
}
/**
* 下载新版本
*/
downloadApp() {
updaterService.download();
return null;
}
/**
* 检查是否有新版本
*/
checkForUpdater() {
console.log('lll')
updaterService.checkForUpdater();
return null;
}
}
FrameworkController.toString = () => '[class FrameworkController]';
module.exports = FrameworkController;
修改electron/preload/index.js
/************************************************* ** preload为预加载模块,该文件将会在程序启动时加载 ** *************************************************/ const { logger } = require('ee-core/log'); const { updaterService } = require('../service/os/updater'); function preload() { logger.info('[preload] load 1'); updaterService.checkForUpdates() } /** * 预加载模块入口 */ module.exports = { preload }
接着我们需要创建路由文件:frontend/src/api目录下创建 electronApi.js
const ipcApiRoute = { framework: { //检查更新 checkForUpdater: 'controller/framework/checkForUpdater', //下载 downloadApp: 'controller/framework/downloadApp', openSoftware: 'controller/framework/openSoftware', //下载增量更新包 downloadAsar: 'controller/framework/downloadAsar', //获取本地配置 getLocalStorage: 'controller/framework/getLocalStorage', //设置本地配置 setLocalStorage: 'controller/framework/setLocalStorage', createNewWindow: 'controller/framework/createNewWindow', } } /** * Customize Channel * Format: Custom (recommended to add a prefix) */ const specialIpcRoute = { appUpdater: 'custom/app/updater', // updater channel createNewWindow: 'custom/app/createNewWindow' } export { ipcApiRoute, specialIpcRoute }
frontend/src目录下创建 config目录 index.js ,代码如下:
const DEFAULT_CONFIG = { //是否加密localStorage, 为空不加密,可填写AES(模式ECB,移位Pkcs7)加密 LS_ENCRYPTION: '',
LS_ENCRYPTION_key: '',
}
export default DEFAULT_CONFIG
frontend/src/utils 目录下创建 tool.js, 代码如下:
import CryptoJS from 'crypto-js'; import sysConfig from "@/config"; const tool = {} /* localStorage */ tool.data = { set(key, data, datetime = 0) { //加密 if(sysConfig.LS_ENCRYPTION == "AES"){ data = tool.crypto.AES.encrypt(JSON.stringify(data), sysConfig.LS_ENCRYPTION_key) } let cacheValue = { content: data, datetime: parseInt(datetime) === 0 ? 0 : new Date().getTime() + parseInt(datetime) * 1000 } return localStorage.setItem(key, JSON.stringify(cacheValue)) }, get(key) { try { const value = JSON.parse(localStorage.getItem(key)) if (value) { let nowTime = new Date().getTime() if (nowTime > value.datetime && value.datetime != 0) { localStorage.removeItem(key) return null; } //解密 if(sysConfig.LS_ENCRYPTION == "AES"){ value.content = JSON.parse(tool.crypto.AES.decrypt(value.content, sysConfig.LS_ENCRYPTION_key)) } return value.content } return null } catch (err) { return null } }, remove(key) { return localStorage.removeItem(key) }, clear() { return localStorage.clear() } } /*sessionStorage*/ tool.session = { set(table, settings) { var _set = JSON.stringify(settings) return sessionStorage.setItem(table, _set); }, get(table) { var data = sessionStorage.getItem(table); try { data = JSON.parse(data) } catch (err) { return null } return data; }, remove(table) { return sessionStorage.removeItem(table); }, clear() { return sessionStorage.clear(); } } /*cookie*/ tool.cookie = { set(name, value, config={}) { var cfg = { expires: null, path: null, domain: null, secure: false, httpOnly: false, ...config } var cookieStr = `${name}=${escape(value)}` if(cfg.expires){ var exp = new Date() exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000) cookieStr += `;expires=${exp.toGMTString()}` } if(cfg.path){ cookieStr += `;path=${cfg.path}` } if(cfg.domain){ cookieStr += `;domain=${cfg.domain}` } console.log(document) document.cookie = cookieStr }, get(name){ var arr = document.cookie.match(new RegExp("(^| )"+name+"=([^;]*)(;|$)")) if(arr != null){ return unescape(arr[2]) }else{ return null } }, remove(name){ var exp = new Date() exp.setTime(exp.getTime() - 1) document.cookie = `${name}=;expires=${exp.toGMTString()}` } } /* Fullscreen */ tool.screen = function (element) { var isFull = !!(document.webkitIsFullScreen || document.mozFullScreen || document.msFullscreenElement || document.fullscreenElement); if(isFull){ if(document.exitFullscreen) { document.exitFullscreen(); }else if (document.msExitFullscreen) { document.msExitFullscreen(); }else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); }else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } }else{ if(element.requestFullscreen) { element.requestFullscreen(); }else if(element.msRequestFullscreen) { element.msRequestFullscreen(); }else if(element.mozRequestFullScreen) { element.mozRequestFullScreen(); }else if(element.webkitRequestFullscreen) { element.webkitRequestFullscreen(); } } } /* 复制对象 */ tool.objCopy = function (obj) { return JSON.parse(JSON.stringify(obj)); } /* 日期格式化 */ tool.dateFormat = function (date, fmt='yyyy-MM-dd hh:mm:ss') { date = new Date(date) var o = { "M+" : date.getMonth()+1, //月份 "d+" : date.getDate(), //日 "h+" : date.getHours(), //小时 "m+" : date.getMinutes(), //分 "s+" : date.getSeconds(), //秒 "q+" : Math.floor((date.getMonth()+3)/3), //季度 "S" : date.getMilliseconds() //毫秒 }; if(/(y+)/.test(fmt)) { fmt=fmt.replace(RegExp.$1, (date.getFullYear()+"").substr(4 - RegExp.$1.length)); } for(var k in o) { if(new RegExp("("+ k +")").test(fmt)){ fmt = fmt.replace(RegExp.$1, (RegExp.$1.length==1) ? (o[k]) : (("00"+ o[k]).substr((""+ o[k]).length))); } } return fmt; } /* 千分符 */ tool.groupSeparator = function (num) { num = num + ''; if(!num.includes('.')){ num += '.' } return num.replace(/(\d)(?=(\d{3})+\.)/g, function ($0, $1) { return $1 + ','; }).replace(/\.$/, ''); } /* 常用加解密 */ tool.crypto = { //MD5加密 MD5(data){ return CryptoJS.MD5(data).toString() }, //BASE64加解密 BASE64: { encrypt(data){ return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data)) }, decrypt(cipher){ return CryptoJS.enc.Base64.parse(cipher).toString(CryptoJS.enc.Utf8) } }, //AES加解密 AES: { encrypt(data, secretKey, config={}){ if(secretKey.length % 8 != 0){ console.warn("[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。") } const result = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(secretKey), { iv: CryptoJS.enc.Utf8.parse(config.iv || ""), mode: CryptoJS.mode[config.mode || "ECB"], padding: CryptoJS.pad[config.padding || "Pkcs7"] }) return result.toString() }, decrypt(cipher, secretKey, config={}){ const result = CryptoJS.AES.decrypt(cipher, CryptoJS.enc.Utf8.parse(secretKey), { iv: CryptoJS.enc.Utf8.parse(config.iv || ""), mode: CryptoJS.mode[config.mode || "ECB"], padding: CryptoJS.pad[config.padding || "Pkcs7"] }) return CryptoJS.enc.Utf8.stringify(result); } } } export default tool
接着我们进入frontend/src/views/framework/updater目录 (没有创建),创建组件 index.vue ,代码如下
<script setup> import { ipc } from '@/utils/ipcRenderer'; import { ipcApiRoute, specialIpcRoute } from '@/api/electronApi.js'; import {ref, onMounted} from 'vue'; import { ElMessage } from 'element-plus'; import tool from '@/utils/tool' // 下载进度条 const progress = ref(''); const percentNumber = ref(0); const updateTitle = ref('软件更新') const open = ref(false); const versionType = ref('') const percentShow = ref(false) const dialogTableVisible = ref(false); onMounted(() => { init() ipc.invoke(ipcApiRoute.framework.getLocalStorage) checkForUpdater() }) const init = () => { ipc.removeAllListeners(specialIpcRoute.appUpdater); ipc.on(specialIpcRoute.appUpdater, (event, result) => { result = JSON.parse(result); //有更新提示信息 if (result.status === 3) { progress.value = result.desc; percentNumber.value = result.percentNumber; } else if(result.status === 1){ //有可用更新,弹出框 if(result.versionType === 'smallUpdate'){ ipc.invoke(ipcApiRoute.framework.downloadAsar) }else if(result.versionType === 'largeUpdate'){ open.value = true } versionType.value = result.versionType; }else if(result.status === 4){ ElMessage({ message: '下载已经完成,正准备自动更新', type: 'success', }) percentShow.value = true //显示更新提示信息 tool.data.set('updateHandleModel','hasUpdate') }else if (result.status === 6) { ElMessage({ message: result.desc, type: 'warning', }) } }) } function checkForUpdater () { ipc.invoke(ipcApiRoute.framework.checkForUpdater) } function updateForDownload(){ switch (versionType.value){ case 'largeUpdate': //大更新,使用全量更新 ipc.invoke(ipcApiRoute.framework.downloadApp) open.value = false percentShow.value = true break; // case 'smallUpdate': // //小更新,使用增量更新 // ipc.invoke(ipcApiRoute.framework.downloadAsar) // break; default : //没有可用的更新 ElMessage.error('没有可用的更新'); break; } } function closeUpdateMsg(){ dialogTableVisible.value = false } function closeUpdateSoftware(){ open.value = false } </script> <template> <el-dialog v-model="dialogTableVisible" title="本次更新内容" @closed="closeUpdateMsg"> <p>本次软件更新的功能:</p> <p>1、优化了windows版本内容</p> <p>2、解决了软件安装时没有中文快捷名称</p> </el-dialog> <div class="home_title_center"> <el-progress type="circle" :percentage="percentNumber" v-show="percentShow" :width="80"/> </div> <el-dialog v-model="open" :title="updateTitle" width="30%" @closed="closeUpdateSoftware" > <span>检测到当前软件有更新,点击按钮可更新到最新版本</span> <template #footer> <span class="dialog-footer"> <el-button @click="open = false">取消</el-button> <el-button type="primary" @click="updateForDownload()"> 点击更新 </el-button> </span> </template> </el-dialog> </template> <style> .home_title_center { position: absolute; top:20px; right:20px; } .el-progress__text{ color: #fff !important; } </style>
frontend/views/example/hello/index.vue中引入组件:
<template> <updater></updater> <section id="hero"> <h1 class="tagline"> <span class="accent">Electron-Egg</span> </h1> <p class="description"> A fast, desktop software development framework </p> <p class="actions"> <a class="setup" href="https://www.kaka996.com/" target="_blank">Get Started</a> </p> </section> </template> <script setup> import updater from '@/views/framework/updater/index.vue' console.log("hello") </script> <style scoped> section { padding: 42px 32px; } #hero { padding: 150px 32px; text-align: center; height: 100%; } .tagline { font-size: 52px; line-height: 1.25; font-weight: bold; letter-spacing: -1.5px; max-width: 960px; margin: 0px auto; } html:not(.dark) .accent, .dark .tagline { background: -webkit-linear-gradient(315deg, #42d392 25%, #647eff); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .description { max-width: 960px; line-height: 1.5; color: var(--vt-c-text-2); transition: color 0.5s; font-size: 22px; margin: 24px auto 40px; } .actions a { font-size: 16px; display: inline-block; background-color: var(--vt-c-bg-mute); padding: 8px 18px; font-weight: 500; border-radius: 8px; transition: background-color 0.5s, color 0.5s; text-decoration:none; } .actions .setup { color: var(--vt-c-text-code); background: -webkit-linear-gradient(315deg, #42d392 25%, #647eff); } .actions .setup:hover { background-color: var(--vt-c-gray-light-4); transition-duration: 0.2s; } </style>
更改package.json 将version改为2.0.1,然后打包,打包之后在根目录下的out目录会生成几个文件,我们需要将文件上传到七牛云,
然后我们在本地再打包一个低版本的 1.0.1的版本,然后测试是否能够增量更新
测试的效果如下:
至此,全量更新就完成了,由于代码有点多,下一章节再讲增量更新