/* 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/. */

/**
 * Main implementation of the Downloads API objects. Consumers should get
 * references to these objects through the "Downloads.sys.mjs" module.
 */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  DownloadHistory: "resource://gre/modules/DownloadHistory.sys.mjs",
  DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
  E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "gExternalAppLauncher",
  "@mozilla.org/uriloader/external-helper-app-service;1",
  Ci.nsPIExternalAppLauncher
);
XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "gExternalHelperAppService",
  "@mozilla.org/uriloader/external-helper-app-service;1",
  Ci.nsIExternalHelperAppService
);

Integration.downloads.defineESModuleGetter(
  lazy,
  "DownloadIntegration",
  "resource://gre/modules/DownloadIntegration.sys.mjs"
);

const BackgroundFileSaverStreamListener = Components.Constructor(
  "@mozilla.org/network/background-file-saver;1?mode=streamlistener",
  "nsIBackgroundFileSaver"
);

/**
 * Returns true if the given value is a primitive string or a String object.
 */
function isString(aValue) {
  // We cannot use the "instanceof" operator reliably across module boundaries.
  return (
    typeof aValue == "string" ||
    (typeof aValue == "object" && "charAt" in aValue)
  );
}

/**
 * Serialize the unknown properties of aObject into aSerializable.
 */
function serializeUnknownProperties(aObject, aSerializable) {
  if (aObject._unknownProperties) {
    for (let property in aObject._unknownProperties) {
      aSerializable[property] = aObject._unknownProperties[property];
    }
  }
}

/**
 * Check for any unknown properties in aSerializable and preserve those in the
 * _unknownProperties field of aObject. aFilterFn is called for each property
 * name of aObject and should return true only for unknown properties.
 */
function deserializeUnknownProperties(aObject, aSerializable, aFilterFn) {
  for (let property in aSerializable) {
    if (aFilterFn(property)) {
      if (!aObject._unknownProperties) {
        aObject._unknownProperties = {};
      }

      aObject._unknownProperties[property] = aSerializable[property];
    }
  }
}

/**
 * Check if the file is a placeholder.
 *
 * @return {Promise}
 * @resolves {boolean}
 * @rejects Never.
 */
async function isPlaceholder(path) {
  try {
    if ((await IOUtils.stat(path)).size == 0) {
      return true;
    }
  } catch (ex) {
    // Canceling the download may have removed the placeholder already.
    if (ex.name != "NotFoundError") {
      console.error(ex);
    }
  }
  return false;
}

/**
 * This determines the minimum time interval between updates to the number of
 * bytes transferred, and is a limiting factor to the sequence of readings used
 * in calculating the speed of the download.
 */
const kProgressUpdateIntervalMs = 400;

/**
 * Represents a single download, with associated state and actions.  This object
 * is transient, though it can be included in a DownloadList so that it can be
 * managed by the user interface and persisted across sessions.
 */
export var Download = function () {
  this._deferSucceeded = Promise.withResolvers();
};

