63-electron-1-增量更新

概述

先介绍一下技术构成,项目是完整的vue项目,在现有项目上完成electron打包及更新。

打包使用的vue-cli-plugin-electron-builder插件,增量更新为具体的代码实现,并没有用到electron-updater。

思路

1、比较本地和远程的版本

本地的版本可以放在一个app.txt文件中,或者通过变量获取。

远程的版本通过服务端文件(yml或者json)获取

比较版本,若相同则不更新,返回。

若不同,则提示更新,或直接进入更新流程

2、更新流程:通过线上增量包文件地址,进行下载

3、将zip压缩包解压缩

4、重新加载

详解

一、vue-cli-plugin-electron-builder

安装vue-cli-plugin-electron-builder

vue add electron-builder@2.0.0-rc.6

版本选择9.0.0

二、新建vue.config.js

1、重点是:"asar": false,

这句话的意思是,打包的时候resources下就不产生app.asar,而是一个app文件夹,而这个文件夹呢是可以直接进行替换的,里面的内容就是我们的前端整个项目的打包,就是渲染进程。所以,我们每次只需要替换前端的打包就可以了,而不需要每次都安装替换整个app

2、将app图标放到public文件夹下

module.exports = {
  pluginOptions: {
    electronBuilder: {
      builderOptions: {
        //productName: "ebopo", // 项目名,这也是生成的exe文件的前缀名
        copyright: 'Copyright © Ambow',  // 应用程序版权行
        publish: [{
          "provider": "generic",
          "channel": "latest",
          "url": "http://ambow-ebopo.oss-cn-beijing.aliyuncs.com/common/electron/",
        }],
        "win": {//win相关配置
          "icon": "./public/app.ico",//图标,当前图标在根目录下,注意这里有两个坑
          "target": [
              {
                  "target": "nsis",//利用nsis制作安装程序
                  "arch": [
                      "x64",//64位
                      "ia32"//32位
                  ]
              }
          ]
        },
        "asar": false,
        "nsis": {
          "oneClick": false, // 是否一键安装
          "allowElevation": true, // 允许请求提升。 如果为false,则用户必须使用提升的权限重新启动安装程序。          
          "allowToChangeInstallationDirectory": true, // 允许修改安装目录
          "installerIcon": "./public/app.ico",// 安装图标
          "uninstallerIcon": "./public/app.ico",//卸载图标
          "installerHeaderIcon": "./public/app.ico", // 安装时头部图标
          "createDesktopShortcut": true, // 创建桌面图标
          "createStartMenuShortcut": true,// 创建开始菜单图标
          "shortcutName": "ebopo", // 图标名称
        },
      }
    }
  }
}

三、配置信息

background.js


let win

function createWindow() {
  win = new BrowserWindow({
    width: 800,
    height: 600,     
    icon: `${__static}/app.ico`, // app内左上角图标
    webPreferences: {
      webSecurity: false, //取消跨域限制
      nodeIntegration:true, //开启node
    }
  })
}

package.json

// 打包exe前缀名称,及项目内左上角显示名称
"name": "ebopo",
// 版本号
"version": "1.0.0",
"author": "leecss",
"description": "electronApp",

四、新增electron目录及设置菜单

在src文件夹下新建electron目录,在目录中新建menu.js

import { BrowserWindow, Menu, app} from 'electron'

let template = [{
  label: '查看',
  submenu: [{
    label: '重载',
    accelerator: 'CmdOrCtrl+R',
    click: (item, focusedWindow) => {
      if (focusedWindow) {
        // 重载之后, 刷新并关闭所有之前打开的次要窗体
        if (focusedWindow.id === 1) {
          BrowserWindow.getAllWindows().forEach(win => {
            if (win.id > 1) win.close()
          })
        }
        focusedWindow.reload()
      }
    }
  }, {
    label: '切换全屏',
    accelerator: (() => {
      if (process.platform === 'darwin') {
        return 'Ctrl+Command+F'
      } else {
        return 'F11'
      }
    })(),
    click: (item, focusedWindow) => {
      if (focusedWindow) {
        focusedWindow.setFullScreen(!focusedWindow.isFullScreen())
      }
    }
  }]
}, {
  label: '窗口',
  role: 'window',
  submenu: [{
    label: '最小化',
    accelerator: 'CmdOrCtrl+M',
    role: 'minimize'
  }, {
    label: '关闭',
    accelerator: 'CmdOrCtrl+W',
    role: 'close'
  }]
}, {
  label: '帮助',
  role: 'help',
  submenu: [     
    {
      label: `版本` + app.getVersion(),
      enabled: false      
    },
    {
      label: '切换开发者工具',
      accelerator: (() => {
        if (process.platform === 'darwin') {
          return 'Alt+Command+I'
        } else {
          return 'Ctrl+Shift+I'
        }
      })(),
      click: (item, focusedWindow) => {
        if (focusedWindow) {
          focusedWindow.toggleDevTools()
        }
      }
    }
  ]
}]

