Electron 增量更新方案-记录一下

693 阅读3分钟

electron-builder的json或着yml配置文件中配置一下beforePack文件

beforePack: './packed/beforePack.js' // 换成对应的目录文件

需要安装 adm-zip和asar这两个库

npm i adm-zip asar

beforePack.js文件内容如下

// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AdmZip = require('adm-zip')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs')

function getFileZip(context, folder, name) {
  // 项目当前目录
  const targetPath = context.packager.info.projectDir
  const zip = new AdmZip()
  folder.map((item) => {
    const unpacked = path.join(targetPath, item)
    const stat = fs.lstatSync(unpacked)
    if (stat.isFile()) {
      zip.addLocalFile(unpacked)
    } else {
      const parent = item.split('/').pop()
      zip.addLocalFolder(unpacked, parent)
    }
  })
  zip.writeZip(path.join(context.outDir, name))
}

exports.default = async function (context) {
  // 将electron 编译后的文件夹以及package.json文件打包进压缩包,作为增量更新包
  // update.zip 为增量包名称
  getFileZip(context, ['./out', './package.json'], 'update.zip')
}

增量包内容已经准备完毕后,剩下的就是封装自动更新

需要安装axios,js-yaml,semver这三个库

npm i axios js-yaml semver

以下为自动更新class内容

import axios from 'axios'
import yaml from 'js-yaml'
import semver from 'semver'
import { join } from 'path'
import fs from 'fs'
import { downloadFile } from './downloadFile'
import asar from 'asar'
import AdmZip from 'adm-zip'


// 获取安装路径
export function getAppPath() {
  const appBind = getApp()
  return appBind.getPath('exe')
}

// 获取 AppData 路径
export function getAppDataPath() {
  return join(
    process.env.APPDATA ||
      (process.platform === 'darwin'
        ? process.env.HOME + '/Library/Application Support'
        : '/var/local'),
    Package.name
  )
}

class AutoUpdaterMode {
  // 是否自动更新
  static autoDownload = true
  // 是否是测试状态
  static isUpdateTest = false
  // 更新url
  private static updateUlr?: string = undefined
  // 增量包名称
  private static updateFileName?: string = undefined
  // 事件监听
  private static EventListener: Recordable<((data?, message?: string) => void)[]> = {}
  // 应用信息
  private static appInfo: Recordable = {
    currentVersion: app.getVersion()
  }
  // 设置检测更新url
  static setFeedURL(updateUlr) {
    this.updateUlr = updateUlr
  }
  // 设置增量包名称
  static setUpdateFile(fileName: string) {
    this.updateFileName = fileName
  }
  // 添加事件监听
  static on(eventName: string, handler: (data?, message?: string) => void) {
    if (this.appInfo[eventName]) {
      this.EventListener[eventName].push(handler)
    } else {
      this.EventListener[eventName] = [handler]
    }
  }

  // 获取最新版本号
  static async getLastVersion(rootUrl: string) {
    const response = await axios.get(`${rootUrl}/latest.yml`)
    const data = yaml.load(response.data)
    return data.version
  }

  // 检测更新
  static checkForUpdates() {
    if (!this.updateUlr) return undefined
    this.getLastVersion(this.updateUlr as string).then((lastVersion) => {
      const { currentVersion } = this.appInfo
      this.appInfo.lastVersion = lastVersion
      if (semver.lt(currentVersion, lastVersion)) {
        this.updateAvailable()
        const { onlineFile, path } = this.getSavePath()
        // 检查文件夹是否存在
        if (!fs.existsSync(path)) {
          // 如果不存在,创建文件夹
          fs.mkdirSync(path)
        }
        if (this.autoDownload) {
          this.updateDownloaded(onlineFile)
        }
      }
    })
  }

  // 安装并退出
  static quitAndInstall() {
    this.elevatePrivileges()
      .then(async () => {
        if (!this.autoDownload) {
          const { onlineFile } = this.getSavePath()
          await this.updateDownloaded(onlineFile)
        }
        this.updateProcess(80, '解压文件中')
        this.extractAsar()
        this.unZip()
        this.updateProcess(90, '安装应用中')
        this.compressAsar().then(() => {
          app.exit()
          app.relaunch()
        })
      })
      .catch((error) => {
        this.updateError(error)
      })
  }

  // 提升权限
  private static elevatePrivileges() {
    return new Promise((resolve) => {
      // 提权的这一步目前还未实现,使用sudo这个库提权不生效,目前在换别的方案
      // 不过可以在electron打包的时候提高权限来解决,这个需要用管理员权限运行应用
      resolve(true)
      // const { root } = this.getSavePath()
      // const options = {
      //   name: 'name'
      // }
      // sudo.exec(
      //   `icacls "${root}" /grant:r %USERNAME%:(OI)(CI)F`,
      //   options,
      //   function (error, stdout) {
      //     if (error) {
      //       reject(error)
      //       return
      //     }
      //     resolve(stdout)
      //   }
      // )
    })
  }

  // 更新进度
  private static updateProcess(process, message) {
    this.EventListener['update-process']?.map((item) => {
      item?.(process, message)
    })
  }

  // 更新失败
  private static updateError(error) {
    this.EventListener['update-error']?.map((item) => {
      item?.(error)
    })
  }

  // 检测到更新
  private static updateAvailable() {
    this.EventListener['update-available']?.map((item) => {
      item?.(this.appInfo)
    })
  }

