windows 平台 Electron 应用增量更新

您所在的位置:网站首页 远程更新app windows 平台 Electron 应用增量更新

windows 平台 Electron 应用增量更新

2024-04-03 02:04| 来源: 网络整理| 查看: 265

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 update.json 打包脚本

在 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, ` ${productName}.zip http://192.151.16.133:8080/Soft/${name.toLocaleUpperCase()}/${productName}.zip 检测到最新版本(${productName}.zip),请及时更新! `, { 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, ` ${productName}.exe http://192.112.61.133:8080/Soft/${name.toLocaleUpperCase()}/${productName}.exe 检测到最新版本(${productName}.exe),请及时更新! `, { 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();


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3