var list = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(list)

修改background.js ,在主进程中引入菜单

async function createWindow() {
+	require('./electron/menu.js')
}

五、添加调试及日志文件输出

npm i  electron-log

在electron目录下新建log.js

import log from 'electron-log'

log.transports.file.level = 'silly'
log.transports.console.level = false // 禁用console输出

export default log

在background.js 中使用

import log from './electron/log'

log.warn('hello')

日志文件查看:文件main.log存储位置

C:\Users\{用户}\AppData\Roaming\{appName}\logs

六、更新流程:比较版本

安装并引入axios

npm i axios

background.js

import axios from 'axios'

比较版本,若相同则返回,不相同则更新

async function createWindow() {
  require('./electron/menu.js')
  // Create the browser window.
  win = new BrowserWindow({
    width: 800,
    height: 600,
    icon: `${__static}/app.ico`, // app内左上角图标
    webPreferences: {      
      webSecurity: false, //取消跨域限制
      nodeIntegration:true, //开启node
    }
  })

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    // Load the url of the dev server if in development mode
    await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
    if (!process.env.IS_TEST) win.webContents.openDevTools()
  } else {
    createProtocol('app')
    // Load the index.html when not in development
    win.loadURL('app://./index.html')
    
    
    
    log.warn('hello')
    // 判断是否热更新,版本对比
    const updateUrl = 'http://ambow-ebopo.oss-cn-beijing.aliyuncs.com/common/electron'
    const currentVersion = app.getVersion()
    log.warn('currentVersion',currentVersion)    
    axios({
      url: updateUrl + '/latest.yml',
      method: 'GET'
    }).then(res => {      
      const remoteVersion = JSON.stringify(res.data).split('\\n')[0].split(' ')[1]
      log.warn('remoteVersion',remoteVersion)      
      if (currentVersion === remoteVersion) {
        log.warn('版本相同,无需更新')
        return;
      }
      // 热更新
      const updateZipUrl = updateUrl + '/app.zip'
      increment(updateZipUrl)      
    })
    
    
    
  }
}

七、更新流程:下载远程更新包app.zip

在electron目录中新建downloadFile.js

下载文件需要两个参数:下载路径,保存路径

下载文件返回一个参数:保存路径

安装fs-extra

npm i fs-extra
const request = require('request')
const fs = require('fs')
const fse = require('fs-extra')
const path = require('path')
import log from './log'

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 = () => { }) {  
  log.warn('download-url',url)
  log.warn('download-saveurl',targetPath)
  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)
      }
    })
  })
}

八、更新流程:将下载的app.zip解压缩并删除

在electron目录中新建increment.js

解压缩需要一个参数:加压压缩包的路径

在文件下载完成后执行

安装adm-zip

npm i adm-zip
import downloadFile from './downloadFile'
import { app } from 'electron'
const fse = require('fs-extra')
const AdmZip = require('adm-zip')
import log from './log'

export default async (updateZipUrl) => {  
  const resourcesPath = process.resourcesPath
  downloadFile({ url: updateZipUrl, targetPath: resourcesPath }).then(async (filePath) => {
    log.warn('unzip-filePath',filePath)
    const zip = new AdmZip(filePath)
    zip.extractAllToAsync(resourcesPath, true, (err) => {
      if (err) {
        console.error(err)
        return
      }      
      fse.removeSync(filePath)      
      setTimeout(() => {
        log.warn('relaunch')
        app.relaunch()
        app.exit(0)                
      }, 2000);
    })
  }).catch(err => {
    console.log(err)
  })
}

