首页/文章/electron

Electron增量更新1

2024-07-29
25689 分钟
...

上一期我们完成了electron的全量更新,本期我们介绍的是如何只修改部分文件以实现增量更新的几种方案。

asar

我们将electron软件进行安装后右键打开文件所在位置进入resources目录(mac显示包内容),可以看到app.asar这个文件,这个文件是electron程序的主业务文件,这东西可不是什么加密文件,实际上是一种压缩文件,我们可以用npm包解压这个文件

npm install -g asar
asar extract app.asar ./和app.asar同级目录下执行安装在c盘下的同学如果解压不了的话用管理员身份运行cmd进入再解压

解压后发现,实际上就是dist_electron/bundled里面的东西,其实我们如果只修改了渲染进程里面的东西的话,并不需要进行完全的打包更新,只要对js,html,css进行替换,那我们的页面也会更新,那么我们只需要更新几M的文件,并不需要让用户再下载一个完整的新包,增量更新的优点也在于此。

但是呢说起来容易,实际操作起来呢还是有一定的问题的,如果你设置的打包asar:true的话,那么在软件启动的时候进行app.asar替换会发现替换不了(win下),正在被软件使用。那么这个方案肯定用普通的替换是走不通了,下面我介绍几种方案供大家查考。

7z-Asar7z

这里呢我还提供一个7z的插件,让7z也能打开asar,链接,如果你的7z是安装在c盘,把Asar.64.dll(64位系统)放入C:\Program Files\7-Zip\Formats\里,Formats没有的话,自己新建一个。

方案一,asar:false

这是一种比较常见的方式,比如vscode就是采用的此方案,在进行打包是修改打包配置(vue.config.js中的builderOptions)asar:false,那么打包的时候resources下就不会产生app.asar,而是一个app文件夹,而这个文件夹呢是可以直接进行替换的,故不存在替换不了的问题。

简单来说就是,设置asar:false,打包,进入打包的绿色安装包dist_electron/win-ia32-unpacked/resources(win32),将app文件压缩成app.zip放到服务器,渲染进程检测增量更新通知主进程,主进程下载app.zip,解压替换。

  • 优点: 简单粗暴。
  • 缺点:安装及全量更新时安装较慢,主进程暴露在外面,替换时都是整体下载替换。

此方案的步骤和方案二差不多,具体做法可参考方案二的方式。

方案二,app.asar.unpacked + app.asar

app.asar.unpacked这个东西还是比较常见的,由于app.asar的限制性,比如文件内只是可读,一些node命令不能使用等,我们经常会把一些第三方的数据库或者dll等文件会用到这个,简单来说就是把本来应该放在app.asar中的文件放入到与app.asar同级的app.asar.unpacked目录中(其实和方案一的app文件夹类似),从而解除app.asar的限制性。

看到这里是不是就有新思路了,既然app.asar不能动,我们可以把变动的文件给扔到app.asar.unpacked里,主进程及一些不变的东西还是放在app.asar,增量更新时替换app.asar.unpacked就行了。

  • 优点: 可以将主进程js等文件保留在app.asar,只对渲染进程文件进行替换。
  • 缺点:由于主进程js没有动,那么主进程注入的环境变量版本号也不会改变,也就是说更新后在主进程使用环境变量获取的版本号不是更新后的版本号(可以从渲染进程拿)。

实现步骤:

1. 设置app.asar.unpacked

首先设置一下我们想要替换的那些文件,打包时会先生成dist_electron/bundled这个文件夹,然后再用electron-builder把这个文件夹打包成我们的electron文件。

vue.config.js的builderOptions

extraResources: [{
  from: "dist_electron/bundled",
  to: "app.asar.unpacked",
  filter: [
    "!**/icons",
    "!**/preload.js",
    "!**/node_modules",
    "!**/background.js"
    ]
  }],
  files: [
    "**/icons/*",
    "**/preload.js",
    "**/node_modules/**/*",
    "**/background.js"
  ],

extraResources呢是设置app.asar.unpacked里面的东西,files是设置app.asar里的东西,这里的意思是我们把dist_electron/bundled里面的除了iconsbackground.js等文件放入app.asar,其余的都放入app.asar.unpacked,打包看看,看看app.asar.unpacked里面是否是我们想要的东西。

2. 构建增量zip

现在我们有了app.asar.unpacked,但是我们不可能每次都进入免安装包里面手动压缩app.asar.unpacked,太麻烦了,我们这里利用打包完成的钩子,自动构建增量包。 adm-zip是处理zip包,fs-extra是fs的拓展,处理文件

npm i adm-zip
npm i fs-extra

electron-builder提供里打包完成的钩子afterPack