Download.prototype = {
  /**
   * DownloadSource object associated with this download.
   */
  source: null,

  /**
   * DownloadTarget object associated with this download.
   */
  target: null,

  /**
   * DownloadSaver object associated with this download.
   */
  saver: null,

  /**
   * Indicates that the download never started, has been completed successfully,
   * failed, or has been canceled.  This property becomes false when a download
   * is started for the first time, or when a failed or canceled download is
   * restarted.
   */
  stopped: true,

  /**
   * Indicates that the download has been completed successfully.
   */
  succeeded: false,

  /**
   * Indicates that the download has been canceled.  This property can become
   * true, then it can be reset to false when a canceled download is restarted.
   *
   * This property becomes true as soon as the "cancel" method is called, though
   * the "stopped" property might remain false until the cancellation request
   * has been processed.  Temporary files or part files may still exist even if
   * they are expected to be deleted, until the "stopped" property becomes true.
   */
  canceled: false,

  /**
   * Downloaded files can be deleted from within Firefox, e.g. via the context
   * menu. Currently Firefox does not track file moves (see bug 1746386), so if
   * a download's target file stops existing we have to assume it's "moved or
   * missing." To distinguish files intentionally deleted within Firefox from
   * files that are moved/missing, we mark them as "deleted" with this property.
   */
  deleted: false,

  /**
   * When the download fails, this is set to a DownloadError instance indicating
   * the cause of the failure.  If the download has been completed successfully
   * or has been canceled, this property is null.  This property is reset to
   * null when a failed download is restarted.
   */
  error: null,

  /**
   * Indicates the start time of the download.  When the download starts,
   * this property is set to a valid Date object.  The default value is null
   * before the download starts.
   */
  startTime: null,

  /**
   * Indicates whether this download's "progress" property is able to report
   * partial progress while the download proceeds, and whether the value in
   * totalBytes is relevant.  This depends on the saver and the download source.
   */
  hasProgress: false,

  /**
   * Progress percent, from 0 to 100.  Intermediate values are reported only if
   * hasProgress is true.
   *
   * @note You shouldn't rely on this property being equal to 100 to determine
   *       whether the download is completed.  You should use the individual
   *       state properties instead.
   */
  progress: 0,

  /**
   * When hasProgress is true, indicates the total number of bytes to be
   * transferred before the download finishes, that can be zero for empty files.
   *
   * When hasProgress is false, this property is always zero.
   *
   * @note This property may be different than the final file size on disk for
   *       downloads that are encoded during the network transfer.  You can use
   *       the "size" property of the DownloadTarget object to get the actual
   *       size on disk once the download succeeds.
   */
  totalBytes: 0,

  /**
   * Number of bytes currently transferred.  This value starts at zero, and may
   * be updated regardless of the value of hasProgress.
   *
   * @note You shouldn't rely on this property being equal to totalBytes to
   *       determine whether the download is completed.  You should use the
   *       individual state properties instead.  This property may not be
   *       updated during the last part of the download.
   */
  currentBytes: 0,

  /**
   * Fractional number representing the speed of the download, in bytes per
   * second.  This value is zero when the download is stopped, and may be
   * updated regardless of the value of hasProgress.
   */
  speed: 0,

  /**
   * Indicates whether, at this time, there is any partially downloaded data
   * that can be used when restarting a failed or canceled download.
   *
   * Even if the download has partial data on disk, hasPartialData will be false
   * if that data cannot be used to restart the download. In order to determine
   * if a part file is being used which contains partial data the
   * Download.target.partFilePath should be checked.
   *
   * This property is relevant while the download is in progress, and also if it
   * failed or has been canceled.  If the download has been completed
   * successfully, this property is always false.
   *
   * Whether partial data can actually be retained depends on the saver and the
   * download source, and may not be known before the download is started.
   */
  hasPartialData: false,

  /**
   * Indicates whether, at this time, there is any data that has been blocked.
   * Since reputation blocking takes place after the download has fully
   * completed a value of true also indicates 100% of the data is present.
   */
  hasBlockedData: false,

  /**
   * This can be set to a function that is called after other properties change.
   */
  onchange: null,

  /**
   * This tells if the user has chosen to open/run the downloaded file after
   * download has completed.
   */
  launchWhenSucceeded: false,

  /**
   * When a download starts, we typically want to automatically open the
   * downloads panel if the pref browser.download.alwaysOpenPanel is enabled.
   * However, there are conditions where we want to prevent this. For example, a
   * false value can prevent the downloads panel from opening when an add-on
   * creates a download without user input as part of some background operation.
   */
  openDownloadsListOnStart: true,

  /**
   * This represents the MIME type of the download.
   */
  contentType: null,

  /**
   * This indicates the path of the application to be used to launch the file,
   * or null if the file should be launched with the default application.
   */
  launcherPath: null,

  /**
   * This contains application id to be used to launch the file,
   * or null if the file is not meant to be launched with GIOHandlerApp.
   */
  launcherId: null,

  /**
   * Raises the onchange notification.
   */
  _notifyChange: function D_notifyChange() {
    try {
      if (this.onchange) {
        this.onchange();
      }
    } catch (ex) {
      console.error(ex);
    }
  },

  /**
   * The download may be stopped and restarted multiple times before it
   * completes successfully. This may happen if any of the download attempts is
   * canceled or fails.
   *
   * This property contains a promise that is linked to the current attempt, or
   * null if the download is either stopped or in the process of being canceled.
   * If the download restarts, this property is replaced with a new promise.
   *
   * The promise is resolved if the attempt it represents finishes successfully,
   * and rejected if the attempt fails.
   */
  _currentAttempt: null,

  /**
   * The download was launched to open from the Downloads Panel.
   */
  _launchedFromPanel: false,

  /**
   * Starts the download for the first time, or restarts a download that failed
   * or has been canceled.
   *
   * Calling this method when the download has been completed successfully has
   * no effect, and the method returns a resolved promise.  If the download is
   * in progress, the method returns the same promise as the previous call.
   *
   * If the "cancel" method was called but the cancellation process has not
   * finished yet, this method waits for the cancellation to finish, then
   * restarts the download immediately.
   *
   * @note If you need to start a new download from the same source, rather than
   *       restarting a failed or canceled one, you should create a separate
   *       Download object with the same source as the current one.
   *
   * @return {Promise}
   * @resolves When the download has finished successfully.
   * @rejects JavaScript exception if the download failed.
   */
  start: function D_start() {
    // If the download succeeded, it's the final state, we have nothing to do.
    if (this.succeeded) {
      return Promise.resolve();
    }

    // If the download already started and hasn't failed or hasn't been
    // canceled, return the same promise as the previous call, allowing the
    // caller to wait for the current attempt to finish.
    if (this._currentAttempt) {
      return this._currentAttempt;
    }

    // While shutting down or disposing of this object, we prevent the download
    // from returning to be in progress.
    if (this._finalized) {
      return Promise.reject(
        new DownloadError({
          message: "Cannot start after finalization.",
        })
      );
    }

    if (this.error && this.error.becauseBlockedByReputationCheck) {
      return Promise.reject(
        new DownloadError({
          message: "Cannot start after being blocked by a reputation check.",
        })
      );
    }

    // Initialize all the status properties for a new or restarted download.
    this.stopped = false;
    this.canceled = false;
    this.error = null;
    // Avoid serializing the previous error, or it would be restored on the next
    // startup, even if the download was restarted.
    delete this._unknownProperties?.errorObj;
    this.hasProgress = false;
    this.hasBlockedData = false;
    this.progress = 0;
    this.totalBytes = 0;
    this.currentBytes = 0;
    this.startTime = new Date();

    // Create a new deferred object and an associated promise before starting
    // the actual download.  We store it on the download as the current attempt.
    let deferAttempt = Promise.withResolvers();
    let currentAttempt = deferAttempt.promise;
    this._currentAttempt = currentAttempt;

    // Restart the progress and speed calculations from scratch.
    this._lastProgressTimeMs = 0;

    // This function propagates progress from the DownloadSaver object, unless
    // it comes in late from a download attempt that was replaced by a new one.
    // If the cancellation process for the download has started, then the update
    // is ignored.
    function DS_setProgressBytes(aCurrentBytes, aTotalBytes, aHasPartialData) {
      if (this._currentAttempt == currentAttempt) {
        this._setBytes(aCurrentBytes, aTotalBytes, aHasPartialData);
      }
    }

    // This function propagates download properties from the DownloadSaver
    // object, unless it comes in late from a download attempt that was
    // replaced by a new one.  If the cancellation process for the download has
    // started, then the update is ignored.
    function DS_setProperties(aOptions) {
      if (this._currentAttempt != currentAttempt) {
        return;
      }

      let changeMade = false;

      for (let property of [
        "contentType",
        "progress",
        "hasPartialData",
        "hasBlockedData",
      ]) {
        if (property in aOptions && this[property] != aOptions[property]) {
          this[property] = aOptions[property];
          changeMade = true;
        }
      }

      if (changeMade) {
        this._notifyChange();
      }
    }

    // Now that we stored the promise in the download object, we can start the
    // task that will actually execute the download.
    deferAttempt.resolve(
      (async () => {
        // Wait upon any pending operation before restarting.
        if (this._promiseCanceled) {
          await this._promiseCanceled;
        }
        if (this._promiseRemovePartialData) {
          try {
            await this._promiseRemovePartialData;
          } catch (ex) {
            // Ignore any errors, which are already reported by the original
            // caller of the removePartialData method.
          }
        }

        // In case the download was restarted while cancellation was in progress,
        // but the previous attempt actually succeeded before cancellation could
        // be processed, it is possible that the download has already finished.
        if (this.succeeded) {
          return;
        }

        try {
          if (this.downloadingToSameFile()) {
            throw new DownloadError({
              message: "Can't overwrite the source file.",
              becauseTargetFailed: true,
            });
          }

          // Disallow download if parental controls service restricts it.
          if (
            await lazy.DownloadIntegration.shouldBlockForParentalControls(this)
          ) {
            throw new DownloadError({ becauseBlockedByParentalControls: true });
          }

          // We should check if we have been canceled in the meantime, after all
          // the previous asynchronous operations have been executed and just
          // before we call the "execute" method of the saver.
          if (this._promiseCanceled) {
            // The exception will become a cancellation in the "catch" block.
            throw new Error(undefined);
          }

          // Execute the actual download through the saver object.
          this._saverExecuting = true;
          try {
            await this.saver.execute(
              DS_setProgressBytes.bind(this),
              DS_setProperties.bind(this)
            );
          } catch (ex) {
            // Remove the target file placeholder and all partial data when
            // needed, independently of which code path failed. In some cases, the
            // component executing the download may have already removed the file.
            if (!this.hasPartialData && !this.hasBlockedData) {
              await this.saver.removeData(true);
            }
            throw ex;
          }

          // Now that the actual saving finished, read the actual file size on
          // disk, that may be different from the amount of data transferred.
          await this.target.refresh();

          // Check for the last time if the download has been canceled. This must
          // be done right before setting the "stopped" property of the download,
          // without any asynchronous operations in the middle, so that another
          // cancellation request cannot start in the meantime and stay unhandled.
          if (this._promiseCanceled) {
            // To keep the internal state of the Download object consistent, we
            // just delete the target and effectively cancel the download. Since
            // the DownloadSaver succeeded, we already renamed the ".part" file to
            // the final name, and this results in all the data being deleted.
            await this.saver.removeData(true);

            // Cancellation exceptions will be changed in the catch block below.
            throw new DownloadError();
          }

          // Update the status properties for a successful download.
          this.progress = 100;
          this.succeeded = true;
          this.hasPartialData = false;
        } catch (originalEx) {
          // We may choose a different exception to propagate in the code below,
          // or wrap the original one. We do this mutation in a different variable
          // because of the "no-ex-assign" ESLint rule.
          let ex = originalEx;

          // Fail with a generic status code on cancellation, so that the caller
          // is forced to actually check the status properties to see if the
          // download was canceled or failed because of other reasons.
          if (this._promiseCanceled) {
            throw new DownloadError({ message: "Download canceled." });
          }

          // An HTTP 450 error code is used by Windows to indicate that a uri is
          // blocked by parental controls. This will prevent the download from
          // occuring, so an error needs to be raised. This is not performed
          // during the parental controls check above as it requires the request
          // to start.
          if (this._blockedByParentalControls) {
            ex = new DownloadError({ becauseBlockedByParentalControls: true });
          }

          // Update the download error, unless a new attempt already started. The
          // change in the status property is notified in the finally block.
          if (this._currentAttempt == currentAttempt || !this._currentAttempt) {
            if (!(ex instanceof DownloadError)) {
              let properties = { innerException: ex };

              if (ex.message) {
                properties.message = ex.message;
              }

              ex = new DownloadError(properties);
            }
            // Don't store an error if it's an abort caused by shutdown, so the
            // download can be retried automatically at the next startup.
            if (
              originalEx.result != Cr.NS_ERROR_ABORT ||
              !Services.startup.isInOrBeyondShutdownPhase(
                Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
              )
            ) {
              this.error = ex;
            }
          }
          throw ex;
        } finally {
          // Any cancellation request has now been processed.
          this._saverExecuting = false;
          this._promiseCanceled = null;

          // Update the status properties, unless a new attempt already started.
          if (this._currentAttempt == currentAttempt || !this._currentAttempt) {
            this._currentAttempt = null;
            this.stopped = true;
            this.speed = 0;
            this._notifyChange();
            if (this.succeeded) {
              await this._succeed();
            }
          }
        }
      })()
    );

    // Notify the new download state before returning.
    this._notifyChange();
    return currentAttempt;
  },

  /**
   * Perform the actions necessary when a Download succeeds.
   *
   * @return {Promise}
   * @resolves When the steps to take after success have completed.
   * @rejects  JavaScript exception if any of the operations failed.
   */
  async _succeed() {
    await lazy.DownloadIntegration.downloadDone(this);

    this._deferSucceeded.resolve();

    if (this.launchWhenSucceeded) {
      this.launch().catch(console.error);

      // Always schedule files to be deleted at the end of the private browsing
      // mode, regardless of the value of the pref.
      if (this.source.isPrivate) {
        lazy.gExternalAppLauncher.deleteTemporaryPrivateFileWhenPossible(
          new lazy.FileUtils.File(this.target.path)
        );
      } else if (
        Services.prefs.getBoolPref("browser.helperApps.deleteTempFileOnExit") &&
        Services.prefs.getBoolPref(
          "browser.download.start_downloads_in_tmp_dir",
          false
        )
      ) {
        lazy.gExternalAppLauncher.deleteTemporaryFileOnExit(
          new lazy.FileUtils.File(this.target.path)
        );
      }
    }
  },

  /**
   * When a request to unblock the download is received, contains a promise
   * that will be resolved when the unblock request is completed. This property
   * will then continue to hold the promise indefinitely.
   */
  _promiseUnblock: null,

  /**
   * When a request to confirm the block of the download is received, contains
   * a promise that will be resolved when cleaning up the download has
   * completed. This property will then continue to hold the promise
   * indefinitely.
   */
  _promiseConfirmBlock: null,

  /**
   * Unblocks a download which had been blocked by reputation.
   *
   * The file will be moved out of quarantine and the download will be
   * marked as succeeded.
   *
   * @return {Promise}
   * @resolves When the Download has been unblocked and succeeded.
   * @rejects  JavaScript exception if any of the operations failed.
   */
  unblock() {
    if (this._promiseUnblock) {
      return this._promiseUnblock;
    }

    if (this._promiseConfirmBlock) {
      return Promise.reject(
        new Error("Download block has been confirmed, cannot unblock.")
      );
    }

    if (this.error?.becauseBlockedByReputationCheck) {
      Services.telemetry
        .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD")
        .add(this.error.reputationCheckVerdict, 2); // unblock
    }

    if (
      this.error?.reputationCheckVerdict == DownloadError.BLOCK_VERDICT_INSECURE
    ) {
      // In this Error case, the download was actually canceled before it was
      // passed to the Download UI. So we need to start the download here.
      this.error = null;
      this.succeeded = false;
      this.hasBlockedData = false;
      // This ensures the verdict will not get set again after the browser
      // restarts and the download gets serialized and de-serialized again.
      delete this._unknownProperties?.errorObj;
      this.start()
        .catch(err => {
          if (err.becauseTargetFailed) {
            // In case we cannot write to the target file
            // retry with a new unique name
            let uniquePath = lazy.DownloadPaths.createNiceUniqueFile(
              new lazy.FileUtils.File(this.target.path)
            ).path;
            this.target.path = uniquePath;
            return this.start();
          }
          return Promise.reject(err);
        })
        .catch(err => {
          if (!this.canceled) {
            console.error(err);
          }
          this._notifyChange();
        });
      this._notifyChange();
      this._promiseUnblock = lazy.DownloadIntegration.downloadDone(this);
      return this._promiseUnblock;
    }

    if (!this.hasBlockedData) {
      return Promise.reject(
        new Error("unblock may only be called on Downloads with blocked data.")
      );
    }

    this._promiseUnblock = (async () => {
      try {
        await IOUtils.move(this.target.partFilePath, this.target.path);
        await this.target.refresh();
      } catch (ex) {
        await this.refresh();
        this._promiseUnblock = null;
        throw ex;
      }

      this.succeeded = true;
      this.hasBlockedData = false;
      this._notifyChange();
      await this._succeed();
    })();

    return this._promiseUnblock;
  },

  /**
   * Confirms that a blocked download should be cleaned up.
   *
   * If a download was blocked but retained on disk this method can be used
   * to remove the file.
   *
   * @return {Promise}
   * @resolves When the Download's data has been removed.
   * @rejects  JavaScript exception if any of the operations failed.
   */
  confirmBlock() {
    if (this._promiseConfirmBlock) {
      return this._promiseConfirmBlock;
    }

    if (this._promiseUnblock) {
      return Promise.reject(
        new Error("Download is being unblocked, cannot confirmBlock.")
      );
    }

    if (this.error?.becauseBlockedByReputationCheck) {
      // We have to record the telemetry in both DownloadsCommon.deleteDownload
      // and confirmBlock here. The former is for cases where users click
      // "Remove file" in the download panel and the latter is when
      // users click "X" button in about:downloads.
      Services.telemetry
        .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD")
        .add(this.error.reputationCheckVerdict, 1); // confirm block
    }

    if (!this.hasBlockedData) {
      return Promise.reject(
        new Error(
          "confirmBlock may only be called on Downloads with blocked data."
        )
      );
    }

    this._promiseConfirmBlock = (async () => {
      // This call never throws exceptions. If the removal fails, the blocked
      // data remains stored on disk in the ".part" file.
      await this.saver.removeData();

      this.hasBlockedData = false;
      this._notifyChange();
    })();

    return this._promiseConfirmBlock;
  },

  /*
   * Launches the file after download has completed. This can open
   * the file with the default application for the target MIME type
   * or file extension, or with a custom application if launcherPath
   * or launcherId is set.
   *
   * @param options.openWhere  Optional string indicating how to open when handling
   *                           download by opening the target file URI.
   *                           One of "window", "tab", "tabshifted"
   * @param options.useSystemDefault
   *                           Optional value indicating how to handle launching this download,
   *                           this time only. Will override the associated mimeInfo.preferredAction
   * @return {Promise}
   * @resolves When the instruction to launch the file has been
   *           successfully given to the operating system. Note that
   *           the OS might still take a while until the file is actually
   *           launched.
   * @rejects  JavaScript exception if there was an error trying to launch
   *           the file.
   */
  launch(options = {}) {
    if (!this.succeeded) {
      return Promise.reject(
        new Error("launch can only be called if the download succeeded")
      );
    }

    if (this._launchedFromPanel) {
      Glean.downloads.fileOpened.add(1);
    }

    return lazy.DownloadIntegration.launchDownload(this, options);
  },

  /*
   * Shows the folder containing the target file, or where the target file
   * will be saved. This may be called at any time, even if the download
   * failed or is currently in progress.
   *
   * @return {Promise}
   * @resolves When the instruction to open the containing folder has been
   *           successfully given to the operating system. Note that
   *           the OS might still take a while until the folder is actually
   *           opened.
   * @rejects  JavaScript exception if there was an error trying to open
   *           the containing folder.
   */
  showContainingDirectory: function D_showContainingDirectory() {
    return lazy.DownloadIntegration.showContainingDirectory(this.target.path);
  },

  /**
   * When a request to cancel the download is received, contains a promise that
   * will be resolved when the cancellation request is processed.  When the
   * request is processed, this property becomes null again.
   */
  _promiseCanceled: null,

  /**
   * True between the call to the "execute" method of the saver and the
   * completion of the current download attempt.
   */
  _saverExecuting: false,

  /**
   * Cancels the download.
   *
   * The cancellation request is asynchronous.  Until the cancellation process
   * finishes, temporary files or part files may still exist even if they are
   * expected to be deleted.
   *
   * In case the download completes successfully before the cancellation request
   * could be processed, this method has no effect, and it returns a resolved
   * promise.  You should check the properties of the download at the time the
   * returned promise is resolved to determine if the download was cancelled.
   *
   * Calling this method when the download has been completed successfully,
   * failed, or has been canceled has no effect, and the method returns a
   * resolved promise.  This behavior is designed for the case where the call
   * to "cancel" happens asynchronously, and is consistent with the case where
   * the cancellation request could not be processed in time.
   *
   * @return {Promise}
   * @resolves When the cancellation process has finished.
   * @rejects Never.
   */
  cancel: function D_cancel() {
    // If the download is currently stopped, we have nothing to do.
    if (this.stopped) {
      return Promise.resolve();
    }

    if (!this._promiseCanceled) {
      // Start a new cancellation request.
      this._promiseCanceled = new Promise(resolve => {
        this._currentAttempt.then(resolve, resolve);
      });

      // The download can already be restarted.
      this._currentAttempt = null;

      // Notify that the cancellation request was received.
      this.canceled = true;
      this._notifyChange();

      // Execute the actual cancellation through the saver object, in case it
      // has already started.  Otherwise, the cancellation will be handled just
      // before the saver is started.
      if (this._saverExecuting) {
        this.saver.cancel();
      }
    }

    return this._promiseCanceled;
  },

  /**
   * Indicates whether any partially downloaded data should be retained, to use
   * when restarting a failed or canceled download.  The default is false.
   *
   * Whether partial data can actually be retained depends on the saver and the
   * download source, and may not be known before the download is started.
   *
   * To have any effect, this property must be set before starting the download.
   * Resetting this property to false after the download has already started
   * will not remove any partial data.
   *
   * If this property is set to true, care should be taken that partial data is
   * removed before the reference to the download is discarded.  This can be
   * done using the removePartialData or the "finalize" methods.
   */
  tryToKeepPartialData: false,

  /**
   * When a request to remove partially downloaded data is received, contains a
   * promise that will be resolved when the removal request is processed.  When
   * the request is processed, this property becomes null again.
   */
  _promiseRemovePartialData: null,

  /**
   * Removes any partial data kept as part of a canceled or failed download.
   *
   * If the download is not canceled or failed, this method has no effect, and
   * it returns a resolved promise.  If the "cancel" method was called but the
   * cancellation process has not finished yet, this method waits for the
   * cancellation to finish, then removes the partial data.
   *
   * After this method has been called, if the tryToKeepPartialData property is
   * still true when the download is restarted, partial data will be retained
   * during the new download attempt.
   *
   * @return {Promise}
   * @resolves When the partial data has been successfully removed.
   * @rejects JavaScript exception if the operation could not be completed.
   */
  removePartialData() {
    if (!this.canceled && !this.error) {
      return Promise.resolve();
    }

    if (!this._promiseRemovePartialData) {
      this._promiseRemovePartialData = (async () => {
        try {
          // Wait upon any pending cancellation request.
          if (this._promiseCanceled) {
            await this._promiseCanceled;
          }
          // Ask the saver object to remove any partial data.
          await this.saver.removeData();
          // For completeness, clear the number of bytes transferred.
          if (this.currentBytes != 0 || this.hasPartialData) {
            this.currentBytes = 0;
            this.hasPartialData = false;
            this.target.refreshPartFileState();
            this._notifyChange();
          }
        } finally {
          this._promiseRemovePartialData = null;
        }
      })();
    }

    return this._promiseRemovePartialData;
  },

  /**
   * Returns true if the download source is the same as the target file.
   */
  downloadingToSameFile() {
    if (!this.source.url || !this.source.url.startsWith("file:")) {
      return false;
    }

    try {
      let sourceUri = lazy.NetUtil.newURI(this.source.url);
      let targetUri = lazy.NetUtil.newURI(
        new lazy.FileUtils.File(this.target.path)
      );
      return sourceUri.equals(targetUri);
    } catch (ex) {
      return false;
    }
  },

  /**
   * This deferred object contains a promise that is resolved as soon as this
   * download finishes successfully, and is never rejected.  This property is
   * initialized when the download is created, and never changes.
   */
  _deferSucceeded: null,

  /**
   * Returns a promise that is resolved as soon as this download finishes
   * successfully, even if the download was stopped and restarted meanwhile.
   *
   * You can use this property for scheduling download completion actions in the
   * current session, for downloads that are controlled interactively.  If the
   * download is not controlled interactively, you should use the promise
   * returned by the "start" method instead, to check for success or failure.
   *
   * @return {Promise}
   * @resolves When the download has finished successfully.
   * @rejects Never.
   */
  whenSucceeded: function D_whenSucceeded() {
    return this._deferSucceeded.promise;
  },

  /**
   * Updates the state of a finished, failed, or canceled download based on the
   * current state in the file system.  If the download is in progress or it has
   * been finalized, this method has no effect, and it returns a resolved
   * promise.
   *
   * This allows the properties of the download to be updated in case the user
   * moved or deleted the target file or its associated ".part" file.
   *
   * @return {Promise}
   * @resolves When the operation has completed.
   * @rejects Never.
   */
  refresh() {
    return (async () => {
      if (!this.stopped || this._finalized) {
        return;
      }

      if (this.succeeded) {
        let oldExists = this.target.exists;
        let oldSize = this.target.size;
        await this.target.refresh();
        if (oldExists != this.target.exists || oldSize != this.target.size) {
          this._notifyChange();
        }
        return;
      }

      // Update the current progress from disk if we retained partial data.
      if (
        (this.hasPartialData || this.hasBlockedData) &&
        this.target.partFilePath
      ) {
        try {
          let stat = await IOUtils.stat(this.target.partFilePath);

          // Ignore the result if the state has changed meanwhile.
          if (!this.stopped || this._finalized) {
            return;
          }

          // Update the bytes transferred and the related progress properties.
          this.currentBytes = stat.size;
          if (this.totalBytes > 0) {
            this.hasProgress = true;
            this.progress = Math.floor(
              (this.currentBytes / this.totalBytes) * 100
            );
          }
        } catch (ex) {
          if (ex.name != "NotFoundError") {
            throw ex;
          }
          // Ignore the result if the state has changed meanwhile.
          if (!this.stopped || this._finalized) {
            return;
          }
          // In case we've blocked the Download becasue its
          // insecure, we should not set hasBlockedData to
          // false as its required to show the Unblock option.
          if (
            this.error.reputationCheckVerdict ==
            DownloadError.BLOCK_VERDICT_INSECURE
          ) {
            return;
          }

          this.hasBlockedData = false;
          this.hasPartialData = false;
        }

        this._notifyChange();
      }
    })().catch(console.error);
  },

  /**
   * True if the "finalize" method has been called.  This prevents the download
   * from starting again after having been stopped.
   */
  _finalized: false,

  /**
   * True if the "finalize" has been called and fully finished it's execution.
   */
  _finalizeExecuted: false,

  /**
   * Ensures that the download is stopped, and optionally removes any partial
   * data kept as part of a canceled or failed download.  After this method has
   * been called, the download cannot be started again.
   *
   * This method should be used in place of "cancel" and removePartialData while
   * shutting down or disposing of the download object, to prevent other callers
   * from interfering with the operation.  This is required because cancellation
   * and other operations are asynchronous.
   *
   * @param aRemovePartialData
   *        Whether any partially downloaded data should be removed after the
   *        download has been stopped.
   *
   * @return {Promise}
   * @resolves When the operation has finished successfully.
   * @rejects JavaScript exception if an error occurred while removing the
   *          partially downloaded data.
   */
  finalize(aRemovePartialData) {
    // Prevents the download from starting again after having been stopped.
    this._finalized = true;
    let promise;

    if (aRemovePartialData) {
      // Cancel the download, in case it is currently in progress, then remove
      // any partially downloaded data.  The removal operation waits for
      // cancellation to be completed before resolving the promise it returns.
      this.cancel();
      promise = this.removePartialData();
    } else {
      // Just cancel the download, in case it is currently in progress.
      promise = this.cancel();
    }
    promise.then(() => {
      // At this point, either removing data / just cancelling the download should be done.
      this._finalizeExecuted = true;
    });

    return promise;
  },

  /**
   * Deletes all file data associated with a download, preserving the download
   * object itself and updating it for download views.
   */
  async manuallyRemoveData() {
    let { path } = this.target;
    if (this.succeeded) {
      // Temp files are made "read-only" by DownloadIntegration.downloadDone, so
      // reset the permission bits to read/write. This won't be necessary after
      // bug 1733587 since Downloads won't ever be temporary.
      await IOUtils.setPermissions(path, 0o660);
      await IOUtils.remove(path, { ignoreAbsent: true });
    }
    this.deleted = true;
    await this.cancel();
    await this.removePartialData();
    // We need to guarantee that the UI is refreshed irrespective of what state
    // the download is in when this is called, to ensure the download doesn't
    // wind up stuck displaying as if it exists when it actually doesn't. And
    // that means updating this.target.partFileExists no matter what.
    await this.target.refreshPartFileState();
    await this.refresh();
    // The above methods will sometimes call _notifyChange, but not always. It
    // depends on whether the download is `succeeded`, `stopped`, `canceled`,
    // etc. Since this method needs to update the UI and can be invoked on any
    // download as long as its target has some file on the system, we need to
    // call _notifyChange no matter what state the download is in.
    this._notifyChange();
  },

  /**
   * Indicates the time of the last progress notification, expressed as the
   * number of milliseconds since January 1, 1970, 00:00:00 UTC.  This is zero
   * until some bytes have actually been transferred.
   */
  _lastProgressTimeMs: 0,

  /**
   * Updates progress notifications based on the number of bytes transferred.
   *
   * The number of bytes transferred is not updated unless enough time passed
   * since this function was last called.  This limits the computation load, in
   * particular when the listeners update the user interface in response.
   *
   * @param aCurrentBytes
   *        Number of bytes transferred until now.
   * @param aTotalBytes
   *        Total number of bytes to be transferred, or -1 if unknown.
   * @param aHasPartialData
   *        Indicates whether the partially downloaded data can be used when
   *        restarting the download if it fails or is canceled.
   */
  _setBytes: function D_setBytes(aCurrentBytes, aTotalBytes, aHasPartialData) {
    let changeMade = this.hasPartialData != aHasPartialData;
    this.hasPartialData = aHasPartialData;

    // Unless aTotalBytes is -1, we can report partial download progress.  In
    // this case, notify when the related properties changed since last time.
    if (
      aTotalBytes != -1 &&
      (!this.hasProgress || this.totalBytes != aTotalBytes)
    ) {
      this.hasProgress = true;
      this.totalBytes = aTotalBytes;
      changeMade = true;
    }

    // Updating the progress and computing the speed require that enough time
    // passed since the last update, or that we haven't started throttling yet.
    let currentTimeMs = Date.now();
    let intervalMs = currentTimeMs - this._lastProgressTimeMs;
    if (intervalMs >= kProgressUpdateIntervalMs) {
      // Don't compute the speed unless we started throttling notifications.
      if (this._lastProgressTimeMs != 0) {
        // Calculate the speed in bytes per second.
        let rawSpeed =
          ((aCurrentBytes - this.currentBytes) / intervalMs) * 1000;
        if (this.speed == 0) {
          // When the previous speed is exactly zero instead of a fractional
          // number, this can be considered the first element of the series.
          this.speed = rawSpeed;
        } else {
          // Apply exponential smoothing, with a smoothing factor of 0.1.
          this.speed = rawSpeed * 0.1 + this.speed * 0.9;
        }
      }

      // Start throttling notifications only when we have actually received some
      // bytes for the first time.  The timing of the first part of the download
      // is not reliable, due to possible latency in the initial notifications.
      // This also allows automated tests to receive and verify the number of
      // bytes initially transferred.
      if (aCurrentBytes > 0) {
        this._lastProgressTimeMs = currentTimeMs;

        // Update the progress now that we don't need its previous value.
        this.currentBytes = aCurrentBytes;
        if (this.totalBytes > 0) {
          this.progress = Math.floor(
            (this.currentBytes / this.totalBytes) * 100
          );
        }
        changeMade = true;
      }

      if (this.hasProgress && this.target && !this.target.partFileExists) {
        this.target.refreshPartFileState();
      }
    }

    if (changeMade) {
      this._notifyChange();
    }
  },

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable() {
    let serializable = {
      source: this.source.toSerializable(),
      target: this.target.toSerializable(),
    };

    let saver = this.saver.toSerializable();
    if (!serializable.source || !saver) {
      // If we are unable to serialize either the source or the saver,
      // we won't persist the download.
      return null;
    }

    // Simplify the representation for the most common saver type.  If the saver
    // is an object instead of a simple string, we can't simplify it because we
    // need to persist all its properties, not only "type".  This may happen for
    // savers of type "copy" as well as other types.
    if (saver !== "copy") {
      serializable.saver = saver;
    }

    if (this.error) {
      serializable.errorObj = this.error.toSerializable();
    }

    if (this.startTime) {
      serializable.startTime = this.startTime.toJSON();
    }

    // These are serialized unless they are false, null, or empty strings.
    for (let property of kPlainSerializableDownloadProperties) {
      if (this[property]) {
        serializable[property] = this[property];
      }
    }

    serializeUnknownProperties(this, serializable);

    return serializable;
  },

  /**
   * Returns a value that changes only when one of the properties of a Download
   * object that should be saved into a file also change.  This excludes
   * properties whose value doesn't usually change during the download lifetime.
   *
   * This function is used to determine whether the download should be
   * serialized after a property change notification has been received.
   *
   * @return String representing the relevant download state.
   */
  getSerializationHash() {
    // The "succeeded", "canceled", "error", and startTime properties are not
    // taken into account because they all change before the "stopped" property
    // changes, and are not altered in other cases.
    return (
      this.stopped +
      "," +
      this.totalBytes +
      "," +
      this.hasPartialData +
      "," +
      this.contentType
    );
  },
};

