Source: version/VersionEntry.js

// import dependencies
import semver from 'semver';
import sort from 'semver-sort';
import {getBranchHash} from '../utils';
import {download, unzip} from '../utils/files'
import Rollout from './Rollout'

/**
 * A VersionEntry holds all the metadata about a given build. The VersionEntry instance can be used to
 * interact with and update build metadata, compare the entry to another one, and even download & extract
 * the VersionEntry.
 */
class VersionEntry {
  constructor(props = {}) {
    this.hash = props.hash
    this.autoUpdate = props.autoUpdate !== undefined ? props.autoUpdate : true
    this.promptUpdate = props.promptUpdate !== undefined ? props.promptUpdate : true
    this.forceUpdate = props.forceUpdate !== undefined ? props.forceUpdate : false
    this.patches = props.patches
    this.origin = props.origin
    this.semver = props.semver
    this.downloadUrl = props.downloadUrl
    this.checksums = props.checksums
    this.releaseTimestamp = props.releaseTimestamp

    // dependencies are used to determine if the module version is dependent on
    // another specific module version
    this.dependencies = props.dependencies;
    this.platform = props.platform || process.platform;

    // used for electron autoupdate module, for distributingnew electron binaries
    this.installerUrl = props.installerUrl;
    this.nupkgUrl = props.nupkgUrl;
    this.nupkgReleasesFileUrl = props.nupkgReleasesFileUrl;

    // rollout configuration
    if (props.rollout !== undefined) {
      this.rollout = new Rollout(props.rollout)
    }

    this.version = props.version // legacy
    this.legacy = !(!!props.semver && !!props.hash) // legacy

    if (!props.version && !props.semver) {
      throw new Error('New entry is missing property: version or semver')
    }

    if (!this.legacy) {
      if (!props.hash) {
        throw new Error('New entry is missing property: hash')
      } else if (!props.branchName && !props.origin) {
        throw new Error('New entry is missing property: branchName or origin')
      }

      // set values for non-legacy versions
      this.timestamp = props.timestamp || new Date().getTime()
      this.setBranch(props.branchName || props.origin, !props.branchName)
    }

    this.setVersion(props.semver || props.version)
  }

  /**
   * Set the version entry to the specified semver.
   * @param {String} version
   */
  setVersion (version) {
    version = semver.coerce(version).version
    this.semver = version
    this.version = `v${version}` // legacy
    if (this.promptUpdate === undefined) {
      this.promptUpdate = semver.patch(version) === 0 // set true by default when not already set
    }
    return this
  }

  /**
   * Set the origin branch for the {@link VersionEntry}.
   * @param {String} branchName
   * @param {Boolean} isOrigin
   * @returns {VersionEntry}
   */
  setBranch (branchName, isOrigin = false) {
    this.origin = isOrigin
      ? branchName
      : getBranchHash(branchName).shortHash;
    return this
  }

  /**
   * Set the commit hash for the {@link VersionEntry}.
   * @param {String} shortHash
   * @returns {VersionEntry}
   */
  setCommitHash (shortHash) {
    this.hash = shortHash
    return this
  }

  /**
   * Set the patches for the {@link VersionEntry}.
   * @param {VersionEntry[]} patches
   * @returns {VersionEntry}
   */
  setPatches (patches) {
    this.patches = patches
    return this
  }

  /**
   * Set the dependency on another module, specifying the origin branch and the semver to target.
   * For example, electron can be dependent on a specific recorder version or vice-versa.
   * @param module
   * @param semver
   */
  setDependency (module, semver) {
    if (!this.dependencies) {
      this.dependencies = {};
    }
    this.dependencies[module] = semver
    return this
  }

  /**
   * Set the timestamp of the release (not the build itself)
   * @param timestamp
   * @returns {VersionEntry}
   */
  setReleaseTimestamp (timestamp = Date.now()) {
    this.releaseTimestamp = timestamp
    return this
  }

  /**
   * Set the {@link Rollout} configuration for this {@link VersionEntry}
   * @param rollout
   */
  setRollout ({direction, seed, percentage, totalBuckets}) {
    this.rollout = new Rollout({
      direction,
      percentage,
      seed,
      totalBuckets
    })
    return this
  }

  /**
   * Return true if this is a legacy entry (pre-update refactor, no semver or origin props etc).
   * @returns {*|boolean}
   */
  isLegacy () {
    return this.legacy;
  }

