/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  UpdateLog: "resource://gre/modules/UpdateLog.sys.mjs",
  UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
});

const PREF_APP_UPDATE_CANCELATIONS_OSX = "app.update.cancelations.osx";
const PREF_APP_UPDATE_ELEVATE_NEVER = "app.update.elevate.never";

class AbortError extends Error {
  constructor(...params) {
    super(...params);
    this.name = this.constructor.name;
  }
}

/**
 * `AbortablePromise`s automatically add themselves to this set on construction
 * and remove themselves when they settle.
 */
var gPendingAbortablePromises = new Set();

/**
 * Creates a Promise that can be resolved immediately with an abort method.
 *
 * Note that the underlying Promise will probably still run to completion since
 * there isn't any general way to abort Promises. So if it is possible to abort
 * the operation instead or in addition to using this class, that is preferable.
 */
class AbortablePromise {
  #abortFn;
  #promise;
  #hasCompleted = false;

  constructor(promise) {
    let abortPromise = new Promise((resolve, reject) => {
      this.#abortFn = () => reject(new AbortError());
    });
    this.#promise = Promise.race([promise, abortPromise]);
    this.#promise = this.#promise.finally(() => {
      this.#hasCompleted = true;
      gPendingAbortablePromises.delete(this);
    });
    gPendingAbortablePromises.add(this);
  }

  abort() {
    if (this.#hasCompleted) {
      return;
    }
    this.#abortFn();
  }

  /**
   * This can be `await`ed on to get the result of the `AbortablePromise`. It
   * will resolve with the value that the Promise provided to the constructor
   * resolves with.
   */
  get promise() {
    return this.#promise;
  }

  /**
   * Will be `true` if the Promise provided to the constructor has resolved or
   * `abort()` has been called. Otherwise `false`.
   */
  get hasCompleted() {
    return this.#hasCompleted;
  }
}

function makeAbortable(promise) {
  let abortable = new AbortablePromise(promise);
  return abortable.promise;
}

function abortAllPromises() {
  for (const promise of gPendingAbortablePromises) {
    promise.abort();
  }
}

/**
 * This class checks for app updates in the foreground.  It has several public
 * methods for checking for updates, downloading updates, stopping the current
 * update, and getting the current update status.  It can also register
 * listeners that will be called back as different stages of updates occur.
 */
export class AppUpdater {
  #listeners = new Set();
  #status = AppUpdater.STATUS.NEVER_CHECKED;
  // This will basically be set to `true` when `AppUpdater.check` is called and
  // back to `false` right before it returns.
  // It is also set to `true` during an update swap and back to `false` when the
  // swap completes.
  #updateBusy = false;
  // When settings require that the user be asked for permission to download
  // updates and we have an update to download, we will assign a function.
  // Calling this function allows the download to proceed.
  #permissionToDownloadGivenFn = null;
  #_update = null;
  // Keeps track of if we have an `update-swap` listener connected. We only
  // connect it when the status is `READY_TO_RESTART`, but we can't use that to
  // tell if its connected because we might be in the middle of an update swap
  // in which case the status will have temporarily changed.
  #swapListenerConnected = false;

  constructor() {
    try {
      this.QueryInterface = ChromeUtils.generateQI([
        "nsIObserver",
        "nsISupportsWeakReference",
      ]);
    } catch (e) {
      this.#onException(e);
    }
  }

