electron-updater 核心源码解析

electron-updater 核心源码解析

插件的源码实现我们根据业务调用的流程去看相对来说好理解,本文档主要根据更新流程来梳理,有兴趣的同学可以扩展其他方面

问题1: 如何实现跨系统兼容
问题2: 为什么本地无法调试
问题3: 如何判断是否需要升级及下载
问题4: 如果进行更新

1..插件各核心模块流程图

2.根据业务层调用解析实现思路

代码中标红色为核心的钩子和配置项

1. 插件入口main.ts

用来区分不同系统的升级流程,此次解析主要以window系统流程为主

ini 复制代码
 if (process.platform === "win32") {
    _autoUpdater = new (require("./NsisUpdater").NsisUpdater)()
  } else if (process.platform === "darwin") {
    _autoUpdater = new (require("./MacUpdater").MacUpdater)()
  } else {
    _autoUpdater = new (require("./AppImageUpdater").AppImageUpdater)()
     ......
  }  
2.1 setFeedURL

此函数的主要作用是将业务层传入的url处理之后生成createClient, 此函数的主要作用是读取文件夹下的latest.yml 文件来获取更新程序的版本信息

typescript 复制代码
  setFeedURL(options: PublishConfiguration | AllPublishOptions | string) {
    const runtimeOptions = this.createProviderRuntimeOptions()
    // https://github.com/electron-userland/electron-builder/issues/1105
    let provider: Provider<any>
    if (typeof options === "string") {
      provider = new GenericProvider({ provider: "generic", url: options }, this, {
        ...runtimeOptions,
        isUseMultipleRangeRequest: isUrlProbablySupportMultiRangeRequests(options),
      })
    } else {
      provider = createClient(options, this, runtimeOptions)
    }
    this.clientPromise = Promise.resolve(provider)
  }
2.2 checkForUpdates

此函数的核心逻辑是:通过下载latest.yml 文件,根据yml的版本信息和本地的版本信息做比对,来确定是否需要升级

kotlin 复制代码
  private async doCheckForUpdates(): Promise<UpdateCheckResult> {
    this.emit("checking-for-update")

    const result = await this.getUpdateInfoAndProvider()
    const updateInfo = result.info
    if (!(await this.isUpdateAvailable(updateInfo))) {
      this._logger.info(
        `Update for version ${this.currentVersion.format()} is not available (latest version: ${updateInfo.version}, downgrade is ${
          this.allowDowngrade ? "allowed" : "disallowed"
        }).`
      )
      this.emit("update-not-available", updateInfo)
      return {
        versionInfo: updateInfo,
        updateInfo,
      }
    }

    this.updateInfoAndProvider = result
    this.onUpdateAvailable(updateInfo)  // 钩子函数 update-available

    const cancellationToken = new CancellationToken()
    //noinspection ES6MissingAwait
    return {
      versionInfo: updateInfo,
      updateInfo,
      cancellationToken,
      // 此时如果配置 autoDownload 则会自动进行下载
      downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null,
    }
  }



  private async isUpdateAvailable(updateInfo: UpdateInfo): Promise<boolean> {
    const latestVersion = parseVersion(updateInfo.version)
    if (latestVersion == null) {
      throw newError(
        `This file could not be downloaded, or the latest version (from update server) does not have a valid semver version: "${updateInfo.version}"`,
        "ERR_UPDATER_INVALID_VERSION"
      )
    }

    const currentVersion = this.currentVersion
    //  判断当前版本和云端配置文件yml 中的版本
    if (isVersionsEqual(latestVersion, currentVersion)) {
      return false
    }

  // 判断支持的系统版本范围
    const minimumSystemVersion = updateInfo?.minimumSystemVersion
    const currentOSVersion = release()
    if (minimumSystemVersion) {
      try {
        if (isVersionLessThan(currentOSVersion, minimumSystemVersion)) {
          this._logger.info(`Current OS version ${currentOSVersion} is less than the minimum OS version required ${minimumSystemVersion} for version ${currentOSVersion}`)
          return false
        }
      } catch (e: any) {
        this._logger.warn(`Failed to compare current OS version(${currentOSVersion}) with minimum OS version(${minimumSystemVersion}): ${(e.message || e).toString()}`)
      }
    }

    const isStagingMatch = await this.isStagingMatch(updateInfo)
    if (!isStagingMatch) {
      return false
    }

    // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405033227
    // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405030797
    const isLatestVersionNewer = isVersionGreaterThan(latestVersion, currentVersion)
    const isLatestVersionOlder = isVersionLessThan(latestVersion, currentVersion)

    if (isLatestVersionNewer) {
      return true
    }
    return this.allowDowngrade && isLatestVersionOlder
  }