/**
 * Defines which properties of the Download object are serializable.
 */
const kPlainSerializableDownloadProperties = [
  "succeeded",
  "canceled",
  "totalBytes",
  "hasPartialData",
  "hasBlockedData",
  "tryToKeepPartialData",
  "launcherPath",
  "launcherId",
  "launchWhenSucceeded",
  "contentType",
  "handleInternally",
  "openDownloadsListOnStart",
];

/**
 * Creates a new Download object from a serializable representation.  This
 * function is used by the createDownload method of Downloads.sys.mjs when a new
 * Download object is requested, thus some properties may refer to live objects
 * in place of their serializable representations.
 *
 * @param aSerializable
 *        An object with the following fields:
 *        {
 *          source: DownloadSource object, or its serializable representation.
 *                  See DownloadSource.fromSerializable for details.
 *          target: DownloadTarget object, or its serializable representation.
 *                  See DownloadTarget.fromSerializable for details.
 *          saver: Serializable representation of a DownloadSaver object.  See
 *                 DownloadSaver.fromSerializable for details.  If omitted,
 *                 defaults to "copy".
 *        }
 *
 * @return The newly created Download object.
 */
Download.fromSerializable = function (aSerializable) {
  let download = new Download();
  if (aSerializable.source instanceof DownloadSource) {
    download.source = aSerializable.source;
  } else {
    download.source = DownloadSource.fromSerializable(aSerializable.source);
  }
  if (aSerializable.target instanceof DownloadTarget) {
    download.target = aSerializable.target;
  } else {
    download.target = DownloadTarget.fromSerializable(aSerializable.target);
  }
  if ("saver" in aSerializable) {
    download.saver = DownloadSaver.fromSerializable(aSerializable.saver);
  } else {
    download.saver = DownloadSaver.fromSerializable("copy");
  }
  download.saver.download = download;

  if ("startTime" in aSerializable) {
    let time = aSerializable.startTime.getTime
      ? aSerializable.startTime.getTime()
      : aSerializable.startTime;
    download.startTime = new Date(time);
  }

  // If 'errorObj' is present it will take precedence over the 'error' property.
  // 'error' is a legacy property only containing message, which is insufficient
  // to represent all of the error information.
  //
  // Instead of just replacing 'error' we use a new 'errorObj' so that previous
  // versions will keep it as an unknown property.
  if ("errorObj" in aSerializable) {
    download.error = DownloadError.fromSerializable(aSerializable.errorObj);
  } else if ("error" in aSerializable) {
    download.error = aSerializable.error;
  }

  for (let property of kPlainSerializableDownloadProperties) {
    if (property in aSerializable) {
      download[property] = aSerializable[property];
    }
  }

  deserializeUnknownProperties(
    download,
    aSerializable,
    property =>
      !kPlainSerializableDownloadProperties.includes(property) &&
      property != "startTime" &&
      property != "source" &&
      property != "target" &&
      property != "error" &&
      property != "saver"
  );

  return download;
};