vue.config.js的builderOptions添加

afterPack: './afterPack.js',

./afterPack.js:
const path = require('path')
const AdmZip = require('adm-zip')

exports.default = async function(context) {
  let targetPath
  if(context.packager.platform.nodeName === 'darwin') {
    targetPath = path.join(context.appOutDir, `${context.packager.appInfo.productName}.app/Contents/Resources`)
  } else {
    targetPath = path.join(context.appOutDir, './resources')
  }
  const unpacked = path.join(targetPath, './app.asar.unpacked')
  var zip = new AdmZip()
  zip.addLocalFolder(unpacked)
  zip.writeZip(path.join(context.outDir, 'unpacked.zip'))
}

mac和win的resources有所区别,现在我们再打包看看,dist_electron目录下会生成一个unpacked.zip,这个就是我们的增量包了。

3. 加载策略修改

在窗口启动篇我们说过,我们渲染进程的html加载是通过app://协议加载的,这个协议呢以前是以app.asar为根目录的,这里把的渲染进程的文件给移出app.asar了,app://协议就找不到我们的渲染进程html,所以我们这里需要修改一下,把app.asar.unpacked作为根目录。

主进程main/index.js下找到



创建createProtocol.js

import { protocol } from 'electron'
import * as path from 'path'
import { readFile } from 'fs'
import { URL } from 'url'

export default (scheme, serverPath = __dirname) => {
  protocol.registerBufferProtocol(
    scheme,
    (request, respond) => {
      let pathName = new URL(request.url).pathname
      pathName = decodeURI(pathName) 
      readFile(path.join(serverPath, pathName), (error, data) => {
        if (error) {
          console.error(
            `Failed to read ${pathName} on ${scheme} protocol`,
            error
          )
        }
        const extension = path.extname(pathName).toLowerCase()
        let mimeType = ''

        if (extension === '.js') {
          mimeType = 'text/javascript'
        } else if (extension === '.html') {
          mimeType = 'text/html'
        } else if (extension === '.css') {
          mimeType = 'text/css'
        } else if (extension === '.svg' || extension === '.svgz') {
          mimeType = 'image/svg+xml'
        } else if (extension === '.json') {
          mimeType = 'application/json'
        } else if (extension === '.wasm') {
          mimeType = 'application/wasm'
        }

        respond({ mimeType, data })
      })
    },
    (error) => {
      if (error) {
        console.error(`Failed to register ${scheme} protocol`, error)
      }
    }
  )
}

主进程引入我们修改的createProtocol.js

import createProtocol from './services/createProtocol'
const resources = process.resourcesPath

将原来的createProtocol('app')修改为
createProtocol('app', path.join(resources, './app.asar.unpacked'))

现在就可以通过app://协议载入app.asar.unpacked下的文件了,打个包试试看看页面能否正常加载,当然如果你是直接用file://协议加载本地文件的其改动也差不多,就是改变一下加载地址,准备工作完成了,我们开始渲染进程增量更新的逻辑。

4. 模拟接口

这里呢就不多说什么了,和上一期全量更新一样,不了解可以去看看上一期内容,用http-server模拟接口返回,修改.env.dev0.0.2,打包生成unpacked.zip,放入server目录下

{
  "code": 200,
  "success": true,
  "data": {
    "forceUpdate": false,
    "fullUpdate": false,
    "upDateUrl": "http://127.0.0.1:4000/unpacked.zip",
    "restart": false,
    "message": "我要升级成0.0.2",
    "version": "0.0.2"
  }
}

5. 渲染进程增量更新

这里的页面逻辑和上一期全量更新差不多,我们检测到更新用win-increment向主进程发送更新信息:

<template>
  <div class="increment">
    <div class="version">当前版本为:{{ config.VUE_APP_VERSION }}</div>
    <a-button type="primary" @click="upDateClick(true)">检测更新</a-button>
  </div>
</template>

<script>
import cfg from '@/config'
import update from '@/utils/update'
import { defineComponent, getCurrentInstance } from 'vue'

export default defineComponent({
  setup() {
    const { proxy } = getCurrentInstance()
    const config = cfg
    const api = proxy.$api
    const message = proxy.$message
    function upDateClick(isClick) {
      api('http://localhost:4000/index.json', {}, { method: 'get' }).then(res => {
        console.log(res)
        if (cfg.NODE_ENV !== 'development') {
          update(config.VUE_APP_VERSION, res).then(() => {
            if (!res.fullUpdate) {
              window.ipcRenderer.invoke('win-increment', res)
            }
          }).catch(err => {
            if (err.code === 0) {
              isClick && message.success('已为最新版本')
            }
          })
        } else {
          message.success('请在打包环境下更新')
        }
      })
    }
    return {
      config,
      upDateClick
    }
  }
})
</script>