//  这里如果不打包 this.app.isPackaged 且不配置  forceDevUpdateConfig(在json的nsis中进行配置)时,本地是无法进行调试的
    public isUpdaterActive(): boolean {
    const isEnabled = this.app.isPackaged || this.forceDevUpdateConfig
    if (!isEnabled) {
      this._logger.info("Skip checkForUpdates because application is not packed and dev update config is not forced")
      return false
    }
    return true
  }

调用此函数会首先触发钩子 "checking-for-update",

如果没有要更新的则触发 "update-not-available",

有需要更新时触发"update-available",如果用户设置的 autoDownload为true 时会自动下载,否则需要手动调用 downloadUpdate 函数

2.3 downloadUpdate

此函数的核心逻辑是:通过云端yml 文件和本地update-info.json 文件来判断比对是否需要下载升级包,如果本地存在,则直接进行升级,如果本地不存在,则先进行下载

kotlin 复制代码
  downloadUpdate(cancellationToken: CancellationToken = new CancellationToken()): Promise<Array<string>> {
    const updateInfoAndProvider = this.updateInfoAndProvider
    if (updateInfoAndProvider == null) {
      const error = new Error("Please check update first")
      this.dispatchError(error)
      return Promise.reject(error)
    }

    if (this.downloadPromise != null) {
      this._logger.info("Downloading update (already in progress)")
      return this.downloadPromise
    }

    this._logger.info(
      `Downloading update from ${asArray(updateInfoAndProvider.info.files)
        .map(it => it.url)
        .join(", ")}`
    )
    const errorHandler = (e: Error): Error => {
      // https://github.com/electron-userland/electron-builder/issues/1150#issuecomment-436891159
      if (!(e instanceof CancellationError)) {
        try {
          this.dispatchError(e)
        } catch (nestedError: any) {
          this._logger.warn(`Cannot dispatch error event: ${nestedError.stack || nestedError}`)
        }
      }

      return e
    }

    this.downloadPromise = this.doDownloadUpdate({
      updateInfoAndProvider,
      requestHeaders: this.computeRequestHeaders(updateInfoAndProvider.provider),
      cancellationToken,
      disableWebInstaller: this.disableWebInstaller,
      disableDifferentialDownload: this.disableDifferentialDownload,
    })
      .catch((e: any) => {
        throw errorHandler(e)
      })
      .finally(() => {
        this.downloadPromise = null
      })

    return this.downloadPromise
  }

  ...

  关键函数
  // 处理下载路径
  export function findFile(files: Array<ResolvedUpdateFileInfo>, extension: string, not?: Array<string>): ResolvedUpdateFileInfo | null | undefined {
  if (files.length === 0) {
    throw newError("No files provided", "ERR_UPDATER_NO_FILES_PROVIDED")
  }

  const result = files.find(it => it.url.pathname.toLowerCase().endsWith(`.${extension}`))
  if (result != null) {
    return result
  } else if (not == null) {
    return files[0]
  } else {
    return files.find(fileInfo => !not.some(ext => fileInfo.url.pathname.toLowerCase().endsWith(`.${ext}`)))
  }
}