/**
 * Represents the source of a download, for example a document or an URI.
 */
export var DownloadSource = function () {};

DownloadSource.prototype = {
  /**
   * String containing the URI for the download source.
   */
  url: null,

  /**
   * String containing the original URL for the download source.
   */
  originalUrl: null,

  /**
   * Indicates whether the download originated from a private window.  This
   * determines the context of the network request that is made to retrieve the
   * resource.
   */
  isPrivate: false,

  /**
   * Represents the referrerInfo of the download source, could be null for
   * example if the download source is not HTTP.
   */
  referrerInfo: null,

  /**
   * For downloads handled by the (default) DownloadCopySaver, this function
   * can adjust the network channel before it is opened, for example to change
   * the HTTP headers or to upload a stream as POST data.
   *
   * @note If this is defined this object will not be serializable, thus the
   *       Download object will not be persisted across sessions.
   *
   * @param aChannel
   *        The nsIChannel to be adjusted.
   *
   * @return {Promise}
   * @resolves When the channel has been adjusted and can be opened.
   * @rejects JavaScript exception that will cause the download to fail.
   */
  adjustChannel: null,

  /**
   * For downloads handled by the (default) DownloadCopySaver, this function
   * will determine, if provided, if a download can progress or has to be
   * cancelled based on the HTTP status code of the network channel.
   *
   * @note If this is defined this object will not be serializable, thus the
   *       Download object will not be persisted across sessions.
   *
   * @param aDownload
   *        The download asking.
   * @param aStatus
   *        The HTTP status in question
   *
   * @return {Boolean} Download can progress
   */
  allowHttpStatus: null,

  /**
   * Represents the loadingPrincipal of the download source,
   * could be null, in which case the system principal is used instead.
   */
  loadingPrincipal: null,

  /**
   * Represents the cookieJarSettings of the download source, could be null if
   * the download source is not from a document.
   */
  cookieJarSettings: null,

  /**
   * Represents the authentication header of the download source, could be null if
   * the download source had no authentication header.
   */
  authHeader: null,
  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable() {
    if (this.adjustChannel) {
      // If the callback was used, we can't reproduce this across sessions.
      return null;
    }

    if (this.allowHttpStatus) {
      // If the callback was used, we can't reproduce this across sessions.
      return null;
    }

    let serializable = { url: this.url };
    if (this.isPrivate) {
      serializable.isPrivate = true;
    }

    if (this.referrerInfo && isString(this.referrerInfo)) {
      serializable.referrerInfo = this.referrerInfo;
    } else if (this.referrerInfo) {
      serializable.referrerInfo = lazy.E10SUtils.serializeReferrerInfo(
        this.referrerInfo
      );
    }

    if (this.loadingPrincipal) {
      serializable.loadingPrincipal = isString(this.loadingPrincipal)
        ? this.loadingPrincipal
        : lazy.E10SUtils.serializePrincipal(this.loadingPrincipal);
    }

    if (this.cookieJarSettings) {
      serializable.cookieJarSettings = isString(this.cookieJarSettings)
        ? this.cookieJarSettings
        : lazy.E10SUtils.serializeCookieJarSettings(this.cookieJarSettings);
    }

    serializeUnknownProperties(this, serializable);

    // Simplify the representation if we don't have other details.
    if (Object.keys(serializable).length === 1) {
      // serializable's only key is "url", just return the URL as a string.
      return this.url;
    }
    return serializable;
  },
};

