jrainlau / blog-articles

My personal blog.
https://jrain.vercel.app
40 stars 11 forks source link

探究 electron-updater 的动态更新原理 #36

Open jrainlau opened 2 years ago

jrainlau commented 2 years ago

image

最近的工作内容,是开发基于 Electron 的桌面应用。在应用开发完成后,即面临着如何进行版本迭代的问题。如果按照传统桌面应用的思路,只能在发布新版本的时候同步上架到官网和商城,通过在应用内设置引导等方式让用户手动去进行版本更新。这种方式对于新版本的覆盖率增长来说是非常被动的,绝大多数用户都会下意识地觉得,我当前版本用得好好的,为什么要这么麻烦地升级呢?还要自己下载、自己安装,麻烦死了。

万幸的是,Electron 官方自带升级模块,也就是今天要研究的 electron-updater。该模块允许应用自己更新自己,无需依靠用户手动下载和安装。为了探究这个模块是如何运行的,我们首先来做一个简单的 demo。

完成的 demo 地址在这里

简单的 demo 实现

新建一个空目录,命名为 /wonderland,然后进行项目初始化:

yarn init -y

yarn add electron electron-builder -D

yarn add electron-log electron-updater

接下来我们新建一个主进程文件 main.js


const { app, BrowserWindow, ipcMain } = require('electron');
const log = require('electron-log');
const { autoUpdater } = require('electron-updater');

autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = 'info';
autoUpdater.allowDowngrade = true; // 允许降级
autoUpdater.allowPrerelease = true; // 允许升级到 pre-release 版本

let mainWindow;

function createWindow () {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 900,
    webPreferences: {
      nodeIntegration: true, // 为了让渲染进程能够 require electron 的模块
      contextIsolation:false, // 为了让渲染进程能够 require electron 的模块
    },
  });
  mainWindow.loadFile('index.html');
  mainWindow.on('closed', function () {
    mainWindow = null;
  });
  mainWindow.once('ready-to-show', () => {
    // autoUpdater.checkForUpdatesAndNotify(); // 应用启动时,不自动检查更新,而是手动进行
  });
}

app.on('ready', () => {
  createWindow();
  mainWindow.webContents.openDevTools();
});

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', function () {
  if (mainWindow === null) {
    createWindow();
  }
});

ipcMain.on('check_update', async (event, version) => {
  log.info('Querying version:', version)
  // 根据渲染进程传入的版本号,去设置 electron-updater 所请求的资源路径
  autoUpdater.setFeedURL(`http://localhost:8081/${version}`)
  // 检查更新
  const updateInfo = await autoUpdater.checkForUpdates();
  log.info('updateInfo: ', updateInfo)
})
ipcMain.on('app_version', (event) => {
  event.sender.send('app_version', { version: app.getVersion() });
});
ipcMain.on('restart_app', () => {
  autoUpdater.quitAndInstall();
});

// 当检查到更新时,通知渲染进程进行展示
autoUpdater.on('update-available', (info) => {
  mainWindow.webContents.send('update_available', info);
});
// 当更新下载完成时,通知渲染进程进行展示
autoUpdater.on('update-downloaded', (info) => {
  mainWindow.webContents.send('update_downloaded', info);
});

主进程的代码写完后,我们接着来写渲染进程的代码。在 /wonderland 目录下新建 index.html 并写入如下内容:

<!DOCTYPE html>
<head>
  <title>Electron Auto Update Example</title>
  <style>
    body {
      box-sizing: border-box;
      margin: 0;
      padding: 20px;
      font-family: sans-serif;
      background-color: #eaeaea;
      text-align: center;
    }
    #notification {
      position: fixed;
      bottom: 20px;
      left: 20px;
      width: 200px;
      padding: 20px;
      border-radius: 5px;
      background-color: white;
      box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
    }
    .hidden {
      display: none;
    }
  </style>
</head>
<body>
  <h1>Electron Auto Update Example</h1>
  <p id="version"></p>
  <input type="text" id="version-input" placeholder="Input version">
  <button onClick="checkUpdate()">Check update</button>
  <div id="notification" class="hidden">
    <p id="message"></p>
    <button id="close-button" onClick="closeNotification()">
      Close
    </button>
    <button id="restart-button" onClick="restartApp()" class="hidden">
      Restart
    </button>
  </div>
  <script>
    const { ipcRenderer } = require('electron');
    const version = document.getElementById('version');

    ipcRenderer.send('app_version');
    ipcRenderer.on('app_version', (event, arg) => {
      ipcRenderer.removeAllListeners('app_version');
      version.innerText = 'Version ' + arg.version;
    });

    const notification = document.getElementById('notification');
    const message = document.getElementById('message');
    const restartButton = document.getElementById('restart-button');

    ipcRenderer.on('update_available', (event, arg) => {
      console.log('update_available', arg)
      message.innerText = 'A new update is available. Downloading now...';
      notification.classList.remove('hidden');
    });
    ipcRenderer.on('update_downloaded', (event, arg) => {
      console.log('update_downloaded', arg)
      message.innerText = 'Update Downloaded. It will be installed on restart. Restart now?';
      restartButton.classList.remove('hidden');
      notification.classList.remove('hidden');
    });

    function closeNotification() {
      notification.classList.add('hidden');
    }
    function restartApp() {
      ipcRenderer.send('restart_app');
    }
    function checkUpdate() {
      const version = document.querySelector('#version-input').value
      console.log(`Querying version:`, version)
      ipcRenderer.send('check_update', version)
    }
  </script>