// 下载文件
protected async executeDownload(taskOptions: DownloadExecutorTask): Promise<Array<string>> {
    const fileInfo = taskOptions.fileInfo
    const downloadOptions: DownloadOptions = {
      headers: taskOptions.downloadUpdateOptions.requestHeaders,
      cancellationToken: taskOptions.downloadUpdateOptions.cancellationToken,
      sha2: (fileInfo.info as any).sha2,
      sha512: fileInfo.info.sha512,
    }

   // 钩子函数 download-progress
    if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) {
      downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it)
    }

    const updateInfo = taskOptions.downloadUpdateOptions.updateInfoAndProvider.info
    const version = updateInfo.version
    const packageInfo = fileInfo.packageInfo

    function getCacheUpdateFileName(): string {
      // NodeJS URL doesn't decode automatically
      const urlPath = decodeURIComponent(taskOptions.fileInfo.url.pathname)
      if (urlPath.endsWith(`.${taskOptions.fileExtension}`)) {
        return path.basename(urlPath)
      } else {
        // url like /latest, generate name
        return taskOptions.fileInfo.info.url
      }
    }

    const downloadedUpdateHelper = await this.getOrCreateDownloadHelper()
    const cacheDir = downloadedUpdateHelper.cacheDirForPendingUpdate
    await mkdir(cacheDir, { recursive: true })
    const updateFileName = getCacheUpdateFileName()
    let updateFile = path.join(cacheDir, updateFileName)
    const packageFile = packageInfo == null ? null : path.join(cacheDir, `package-${version}${path.extname(packageInfo.path) || ".7z"}`)

  // 此函数用来写入本地update-info.json
    const done = async (isSaveCache: boolean) => {
      await downloadedUpdateHelper.setDownloadedFile(updateFile, packageFile, updateInfo, fileInfo, updateFileName, isSaveCache)
      await taskOptions.done!({
        ...updateInfo,
        downloadedFile: updateFile,
      })
      return packageFile == null ? [updateFile] : [updateFile, packageFile]
    }

    const log = this._logger
    // 此函数是云端yml文件和update-info.json 进行判断比较,用来确定是否需要下载
    const cachedUpdateFile = await downloadedUpdateHelper.validateDownloadedPath(updateFile, updateInfo, fileInfo, log)
    if (cachedUpdateFile != null) {
      updateFile = cachedUpdateFile
      return await done(false)
    }

    const removeFileIfAny = async () => {
      await downloadedUpdateHelper.clear().catch(() => {
        // ignore
      })
      return await unlink(updateFile).catch(() => {
        // ignore
      })
    }

    const tempUpdateFile = await createTempUpdateFile(`temp-${updateFileName}`, cacheDir, log)
    try {
      await taskOptions.task(tempUpdateFile, downloadOptions, packageFile, removeFileIfAny)
      await retry(
        () => rename(tempUpdateFile, updateFile),
        60,
        500,
        0,
        0,
        error => error instanceof Error && /^EBUSY:/.test(error.message)
      )
    } catch (e: any) {
      await removeFileIfAny()

      if (e instanceof CancellationError) {
        log.info("cancelled")
        this.emit("update-cancelled", updateInfo)
      }
      throw e
    }

    log.info(`New version ${version} has been downloaded to ${updateFile}`)
    return await done(true)
  }

// 下载成功
  protected executeDownload(taskOptions: DownloadExecutorTask): Promise<Array<string>> {
    return super.executeDownload({
      ...taskOptions,
      done: event => {
        this.dispatchUpdateDownloaded(event) // 钩子函数 update-downloaded
        this.addQuitHandler()
        return Promise.resolve()
      },
    })
  }

 // 下载完整之后的更新逻辑

  // 处理退出逻辑
  autoUpdater.autoInstallOnAppQuit = false 
  
   protected addQuitHandler(): void {
   // 根据 autoInstallOnAppQuit 来确定是否自动退出升级 
    if (this.quitHandlerAdded || !this.autoInstallOnAppQuit) {
      return
    }

    this.quitHandlerAdded = true

    this.app.onQuit(exitCode => {
      if (this.quitAndInstallCalled) {
        this._logger.info("Update installer has already been triggered. Quitting application.")
        return
      }

      if (!this.autoInstallOnAppQuit) {
        this._logger.info("Update will not be installed on quit because autoInstallOnAppQuit is set to false.")
        return
      }

      if (exitCode !== 0) {
        this._logger.info(`Update will be not installed on quit because application is quitting with exit code ${exitCode}`)
        return
      }

      this._logger.info("Auto install update on quit")
      this.install(true, false)
    })
  }
2.4 quitAndInstall

此函数的核心逻辑是:通过不同的策略生成不同的启动参数传给exe, exe安装时nsis根据不同的参数执行不同的安装流程, 要说明的是,此流程参数为electron-builder 自带的打包生成的exe,云盘客户端为自定义nsis 打包,此传参不适用,目前只集成了 /S 静默升级流程

