electron实现增量更新(文件断点续下载、windows提权、adm-zip解压文件、bat批处理语言、文件流读写)

前言

electron软件开发中,用的比较多的更新方式应该就是electron官方的electron-updater,利用打包生成的yml和dmg.zip、setup.exe文件进行下载安装更新,windows系统会进行重新安装,macos直接重启就可以。但是这种更新方式比较繁琐,更新频繁的话需要经常重新安装,极大的降低了用户体验,增量更新就解决了这一问题

思路

众所周知,electron框架给前端代码提供了一个Chromium浏览器引擎作为运行环境实现了与系统原生的交互,前端代码打包安装之后会被放置在软件包的resources文件夹中,通过配置打包选项可以实现对输出文件的控制(app.asar、app.asar.unpacked),要实现增量更新,需要从打包、生成yml文件、触发更新、下载文件、解压、移动文件等方面入手

一、打包

electron-builder打包除去系统相关文件(.pak、.dll等文件)外,默认会将web代码文件打包成app.asar文件,包括主进程、渲染进程的所有文件,应产品需求,需要实现刷新即更新,这就需要渲染进程和主进程的代码分开打包,在处理完文件替换后直接调用reload方法就可以实现更新,打包配置如下

 builderOptions: {
        appId: "appid",
        ///...
        afterAllArtifactBuild: "packed/index.js",
        // 增量更新 将web运行文件打包到app.asar.unpacked中 
        extraResources: [
          {
            from: "dist_electron/bundled",
            to: "app.asar.unpacked",
            //过滤掉主进程文件
            filter: [
              "!**/icons",
              "!**/preload.js",
              "!**/node_modules",
              "!**/background.js",
              "!**/electronImg",
            ],
          },
        ]
    }
二、处理打包文件    

windows系统无法对asar文件直接处理,下载文件无法下载文件夹,为了实现更新,需要在打包过后对asar文件和app.asar.unpacked文件进行修改、压缩处理,利用adm-zip对app.asar.unpacked文件夹压缩,配置builderOptions的afterPack周期函数

 builderOptions: {
        appId: "appid",
        ...
        afterPack: "packed/index.js",
}

新增packed/index.js

const path = require("path");
const crypto = require('crypto');

const AdmZip = require("adm-zip");
const version = require("../package.json").productVersion;
const fs = require('fs');
const yaml = require('js-yaml');
const isMac = process.platform === "darwin" ? "mac-" : "";
const processEnv = process.env.NODE_ENV;
let envFlag = ""
//判断环境确定打包路径
if(processEnv=="none"){
  envFlag = "-uat"
}
if(processEnv=="development"){
  envFlag = "-dev"
}


exports.default = async function (context) {
  console.log("开始拆包")
  let targetPath;

  if (process.platform === "darwin") {
    targetPath = path.join(
      context.outDir,
      `mac/${context.configuration.productName}.app/Contents/Resources`
    );
  } else {
    targetPath = path.join(context.outDir, "./win-unpacked", "./resources");
  }
//app.asar.unpacked文件路径
  const unpacked = path.join(targetPath, "./app.asar.unpacked");
  // fs.renameSync(unpacked,unpackedRename)
  var zip = new AdmZip();
//压缩 UNPACKED文件夹
  zip.addLocalFolder(unpacked);
  await zip.writeZip(path.join(context.outDir, "unpacked-" + isMac + version.split("-")[1] + ".zip"));
// 由于win下载时无法使用writeStream处理asar文件,先把app.asar修改格式为txt在进行压缩
  await creatAsar("app.asar", targetPath, "app-" + version.split("-")[0] + ".txt", context.outDir)
//计算两个压缩文件的大小  在更新下载、解压文件后对文件完整度进行判断;也可以通过计算文件哈希值来判断
  const unzipAsarBytes = fs.readFileSync(path.join(context.outDir, "app-" + version.split("-")[0] + ".txt")).length;
  // const unzipAsarBytes = await hashfile(path.join(context.outDir, "app-" + version.split("-")[0] + ".asar"))
// 整合文件信息 压缩前、压缩后的文件 生成yml文件
// 生成json文件再改名为yml文件
  const data = {
    name: 'tailan-yaml',
    version: version,
    asarUnzipName: "app-" + isMac + version.split("-")[0] + ".asar",
    asarName: "app-" + isMac + version.split("-")[0] + ".zip",
    asarBytes: fs.readFileSync(path.join(context.outDir, "app-" + isMac + version.split("-")[0] + ".zip")).length,
    unzipAsarBytes:unzipAsarBytes,
    unpackedUnzipName: "unpacked-" + isMac + version.split("-")[1],
    unpackedName: "unpacked-" + isMac + version.split("-")[1] + ".zip",
    unpackedBytes: fs.readFileSync(path.join(context.outDir, "unpacked-" + isMac + version.split("-")[1] + ".zip")).length,
    unzipUnpackedBytes:unzipUnpackedBytes,
  };

  const yamlStr = yaml.dump(data);
  const ymlName = process.platform === "darwin" ? "latestP-mac" : "latestP"
  fs.writeFileSync(path.join(context.outDir, ymlName + ".json"), yamlStr, 'utf8');
  fs.renameSync(path.join(context.outDir, ymlName + ".json"), path.join(context.outDir, ymlName + ".yml"))
};
function creatAsar(fromAsar, fromPath, toAsar, toPath) {
  return new Promise(resolve => {

    var filePathSource = path.join(fromPath, fromAsar)
    var filePathTarget = path.join(fromPath, 'test.txt')
    var toTxt = path.join(toPath,toAsar.slice(0, -4) + ".txt")
    // var filePathSourceNew = filePathSource.slice(0, -5) + ".txt";

    fs.rename(filePathSource, toTxt, function (err) {
      if (err) {
        console.log('ERROR: ' + err);
      } else {
        var asarZip = new AdmZip();
        asarZip.addLocalFile(toTxt);
        asarZip.writeZip(path.join(toPath, "app-" + isMac + version.split("-")[0] + ".zip"))
        resolve()
      }
    });
  })

}
//递归计算文件大小
function getFolderSize(dir) {
  let size = 0;
  fs.readdirSync(dir).forEach(file => {
    const filePath = path.join(dir, file);
    const stat = fs.statSync(filePath);
    if (stat.isDirectory()) {
      size += getFolderSize(filePath);
    } else {
      size += stat.size;
    }
  });
  return size;
}
三、触发更新