  #onException(exception) {
    try {
      this.#update = null;

      if (this.#swapListenerConnected) {
        LOG("AppUpdater:#onException - Removing update-swap listener");
        Services.obs.removeObserver(this, "update-swap");
        this.#swapListenerConnected = false;
      }

      if (exception instanceof AbortError) {
        // This should be where we end up if `AppUpdater.stop()` is called while
        // `AppUpdater.check` is running or during an update swap.
        LOG(
          "AppUpdater:#onException - Caught AbortError. Setting status " +
            "NEVER_CHECKED"
        );
        this.#setStatus(AppUpdater.STATUS.NEVER_CHECKED);
      } else {
        LOG(
          "AppUpdater:#onException - Exception caught. Setting status " +
            "INTERNAL_ERROR"
        );
        console.error(exception);
        this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
      }
    } catch (e) {
      LOG(
        "AppUpdater:#onException - Caught additional exception while " +
          "handling previous exception"
      );
      console.error(e);
    }
  }

  /**
   * This can be accessed by consumers to inspect the update that is being
   * prepared for installation. It will always be null if `AppUpdater.check`
   * hasn't been called yet. `AppUpdater.check` will set it to an instance of
   * nsIUpdate once there is one available. This may be immediate, if an update
   * is already downloading or has been downloaded. It may be delayed if an
   * update check needs to be performed first. It also may remain null if the
   * browser is up to date or if the update check fails.
   *
   * Regarding the difference between `AppUpdater.update`, `AppUpdater.#update`,
   * and `AppUpdater.#_update`:
   *  - `AppUpdater.update` and `AppUpdater.#update` are effectively identical
   *    except that `AppUpdater.update` is readonly since it should not be
   *    changed from outside this class.
   *  - `AppUpdater.#_update` should only ever be modified by the setter for
   *    `AppUpdater.#update` in order to ensure that the "foregroundDownload"
   *    property is set on assignment.
   * The quick and easy rule for using these is to always use `#update`
   * internally and (of course) always use `update` externally.
   */
  get update() {
    return this.#update;
  }

  get #update() {
    return this.#_update;
  }

  set #update(update) {
    this.#_update = update;
    if (this.#_update) {
      this.#_update.QueryInterface(Ci.nsIWritablePropertyBag);
      this.#_update.setProperty("foregroundDownload", "true");
    }
  }

  /**
   * The main entry point for checking for updates.  As different stages of the
   * check and possible subsequent update occur, the updater's status is set and
   * listeners are called.
   *
   * Note that this is the correct entry point, regardless of the current state
   * of the updater. Although the function name suggests that this function will
   * start an update check, it will only do that if we aren't already in the
   * update process. Otherwise, it will simply monitor the update process,
   * update its own status, and call listeners.
   *
   * This function is async and will resolve when the update is ready to
   * install, or a failure state is reached.
   * However, most callers probably don't actually want to track its progress by
   * awaiting on this function. More likely, it is desired to kick this function
   * off without awaiting and add a listener via addListener. This allows the
   * caller to see when the updater is checking for an update, downloading it,
   * etc rather than just knowing "now it's running" and "now it's done".
   *
   * Note that calling this function while this instance is already performing
   * or monitoring an update check/download will have no effect. In other words,
   * it is only really necessary/useful to call this function when the status is
   * `NEVER_CHECKED` or `NO_UPDATES_FOUND`.
   *
   * @param  {object} optional parameters
   *  {boolean} checkOnly
   *    If `true`, this function performs only a check and will not begin
   *    downloading an update. The update process can be resumed by calling the
   *    `allowUpdateDownload` method.
   */
  async check({ checkOnly = false } = {}) {
    try {
      // We don't want to end up with multiple instances of the same `async`
      // functions waiting on the same events, so if we are already busy going
      // through the update state, don't enter this function. This must not
      // be in the try/catch that sets #updateBusy to false in its finally
      // block.
      if (this.#updateBusy) {
        return;
      }
    } catch (e) {
      this.#onException(e);
    }

    try {
      this.#updateBusy = true;
      this.#update = null;
      this.#status = AppUpdater.STATUS.NEVER_CHECKED;
      this.#permissionToDownloadGivenFn = null;

      if (this.#swapListenerConnected) {
        LOG("AppUpdater:check - Removing update-swap listener");
        Services.obs.removeObserver(this, "update-swap");
        this.#swapListenerConnected = false;
      }

      if (!AppConstants.MOZ_UPDATER || this.#updateDisabledByPackage) {
        LOG(
          "AppUpdater:check -" +
            "AppConstants.MOZ_UPDATER=" +
            AppConstants.MOZ_UPDATER +
            "this.#updateDisabledByPackage: " +
            this.#updateDisabledByPackage
        );
        this.#setStatus(AppUpdater.STATUS.NO_UPDATER);
        return;
      }

      if (this.aus.disabled) {
        LOG("AppUpdater:check - AUS disabled");
        this.#setStatus(AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY);
        return;
      }

      let updateState = this.aus.currentState;
      let stateName = this.aus.getStateName(updateState);
      LOG(`AppUpdater:check - currentState=${stateName}`);

      if (updateState == Ci.nsIApplicationUpdateService.STATE_PENDING) {
        LOG("AppUpdater:check - ready for restart");
        this.#onReadyToRestart();
        return;
      }

      if (this.aus.isOtherInstanceHandlingUpdates) {
        LOG("AppUpdater:check - this.aus.isOtherInstanceHandlingUpdates");
        this.#setStatus(AppUpdater.STATUS.OTHER_INSTANCE_HANDLING_UPDATES);
        return;
      }

      if (updateState == Ci.nsIApplicationUpdateService.STATE_DOWNLOADING) {
        LOG("AppUpdater:check - downloading");
        this.#update = await this.um.getDownloadingUpdate();
        await this.#downloadUpdate();
        return;
      }

      if (updateState == Ci.nsIApplicationUpdateService.STATE_STAGING) {
        LOG("AppUpdater:check - staging");
        this.#update = await this.um.getReadyUpdate();
        await this.#awaitStagingComplete();
        return;
      }

      // Clear prefs that could prevent a user from discovering available updates.
      if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)) {
        Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX);
      }
      if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_NEVER)) {
        Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_NEVER);
      }
      this.#setStatus(AppUpdater.STATUS.CHECKING);
      LOG("AppUpdater:check - starting update check");
      let check = this.checker.checkForUpdates(this.checker.FOREGROUND_CHECK);
      let result;
      try {
        result = await makeAbortable(check.result);
      } catch (e) {
        // If we are aborting, stop the update check on our way out.
        if (e instanceof AbortError) {
          this.checker.stopCheck(check.id);
        }
        throw e;
      }

      if (!result.checksAllowed) {
        // This shouldn't happen. The cases where this can happen should be
        // handled specifically, above.
        LOG("AppUpdater:check - !checksAllowed; INTERNAL_ERROR");
        this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
        return;
      }

      if (!result.succeeded) {
        LOG("AppUpdater:check - Update check failed; CHECKING_FAILED");
        this.#setStatus(AppUpdater.STATUS.CHECKING_FAILED);
        return;
      }

      LOG("AppUpdater:check - Update check succeeded");
      this.#update = await this.aus.selectUpdate(result.updates);
      if (!this.#update) {
        LOG("AppUpdater:check - result: NO_UPDATES_FOUND");
        this.#setStatus(AppUpdater.STATUS.NO_UPDATES_FOUND);
        return;
      }

      if (this.#update.unsupported) {
        LOG("AppUpdater:check - result: UNSUPPORTED SYSTEM");
        this.#setStatus(AppUpdater.STATUS.UNSUPPORTED_SYSTEM);
        return;
      }

      if (!this.aus.canApplyUpdates) {
        LOG("AppUpdater:check - result: MANUAL_UPDATE");
        this.#setStatus(AppUpdater.STATUS.MANUAL_UPDATE);
        return;
      }

      let updateAuto = await makeAbortable(
        lazy.UpdateUtils.getAppUpdateAutoEnabled()
      );
      if (!updateAuto || this.aus.manualUpdateOnly || checkOnly) {
        if (checkOnly) {
          LOG(`AppUpdater:check - Done. checkOnly = ${checkOnly}`);
        } else {
          LOG(
            "AppUpdater:check - Need to wait for user approval to start the " +
              "download."
          );
        }

        let downloadPermissionPromise = new Promise(resolve => {
          this.#permissionToDownloadGivenFn = resolve;
        });
        // There are other interfaces through which the user can start the
        // download, so we want to listen both for permission, and for the
        // download to independently start.
        let downloadStartPromise = Promise.race([
          downloadPermissionPromise,
          this.aus.stateTransition,
        ]);

        this.#setStatus(AppUpdater.STATUS.DOWNLOAD_AND_INSTALL);

        await makeAbortable(downloadStartPromise);
        LOG("AppUpdater:check - Got user approval. Proceeding with download");
        // If we resolved because of `aus.stateTransition`, we may actually be
        // downloading a different update now.
        const downloadingUpdate = await this.um.getDownloadingUpdate();
        if (downloadingUpdate) {
          this.#update = downloadingUpdate;
        }
      } else {
        LOG(
          "AppUpdater:check - updateAuto is active and " +
            "manualUpdateOnlydateOnly is inactive. Start the download."
        );
      }
      await this.#downloadUpdate();
    } catch (e) {
      this.#onException(e);
    } finally {
      this.#updateBusy = false;
    }
  }

  /**
   * This only has an effect if the status is `DOWNLOAD_AND_INSTALL`.This
   * indicates that the user has configured Firefox not to download updates
   * without permission, and we are waiting the user's permission.
   * This function should be called if and only if the user's permission was
   * given as it will allow the update download to proceed.
   */
  allowUpdateDownload() {
    if (this.#permissionToDownloadGivenFn) {
      this.#permissionToDownloadGivenFn();
    }
  }

  // true if updating is disabled because we're running in an app package.
  // This is distinct from aus.disabled because we need to avoid
  // messages being shown to the user about an "administrator" handling
  // updates; packaged apps may be getting updated by an administrator or they
  // may not be, and we don't have a good way to tell the difference from here,
  // so we err to the side of less confusion for unmanaged users.
  get #updateDisabledByPackage() {
    return Services.sysinfo.getProperty("isPackagedApp");
  }

  /**
   * Downloads an update mar or connects to an in-progress download.
   * Doesn't resolve until the update is ready to install, or a failure state
   * is reached.
   */
  async #downloadUpdate() {
    this.#setStatus(AppUpdater.STATUS.DOWNLOADING);

    let result = await this.aus.downloadUpdate(this.#update, false);
    if (result != Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS) {
      LOG("AppUpdater:#downloadUpdate - downloadUpdate failed.");
      this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
      return;
    }

    await this.#awaitDownloadComplete();
  }

  /**
   * Listens for a download to complete.
   * Doesn't resolve until the update is ready to install, or a failure state
   * is reached.
   */
  async #awaitDownloadComplete() {
    // These cases are unlikely, but we might have just completed really fast.
    let updateState = this.aus.currentState;
    switch (updateState) {
      case Ci.nsIApplicationUpdateService.STATE_IDLE:
        LOG("AppUpdater:#awaitDownloadComplete - Quick failure.");
        this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
        return;
      case Ci.nsIApplicationUpdateService.STATE_STAGING:
        LOG("AppUpdater:#awaitDownloadComplete - Quick staging.");
        await this.#awaitStagingComplete();
        return;
      case Ci.nsIApplicationUpdateService.STATE_PENDING:
        LOG("AppUpdater:#awaitDownloadComplete - Quick pending.");
        this.#onReadyToRestart();
        return;
    }

    // We may already be in the `DOWNLOADING` state, depending on how we entered
    // this function. But we actually want to alert the listeners again even if
    // we are because `this.update.selectedPatch` is null early in the
    // downloading state, but it should be set by now and listeners may want to
    // update UI based on that.
    this.#setStatus(AppUpdater.STATUS.DOWNLOADING);

    const updateDownloadProgress = (progress, progressMax) => {
      this.#setStatus(AppUpdater.STATUS.DOWNLOADING, progress, progressMax);
    };

    const progressObserver = {
      onStartRequest(aRequest) {
        LOG(
          `AppUpdater:#awaitDownloadComplete.observer.onStartRequest - ` +
            `aRequest: ${aRequest}`
        );
      },

      onStatus(aRequest, aStatus, aStatusArg) {
        LOG(
          `AppUpdater:#awaitDownloadComplete.observer.onStatus ` +
            `- aRequest: ${aRequest}, aStatus: ${aStatus}, ` +
            `aStatusArg: ${aStatusArg}`
        );
      },

      onProgress(aRequest, aProgress, aProgressMax) {
        LOG(
          `AppUpdater:#awaitDownloadComplete.observer.onProgress ` +
            `- aRequest: ${aRequest}, aProgress: ${aProgress}, ` +
            `aProgressMax: ${aProgressMax}`
        );
        updateDownloadProgress(aProgress, aProgressMax);
      },

      onStopRequest(aRequest, aStatusCode) {
        LOG(
          `AppUpdater:#awaitDownloadComplete.observer.onStopRequest ` +
            `- aRequest: ${aRequest}, aStatusCode: ${aStatusCode}`
        );
      },

      QueryInterface: ChromeUtils.generateQI([
        "nsIProgressEventSink",
        "nsIRequestObserver",
      ]),
    };

    let listenForProgress =
      updateState == Ci.nsIApplicationUpdateService.STATE_DOWNLOADING;

    if (listenForProgress) {
      this.aus.addDownloadListener(progressObserver);
      LOG("AppUpdater:#awaitDownloadComplete - Registered download listener");
    }

    LOG("AppUpdater:#awaitDownloadComplete - Waiting for state transition.");
    try {
      await makeAbortable(this.aus.stateTransition);
    } finally {
      if (listenForProgress) {
        this.aus.removeDownloadListener(progressObserver);
        LOG("AppUpdater:#awaitDownloadComplete - Download listener removed");
      }
    }

    updateState = this.aus.currentState;
    LOG(
      "AppUpdater:#awaitDownloadComplete - State transition seen. New state: " +
        this.aus.getStateName(updateState)
    );

    switch (updateState) {
      case Ci.nsIApplicationUpdateService.STATE_IDLE:
        LOG(
          "AppUpdater:#awaitDownloadComplete - Setting status DOWNLOAD_FAILED."
        );
        this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
        break;
      case Ci.nsIApplicationUpdateService.STATE_STAGING:
        LOG("AppUpdater:#awaitDownloadComplete - awaiting staging completion.");
        await this.#awaitStagingComplete();
        break;
      case Ci.nsIApplicationUpdateService.STATE_PENDING:
        LOG("AppUpdater:#awaitDownloadComplete - ready to restart.");
        this.#onReadyToRestart();
        break;
      default:
        LOG(
          "AppUpdater:#awaitDownloadComplete - Setting status INTERNAL_ERROR."
        );
        this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
        break;
    }
  }

  /**
   * This function registers an observer that watches for the staging process
   * to complete. Once it does, it sets the status to either request that the
   * user restarts to install the update on success, request that the user
   * manually download and install the newer version, or automatically download
   * a complete update if applicable.
   * Doesn't resolve until the update is ready to install, or a failure state
   * is reached.
   */
  async #awaitStagingComplete() {
    // These cases are unlikely, but we might have just completed really fast.
    let updateState = this.aus.currentState;
    switch (updateState) {
      case Ci.nsIApplicationUpdateService.STATE_IDLE:
        LOG("AppUpdater:#awaitStagingComplete - Quick failure.");
        this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
        return;
      case Ci.nsIApplicationUpdateService.STATE_DOWNLOADING:
        LOG("AppUpdater:#awaitStagingComplete - Quick fallback.");
        await this.#awaitDownloadComplete();
        return;
      case Ci.nsIApplicationUpdateService.STATE_PENDING:
        LOG("AppUpdater:#awaitStagingComplete - Quick pending.");
        this.#onReadyToRestart();
        return;
      case Ci.nsIApplicationUpdateService.STATE_SWAP:
        LOG("AppUpdater:#awaitStagingComplete - Quick swap.");
        await this.#awaitDownloadComplete();
        return;
    }

    LOG("AppUpdater:#awaitStagingComplete - Setting status STAGING.");
    this.#setStatus(AppUpdater.STATUS.STAGING);

    LOG("AppUpdater:#awaitStagingComplete - Waiting for state transition.");
    await makeAbortable(this.aus.stateTransition);

    updateState = this.aus.currentState;
    LOG(
      "AppUpdater:#awaitStagingComplete - State transition seen. New state: " +
        this.aus.getStateName(updateState)
    );

    switch (updateState) {
      case Ci.nsIApplicationUpdateService.STATE_PENDING:
        LOG("AppUpdater:#awaitStagingComplete - ready for restart");
        this.#onReadyToRestart();
        break;
      case Ci.nsIApplicationUpdateService.STATE_IDLE:
        LOG("AppUpdater:#awaitStagingComplete - DOWNLOAD_FAILED");
        this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
        break;
      case Ci.nsIApplicationUpdateService.STATE_DOWNLOADING:
        // We've fallen back to downloading the complete update because the
        // partial update failed to be staged. Return to the downloading stage.
        LOG(
          "AppUpdater:#awaitStagingComplete - Partial update must have " +
            "failed to stage. Downloading complete update."
        );
        await this.#awaitDownloadComplete();
        break;
      default:
        LOG(
          "AppUpdater:#awaitStagingComplete - Setting status INTERNAL_ERROR."
        );
        this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
        break;
    }
  }

  #onReadyToRestart() {
    let updateState = this.aus.currentState;
    if (updateState != Ci.nsIApplicationUpdateService.STATE_PENDING) {
      throw new Error(
        "AppUpdater:#onReadyToRestart invoked in unexpected state: " +
          this.aus.getStateName(updateState)
      );
    }

    LOG("AppUpdater:#onReadyToRestart - Setting status READY_FOR_RESTART.");
    if (this.#swapListenerConnected) {
      LOG(
        "AppUpdater:#onReadyToRestart - update-swap listener already attached"
      );
    } else {
      this.#swapListenerConnected = true;
      LOG("AppUpdater:#onReadyToRestart - Attaching update-swap listener");
      Services.obs.addObserver(this, "update-swap", /* ownsWeak */ true);
    }
    this.#setStatus(AppUpdater.STATUS.READY_FOR_RESTART);
  }

  /**
   * Stops the current check for updates and any ongoing download.
   *
   * If this is called before `AppUpdater.check()` is called or after it
   * resolves, this should have no effect. If this is called while `check()` is
   * still running, `AppUpdater` will return to the NEVER_CHECKED state. We
   * don't really want to leave it in any of the intermediary states after we
   * have disconnected all the listeners that would allow those states to ever
   * change.
   */
  stop() {
    LOG("AppUpdater:stop called");
    if (this.#swapListenerConnected) {
      LOG("AppUpdater:stop - Removing update-swap listener");
      Services.obs.removeObserver(this, "update-swap");
      this.#swapListenerConnected = false;
    }
    abortAllPromises();
  }

  /**
   * {AppUpdater.STATUS} The status of the current check or update.
   *
   * Note that until AppUpdater.check has been called, this will always be set
   * to NEVER_CHECKED.
   */
  get status() {
    return this.#status;
  }

  /**
   * Adds a listener function that will be called back on status changes as
   * different stages of updates occur.  The function will be called without
   * arguments for most status changes; see the comments around the STATUS value
   * definitions below.  This is safe to call multiple times with the same
   * function.  It will be added only once.
   *
   * @param {function} listener
   *   The listener function to add.
   */
  addListener(listener) {
    this.#listeners.add(listener);
  }

  /**
   * Removes a listener.  This is safe to call multiple times with the same
   * function, or with a function that was never added.
   *
   * @param {function} listener
   *   The listener function to remove.
   */
  removeListener(listener) {
    this.#listeners.delete(listener);
  }

  /**
   * Sets the updater's current status and calls listeners.
   *
   * @param {AppUpdater.STATUS} status
   *   The new updater status.
   * @param {*} listenerArgs
   *   Arguments to pass to listeners.
   */
  #setStatus(status, ...listenerArgs) {
    this.#status = status;
    for (let listener of this.#listeners) {
      listener(status, ...listenerArgs);
    }
    return status;
  }

  observe(subject, topic, status) {
    LOG(
      "AppUpdater:observe " +
        "- subject: " +
        subject +
        ", topic: " +
        topic +
        ", status: " +
        status
    );
    switch (topic) {
      case "update-swap":
        // This is asynchronous, but we don't really want to wait for it in this
        // observer.
        this.#handleUpdateSwap();
        break;
    }
  }

  async #handleUpdateSwap() {
    try {
      // This must not be in the try/catch that sets #updateBusy to `false` in
      // its finally block.
      // There really shouldn't be any way to enter this function when
      // `#updateBusy` is `true`. But let's just be safe because we don't want
      // to ever end up with two things running at once.
      if (this.#updateBusy) {
        return;
      }
    } catch (e) {
      this.#onException(e);
    }

    try {
      this.#updateBusy = true;

      // During an update swap, the new update will initially be stored in
      // `downloadingUpdate`. Part way through, it will be moved into
      // `readyUpdate` and `downloadingUpdate` will be set to `null`.
      this.#update = await this.um.getDownloadingUpdate();
      if (!this.#update) {
        this.#update = await this.um.getReadyUpdate();
      }

      await this.#awaitDownloadComplete();
    } catch (e) {
      this.#onException(e);
    } finally {
      this.#updateBusy = false;
    }
  }
}

