electron中实现热更新替换app.asar

3/5/2021 JavaScript

# 原理

electron-builder 在指定 asar 为 ture 时,打包的时候会生成 app.asar 的文件,即项目的业务代码。在 electron6 之前还会一并的将 electron.asar 一同的放在 process.resoucePaths 下。我们要做的就是替换旧版本的 app.asar 步骤:

  1. 根据版本号等业务配置,下载最新的 app.asar 文件到 process.resourcePaths 下
  2. mac 下直接用 Node.js 或者 bash 替换原有的 app.asar
  3. window 下,通过 Node.js 的 child_process.spawn 唤起一个 cmd.exe 进程,执行一个替换文件的.bat 脚本

# mac

在启动的时候,根据当前的版本号下载最新的 app.asar,然后用 Node.js 替换这个文件

# window

因为 electron 在运行时,会读取当前的 app.asar 文件。文件的状态是 EBUSY,所以这里不能用 Node.js 去替换。改用外挂程序的方式。 目前设想:

用一个 bat 的批处理脚本,在合适的时机去运行。脚本的作用就是替换原有的 app.asar

# 代码实现

import path from "path";
import fs from "fs";
import axios from "axios";
import { app, dialog, shell } from "electron";
import { AppUpdater } from "electron-updater";
import log from "electron-log";
import AdmZip from "adm-zip";
import child_process from "child_process";

import { requestHeaders, feedPrefix } from "./constants";

const logInfo = log.info;
const logError = log.error;

/* 热更新 */
class HotUpdater {
  RESOURCES_PATH = process.resourcesPath;
  APP_ASAR_FILE = "app.asar"; // 业务源文件
  HOT_UPDATE_BIN = "hot-update.bin"; // 热更新的文件
  HOT_UPDATE_ASAR = "hot-update.asar"; // 热更新的文件
  BAT_FILE = "mv.bat"; // windows下的bat脚本

  AppAsarPath = path.resolve(this.RESOURCES_PATH, this.APP_ASAR_FILE);
  HotUpdateBinPath = path.resolve(this.RESOURCES_PATH, this.HOT_UPDATE_BIN);
  HotUpdateAsarPath = path.resolve(this.RESOURCES_PATH, this.HOT_UPDATE_ASAR);
  BatFilePath = path.resolve(this.RESOURCES_PATH, this.BAT_FILE);

  patchDownloadUrl = `${feedPrefix}/ximalaya-pc-updater/api/v1/update/hot/download`;
  // patchDownloadUrl = `${feedPrefix}/api/v1/update/hot/download`; // for debug =====

  CAN_HOT_UPDATE = false; // 是否需要热更新,默认是不需要的

  constructor() {}

  /* 下载补丁文件 */
  async downloadPatchFile(patchDownloadUrl: string = this.patchDownloadUrl) {
    try {
      logInfo("start download patch file");
      const response = await axios.get(patchDownloadUrl, {
        responseType: "stream",
        headers: {
          ...requestHeaders,
        },
      });

      if (
        response.status === 200 &&
        response.headers["content-type"].includes("application/octet-stream")
      ) {
        /* 确保patch文件是最新的 */
        if (fs.existsSync(this.HotUpdateBinPath)) {
          fs.unlinkSync(this.HotUpdateBinPath);
        }
        const writerStream = fs.createWriteStream(this.HotUpdateBinPath);
        response.data.pipe(writerStream);
        response.data.on("close", () => {
          this.unZipPathFile();
          Promise.resolve("unZip success");
          logInfo("hot-update.bin download success: ", this.HotUpdateBinPath);
        });
        response.data.on("error", () => {
          Promise.reject("unZip error");
          logError("hot-update.bin download error: ", this.HotUpdateBinPath);
        });
      }
    } catch (error) {
      logError("downloadPatchFile error: ", error);
    }
  }