  /**
   * Add a patch to the {@link VersionEntry}.
   * @param patch
   * @returns {VersionEntry}
   */
  addPatch (patch) {
    // @todo validate the patch in some better way
    if (!patch.hash || !patch.semver || !patch.origin) {
      throw new Error('Cannot add invalid patch: ' + JSON.stringify(patch))
    }
    if (!this.patches) this.patches = []
    this.patches.push(patch)

    // map the versions to an array, then sort ascending for patches
    const sortedVersionStrings = sort.asc(this.patches.map(v => (v.semver || semver.coerce(v.version).version)));

    // map back to an array of version entries
    this.patches = sortedVersionStrings.map(v => {
      return this.patches.find(rv => {
        return semver.coerce(rv.semver || rv.version).version === v;
      })
    })

    return this
  }

  /**
   * Set the force update state for the {@link VersionEntry}.
   * If true, the users app will restart at the first chance it gets (outside of game) and install the update. Default is false.
   * @param {Boolean} forceUpdate
   * @returns {VersionEntry}
   */
  setForceUpdate (forceUpdate) {
    this.forceUpdate = this.forced = forceUpdate
    this.forced = forceUpdate // legacy
    return this
  }

  /**
   * Set the prompt update state for the {@link VersionEntry}.
   * If true, the user will be shown UI prompts to update the app. Default is true.
   * @param {Boolean} promptUpdate
   * @returns {VersionEntry}
   */
  setPromptUpdate (promptUpdate) {
    this.promptUpdate = promptUpdate
    return this
  }

  /**
   * Set whether or not this {@link VersionEntry} should automatically install updates
   * on application launch.
   * @param autoUpdate
   */
  setAutoUpdate (autoUpdate) {
    this.autoUpdate = autoUpdate;
    return this
  }

  /**
   * Set the url for the installer MedalSetup.exe for this version.
   * @param installer
   * @returns {VersionEntry}
   */
  setInstallerUrl (installer) {
    this.installerUrl = installer;
    return this;
  }

  /**
   * Set the url for the NuGet package for this version.
   * @param nupkgUrl
   * @returns {VersionEntry}
   */
  setNupkgUrl (nupkgUrl) {
    this.nupkgUrl = nupkgUrl;
    return this;
  }

  /**
   * Set the url for the NuGet RELEASES file for this version.
   * @param nupkgReleasesFileUrl
   * @returns {VersionEntry}
   */
  setNupkgReleasesFileUrl (nupkgReleasesFileUrl) {
    this.nupkgReleasesFileUrl = nupkgReleasesFileUrl;
    return this;
  }

  /**
   * Update the checksums of the {@link VersionEntry}.
   * @param checksums
   * @returns {VersionEntry}
   */
  setChecksums (checksums) {
    this.checksums = checksums;
    return this;
  }

  /**
   * Return true if this {@link VersionEntry} is a mutation of the specified {@link VersionEntry}, or vice versa.
   * @param {VersionEntry} compareTo
   * @param opts
   * @returns {boolean}
   */
  isMutationOf (compareTo, opts = {matchOrigin: true, matchMajorMinor: true, matchSemver: true}) {
    if (!this.platform !== compareTo.platform) {
      return false
    }

    // mutation = version match, origin branch match, or one is a patch of the other
    if (opts?.matchSemver !== false && semver.eq(this.semver, compareTo.semver)) {
      return true
    }
    if (opts?.matchMajorMinor !== false && semver.major(this.semver) === semver.major(compareTo.semver) && semver.minor(this.semver) === semver.minor(compareTo.semver)) {
      return true
    }
    if (opts?.matchOrigin !== false && this.origin && compareTo.origin && this.origin === compareTo.origin) {
      return true
    }
    return false
  }

  /**
   * Return true if this entry is a mutation of the comparison {@link VersionEntry}, or vice versa.
   * @param {VersionEntry} compareTo
   * @returns {boolean}
   */
  isEqual (compareTo) {
    // mutation = version match, origin branch match,
    // @todo or one is a patch of the other
    return semver.eq(this.semver, compareTo.semver) &&
      this.hash === compareTo.hash &&
      this.origin === compareTo.origin &&
      this.platform === compareTo.platform &&
      this.timestamp === compareTo.timestamp
  }

  /**
   * Download the update package for this {@link VersionEntry} to the specified destination.
   * @param downloadPath
   * @param onProgress
   */
  async download (downloadPath, onProgress = () => {}) {
    return download(this.downloadUrl, downloadPath, onProgress);
  }

  /**
   * Extract the update package for this {@link VersionEntry} to the specified destination.
   */
  async extract (src, dest) {
    return unzip(src, dest);
  }

  /**
   * Convert to a parsed JSON object.
   * @returns {Object}
   */
  toJson () {
    const json = JSON.parse(JSON.stringify(this))
    if (json.legacy !== undefined) {
      delete json.legacy
    }
    return json
  }
}

export default VersionEntry;