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脚本通过参数的不同执行不同的安装流程

至此插件的核心流程简单梳理结束。
相关推荐
酷爱码12 分钟前
css中的 vertical-align与line-height作用详解
前端·css
沐土Arvin26 分钟前
深入理解 requestIdleCallback:浏览器空闲时段的性能优化利器
开发语言·前端·javascript·设计模式·html
专注VB编程开发20年28 分钟前
VB.NET关于接口实现与简化设计的分析,封装其他类
java·前端·数据库
小妖66637 分钟前
css 中 content: “\e6d0“ 怎么变成图标的?
前端·css
L耀早睡1 小时前
mapreduce打包运行
大数据·前端·spark·mapreduce
HouGISer2 小时前
副业小程序YUERGS,从开发到变现
前端·小程序
outstanding木槿2 小时前
react中安装依赖时的问题 【集合】
前端·javascript·react.js·node.js
霸王蟹2 小时前
React中useState中更新是同步的还是异步的?
前端·javascript·笔记·学习·react.js·前端框架
霸王蟹2 小时前
React Hooks 必须在组件最顶层调用的原因解析
前端·javascript·笔记·学习·react.js
专注VB编程开发20年3 小时前
asp.net IHttpHandler 对分块传输编码的支持,IIs web服务器后端技术
服务器·前端·asp.net