electron实现软件(热)更新(附带示例源码)
热更新指的是:electron 程序已经开启,在不关闭的情况下执行更新,需要我们把远程的app.asar文件下载到本地执行替换,然而 在electron应用程序开启状态是无法直接下载app.asar文件的,下载会检查出app.asar文件被占用,所以我们需要在本地将app.asar文件反编译,编译出一个app文件夹,里面有项目所需的所有源码文件,这时通过vue或react打包的渲染进程代码是经过压缩的,但是主进程代码会直接暴露,所以刚好,我们可以将主进程代码做压缩混淆,然后生成一个
app.zip
压缩包,上传至服务器,然后下载这个压缩包,解压,将其编译成app.asar
文件替换到resources目录中,从而实现electron软件的热更新。
- 主进程(nodejs)侧代码:
const {app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')
const fs = require("fs");
const http = require("http");
const asar = require('asar');
const AdmZip = require('adm-zip');
const fsExtra = require('fs-extra');
const mainData = require("./mainData");
const txtConsole = require("./txtConsole");
//当前环境
const production = mainData?.production;
//Electron 安装根目录
const rootPath = production === 'dev' ? path.resolve('./public') : path.dirname(app.getPath('exe'));
mainData.rootPath = rootPath;
// 软件更新配置信息(目前需要手动修改~~~)
const winUpdateConfig = {
currentVersion: null, //当前版本
updateVersionFilePath: 'http://103.117.121.53:8002/latest', //远程版本信息路径
updateFilePath: 'http://103.117.121.53:8002/app.zip', //远程包路径
localUpdateVersionFilePath: production === 'dev' ? `${rootPath}/latest` : `${rootPath}/resources/latest`, //本地版本信息路径
localUpdateFilePath: production === 'dev' ? rootPath : `${rootPath}/resources`, //本地包路径
updateSteps: [
{id: 1, desc: '开始下载并解压更新文件,请勿重启!', active: 'active'},
{id: 2, desc: '下载并解压完成, 开始覆盖安装!', active: 'wait'},
{id: 3, desc: '更新完毕, 即将重启,请稍候!(第3步完成后也可以手动重启)', active: 'wait'},
], //更新步骤 active:正在进行 wait:等待 success:执行成功 error: 执行失败
};
let versionInfo = ''; //获取最新版本信息
let locallatest = ''; //本地版本号
function appInit() {
txtConsole.log('初始化');
try {
locallatest = fs.readFileSync(winUpdateConfig.localUpdateVersionFilePath, 'utf-8');
locallatest = JSON.parse(locallatest);
//设置当前版本信息
winUpdateConfig.currentVersion = locallatest?.version;
txtConsole.log('已设置当前版本信息', locallatest?.version);
//删除日志
txtConsole.clearLog();
} catch (err) {
txtConsole.log(err);
}
}
//创建主窗口
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
contextIsolation: false,
webSecurity: false, //禁用同源策略
}
});
mainWindow.loadFile('index.html').then();
// 打开开发者工具 控制台
if (mainData.winControl === 'dev') {
mainWindow.webContents.openDevTools();
}
// 检查更新
ipcMain.on('window-version', function (event) {
try {
txtConsole.log('检查更新', versionInfo);
if (!versionInfo) {
!event?.sender?.isDestroyed() &&
event?.sender?.send('window-version-err-msg', '更新文件读取失败');
return;
}
const v = {...(versionInfo || {})};
//最新版本号
let firNewVersion = versionInfo?.version?.split('.')?.[0]; //第一位
let secNewVersion = versionInfo?.version?.split('.')?.[1]; //第二位
let thiNewVersion = versionInfo?.version?.split('.')?.[2]; //第三位
//当前版本号
let firOldVersion = versionInfo?.currentVersion?.split('.')?.[0]; //第一位
let secOldVersion = versionInfo?.currentVersion?.split('.')?.[1]; //第二位
let thiOldVersion = versionInfo?.currentVersion?.split('.')?.[2]; //第三位
//按位比较是否需要更新
if (Number(firNewVersion || 10000) > Number(firOldVersion || 10000)) {
v['versionVisible'] = true;
}
else if (Number(secNewVersion || 10000) > Number(secOldVersion || 10000)) {
v['versionVisible'] = true;
}
else if (Number(thiNewVersion || 10000) > Number(thiOldVersion || 10000)) {
v['versionVisible'] = true;
}
else {
v['versionVisible'] = false;
v['currentVersion'] = versionInfo?.version;
}
if (!v['versionVisible']) {
let latest = fs.readFileSync(winUpdateConfig.localUpdateVersionFilePath, 'utf-8');
latest = JSON.parse(latest);
latest.version = versionInfo?.version || latest?.version;
latest.currentVersion = versionInfo?.version || latest?.version;
fs.writeFileSync(winUpdateConfig.localUpdateVersionFilePath, JSON.stringify(latest));
txtConsole.log('hot: ', latest.version);
}
txtConsole.log('versionVisible=> ', v['versionVisible']);
!event?.sender?.isDestroyed() && event?.sender?.send('window-version-msg', v);
} catch (err) {
!event?.sender?.isDestroyed() &&
event?.sender?.send('window-version-err-msg', '更新文件读取失败');
txtConsole.log('检查更新err:', err);
}
});
// 下载更新文件
ipcMain.on('window-download-newfile', function (event) {
txtConsole.log('开始下载并解压更新文件 热更新');
event?.sender?.send('window-download-newfile-msg', winUpdateConfig.updateSteps);
const file = fs.createWriteStream(
path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'),
);
let downloadedBytes = 0;
let totalBytes = 0;
http.get(winUpdateConfig.updateFilePath, (response) => {
totalBytes = parseInt(response?.headers['content-length'], 10);
let prevTimestamp = Date.now();
response?.on('data', (chunk) => {
downloadedBytes += chunk.length;
const timestamp = Date.now();
const timeDiff = timestamp - prevTimestamp;
// 每1.5秒钟更新一次进度
if (timeDiff >= 1500) {
const progress = ((downloadedBytes / totalBytes) * 100).toFixed(2);
txtConsole.log(`下载进度:${progress}% `, totalBytes);
prevTimestamp = timestamp;
event?.sender?.send('window-download-progress-msg', Math.min(Number(progress), 80));
}
});
response?.pipe(file);
}).on('error', (err) => {
txtConsole.log(`下载错误: ${err.message}`);
event?.sender?.send('window-download-newfile-err-msg', '更新文件下载失败');
});
file?.on('finish', function () {
event.sender.send('window-download-progress-msg', 90);
winUpdateConfig.updateSteps[0]['active'] = 'success';
winUpdateConfig.updateSteps[1]['active'] = 'active';
event?.sender?.send('window-download-newfile-msg', winUpdateConfig.updateSteps);
// 文件已经完全写入磁盘,开始解压
try {
const zip = new AdmZip(path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'), void 0);
zip.extractAllTo(winUpdateConfig.localUpdateFilePath, true, void 0, void 0);
} catch (err) {
txtConsole.log('解压异常 error: ', err);
!event?.sender?.isDestroyed() &&
event?.sender?.send('window-download-newfile-err-msg', '解压异常');
return;
}
winUpdateConfig.updateSteps[1]['active'] = 'success';
winUpdateConfig.updateSteps[2]['active'] = 'active';
event?.sender?.send('window-download-newfile-msg', winUpdateConfig.updateSteps);
event?.sender?.send('window-download-progress-msg', 95);
const sourceDir = path.join(winUpdateConfig.localUpdateFilePath, 'apps');
const destPath = path.join(winUpdateConfig.localUpdateFilePath, 'app.asar');
asar.createPackage(sourceDir, destPath).then(() => {
if (fs.existsSync(path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'))) {
fs.unlinkSync(path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'));
}
txtConsole.log('更新完毕');
event.sender.send('window-download-progress-msg', 100);
winUpdateConfig.updateSteps[2]['active'] = 'success';
event?.sender?.send(
'window-download-newfile-msg',
winUpdateConfig.updateSteps,
'success',
);
//设置当前版本信息
try {
let latest = fs.readFileSync(winUpdateConfig.localUpdateVersionFilePath, 'utf-8');
latest = JSON.parse(latest);
latest.version = versionInfo.version;
fs.writeFileSync(winUpdateConfig.localUpdateVersionFilePath, JSON.stringify(latest));
txtConsole.log('更新后已设置当前版本信息', latest?.version);
//删除apps文件夹 防止执行文件夹内的代码
deleteFolderRecursive(sourceDir);
} catch (err) {
txtConsole.log(err);
}
}).catch((err) => {
txtConsole.log('创建asar文件失败: ', err);
event.sender.send('window-download-newfile-err-msg', 'asar文件创建失败');
});
});
file?.on('error', function (err) {
txtConsole.log('更新asar=>Error: ', err);
event.sender.send('window-download-newfile-err-msg', err);
});
});
}
//检查更新
function checkUpdate(callback) {
txtConsole.log('检查更新');
http.get(winUpdateConfig.updateVersionFilePath, (res) => {
res.on('data', (chunk) => {
versionInfo += chunk;
});
res.on('end', () => {
try {
if (versionInfo && versionInfo?.indexOf('404 Not Found') < 0) {
versionInfo = JSON.parse(versionInfo);
winUpdateConfig.updateFilePath = versionInfo.updateFilePath;
//热更最新信息
let asarVersionInfo = {
newVersionDesc: versionInfo.newVersionDesc,
currentVersion: winUpdateConfig.currentVersion,
};
versionInfo.currentVersion = winUpdateConfig.currentVersion;
let writeNewVersonInfo;
//不存在则创建latest文件
if (!fs.existsSync(winUpdateConfig.localUpdateVersionFilePath)) {
writeNewVersonInfo = versionInfo;
txtConsole.log('latest文件重新创建成功');
}
else {
let currentVersion = fs.readFileSync(
winUpdateConfig.localUpdateVersionFilePath,
'utf8',
);
currentVersion = JSON.parse(currentVersion);
currentVersion['updateFilePath'] = '';
//只覆盖热更版本信息
writeNewVersonInfo = {...currentVersion, ...asarVersionInfo};
}
//将整理好的配置文件信息写入
fs.writeFileSync(
winUpdateConfig.localUpdateVersionFilePath,
JSON.stringify(writeNewVersonInfo),
);
// txtConsole.log('已将新的更新配置文件信息写入:', JSON.stringify(writeNewVersonInfo));
txtConsole.log(
`更新检查完毕:最新版本:${versionInfo.version}, 当前版本:${asarVersionInfo.currentVersion}`,
);
txtConsole.log('-------------------------------------------------------');
callback?.(null, versionInfo);
}
else {
txtConsole.log('更新配置文件读取失败');
callback?.('更新配置文件读取失败');
}
} catch (err) {
txtConsole.log('更新配置文件覆写失败');
callback?.('更新配置文件覆写失败');
}
});
}).on('error', (error) => {
txtConsole.log(`更新配置文件下载失败: ${error.message}`);
callback?.('更新配置文件下载失败');
});
}
//删除更新文件
function deleteFolderRecursive(folderPath) {
if (fs.existsSync(folderPath)) {
fs.readdirSync(folderPath).forEach((file) => {
const curPath = path.join(folderPath, file);
if (fs.lstatSync(curPath).isDirectory()) {
// 递归删除子文件夹
deleteFolderRecursive(curPath);
}
else {
// 删除文件
fs.unlinkSync(curPath);
}
});
// 删除子文件夹后删除文件夹本身
fs.rmdirSync(folderPath);
}
}
app.whenReady().then(() => {
//初始化
appInit();
//检查更新
checkUpdate(async (check, versionInfo = {}) => {
if (check) {
txtConsole.log('检查更新执行失败');
}
else {
txtConsole.log('检查更新执行成功');
}
createWindow();
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
});
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
- 渲染进程侧代码(以原生为例):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
<button onclick="onCheckUpdate()">检查更新</button>
<button onclick="onUpdateVersion()">测试更新</button>
<div class="updateInfo"></div>
<div class="descInfo"></div>
<script>
const {ipcRenderer} = require("electron");
const onVersion = {
updateDsec: [],
process: 0,
};
//检查更新
function onCheckUpdate() {
ipcRenderer?.send('window-version');
ipcRenderer?.once('window-version-msg', (_, bool) => {
document.querySelector('.updateInfo').innerHTML = JSON.stringify(bool);
});
ipcRenderer?.once('window-version-err-msg', (_, err) => {
document.querySelector('.updateInfo').innerHTML = err;
});
}
//测试更新
function onUpdateVersion() {
onVersion.updateDsec = [];
ipcRenderer?.send('window-download-newfile');
//监听下载版本信息
ipcRenderer?.on('window-download-newfile-msg', (event, updateDsec, status) => {
if (!event.handel) {
event.handel = true;
onVersion.isStartUpdate = true;
onVersion.updateDsec = updateDsec;
document.querySelector('.descInfo').innerHTML = updateDsec.map(item => `<span style="color:${item.active === 'success' ? 'green' : 'orangered'}">${item.desc}</span>`).join('</br>');
if (status === 'success') {
ipcRenderer?.send('window-restart-app');
}
onVersion.visible = true;
}
});
//监听更新包下载进度
ipcRenderer?.on('window-download-progress-msg', (event, process) => {
if (!event.handel) {
event.handel = true;
document.querySelector('.updateInfo').innerHTML = String("完成进度:" + process + '%');
}
});
//监听下载版本错误信息
ipcRenderer?.once('window-download-newfile-err-msg', (event, res) => {
console.log(res)
});
}
</script>
</body>
</html>
- 轮子(实现压缩混淆反编译):
//生成 反编译app.asar 并生成压缩包
const asar = require('asar');
const path = require('path');
const fs = require('fs');
const fsExtra = require('fs-extra');
const zlib = require('zlib');
const archiver = require('archiver');
const uglify = require('uglify-js');
const moment = require('moment');
const {exec} = require('child_process');
const JavaScriptObfuscator = require('javascript-obfuscator');
const mainData = require('./mainData');
const startTime = moment().unix(); //秒级时间戳
const rootPath = path.resolve(__dirname); // 获取项目根路径
const asarPath = './build/win-ia32-unpacked/resources/app.asar'; // 获取 app.asar 文件路径
const sourceDir = './apps'; // 要压缩的文件夹路径
const asarAppPath = './apps/apps'; // asar反编译文件的存放路径
const buildPath = './build'; //electron 打包后的build文件夹
const destFile = './app.zip'; // 压缩后的文件路径
const publicLogPath = './public/log.txt';
const publicAsarPath = './public/app.asar';
// 配置环境路径为项目根路径
const env = Object.assign({}, process.env, {
PATH: rootPath + ';' + process.env.PATH,
npm_config_prefix: 'C:\\Program Files\\nodejs\\npm', // 这里是你的 npm 安装路径
});
const Console = {
log(p1 = '', p2 = '', p3 = '', p4 = '', p5 = '') {
console.log(`${moment().format('HH:mm:ss')} | ${p1}${p2}${p3}${p4}${p5}`);
},
};
//压缩主进程 main.js 相关代码
function zipMainJS() {
try {
const dir = path.resolve(asarAppPath, 'main.js');
const dirJs = fs.readFileSync(dir, 'utf8');
//压缩代码 mangle: true,
const result = uglify.minify(dirJs, {
mangle: {
toplevel: true,
},
});
// 混淆代码
const obfuscationResult = JavaScriptObfuscator.obfuscate(result.code, {
compact: true,
controlFlowFlattening: true,
controlFlowFlatteningThreshold: 0.75,
numbersToExpressions: true,
simplify: true,
shuffleStringArray: true,
splitStrings: true,
stringArrayThreshold: 0.75,
});
fs.writeFileSync(dir, obfuscationResult.getObfuscatedCode());
return true;
} catch (err) {
Console.log(err);
return false;
}
}
//添加开始执行 app.asar反编译逻辑
async function init() {
//执行app.asar 反编译、压缩、混淆
Console.log('正在执行app.asar 反编译、压缩、混淆');
// 将 app.asar 解压缩到指定文件夹中
asar.extractAll(asarPath, asarAppPath);
Console.log('正在压缩app文件夹到项目根目录');
//压缩main.js相关代码
let zipRes = zipMainJS();
if (!zipRes) {
Console.log('!!!压缩main.js主进程代码失败!');
return;
}
Console.log('主进程相关代码压缩完毕');
//再次生成 app.asar
asar.createPackage(asarAppPath, asarPath).then(() => {
Console.log('已再次生成 app.asar 文件(代码压缩后的asar文件)');
onAppZip();
});
}
function onAppZip() {
// 创建一个可写流,将压缩后的文件写入到目标文件中
const destStream = fs.createWriteStream(destFile);
// 创建一个 archiver 实例
const archive = archiver('zip', {
zlib: {level: zlib.constants.Z_BEST_COMPRESSION},
});
// 将可写流传递给 archiver 实例
archive.pipe(destStream);
// 将要压缩的文件夹添加到 archiver 实例中
archive.directory(sourceDir, false, null);
// 完成压缩并关闭可写流
archive.finalize();
// 监听可写流的 'close' 事件,表示压缩完成
destStream.on('close', () => {
Console.log(`压缩完毕,压缩包路径:【${path.resolve(__dirname, destFile)}】`);
Console.log('共用时:' + (moment().unix() - startTime) + '秒');
});
}
try {
if (mainData?.production === 'dev') {
throw "请将环境切换为生产环境 mainData.js => 【const production = 'pro';】";
}
if (mainData?.winControl === 'dev') {
throw '请关闭主窗口调试控制台!' + 'winControl';
}
fsExtra.removeSync(buildPath);
Console.log('已删除build文件夹内容');
fsExtra.removeSync(publicLogPath);
Console.log('已删除public/log.txt');
fsExtra.removeSync(publicAsarPath);
Console.log('已删除public/app.asar');
fsExtra.removeSync(asarAppPath);
Console.log('已删除apps');
//执行 打包命令
Console.log('正在执行【npm run packager32】命令');
exec('npm run packager32', env, (error, stdout, stderr) => {
if (error) {
Console.log(`执行出错: ${error}`);
return;
}
stderr && Console.log('【npm run packager32】 stderr=>', stderr);
//生成app.
init();
});
} catch (err) {
Console.log(err);
}
示例Demo: https://github.com/qglovehy/electron-updater.git