</body>

渲染进程的代码写完后,就可以准备构建了。electron-updater 最适合搭配 electron-builder 去使用。接下来我们来新建一个 electron-builder 的配置文件 build.config.js

electron-updater 生效的应用必须经过数字签名,这里不展开。有需要的同学请自行查阅相关资料。

const { version } = require('./package.json')
const fs = require('fs-extra')

fs.ensureDirSync('./dist')

module.exports = {
  publish: [{
    provider: 'generic',
    url: ''
  }],
  asar: false,
  directories: {
    output: `./dist/${version}` // 输出到对应版本号的目录里
  },
}

配置文件写好了,最后只需要在 package.json 里面添加几行 scripts 指令即可:

  "scripts": {
    "start": "electron .",
    "build:mac": "electron-builder -c ./build.config.js build --mac",
    "build:win": "electron-builder -c ./build.config.js build --win",
    "serve": "npx http-server ./dist --port=8081"
  },

首先修改一下 pacakge.json 的版本号为 1.0.1,接下来我们来构建这个版本(我的设备是 Macbook Pro M1,所以本文的例子都是基于 Mac 平台的展示):

yarn build:mac

构建完成后,会在 /dist/1.0.1/mac-arm64 里找到 wonderland.app,双击打开即可。

版本切换功能

1.0.1 版本构建出来并顺利运行了。为了检查它的更新能力,我们需要构建一个新版本 1.0.2,并尝试从当前版本升级过去。

回到 package.json 文件,把版本号修改为 1.0.2,然后重新执行 build:mac 指令,即可在 /dist/1.0.2 目录里看到产物。

接下来我们需要开启一个静态资源服务器,去托管整个 /dist 目录,以提供给 electron-updater 去拉取更新资源。在这个例子里,我使用了 http-server,并把它的开启指令集成到了 npm scripts 里面。

在项目根目录执行 yarn serve,即可在本地开启一个 8081 端口的静态资源服务器。

回到刚刚打开的 1.0.1 应用,在输入框内输入 1.0.2 并点击”Check update“ 按钮,从 http-server 的控制台可以看到,它请求了两个文件,分别是 /1.0.2/latest-mac.yml/1.0.2/wonderland-1.0.2-arm64-mac.zip。前者是 1.0.2 的版本信息文件,后者是 1.0.2 的版本资源。

在完成资源下载后,可以在应用内看到更新信息。

点击”Restart“即可重启应用。重启后,版本已然更新为 1.0.2。

原理分析

回到构建目录 /dist,可以看到两个版本的目录结构是一样的:

最关键的地方在于目录里的 latest-mac.yml 文件:

这个文件记录了对应版本的信息,electron-updater 正是因为从静态资源服务器上读取了它,才能判断是否允许更新。

更新资源的缓存目录,默认位于 ~/Library/Application Support/Caches/wonderland-updater 内。

该目录里的 update-info.json,其内容和 /dist/1.0.2/latest-mac.yml 里的 files 字段一致:

在实际的更新中,electron-updater 就是在完成更新资源的下载以后,把它解压缩并替换原安装目录内的应用(/dist/1.0.1/mac-arm64/wonderland.app)本体来实现的。

有趣的地方来了。electron-updater 所执行的”替换“操作,是在应用运行时进行的。如果在应用运行时,我们手动尝试把另一个 wonderland.app 替换当前的这个,会出现报错:

类似的,当一个应用在运行时,默认是不允许对它进行修改的。那 electron-updater 是运用了什么黑科技,能够在应用运行时就把它替换掉呢?

当我点击”Restart“按钮的时候,实际上是调用了 autoUpdater.quitAndInstall() 方法,接下来我们就来看看这个方法到底做了些什么。

运行时替换本体

我下载了 electron-updater 的源码,在 BaseUpdater.ts 里找到了这个方法的实现:

这个方法里又间接引用了一个叫做 doInstall() 的方法,它的实现才是整个更新原理的关键。

结合注释所提到的链接 https://stackoverflow.com/a/1712051/1910191,真相水落石出。这里并没有使用任何的黑科技,而是通过系统命令调用的方式,用 unlinkSync 方法直接把应用本体给删除,再把缓存目录内的新应用给移动过去。

要验证也很简单,我们可以在应用运行的时候,尝试 rm -rf ./wonderland.app。你会发现删除是成功的,没有任何的报错;接下来只需要拖一个别的版本的应用进去,退出重启后其可看到版本已经更新。

对于 Windows 系统,其原理却不同。我们可以在 NsisUpdater.ts 里找到 doInstall() 的实现:

它会通过一个第三方的 elevate.exe 去执行应用的安装,在内部规避了应用运行时无法修改的问题。

待续……

forsigner commented 1 year ago

hello