summaryrefslogtreecommitdiffstats
path: root/toolkit/components/downloads/DownloadCore.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/downloads/DownloadCore.sys.mjs')
-rw-r--r--toolkit/components/downloads/DownloadCore.sys.mjs3082
1 files changed, 3082 insertions, 0 deletions
diff --git a/toolkit/components/downloads/DownloadCore.sys.mjs b/toolkit/components/downloads/DownloadCore.sys.mjs
new file mode 100644
index 0000000000..1711a01784
--- /dev/null
+++ b/toolkit/components/downloads/DownloadCore.sys.mjs
@@ -0,0 +1,3082 @@
+/* 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,
+
+ /**
+ * 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
+ * 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) {
+ Services.telemetry.scalarAdd("downloads.file_opened", 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",
+ "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, 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.
+ * }
+ * 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;
+ 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,
+ 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(aSetProgressBytesFn, aSetPropertiesFn) {
+ 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(canRemoveFinalTarget) {},
+
+ /**
+ * 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 aStatus
+ * Status code received by the nsITransfer implementation.
+ */
+ onTransferFinished: function DLS_onTransferFinished(aStatus) {
+ if (Components.isSuccessCode(aStatus)) {
+ 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: aStatus, inferCause: true };
+ 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();
+};