/**
 * Creates a new DownloadSource object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadSource object.  This may be a
 *        string containing the URI for the download source, an nsIURI, or an
 *        object with the following properties:
 *        {
 *          url: String containing the URI for the download source.
 *          isPrivate: Indicates whether the download originated from a private
 *                     window.  If omitted, the download is public.
 *          referrerInfo: represents the referrerInfo of the download source.
 *                        Can be omitted or null for example if the download
 *                        source is not HTTP.
 *          cookieJarSettings: represents the cookieJarSettings of the download
 *                             source. Can be omitted or null if the download
 *                             source is not from a document.
 *          adjustChannel: For downloads handled by (default) DownloadCopySaver,
 *                         this function can adjust the network channel before
 *                         it is opened, for example to change the HTTP headers
 *                         or to upload a stream as POST data.  Optional.
 *          allowHttpStatus: For downloads handled by the (default)
 *                           DownloadCopySaver, this function will determine, if
 *                           provided, if a download can progress or has to be
 *                           cancelled based on the HTTP status code of the
 *                           network channel.
 *        }
 *
 * @return The newly created DownloadSource object.
 */
DownloadSource.fromSerializable = function (aSerializable) {
  let source = new DownloadSource();
  if (isString(aSerializable)) {
    // Convert String objects to primitive strings at this point.
    source.url = aSerializable.toString();
  } else if (aSerializable instanceof Ci.nsIURI) {
    source.url = aSerializable.spec;
  } else {
    // Convert String objects to primitive strings at this point.
    source.url = aSerializable.url.toString();
    for (let propName of ["isPrivate", "userContextId", "browsingContextId"]) {
      if (propName in aSerializable) {
        source[propName] = aSerializable[propName];
      }
    }
    if ("originalUrl" in aSerializable) {
      source.originalUrl = aSerializable.originalUrl;
    }
    if ("referrerInfo" in aSerializable) {
      // Quick pass, pass directly nsIReferrerInfo, we don't need to serialize
      // and deserialize
      if (aSerializable.referrerInfo instanceof Ci.nsIReferrerInfo) {
        source.referrerInfo = aSerializable.referrerInfo;
      } else {
        source.referrerInfo = lazy.E10SUtils.deserializeReferrerInfo(
          aSerializable.referrerInfo
        );
      }
    }
    if ("loadingPrincipal" in aSerializable) {
      // Quick pass, pass directly nsIPrincipal, we don't need to serialize
      // and deserialize
      if (aSerializable.loadingPrincipal instanceof Ci.nsIPrincipal) {
        source.loadingPrincipal = aSerializable.loadingPrincipal;
      } else {
        source.loadingPrincipal = lazy.E10SUtils.deserializePrincipal(
          aSerializable.loadingPrincipal
        );
      }
    }
    if ("adjustChannel" in aSerializable) {
      source.adjustChannel = aSerializable.adjustChannel;
    }

    if ("allowHttpStatus" in aSerializable) {
      source.allowHttpStatus = aSerializable.allowHttpStatus;
    }

    if ("cookieJarSettings" in aSerializable) {
      if (aSerializable.cookieJarSettings instanceof Ci.nsICookieJarSettings) {
        source.cookieJarSettings = aSerializable.cookieJarSettings;
      } else {
        source.cookieJarSettings = lazy.E10SUtils.deserializeCookieJarSettings(
          aSerializable.cookieJarSettings
        );
      }
    }

    if ("authHeader" in aSerializable) {
      source.authHeader = aSerializable.authHeader;
    }

    deserializeUnknownProperties(
      source,
      aSerializable,
      property =>
        property != "url" &&
        property != "originalUrl" &&
        property != "isPrivate" &&
        property != "referrerInfo" &&
        property != "cookieJarSettings" &&
        property != "authHeader"
    );
  }

  return source;
};

/**
 * Represents the target of a download, for example a file in the global
 * downloads directory, or a file in the system temporary directory.
 */
export var DownloadTarget = function () {};

DownloadTarget.prototype = {
  /**
   * String containing the path of the target file.
   */
  path: null,

  /**
   * String containing the path of the ".part" file containing the data
   * downloaded so far, or null to disable the use of a ".part" file to keep
   * partially downloaded data.
   */
  partFilePath: null,

  /**
   * Indicates whether the target file exists.
   *
   * This is a dynamic property updated when the download finishes or when the
   * "refresh" method of the Download object is called. It can be used by the
   * front-end to reduce I/O compared to checking the target file directly.
   */
  exists: false,

  /**
   * Indicates whether the part file exists. Like `exists`, this is updated
   * dynamically to reduce I/O compared to checking the target file directly.
   */
  partFileExists: false,

  /**
   * Size in bytes of the target file, or zero if the download has not finished.
   *
   * Even if the target file does not exist anymore, this property may still
   * have a value taken from the download metadata. If the metadata has never
   * been available in this session and the size cannot be obtained from the
   * file because it has already been deleted, this property will be zero.
   *
   * For single-file downloads, this property will always match the actual file
   * size on disk, while the totalBytes property of the Download object, when
   * available, may represent the size of the encoded data instead.
   *
   * For downloads involving multiple files, like complete web pages saved to
   * disk, the meaning of this value is undefined. It currently matches the size
   * of the main file only rather than the sum of all the written data.
   *
   * This is a dynamic property updated when the download finishes or when the
   * "refresh" method of the Download object is called. It can be used by the
   * front-end to reduce I/O compared to checking the target file directly.
   */
  size: 0,

  /**
   * Sets the "exists" and "size" properties based on the actual file on disk.
   *
   * @return {Promise}
   * @resolves When the operation has finished successfully.
   * @rejects JavaScript exception.
   */
  async refresh() {
    try {
      this.size = (await IOUtils.stat(this.path)).size;
      this.exists = true;
    } catch (ex) {
      // Report any error not caused by the file not being there. In any case,
      // the size of the download is not updated and the known value is kept.
      if (ex.name != "NotFoundError") {
        console.error(ex);
      }
      this.exists = false;
    }
    this.refreshPartFileState();
  },

  async refreshPartFileState() {
    if (!this.partFilePath) {
      this.partFileExists = false;
      return;
    }
    try {
      this.partFileExists = (await IOUtils.stat(this.partFilePath)).size > 0;
    } catch (ex) {
      if (ex.name != "NotFoundError") {
        console.error(ex);
      }
      this.partFileExists = false;
    }
  },

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable() {
    // Simplify the representation if we don't have other details.
    if (!this.partFilePath && !this._unknownProperties) {
      return this.path;
    }

    let serializable = { path: this.path, partFilePath: this.partFilePath };
    serializeUnknownProperties(this, serializable);
    return serializable;
  },
};

/**
 * Creates a new DownloadTarget object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadTarget object.  This may be a
 *        string containing the path of the target file, an nsIFile, or an
 *        object with the following properties:
 *        {
 *          path: String containing the path of the target file.
 *          partFilePath: optional string containing the part file path.
 *        }
 *
 * @return The newly created DownloadTarget object.
 */
DownloadTarget.fromSerializable = function (aSerializable) {
  let target = new DownloadTarget();
  if (isString(aSerializable)) {
    // Convert String objects to primitive strings at this point.
    target.path = aSerializable.toString();
  } else if (aSerializable instanceof Ci.nsIFile) {
    // Read the "path" property of nsIFile after checking the object type.
    target.path = aSerializable.path;
  } else {
    // Read the "path" property of the serializable DownloadTarget
    // representation, converting String objects to primitive strings.
    target.path = aSerializable.path.toString();
    if ("partFilePath" in aSerializable) {
      target.partFilePath = aSerializable.partFilePath;
    }

    deserializeUnknownProperties(
      target,
      aSerializable,
      property => property != "path" && property != "partFilePath"
    );
  }
  return target;
};

/**
 * Provides detailed information about a download failure.
 *
 * @param aProperties
 *        Object which may contain any of the following properties:
 *          {
 *            result: Result error code, defaulting to Cr.NS_ERROR_FAILURE
 *            message: String error message to be displayed in the console, or
 *                     null to use the message associated with the result code.
 *            inferCause: If true, attempts to determine if the cause of the
 *                        download is a network failure or a local file failure,
 *                        based on a set of known values of the result code.
 *                        This is useful when the error is received by a
 *                        component that handles both aspects of the download.
 *            localizedReason: If available, is a localized reason for the error
 *                             that can be directly displayed in the UI.
 *          }
 *        The properties object may also contain any of the DownloadError's
 *        because properties, which will be set accordingly in the error object.
 */
export var DownloadError = function (aProperties) {
  const NS_ERROR_MODULE_BASE_OFFSET = 0x45;
  const NS_ERROR_MODULE_NETWORK = 6;
  const NS_ERROR_MODULE_FILES = 13;

  // Set the error name used by the Error object prototype first.
  this.name = "DownloadError";
  this.result = aProperties.result || Cr.NS_ERROR_FAILURE;
  this.localizedReason = aProperties.localizedReason;
  if (aProperties.message) {
    this.message = aProperties.message;
  } else if (
    aProperties.becauseBlocked ||
    aProperties.becauseBlockedByParentalControls ||
    aProperties.becauseBlockedByReputationCheck
  ) {
    this.message = "Download blocked.";
  } else {
    let exception = new Components.Exception("", this.result);
    this.message = exception.toString();
  }
  if (aProperties.inferCause) {
    let module =
      ((this.result & 0x7fff0000) >> 16) - NS_ERROR_MODULE_BASE_OFFSET;
    this.becauseSourceFailed = module == NS_ERROR_MODULE_NETWORK;
    this.becauseTargetFailed = module == NS_ERROR_MODULE_FILES;
  } else {
    if (aProperties.becauseSourceFailed) {
      this.becauseSourceFailed = true;
    }
    if (aProperties.becauseTargetFailed) {
      this.becauseTargetFailed = true;
    }
  }

  if (aProperties.becauseBlockedByParentalControls) {
    this.becauseBlocked = true;
    this.becauseBlockedByParentalControls = true;
  } else if (aProperties.becauseBlockedByReputationCheck) {
    this.becauseBlocked = true;
    this.becauseBlockedByReputationCheck = true;
    this.reputationCheckVerdict = aProperties.reputationCheckVerdict || "";
  } else if (aProperties.becauseBlocked) {
    this.becauseBlocked = true;
  }

  if (aProperties.innerException) {
    this.innerException = aProperties.innerException;
  }

  this.stack = new Error().stack;
};

/**
 * These constants are used by the reputationCheckVerdict property and indicate
 * the detailed reason why a download is blocked.
 *
 * @note These values should not be changed because they can be serialized.
 */
DownloadError.BLOCK_VERDICT_MALWARE = "Malware";
DownloadError.BLOCK_VERDICT_POTENTIALLY_UNWANTED = "PotentiallyUnwanted";
DownloadError.BLOCK_VERDICT_INSECURE = "Insecure";
DownloadError.BLOCK_VERDICT_UNCOMMON = "Uncommon";
DownloadError.BLOCK_VERDICT_DOWNLOAD_SPAM = "DownloadSpam";