  // 下载更新包
  private static updateDownloaded(fileUrl) {
    return downloadFile(fileUrl, this.getSavePath().filePath, (process) => {
      this.updateProcess((process * 0.8).toFixed(0), '下载文件中')
    }).then(() => {
      // 下载完成
      this.EventListener['update-downloaded']?.map((item) => {
        item?.(this.appInfo)
      })
      return true
    })
  }

  // 解压Zip
  private static unZip() {
    const { filePath, app } = this.getSavePath()
    const zip = new AdmZip(filePath)
    zip.extractAllTo(app, /*overwrite*/ true)
  }

  // 解压Asar文件
  private static extractAsar() {
    const { app, appAsar } = this.getSavePath()
    asar.extractAll(appAsar, app)
  }

  // 压缩成Asar文件
  private static compressAsar() {
    return new Promise((resolve) => {
      const { app, appAsar } = this.getSavePath()
      asar.createPackage(app, appAsar).then(() => {
        fs.rmSync(app, { recursive: true })
        resolve(true)
      })
    })
  }

  // 获取保存目录
  private static getSavePath() {
    const path = join(getAppDataPath(), './pending')
    const resources = !this.isUpdateTest
      ? join(getAppPath(), '../resources')
      // 这个为开发环境下自己安装的环境,没找到获取电脑上已安装路径的变量和方法,如果有人知道也可以讲一下
      : join('C:/Program Files (x86)/odin-mes', '/resources')
    // : join('D:/Program Files (x86)/odin-mes', '/resources')
    return {
      root: join(getAppPath(), '..'),
      path,
      onlineFile: `${this.updateUlr}/${this.updateFileName}`,
      filePath: join(path, `/${this.updateFileName}`),
      resources,
      app: join(resources, '/app'),
      appAsar: join(resources, '/app.asar')
    }
  }
}

downloadFile.ts文件内容

import axios from 'axios'
import fs from 'fs'

// 文件下载方法
export async function downloadFile(
  url: string,
  destinationPath: string,
  onProgress?: (process) => void
): Promise<void> {
  const response = await axios({
    method: 'GET',
    url: url,
    responseType: 'stream', // 设置响应类型为流
    onDownloadProgress: (progressEvent) => {
      const totalLength = progressEvent.total ?? 0
      const downloadedBytes = progressEvent.loaded
      const percentage = (downloadedBytes / totalLength) * 100
      onProgress?.(percentage.toFixed(2))
    }
  })
  const writer = fs.createWriteStream(destinationPath)
  // 使用管道将响应的流写入文件
  response.data.pipe(writer)

  return new Promise((resolve, reject) => {
    writer.on('finish', resolve)
    writer.on('error', reject)
  })
}

现在自动更新的库封装好了,我们就来使用一下,先封装一个钩子函数,以方便使用

import { app, ipcMain } from 'electron'
export function useAutoUpdate() {
  const { UpdateServiceUrl } = '你的服务器url'
  const mainWindow = getMainWindow() // 换成你获取主进程的方法
  let updateUlr = ''
  // 设置要检测更新的路径
  if (process.platform == 'darwin') { 
    updateUlr = `${UpdateServiceUrl}/darwin` // ios
  } else { 
    updateUlr = `${UpdateServiceUrl}/win32` // win
  }

  function checkUpdate() {
    AutoUpdaterMode.setFeedURL(updateUlr)
    // 设置你的环境是否是开发环境
    // AutoUpdaterMode.isUpdateTest = true
    AutoUpdaterMode.setUpdateFile('update.zip')
    // 更新进度
    AutoUpdaterMode.on('update-process', (process, message) => {
      mainWindow.webContents.send('update-process', { process, message })
    })
    // 监听'update-error'事件,更新失败
    AutoUpdaterMode.on('update-error', (error) => {
      mainWindow.webContents.send('update-error', error)
    })
    // 监听'update-available'事件,发现有新版本时触发
    AutoUpdaterMode.on('update-available', () => {
      setTimeout(() => {
        // 通知渲染进程提示更新
        mainWindow.webContents.send('update-app', true)
      }, 1000)
    })
    // 默认会自动下载新版本,如果不想自动下载,设置
    AutoUpdaterMode.autoDownload = false
    // 监听'update-downloaded'事件,新版本下载完成时触发
    AutoUpdaterMode.on('update-downloaded', ({ lastVersion }) => {
      console.log('下载完成')
    })
    // 检测更新
    AutoUpdaterMode.checkForUpdates()
    // 监听渲染进程的更新回调
    ipcMain.on('restart-update', () => {
      AutoUpdaterMode.quitAndInstall()
    })
  }

  return {
    checkUpdate
  }
}

最后再主应用是调用一下检测更新即可

app.whenReady().then(() => {
  const { checkUpdate } = useAutoUpdate()
  checkUpdate()
})

大功告成!!!!庆祝一下

网上找了很多的增量更新的方案,看了之后感觉没有真正的解决我的问题,最后只能手搓一个
还有一个提权的问题没有找到好的方案,如果你们有好的方案可以告诉一下
目前是设置的electron-builder的requestedExecutionLevel属性来临时解决
不过这个是主要针对的是全用户安装C盘情况下的权限问题,如果你为个人用户安装就可以不考虑这些

"requestedExecutionLevel": "highestAvailable"