6. 主进程处理

ipcMain.js添加
import increment from '../utils/increment'

ipcMain.handle('win-increment', (_, data) => {
  increment(data)
})

增量更新处理increment.js,通过upDateUrl下载增量包,下载完成之后,我们先把原来的app.asar.unpacked重命名备份,如果出错的话可以还原,然后将下载的解压,处理完成之后我们可以用reloadIgnoringCache重新加载页面即可,当然你也可以用app.relaunch()重启应用

import downloadFile from './downloadFile'
import global from '../config/global'
import { app } from 'electron'
const path = require('path')
const fse = require('fs-extra')
const AdmZip = require('adm-zip')

export default (data) => {
  const resourcesPath = process.resourcesPath
  const unpackedPath = path.join(resourcesPath, './app.asar.unpacked')
  downloadFile({ url: data.upDateUrl, targetPath: resourcesPath }).then(async (filePath) => {
    backups(unpackedPath)
    const zip = new AdmZip(filePath)
    zip.extractAllToAsync(unpackedPath, true, (err) => {
      if (err) {
        console.error(err)
        reduction(unpackedPath)
        return
      }
      fse.removeSync(filePath)
      if (data.restart) {
        reLoad(true)
      } else {
        reLoad(false)
      }
    })
  }).catch(err => {
    console.log(err)
  })
}

function backups(targetPath) {
  if (fse.pathExistsSync(targetPath + '.back')) { 
    fse.removeSync(targetPath + '.back')
  }
  if (fse.pathExistsSync(targetPath)) {
    fse.moveSync(targetPath, targetPath + '.back') 
  }
}

function reduction(targetPath) {
  if (fse.pathExistsSync(targetPath + '.back')) {
    fse.moveSync(targetPath + '.back', targetPath)
  }
  reLoad(false)
}

function reLoad(close) {
  if (close) {
    app.relaunch()
    app.exit(0)
  } else {
    global.sharedObject.win.webContents.reloadIgnoringCache()
  }
}

封装的下载文件downloadFile.js

const request = require('request')
const fs = require('fs')
const fse = require('fs-extra')
const path = require('path')

function download(url, targetPath, cb = () => { }) {
  let status
  const req = request({
    method: 'GET',
    uri: encodeURI(url)
  })
  try {
    const stream = fs.createWriteStream(targetPath)
    let len = 0
    let cur = 0
    req.pipe(stream)
    req.on('response', (data) => {
      len = parseInt(data.headers['content-length'])
    })
    req.on('data', (chunk) => {
      cur += chunk.length
      const progress = (100 * cur / len).toFixed(2)
      status = 'progressing'
      cb(status, progress)
    })
    req.on('end', function () {
      if (req.response.statusCode === 200) {
        if (len === cur) {
          console.log(targetPath + ' Download complete ')
          status = 'completed'
          cb(status, 100)
        } else {
          stream.end()
          removeFile(targetPath)
          status = 'error'
          cb(status, '网络波动,下载文件不全')
        }
      } else {
        stream.end()
        removeFile(targetPath)
        status = 'error'
        cb(status, req.response.statusMessage)
      }
    })
    req.on('error', (e) => {
      stream.end()
      removeFile(targetPath)
      if (len !== cur) {
        status = 'error'
        cb(status, '网络波动,下载失败')
      } else {
        status = 'error'
        cb(status, e)
      }
    })
  } catch (error) {
    console.log(error)
  }
}

function removeFile(targetPath) {
  try {
    fse.removeSync(targetPath)
  } catch (error) {
    console.log(error)
  }
}

export default async function downloadFile({ url, targetPath, folder = './' }, cb = () => { }) {
  if (!targetPath || !url) {
    throw new Error('targetPath or url is nofind')
  }
  try {
    await fse.ensureDirSync(path.join(targetPath, folder))
  } catch (error) {
    throw new Error(error)
  }
  return new Promise((resolve, reject) => {
    const name = url.split('/').pop()
    const filePath = path.join(targetPath, folder, name)
    download(url, filePath, (status, result) => {
      if (status === 'completed') {
        resolve(filePath)
      }
      if (status === 'error') {
        reject(result)
      }
      if (status === 'progressing') {
        cb && cb(result)
      }
    })
  })
}

增量更新的基本逻辑就完成了,如果你是采用方案一的话,也可以参考一下流程,点击渲染进程的检测更新,看看版本变成0.0.2没有

方案缺陷处理

