windows 平台 Electron 应用增量更新

953 阅读5分钟

windows 平台 Electron 增量更新

技术栈

Quasar + Electron(11.5.0) + electron-builder(23.6.0)

Electron 打包模式

  • asar 模式

    • 打包后业务相关文件归档到 app.asar 文件中
    • 安装时写入文件少,安装速度快
    • app.asar 文件在程序运行时无法替换更新
  • 非 asar 模式

    • 打包后文件单独存在,不归档到 app.asar 文件中
    • 安装时写入文件多,安装速度慢
    • 程序运行时支持直接替换文件更新程序

如何实现一个理想的增量更新功能?

在不影响全量安装的前提下实现增量更新。首先我们来分析导致全量安装慢的原因,主要就一点文件过多,写入时间过长。 解决这一问题只需要减少安装时写入的文件数量即可。想要减少文件数量就得使用 asar 模式,但要如何解决该模式下资源文件 无法替换的问题,能否将两种模式进行整合实现。首先我们来分析下 Electron 应用的加载流程。

  1. 启动程序
  2. 通过 package.json 的 main 字段获取入口文件(此处为 electron-main.js)
  3. 执行入口文件 electron-main.js
  4. 创建应用窗口,加载 app.asar 下的 web 页面。

由上述流程看,我们可以有下面两种方式实现增量更新需求。由于方案二更易于实现,选择方案二。

  1. 我们可以直接修改 package.json 中入口文件的地址就可以实现增量更新功能,但是会发现一个问题,入口文件中引用文件 的路径也需要同步变更才能满足这一需要(实现困难)。
  2. 替换 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, `<?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();