windows 平台 Electron 增量更新
技术栈
Quasar + Electron(11.5.0) + electron-builder(23.6.0)
Electron 打包模式
-
asar 模式
- 打包后业务相关文件归档到 app.asar 文件中
- 安装时写入文件少,安装速度快
- app.asar 文件在程序运行时无法替换更新
-
非 asar 模式
- 打包后文件单独存在,不归档到 app.asar 文件中
- 安装时写入文件多,安装速度慢
- 程序运行时支持直接替换文件更新程序
如何实现一个理想的增量更新功能?
在不影响全量安装的前提下实现增量更新。首先我们来分析导致全量安装慢的原因,主要就一点文件过多,写入时间过长。 解决这一问题只需要减少安装时写入的文件数量即可。想要减少文件数量就得使用 asar 模式,但要如何解决该模式下资源文件 无法替换的问题,能否将两种模式进行整合实现。首先我们来分析下 Electron 应用的加载流程。
- 启动程序
- 通过 package.json 的 main 字段获取入口文件(此处为 electron-main.js)
- 执行入口文件 electron-main.js
- 创建应用窗口,加载 app.asar 下的 web 页面。
由上述流程看,我们可以有下面两种方式实现增量更新需求。由于方案二更易于实现,选择方案二。
- 我们可以直接修改 package.json 中入口文件的地址就可以实现增量更新功能,但是会发现一个问题,入口文件中引用文件 的路径也需要同步变更才能满足这一需要(实现困难)。
- 替换 package.json 和 electron-main.js 文件,并将 electron-main.js 中的 web 地址指向新的 app.asar(实现简单)
方案具体如何实现?
- 打包配置
// quasar.config.js
// 下面只提供了 electron-builder 打包相关配置
module.exports = function(/* ctx */) {
return {
// ... 其他
electron: {
bundler: "builder",
builder: {
win: {
target: "nsis",
icon: "public/images/logo512.png",
requestedExecutionLevel: "highestAvailable"
},
nsis: {
oneClick: true,
allowToChangeInstallationDirectory: false,
artifactName: "${productName}.${ext}",
installerIcon: "public/images/logo.ico",
shortcutName: "Electron App"
},
buildDependenciesFromSource: true,
// 该配置会将这两个文件从 app.asar 中解包出来,如此我们便可以替换这两个文件
asarUnpack : ["electron-main.js", "package.json"],
},
nodeIntegration: true,
extendWebpack(/* cfg */) {}
}
};
};
- App 启动时检测、下载、更新、重启
// electron-main.js
import { app, BrowserWindow } from "electron";
import { checkApp } from "./CheckApp";
let mainWindow;
// 检测 app 是否需要更新
function checkAppHandle() {
const appUrl = getAppUrl();
// 开发环境不做检测更新
if (process.env.NODE_ENV === "development") return createWindow(appUrl);
let win = null;
checkApp({
// 开始执行更新
start: () => {
// 创建一个加载动画(根据自己的需要添加-不需要可以删掉)
win = new BrowserWindow({
width: 300,
height: 160,
setSkipTaskbar: true,
alwaysOnTop: true,
resizable: false,
frame: false,
title: "",
webPreferences: {
devTools: false,
nodeIntegration: true,
}
})
const url = process.env.NODE_ENV === "development" ?
path.join(process.cwd(), "public/update.html") :
appUrl.replace("index.html", "update.html");
win.loadURL(url);
},
// 升级结束(成功|失败)
end: version => {
// 更新结束
if (version) { // 成功
app.relaunch();
return app.exit(0);
}
if (win) {
win.on('closed', () => { win = null; createWindow(appUrl); });
return win.close();
}
createWindow(appUrl);
}
});
}
// 删除上一增量更新版本
function delFiles(directory) {
try {
if (fs.existsSync(directory)) {
fs.readdirSync(directory).forEach((file, index) => {
let currentPath = path.join(directory, file);
if (fs.lstatSync(currentPath).isDirectory()) {
delFiles(currentPath);
} else {
fs.unlinkSync(currentPath);
}
});
fs.rmdirSync(directory);
}
} catch (e) {}
}
// 根据 app.json 文件得到当前程序 web 页面地址
function getAppUrl() {
try {
const resourcesDir = path.join(process.cwd(), "./resources");
const jsonFile = path.join(resourcesDir, "./app.json");
if (!fs.existsSync(jsonFile)) return process.env.APP_URL;
const json = fs.readFileSync(jsonFile);
if (!json) return process.env.APP_URL;
const { name, unlink, origin } = JSON.parse(json);
if (unlink) {
const directory = path.join(resourcesDir, `./${unlink}`);
fs.unlinkSync(path.join(directory, './app.asar'));
delFiles(directory);
}
const appDir = path.join(resourcesDir, `./${name}/app.asar`);
if (fs.existsSync(appDir)) return path.join(appDir, "./index.html");
return process.env.APP_URL;
} catch (e) {
return process.env.APP_URL;
}
}
// 创建APP窗口
function createWindow(url) {
mainWindow = new BrowserWindow({
width: 1366,
height: 800,
webPreferences: {
enableRemoteModule: true,
nodeIntegration: true,
plugins: true
},
});
mainWindow.loadURL(url);
mainWindow.on("closed", () => mainWindow = null);
mainWindow.webContents.on("crashed", function (event, killed) {
app.relaunch();
app.exit();
});
}
app.on("ready", checkAppHandle);
app.on("activate", () => {
if (mainWindow === null) checkAppHandle();
});
// CheckApp.js
const fs = require("fs");
const fsExtra = require("fs-extra");
const path = require("path");
const http = require('http');
const electron = require("electron");
const app = electron.app || electron.remote?.app;
class CheckApp {
baseUrl = "";
remoteVersion = "";
localVersion = app.getName(0);
resourcesDir = "";
log = msg => console.log(msg);
start = () => {}; // 开始升级
end = () => {}; // 更新结果回调
constructor({ log, start, end }) {
this.log = log; // 日志记录
this.remoteVersion = "";
this.end = end || (() => {});
this.start = start || (() => {});
// 远程服务器上的程序更新目录-用于放置新的增量更新包
this.baseUrl = `http://192.168.1.111:10020/app/programs`;
// app 的资源文件目录
this.resourcesDir = path.join(process.cwd(), "./resources");
}
/*
* 获取远程版本号
* 检测服务端的 update.json 文件中的版本号
* */
getRemoteVersion() {
return new Promise(resolve => {
const getUrl = `${this.baseUrl}/update.json`;
this.log(`检测服务端APP版本,URL=${getUrl}`);
http.get(getUrl, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
if (res.statusCode === 200 && data && data.indexOf("productName") > -1) {
data = JSON.parse(data);
this.log(`本地APP版本=${this.localVersion}`);
this.log(`服务端APP版本=${data.productName}`);
const value = this.compareVersion(data.productName, this.localVersion);
const version = value > 0 ? data.productName : "";
this.remoteVersion = version;
return resolve(version);
}
resolve("");
});
}).on("error", (err) => {
this.log(`远程版本获取失败:${err}`);
this.remoteVersion = "";
resolve("");
});
})
}
/* 检测是否有新版本 */
checkVersion() {
return new Promise(resolve => {
try {
if (this.remoteVersion) {
this.log(`本地APP版本=${this.localVersion}`);
this.log(`服务端APP版本=${this.remoteVersion}`);
const value = this.compareVersion(this.remoteVersion, this.localVersion);
return resolve(value > 0 ? this.remoteVersion : "");
}
this.getRemoteVersion().then(version => {
const value = this.compareVersion(version, this.localVersion);
resolve(value > 0 ? version : "");
});
} catch (e) {
this.log(`checkVersion执行异常=${e}`);
resolve("");
}
})
}
/* 比较版本大小 v1:远程版本 v2:本地版本 */
compareVersion(ver1, ver2) {
try {
if (!ver1 || !ver2) return 0;
if (ver1 === ver2) return 0;
// 开发环境不下载包
if (ver2 === "Electron") return 0;
let v1 = ver1.split('.').map(Number);
let v2 = ver2.split('.').map(Number);
for(let i = 0; i < v1.length || i < v2.length; i++) {
let part1 = v1[i] || 0;
let part2 = v2[i] || 0;
if(part1 > part2) return 1; // 如果第一个版本的一部分比第二个大
if(part1 < part2) return -1; // 反之
}
} catch (e) {
this.log(`compareVersion执行异常=${e}`);
return 0;
}
}
/* 下载 */
downLoadFunc() {
return new Promise(async resolve => {
try {
const zipPath = path.join(this.resourcesDir, `${this.remoteVersion}.zip`);
if (fs.existsSync(zipPath)) return resolve(true);
const downPath = `${this.baseUrl}/${this.remoteVersion}.zip`;
const Downloader = require("nodejs-file-downloader");
this.log(`版本下载${downPath}=>${this.resourcesDir}`);
const downloader = new Downloader({ url: downPath, directory: this.resourcesDir });
await downloader.download()
this.log(`${this.remoteVersion}版本下载完成`);
resolve(true);
} catch (e) {
this.log(`downLoadFunc执行异常=${e}`);
resolve(false);
}
});
}
/* 获取应用更新目录-以版本号命名 */
getAppDirName() {
return new Promise(resolve => {
try {
const jsonFile = path.join(this.resourcesDir, "./app.json");
let appDirName = this.remoteVersion;
if (fs.existsSync(jsonFile)) {
let json= fs.readFileSync(jsonFile);
if (json) {
json = JSON.parse(json);
appDirName = json.name === this.remoteVersion ? `${this.remoteVersion}_1` : this.remoteVersion;
}
}
resolve(appDirName);
} catch (e) {
this.log(`getAppDirName执行异常=${e}`);
resolve("");
}
})
}
/* 解压 */
extractFunc(appDir) {
return new Promise(async resolve => {
try {
const zipPath = path.join(this.resourcesDir, `${this.remoteVersion}.zip`);
const AdmZip = require("adm-zip");
const targetDir = path.join(this.resourcesDir, `./${appDir}`);
this.log(`版本解压${zipPath}->${targetDir}`);
const { extractAllTo } = new AdmZip(zipPath);
await extractAllTo(targetDir, true);
this.log(`${this.remoteVersion}版本解压完成`);
resolve(true);
} catch (e) {
this.log(`extractFunc执行异常=${e}`);
resolve(false);
}
})
}
/* 拷贝替换资源文件包 */
copyMainFile(appDir) {
return new Promise(resolve => {
try {
const unPack = path.join(this.resourcesDir, `./${appDir}/app.asar.unpacked`);
const unPackTar = path.join(this.resourcesDir, `./app.asar.unpacked`);
const yml = path.join(this.resourcesDir, `./${appDir}/app-update.yml`);
const ymlTarget = path.join(this.resourcesDir, `./app-update.yml`);
const elevate = path.join(this.resourcesDir, `./${appDir}/elevate.exe`);
const elevateTarget = path.join(this.resourcesDir, `./elevate.exe`);
// 将安装生成的主进程文件备份到当前文件夹
fsExtra.copySync(unPack, unPackTar, { overwrite: true })
fs.copyFileSync(yml, ymlTarget);
fs.copyFileSync(elevate, elevateTarget);
resolve(true);
} catch (e) {
this.log(`copyMainFile执行异常=${e}`);
resolve(false);
}
})
}
/* 删除压缩包 */
delFunc() {
return new Promise(resolve => {
try {
const zipPath = path.join(this.resourcesDir, `${this.remoteVersion}.zip`);
let execCmd = `del/f/s/q ${zipPath}`;
this.log(`删除zip包`);
require("child_process").execSync(execCmd);
resolve(true);
} catch (e) {
this.log(`delFunc执行异常=${e}`);
resolve(false);
}
})
}
/*
* 更新 app.json
* name: 新版本号
* unlink: 更新前版本号(不包括全量更新版本)
* origin:最新一次全量更新版本
* */
updateAppJson(appDir) {
return new Promise(resolve => {
try {
const jsonFile = path.join(this.resourcesDir, "./app.json");
if (!fs.existsSync(jsonFile)) {
fs.writeFileSync(jsonFile, JSON.stringify({ name: appDir, unlink: "", origin: app.getName(0) }));
resolve(true);
} else {
const json = fs.readFileSync(jsonFile);
if (json) {
const data = JSON.parse(json);
if (data.name !== appDir) {
fs.writeFileSync(jsonFile, JSON.stringify({name: appDir, unlink: data.name, origin: data.origin }));
}
resolve(true);
} else {
fs.writeFileSync(jsonFile, JSON.stringify({ name: appDir, unlink: "", origin: app.getName(0) }));
resolve(true);
}
}
} catch (e) {
this.log(`updateAppJson执行异常=${e}`);
resolve(false);
}
})
}
/* 更新 */
async update(newVersion) {
try {
this.log(`执行增量更新${newVersion}`);
const down = await this.downLoadFunc()
if (!down) return this.end();
const appDir = await this.getAppDirName();
if (!appDir) return this.end();
const extract = await this.extractFunc(appDir);
if (!extract) return this.end();
const copy = await this.copyMainFile(appDir);
if (!copy) return this.end();
const upJson = await this.updateAppJson(appDir);
if (!upJson) return this.end();
const del = await this.delFunc();
if (!del) return this.end();
this.log(`增量更新完成,更新后版本${newVersion}`);
this.end(newVersion)
} catch (e) {
this.log(`update执行异常=${e}`);
this.end()
}
}
}
export const checkApp = opts => {
const checkApp = new CheckApp(opts);
checkApp.checkVersion().then(version => {
if (!version && checkApp.end) return checkApp.end();
checkApp.start && checkApp.start(version);
checkApp.update(version);
});
}
更新文件说明
-
update.json
- 需要升级的版本内容,放置在服务器上,app 启动时检测该文件中版本。
{ "productName": "1.2.07" } -
app.json
- 应用更新完成时创建
- 程序启动时会检测这个文件中的 name 字段,并根据该字段修改 web 网页地址为新地址
- name: 更新后的版本目录
- unlink: 更新前的版本目录
- origin: 最后一次全量更新的版本
{ "name": "1.2.07", "unlink": "1.2.06", "origin": "1.2.01" } -
增量更新文件目录
- 1.2.07.zip 资源压缩包(目前的方案中升级包必须有这些文件,你也可以根据自己的需求进行优化
- app.asar.unpacked
- electron-main.js
- package.json
- app.asar
- app-update.yml
- elevate.exe
- app.asar.unpacked
- update.json
- 1.2.07.zip 资源压缩包(目前的方案中升级包必须有这些文件,你也可以根据自己的需求进行优化
打包脚本
在 package.json 同级目录下创建一个 zip.js 文件用于压缩全量更新、增量更新包。
{
"build": "quasar build -m electron -A ia32",
"zip": "node zip.js",
"build:zip": "quasar build -m electron -A ia32 && npm run zip",
}
const fs = require("fs");
const AdmZip = require("adm-zip");
const path = require("path");
const { name, productName } = require("./package.json");
const appPath = path.join(__dirname, `dist/${productName}`);
const zipPath = path.resolve(appPath, "./zip");
const zipDir = path.join(zipPath, `./${productName}`);
require("child_process").exec(`rd /s /q ${appPath}`);
if (!fs.existsSync(appPath)) fs.mkdirSync(appPath);
if (!fs.existsSync(zipPath)) fs.mkdirSync(zipPath);
if (!fs.existsSync(zipDir)) fs.mkdirSync(zipDir);
zipPartPack();
zipInstallPack();
require("child_process").exec(`rd /s /q ${zipPath}`);
console.log("SUCCESS!!!");
function zipPartPack() {
const resources = path.join(__dirname, `./dist/electron/Packaged/win-ia32-unpacked/resources`);
let zip = new AdmZip();
zip.addLocalFolder(resources);
const zipFile = path.join(zipDir, `./${productName}.zip`);
zip.writeZip(zipFile);
const upJson = path.join(zipDir, `./update.json`);
fs.writeFileSync(upJson, JSON.stringify({ productName }), {encoding: "UTF-8"});
const admZip = new AdmZip();
admZip.addLocalFolder(zipPath)
admZip.writeZip(path.join(appPath, `./${productName}.zip`));
require("child_process").exec(`rd /s /q ${zipDir}`);
console.info("incremental package zip success!!!");
}
function zipInstallPack() {
const dir = path.join(__dirname, "./dist/electron/Packaged")
const exe = path.join(dir, `${productName}.exe`);
const map = path.join(dir, `${productName}.exe.blockmap`);
const yml = path.join(dir, `latest.yml`);
if (!fs.existsSync(zipDir)) fs.mkdirSync(zipDir);
fs.copyFileSync(exe, path.join(zipDir, path.basename(exe)));
fs.copyFileSync(map, path.join(zipDir, path.basename(map)));
fs.copyFileSync(yml, path.join(zipDir, path.basename(yml)));
const zip = new AdmZip();
zip.addLocalFolder(zipPath)
zip.writeZip(path.join(appPath, `./${productName}.installer.zip`));
console.info("installer zip success!!!");
}
amd-zip 压缩文件过大会失败,使用 archiver 代替
// zip.js
const fs = require('fs');
const path = require('path');
const { name, productName } = require("./package.json");
/* 压缩函数 */
const zip = (zipName, fileList = []) => {
return new Promise(resolve => {
const archiver = require('archiver');
const output = fs.createWriteStream(zipName);
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
});
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', function() {
console.log(archive.pointer() + ' total bytes');
console.log('archiver has been finalized and the output file descriptor has closed.');
resolve(true);
});
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', function() {
console.log('Data has been drained');
});
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function(err) {
if (err.code === 'ENOENT') {
// log warning
} else {
// throw error
throw err;
}
});
// good practice to catch this error explicitly
archive.on('error', function(err) {
throw err;
});
// pipe archive data to the file
archive.pipe(output);
for (let file of fileList) {
if (fs.statSync(file).isFile()) {
archive.file(file, { name: path.basename(file) });
} else {
archive.directory(file, false);
}
}
// finalize the archive (ie we are done appending files but streams have to finish yet)
// 'close', 'end' or 'finish' may be fired right after calling this method so register to them beforehand
archive.finalize();
})
}
/* 压缩增量包 */
const zipPartPack = async (versionDir, zipDir) => {
const zipDirVersion = path.join(zipDir, `./${productName}`);
// 创建目录
if (!fs.existsSync(versionDir)) fs.mkdirSync(versionDir);
if (!fs.existsSync(zipDir)) fs.mkdirSync(zipDir);
if (!fs.existsSync(zipDirVersion)) fs.mkdirSync(zipDirVersion);
// 第一次 压缩应用增量更新资源(dist/zip/1.1.00/1.1.00.zip)
const zipFirstName = path.join(zipDirVersion, `./${productName}.zip`);
const resources = path.join(__dirname, `./dist/electron/Packaged/win-ia32-unpacked/resources`);
await zip(zipFirstName, [resources]);
console.log('First compression completed');
// 写入升级版本检测文件
const upJson = path.join(zipDirVersion, `./update.json`);
fs.writeFileSync(upJson, JSON.stringify({ productName }), { encoding: "UTF-8" });
// 写入运维升级 XML 文件
const xml = path.join(zipDirVersion, `./update_app_info.xml`);
fs.writeFileSync(xml, `<?xml version="1.0" encoding="utf-8"?><info>
<version>${productName}.zip</version>
<url>http://192.151.16.133:8080/Soft/${name.toLocaleUpperCase()}/${productName}.zip</url>
<description>检测到最新版本(${productName}.zip),请及时更新!</description>
<patchVersion></patchVersion><patchUrl></patchUrl><patchDescription></patchDescription></info>`,
{ encoding: "UTF-8" });
// 第二次压缩,将升级资源包和升级相关文件再次压缩(dist/1.1.00/1.1.00.zip)
const zipName = path.join(versionDir, `./${productName}.zip`);
await zip(zipName, [zipDir]);
console.log("Second compression completed");
}
/* 压缩全量包 */
const zipInstallPack = async (versionDir, zipDir) => {
const zipDirVersion = path.join(zipDir, `./${productName}`);
// 创建目录
if (!fs.existsSync(versionDir)) fs.mkdirSync(versionDir);
if (!fs.existsSync(zipDir)) fs.mkdirSync(zipDir);
if (!fs.existsSync(zipDirVersion)) fs.mkdirSync(zipDirVersion);
// 拷贝需要压缩的资源包到指定目录
const packaged = path.join(__dirname, "./dist/electron/Packaged");
const fileList = [`${productName}.exe`, `${productName}.exe.blockmap`, "latest.yml"];
for (let file of fileList) {
const filePath = path.join(packaged, file);
const fileTarget = path.join(zipDirVersion, file);
fs.copyFileSync(filePath, fileTarget);
}
// 写入运维升级 XML 文件
const xml = path.join(zipDirVersion, `./update_app_info.xml`);
fs.writeFileSync(xml, `<?xml version="1.0" encoding="utf-8"?><info>
<version>${productName}.exe</version>
<url>http://192.112.61.133:8080/Soft/${name.toLocaleUpperCase()}/${productName}.exe</url>
<description>检测到最新版本(${productName}.exe),请及时更新!</description>
<patchVersion></patchVersion><patchUrl></patchUrl><patchDescription></patchDescription></info>`,
{ encoding: "UTF-8" });
// 压缩全量安装包
const zipName = path.join(versionDir, `./${productName}.installer.zip`);
await zip(zipName, [zipDirVersion]);
console.log("Installer compression completed");
}
/* 删除临时文件夹 */
const removePack = (dirList = []) => {
for (let dir of dirList) {
if (fs.existsSync(dir)) {
require("child_process").exec(`rd /s /q ${dir}`);
}
}
}
const start = async () => {
// 最终压缩包目录
const versionDir = path.join(__dirname, `dist/${productName}`);
// 临时压缩目录
const folderZip = path.join(__dirname, `dist/zip`);
const folderZip2 = path.join(__dirname, `dist/zipInstall`);
removePack([versionDir]);
await zipPartPack(versionDir, folderZip);
await zipInstallPack(versionDir, folderZip2);
removePack([folderZip, folderZip2]);
}
start().then();