出发更新的方式有很多,socket发版通知、定时主动请求,

使用node的request模块请求线上yml文件资源,下载js-yaml依赖解析yml资源

const YAML = require("js-yaml");
const request = require("request");
//...
  startLoop() {
    if (this.progressNumber) return;
    if ((!this.provider) || !this.localVersion) {
      this.emit("get-version-failed", "版本号/地址不对");
    }
    try {
      request(encodeURI("***.yml"), (error, res, body) => {
        console.log("result")
        console.log(error)
        console.log(res)
        console.log(body)
        if (!res) {
          //"获取更新版本号失败"
          return
        }
        if (error || res.statusCode !== 200) {
         //"获取更新版本号失败"
         return
        }
        let result = YAML.load(body);
        //...版本号判断
        //触发文件下载
}

通过版本号计算规则来判断是否需要下载更新 

function compareVersion(version1, version2) {
  // 将版本号拆分成数字数组
  var arr1 = version1.split('.');
  var arr2 = version2.split('.');

  // 遍历数字数组进行逐段比较
  for (var i = 0; i < Math.max(arr1.length, arr2.length); i++) {
    var num1 = parseInt(arr1[i] || 0); // 如果数组长度不够,则将缺失部分补0
    var num2 = parseInt(arr2[i] || 0);

    if (num1 < num2) {
      return -1; // 版本1小于版本2
    } else if (num1 > num2) {
      return 1; // 版本1大于版本2
    }
  }

  return 0; // 版本1等于版本2
}
四、文件下载

文件下载实现了文件流写入、断点续下载功能,使用node的fs模块及request模块;文件流使用createWriteStream

import request from "request";
import fs from "fs";
let readStream = fs.createReadStream(backups);
let stream = fs.createWriteStream(downloadingFile);
readStream.pipe(stream)
readStream.on("end", () => {
    //..写入结束
})

 文件下载:request.pipe写入到fs.createWriteStream,配置request的headers.range参数,确定请求片段,本地如果有未下载完成的,writeStream的参数需要配置为追加

import request from "request";
import fs from "fs";
import fse from "fs-extra";
const path = require("path");
import elLog from "electron-log";
export default function(opt) {
  let {
    updateUrl,
    targetPath,
    fileName,
    reFileName,
    totalBytes,
    updateType,
  } = opt;
  elLog.info("opt=====");
  elLog.info(opt);
  return new Promise((resolve, reject) => {
    const targetFile = path.join(targetPath, fileName);
    try {
      if (fs.existsSync(targetFile)) {
        const fileInfo = fs.readFileSync(targetFile);
        if (fileInfo.length == totalBytes) {
          resolve(true);
          return;
        }
      }
    } catch (err) {}
    const downloadingFile = path.join(targetPath, "temp-" + fileName);
    const requestUrl = encodeURI(
      `${updateUrl}${fileName}?v=${new Date().getTime()}`
    );
    let headers = {};
    let stream = null;
    let readStream = null;
    let current = 0;
    let downloadSuccess = false;
    let realBytes = 0;
    let bytescha = 0;
    try {
      // 存在正在下载中的文件

      if (fs.existsSync(downloadingFile)) {
        const fileInfo = fs.readFileSync(downloadingFile);
        console.log("fileInfo");
        // console.log(fileInfo)
        if (fileInfo && fileInfo.length > 0 && fileInfo.length < totalBytes) {
          bytescha = totalBytes - fileInfo.length;
          current = fileInfo.length;
          headers = {
            range: `bytes=${fileInfo.length}-${totalBytes}`,//下载片段
          };
          stream = fs.createWriteStream(downloadingFile,{
            start:current,//写入起始位置
            flags:"r+"//追加
          });
        } else {
      if (stream) stream.end();
      headers = null;
          stream = null;
        }
      } else {
      if (stream) stream.end();
      headers = null;
        stream = null;
      }
    } catch (err) {
      if (stream) stream.end();
      headers = null;
      stream = null;
    }
    downloadRequest();

    function downloadRequest() {
        elLog.info("stream");
        elLog.info(stream);
      if (!stream) {
        try {
          current = 0;
          headers = null;
          stream = fs.createWriteStream(downloadingFile);
        } catch (err) {
          if (stream) stream.end();
          removeFile(downloadingFile);
          reject("创建文档流出错");
        }
      }
      const req = request(requestUrl, { headers });
      try {
        req.pipe(stream);
      } catch (err) {
        reject("数据流写入异常");
      }
      // 请求返回文件长度
      req.on("response", (data) => {
        realBytes = parseInt(data.headers["content-length"]);
        elLog.info("requestLength")
        elLog.info(realBytes)
        if (totalBytes != (realBytes+current)) {
          if (stream) stream.end();
          req.end();
          removeFile(downloadingFile);
          reject("文件下载出错");
        }
      });
      req.on("error", (err) => {
        if (stream) stream.end();
        removeFile(downloadingFile);
        reject("网络请求出错");
        elLog.error("downloadError");
        elLog.error(err);
      });
      req.on("data", (chunk) => {
        if (chunk && typeof chunk != "undefined") current += chunk.length;
      });
      req.on("end", () => {
        elLog.info("request=====end");
        elLog.info(req.response);
        if (req.response.statusCode === 200||req.response.statusCode===206) {
          if (current == totalBytes) {
            downloadSuccess = true;
          } else {
            // 文件下载不全
            downloadSuccess = false;
            if (stream) stream.end();
            removeFile(downloadingFile);
            reject("文件下载不全");
          }
        } else {
          downloadSuccess = false;
          if (stream) stream.end();
          removeFile(downloadingFile);
          reject("下载请求异常");
        }
      });
      req.on("close", () => {
        if (req.response.statusCode != 200&&req.response.statusCode != 206) {
          // 异常

          // elLog.info("下载请求异常")
          // elLog.info(req)
          if (stream) stream.end();
          removeFile(downloadingFile);
          reject("下载请求异常");
        }
      });

      stream.on("finish", () => {
        //最后一层 判断文件是否完整
        try {
          if (fs.existsSync(downloadingFile)) {
            let downloadedFileInfo = fs.readFileSync(downloadingFile);
            console.log("downloadedFileInfo.length");
            console.log(downloadedFileInfo.length);
            if (
              downloadedFileInfo &&
              typeof downloadedFileInfo != "undefined" &&
              downloadedFileInfo.length == totalBytes
            ) {
              try {
                if (stream) stream.end();
                fs.renameSync(downloadingFile, targetFile);
                resolve(true);
              } catch (err) {
                if (stream) stream.end();
                removeFile(downloadingFile);
                reject("");
                return;
              }
              // fs.rename(downloadingFile, targetFile, (err) => {
              //     if (err) {
              // removeFile(downloadingFile)
              // reject("");
              //         return;
              //     }
              //     resolve(true);
              // });
            } else {
              // 读写问题
              if (stream) stream.end();
              removeFile(downloadingFile);
              reject("读写文件异常");
            }
          }
        } catch (err) {
          if (stream) stream.end();
          removeFile(downloadingFile);
          reject("读写文件异常");
        }
      });
      
    }
    // const request
  });
}
function removeFile(targetPath) {
  try {
    if (fs.existsSync(targetPath)) {
      fse.removeSync(targetPath);
    }
  } catch (error) {
    console.log(error);
  }
}
五、文件解压

利用adm-zip依赖实现文件解压,windows系统无法解压asar.zip文件,所以需要在打包的钩子函数改名成txt文件,使用fs.rename即可

function unzip(filePath, fileName, unzipPath) {
    return new Promise((resolve, reject) => {
        //要解压的文件路径
        const unzip = new admZip(path.join(filePath, fileName));
        try {
            // unzipPath 要解压到的文件夹路径
            unzip.extractAllToAsync(unzipPath, true, (err) => {
                if (err) {
                    elLog.error("unzipErr==start");
                    elLog.error("filePath:" + filePath);
                    elLog.error("fileName:" + fileName);
                    elLog.error("unzipPath:" + unzipPath);
                    elLog.error("err:" + err);
                    elLog.error("unzipErr==end");
                    reject(err)
                    return;
                }
                removeFile(path.join(filePath, fileName))
                resolve(null)
            })
        } catch (err) {
            if(fs.existsSync(path.join(filePath, fileName))){
                removeFile(path.join(filePath, fileName))

            }
                reject(err)
        }


    })
}
六、文件替换(备份、删除、移动)

文件删除、移动在mac系统中直接用node的fs模块即可,一般情况windows直接使用fs也可以实现,如果用户将你的程序安装到c盘或者需要管理员操作权限的文件夹内则需要执行提权脚本来处理这些文件,asar文件不论需不需要权限都只能使用脚本处理,windows系统无法处理asar文件

mac处理文件及windows处理非asar文件

function macRename(opt) {
  const { resourcesPath, downloadLocalPath, fromName, toName } = opt;
  return new Promise((resolve, reject) => {
    if (fs.existsSync(path.join(resourcesPath, "old-" + toName))) {
      removeFile(path.join(resourcesPath, "old-" + toName))
    }
    if (!fs.existsSync(path.join(downloadLocalPath, fromName))) {
      reject();
    }
    fs.rename(path.join(resourcesPath, toName), path.join(resourcesPath, "old-" + toName), async err => {
      if (err) {
        elLog.info("renameerr")
        elLog.info(err)
        reject()
      } else {
        fs.rename(path.join(downloadLocalPath, fromName), path.join(resourcesPath, toName), err => {
          if (err) {
            elLog.info("renameunpacked")
            elLog.info(err)
            // 异常处理
            fs.rename(path.join(resourcesPath, "old-" + toName), path.join(resourcesPath, toName))
            reject()
          } else {
            resolve()
            // 刷新页面
          }
        })

      }
    })
  })
}

windows bat脚本代码 处理asar文件1-5分别对应脚本运行的参数

1目标路径;2更新文件存放路径;3更新文件名;4:目标文件名;5运行程序名.exe;6运行程序路径.exe

@echo off
taskkill /f /im %5
timeout /T 1 /NOBREAK
del %1\old-%4
ren %1\%4 old-%4
ren %2\%3 %4
move %2\%4 %1
%6

该方法只能处理单文件,处理文件夹需要使用rd

@echo off
taskkill /f /im %5
timeout /T 1 /NOBREAK
rd /s /q %1\old-%4
ren %1\%4 old-%4
ren %2\%3 %4
move %2\%4 %1
%6

提权使用的是sudo-prompt依赖,脚本存放在线上,更新前先下载脚本然后使用sudo-prompt运行

sudo.js

var sudo = require('sudo-prompt')
var options = {
  name: 'Electron',
}

export default (shell) => {
  return new Promise((resolve, reject) => {
    sudo.exec(shell, options, function(error, stdout, stderr) {
      if (error) {
        reject(error)
        console.log('error:' + error)
        return
      }
      resolve(stdout)
      console.log('stdout: ' + stdout)
    })
  })
}

调用sudo.js

import downloadFile from './downloadFile'
import sudoPrompt from './sudoPrompt'
const path = require('path')
//调用方法下载线上的bat资源
await downloadFile({
            url: batUpdateUrl + updateExeName,
            targetPath: data.downloadLocalPath
          })
//参数为脚本执行路径,脚本参数空格分隔
sudoPrompt(
     `"${path.join(data.downloadLocalPath,'./' + updateExeName)}" "${resourcesPath}" "${data.downloadLocalPath}" "${fromAsarName}" "${toAsarName}" "${fromUnpackedName}" "${toUnpackedName}" "${exeName}" "${exePath}"`
            )
七、其他

1、app.whenReady内调用更新方法,用户不会感知到重启,如果只修改unpacked文件加,则不需要重启,browserWindow.loadURL()或者页面的reload方法都可以直接运行最新文件

2、unzip.extractAllToAsync解压文件如果不用异步方法,会严重阻塞进程

3、如果你想一步解决文件写入权限问题,builderOptions的win配置项有个requestedExecutionLevel属性,可以设置软件的打开权限,但也同样会出现文件无法拖拽入软件内的问题

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值