DownloadError.prototype = {
  /**
   * The result code associated with this error.
   */
  result: false,

  /**
   * Indicates an error occurred while reading from the remote location.
   */
  becauseSourceFailed: false,

  /**
   * Indicates an error occurred while writing to the local target.
   */
  becauseTargetFailed: false,

  /**
   * Indicates the download failed because it was blocked.  If the reason for
   * blocking is known, the corresponding property will be also set.
   */
  becauseBlocked: false,

  /**
   * Indicates the download was blocked because downloads are globally
   * disallowed by the Parental Controls or Family Safety features on Windows.
   */
  becauseBlockedByParentalControls: false,

  /**
   * Indicates the download was blocked because it failed the reputation check
   * and may be malware.
   */
  becauseBlockedByReputationCheck: false,

  /**
   * If becauseBlockedByReputationCheck is true, indicates the detailed reason
   * why the download was blocked, according to the "BLOCK_VERDICT_" constants.
   *
   * If the download was not blocked or the reason for the block is unknown,
   * this will be an empty string.
   */
  reputationCheckVerdict: "",

  /**
   * If this DownloadError was caused by an exception this property will
   * contain the original exception. This will not be serialized when saving
   * to the store.
   */
  innerException: null,

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable() {
    let serializable = {
      result: this.result,
      localizedReason: this.localizedReason,
      message: this.message,
      becauseSourceFailed: this.becauseSourceFailed,
      becauseTargetFailed: this.becauseTargetFailed,
      becauseBlocked: this.becauseBlocked,
      becauseBlockedByParentalControls: this.becauseBlockedByParentalControls,
      becauseBlockedByReputationCheck: this.becauseBlockedByReputationCheck,
      reputationCheckVerdict: this.reputationCheckVerdict,
    };

    serializeUnknownProperties(this, serializable);
    return serializable;
  },
};
Object.setPrototypeOf(DownloadError.prototype, Error.prototype);

/**
 * Creates a new DownloadError object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadError object.
 *
 * @return The newly created DownloadError object.
 */
DownloadError.fromSerializable = function (aSerializable) {
  let e = new DownloadError(aSerializable);
  deserializeUnknownProperties(
    e,
    aSerializable,
    property =>
      property != "result" &&
      property != "message" &&
      property != "becauseSourceFailed" &&
      property != "becauseTargetFailed" &&
      property != "becauseBlocked" &&
      property != "becauseBlockedByParentalControls" &&
      property != "becauseBlockedByReputationCheck" &&
      property != "reputationCheckVerdict"
  );

  return e;
};

/**
 * Template for an object that actually transfers the data for the download.
 */
export var DownloadSaver = function () {};

DownloadSaver.prototype = {
  /**
   * Download object for raising notifications and reading properties.
   *
   * If the tryToKeepPartialData property of the download object is false, the
   * saver should never try to keep partially downloaded data if the download
   * fails.
   */
  download: null,

  /**
   * Executes the download.
   *
   * @param aSetProgressBytesFn
   *        This function may be called by the saver to report progress. It
   *        takes three arguments: the first is the number of bytes transferred
   *        until now, the second is the total number of bytes to be
   *        transferred (or -1 if unknown), the third indicates whether the
   *        partially downloaded data can be used when restarting the download
   *        if it fails or is canceled.
   * @param aSetPropertiesFn
   *        This function may be called by the saver to report information
   *        about new download properties discovered by the saver during the
   *        download process. It takes an object where the keys represents
   *        the names of the properties to set, and the value represents the
   *        value to set.
   *
   * @return {Promise}
   * @resolves When the download has finished successfully.
   * @rejects JavaScript exception if the download failed.
   */
  async execute() {
    throw new Error("Not implemented.");
  },

  /**
   * Cancels the download.
   */
  cancel: function DS_cancel() {
    throw new Error("Not implemented.");
  },

  /**
   * Removes any target file placeholder and any partial data kept as part of a
   * canceled, failed, or temporarily blocked download.
   *
   * This method is never called until the promise returned by "execute" is
   * either resolved or rejected, and the "execute" method is not called again
   * until the promise returned by this method is resolved or rejected.
   *
   * @param canRemoveFinalTarget
   *        True if can remove target file regardless of it being a placeholder.
   * @return {Promise}
   * @resolves When the operation has finished successfully.
   * @rejects Never.
   */
  async removeData() {},

  /**
   * This can be called by the saver implementation when the download is already
   * started, to add it to the browsing history.  This method has no effect if
   * the download is private.
   */
  addToHistory() {
    if (AppConstants.MOZ_PLACES) {
      lazy.DownloadHistory.addDownloadToHistory(this.download).catch(
        console.error
      );
    }
  },

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable() {
    throw new Error("Not implemented.");
  },

  /**
   * Returns the SHA-256 hash of the downloaded file, if it exists.
   */
  getSha256Hash() {
    throw new Error("Not implemented.");
  },

  getSignatureInfo() {
    throw new Error("Not implemented.");
  },
}; // DownloadSaver

/**
 * Creates a new DownloadSaver object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadSaver object.  If no initial
 *        state information for the saver object is needed, can be a string
 *        representing the class of the download operation, for example "copy".
 *
 * @return The newly created DownloadSaver object.
 */
DownloadSaver.fromSerializable = function (aSerializable) {
  let serializable = isString(aSerializable)
    ? { type: aSerializable }
    : aSerializable;
  let saver;
  switch (serializable.type) {
    case "copy":
      saver = DownloadCopySaver.fromSerializable(serializable);
      break;
    case "legacy":
      saver = DownloadLegacySaver.fromSerializable(serializable);
      break;
    default:
      throw new Error("Unrecoginzed download saver type.");
  }
  return saver;
};

/**
 * Saver object that simply copies the entire source file to the target.
 */
export var DownloadCopySaver = function () {};