kotlin 复制代码
  quitAndInstall(isSilent = false, isForceRunAfter = false): void {
    this._logger.info(`Install on explicit quitAndInstall`)
    const isInstalled = this.install(isSilent, isSilent ? isForceRunAfter : this.autoRunAppAfterInstall)
    if (isInstalled) {
      setImmediate(() => {
        // this event is normally emitted when calling quitAndInstall, this emulates that
        require("electron").autoUpdater.emit("before-quit-for-update")
        this.app.quit()
      })
    } else {
      this.quitAndInstallCalled = false
    }
  }


  
// 设置exe  启动参数
 protected doInstall(options: InstallOptions): boolean {
    const args = ["--updated"]
    if (options.isSilent) {  // 是否静默
      args.push("/S")
    }

    if (options.isForceRunAfter) {  // 是否强制启动
      args.push("--force-run")
    }

    if (this.installDirectory) {    // 默认路径,设置在nsis - installDirectory
      // maybe check if folder exists
      args.push(`/D=${this.installDirectory}`)
    }

    const packagePath = this.downloadedUpdateHelper == null ? null : this.downloadedUpdateHelper.packageFile
    if (packagePath != null) {
      // only = form is supported
      args.push(`--package-file=${packagePath}`)
    }

    const callUsingElevation = (): void => {
      this.spawnLog(path.join(process.resourcesPath, "elevate.exe"), [options.installerPath].concat(args)).catch(e => this.dispatchError(e))
    }

    if (options.isAdminRightsRequired) {
      this._logger.info("isAdminRightsRequired is set to true, run installer using elevate.exe")
      callUsingElevation()
      return true
    }

    this.spawnLog(options.installerPath, args).catch((e: Error) => {
      // https://github.com/electron-userland/electron-builder/issues/1129
      // Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors
      const errorCode = (e as NodeJS.ErrnoException).code
      this._logger.info(
        `Cannot run installer: error code: ${errorCode}, error message: "${e.message}", will be executed again using elevate if EACCES, and will try to use electron.shell.openItem if ENOENT`
      )
      if (errorCode === "UNKNOWN" || errorCode === "EACCES") {
        callUsingElevation()
      } else if (errorCode === "ENOENT") {
        require("electron")
          .shell.openPath(options.installerPath)
          .catch((err: Error) => this.dispatchError(err))
      } else {
        this.dispatchError(e)
      }
    })
    return true
  }

注:启动参数为electron-builder 打包之后的安装流程,云盘客户端安装卸载使用nsis自定义开发,上述配置只有 静默安装 /S 有效,其余参数无效

3.问题回溯

问题1: 如何实现跨系统兼容

同一入口文件,通过不同的环境变量来区分加载不同系统的升级文件

问题2: 为什么本地无法调试

代码内容做了拦截判断,本地需要配置 forceDevUpdateConfig

问题3: 如何判断是否需要升级及下载

通过读取升级文件夹下的yml 来获取最新版本的相关信息

问题4: 如果进行更新

通过各种配置策略生成启动命令参数,然后直接启动exe 进行升级 ,nsis脚本通过参数的不同执行不同的安装流程

至此插件的核心流程简单梳理结束。
相关推荐
泯泷4 分钟前
「译」解析 JavaScript 中的循环依赖
前端·javascript·架构
抹茶san7 分钟前
前端实战:从 0 开始搭建 pnpm 单一仓库(1)
前端·架构
Senar34 分钟前
Web端选择本地文件的几种方式
前端·javascript·html
烛阴1 小时前
UV Coordinates & Uniforms -- OpenGL UV坐标和Uniform变量
前端·webgl
姑苏洛言1 小时前
扫码小程序实现仓库进销存管理中遇到的问题 setStorageSync 存储大小限制错误解决方案
前端·后端
烛阴1 小时前
JavaScript 的 8 大“阴间陷阱”,你绝对踩过!99% 程序员崩溃瞬间
前端·javascript·面试
lh_12542 小时前
ECharts 地图开发入门
前端·javascript·echarts
jjw_zyfx2 小时前
成熟的前端vue vite websocket,Django后端实现方案包含主动断开websocket连接的实现
前端·vue.js·websocket
Mikey_n2 小时前
前台调用接口的方式及速率对比
前端
周之鸥2 小时前
使用 Electron 打包可执行文件和资源:完整实战教程
前端·javascript·electron