XPCOMUtils.defineLazyServiceGetter(
  AppUpdater.prototype,
  "aus",
  "@mozilla.org/updates/update-service;1",
  Ci.nsIApplicationUpdateService
);
XPCOMUtils.defineLazyServiceGetter(
  AppUpdater.prototype,
  "checker",
  "@mozilla.org/updates/update-checker;1",
  Ci.nsIUpdateChecker
);
XPCOMUtils.defineLazyServiceGetter(
  AppUpdater.prototype,
  "um",
  "@mozilla.org/updates/update-manager;1",
  Ci.nsIUpdateManager
);

AppUpdater.STATUS = {
  // Updates are allowed and there's no downloaded or staged update, but the
  // AppUpdater hasn't checked for updates yet, so it doesn't know more than
  // that.
  NEVER_CHECKED: 0,

  // The updater isn't available (AppConstants.MOZ_UPDATER is falsey).
  NO_UPDATER: 1,

  // "appUpdate" is not allowed by policy.
  UPDATE_DISABLED_BY_POLICY: 2,

  // Another app instance is handling updates.
  OTHER_INSTANCE_HANDLING_UPDATES: 3,

  // There's an update, but it's not supported on this system.
  UNSUPPORTED_SYSTEM: 4,

  // The user must apply updates manually.
  MANUAL_UPDATE: 5,

  // The AppUpdater is checking for updates.
  CHECKING: 6,

  // The AppUpdater checked for updates and none were found.
  NO_UPDATES_FOUND: 7,

  // The AppUpdater is downloading an update.  Listeners are notified of this
  // status as a download starts.  They are also notified on download progress,
  // and in that case they are passed two arguments: the current download
  // progress and the total download size.
  DOWNLOADING: 8,

  // The AppUpdater tried to download an update but it failed.
  DOWNLOAD_FAILED: 9,

  // There's an update available, but the user wants us to ask them to download
  // and install it.
  DOWNLOAD_AND_INSTALL: 10,

  // An update is staging.
  STAGING: 11,

  // An update is downloaded and staged and will be applied on restart.
  READY_FOR_RESTART: 12,

  // Essential components of the updater are failing and preventing us from
  // updating.
  INTERNAL_ERROR: 13,

  // Failed to check for updates, network timeout, dns errors could cause this
  CHECKING_FAILED: 14,

  /**
   * Is the given `status` a terminal state in the update state machine?
   *
   * A terminal state means that the `check()` method has completed.
   *
   * N.b.: `DOWNLOAD_AND_INSTALL` is not considered terminal because the normal
   * flow is that Firefox will show UI prompting the user to install, and when
   * the user interacts, the `check()` method will continue through the update
   * state machine.
   *
   * @returns {boolean} `true` if `status` is terminal.
   */
  isTerminalStatus(status) {
    return ![
      AppUpdater.STATUS.CHECKING,
      AppUpdater.STATUS.DOWNLOAD_AND_INSTALL,
      AppUpdater.STATUS.DOWNLOADING,
      AppUpdater.STATUS.NEVER_CHECKED,
      AppUpdater.STATUS.STAGING,
    ].includes(status);
  },

  /**
   * Turn the given `status` into a string for debugging.
   *
   * @returns {?string} representation of given numerical `status`.
   */
  debugStringFor(status) {
    for (let [k, v] of Object.entries(AppUpdater.STATUS)) {
      if (v == status) {
        return k;
      }
    }
    return null;
  },
};

/**
 * Logs a string to the error console. If enabled, also logs to the update
 * messages file.
 *
 * @param   string
 *          The string to write to the error console.
 */
function LOG(string) {
  lazy.UpdateLog.logPrefixedString("AUS:AUM", string);
}