在background.js引入并调用

import increment from './electron/increment'
// 热更新
const updateZipUrl = updateUrl + '/app.zip'
increment(updateZipUrl)

九、更新流程:将更新重启替换为重新加载

在electron目录下新建global.js

global.sharedObject = {
  win: ''
}

export default global

在background.js引入并调用

import global from './electron/global'
async function createWindow() {
  require('./electron/menu.js')
  // Create the browser window.
  win = new BrowserWindow({
    width: 800,
    height: 600,
    icon: `${__static}/app.ico`, // app内左上角图标
    webPreferences: {      
      webSecurity: false, //取消跨域限制
      nodeIntegration:true, //开启node
    }
  })

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    // Load the url of the dev server if in development mode
    await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
    if (!process.env.IS_TEST) win.webContents.openDevTools()
  } else {
    createProtocol('app')
    // Load the index.html when not in development
    win.loadURL('app://./index.html')

    log.warn('hello')
    // 判断是否热更新,版本对比
    const updateUrl = 'http://ambow-ebopo.oss-cn-beijing.aliyuncs.com/common/electron'
    const currentVersion = app.getVersion()
    log.warn('currentVersion',currentVersion)    
    axios({
      url: updateUrl + '/latest.yml',
      method: 'GET'
    }).then(res => {      
      const remoteVersion = JSON.stringify(res.data).split('\\n')[0].split(' ')[1]
      log.warn('remoteVersion',remoteVersion)      
      if (currentVersion === remoteVersion) {
        log.warn('版本相同,无需更新')
        return;
      }
      // 热更新
      const updateZipUrl = updateUrl + '/app.zip'
      increment(updateZipUrl)      
    })

  }



  global.sharedObject.win = win
  win.on('closed', () => {
    win = null
    global.sharedObject.win = null
  })


  
}

在increment.js中引入并调用

import downloadFile from './downloadFile'
import global from './global'
const fse = require('fs-extra')
const AdmZip = require('adm-zip')
import log from './log'

export default async (updateZipUrl) => {  
  const resourcesPath = process.resourcesPath
  downloadFile({ url: updateZipUrl, targetPath: resourcesPath }).then(async (filePath) => {
    log.warn('unzip-filePath',filePath)
    const zip = new AdmZip(filePath)
    zip.extractAllToAsync(resourcesPath, true, (err) => {
      if (err) {
        console.error(err)
        return
      }      
      fse.removeSync(filePath)      
      setTimeout(() => {        
        log.warn('relaunch')
        global.sharedObject.win.webContents.reloadIgnoringCache()          
      }, 2000);
    })
  }).catch(err => {
    console.log(err)
  })
}

setTimeout是为了演示效果,最后去掉。

十、遇到的问题

更新时报错,如下:

Error: EPERM: operation not permitted

原因是没有管理员权限,且安装时选择的为所有用户安装。

方案一:运行时选择:以管理员身份运行

方案二:下载时,仅为当前用户安装

解决方案:

在设置上,不给用户选择为所有用户安装的机会,默认一键安装,设置如下

"nsis": {
    "oneClick": true, // 是否一键安装          
    "installerIcon": "./public/app.ico",// 安装图标
    "uninstallerIcon": "./public/app.ico",//卸载图标
    "installerHeaderIcon": "./public/app.ico", // 安装时头部图标
    "createDesktopShortcut": true, // 创建桌面图标
    "createStartMenuShortcut": true,// 创建开始菜单图标
    "shortcutName": "ebopo", // 图标名称
},

十一、在ts项目中使用

electron-log的使用要用ts文件,并且用any声明,其他相似问题同理。

log.ts

const log:any = require('electron-log');

log.transports.file.level = 'silly'
log.transports.console.level = false // 禁用console输出

export default log

GitHub地址

vue-cli-electron-update-part

参考

https://xuxin123.com/electron/increment-update1/

文档

https://nklayman.github.io/vue-cli-plugin-electron-builder/