前面我们说了,此方案有个缺点就是主进程中的环境变量不会改变,那么我们在主进程中通过process.env.VUE_APP_VERSION获取版本号拿到的还是之前的版本号。
我们的渲染进程是重新打包的,故其环境变量都是准确的,此时我们可以在页面加载时,从渲染进程把配置信息发送给主进程。

renderer的App.vue
import cfg from '@/config'
window.ipcRenderer.invoke('win-envConfig', cfg)

global.js
global.envConfig = {}

main的ipcMain.js
import global from '../config/global'
ipcMain.handle('win-envConfig', (_, data) => {
  global.envConfig = data
})

不再使用process.env.VUE_APP_VERSION获取版本号信息,使用global.config.VUE_APP_VERSION获取,重新打个0.0.2的包试试。

补充说明

  • 这里呢只是简单的完成了增量更新的逻辑,如果你想要一个下载进度呀,可以自己实现一下
  • 一般来说这类增量更新包在上传时会将地址保存到数据库中,可以做一下安全处理,比如在保存时附加文件的md5或sha呀,然后在增量更新下载完成后本地校验是否一致再进行解压,保证文件准确性。
  • 当然还有解压失败处理,假如我们的增量更新包损坏了,虽然我们有备份,但是重启还是会拉取更新包进行更新,如果使用了重启更新的话,就陷入了死循环了,这里可以做一个版本更新重启记录,超过多少次后,就不再对这个版本的包进行处理了。

当然增量更新还有其他的方式实现,一期讲完太多了,其他方案我们放到下一期继续。

本系列更新只有利用周末和下班时间整理,比较多的内容的话更新会比较慢,希望能对你有所帮助,请多多star或点赞收藏支持一下

本文地址:xuxin123.com/electron/in…
本文github地址:链接

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
electron2025-03-05
不依赖第三方库自己实现 Electron 的主与渲染进程的国际化功能
正文 直接贴出代码: 主进程实现 首先需要在 文件中获取当前系统的语言,然后初始化国际化相关: 文件: 文件 然后在主进程中就可以通过 来实现国际化了。 渲染进程的实现 App 在启动的时候渲染进程已经执行了国际化的初始化,所以在主进程已经保存了一份当前语言的 信息,所以渲染进程就不需要进程这一步,直接从主进程获取即可。 主进程进程添加一个 事件,供渲染进程获取国际化相关数据: 首先我们要在 preload 中提前将国际化相关的信息写入...
学习
electron2025-03-05
应用多开,限制只启动一个应用,防止多个实例
通过app.requestSingleInstanceLock来控制应用的多开,返回值为boolean。 此方法的返回值表示你的应用程序实例是否成功取得了锁。如果它取得锁失败,你可以假设另一个应用实例已经取得了锁并且仍旧在运行,并立即退出。 即:如果当前进程是应用程序的主要实例,则此方法返回true,同时你的应用会继续运行。如果当它返回false,如果你的程序没有取得锁,它应该立刻退出,并且将参数发送给那个已经取到锁的进程。 在macO...
学习
electron2024-09-23
Electron实现文件缓存背景
背景 基于Electron研发的一款IM企业通信桌面端应用,会存在非常多文件、图片、视频类型回话消息。在Electron应用(桌面客户端软件)中,快速加载并显示图片是提升用户体验的关键。然而,传统的图片加载方式往往存在加载速度慢、资源占用高等问题,影响了用户的使用体验。 解决的问题 1. 支持自定义配置存储的磁盘位置 2. 支持长期存储 3. 支持自定义存储大小 4. 支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME) 5...
学习
electron2024-09-23
我的 Electron 客户端也可以全量和增量更新了
前言 本文主要介绍 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。 如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。 前置说明: 1. 由于业务场景的限制,本文介绍的更新仅支持 操作系统,其余操作系统未作兼容处理。 2. 更新流...
学习
electron2024-09-20
我的 Electron 客户端被第三方页面入侵了
问题描述 公司有个内部项目是用 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面 。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。 翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。 奇怪的是两者不是 跨域 了吗,为什么 还可以影响顶级窗口。 先说一下我当时的一些解决办法: 1. 用 替换 2. 给 添...
学习
electron2024-09-13
Electron+WebRTC实现局域网内的远程控制软件
我的工位上有一台笔记本(windows),一个显示器,一台服务器(linux)。显示器同时连接笔记本和服务器,当需要用到服务器的时候:1.使用todesk等软件进行连接;2.直接把显示器切换到服务器桌面,然后给服务器插上鼠标键盘。在这种场景下,屏幕画面的传输对我来说并不是必要的,这种场景下能不能用一套键鼠控制两个电脑呢?边做边学便有了这个小工具。记录下来,其中遇到的问题和解决方案。 通过electron + Vue + primevue...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录