  /* 在resourcesPath在解压app.asar */
  unZipPathFile(
    filePath: string = this.HotUpdateBinPath,
    targetPath: string = this.RESOURCES_PATH
  ) {
    try {
      log.info("unZipPathFile: ", filePath);
      const zip = new AdmZip(filePath);

      zip.extractEntryTo(
        "./app.asar",
        targetPath,
        false,
        true,
        "hot-update.asar"
      );
      this.CAN_HOT_UPDATE = true;

      /* 解压提示 */
      switch (process.platform) {
        case "darwin":
          hotUpdater.mvInMac();
          break;

        case "win32":
          this.successDialog().then((res) => {
            this.mkBatFile();
            shell.openPath(this.BatFilePath);
            setTimeout(() => {
              app.quit();
            }, 1000);
          });
          break;

        default:
          break;
      }
    } catch (error) {
      logError("unZipPathFile error: ", error);
    } finally {
      /* 解压完删除.bin */
      fs.unlinkSync(filePath);
    }
  }

  /* mac下的文件移动 */
  mvInMac() {
    try {
      if (!this.CAN_HOT_UPDATE) return;
      if (fs.existsSync(this.HotUpdateAsarPath)) {
        const bash = child_process.spawn(
          "bash",
          ["-c", `mv -f ${this.HotUpdateAsarPath} ${this.AppAsarPath}`],
          {
            detached: true,
          }
        );
        bash.once("close", () => {
          this.successDialog();
        });
      }
    } catch (error) {
      logError("mvInMac error: ", error);
      this.errorDialog();
    }
  }

  /* win下的文件移动:外挂cmd.exe的一个bat脚本 */
  mvInWin() {
    try {
      if (!this.CAN_HOT_UPDATE) return;
      this.mkBatFile();
      this.runBatFile(
        this.BatFilePath,
        this.AppAsarPath,
        this.HotUpdateAsarPath
      );
    } catch (error) {
      logError("mvInWin error: ", error);
      this.errorDialog();
    }
  }

  successDialog() {
    return dialog.showMessageBox({
      type: "info",
      message: "热更新补丁下载成功,请重新启动应用!",
    });
  }

  errorDialog() {
    return dialog.showMessageBox({
      type: "error",
      message: "热更新补丁失败,请尝试重新下载或者联系官方!",
    });
  }

  /* 动态写脚本,确保安全 */
  mkBatFile() {
    const bat = `
echo off
set appAsar=app.asar
set a=".\\app.asar"
set b=".\\hot-update.asar"
timeout /nobreak /t 2
if exist %a% (
  if exist %b% (
    del /s /q %a%
    rename %b% %appAsar%
  )
)
del %0 
     `;
    if (fs.existsSync(this.BatFilePath)) {
      logError("batFilePath is exist: ", this.BatFilePath);
      fs.unlinkSync(this.BatFilePath);
    }
    fs.writeFileSync(this.BatFilePath, bat, "utf-8");
  }

  /* 运行脚本 */
  runBatFile(batFilePath: string, asarPath: string, patchPath: string) {
    const bat = child_process.spawn(
      "cmd.exe",
      ["/s", "/c", batFilePath, asarPath, patchPath],
      {
        detached: true,
        windowsVerbatimArguments: true,
        stdio: "ignore",
        windowsHide: true,
      }
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204

# .bat 脚本

说明:执行的时候传入两个文件地址,老的 app.asar 和新的 app.asar 地址,等待 3 秒之后然后就是简单的替换文件操作,最后删除脚本文件

echo off
set appAsar=app.asar
timeout /nobreak /t 3
if exist %1 (
  if exist %2 (
    del /s /q %1
    rename %2 %appAsar%
  )
)
del %0
1
2
3
4
5
6
7
8
9
10

# 注意

  • child_process.spawn 的 detached 的参数设置为 true,以免退出父进程的时候,cmd.exe 不执行,文档地址 (opens new window)
  • 这里选择动态生成 .bat 脚本,尽可能的确保执行脚本的安全性
  • 后续发现有几率不会执行.bat 脚本。改用 electron 的 openPath (opens new window) 执行.bat 脚本
  • 如果是安装在 c 盘的情况下,由于缺少管理员权限无法创建文件。可考虑让 bat 脚本自动获取 root 权限
Last Updated: 2/21/2022, 11:35:56 AM