DownloadCopySaver.prototype = {
  /**
   * BackgroundFileSaver object currently handling the download.
   */
  _backgroundFileSaver: null,

  /**
   * Indicates whether the "cancel" method has been called.  This is used to
   * prevent the request from starting in case the operation is canceled before
   * the BackgroundFileSaver instance has been created.
   */
  _canceled: false,

  /**
   * Save the SHA-256 hash in raw bytes of the downloaded file. This is null
   * unless BackgroundFileSaver has successfully completed saving the file.
   */
  _sha256Hash: null,

  /**
   * Save the signature info as an Array of Array of raw bytes of nsIX509Cert
   * if the file is signed. This is empty if the file is unsigned, and null
   * unless BackgroundFileSaver has successfully completed saving the file.
   */
  _signatureInfo: null,

  /**
   * Save the redirects chain as an nsIArray of nsIPrincipal.
   */
  _redirects: null,

  /**
   * True if the associated download has already been added to browsing history.
   */
  alreadyAddedToHistory: false,

  /**
   * String corresponding to the entityID property of the nsIResumableChannel
   * used to execute the download, or null if the channel was not resumable or
   * the saver was instructed not to keep partially downloaded data.
   */
  entityID: null,

  /**
   * Implements "DownloadSaver.execute".
   */
  async execute(aSetProgressBytesFn, aSetPropertiesFn) {
    this._canceled = false;

    let download = this.download;
    let targetPath = download.target.path;
    let partFilePath = download.target.partFilePath;
    let keepPartialData = download.tryToKeepPartialData;

    // Add the download to history the first time it is started in this
    // session.  If the download is restarted in a different session, a new
    // history visit will be added.  We do this just to avoid the complexity
    // of serializing this state between sessions, since adding a new visit
    // does not have any noticeable side effect.
    if (!this.alreadyAddedToHistory) {
      this.addToHistory();
      this.alreadyAddedToHistory = true;
    }

    // To reduce the chance that other downloads reuse the same final target
    // file name, we should create a placeholder as soon as possible, before
    // starting the network request.  The placeholder is also required in case
    // we are using a ".part" file instead of the final target while the
    // download is in progress.
    try {
      // If the file already exists, don't delete its contents yet.
      await IOUtils.writeUTF8(targetPath, "", { mode: "appendOrCreate" });
    } catch (ex) {
      if (!DOMException.isInstance(ex)) {
        throw ex;
      }
      // Throw a DownloadError indicating that the operation failed because of
      // the target file.  We cannot translate this into a specific result
      // code, but we preserve the original message.
      let error = new DownloadError({ message: ex.message });
      error.becauseTargetFailed = true;
      throw error;
    }

    let deferSaveComplete = Promise.withResolvers();

    if (this._canceled) {
      // Don't create the BackgroundFileSaver object if we have been
      // canceled meanwhile.
      throw new DownloadError({ message: "Saver canceled." });
    }

    // Create the object that will save the file in a background thread.
    let backgroundFileSaver = new BackgroundFileSaverStreamListener();
    backgroundFileSaver.QueryInterface(Ci.nsIStreamListener);

    try {
      // When the operation completes, reflect the status in the promise
      // returned by this download execution function.
      backgroundFileSaver.observer = {
        onTargetChange() {},
        onSaveComplete: (aSaver, aStatus) => {
          // Send notifications now that we can restart if needed.
          if (Components.isSuccessCode(aStatus)) {
            // Save the hash before freeing backgroundFileSaver.
            this._sha256Hash = aSaver.sha256Hash;
            this._signatureInfo = aSaver.signatureInfo;
            this._redirects = aSaver.redirects;
            deferSaveComplete.resolve();
          } else {
            // Infer the origin of the error from the failure code, because
            // BackgroundFileSaver does not provide more specific data.
            let properties = { result: aStatus, inferCause: true };
            deferSaveComplete.reject(new DownloadError(properties));
          }
          // Free the reference cycle, to release resources earlier.
          backgroundFileSaver.observer = null;
          this._backgroundFileSaver = null;
        },
      };

      // If we have data that we can use to resume the download from where
      // it stopped, try to use it.
      let resumeAttempted = false;
      let resumeFromBytes = 0;

      const notificationCallbacks = {
        QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor"]),
        getInterface: ChromeUtils.generateQI(["nsIProgressEventSink"]),
        onProgress: function DCSE_onProgress(
          aRequest,
          aProgress,
          aProgressMax
        ) {
          let currentBytes = resumeFromBytes + aProgress;
          let totalBytes =
            aProgressMax == -1 ? -1 : resumeFromBytes + aProgressMax;
          aSetProgressBytesFn(
            currentBytes,
            totalBytes,
            aProgress > 0 && partFilePath && keepPartialData
          );
        },
        onStatus() {},
      };

      const streamListener = {
        onStartRequest: function (aRequest) {
          backgroundFileSaver.onStartRequest(aRequest);

          if (aRequest instanceof Ci.nsIHttpChannel) {
            // Check if the request's response has been blocked by Windows
            // Parental Controls with an HTTP 450 error code.
            if (aRequest.responseStatus == 450) {
              // Set a flag that can be retrieved later when handling the
              // cancellation so that the proper error can be thrown.
              this.download._blockedByParentalControls = true;
              aRequest.cancel(Cr.NS_BINDING_ABORTED);
              return;
            }

            // Check back with the initiator if we should allow a certain
            // HTTP code. By default, we'll just save error pages too,
            // however a consumer down the line, such as the WebExtensions
            // downloads API might want to handle this differently.
            if (
              download.source.allowHttpStatus &&
              !download.source.allowHttpStatus(
                download,
                aRequest.responseStatus
              )
            ) {
              aRequest.cancel(Cr.NS_BINDING_ABORTED);
              return;
            }
          }

          if (aRequest instanceof Ci.nsIChannel) {
            aSetPropertiesFn({ contentType: aRequest.contentType });

            // Ensure we report the value of "Content-Length", if available,
            // even if the download doesn't generate any progress events
            // later.
            if (aRequest.contentLength >= 0) {
              aSetProgressBytesFn(0, aRequest.contentLength);
            }
          }

          // If the URL we are downloading from includes a file extension
          // that matches the "Content-Encoding" header, for example ".gz"
          // with a "gzip" encoding, we should save the file in its encoded
          // form.  In all other cases, we decode the body while saving.
          if (
            aRequest instanceof Ci.nsIEncodedChannel &&
            aRequest.contentEncodings
          ) {
            let uri = aRequest.URI;
            if (uri instanceof Ci.nsIURL && uri.fileExtension) {
              // Only the first, outermost encoding is considered.
              let encoding = aRequest.contentEncodings.getNext();
              if (encoding) {
                aRequest.applyConversion =
                  lazy.gExternalHelperAppService.applyDecodingForExtension(
                    uri.fileExtension,
                    encoding
                  );
              }
            }
          }

          if (keepPartialData) {
            // If the source is not resumable, don't keep partial data even
            // if we were asked to try and do it.
            if (aRequest instanceof Ci.nsIResumableChannel) {
              try {
                // If reading the ID succeeds, the source is resumable.
                this.entityID = aRequest.entityID;
              } catch (ex) {
                if (
                  !(ex instanceof Components.Exception) ||
                  ex.result != Cr.NS_ERROR_NOT_RESUMABLE
                ) {
                  throw ex;
                }
                keepPartialData = false;
              }
            } else {
              keepPartialData = false;
            }
          }

          // Enable hashing and signature verification before setting the
          // target.
          backgroundFileSaver.enableSha256();
          backgroundFileSaver.enableSignatureInfo();
          if (partFilePath) {
            // If we actually resumed a request, append to the partial data.
            if (resumeAttempted) {
              // TODO: Handle Cr.NS_ERROR_ENTITY_CHANGED
              backgroundFileSaver.enableAppend();
            }

            // Use a part file, determining if we should keep it on failure.
            backgroundFileSaver.setTarget(
              new lazy.FileUtils.File(partFilePath),
              keepPartialData
            );
          } else {
            // Set the final target file, and delete it on failure.
            backgroundFileSaver.setTarget(
              new lazy.FileUtils.File(targetPath),
              false
            );
          }
        }.bind(this),

        onStopRequest(aRequest, aStatusCode) {
          try {
            backgroundFileSaver.onStopRequest(aRequest, aStatusCode);
          } finally {
            // If the data transfer completed successfully, indicate to the
            // background file saver that the operation can finish.  If the
            // data transfer failed, the saver has been already stopped.
            if (Components.isSuccessCode(aStatusCode)) {
              backgroundFileSaver.finish(Cr.NS_OK);
            }
          }
        },

        onDataAvailable: (aRequest, aInputStream, aOffset, aCount) => {
          // Check if the download have been canceled in the mean time,
          // and close the channel and return earlier, BackgroundFileSaver
          // methods shouldn't be called anymore after `finish` was called
          // on download cancellation.
          if (this._canceled) {
            aRequest.cancel(Cr.NS_BINDING_ABORTED);
            return;
          }
          backgroundFileSaver.onDataAvailable(
            aRequest,
            aInputStream,
            aOffset,
            aCount
          );
        },
      };

      // Wrap the channel creation, to prevent the listener code from
      // accidentally using the wrong channel.
      // The channel that is created here is not necessarily the same channel
      // that will eventually perform the actual download.
      // When a HTTP redirect happens, the http backend will create a new
      // channel, this initial channel will be abandoned, and its properties
      // will either return incorrect data, or worse, will throw exceptions
      // upon access.
      const open = async () => {
        // Create a channel from the source, and listen to progress
        // notifications.
        let channel;
        if (download.source.loadingPrincipal) {
          channel = lazy.NetUtil.newChannel({
            uri: download.source.url,
            contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
            loadingPrincipal: download.source.loadingPrincipal,
            // triggeringPrincipal must be the system principal to prevent the
            // request from being mistaken as a third-party request.
            triggeringPrincipal:
              Services.scriptSecurityManager.getSystemPrincipal(),
            securityFlags:
              Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
          });
        } else {
          channel = lazy.NetUtil.newChannel({
            uri: download.source.url,
            contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
            loadUsingSystemPrincipal: true,
          });
        }
        if (channel instanceof Ci.nsIPrivateBrowsingChannel) {
          channel.setPrivate(download.source.isPrivate);
        }
        if (
          channel instanceof Ci.nsIHttpChannel &&
          download.source.referrerInfo
        ) {
          channel.referrerInfo = download.source.referrerInfo;
          // Stored computed referrerInfo;
          download.source.referrerInfo = channel.referrerInfo;
        }
        if (
          channel instanceof Ci.nsIHttpChannel &&
          download.source.cookieJarSettings
        ) {
          channel.loadInfo.cookieJarSettings =
            download.source.cookieJarSettings;
        }
        if (
          channel instanceof Ci.nsIHttpChannel &&
          download.source.authHeader
        ) {
          try {
            channel.setRequestHeader(
              "Authorization",
              download.source.authHeader,
              true
            );
          } catch (e) {}
        }

        if (download.source.userContextId) {
          // Getters and setters only exist on originAttributes,
          // so it has to be cloned, changed, and re-set
          channel.loadInfo.originAttributes = {
            ...channel.loadInfo.originAttributes,
            userContextId: download.source.userContextId,
          };
        }

        // This makes the channel be corretly throttled during page loads
        // and also prevents its caching.
        if (channel instanceof Ci.nsIHttpChannelInternal) {
          channel.channelIsForDownload = true;

          // Include cookies even if cookieBehavior is BEHAVIOR_REJECT_FOREIGN.
          channel.forceAllowThirdPartyCookie = true;
        }

        if (
          channel instanceof Ci.nsIResumableChannel &&
          this.entityID &&
          partFilePath &&
          keepPartialData
        ) {
          try {
            let stat = await IOUtils.stat(partFilePath);
            channel.resumeAt(stat.size, this.entityID);
            resumeAttempted = true;
            resumeFromBytes = stat.size;
          } catch (ex) {
            if (ex.name != "NotFoundError") {
              throw ex;
            }
          }
        }

        channel.notificationCallbacks = notificationCallbacks;

        // If the callback was set, handle it now before opening the channel.
        if (download.source.adjustChannel) {
          await download.source.adjustChannel(channel);
        }
        channel.asyncOpen(streamListener);
      };

      // Kick off the download, creating and opening the channel.
      await open();

      // We should check if we have been canceled in the meantime, after
      // all the previous asynchronous operations have been executed and
      // just before we set the _backgroundFileSaver property.
      if (this._canceled) {
        throw new DownloadError({ message: "Saver canceled." });
      }

      // If the operation succeeded, store the object to allow cancellation.
      this._backgroundFileSaver = backgroundFileSaver;
    } catch (ex) {
      // In case an error occurs while setting up the chain of objects for
      // the download, ensure that we release the resources of the saver.
      backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
      // Since we're not going to handle deferSaveComplete.promise below,
      // we need to make sure that the rejection is handled.
      deferSaveComplete.promise.catch(() => {});
      throw ex;
    }

    // We will wait on this promise in case no error occurred while setting
    // up the chain of objects for the download.
    await deferSaveComplete.promise;

    await this._checkReputationAndMove(aSetPropertiesFn);
  },

  /**
   * Perform the reputation check and cleanup the downloaded data if required.
   * If the download passes the reputation check and is using a part file we
   * will move it to the target path since reputation checking is the final
   * step in the saver.
   *
   * @param aSetPropertiesFn
   *        Function provided to the "execute" method.
   *
   * @return {Promise}
   * @resolves When the reputation check and cleanup is complete.
   * @rejects DownloadError if the download should be blocked.
   */
  async _checkReputationAndMove(aSetPropertiesFn) {
    let download = this.download;
    let targetPath = this.download.target.path;
    let partFilePath = this.download.target.partFilePath;

    let { shouldBlock, verdict } =
      await lazy.DownloadIntegration.shouldBlockForReputationCheck(download);
    if (shouldBlock) {
      Services.telemetry
        .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD")
        .add(verdict, 0);

      let newProperties = { progress: 100, hasPartialData: false };

      // We will remove the potentially dangerous file if instructed by
      // DownloadIntegration. We will always remove the file when the
      // download did not use a partial file path, meaning it
      // currently has its final filename.
      if (!lazy.DownloadIntegration.shouldKeepBlockedData() || !partFilePath) {
        await this.removeData(!partFilePath);
      } else {
        newProperties.hasBlockedData = true;
      }

      aSetPropertiesFn(newProperties);

      throw new DownloadError({
        becauseBlockedByReputationCheck: true,
        reputationCheckVerdict: verdict,
      });
    }

    if (partFilePath) {
      try {
        await IOUtils.move(partFilePath, targetPath);
      } catch (e) {
        if (e.name === "NotAllowedError") {
          // In case we cannot write to the target file
          // retry with a new unique name
          let uniquePath = lazy.DownloadPaths.createNiceUniqueFile(
            new lazy.FileUtils.File(targetPath)
          ).path;
          await IOUtils.move(partFilePath, uniquePath);
          this.download.target.path = uniquePath;
        } else {
          throw e;
        }
      }
    }
  },

  /**
   * Implements "DownloadSaver.cancel".
   */
  cancel: function DCS_cancel() {
    this._canceled = true;
    if (this._backgroundFileSaver) {
      this._backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
      this._backgroundFileSaver = null;
    }
  },

  /**
   * Implements "DownloadSaver.removeData".
   */
  async removeData(canRemoveFinalTarget = false) {
    // Defined inline so removeData can be shared with DownloadLegacySaver.
    async function _tryToRemoveFile(path) {
      try {
        await IOUtils.remove(path);
      } catch (ex) {
        // On Windows we may get an access denied error instead of a no such
        // file error if the file existed before, and was recently deleted. This
        // is likely to happen when the component that executed the download has
        // just deleted the target file itself.
        if (!["NotFoundError", "NotAllowedError"].includes(ex.name)) {
          console.error(ex);
        }
      }
    }

    if (this.download.target.partFilePath) {
      await _tryToRemoveFile(this.download.target.partFilePath);
    }

    if (this.download.target.path) {
      if (
        canRemoveFinalTarget ||
        (await isPlaceholder(this.download.target.path))
      ) {
        await _tryToRemoveFile(this.download.target.path);
      }
      this.download.target.exists = false;
      this.download.target.size = 0;
    }
  },

  /**
   * Implements "DownloadSaver.toSerializable".
   */
  toSerializable() {
    // Simplify the representation if we don't have other details.
    if (!this.entityID && !this._unknownProperties) {
      return "copy";
    }

    let serializable = { type: "copy", entityID: this.entityID };
    serializeUnknownProperties(this, serializable);
    return serializable;
  },

  /**
   * Implements "DownloadSaver.getSha256Hash"
   */
  getSha256Hash() {
    return this._sha256Hash;
  },

  /*
   * Implements DownloadSaver.getSignatureInfo.
   */
  getSignatureInfo() {
    return this._signatureInfo;
  },

  /*
   * Implements DownloadSaver.getRedirects.
   */
  getRedirects() {
    return this._redirects;
  },
};
Object.setPrototypeOf(DownloadCopySaver.prototype, DownloadSaver.prototype);

