electron下的mac无签名证书自动更新

536 阅读3分钟

背景

公司有个项目,需要做个桌面端应用,把某一个后台系统嵌入进去,同时不用花一分钱去上架应用商店和签名,只给内部人员mac或者win电脑上使用,要求能自动更新最新的版本

方案

问题分析

  • 需要嵌入一个后台系统意思是功能和界面长的和web后台系统一样,还得跨端,好像只能使用是electron
  • mac下自动更新需要签名才可以,这里不想签名那就只能自己实现一个伪自动更新 然后分析了下在electron中的自动更新是依赖于另外一个执行文件操作, 安装包下载完成后,不是用node去执行安装的,会调用其他语言的脚本如window下的bat脚本执行校验,解压,替换文件夹,重启。为啥不用node呢,因为解压完后要替换,node的执行依赖于当前项目下的node_modules包,替换命令执行的时候,是先删除node执行环境目录才move的,这时候就会报node不存在的错误。

mac下的electron应用构成

image.png 可以看到Resource中的应用代码,Resource之外基本都是electron要运行的框架代码,基本不变,所以在mac下可以考虑把Resource和应用信息的Info.plist替换即可,或者你也可以像官网一样,用另外一种脚本语言去执行校验,解压,替换文件夹,重启应用。接下来的实现只用node去实现

方案实现

技术选型

  • 框架 electron
  • 打包 electron-builder
  • 自动更新 electron-updater

首先打包时候需要在配置文件electron-builder.js中加上

  releaseInfo: { // 更新日志
    releaseNotes: '更新XXXXXX功能'
  },
  publish: [
    {
      provider: 'generic',
      // 安装包的下载和检查基础路径
      url: 'http://localost/update/'
    }
  ],

这样electron-updater默认就会使用url去获取需要更新的文件

然后在项目中新建一个autoUpdate.js文件,在主进程的app.on('activate', cb)事件回调中触发这个文件中的initUpdater方法

const { autoUpdater } = require('electron-updater')
const AdmZip = require('adm-zip')
const { app } = require('electron')

function unZip(zipPath, extractionPath) {
  return new Promise((resolve, reject) => {
    try {
      const zip = new AdmZip(zipPath)
      zip.extractAllTo(extractionPath, true)
      resolve()
    } catch (err) {
      reject(err)
    }
  })
}

function execMd(md) {
  return new Promise((resolve, reject) => {
    const { exec } = require('node:child_process')
    exec(md, (error, stdout, stderr) => {
      if (error) {
        reject(error)
        return
      }
      resolve()
    })
  })
}

async function startInstall (res) {
    try {
      if (!isMac) {
        // windows安装不需要证书,直接走框架的默认安装
        autoUpdater.quitAndInstall()
        return
      }

      await customInstallMacApp(res)
    } catch (err) {
      log.error('安装失败 => ', err)
    }
}

async function customInstallMacApp(res) {
  const appPath = app.getAppPath() // 包路径,如xxx/asar
  const appFile = appPath.match(/(?<=\/)\W+(?=\.app)/, '')[0] + '.app' // app名称,如demo.app
  const installDir = appPath.replace(new RegExp(`${appFile}.*`), '') // 安装路径,比如/Applications
  const unzipPath = res.downloadedFile.replace(res.path, '') // 安装包下载路径
  const installPath = installDir + appFile // 安装的文件路径,比如/Applications/demo.app

  // 第一步,解压zip压缩包
  await unZip(res.downloadedFile, unzipPath)

  // 安装mac应用, 只替换原有应用下的Resources目录
  await execMd(`rm -rf ${installPath}/Contents/Resources`)
  await execMd(`mv -f ${unzipPath}${appFile}/Contents/Resources ${installPath}/Contents/Resources`)
  app.quit()
  app.relaunch()
}

function initUpdater() {
  autoUpdater.disableWebInstaller = false // 这个写成 false,写成 true 时,可能会报没权限更新
  autoUpdater.autoInstallOnAppQuit = false
  autoUpdater.autoDownload = false

  autoUpdater.on('update-available', (info) => {
    const { version } = info
    // 这里做的是检测到更新,直接就下载
    autoUpdater.downloadUpdate()
  })

  // 在更新下载完成的时候触发。
  autoUpdater.on('update-downloaded', async (res) => {
    // 下载完毕, 退出应用,安装开始
    startInstall(res)
  })

  // 让应用跑一会儿,在检查更新,避免UI冲突
  setTimeout(() => {
    autoUpdater.checkForUpdates()
  }, 5000)
}

验证阶段

对于正常electron开发者来说,update-downloaded这个事件肯定是能走到的,只是卡在安装这个问题上,这里为了验证结果,可以模拟下update-downloaded事件的返回

const res = {
    version: '1.0.1',
    files: [
      {
        url: 'xxx-1.0.1-arm64-mac.zip',
        sha512:
          'w2WBWauiXCBa6QP13596RgREoGN5jlAOsNzUYKBhK/6EYTWL4c+5qh3hSSrTcMPDPS0A5AxGssao/rOo41remg==',
        size: 203435203
      },
      {
        url: 'xxx-1.0.1-arm64.dmg',
        sha512:
          'dWtzvrTkWeAlGBjkVkAU8E9F+8uiBGEfFmmD/r6Syx0l8+1l/shNHQ9yLTUAz1dnnQuymX/DPQumyM17zsp8gg==',
        size: 210364932
      }
    ],
    path: 'xxx-1.0.1-arm64-mac.zip',
    sha512:
      'w2WBWauiXCBa6QP13596RgREoGN5jlAOsNzUYKBhK/6EYTWL4c+5qh3hSSrTcMPDPS0A5AxG3bao/uOo41remg==',
    releaseNotes: '<ul><li>更新了xxx</li><li>更新了xxx</li></ul>',
    releaseDate: '2025-05-11T05:23:26.014Z',
    downloadedFile:
      '/Users/xxxx/Library/Caches/yourproject-updater/pending/xxx-1.0.0-arm64-mac.zip'
  }
  setTimeout(() => {
    startInstall(res)
    autoUpdater.checkForUpdates()
  }, 5000)

结语

这种更新模式适合内部人员使用,不想频繁去链接上下载。