/**
 * Creates a new DownloadCopySaver object, with its initial state derived from
 * its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadCopySaver object.
 *
 * @return The newly created DownloadCopySaver object.
 */
DownloadCopySaver.fromSerializable = function (aSerializable) {
  let saver = new DownloadCopySaver();
  if ("entityID" in aSerializable) {
    saver.entityID = aSerializable.entityID;
  }

  deserializeUnknownProperties(
    saver,
    aSerializable,
    property => property != "entityID" && property != "type"
  );

  return saver;
};

/**
 * Saver object that integrates with the legacy nsITransfer interface.
 *
 * For more background on the process, see the DownloadLegacyTransfer object.
 */
export var DownloadLegacySaver = function () {
  this.deferExecuted = Promise.withResolvers();
  this.deferCanceled = Promise.withResolvers();
};

DownloadLegacySaver.prototype = {
  /**
   * Save the SHA-256 hash in raw bytes of the downloaded file. This may be
   * null when nsExternalHelperAppService (and thus BackgroundFileSaver) is not
   * invoked.
   */
  _sha256Hash: null,

  /**
   * Save the signature info as an Array of Array of raw bytes of nsIX509Cert
   * if the file is signed. This is empty if the file is unsigned, and null
   * unless BackgroundFileSaver has successfully completed saving the file.
   */
  _signatureInfo: null,

  /**
   * Save the redirect chain as an nsIArray of nsIPrincipal.
   */
  _redirects: null,

  /**
   * nsIRequest object associated to the status and progress updates we
   * received.  This object is null before we receive the first status and
   * progress update, and is also reset to null when the download is stopped.
   */
  request: null,

  /**
   * This deferred object contains a promise that is resolved as soon as this
   * download finishes successfully, and is rejected in case the download is
   * canceled or receives a failure notification through nsITransfer.
   */
  deferExecuted: null,

  /**
   * This deferred object contains a promise that is resolved if the download
   * receives a cancellation request through the "cancel" method, and is never
   * rejected.  The nsITransfer implementation will register a handler that
   * actually causes the download cancellation.
   */
  deferCanceled: null,

  /**
   * This is populated with the value of the aSetProgressBytesFn argument of the
   * "execute" method, and is null before the method is called.
   */
  setProgressBytesFn: null,

  /**
   * Called by the nsITransfer implementation while the download progresses.
   *
   * @param aCurrentBytes
   *        Number of bytes transferred until now.
   * @param aTotalBytes
   *        Total number of bytes to be transferred, or -1 if unknown.
   */
  onProgressBytes: function DLS_onProgressBytes(aCurrentBytes, aTotalBytes) {
    this.progressWasNotified = true;

    // Ignore progress notifications until we are ready to process them.
    if (!this.setProgressBytesFn) {
      // Keep the data from the last progress notification that was received.
      this.currentBytes = aCurrentBytes;
      this.totalBytes = aTotalBytes;
      return;
    }

    let hasPartFile = !!this.download.target.partFilePath;

    this.setProgressBytesFn(
      aCurrentBytes,
      aTotalBytes,
      aCurrentBytes > 0 && hasPartFile
    );
  },

  /**
   * Whether the onProgressBytes function has been called at least once.
   */
  progressWasNotified: false,

  /**
   * Called by the nsITransfer implementation when the request has started.
   *
   * @param aRequest
   *        nsIRequest associated to the status update.
   */
  onTransferStarted(aRequest) {
    // Store a reference to the request, used in some cases when handling
    // completion, and also checked during the download by unit tests.
    this.request = aRequest;

    // Store the entity ID to use for resuming if required.
    if (
      this.download.tryToKeepPartialData &&
      aRequest instanceof Ci.nsIResumableChannel
    ) {
      try {
        // If reading the ID succeeds, the source is resumable.
        this.entityID = aRequest.entityID;
      } catch (ex) {
        if (
          !(ex instanceof Components.Exception) ||
          ex.result != Cr.NS_ERROR_NOT_RESUMABLE
        ) {
          throw ex;
        }
      }
    }

    // For legacy downloads, we must update the referrerInfo at this time.
    if (aRequest instanceof Ci.nsIHttpChannel) {
      this.download.source.referrerInfo = aRequest.referrerInfo;
    }

    // Don't open the download panel when the user initiated to save a
    // link or document.
    if (
      aRequest instanceof Ci.nsIChannel &&
      aRequest.loadInfo.isUserTriggeredSave
    ) {
      this.download.openDownloadsListOnStart = false;
    }

    this.addToHistory();
  },

  /**
   * Called by the nsITransfer implementation when the request has finished.
   *
   * @param {nsresult} status
   *        Status code received by the nsITransfer implementation.
   * @param {string} [localizedReason]
   *        Optional localized error message associated with a failure
   */
  onTransferFinished(status, localizedReason) {
    if (Components.isSuccessCode(status)) {
      this.deferExecuted.resolve();
    } else {
      // Infer the origin of the error from the failure code, because more
      // specific data is not available through the nsITransfer implementation.
      let properties = {
        result: status,
        inferCause: true,
        localizedReason,
      };
      this.deferExecuted.reject(new DownloadError(properties));
    }
  },

  /**
   * When the first execution of the download finished, it can be restarted by
   * using a DownloadCopySaver object instead of the original legacy component
   * that executed the download.
   */
  firstExecutionFinished: false,

  /**
   * In case the download is restarted after the first execution finished, this
   * property contains a reference to the DownloadCopySaver that is executing
   * the new download attempt.
   */
  copySaver: null,

  /**
   * String corresponding to the entityID property of the nsIResumableChannel
   * used to execute the download, or null if the channel was not resumable or
   * the saver was instructed not to keep partially downloaded data.
   */
  entityID: null,

  /**
   * Implements "DownloadSaver.execute".
   */
  async execute(aSetProgressBytesFn, aSetPropertiesFn) {
    // Check if this is not the first execution of the download.  The Download
    // object guarantees that this function is not re-entered during execution.
    if (this.firstExecutionFinished) {
      if (!this.copySaver) {
        this.copySaver = new DownloadCopySaver();
        this.copySaver.download = this.download;
        this.copySaver.entityID = this.entityID;
        this.copySaver.alreadyAddedToHistory = true;
      }
      await this.copySaver.execute.apply(this.copySaver, arguments);
      return;
    }

    this.setProgressBytesFn = aSetProgressBytesFn;
    if (this.progressWasNotified) {
      this.onProgressBytes(this.currentBytes, this.totalBytes);
    }

    try {
      // Wait for the component that executes the download to finish.
      await this.deferExecuted.promise;

      // At this point, the "request" property has been populated.  Ensure we
      // report the value of "Content-Length", if available, even if the
      // download didn't generate any progress events.
      if (
        !this.progressWasNotified &&
        this.request instanceof Ci.nsIChannel &&
        this.request.contentLength >= 0
      ) {
        aSetProgressBytesFn(0, this.request.contentLength);
      }

      // If the component executing the download provides the path of a
      // ".part" file, it means that it expects the listener to move the file
      // to its final target path when the download succeeds.  In this case,
      // an empty ".part" file is created even if no data was received from
      // the source.
      //
      // When no ".part" file path is provided the download implementation may
      // not have created the target file (if no data was received from the
      // source).  In this case, ensure that an empty file is created as
      // expected.
      if (!this.download.target.partFilePath) {
        try {
          // This atomic operation is more efficient than an existence check.
          await IOUtils.writeUTF8(this.download.target.path, "", {
            mode: "create",
          });
        } catch (ex) {
          if (
            !DOMException.isInstance(ex) ||
            ex.name !== "NoModificationAllowedError"
          ) {
            throw ex;
          }
        }
      }

      await this._checkReputationAndMove(aSetPropertiesFn);
    } catch (ex) {
      // In case the operation failed, ensure we stop downloading data.  Since
      // we never re-enter this function, deferCanceled is always available.
      this.deferCanceled.resolve();
      throw ex;
    } finally {
      // We don't need the reference to the request anymore.  We must also set
      // deferCanceled to null in order to free any indirect references it
      // may hold to the request.
      this.request = null;
      this.deferCanceled = null;
      // Allow the download to restart through a DownloadCopySaver.
      this.firstExecutionFinished = true;
    }
  },

  _checkReputationAndMove() {
    return DownloadCopySaver.prototype._checkReputationAndMove.apply(
      this,
      arguments
    );
  },

  /**
   * Implements "DownloadSaver.cancel".
   */
  cancel: function DLS_cancel() {
    // We may be using a DownloadCopySaver to handle resuming.
    if (this.copySaver) {
      return this.copySaver.cancel.apply(this.copySaver, arguments);
    }

    // If the download hasn't stopped already, resolve deferCanceled so that the
    // operation is canceled as soon as a cancellation handler is registered.
    // Note that the handler might not have been registered yet.
    if (this.deferCanceled) {
      this.deferCanceled.resolve();
    }
  },

  /**
   * Implements "DownloadSaver.removeData".
   */
  removeData(canRemoveFinalTarget) {
    // DownloadCopySaver and DownloadLegacySaver use the same logic for removing
    // partially downloaded data, though this implementation isn't shared by
    // other saver types, thus it isn't found on their shared prototype.
    return DownloadCopySaver.prototype.removeData.call(
      this,
      canRemoveFinalTarget
    );
  },

  /**
   * Implements "DownloadSaver.toSerializable".
   */
  toSerializable() {
    // This object depends on legacy components that are created externally,
    // thus it cannot be rebuilt during deserialization.  To support resuming
    // across different browser sessions, this object is transformed into a
    // DownloadCopySaver for the purpose of serialization.
    return DownloadCopySaver.prototype.toSerializable.call(this);
  },

  /**
   * Implements "DownloadSaver.getSha256Hash".
   */
  getSha256Hash() {
    if (this.copySaver) {
      return this.copySaver.getSha256Hash();
    }
    return this._sha256Hash;
  },

  /**
   * Called by the nsITransfer implementation when the hash is available.
   */
  setSha256Hash(hash) {
    this._sha256Hash = hash;
  },

  /**
   * Implements "DownloadSaver.getSignatureInfo".
   */
  getSignatureInfo() {
    if (this.copySaver) {
      return this.copySaver.getSignatureInfo();
    }
    return this._signatureInfo;
  },

  /**
   * Called by the nsITransfer implementation when the hash is available.
   */
  setSignatureInfo(signatureInfo) {
    this._signatureInfo = signatureInfo;
  },

  /**
   * Implements "DownloadSaver.getRedirects".
   */
  getRedirects() {
    if (this.copySaver) {
      return this.copySaver.getRedirects();
    }
    return this._redirects;
  },

  /**
   * Called by the nsITransfer implementation when the redirect chain is
   * available.
   */
  setRedirects(redirects) {
    this._redirects = redirects;
  },
};
Object.setPrototypeOf(DownloadLegacySaver.prototype, DownloadSaver.prototype);

/**
 * Returns a new DownloadLegacySaver object.  This saver type has a
 * deserializable form only when creating a new object in memory, because it
 * cannot be serialized to disk.
 */
DownloadLegacySaver.fromSerializable = function () {
  return new DownloadLegacySaver();
};
