summaryrefslogtreecommitdiffstats
path: root/toolkit/components/downloads
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/downloads
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/downloads')
-rw-r--r--toolkit/components/downloads/DownloadCore.sys.mjs3082
-rw-r--r--toolkit/components/downloads/DownloadHistory.sys.mjs863
-rw-r--r--toolkit/components/downloads/DownloadIntegration.sys.mjs1327
-rw-r--r--toolkit/components/downloads/DownloadLegacy.sys.mjs503
-rw-r--r--toolkit/components/downloads/DownloadList.sys.mjs668
-rw-r--r--toolkit/components/downloads/DownloadPaths.sys.mjs102
-rw-r--r--toolkit/components/downloads/DownloadPlatform.cpp319
-rw-r--r--toolkit/components/downloads/DownloadPlatform.h28
-rw-r--r--toolkit/components/downloads/DownloadStore.sys.mjs211
-rw-r--r--toolkit/components/downloads/DownloadUIHelper.sys.mjs240
-rw-r--r--toolkit/components/downloads/Downloads.sys.mjs294
-rw-r--r--toolkit/components/downloads/components.conf14
-rw-r--r--toolkit/components/downloads/moz.build55
-rw-r--r--toolkit/components/downloads/mozIDownloadPlatform.idl62
-rw-r--r--toolkit/components/downloads/test/data/empty.txt0
-rw-r--r--toolkit/components/downloads/test/data/source.txt1
-rw-r--r--toolkit/components/downloads/test/unit/common_test_Download.js2753
-rw-r--r--toolkit/components/downloads/test/unit/head.js1183
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js113
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadCore.js291
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadHistory.js273
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js108
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js61
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadIntegration.js441
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadLegacy.js100
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadList.js677
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadPaths.js189
-rw-r--r--toolkit/components/downloads/test/unit/test_DownloadStore.js458
-rw-r--r--toolkit/components/downloads/test/unit/test_Download_noext_win.js59
-rw-r--r--toolkit/components/downloads/test/unit/test_Downloads.js153
-rw-r--r--toolkit/components/downloads/test/unit/xpcshell.toml33
31 files changed, 14661 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();
+};
diff --git a/toolkit/components/downloads/DownloadHistory.sys.mjs b/toolkit/components/downloads/DownloadHistory.sys.mjs
new file mode 100644
index 0000000000..0077601d84
--- /dev/null
+++ b/toolkit/components/downloads/DownloadHistory.sys.mjs
@@ -0,0 +1,863 @@
+/* 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/. */
+
+/**
+ * Provides access to downloads from previous sessions on platforms that store
+ * them in a different location than session downloads.
+ *
+ * This module works with objects that are compatible with Download, while using
+ * the Places interfaces internally. Some of the Places objects may also be
+ * exposed to allow the consumers to integrate with history view commands.
+ */
+
+import { DownloadList } from "resource://gre/modules/DownloadList.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+// Places query used to retrieve all history downloads for the related list.
+const HISTORY_PLACES_QUERY = `place:transition=${Ci.nsINavHistoryService.TRANSITION_DOWNLOAD}&sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}`;
+const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI";
+const METADATA_ANNO = "downloads/metaData";
+
+const METADATA_STATE_FINISHED = 1;
+const METADATA_STATE_FAILED = 2;
+const METADATA_STATE_CANCELED = 3;
+const METADATA_STATE_PAUSED = 4;
+const METADATA_STATE_BLOCKED_PARENTAL = 6;
+const METADATA_STATE_DIRTY = 8;
+
+/**
+ * Provides methods to retrieve downloads from previous sessions and store
+ * downloads for future sessions.
+ */
+export let DownloadHistory = {
+ /**
+ * Retrieves the main DownloadHistoryList object which provides a unified view
+ * on downloads from both previous browsing sessions and this session.
+ *
+ * @param type
+ * Determines which type of downloads from this session should be
+ * included in the list. This is Downloads.PUBLIC by default, but can
+ * also be Downloads.PRIVATE or Downloads.ALL.
+ * @param maxHistoryResults
+ * Optional number that limits the amount of results the history query
+ * may return.
+ *
+ * @return {Promise}
+ * @resolves The requested DownloadHistoryList object.
+ * @rejects JavaScript exception.
+ */
+ async getList({ type = lazy.Downloads.PUBLIC, maxHistoryResults } = {}) {
+ await DownloadCache.ensureInitialized();
+
+ let key = `${type}|${maxHistoryResults ? maxHistoryResults : -1}`;
+ if (!this._listPromises[key]) {
+ this._listPromises[key] = lazy.Downloads.getList(type).then(list => {
+ // When the amount of history downloads is capped, we request the list in
+ // descending order, to make sure that the list can apply the limit.
+ let query =
+ HISTORY_PLACES_QUERY +
+ (maxHistoryResults ? `&maxResults=${maxHistoryResults}` : "");
+
+ return new DownloadHistoryList(list, query);
+ });
+ }
+
+ return this._listPromises[key];
+ },
+
+ /**
+ * This object is populated with one key for each type of download list that
+ * can be returned by the getList method. The values are promises that resolve
+ * to DownloadHistoryList objects.
+ */
+ _listPromises: {},
+
+ async addDownloadToHistory(download) {
+ if (
+ download.source.isPrivate ||
+ !lazy.PlacesUtils.history.canAddURI(
+ lazy.PlacesUtils.toURI(download.source.url)
+ )
+ ) {
+ return;
+ }
+
+ await DownloadCache.addDownload(download);
+
+ await this._updateHistoryListData(download.source.url);
+ },
+
+ /**
+ * Stores new detailed metadata for the given download in history. This is
+ * normally called after a download finishes, fails, or is canceled.
+ *
+ * Failed or canceled downloads with partial data are not stored as paused,
+ * because the information from the session download is required for resuming.
+ *
+ * @param download
+ * Download object whose metadata should be updated. If the object
+ * represents a private download, the call has no effect.
+ */
+ async updateMetaData(download) {
+ if (
+ download.source.isPrivate ||
+ !download.stopped ||
+ !lazy.PlacesUtils.history.canAddURI(
+ lazy.PlacesUtils.toURI(download.source.url)
+ )
+ ) {
+ return;
+ }
+
+ let state = METADATA_STATE_CANCELED;
+ if (download.succeeded) {
+ state = METADATA_STATE_FINISHED;
+ } else if (download.error) {
+ if (download.error.becauseBlockedByParentalControls) {
+ state = METADATA_STATE_BLOCKED_PARENTAL;
+ } else if (download.error.becauseBlockedByReputationCheck) {
+ state = METADATA_STATE_DIRTY;
+ } else {
+ state = METADATA_STATE_FAILED;
+ }
+ }
+
+ let metaData = {
+ state,
+ deleted: download.deleted,
+ endTime: download.endTime,
+ };
+ if (download.succeeded) {
+ metaData.fileSize = download.target.size;
+ }
+
+ // The verdict may still be present even if the download succeeded.
+ if (download.error && download.error.reputationCheckVerdict) {
+ metaData.reputationCheckVerdict = download.error.reputationCheckVerdict;
+ }
+
+ // This should be executed before any async parts, to ensure the cache is
+ // updated before any notifications are activated.
+ await DownloadCache.setMetadata(download.source.url, metaData);
+
+ await this._updateHistoryListData(download.source.url);
+ },
+
+ async _updateHistoryListData(sourceUrl) {
+ for (let key of Object.getOwnPropertyNames(this._listPromises)) {
+ let downloadHistoryList = await this._listPromises[key];
+ downloadHistoryList.updateForMetaDataChange(
+ sourceUrl,
+ DownloadCache.get(sourceUrl)
+ );
+ }
+ },
+};
+
+/**
+ * This cache exists:
+ * - in order to optimize the load of DownloadsHistoryList, when Places
+ * annotations for history downloads must be read. In fact, annotations are
+ * stored in a single table, and reading all of them at once is much more
+ * efficient than an individual query.
+ * - to avoid needing to do asynchronous reading of the database during download
+ * list updates, which are designed to be synchronous (to improve UI
+ * responsiveness).
+ *
+ * The cache is initialized the first time DownloadHistory.getList is called, or
+ * when data is added.
+ */
+let DownloadCache = {
+ _data: new Map(),
+ _initializePromise: null,
+
+ /**
+ * Initializes the cache, loading the data from the places database.
+ *
+ * @return {Promise} Returns a promise that is resolved once the
+ * initialization is complete.
+ */
+ ensureInitialized() {
+ if (this._initializePromise) {
+ return this._initializePromise;
+ }
+ this._initializePromise = (async () => {
+ const placesObserver = new PlacesWeakCallbackWrapper(
+ this.handlePlacesEvents.bind(this)
+ );
+ PlacesObservers.addListener(
+ ["history-cleared", "page-removed"],
+ placesObserver
+ );
+
+ let pageAnnos = await lazy.PlacesUtils.history.fetchAnnotatedPages([
+ METADATA_ANNO,
+ DESTINATIONFILEURI_ANNO,
+ ]);
+
+ let metaDataPages = pageAnnos.get(METADATA_ANNO);
+ if (metaDataPages) {
+ for (let { uri, content } of metaDataPages) {
+ try {
+ this._data.set(uri.href, JSON.parse(content));
+ } catch (ex) {
+ // Do nothing - JSON.parse could throw.
+ }
+ }
+ }
+
+ let destinationFilePages = pageAnnos.get(DESTINATIONFILEURI_ANNO);
+ if (destinationFilePages) {
+ for (let { uri, content } of destinationFilePages) {
+ let newData = this.get(uri.href);
+ newData.targetFileSpec = content;
+ this._data.set(uri.href, newData);
+ }
+ }
+ })();
+
+ return this._initializePromise;
+ },
+
+ /**
+ * This returns an object containing the meta data for the supplied URL.
+ *
+ * @param {String} url The url to get the meta data for.
+ * @return {Object|null} Returns an empty object if there is no meta data found, or
+ * an object containing the meta data. The meta data
+ * will look like:
+ *
+ * { targetFileSpec, state, deleted, endTime, fileSize, ... }
+ *
+ * The targetFileSpec property is the value of "downloads/destinationFileURI",
+ * while the other properties are taken from "downloads/metaData". Any of the
+ * properties may be missing from the object.
+ */
+ get(url) {
+ return this._data.get(url) || {};
+ },
+
+ /**
+ * Adds a download to the cache and the places database.
+ *
+ * @param {Download} download The download to add to the database and cache.
+ */
+ async addDownload(download) {
+ await this.ensureInitialized();
+
+ let targetFile = new lazy.FileUtils.File(download.target.path);
+ let targetUri = Services.io.newFileURI(targetFile);
+
+ // This should be executed before any async parts, to ensure the cache is
+ // updated before any notifications are activated.
+ // Note: this intentionally overwrites any metadata as this is
+ // the start of a new download.
+ this._data.set(download.source.url, { targetFileSpec: targetUri.spec });
+
+ let originalPageInfo = await lazy.PlacesUtils.history.fetch(
+ download.source.url
+ );
+
+ let pageInfo = await lazy.PlacesUtils.history.insert({
+ url: download.source.url,
+ // In case we are downloading a file that does not correspond to a web
+ // page for which the title is present, we populate the otherwise empty
+ // history title with the name of the destination file, to allow it to be
+ // visible and searchable in history results.
+ title:
+ (originalPageInfo && originalPageInfo.title) || targetFile.leafName,
+ visits: [
+ {
+ // The start time is always available when we reach this point.
+ date: download.startTime,
+ transition: lazy.PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ referrer: download.source.referrerInfo
+ ? download.source.referrerInfo.originalReferrer
+ : null,
+ },
+ ],
+ });
+
+ await lazy.PlacesUtils.history.update({
+ annotations: new Map([["downloads/destinationFileURI", targetUri.spec]]),
+ // XXX Bug 1479445: We shouldn't have to supply both guid and url here,
+ // but currently we do.
+ guid: pageInfo.guid,
+ url: pageInfo.url,
+ });
+ },
+
+ /**
+ * Sets the metadata for a given url. If the cache already contains meta data
+ * for the given url, it will be overwritten (note: the targetFileSpec will be
+ * maintained).
+ *
+ * @param {String} url The url to set the meta data for.
+ * @param {Object} metadata The new metaData to save in the cache.
+ */
+ async setMetadata(url, metadata) {
+ await this.ensureInitialized();
+
+ // This should be executed before any async parts, to ensure the cache is
+ // updated before any notifications are activated.
+ let existingData = this.get(url);
+ let newData = { ...metadata };
+ if ("targetFileSpec" in existingData) {
+ newData.targetFileSpec = existingData.targetFileSpec;
+ }
+ this._data.set(url, newData);
+
+ try {
+ await lazy.PlacesUtils.history.update({
+ annotations: new Map([[METADATA_ANNO, JSON.stringify(metadata)]]),
+ url,
+ });
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),
+
+ handlePlacesEvents(events) {
+ for (const event of events) {
+ switch (event.type) {
+ case "history-cleared": {
+ this._data.clear();
+ break;
+ }
+ case "page-removed": {
+ if (event.isRemovedFromStore) {
+ this._data.delete(event.url);
+ }
+ break;
+ }
+ }
+ }
+ },
+};
+
+/**
+ * Represents a download from the browser history. This object implements part
+ * of the interface of the Download object.
+ *
+ * While Download objects are shared between the public DownloadList and all the
+ * DownloadHistoryList instances, multiple HistoryDownload objects referring to
+ * the same item can be created for different DownloadHistoryList instances.
+ *
+ * @param placesNode
+ * The Places node from which the history download should be initialized.
+ */
+class HistoryDownload {
+ constructor(placesNode) {
+ this.placesNode = placesNode;
+
+ // History downloads should get the referrer from Places (bug 829201).
+ this.source = {
+ url: placesNode.uri,
+ isPrivate: false,
+ };
+ this.target = {
+ path: undefined,
+ exists: false,
+ size: undefined,
+ };
+
+ // In case this download cannot obtain its end time from the Places metadata,
+ // use the time from the Places node, that is the start time of the download.
+ this.endTime = placesNode.time / 1000;
+ }
+
+ /**
+ * DownloadSlot containing this history download.
+ *
+ * @type {DownloadSlot}
+ */
+ slot = null;
+
+ /**
+ * History downloads are never in progress.
+ *
+ * @type {Boolean}
+ */
+ stopped = true;
+
+ /**
+ * No percentage indication is shown for history downloads.
+ *
+ * @type {Boolean}
+ */
+ hasProgress = false;
+
+ /**
+ * History downloads cannot be restarted using their partial data, even if
+ * they are indicated as paused in their Places metadata. The only way is to
+ * use the information from a persisted session download, that will be shown
+ * instead of the history download. In case this session download is not
+ * available, we show the history download as canceled, not paused.
+ *
+ * @type {Boolean}
+ */
+ hasPartialData = false;
+
+ /**
+ * Pushes information from Places metadata into this object.
+ */
+ updateFromMetaData(metaData) {
+ try {
+ this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"]
+ .getService(Ci.nsIFileProtocolHandler)
+ .getFileFromURLSpec(metaData.targetFileSpec).path;
+ } catch (ex) {
+ this.target.path = undefined;
+ }
+
+ if ("state" in metaData) {
+ this.succeeded = metaData.state == METADATA_STATE_FINISHED;
+ this.canceled =
+ metaData.state == METADATA_STATE_CANCELED ||
+ metaData.state == METADATA_STATE_PAUSED;
+ this.endTime = metaData.endTime;
+ this.deleted = metaData.deleted;
+
+ // Recreate partial error information from the state saved in history.
+ if (metaData.state == METADATA_STATE_FAILED) {
+ this.error = { message: "History download failed." };
+ } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) {
+ this.error = { becauseBlockedByParentalControls: true };
+ } else if (metaData.state == METADATA_STATE_DIRTY) {
+ this.error = {
+ becauseBlockedByReputationCheck: true,
+ reputationCheckVerdict: metaData.reputationCheckVerdict || "",
+ };
+ } else {
+ this.error = null;
+ }
+
+ // Normal history downloads are assumed to exist until the user interface
+ // is refreshed, at which point these values may be updated.
+ this.target.exists = true;
+ this.target.size = metaData.fileSize;
+ } else {
+ // Metadata might be missing from a download that has started but hasn't
+ // stopped already. Normally, this state is overridden with the one from
+ // the corresponding in-progress session download. But if the browser is
+ // terminated abruptly and additionally the file with information about
+ // in-progress downloads is lost, we may end up using this state. We use
+ // the failed state to allow the download to be restarted.
+ //
+ // On the other hand, if the download is missing the target file
+ // annotation as well, it is just a very old one, and we can assume it
+ // succeeded.
+ this.succeeded = !this.target.path;
+ this.error = this.target.path ? { message: "Unstarted download." } : null;
+ this.canceled = false;
+ this.deleted = false;
+
+ // These properties may be updated if the user interface is refreshed.
+ this.target.exists = false;
+ this.target.size = undefined;
+ }
+ }
+
+ /**
+ * This method may be called when deleting a history download.
+ */
+ async finalize() {}
+
+ /**
+ * This method mimicks the "refresh" method of session downloads.
+ */
+ async refresh() {
+ try {
+ this.target.size = (await IOUtils.stat(this.target.path)).size;
+ this.target.exists = true;
+ } catch (ex) {
+ // We keep the known file size from the metadata, if any.
+ this.target.exists = false;
+ }
+
+ this.slot.list._notifyAllViews("onDownloadChanged", this);
+ }
+
+ /**
+ * This method mimicks the "manuallyRemoveData" method of session downloads.
+ */
+ async manuallyRemoveData() {
+ let { path } = this.target;
+ if (this.target.path && 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.refresh();
+ }
+}
+
+/**
+ * Represents one item in the list of public session and history downloads.
+ *
+ * The object may contain a session download, a history download, or both. When
+ * both a history and a session download are present, the session download gets
+ * priority and its information is accessed.
+ *
+ * @param list
+ * The DownloadHistoryList that owns this DownloadSlot object.
+ */
+class DownloadSlot {
+ constructor(list) {
+ this.list = list;
+ }
+
+ /**
+ * Download object representing the session download contained in this slot.
+ */
+ sessionDownload = null;
+ _historyDownload = null;
+
+ /**
+ * HistoryDownload object contained in this slot.
+ */
+ get historyDownload() {
+ return this._historyDownload;
+ }
+
+ set historyDownload(historyDownload) {
+ this._historyDownload = historyDownload;
+ if (historyDownload) {
+ historyDownload.slot = this;
+ }
+ }
+
+ /**
+ * Returns the Download or HistoryDownload object for displaying information
+ * and executing commands in the user interface.
+ */
+ get download() {
+ return this.sessionDownload || this.historyDownload;
+ }
+}
+
+/**
+ * Represents an ordered collection of DownloadSlot objects containing a merged
+ * view on session downloads and history downloads. Views on this list will
+ * receive notifications for changes to both types of downloads.
+ *
+ * Downloads in this list are sorted from oldest to newest, with all session
+ * downloads after all the history downloads. When a new history download is
+ * added and the list also contains session downloads, the insertBefore option
+ * of the onDownloadAdded notification refers to the first session download.
+ *
+ * The list of downloads cannot be modified using the DownloadList methods.
+ *
+ * @param publicList
+ * Underlying DownloadList containing public downloads.
+ * @param place
+ * Places query used to retrieve history downloads.
+ */
+class DownloadHistoryList extends DownloadList {
+ constructor(publicList, place) {
+ super();
+
+ // While "this._slots" contains all the data in order, the other properties
+ // provide fast access for the most common operations.
+ this._slots = [];
+ this._slotsForUrl = new Map();
+ this._slotForDownload = new WeakMap();
+
+ // Start the asynchronous queries to retrieve history and session downloads.
+ publicList.addView(this).catch(console.error);
+ let query = {},
+ options = {};
+ lazy.PlacesUtils.history.queryStringToQuery(place, query, options);
+
+ // NB: The addObserver call sets our nsINavHistoryResultObserver.result.
+ let result = lazy.PlacesUtils.history.executeQuery(
+ query.value,
+ options.value
+ );
+ result.addObserver(this);
+
+ // Our history result observer is long lived for fast shared views, so free
+ // the reference on shutdown to prevent leaks.
+ Services.obs.addObserver(() => {
+ this.result = null;
+ }, "quit-application-granted");
+ }
+
+ /**
+ * This is set when executing the Places query.
+ */
+ _result = null;
+
+ /**
+ * Index of the first slot that contains a session download. This is equal to
+ * the length of the list when there are no session downloads.
+ *
+ * @type {Number}
+ */
+ _firstSessionSlotIndex = 0;
+
+ get result() {
+ return this._result;
+ }
+
+ set result(result) {
+ if (this._result == result) {
+ return;
+ }
+
+ if (this._result) {
+ this._result.removeObserver(this);
+ this._result.root.containerOpen = false;
+ }
+
+ this._result = result;
+
+ if (this._result) {
+ this._result.root.containerOpen = true;
+ }
+ }
+
+ /**
+ * Updates the download history item when the meta data or destination file
+ * changes.
+ *
+ * @param {String} sourceUrl The sourceUrl which was updated.
+ * @param {Object} metaData The new meta data for the sourceUrl.
+ */
+ updateForMetaDataChange(sourceUrl, metaData) {
+ let slotsForUrl = this._slotsForUrl.get(sourceUrl);
+ if (!slotsForUrl) {
+ return;
+ }
+
+ for (let slot of slotsForUrl) {
+ if (slot.sessionDownload) {
+ // The visible data doesn't change, so we don't have to notify views.
+ return;
+ }
+ slot.historyDownload.updateFromMetaData(metaData);
+ this._notifyAllViews("onDownloadChanged", slot.download);
+ }
+ }
+
+ _insertSlot({ slot, index, slotsForUrl }) {
+ // Add the slot to the ordered array.
+ this._slots.splice(index, 0, slot);
+ this._downloads.splice(index, 0, slot.download);
+ if (!slot.sessionDownload) {
+ this._firstSessionSlotIndex++;
+ }
+
+ // Add the slot to the fast access maps.
+ slotsForUrl.add(slot);
+ this._slotsForUrl.set(slot.download.source.url, slotsForUrl);
+
+ // Add the associated view items.
+ this._notifyAllViews("onDownloadAdded", slot.download, {
+ insertBefore: this._downloads[index + 1],
+ });
+ }
+
+ _removeSlot({ slot, slotsForUrl }) {
+ // Remove the slot from the ordered array.
+ let index = this._slots.indexOf(slot);
+ this._slots.splice(index, 1);
+ this._downloads.splice(index, 1);
+ if (this._firstSessionSlotIndex > index) {
+ this._firstSessionSlotIndex--;
+ }
+
+ // Remove the slot from the fast access maps.
+ slotsForUrl.delete(slot);
+ if (slotsForUrl.size == 0) {
+ this._slotsForUrl.delete(slot.download.source.url);
+ }
+
+ // Remove the associated view items.
+ this._notifyAllViews("onDownloadRemoved", slot.download);
+ }
+
+ /**
+ * Ensures that the information about a history download is stored in at least
+ * one slot, adding a new one at the end of the list if necessary.
+ *
+ * A reference to the same Places node will be stored in the HistoryDownload
+ * object for all the DownloadSlot objects associated with the source URL.
+ *
+ * @param placesNode
+ * The Places node that represents the history download.
+ */
+ _insertPlacesNode(placesNode) {
+ let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set();
+
+ // If there are existing slots associated with this URL, we only have to
+ // ensure that the Places node reference is kept updated in case the more
+ // recent Places notification contained a different node object.
+ if (slotsForUrl.size > 0) {
+ for (let slot of slotsForUrl) {
+ if (!slot.historyDownload) {
+ slot.historyDownload = new HistoryDownload(placesNode);
+ } else {
+ slot.historyDownload.placesNode = placesNode;
+ }
+ }
+ return;
+ }
+
+ // If there are no existing slots for this URL, we have to create a new one.
+ // Since the history download is visible in the slot, we also have to update
+ // the object using the Places metadata.
+ let historyDownload = new HistoryDownload(placesNode);
+ historyDownload.updateFromMetaData(DownloadCache.get(placesNode.uri));
+ let slot = new DownloadSlot(this);
+ slot.historyDownload = historyDownload;
+ this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex });
+ }
+
+ // nsINavHistoryResultObserver
+ containerStateChanged(node, oldState, newState) {
+ this.invalidateContainer(node);
+ }
+
+ // nsINavHistoryResultObserver
+ invalidateContainer(container) {
+ this._notifyAllViews("onDownloadBatchStarting");
+
+ // Remove all the current slots containing only history downloads.
+ for (let index = this._slots.length - 1; index >= 0; index--) {
+ let slot = this._slots[index];
+ if (slot.sessionDownload) {
+ // The visible data doesn't change, so we don't have to notify views.
+ slot.historyDownload = null;
+ } else {
+ let slotsForUrl = this._slotsForUrl.get(slot.download.source.url);
+ this._removeSlot({ slot, slotsForUrl });
+ }
+ }
+
+ // Add new slots or reuse existing ones for history downloads.
+ for (let index = container.childCount - 1; index >= 0; --index) {
+ try {
+ this._insertPlacesNode(container.getChild(index));
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ this._notifyAllViews("onDownloadBatchEnded");
+ }
+
+ // nsINavHistoryResultObserver
+ nodeInserted(parent, placesNode) {
+ this._insertPlacesNode(placesNode);
+ }
+
+ // nsINavHistoryResultObserver
+ nodeRemoved(parent, placesNode, aOldIndex) {
+ let slotsForUrl = this._slotsForUrl.get(placesNode.uri);
+ for (let slot of slotsForUrl) {
+ if (slot.sessionDownload) {
+ // The visible data doesn't change, so we don't have to notify views.
+ slot.historyDownload = null;
+ } else {
+ this._removeSlot({ slot, slotsForUrl });
+ }
+ }
+ }
+
+ // nsINavHistoryResultObserver
+ nodeIconChanged() {}
+ nodeTitleChanged() {}
+ nodeKeywordChanged() {}
+ nodeDateAddedChanged() {}
+ nodeLastModifiedChanged() {}
+ nodeHistoryDetailsChanged() {}
+ nodeTagsChanged() {}
+ sortingChanged() {}
+ nodeMoved() {}
+ nodeURIChanged() {}
+ batching() {}
+
+ // DownloadList callback
+ onDownloadAdded(download) {
+ let url = download.source.url;
+ let slotsForUrl = this._slotsForUrl.get(url) || new Set();
+
+ // For every source URL, there can be at most one slot containing a history
+ // download without an associated session download. If we find one, then we
+ // can reuse it for the current session download, although we have to move
+ // it together with the other session downloads.
+ let slot = [...slotsForUrl][0];
+ if (slot && !slot.sessionDownload) {
+ // Remove the slot because we have to change its position.
+ this._removeSlot({ slot, slotsForUrl });
+ } else {
+ slot = new DownloadSlot(this);
+ }
+ slot.sessionDownload = download;
+ this._insertSlot({ slot, slotsForUrl, index: this._slots.length });
+ this._slotForDownload.set(download, slot);
+ }
+
+ // DownloadList callback
+ onDownloadChanged(download) {
+ let slot = this._slotForDownload.get(download);
+ this._notifyAllViews("onDownloadChanged", slot.download);
+ }
+
+ // DownloadList callback
+ onDownloadRemoved(download) {
+ let url = download.source.url;
+ let slotsForUrl = this._slotsForUrl.get(url);
+ let slot = this._slotForDownload.get(download);
+ this._removeSlot({ slot, slotsForUrl });
+
+ this._slotForDownload.delete(download);
+
+ // If there was only one slot for this source URL and it also contained a
+ // history download, we should resurrect it in the correct area of the list.
+ if (slotsForUrl.size == 0 && slot.historyDownload) {
+ // We have one download slot containing both a session download and a
+ // history download, and we are now removing the session download.
+ // Previously, we did not use the Places metadata because it was obscured
+ // by the session download. Since this is no longer the case, we have to
+ // read the latest metadata before resurrecting the history download.
+ slot.historyDownload.updateFromMetaData(DownloadCache.get(url));
+ slot.sessionDownload = null;
+ // Place the resurrected history slot after all the session slots.
+ this._insertSlot({
+ slot,
+ slotsForUrl,
+ index: this._firstSessionSlotIndex,
+ });
+ }
+ }
+
+ // DownloadList
+ add() {
+ throw new Error("Not implemented.");
+ }
+
+ // DownloadList
+ remove() {
+ throw new Error("Not implemented.");
+ }
+
+ // DownloadList
+ removeFinished() {
+ throw new Error("Not implemented.");
+ }
+}
diff --git a/toolkit/components/downloads/DownloadIntegration.sys.mjs b/toolkit/components/downloads/DownloadIntegration.sys.mjs
new file mode 100644
index 0000000000..5762f95cc2
--- /dev/null
+++ b/toolkit/components/downloads/DownloadIntegration.sys.mjs
@@ -0,0 +1,1327 @@
+/* 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/. */
+
+/**
+ * Provides functions to integrate with the host application, handling for
+ * example the global prompts on shutdown.
+ */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { Downloads } from "resource://gre/modules/Downloads.sys.mjs";
+import { Integration } from "resource://gre/modules/Integration.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ DownloadSpamProtection: "resource:///modules/DownloadSpamProtection.sys.mjs",
+ DownloadStore: "resource://gre/modules/DownloadStore.sys.mjs",
+ DownloadUIHelper: "resource://gre/modules/DownloadUIHelper.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gDownloadPlatform",
+ "@mozilla.org/toolkit/download-platform;1",
+ "mozIDownloadPlatform"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gExternalProtocolService",
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ "nsIExternalProtocolService"
+);
+
+ChromeUtils.defineLazyGetter(lazy, "gParentalControlsService", function () {
+ if ("@mozilla.org/parental-controls-service;1" in Cc) {
+ return Cc["@mozilla.org/parental-controls-service;1"].createInstance(
+ Ci.nsIParentalControlsService
+ );
+ }
+ return null;
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gApplicationReputationService",
+ "@mozilla.org/reputationservice/application-reputation-service;1",
+ Ci.nsIApplicationReputationService
+);
+
+Integration.downloads.defineESModuleGetter(
+ lazy,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+ChromeUtils.defineLazyGetter(lazy, "gCombinedDownloadIntegration", () => {
+ return lazy.DownloadIntegration;
+});
+
+ChromeUtils.defineLazyGetter(lazy, "stringBundle", () =>
+ Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/downloads.properties"
+ )
+);
+
+const Timer = Components.Constructor(
+ "@mozilla.org/timer;1",
+ "nsITimer",
+ "initWithCallback"
+);
+
+/**
+ * Indicates the delay between a change to the downloads data and the related
+ * save operation.
+ *
+ * For best efficiency, this value should be high enough that the input/output
+ * for opening or closing the target file does not overlap with the one for
+ * saving the list of downloads.
+ */
+const kSaveDelayMs = 1500;
+
+/**
+ * List of observers to listen against
+ */
+const kObserverTopics = [
+ "quit-application-requested",
+ "offline-requested",
+ "last-pb-context-exiting",
+ "last-pb-context-exited",
+ "sleep_notification",
+ "suspend_process_notification",
+ "wake_notification",
+ "resume_process_notification",
+ "network:offline-about-to-go-offline",
+ "network:offline-status-changed",
+ "xpcom-will-shutdown",
+ "blocked-automatic-download",
+];
+
+/**
+ * Maps nsIApplicationReputationService verdicts with the DownloadError ones.
+ */
+const kVerdictMap = {
+ [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS]:
+ Downloads.Error.BLOCK_VERDICT_MALWARE,
+ [Ci.nsIApplicationReputationService.VERDICT_UNCOMMON]:
+ Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+ [Ci.nsIApplicationReputationService.VERDICT_POTENTIALLY_UNWANTED]:
+ Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED,
+ [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS_HOST]:
+ Downloads.Error.BLOCK_VERDICT_MALWARE,
+};
+
+/**
+ * Provides functions to integrate with the host application, handling for
+ * example the global prompts on shutdown.
+ */
+export var DownloadIntegration = {
+ /**
+ * Main DownloadStore object for loading and saving the list of persistent
+ * downloads, or null if the download list was never requested and thus it
+ * doesn't need to be persisted.
+ */
+ _store: null,
+
+ /**
+ * Returns whether data for blocked downloads should be kept on disk.
+ * Implementations which support unblocking downloads may return true to
+ * keep the blocked download on disk until its fate is decided.
+ *
+ * If a download is blocked and the partial data is kept the Download's
+ * 'hasBlockedData' property will be true. In this state Download.unblock()
+ * or Download.confirmBlock() may be used to either unblock the download or
+ * remove the downloaded data respectively.
+ *
+ * Even if shouldKeepBlockedData returns true, if the download did not use a
+ * partFile the blocked data will be removed - preventing the complete
+ * download from existing on disk with its final filename.
+ *
+ * @return boolean True if data should be kept.
+ */
+ shouldKeepBlockedData() {
+ const FIREFOX_ID = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
+ return Services.appinfo.ID == FIREFOX_ID;
+ },
+
+ /**
+ * Performs initialization of the list of persistent downloads, before its
+ * first use by the host application. This function may be called only once
+ * during the entire lifetime of the application.
+ *
+ * @param list
+ * DownloadList object to be initialized.
+ *
+ * @return {Promise}
+ * @resolves When the list has been initialized.
+ * @rejects JavaScript exception.
+ */
+ async initializePublicDownloadList(list) {
+ try {
+ await this.loadPublicDownloadListFromStore(list);
+ } catch (ex) {
+ console.error(ex);
+ }
+
+ if (AppConstants.MOZ_PLACES) {
+ // After the list of persistent downloads has been loaded, we can add the
+ // history observers, even if the load operation failed. This object is kept
+ // alive by the history service.
+ new DownloadHistoryObserver(list);
+ }
+ },
+
+ /**
+ * Called by initializePublicDownloadList to load the list of persistent
+ * downloads, before its first use by the host application. This function may
+ * be called only once during the entire lifetime of the application.
+ *
+ * @param list
+ * DownloadList object to be populated with the download objects
+ * serialized from the previous session. This list will be persisted
+ * to disk during the session lifetime.
+ *
+ * @return {Promise}
+ * @resolves When the list has been populated.
+ * @rejects JavaScript exception.
+ */
+ async loadPublicDownloadListFromStore(list) {
+ if (this._store) {
+ throw new Error("Initialization may be performed only once.");
+ }
+
+ this._store = new lazy.DownloadStore(
+ list,
+ PathUtils.join(PathUtils.profileDir, "downloads.json")
+ );
+ this._store.onsaveitem = this.shouldPersistDownload.bind(this);
+
+ try {
+ await this._store.load();
+ } catch (ex) {
+ console.error(ex);
+ }
+
+ // Add the view used for detecting changes to downloads to be persisted.
+ // We must do this after the list of persistent downloads has been loaded,
+ // even if the load operation failed. We wait for a complete initialization
+ // so other callers cannot modify the list without being detected. The
+ // DownloadAutoSaveView is kept alive by the underlying DownloadList.
+ await new DownloadAutoSaveView(list, this._store).initialize();
+ },
+
+ /**
+ * Determines if a Download object from the list of persistent downloads
+ * should be saved into a file, so that it can be restored across sessions.
+ *
+ * This function allows filtering out downloads that the host application is
+ * not interested in persisting across sessions, for example downloads that
+ * finished successfully.
+ *
+ * @param aDownload
+ * The Download object to be inspected. This is originally taken from
+ * the global DownloadList object for downloads that were not started
+ * from a private browsing window. The item may have been removed
+ * from the list since the save operation started, though in this case
+ * the save operation will be repeated later.
+ *
+ * @return True to save the download, false otherwise.
+ */
+ shouldPersistDownload(aDownload) {
+ // On all platforms, we save all the downloads currently in progress, as
+ // well as stopped downloads for which we retained partially downloaded
+ // data or we have blocked data.
+ // On Android we store all history; on Desktop, stopped downloads for which
+ // we don't need to track the presence of a ".part" file are only retained
+ // in the browser history.
+ return (
+ !aDownload.stopped ||
+ aDownload.hasPartialData ||
+ aDownload.hasBlockedData ||
+ AppConstants.platform == "android"
+ );
+ },
+
+ /**
+ * Returns the system downloads directory asynchronously.
+ *
+ * @return {Promise}
+ * @resolves The downloads directory string path.
+ */
+ async getSystemDownloadsDirectory() {
+ if (this._downloadsDirectory) {
+ return this._downloadsDirectory;
+ }
+
+ if (AppConstants.platform == "android") {
+ // Android doesn't have a $HOME directory, and by default we only have
+ // write access to /data/data/org.mozilla.{$APP} and /sdcard
+ this._downloadsDirectory = Services.env.get("DOWNLOADS_DIRECTORY");
+ if (!this._downloadsDirectory) {
+ throw new Components.Exception(
+ "DOWNLOADS_DIRECTORY is not set.",
+ Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH
+ );
+ }
+ } else {
+ try {
+ this._downloadsDirectory = this._getDirectory("DfltDwnld");
+ } catch (e) {
+ this._downloadsDirectory = await this._createDownloadsDirectory("Home");
+ }
+ }
+
+ return this._downloadsDirectory;
+ },
+ _downloadsDirectory: null,
+
+ /**
+ * Returns the user downloads directory asynchronously.
+ *
+ * @return {Promise}
+ * @resolves The downloads directory string path.
+ */
+ async getPreferredDownloadsDirectory() {
+ let directoryPath = null;
+ let prefValue = Services.prefs.getIntPref("browser.download.folderList", 1);
+
+ switch (prefValue) {
+ case 0: // Desktop
+ directoryPath = this._getDirectory("Desk");
+ break;
+ case 1: // Downloads
+ directoryPath = await this.getSystemDownloadsDirectory();
+ break;
+ case 2: // Custom
+ try {
+ let directory = Services.prefs.getComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile
+ );
+ directoryPath = directory.path;
+ await IOUtils.makeDirectory(directoryPath, {
+ createAncestors: false,
+ });
+ } catch (ex) {
+ console.error(ex);
+ // Either the preference isn't set or the directory cannot be created.
+ directoryPath = await this.getSystemDownloadsDirectory();
+ }
+ break;
+ default:
+ directoryPath = await this.getSystemDownloadsDirectory();
+ }
+ return directoryPath;
+ },
+
+ /**
+ * Returns the temporary downloads directory asynchronously.
+ *
+ * @return {Promise}
+ * @resolves The downloads directory string path.
+ */
+ async getTemporaryDownloadsDirectory() {
+ let directoryPath = null;
+ if (AppConstants.platform == "macosx") {
+ directoryPath = await this.getPreferredDownloadsDirectory();
+ } else if (AppConstants.platform == "android") {
+ directoryPath = await this.getSystemDownloadsDirectory();
+ } else {
+ directoryPath = this._getDirectory("TmpD");
+ }
+ return directoryPath;
+ },
+
+ /**
+ * Checks to determine whether to block downloads for parental controls.
+ *
+ * aParam aDownload
+ * The download object.
+ *
+ * @return {Promise}
+ * @resolves The boolean indicates to block downloads or not.
+ */
+ shouldBlockForParentalControls(aDownload) {
+ let isEnabled =
+ lazy.gParentalControlsService &&
+ lazy.gParentalControlsService.parentalControlsEnabled;
+ let shouldBlock =
+ isEnabled && lazy.gParentalControlsService.blockFileDownloadsEnabled;
+
+ // Log the event if required by parental controls settings.
+ if (isEnabled && lazy.gParentalControlsService.loggingEnabled) {
+ lazy.gParentalControlsService.log(
+ lazy.gParentalControlsService.ePCLog_FileDownload,
+ shouldBlock,
+ lazy.NetUtil.newURI(aDownload.source.url),
+ null
+ );
+ }
+
+ return Promise.resolve(shouldBlock);
+ },
+
+ /**
+ * Checks to determine whether to block downloads because they might be
+ * malware, based on application reputation checks.
+ *
+ * aParam aDownload
+ * The download object.
+ *
+ * @return {Promise}
+ * @resolves Object with the following properties:
+ * {
+ * shouldBlock: Whether the download should be blocked.
+ * verdict: Detailed reason for the block, according to the
+ * "Downloads.Error.BLOCK_VERDICT_" constants, or empty
+ * string if the reason is unknown.
+ * }
+ */
+ shouldBlockForReputationCheck(aDownload) {
+ let hash;
+ let sigInfo;
+ let channelRedirects;
+ try {
+ hash = aDownload.saver.getSha256Hash();
+ sigInfo = aDownload.saver.getSignatureInfo();
+ channelRedirects = aDownload.saver.getRedirects();
+ } catch (ex) {
+ // Bail if DownloadSaver doesn't have a hash or signature info.
+ return Promise.resolve({
+ shouldBlock: false,
+ verdict: "",
+ });
+ }
+ if (!hash || !sigInfo) {
+ return Promise.resolve({
+ shouldBlock: false,
+ verdict: "",
+ });
+ }
+ return new Promise(resolve => {
+ lazy.gApplicationReputationService.queryReputation(
+ {
+ sourceURI: lazy.NetUtil.newURI(aDownload.source.url),
+ referrerInfo: aDownload.source.referrerInfo,
+ fileSize: aDownload.currentBytes,
+ sha256Hash: hash,
+ suggestedFileName: PathUtils.filename(aDownload.target.path),
+ signatureInfo: sigInfo,
+ redirects: channelRedirects,
+ },
+ function onComplete(aShouldBlock, aRv, aVerdict) {
+ resolve({
+ shouldBlock: aShouldBlock,
+ verdict: (aShouldBlock && kVerdictMap[aVerdict]) || "",
+ });
+ }
+ );
+ });
+ },
+
+ /**
+ * Checks whether downloaded files should be marked as coming from
+ * Internet Zone.
+ *
+ * @return true if files should be marked
+ */
+ _shouldSaveZoneInformation() {
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ key.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Attachments",
+ Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE
+ );
+ try {
+ return key.readIntValue("SaveZoneInformation") != 1;
+ } finally {
+ key.close();
+ }
+ } catch (ex) {
+ // If the key is not present, files should be marked by default.
+ return true;
+ }
+ },
+
+ /**
+ * Builds a key and URL value pair for the "Zone.Identifier" Alternate Data
+ * Stream.
+ *
+ * @param aKey
+ * String to write before the "=" sign. This is not validated.
+ * @param aUrl
+ * URL string to write after the "=" sign. Only the "http(s)" and
+ * "ftp" schemes are allowed, and usernames and passwords are
+ * stripped.
+ * @param [optional] aFallback
+ * Value to place after the "=" sign in case the URL scheme is not
+ * allowed. If unspecified, an empty string is returned when the
+ * scheme is not allowed.
+ *
+ * @return Line to add to the stream, including the final CRLF, or an empty
+ * string if the validation failed.
+ */
+ _zoneIdKey(aKey, aUrl, aFallback) {
+ try {
+ let url;
+ const uri = lazy.NetUtil.newURI(aUrl);
+ if (["http", "https", "ftp"].includes(uri.scheme)) {
+ url = uri.mutate().setUserPass("").finalize().spec;
+ } else if (aFallback) {
+ url = aFallback;
+ } else {
+ return "";
+ }
+ return aKey + "=" + url + "\r\n";
+ } catch (e) {
+ return "";
+ }
+ },
+
+ /**
+ * Performs platform-specific operations when a download is done.
+ *
+ * aParam aDownload
+ * The Download object.
+ *
+ * @return {Promise}
+ * @resolves When all the operations completed successfully.
+ * @rejects JavaScript exception if any of the operations failed.
+ */
+ async downloadDone(aDownload) {
+ // On Windows, we mark any file saved to the NTFS file system as coming
+ // from the Internet security zone unless Group Policy disables the
+ // feature. We do this by writing to the "Zone.Identifier" Alternate
+ // Data Stream directly, because the Save method of the
+ // IAttachmentExecute interface would trigger operations that may cause
+ // the application to hang, or other performance issues.
+ // The stream created in this way is forward-compatible with all the
+ // current and future versions of Windows.
+ if (AppConstants.platform == "win" && this._shouldSaveZoneInformation()) {
+ let zone;
+ try {
+ zone = lazy.gDownloadPlatform.mapUrlToZone(aDownload.source.url);
+ } catch (e) {
+ // Default to Internet Zone if mapUrlToZone failed for
+ // whatever reason.
+ zone = Ci.mozIDownloadPlatform.ZONE_INTERNET;
+ }
+ // Don't write zone IDs for Local, Intranet, or Trusted sites
+ // to match Windows behavior.
+ if (zone >= Ci.mozIDownloadPlatform.ZONE_INTERNET) {
+ let path = aDownload.target.path + ":Zone.Identifier";
+ try {
+ let zoneId = "[ZoneTransfer]\r\nZoneId=" + zone + "\r\n";
+ let { url, isPrivate, referrerInfo } = aDownload.source;
+ if (!isPrivate) {
+ let referrer = referrerInfo
+ ? referrerInfo.computedReferrerSpec
+ : "";
+ zoneId +=
+ this._zoneIdKey("ReferrerUrl", referrer) +
+ this._zoneIdKey("HostUrl", url, "about:internet");
+ }
+ await IOUtils.writeUTF8(
+ PathUtils.toExtendedWindowsPath(path),
+ zoneId
+ );
+ } catch (ex) {
+ // If writing to the file fails, we ignore the error and continue.
+ if (!DOMException.isInstance(ex)) {
+ console.error(ex);
+ }
+ }
+ }
+ }
+
+ // The file with the partially downloaded data has restrictive permissions
+ // that don't allow other users on the system to access it. Now that the
+ // download is completed, we need to adjust permissions based on whether
+ // this is a permanently downloaded file or a temporary download to be
+ // opened read-only with an external application.
+ try {
+ let isTemporaryDownload =
+ aDownload.launchWhenSucceeded && aDownload.source.isPrivate;
+ // Permanently downloaded files are made accessible by other users on
+ // this system, while temporary downloads are marked as read-only.
+ let unixMode;
+ if (isTemporaryDownload) {
+ unixMode = 0o400;
+ } else {
+ unixMode = 0o666;
+ }
+ // On Unix, the umask of the process is respected.
+ await IOUtils.setPermissions(aDownload.target.path, unixMode);
+ } catch (ex) {
+ // We should report errors with making the permissions less restrictive
+ // or marking the file as read-only on Unix and Mac, but this should not
+ // prevent the download from completing.
+ if (!DOMException.isInstance(ex)) {
+ console.error(ex);
+ }
+ }
+
+ let aReferrer = null;
+ if (aDownload.source.referrerInfo) {
+ aReferrer = aDownload.source.referrerInfo.originalReferrer;
+ }
+
+ await lazy.gDownloadPlatform.downloadDone(
+ lazy.NetUtil.newURI(aDownload.source.url),
+ aReferrer,
+ new lazy.FileUtils.File(aDownload.target.path),
+ aDownload.contentType,
+ aDownload.source.isPrivate
+ );
+ },
+
+ /**
+ * Decide whether a download of this type, opened from the downloads
+ * list, should open internally.
+ *
+ * @param aMimeType
+ * The MIME type of the file, as a string
+ * @param [optional] aExtension
+ * The file extension, which can match instead of the MIME type.
+ */
+ shouldViewDownloadInternally(aMimeType, aExtension) {
+ // Refuse all files by default, this is meant to be replaced with a check
+ // for specific types via Integration.downloads.register().
+ return false;
+ },
+
+ /**
+ * Launches a file represented by the target of a download. This can
+ * open the file with the default application for the target MIME type
+ * or file extension, or with a custom application if
+ * aDownload.launcherPath is set.
+ *
+ * @param aDownload
+ * A Download object that contains the necessary information
+ * to launch the file. The relevant properties are: the target
+ * file, the contentType and the custom application chosen
+ * to launch it.
+ * @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.
+ */
+ async launchDownload(aDownload, { openWhere, useSystemDefault = null }) {
+ let file = new lazy.FileUtils.File(aDownload.target.path);
+
+ // In case of a double extension, like ".tar.gz", we only
+ // consider the last one, because the MIME service cannot
+ // handle multiple extensions.
+ let fileExtension = null,
+ mimeInfo = null;
+ let match = file.leafName.match(/\.([^.]+)$/);
+ if (match) {
+ fileExtension = match[1];
+ }
+
+ let isWindowsExe =
+ AppConstants.platform == "win" &&
+ fileExtension &&
+ fileExtension.toLowerCase() == "exe";
+
+ let isExemptExecutableExtension =
+ Services.policies.isExemptExecutableExtension(
+ aDownload.source.url,
+ fileExtension
+ );
+
+ // Ask for confirmation if the file is executable, except for .exe on
+ // Windows where the operating system will show the prompt based on the
+ // security zone. We do this here, instead of letting the caller handle
+ // the prompt separately in the user interface layer, for two reasons. The
+ // first is because of its security nature, so that add-ons cannot forget
+ // to do this check. The second is that the system-level security prompt
+ // would be displayed at launch time in any case.
+ // We allow policy to override this behavior for file extensions on specific domains.
+ if (
+ file.isExecutable() &&
+ !isWindowsExe &&
+ !isExemptExecutableExtension &&
+ !(await this.confirmLaunchExecutable(file.path))
+ ) {
+ return;
+ }
+
+ try {
+ // The MIME service might throw if contentType == "" and it can't find
+ // a MIME type for the given extension, so we'll treat this case as
+ // an unknown mimetype.
+ mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
+ aDownload.contentType,
+ fileExtension
+ );
+ } catch (e) {}
+
+ if (aDownload.launcherPath) {
+ if (!mimeInfo) {
+ // This should not happen on normal circumstances because launcherPath
+ // is only set when we had an instance of nsIMIMEInfo to retrieve
+ // the custom application chosen by the user.
+ throw new Error(
+ "Unable to create nsIMIMEInfo to launch a custom application"
+ );
+ }
+
+ // Custom application chosen
+ let localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.executable = new lazy.FileUtils.File(
+ aDownload.launcherPath
+ );
+
+ mimeInfo.preferredApplicationHandler = localHandlerApp;
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
+
+ this.launchFile(file, mimeInfo);
+ // After an attempt has been made to launch the download, clear the
+ // launchWhenSucceeded bit so future attempts to open the download can go
+ // through Firefox when possible.
+ aDownload.launchWhenSucceeded = false;
+ return;
+ }
+
+ if (!useSystemDefault && mimeInfo) {
+ useSystemDefault = mimeInfo.preferredAction == mimeInfo.useSystemDefault;
+ }
+ if (!useSystemDefault) {
+ // No explicit instruction was passed to launch this download using the default system viewer.
+ if (
+ aDownload.handleInternally ||
+ (mimeInfo &&
+ this.shouldViewDownloadInternally(mimeInfo.type, fileExtension) &&
+ !mimeInfo.alwaysAskBeforeHandling &&
+ (mimeInfo.preferredAction === Ci.nsIHandlerInfo.handleInternally ||
+ (["image/svg+xml", "text/xml", "application/xml"].includes(
+ mimeInfo.type
+ ) &&
+ mimeInfo.preferredAction === Ci.nsIHandlerInfo.saveToDisk)) &&
+ !aDownload.launchWhenSucceeded)
+ ) {
+ lazy.DownloadUIHelper.loadFileIn(file, {
+ browsingContextId: aDownload.source.browsingContextId,
+ isPrivate: aDownload.source.isPrivate,
+ openWhere,
+ userContextId: aDownload.source.userContextId,
+ });
+ return;
+ }
+ }
+
+ // An attempt will now be made to launch the download, clear the
+ // launchWhenSucceeded bit so future attempts to open the download can go
+ // through Firefox when possible.
+ aDownload.launchWhenSucceeded = false;
+
+ // When a file has no extension, and there's an executable file with the
+ // same name in the same folder, Windows shell can get confused.
+ // For this reason we show the file in the containing folder instead of
+ // trying to open it.
+ // We also don't trust mimeinfo, it could be a type we can forward to a
+ // system handler, but it could also be an executable type, and we
+ // don't have an exhaustive list with all of them.
+ if (!fileExtension && AppConstants.platform == "win") {
+ // We can't check for the existance of a same-name file with every
+ // possible executable extension, so this is a catch-all.
+ this.showContainingDirectory(aDownload.target.path);
+ return;
+ }
+
+ // No custom application chosen, let's launch the file with the default
+ // handler. First, let's try to launch it through the MIME service.
+ if (mimeInfo) {
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.useSystemDefault;
+ try {
+ this.launchFile(file, mimeInfo);
+ return;
+ } catch (ex) {}
+ }
+
+ // If it didn't work or if there was no MIME info available,
+ // let's try to directly launch the file.
+ try {
+ this.launchFile(file);
+ return;
+ } catch (ex) {}
+
+ // If our previous attempts failed, try sending it through
+ // the system's external "file:" URL handler.
+ lazy.gExternalProtocolService.loadURI(
+ lazy.NetUtil.newURI(file),
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ },
+
+ /**
+ * Asks for confirmation for launching the specified executable file. This
+ * can be overridden by regression tests to avoid the interactive prompt.
+ */
+ async confirmLaunchExecutable(path) {
+ // We don't anchor the prompt to a specific window intentionally, not
+ // only because this is the same behavior as the system-level prompt,
+ // but also because the most recently active window is the right choice
+ // in basically all cases.
+ return lazy.DownloadUIHelper.getPrompter().confirmLaunchExecutable(path);
+ },
+
+ /**
+ * Launches the specified file, unless overridden by regression tests.
+ * @note Always use launchDownload() from the outside of this module, it is
+ * both more powerful and safer.
+ */
+ launchFile(file, mimeInfo) {
+ if (mimeInfo) {
+ mimeInfo.launchWithFile(file);
+ } else {
+ file.launch();
+ }
+ },
+
+ /**
+ * Shows the containing folder of a file.
+ *
+ * @param aFilePath
+ * The path to the file.
+ *
+ * @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.
+ */
+ async showContainingDirectory(aFilePath) {
+ let file = new lazy.FileUtils.File(aFilePath);
+
+ try {
+ // Show the directory containing the file and select the file.
+ file.reveal();
+ return;
+ } catch (ex) {}
+
+ // If reveal fails for some reason (e.g., it's not implemented on unix
+ // or the file doesn't exist), try using the parent if we have it.
+ let parent = file.parent;
+ if (!parent) {
+ throw new Error(
+ "Unexpected reference to a top-level directory instead of a file"
+ );
+ }
+
+ try {
+ // Open the parent directory to show where the file should be.
+ parent.launch();
+ return;
+ } catch (ex) {}
+
+ // If launch also fails (probably because it's not implemented), let
+ // the OS handler try to open the parent.
+ lazy.gExternalProtocolService.loadURI(
+ lazy.NetUtil.newURI(parent),
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ },
+
+ /**
+ * Calls the directory service, create a downloads directory and returns an
+ * nsIFile for the downloads directory.
+ *
+ * @return {Promise}
+ * @resolves The directory string path.
+ */
+ _createDownloadsDirectory(aName) {
+ // We read the name of the directory from the list of translated strings
+ // that is kept by the UI helper module, even if this string is not strictly
+ // displayed in the user interface.
+ let directoryPath = PathUtils.join(
+ this._getDirectory(aName),
+ lazy.stringBundle.GetStringFromName("downloadsFolder")
+ );
+
+ // Create the Downloads folder and ignore if it already exists.
+ return IOUtils.makeDirectory(directoryPath, {
+ createAncestors: false,
+ }).then(() => directoryPath);
+ },
+
+ /**
+ * Returns the string path for the given directory service location name. This
+ * can be overridden by regression tests to return the path of the system
+ * temporary directory in all cases.
+ */
+ _getDirectory(name) {
+ return Services.dirsvc.get(name, Ci.nsIFile).path;
+ },
+
+ /**
+ * Initializes the DownloadSpamProtection instance.
+ * This is used to observe and group multiple automatic downloads.
+ */
+ _initializeDownloadSpamProtection() {
+ if (!this.downloadSpamProtection) {
+ this.downloadSpamProtection = new lazy.DownloadSpamProtection();
+ }
+ },
+
+ /**
+ * Register the downloads interruption observers.
+ *
+ * @param aList
+ * The public or private downloads list.
+ * @param aIsPrivate
+ * True if the list is private, false otherwise.
+ *
+ * @return {Promise}
+ * @resolves When the views and observers are added.
+ */
+ addListObservers(aList, aIsPrivate) {
+ DownloadObserver.registerView(aList, aIsPrivate);
+ if (!DownloadObserver.observersAdded) {
+ DownloadObserver.observersAdded = true;
+ for (let topic of kObserverTopics) {
+ Services.obs.addObserver(DownloadObserver, topic);
+ }
+ }
+ return Promise.resolve();
+ },
+
+ /**
+ * Force a save on _store if it exists. Used to ensure downloads do not
+ * persist after being sanitized on Android.
+ *
+ * @return {Promise}
+ * @resolves When _store.save() completes.
+ */
+ forceSave() {
+ if (this._store) {
+ return this._store.save();
+ }
+ return Promise.resolve();
+ },
+};
+
+var DownloadObserver = {
+ /**
+ * Flag to determine if the observers have been added previously.
+ */
+ observersAdded: false,
+
+ /**
+ * Timer used to delay restarting canceled downloads upon waking and returning
+ * online.
+ */
+ _wakeTimer: null,
+
+ /**
+ * Set that contains the in progress publics downloads.
+ * It's kept updated when a public download is added, removed or changes its
+ * properties.
+ */
+ _publicInProgressDownloads: new Set(),
+
+ /**
+ * Set that contains the in progress private downloads.
+ * It's kept updated when a private download is added, removed or changes its
+ * properties.
+ */
+ _privateInProgressDownloads: new Set(),
+
+ /**
+ * Set that contains the downloads that have been canceled when going offline
+ * or to sleep. These are started again when returning online or waking. This
+ * list is not persisted so when exiting and restarting, the downloads will not
+ * be started again.
+ */
+ _canceledOfflineDownloads: new Set(),
+
+ /**
+ * Registers a view that updates the corresponding downloads state set, based
+ * on the aIsPrivate argument. The set is updated when a download is added,
+ * removed or changes its properties.
+ *
+ * @param aList
+ * The public or private downloads list.
+ * @param aIsPrivate
+ * True if the list is private, false otherwise.
+ */
+ registerView: function DO_registerView(aList, aIsPrivate) {
+ let downloadsSet = aIsPrivate
+ ? this._privateInProgressDownloads
+ : this._publicInProgressDownloads;
+ let downloadsView = {
+ onDownloadAdded: aDownload => {
+ if (!aDownload.stopped) {
+ downloadsSet.add(aDownload);
+ }
+ },
+ onDownloadChanged: aDownload => {
+ if (aDownload.stopped) {
+ downloadsSet.delete(aDownload);
+ } else {
+ downloadsSet.add(aDownload);
+ }
+ },
+ onDownloadRemoved: aDownload => {
+ downloadsSet.delete(aDownload);
+ // The download must also be removed from the canceled when offline set.
+ this._canceledOfflineDownloads.delete(aDownload);
+ },
+ };
+
+ // We register the view asynchronously.
+ aList.addView(downloadsView).catch(console.error);
+ },
+
+ /**
+ * Wrapper that handles the test mode before calling the prompt that display
+ * a warning message box that informs that there are active downloads,
+ * and asks whether the user wants to cancel them or not.
+ *
+ * @param aCancel
+ * The observer notification subject.
+ * @param aDownloadsCount
+ * The current downloads count.
+ * @param aPrompter
+ * The prompter object that shows the confirm dialog.
+ * @param aPromptType
+ * The type of prompt notification depending on the observer.
+ */
+ _confirmCancelDownloads: function DO_confirmCancelDownload(
+ aCancel,
+ aDownloadsCount,
+ aPromptType
+ ) {
+ // Handle test mode
+ if (lazy.gCombinedDownloadIntegration._testPromptDownloads) {
+ lazy.gCombinedDownloadIntegration._testPromptDownloads = aDownloadsCount;
+ return;
+ }
+
+ if (!aDownloadsCount) {
+ return;
+ }
+
+ // If user has already dismissed the request, then do nothing.
+ if (aCancel instanceof Ci.nsISupportsPRBool && aCancel.data) {
+ return;
+ }
+
+ let prompter = lazy.DownloadUIHelper.getPrompter();
+ aCancel.data = prompter.confirmCancelDownloads(
+ aDownloadsCount,
+ prompter[aPromptType]
+ );
+ },
+
+ /**
+ * Resume all downloads that were paused when going offline, used when waking
+ * from sleep or returning from being offline.
+ */
+ _resumeOfflineDownloads: function DO_resumeOfflineDownloads() {
+ this._wakeTimer = null;
+
+ for (let download of this._canceledOfflineDownloads) {
+ download.start().catch(() => {});
+ }
+ this._canceledOfflineDownloads.clear();
+ },
+
+ // nsIObserver
+ observe: function DO_observe(aSubject, aTopic, aData) {
+ let downloadsCount;
+ switch (aTopic) {
+ case "quit-application-requested":
+ downloadsCount =
+ this._publicInProgressDownloads.size +
+ this._privateInProgressDownloads.size;
+ this._confirmCancelDownloads(aSubject, downloadsCount, "ON_QUIT");
+ break;
+ case "offline-requested":
+ downloadsCount =
+ this._publicInProgressDownloads.size +
+ this._privateInProgressDownloads.size;
+ this._confirmCancelDownloads(aSubject, downloadsCount, "ON_OFFLINE");
+ break;
+ case "last-pb-context-exiting":
+ downloadsCount = this._privateInProgressDownloads.size;
+ this._confirmCancelDownloads(
+ aSubject,
+ downloadsCount,
+ "ON_LEAVE_PRIVATE_BROWSING"
+ );
+ break;
+ case "last-pb-context-exited":
+ let promise = (async function () {
+ let list = await Downloads.getList(Downloads.PRIVATE);
+ let downloads = await list.getAll();
+
+ // We can remove the downloads and finalize them in parallel.
+ for (let download of downloads) {
+ list.remove(download).catch(console.error);
+ download.finalize(true).catch(console.error);
+ }
+ })();
+ // Handle test mode
+ if (lazy.gCombinedDownloadIntegration._testResolveClearPrivateList) {
+ lazy.gCombinedDownloadIntegration._testResolveClearPrivateList(
+ promise
+ );
+ } else {
+ promise.catch(ex => console.error(ex));
+ }
+ break;
+ case "sleep_notification":
+ case "suspend_process_notification":
+ case "network:offline-about-to-go-offline":
+ // Ignore shutdown notification so aborted downloads will be restarted
+ // on the next session.
+ if (
+ Services.startup.isInOrBeyondShutdownPhase(
+ Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ )
+ ) {
+ break;
+ }
+ for (let download of this._publicInProgressDownloads) {
+ download.cancel();
+ this._canceledOfflineDownloads.add(download);
+ }
+ for (let download of this._privateInProgressDownloads) {
+ download.cancel();
+ this._canceledOfflineDownloads.add(download);
+ }
+ break;
+ case "wake_notification":
+ case "resume_process_notification":
+ let wakeDelay = Services.prefs.getIntPref(
+ "browser.download.manager.resumeOnWakeDelay",
+ 10000
+ );
+
+ if (wakeDelay >= 0) {
+ this._wakeTimer = new Timer(
+ this._resumeOfflineDownloads.bind(this),
+ wakeDelay,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }
+ break;
+ case "network:offline-status-changed":
+ if (aData == "online") {
+ this._resumeOfflineDownloads();
+ }
+ break;
+ // We need to unregister observers explicitly before we reach the
+ // "xpcom-shutdown" phase, otherwise observers may be notified when some
+ // required services are not available anymore. We can't unregister
+ // observers on "quit-application", because this module is also loaded
+ // during "make package" automation, and the quit notification is not sent
+ // in that execution environment (bug 973637).
+ case "xpcom-will-shutdown":
+ for (let topic of kObserverTopics) {
+ Services.obs.removeObserver(this, topic);
+ }
+ break;
+ case "blocked-automatic-download":
+ if (AppConstants.MOZ_BUILD_APP == "browser") {
+ DownloadIntegration._initializeDownloadSpamProtection();
+ DownloadIntegration.downloadSpamProtection.update(
+ aData,
+ aSubject.topChromeWindow
+ );
+ }
+ break;
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * Registers a Places observer so that operations on download history are
+ * reflected on the provided list of downloads.
+ *
+ * You do not need to keep a reference to this object in order to keep it alive,
+ * because the history service already keeps a strong reference to it.
+ *
+ * @param aList
+ * DownloadList object linked to this observer.
+ */
+var DownloadHistoryObserver = function (aList) {
+ this._list = aList;
+
+ const placesObserver = new PlacesWeakCallbackWrapper(
+ this.handlePlacesEvents.bind(this)
+ );
+ PlacesObservers.addListener(
+ ["history-cleared", "page-removed"],
+ placesObserver
+ );
+};
+
+DownloadHistoryObserver.prototype = {
+ /**
+ * DownloadList object linked to this observer.
+ */
+ _list: null,
+
+ handlePlacesEvents(events) {
+ for (const event of events) {
+ switch (event.type) {
+ case "history-cleared": {
+ this._list.removeFinished();
+ break;
+ }
+ case "page-removed": {
+ if (event.isRemovedFromStore) {
+ this._list.removeFinished(
+ download => event.url === download.source.url
+ );
+ }
+ break;
+ }
+ }
+ }
+ },
+};
+
+/**
+ * This view can be added to a DownloadList object to trigger a save operation
+ * in the given DownloadStore object when a relevant change occurs. You should
+ * call the "initialize" method in order to register the view and load the
+ * current state from disk.
+ *
+ * You do not need to keep a reference to this object in order to keep it alive,
+ * because the DownloadList object already keeps a strong reference to it.
+ *
+ * @param aList
+ * The DownloadList object on which the view should be registered.
+ * @param aStore
+ * The DownloadStore object used for saving.
+ */
+var DownloadAutoSaveView = function (aList, aStore) {
+ this._list = aList;
+ this._store = aStore;
+ this._downloadsMap = new Map();
+ this._writer = new lazy.DeferredTask(() => this._store.save(), kSaveDelayMs);
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "DownloadAutoSaveView: writing data",
+ () => this._writer.finalize()
+ );
+};
+
+DownloadAutoSaveView.prototype = {
+ /**
+ * DownloadList object linked to this view.
+ */
+ _list: null,
+
+ /**
+ * The DownloadStore object used for saving.
+ */
+ _store: null,
+
+ /**
+ * True when the initial state of the downloads has been loaded.
+ */
+ _initialized: false,
+
+ /**
+ * Registers the view and loads the current state from disk.
+ *
+ * @return {Promise}
+ * @resolves When the view has been registered.
+ * @rejects JavaScript exception.
+ */
+ initialize() {
+ // We set _initialized to true after adding the view, so that
+ // onDownloadAdded doesn't cause a save to occur.
+ return this._list.addView(this).then(() => (this._initialized = true));
+ },
+
+ /**
+ * This map contains only Download objects that should be saved to disk, and
+ * associates them with the result of their getSerializationHash function, for
+ * the purpose of detecting changes to the relevant properties.
+ */
+ _downloadsMap: null,
+
+ /**
+ * DeferredTask for the save operation.
+ */
+ _writer: null,
+
+ /**
+ * Called when the list of downloads changed, this triggers the asynchronous
+ * serialization of the list of downloads.
+ */
+ saveSoon() {
+ this._writer.arm();
+ },
+
+ // DownloadList callback
+ onDownloadAdded(aDownload) {
+ if (lazy.gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) {
+ this._downloadsMap.set(aDownload, aDownload.getSerializationHash());
+ if (this._initialized) {
+ this.saveSoon();
+ }
+ }
+ },
+
+ // DownloadList callback
+ onDownloadChanged(aDownload) {
+ if (!lazy.gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) {
+ if (this._downloadsMap.has(aDownload)) {
+ this._downloadsMap.delete(aDownload);
+ this.saveSoon();
+ }
+ return;
+ }
+
+ let hash = aDownload.getSerializationHash();
+ if (this._downloadsMap.get(aDownload) != hash) {
+ this._downloadsMap.set(aDownload, hash);
+ this.saveSoon();
+ }
+ },
+
+ // DownloadList callback
+ onDownloadRemoved(aDownload) {
+ if (this._downloadsMap.has(aDownload)) {
+ this._downloadsMap.delete(aDownload);
+ this.saveSoon();
+ }
+ },
+};
diff --git a/toolkit/components/downloads/DownloadLegacy.sys.mjs b/toolkit/components/downloads/DownloadLegacy.sys.mjs
new file mode 100644
index 0000000000..ae38b0d26c
--- /dev/null
+++ b/toolkit/components/downloads/DownloadLegacy.sys.mjs
@@ -0,0 +1,503 @@
+/* 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/. */
+
+/**
+ * This component implements the XPCOM interfaces required for integration with
+ * the legacy download components.
+ *
+ * New code is expected to use the "Downloads.sys.mjs" module directly, without
+ * going through the interfaces implemented in this XPCOM component. These
+ * interfaces are only maintained for backwards compatibility with components
+ * that still work synchronously on the main thread.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadError: "resource://gre/modules/DownloadCore.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+});
+
+/**
+ * nsITransfer implementation that provides a bridge to a Download object.
+ *
+ * Legacy downloads work differently than the JavaScript implementation. In the
+ * latter, the caller only provides the properties for the Download object and
+ * the entire process is handled by the "start" method. In the legacy
+ * implementation, the caller must create a separate object to execute the
+ * download, and then make the download visible to the user by hooking it up to
+ * an nsITransfer instance.
+ *
+ * Since nsITransfer instances may be created before the download system is
+ * initialized, and initialization as well as other operations are asynchronous,
+ * this implementation is able to delay all progress and status notifications it
+ * receives until the associated Download object is finally created.
+ *
+ * Conversely, the DownloadLegacySaver object can also receive execution and
+ * cancellation requests asynchronously, before or after it is connected to
+ * this nsITransfer instance. For that reason, those requests are communicated
+ * in a potentially deferred way, using promise objects.
+ *
+ * The component that executes the download implements nsICancelable to receive
+ * cancellation requests, but after cancellation it cannot be reused again.
+ *
+ * Since the components that execute the download may be different and they
+ * don't always give consistent results, this bridge takes care of enforcing the
+ * expectations, for example by ensuring the target file exists when the
+ * download is successful, even if the source has a size of zero bytes.
+ */
+export function DownloadLegacyTransfer() {
+ this._promiseDownload = new Promise(r => (this._resolveDownload = r));
+}
+
+DownloadLegacyTransfer.prototype = {
+ classID: Components.ID("{1b4c85df-cbdd-4bb6-b04e-613caece083c}"),
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsITransfer",
+ ]),
+
+ // nsIWebProgressListener
+ onStateChange: function DLT_onStateChange(
+ aWebProgress,
+ aRequest,
+ aStateFlags,
+ aStatus
+ ) {
+ if (!Components.isSuccessCode(aStatus)) {
+ this._componentFailed = true;
+ }
+
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_START &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ let blockedByParentalControls = false;
+ // If it is a failed download, aRequest.responseStatus doesn't exist.
+ // (missing file on the server, network failure to download)
+ try {
+ // If the request's response has been blocked by Windows Parental Controls
+ // with an HTTP 450 error code, we must cancel the request synchronously.
+ blockedByParentalControls =
+ aRequest instanceof Ci.nsIHttpChannel &&
+ aRequest.responseStatus == 450;
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) {
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ }
+ }
+
+ if (blockedByParentalControls) {
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ }
+
+ // The main request has just started. Wait for the associated Download
+ // object to be available before notifying.
+ this._promiseDownload
+ .then(download => {
+ // If the request was blocked, now that we have the download object we
+ // should set a flag that can be retrieved later when handling the
+ // cancellation so that the proper error can be thrown.
+ if (blockedByParentalControls) {
+ download._blockedByParentalControls = true;
+ }
+
+ download.saver.onTransferStarted(aRequest);
+
+ // To handle asynchronous cancellation properly, we should hook up the
+ // handler only after we have been notified that the main request
+ // started. We will wait until the main request stopped before
+ // notifying that the download has been canceled. Since the request has
+ // not completed yet, deferCanceled is guaranteed to be set.
+ return download.saver.deferCanceled.promise.then(() => {
+ // Only cancel if the object executing the download is still running.
+ if (this._cancelable && !this._componentFailed) {
+ this._cancelable.cancel(Cr.NS_ERROR_ABORT);
+ }
+ });
+ })
+ .catch(console.error);
+ } else if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ // The last file has been received, or the download failed. Wait for the
+ // associated Download object to be available before notifying.
+ this._promiseDownload
+ .then(download => {
+ // At this point, the hash has been set and we need to copy it to the
+ // DownloadSaver.
+ if (Components.isSuccessCode(aStatus)) {
+ download.saver.setSha256Hash(this._sha256Hash);
+ download.saver.setSignatureInfo(this._signatureInfo);
+ download.saver.setRedirects(this._redirects);
+ }
+ download.saver.onTransferFinished(aStatus);
+ })
+ .catch(console.error);
+
+ // Release the reference to the component executing the download.
+ this._cancelable = null;
+ }
+ },
+
+ // nsIWebProgressListener
+ onProgressChange: function DLT_onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ this.onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ },
+
+ onLocationChange() {},
+
+ // nsIWebProgressListener
+ onStatusChange: function DLT_onStatusChange(
+ aWebProgress,
+ aRequest,
+ aStatus,
+ aMessage
+ ) {
+ // The status change may optionally be received in addition to the state
+ // change, but if no network request actually started, it is possible that
+ // we only receive a status change with an error status code.
+ if (!Components.isSuccessCode(aStatus)) {
+ this._componentFailed = true;
+
+ // Wait for the associated Download object to be available.
+ this._promiseDownload
+ .then(download => {
+ download.saver.onTransferFinished(aStatus);
+ })
+ .catch(console.error);
+ }
+ },
+
+ onSecurityChange() {},
+
+ onContentBlockingEvent() {},
+
+ // nsIWebProgressListener2
+ onProgressChange64: function DLT_onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ // Since this progress function is invoked frequently, we use a slightly
+ // more complex solution that optimizes the case where we already have an
+ // associated Download object, avoiding the Promise overhead.
+ if (this._download) {
+ this._hasDelayedProgress = false;
+ this._download.saver.onProgressBytes(
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ return;
+ }
+
+ // If we don't have a Download object yet, store the most recent progress
+ // notification to send later. We must do this because there is no guarantee
+ // that a future notification will be sent if the download stalls.
+ this._delayedCurTotalProgress = aCurTotalProgress;
+ this._delayedMaxTotalProgress = aMaxTotalProgress;
+
+ // Do not enqueue multiple callbacks for the progress report.
+ if (this._hasDelayedProgress) {
+ return;
+ }
+ this._hasDelayedProgress = true;
+
+ this._promiseDownload
+ .then(download => {
+ // Check whether an immediate progress report has been already processed
+ // before we could send the delayed progress report.
+ if (!this._hasDelayedProgress) {
+ return;
+ }
+ download.saver.onProgressBytes(
+ this._delayedCurTotalProgress,
+ this._delayedMaxTotalProgress
+ );
+ })
+ .catch(console.error);
+ },
+ _hasDelayedProgress: false,
+ _delayedCurTotalProgress: 0,
+ _delayedMaxTotalProgress: 0,
+
+ // nsIWebProgressListener2
+ onRefreshAttempted: function DLT_onRefreshAttempted(
+ aWebProgress,
+ aRefreshURI,
+ aMillis,
+ aSameURI
+ ) {
+ // Indicate that refreshes and redirects are allowed by default. However,
+ // note that download components don't usually call this method at all.
+ return true;
+ },
+
+ // nsITransfer
+ init: function DLT_init(
+ aSource,
+ aSourceOriginalURI,
+ aTarget,
+ aDisplayName,
+ aMIMEInfo,
+ aStartTime,
+ aTempFile,
+ aCancelable,
+ aIsPrivate,
+ aDownloadClassification,
+ aReferrerInfo,
+ aOpenDownloadsListOnStart
+ ) {
+ return this._nsITransferInitInternal(
+ aSource,
+ aSourceOriginalURI,
+ aTarget,
+ aDisplayName,
+ aMIMEInfo,
+ aStartTime,
+ aTempFile,
+ aCancelable,
+ aIsPrivate,
+ aDownloadClassification,
+ aReferrerInfo,
+ aOpenDownloadsListOnStart
+ );
+ },
+
+ // nsITransfer
+ initWithBrowsingContext(
+ aSource,
+ aTarget,
+ aDisplayName,
+ aMIMEInfo,
+ aStartTime,
+ aTempFile,
+ aCancelable,
+ aIsPrivate,
+ aDownloadClassification,
+ aReferrerInfo,
+ aOpenDownloadsListOnStart,
+ aBrowsingContext,
+ aHandleInternally,
+ aHttpChannel
+ ) {
+ let browsingContextId;
+ let userContextId;
+ if (aBrowsingContext && aBrowsingContext.currentWindowGlobal) {
+ browsingContextId = aBrowsingContext.id;
+ let windowGlobal = aBrowsingContext.currentWindowGlobal;
+ let originAttributes = windowGlobal.documentPrincipal.originAttributes;
+ userContextId = originAttributes.userContextId;
+ }
+ return this._nsITransferInitInternal(
+ aSource,
+ null,
+ aTarget,
+ aDisplayName,
+ aMIMEInfo,
+ aStartTime,
+ aTempFile,
+ aCancelable,
+ aIsPrivate,
+ aDownloadClassification,
+ aReferrerInfo,
+ aOpenDownloadsListOnStart,
+ userContextId,
+ browsingContextId,
+ aHandleInternally,
+ aHttpChannel
+ );
+ },
+
+ _nsITransferInitInternal(
+ aSource,
+ aSourceOriginalURI,
+ aTarget,
+ aDisplayName,
+ aMIMEInfo,
+ aStartTime,
+ aTempFile,
+ aCancelable,
+ isPrivate,
+ aDownloadClassification,
+ referrerInfo,
+ openDownloadsListOnStart = true,
+ userContextId = 0,
+ browsingContextId = 0,
+ handleInternally = false,
+ aHttpChannel = null
+ ) {
+ this._cancelable = aCancelable;
+ let launchWhenSucceeded = false,
+ contentType = null,
+ launcherPath = null;
+
+ if (aMIMEInfo instanceof Ci.nsIMIMEInfo) {
+ launchWhenSucceeded =
+ aMIMEInfo.preferredAction != Ci.nsIMIMEInfo.saveToDisk;
+ contentType = aMIMEInfo.type;
+
+ let appHandler = aMIMEInfo.preferredApplicationHandler;
+ if (
+ aMIMEInfo.preferredAction == Ci.nsIMIMEInfo.useHelperApp &&
+ appHandler instanceof Ci.nsILocalHandlerApp
+ ) {
+ launcherPath = appHandler.executable.path;
+ }
+ }
+ // Create a new Download object associated to a DownloadLegacySaver, and
+ // wait for it to be available. This operation may cause the entire
+ // download system to initialize before the object is created.
+ let authHeader = null;
+ if (aHttpChannel) {
+ try {
+ authHeader = aHttpChannel.getRequestHeader("Authorization");
+ } catch (e) {}
+ }
+ let serialisedDownload = {
+ source: {
+ url: aSource.spec,
+ originalUrl: aSourceOriginalURI && aSourceOriginalURI.spec,
+ isPrivate,
+ userContextId,
+ browsingContextId,
+ referrerInfo,
+ authHeader,
+ },
+ target: {
+ path: aTarget.QueryInterface(Ci.nsIFileURL).file.path,
+ partFilePath: aTempFile && aTempFile.path,
+ },
+ saver: "legacy",
+ launchWhenSucceeded,
+ contentType,
+ launcherPath,
+ handleInternally,
+ openDownloadsListOnStart,
+ };
+
+ // In case the Download was classified as insecure/dangerous
+ // it is already canceled, so we need to generate and attach the
+ // corresponding error to the download.
+ if (aDownloadClassification == Ci.nsITransfer.DOWNLOAD_POTENTIALLY_UNSAFE) {
+ Services.telemetry
+ .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD")
+ .add(lazy.DownloadError.BLOCK_VERDICT_INSECURE, 0);
+
+ serialisedDownload.errorObj = {
+ becauseBlockedByReputationCheck: true,
+ reputationCheckVerdict: lazy.DownloadError.BLOCK_VERDICT_INSECURE,
+ };
+ // hasBlockedData needs to be true
+ // because the unblock UI is hidden if there is
+ // no data to be unblocked.
+ serialisedDownload.hasBlockedData = true;
+ // We cannot use the legacy saver here, as the original channel
+ // is already closed. A copy saver would create a new channel once
+ // start() is called.
+ serialisedDownload.saver = "copy";
+
+ // Since the download is canceled already, we do not need to keep refrences
+ this._download = null;
+ this._cancelable = null;
+ }
+
+ lazy.Downloads.createDownload(serialisedDownload)
+ .then(async aDownload => {
+ // Legacy components keep partial data when they use a ".part" file.
+ if (aTempFile) {
+ aDownload.tryToKeepPartialData = true;
+ }
+
+ // Start the download before allowing it to be controlled. Ignore errors.
+ aDownload.start().catch(() => {});
+
+ // Start processing all the other events received through nsITransfer.
+ this._download = aDownload;
+ this._resolveDownload(aDownload);
+
+ // Add the download to the list, allowing it to be seen and canceled.
+ await (await lazy.Downloads.getList(lazy.Downloads.ALL)).add(aDownload);
+ if (serialisedDownload.errorObj) {
+ // In case we added an already canceled dummy download
+ // we need to manually trigger a change event
+ // as all the animations for finishing downloads are
+ // listening on onChange.
+ aDownload._notifyChange();
+ }
+ })
+ .catch(console.error);
+ },
+
+ setSha256Hash(hash) {
+ this._sha256Hash = hash;
+ },
+
+ setSignatureInfo(signatureInfo) {
+ this._signatureInfo = signatureInfo;
+ },
+
+ setRedirects(redirects) {
+ this._redirects = redirects;
+ },
+
+ /**
+ * Download object associated with this nsITransfer instance. This is not
+ * available immediately when the nsITransfer instance is created.
+ */
+ _download: null,
+
+ /**
+ * Promise that resolves to the Download object associated with this
+ * nsITransfer instance after the _resolveDownload method is invoked.
+ *
+ * Waiting on this promise using "then" ensures that the callbacks are invoked
+ * in the correct order even if enqueued before the object is available.
+ */
+ _promiseDownload: null,
+ _resolveDownload: null,
+
+ /**
+ * Reference to the component that is executing the download. This component
+ * allows cancellation through its nsICancelable interface.
+ */
+ _cancelable: null,
+
+ /**
+ * Indicates that the component that executes the download has notified a
+ * failure condition. In this case, we should never use the component methods
+ * that cancel the download.
+ */
+ _componentFailed: false,
+
+ /**
+ * Save the SHA-256 hash in raw bytes of the downloaded file.
+ */
+ _sha256Hash: null,
+
+ /**
+ * Save the signature info in a serialized protobuf of the downloaded file.
+ */
+ _signatureInfo: null,
+};
diff --git a/toolkit/components/downloads/DownloadList.sys.mjs b/toolkit/components/downloads/DownloadList.sys.mjs
new file mode 100644
index 0000000000..46f917d16b
--- /dev/null
+++ b/toolkit/components/downloads/DownloadList.sys.mjs
@@ -0,0 +1,668 @@
+/* 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/. */
+
+/**
+ * Provides collections of Download objects and aggregate views on them.
+ */
+
+const FILE_EXTENSIONS = [
+ "aac",
+ "adt",
+ "adts",
+ "accdb",
+ "accde",
+ "accdr",
+ "accdt",
+ "aif",
+ "aifc",
+ "aiff",
+ "apng",
+ "aspx",
+ "avi",
+ "avif",
+ "bat",
+ "bin",
+ "bmp",
+ "cab",
+ "cda",
+ "csv",
+ "dif",
+ "dll",
+ "doc",
+ "docm",
+ "docx",
+ "dot",
+ "dotx",
+ "eml",
+ "eps",
+ "exe",
+ "flac",
+ "flv",
+ "gif",
+ "htm",
+ "html",
+ "ico",
+ "ini",
+ "iso",
+ "jar",
+ "jfif",
+ "jpg",
+ "jpeg",
+ "json",
+ "m4a",
+ "mdb",
+ "mid",
+ "midi",
+ "mov",
+ "mp3",
+ "mp4",
+ "mpeg",
+ "mpg",
+ "msi",
+ "mui",
+ "oga",
+ "ogg",
+ "ogv",
+ "opus",
+ "pdf",
+ "pjpeg",
+ "pjp",
+ "png",
+ "pot",
+ "potm",
+ "potx",
+ "ppam",
+ "pps",
+ "ppsm",
+ "ppsx",
+ "ppt",
+ "pptm",
+ "pptx",
+ "psd",
+ "pst",
+ "pub",
+ "rar",
+ "rdf",
+ "rtf",
+ "shtml",
+ "sldm",
+ "sldx",
+ "svg",
+ "swf",
+ "sys",
+ "tif",
+ "tiff",
+ "tmp",
+ "txt",
+ "vob",
+ "vsd",
+ "vsdm",
+ "vsdx",
+ "vss",
+ "vssm",
+ "vst",
+ "vstm",
+ "vstx",
+ "wav",
+ "wbk",
+ "webm",
+ "webp",
+ "wks",
+ "wma",
+ "wmd",
+ "wmv",
+ "wmz",
+ "wms",
+ "wpd",
+ "wp5",
+ "xht",
+ "xhtml",
+ "xla",
+ "xlam",
+ "xll",
+ "xlm",
+ "xls",
+ "xlsm",
+ "xlsx",
+ "xlt",
+ "xltm",
+ "xltx",
+ "xml",
+ "zip",
+];
+
+const TELEMETRY_EVENT_CATEGORY = "downloads";
+
+/**
+ * Represents a collection of Download objects that can be viewed and managed by
+ * the user interface, and persisted across sessions.
+ */
+export class DownloadList {
+ constructor() {
+ /**
+ * Array of Download objects currently in the list.
+ */
+ this._downloads = [];
+
+ /**
+ * Set of currently registered views.
+ */
+ this._views = new Set();
+ }
+
+ /**
+ * Retrieves a snapshot of the downloads that are currently in the list. The
+ * returned array does not change when downloads are added or removed, though
+ * the Download objects it contains are still updated in real time.
+ *
+ * @return {Promise}
+ * @resolves An array of Download objects.
+ * @rejects JavaScript exception.
+ */
+ async getAll() {
+ return Array.from(this._downloads);
+ }
+
+ /**
+ * Adds a new download to the end of the items list.
+ *
+ * @note When a download is added to the list, its "onchange" event is
+ * registered by the list, thus it cannot be used to monitor the
+ * download. To receive change notifications for downloads that are
+ * added to the list, use the addView method to register for
+ * onDownloadChanged notifications.
+ *
+ * @param download
+ * The Download object to add.
+ *
+ * @return {Promise}
+ * @resolves When the download has been added.
+ * @rejects JavaScript exception.
+ */
+ async add(download) {
+ this._downloads.push(download);
+ download.onchange = this._change.bind(this, download);
+ this._notifyAllViews("onDownloadAdded", download);
+ }
+
+ /**
+ * Removes a download from the list. If the download was already removed,
+ * this method has no effect.
+ *
+ * This method does not change the state of the download, to allow adding it
+ * to another list, or control it directly. If you want to dispose of the
+ * download object, you should cancel it afterwards, and remove any partially
+ * downloaded data if needed.
+ *
+ * @param download
+ * The Download object to remove.
+ *
+ * @return {Promise}
+ * @resolves When the download has been removed.
+ * @rejects JavaScript exception.
+ */
+ async remove(download) {
+ let index = this._downloads.indexOf(download);
+ if (index != -1) {
+ this._downloads.splice(index, 1);
+ download.onchange = null;
+ this._notifyAllViews("onDownloadRemoved", download);
+ }
+ }
+
+ /**
+ * This function is called when "onchange" events of downloads occur.
+ *
+ * @param download
+ * The Download object that changed.
+ */
+ _change(download) {
+ this._notifyAllViews("onDownloadChanged", download);
+ }
+
+ /**
+ * Adds a view that will be notified of changes to downloads. The newly added
+ * view will receive onDownloadAdded notifications for all the downloads that
+ * are already in the list.
+ *
+ * @param view
+ * The view object to add. The following methods may be defined:
+ * {
+ * onDownloadAdded: function (download) {
+ * // Called after download is added to the end of the list.
+ * },
+ * onDownloadChanged: function (download) {
+ * // Called after the properties of download change.
+ * },
+ * onDownloadRemoved: function (download) {
+ * // Called after download is removed from the list.
+ * },
+ * onDownloadBatchStarting: function () {
+ * // Called before multiple changes are made at the same time.
+ * },
+ * onDownloadBatchEnded: function () {
+ * // Called after all the changes have been made.
+ * },
+ * }
+ *
+ * @return {Promise}
+ * @resolves When the view has been registered and all the onDownloadAdded
+ * notifications for the existing downloads have been sent.
+ * @rejects JavaScript exception.
+ */
+ async addView(view) {
+ this._views.add(view);
+
+ if ("onDownloadAdded" in view) {
+ this._notifyAllViews("onDownloadBatchStarting");
+ for (let download of this._downloads) {
+ try {
+ view.onDownloadAdded(download);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ this._notifyAllViews("onDownloadBatchEnded");
+ }
+ }
+
+ /**
+ * Removes a view that was previously added using addView.
+ *
+ * @param view
+ * The view object to remove.
+ *
+ * @return {Promise}
+ * @resolves When the view has been removed. At this point, the removed view
+ * will not receive any more notifications.
+ * @rejects JavaScript exception.
+ */
+ async removeView(view) {
+ this._views.delete(view);
+ }
+
+ /**
+ * Notifies all the views of a download addition, change, removal, or other
+ * event. The additional arguments are passed to the called method.
+ *
+ * @param methodName
+ * String containing the name of the method to call on the view.
+ */
+ _notifyAllViews(methodName, ...args) {
+ for (let view of this._views) {
+ try {
+ if (methodName in view) {
+ view[methodName](...args);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Removes downloads from the list that have finished, have failed, or have
+ * been canceled without keeping partial data. A filter function may be
+ * specified to remove only a subset of those downloads.
+ *
+ * This method finalizes each removed download, ensuring that any partially
+ * downloaded data associated with it is also removed.
+ *
+ * @param filterFn
+ * The filter function is called with each download as its only
+ * argument, and should return true to remove the download and false
+ * to keep it. This parameter may be null or omitted to have no
+ * additional filter.
+ */
+ removeFinished(filterFn) {
+ (async () => {
+ let list = await this.getAll();
+ for (let download of list) {
+ // Remove downloads that have been canceled, even if the cancellation
+ // operation hasn't completed yet so we don't check "stopped" here.
+ // Failed downloads with partial data are also removed.
+ if (
+ download.stopped &&
+ (!download.hasPartialData || download.error) &&
+ (!filterFn || filterFn(download))
+ ) {
+ // Remove the download first, so that the views don't get the change
+ // notifications that may occur during finalization.
+ await this.remove(download);
+ // Find if a file with the same path is also downloading.
+ let sameFileIsDownloading = false;
+ for (let otherDownload of await this.getAll()) {
+ if (
+ download !== otherDownload &&
+ download.target.path == otherDownload.target.path &&
+ !otherDownload.error
+ ) {
+ sameFileIsDownloading = true;
+ }
+ }
+ // Ensure that the download is stopped and no partial data is kept.
+ // This works even if the download state has changed meanwhile. We
+ // don't need to wait for the procedure to be complete before
+ // processing the other downloads in the list.
+ let removePartialData = !sameFileIsDownloading;
+ download.finalize(removePartialData).catch(console.error);
+ }
+ }
+ })().catch(console.error);
+ }
+}
+
+/**
+ * Provides a unified, unordered list combining public and private downloads.
+ *
+ * Download objects added to this list are also added to one of the two
+ * underlying lists, based on their "source.isPrivate" property. Views on this
+ * list will receive notifications for both public and private downloads.
+ *
+ * @param publicList
+ * Underlying DownloadList containing public downloads.
+ * @param privateList
+ * Underlying DownloadList containing private downloads.
+ */
+export class DownloadCombinedList extends DownloadList {
+ constructor(publicList, privateList) {
+ super();
+
+ /**
+ * Underlying DownloadList containing public downloads.
+ */
+ this._publicList = publicList;
+
+ /**
+ * Underlying DownloadList containing private downloads.
+ */
+ this._privateList = privateList;
+
+ publicList.addView(this).catch(console.error);
+ privateList.addView(this).catch(console.error);
+ }
+
+ /**
+ * Adds a new download to the end of the items list.
+ *
+ * @note When a download is added to the list, its "onchange" event is
+ * registered by the list, thus it cannot be used to monitor the
+ * download. To receive change notifications for downloads that are
+ * added to the list, use the addView method to register for
+ * onDownloadChanged notifications.
+ *
+ * @param download
+ * The Download object to add.
+ *
+ * @return {Promise}
+ * @resolves When the download has been added.
+ * @rejects JavaScript exception.
+ */
+ add(download) {
+ let extension = download.target.path.split(".").pop();
+
+ if (!FILE_EXTENSIONS.includes(extension)) {
+ extension = "other";
+ }
+
+ try {
+ Services.telemetry.recordEvent(
+ TELEMETRY_EVENT_CATEGORY,
+ "added",
+ "fileExtension",
+ extension,
+ {}
+ );
+ } catch (ex) {
+ console.error(
+ "DownloadsCommon: error recording telemetry event.",
+ ex.message
+ );
+ }
+
+ if (download.source.isPrivate) {
+ return this._privateList.add(download);
+ }
+
+ return this._publicList.add(download);
+ }
+
+ /**
+ * Removes a download from the list. If the download was already removed,
+ * this method has no effect.
+ *
+ * This method does not change the state of the download, to allow adding it
+ * to another list, or control it directly. If you want to dispose of the
+ * download object, you should cancel it afterwards, and remove any partially
+ * downloaded data if needed.
+ *
+ * @param download
+ * The Download object to remove.
+ *
+ * @return {Promise}
+ * @resolves When the download has been removed.
+ * @rejects JavaScript exception.
+ */
+ remove(download) {
+ if (download.source.isPrivate) {
+ return this._privateList.remove(download);
+ }
+ return this._publicList.remove(download);
+ }
+
+ // DownloadList callback
+ onDownloadAdded(download) {
+ this._downloads.push(download);
+ this._notifyAllViews("onDownloadAdded", download);
+ }
+
+ // DownloadList callback
+ onDownloadChanged(download) {
+ this._notifyAllViews("onDownloadChanged", download);
+ }
+
+ // DownloadList callback
+ onDownloadRemoved(download) {
+ let index = this._downloads.indexOf(download);
+ if (index != -1) {
+ this._downloads.splice(index, 1);
+ }
+ this._notifyAllViews("onDownloadRemoved", download);
+ }
+}
+/**
+ * Provides an aggregated view on the contents of a DownloadList.
+ */
+export class DownloadSummary {
+ constructor() {
+ /**
+ * Array of Download objects that are currently part of the summary.
+ */
+ this._downloads = [];
+
+ /**
+ * Set of currently registered views.
+ */
+ this._views = new Set();
+ }
+
+ /**
+ * Underlying DownloadList whose contents should be summarized.
+ */
+ _list = null;
+
+ /**
+ * Indicates whether all the downloads are currently stopped.
+ */
+ allHaveStopped = true;
+
+ /**
+ * Indicates whether whether all downloads have an unknown final size.
+ */
+ allUnknownSize = true;
+
+ /**
+ * Indicates the total number of bytes to be transferred before completing all
+ * the downloads that are currently in progress.
+ *
+ * For downloads that do not have a known final size, the number of bytes
+ * currently transferred is reported as part of this property.
+ *
+ * This is zero if no downloads are currently in progress.
+ */
+ progressTotalBytes = 0;
+
+ /**
+ * Number of bytes currently transferred as part of all the downloads that are
+ * currently in progress.
+ *
+ * This is zero if no downloads are currently in progress.
+ */
+ progressCurrentBytes = 0;
+
+ /**
+ * This method may be called once to bind this object to a DownloadList.
+ *
+ * Views on the summarized data can be registered before this object is bound
+ * to an actual list. This allows the summary to be used without requiring
+ * the initialization of the DownloadList first.
+ *
+ * @param list
+ * Underlying DownloadList whose contents should be summarized.
+ *
+ * @return {Promise}
+ * @resolves When the view on the underlying list has been registered.
+ * @rejects JavaScript exception.
+ */
+ async bindToList(list) {
+ if (this._list) {
+ throw new Error("bindToList may be called only once.");
+ }
+
+ await list.addView(this);
+ // Set the list reference only after addView has returned, so that we don't
+ // send a notification to our views for each download that is added.
+ this._list = list;
+ this._onListChanged();
+ }
+
+ /**
+ * Adds a view that will be notified of changes to the summary. The newly
+ * added view will receive an initial onSummaryChanged notification.
+ *
+ * @param view
+ * The view object to add. The following methods may be defined:
+ * {
+ * onSummaryChanged: function () {
+ * // Called after any property of the summary has changed.
+ * },
+ * }
+ *
+ * @return {Promise}
+ * @resolves When the view has been registered and the onSummaryChanged
+ * notification has been sent.
+ * @rejects JavaScript exception.
+ */
+ async addView(view) {
+ this._views.add(view);
+
+ if ("onSummaryChanged" in view) {
+ try {
+ view.onSummaryChanged();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Removes a view that was previously added using addView.
+ *
+ * @param view
+ * The view object to remove.
+ *
+ * @return {Promise}
+ * @resolves When the view has been removed. At this point, the removed view
+ * will not receive any more notifications.
+ * @rejects JavaScript exception.
+ */
+ async removeView(view) {
+ this._views.delete(view);
+ }
+
+ /**
+ * This function is called when any change in the list of downloads occurs,
+ * and will recalculate the summary and notify the views in case the
+ * aggregated properties are different.
+ */
+ _onListChanged() {
+ let allHaveStopped = true;
+ let allUnknownSize = true;
+ let progressTotalBytes = 0;
+ let progressCurrentBytes = 0;
+
+ // Recalculate the aggregated state. See the description of the individual
+ // properties for an explanation of the summarization logic.
+ for (let download of this._downloads) {
+ if (!download.stopped) {
+ allHaveStopped = false;
+ if (download.hasProgress) {
+ allUnknownSize = false;
+ progressTotalBytes += download.totalBytes;
+ } else {
+ progressTotalBytes += download.currentBytes;
+ }
+ progressCurrentBytes += download.currentBytes;
+ }
+ }
+
+ // Exit now if the properties did not change.
+ if (
+ this.allHaveStopped == allHaveStopped &&
+ this.allUnknownSize == allUnknownSize &&
+ this.progressTotalBytes == progressTotalBytes &&
+ this.progressCurrentBytes == progressCurrentBytes
+ ) {
+ return;
+ }
+
+ this.allHaveStopped = allHaveStopped;
+ this.allUnknownSize = allUnknownSize;
+ this.progressTotalBytes = progressTotalBytes;
+ this.progressCurrentBytes = progressCurrentBytes;
+
+ // Notify all the views that our properties changed.
+ for (let view of this._views) {
+ try {
+ if ("onSummaryChanged" in view) {
+ view.onSummaryChanged();
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ // DownloadList callback
+ onDownloadAdded(download) {
+ this._downloads.push(download);
+ if (this._list) {
+ this._onListChanged();
+ }
+ }
+
+ // DownloadList callback
+ onDownloadChanged(download) {
+ this._onListChanged();
+ }
+
+ // DownloadList callback
+ onDownloadRemoved(download) {
+ let index = this._downloads.indexOf(download);
+ if (index != -1) {
+ this._downloads.splice(index, 1);
+ }
+ this._onListChanged();
+ }
+}
diff --git a/toolkit/components/downloads/DownloadPaths.sys.mjs b/toolkit/components/downloads/DownloadPaths.sys.mjs
new file mode 100644
index 0000000000..f1e0a080c2
--- /dev/null
+++ b/toolkit/components/downloads/DownloadPaths.sys.mjs
@@ -0,0 +1,102 @@
+/* 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/. */
+
+/**
+ * Provides methods for giving names and paths to files being downloaded.
+ */
+
+export var DownloadPaths = {
+ /**
+ * Sanitizes an arbitrary string via mimeSvc.validateFileNameForSaving.
+ *
+ * If the filename being validated is one that was returned from a
+ * file picker, pass false for compressWhitespaces and true for
+ * allowInvalidFilenames. Otherwise, the default values of the arguments
+ * should generally be used.
+ *
+ * @param {string} leafName The full leaf name to sanitize
+ * @param {boolean} [compressWhitespaces] Whether consecutive whitespaces
+ * should be compressed. The default value is true.
+ * @param {boolean} [allowInvalidFilenames] Allow invalid and dangerous
+ * filenames and extensions as is.
+ */
+ sanitize(
+ leafName,
+ { compressWhitespaces = true, allowInvalidFilenames = false } = {}
+ ) {
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ let flags = mimeSvc.VALIDATE_SANITIZE_ONLY | mimeSvc.VALIDATE_DONT_TRUNCATE;
+ if (!compressWhitespaces) {
+ flags |= mimeSvc.VALIDATE_DONT_COLLAPSE_WHITESPACE;
+ }
+ if (allowInvalidFilenames) {
+ flags |= mimeSvc.VALIDATE_ALLOW_INVALID_FILENAMES;
+ }
+ return mimeSvc.validateFileNameForSaving(leafName, "", flags);
+ },
+
+ /**
+ * Creates a uniquely-named file starting from the name of the provided file.
+ * If a file with the provided name already exists, the function attempts to
+ * create nice alternatives, like "base(1).ext" (instead of "base-1.ext").
+ *
+ * If a unique name cannot be found, the function throws the XPCOM exception
+ * NS_ERROR_FILE_TOO_BIG. Other exceptions, like NS_ERROR_FILE_ACCESS_DENIED,
+ * can also be expected.
+ *
+ * @param templateFile
+ * nsIFile whose leaf name is going to be used as a template. The
+ * provided object is not modified.
+ *
+ * @return A new instance of an nsIFile object pointing to the newly created
+ * empty file. On platforms that support permission bits, the file is
+ * created with permissions 644.
+ */
+ createNiceUniqueFile(templateFile) {
+ // Work on a clone of the provided template file object.
+ let curFile = templateFile.clone().QueryInterface(Ci.nsIFile);
+ let [base, ext] = DownloadPaths.splitBaseNameAndExtension(curFile.leafName);
+ // Try other file names, for example "base(1).txt" or "base(1).tar.gz",
+ // only if the file name initially set already exists.
+ for (let i = 1; i < 10000 && curFile.exists(); i++) {
+ curFile.leafName = base + "(" + i + ")" + ext;
+ }
+ // At this point we hand off control to createUnique, which will create the
+ // file with the name we chose, if it is valid. If not, createUnique will
+ // attempt to modify it again, for example it will shorten very long names
+ // that can't be created on some platforms, and for which a normal call to
+ // nsIFile.create would result in NS_ERROR_FILE_NOT_FOUND. This can result
+ // very rarely in strange names like "base(9999).tar-1.gz" or "ba-1.gz".
+ curFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+ return curFile;
+ },
+
+ /**
+ * Separates the base name from the extension in a file name, recognizing some
+ * double extensions like ".tar.gz".
+ *
+ * @param leafName
+ * The full leaf name to be parsed. Be careful when processing names
+ * containing leading or trailing dots or spaces.
+ *
+ * @return [base, ext]
+ * The base name of the file, which can be empty, and its extension,
+ * which always includes the leading dot unless it's an empty string.
+ * Concatenating the two items always results in the original name.
+ */
+ splitBaseNameAndExtension(leafName) {
+ // The following regular expression is built from these key parts:
+ // .*? Matches the base name non-greedily.
+ // \.[A-Z0-9]{1,3} Up to three letters or numbers preceding a
+ // double extension.
+ // \.(?:gz|bz2|Z) The second part of common double extensions.
+ // \.[^.]* Matches any extension or a single trailing dot.
+ let [, base, ext] = /(.*?)(\.[A-Z0-9]{1,3}\.(?:gz|bz2|Z)|\.[^.]*)?$/i.exec(
+ leafName
+ );
+ // Return an empty string instead of undefined if no extension is found.
+ return [base, ext || ""];
+ },
+};
diff --git a/toolkit/components/downloads/DownloadPlatform.cpp b/toolkit/components/downloads/DownloadPlatform.cpp
new file mode 100644
index 0000000000..ee2e0a3248
--- /dev/null
+++ b/toolkit/components/downloads/DownloadPlatform.cpp
@@ -0,0 +1,319 @@
+/* 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/. */
+
+#include "DownloadPlatform.h"
+#include "nsNetUtil.h"
+#include "nsString.h"
+#include "nsINestedURI.h"
+#include "nsIProtocolHandler.h"
+#include "nsIURI.h"
+#include "nsIFile.h"
+#include "xpcpublic.h"
+
+#include "mozilla/dom/Promise.h"
+#include "mozilla/Preferences.h"
+
+#define PREF_BDM_ADDTORECENTDOCS "browser.download.manager.addToRecentDocs"
+
+#ifdef XP_WIN
+# include <shlobj.h>
+# include <urlmon.h>
+# include "nsILocalFileWin.h"
+# include "WinTaskbar.h"
+#endif
+
+#ifdef XP_MACOSX
+# include <CoreFoundation/CoreFoundation.h>
+# include "../../../xpcom/io/CocoaFileUtils.h"
+#endif
+
+#ifdef MOZ_WIDGET_GTK
+# include <gtk/gtk.h>
+#endif
+
+using namespace mozilla;
+using dom::Promise;
+
+DownloadPlatform* DownloadPlatform::gDownloadPlatformService = nullptr;
+
+NS_IMPL_ISUPPORTS(DownloadPlatform, mozIDownloadPlatform);
+
+DownloadPlatform* DownloadPlatform::GetDownloadPlatform() {
+ if (!gDownloadPlatformService) {
+ gDownloadPlatformService = new DownloadPlatform();
+ }
+
+ NS_ADDREF(gDownloadPlatformService);
+
+ return gDownloadPlatformService;
+}
+
+#ifdef MOZ_WIDGET_GTK
+static void gio_set_metadata_done(GObject* source_obj, GAsyncResult* res,
+ gpointer user_data) {
+ GError* err = nullptr;
+ g_file_set_attributes_finish(G_FILE(source_obj), res, nullptr, &err);
+ if (err) {
+# ifdef DEBUG
+ NS_DebugBreak(NS_DEBUG_WARNING, "Set file metadata failed: ", err->message,
+ __FILE__, __LINE__);
+# endif
+ g_error_free(err);
+ }
+}
+#endif
+
+#ifdef XP_MACOSX
+// Caller is responsible for freeing any result (CF Create Rule)
+CFURLRef CreateCFURLFromNSIURI(nsIURI* aURI) {
+ nsAutoCString spec;
+ if (aURI) {
+ aURI->GetSpec(spec);
+ }
+
+ CFStringRef urlStr = ::CFStringCreateWithCString(
+ kCFAllocatorDefault, spec.get(), kCFStringEncodingUTF8);
+ if (!urlStr) {
+ return NULL;
+ }
+
+ CFURLRef url = ::CFURLCreateWithString(kCFAllocatorDefault, urlStr, NULL);
+
+ ::CFRelease(urlStr);
+
+ return url;
+}
+#endif
+
+#ifdef XP_WIN
+static void AddToRecentDocs(nsIFile* aTarget, nsAutoString& aPath) {
+ nsString modelId;
+ if (mozilla::widget::WinTaskbar::GetAppUserModelID(modelId)) {
+ nsCOMPtr<nsIURI> uri;
+ if (NS_SUCCEEDED(NS_NewFileURI(getter_AddRefs(uri), aTarget)) && uri) {
+ nsCString spec;
+ if (NS_SUCCEEDED(uri->GetSpec(spec))) {
+ IShellItem2* psi = nullptr;
+ if (SUCCEEDED(
+ SHCreateItemFromParsingName(NS_ConvertASCIItoUTF16(spec).get(),
+ nullptr, IID_PPV_ARGS(&psi)))) {
+ SHARDAPPIDINFO info = {psi, modelId.get()};
+ ::SHAddToRecentDocs(SHARD_APPIDINFO, &info);
+ psi->Release();
+ return;
+ }
+ }
+ }
+ }
+
+ ::SHAddToRecentDocs(SHARD_PATHW, aPath.get());
+}
+#endif
+
+nsresult DownloadPlatform::DownloadDone(nsIURI* aSource, nsIURI* aReferrer,
+ nsIFile* aTarget,
+ const nsACString& aContentType,
+ bool aIsPrivate, JSContext* aCx,
+ Promise** aPromise) {
+ nsIGlobalObject* globalObject =
+ xpc::NativeGlobal(JS::CurrentGlobalOrNull(aCx));
+
+ if (NS_WARN_IF(!globalObject)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ ErrorResult result;
+ RefPtr<Promise> promise = Promise::Create(globalObject, result);
+
+ if (NS_WARN_IF(result.Failed())) {
+ return result.StealNSResult();
+ }
+
+ nsresult rv = NS_OK;
+ bool pendingAsyncOperations = false;
+
+#if defined(XP_WIN) || defined(XP_MACOSX) || defined(MOZ_WIDGET_ANDROID) || \
+ defined(MOZ_WIDGET_GTK)
+
+ nsAutoString path;
+ if (aTarget && NS_SUCCEEDED(aTarget->GetPath(path))) {
+# if defined(XP_WIN) || defined(MOZ_WIDGET_GTK) || defined(MOZ_WIDGET_ANDROID)
+ // On Windows and Gtk, add the download to the system's "recent documents"
+ // list, with a pref to disable.
+ {
+# ifndef MOZ_WIDGET_ANDROID
+ bool addToRecentDocs = Preferences::GetBool(PREF_BDM_ADDTORECENTDOCS);
+ if (addToRecentDocs && !aIsPrivate) {
+# ifdef XP_WIN
+ AddToRecentDocs(aTarget, path);
+# elif defined(MOZ_WIDGET_GTK)
+ GtkRecentManager* manager = gtk_recent_manager_get_default();
+
+ gchar* uri = g_filename_to_uri(NS_ConvertUTF16toUTF8(path).get(),
+ nullptr, nullptr);
+ if (uri) {
+ gtk_recent_manager_add_item(manager, uri);
+ g_free(uri);
+ }
+# endif
+ }
+# endif
+# ifdef MOZ_WIDGET_GTK
+ // Private window should not leak URI to the system (Bug 1535950)
+ if (!aIsPrivate) {
+ // Use GIO to store the source URI for later display in the file
+ // manager.
+ GFile* gio_file =
+ g_file_new_for_path(NS_ConvertUTF16toUTF8(path).get());
+ nsCString source_uri;
+ nsresult rv = aSource->GetSpec(source_uri);
+ NS_ENSURE_SUCCESS(rv, rv);
+ GFileInfo* file_info = g_file_info_new();
+ g_file_info_set_attribute_string(file_info, "metadata::download-uri",
+ source_uri.get());
+ g_file_set_attributes_async(gio_file, file_info, G_FILE_QUERY_INFO_NONE,
+ G_PRIORITY_DEFAULT, nullptr,
+ gio_set_metadata_done, nullptr);
+ g_object_unref(file_info);
+ g_object_unref(gio_file);
+ }
+# endif
+ }
+# endif
+
+# ifdef XP_MACOSX
+ // On OS X, make the downloads stack bounce.
+ CFStringRef observedObject = ::CFStringCreateWithCString(
+ kCFAllocatorDefault, NS_ConvertUTF16toUTF8(path).get(),
+ kCFStringEncodingUTF8);
+ CFNotificationCenterRef center =
+ ::CFNotificationCenterGetDistributedCenter();
+ ::CFNotificationCenterPostNotification(
+ center, CFSTR("com.apple.DownloadFileFinished"), observedObject,
+ nullptr, TRUE);
+ ::CFRelease(observedObject);
+
+ // Add OS X origin and referrer file metadata
+ CFStringRef pathCFStr = NULL;
+ if (!path.IsEmpty()) {
+ pathCFStr = ::CFStringCreateWithCharacters(
+ kCFAllocatorDefault, (const UniChar*)path.get(), path.Length());
+ }
+ if (pathCFStr && !aIsPrivate) {
+ bool isFromWeb = IsURLPossiblyFromWeb(aSource);
+ nsCOMPtr<nsIURI> source(aSource);
+ nsCOMPtr<nsIURI> referrer(aReferrer);
+
+ rv = NS_DispatchBackgroundTask(
+ NS_NewRunnableFunction(
+ "DownloadPlatform::DownloadDone",
+ [pathCFStr, isFromWeb, source, referrer, promise]() mutable {
+ CFURLRef sourceCFURL = CreateCFURLFromNSIURI(source);
+ CFURLRef referrerCFURL = CreateCFURLFromNSIURI(referrer);
+
+ CocoaFileUtils::AddOriginMetadataToFile(pathCFStr, sourceCFURL,
+ referrerCFURL);
+ CocoaFileUtils::AddQuarantineMetadataToFile(
+ pathCFStr, sourceCFURL, referrerCFURL, isFromWeb);
+ ::CFRelease(pathCFStr);
+ if (sourceCFURL) {
+ ::CFRelease(sourceCFURL);
+ }
+ if (referrerCFURL) {
+ ::CFRelease(referrerCFURL);
+ }
+
+ DebugOnly<nsresult> rv =
+ NS_DispatchToMainThread(NS_NewRunnableFunction(
+ "DownloadPlatform::DownloadDoneResolve",
+ [promise = std::move(promise)]() {
+ promise->MaybeResolveWithUndefined();
+ }));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ // In non-debug builds, if we've for some reason failed to
+ // dispatch a runnable to the main thread to resolve the
+ // Promise, then it's unlikely we can reject it either. At that
+ // point, the Promise is going to remain in pending limbo until
+ // its global goes away.
+ }),
+ NS_DISPATCH_EVENT_MAY_BLOCK);
+
+ if (NS_SUCCEEDED(rv)) {
+ pendingAsyncOperations = true;
+ }
+ }
+# endif
+ }
+
+#endif
+
+ if (!pendingAsyncOperations) {
+ promise->MaybeResolveWithUndefined();
+ }
+ promise.forget(aPromise);
+ return rv;
+}
+
+nsresult DownloadPlatform::MapUrlToZone(const nsAString& aURL,
+ uint32_t* aZone) {
+#ifdef XP_WIN
+ RefPtr<IInternetSecurityManager> inetSecMgr;
+ if (FAILED(CoCreateInstance(CLSID_InternetSecurityManager, NULL, CLSCTX_ALL,
+ IID_IInternetSecurityManager,
+ getter_AddRefs(inetSecMgr)))) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ DWORD zone;
+ if (inetSecMgr->MapUrlToZone(PromiseFlatString(aURL).get(), &zone, 0) !=
+ S_OK) {
+ return NS_ERROR_UNEXPECTED;
+ } else {
+ *aZone = zone;
+ }
+
+ return NS_OK;
+#else
+ return NS_ERROR_NOT_IMPLEMENTED;
+#endif
+}
+
+// Check if a URI is likely to be web-based, by checking its URI flags.
+// If in doubt (e.g. if anything fails during the check) claims things
+// are from the web.
+bool DownloadPlatform::IsURLPossiblyFromWeb(nsIURI* aURI) {
+ nsCOMPtr<nsIIOService> ios = do_GetIOService();
+ nsCOMPtr<nsIURI> uri = aURI;
+ if (!ios) {
+ return true;
+ }
+
+ while (uri) {
+ // We're not using NS_URIChainHasFlags because we're checking for *any* of 3
+ // flags to be present on *all* of the nested URIs, which it can't do.
+ uint32_t flags;
+ nsresult rv = ios->GetDynamicProtocolFlags(uri, &flags);
+ if (NS_FAILED(rv)) {
+ return true;
+ }
+ // If not dangerous to load, not a UI resource and not a local file,
+ // assume this is from the web:
+ if (!(flags & nsIProtocolHandler::URI_DANGEROUS_TO_LOAD) &&
+ !(flags & nsIProtocolHandler::URI_IS_UI_RESOURCE) &&
+ !(flags & nsIProtocolHandler::URI_IS_LOCAL_FILE)) {
+ return true;
+ }
+ // Otherwise, check if the URI is nested, and if so go through
+ // the loop again:
+ nsCOMPtr<nsINestedURI> nestedURI = do_QueryInterface(uri);
+ uri = nullptr;
+ if (nestedURI) {
+ rv = nestedURI->GetInnerURI(getter_AddRefs(uri));
+ if (NS_FAILED(rv)) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/toolkit/components/downloads/DownloadPlatform.h b/toolkit/components/downloads/DownloadPlatform.h
new file mode 100644
index 0000000000..82e74f8c49
--- /dev/null
+++ b/toolkit/components/downloads/DownloadPlatform.h
@@ -0,0 +1,28 @@
+/* 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/. */
+
+#ifndef __DownloadPlatform_h__
+#define __DownloadPlatform_h__
+
+#include "mozIDownloadPlatform.h"
+
+#include "nsISupports.h"
+
+class DownloadPlatform : public mozIDownloadPlatform {
+ protected:
+ virtual ~DownloadPlatform() = default;
+
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_MOZIDOWNLOADPLATFORM
+
+ static DownloadPlatform* gDownloadPlatformService;
+
+ static DownloadPlatform* GetDownloadPlatform();
+
+ private:
+ static bool IsURLPossiblyFromWeb(nsIURI* aURI);
+};
+
+#endif
diff --git a/toolkit/components/downloads/DownloadStore.sys.mjs b/toolkit/components/downloads/DownloadStore.sys.mjs
new file mode 100644
index 0000000000..542a8c765a
--- /dev/null
+++ b/toolkit/components/downloads/DownloadStore.sys.mjs
@@ -0,0 +1,211 @@
+/* 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/. */
+
+/**
+ * Handles serialization of Download objects and persistence into a file, so
+ * that the state of downloads can be restored across sessions.
+ *
+ * The file is stored in JSON format, without indentation. With indentation
+ * applied, the file would look like this:
+ *
+ * {
+ * "list": [
+ * {
+ * "source": "http://www.example.com/download.txt",
+ * "target": "/home/user/Downloads/download.txt"
+ * },
+ * {
+ * "source": {
+ * "url": "http://www.example.com/download.txt",
+ * "referrerInfo": serialized string represents referrerInfo object
+ * },
+ * "target": "/home/user/Downloads/download-2.txt"
+ * }
+ * ]
+ * }
+ */
+
+// Time after which insecure downloads that have not been dealt with on shutdown
+// get removed (5 minutes).
+const MAX_INSECURE_DOWNLOAD_AGE_MS = 5 * 60 * 1000;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "gTextDecoder", function () {
+ return new TextDecoder();
+});
+
+ChromeUtils.defineLazyGetter(lazy, "gTextEncoder", function () {
+ return new TextEncoder();
+});
+
+/**
+ * Handles serialization of Download objects and persistence into a file, so
+ * that the state of downloads can be restored across sessions.
+ *
+ * @param aList
+ * DownloadList object to be populated or serialized.
+ * @param aPath
+ * String containing the file path where data should be saved.
+ */
+export var DownloadStore = function (aList, aPath) {
+ this.list = aList;
+ this.path = aPath;
+};
+
+DownloadStore.prototype = {
+ /**
+ * DownloadList object to be populated or serialized.
+ */
+ list: null,
+
+ /**
+ * String containing the file path where data should be saved.
+ */
+ path: "",
+
+ /**
+ * This function is called with a Download object as its first argument, and
+ * should return true if the item should be saved.
+ */
+ onsaveitem: () => true,
+
+ /**
+ * Loads persistent downloads from the file to the list.
+ *
+ * @return {Promise}
+ * @resolves When the operation finished successfully.
+ * @rejects JavaScript exception.
+ */
+ load: function DS_load() {
+ return (async () => {
+ let bytes;
+ try {
+ bytes = await IOUtils.read(this.path);
+ } catch (ex) {
+ if (!(ex.name == "NotFoundError")) {
+ throw ex;
+ }
+ // If the file does not exist, there are no downloads to load.
+ return;
+ }
+
+ // Set this to true when we make changes to the download list that should
+ // be reflected in the file again.
+ let storeChanges = false;
+ let removePromises = [];
+ let storeData = JSON.parse(lazy.gTextDecoder.decode(bytes));
+
+ // Create live downloads based on the static snapshot.
+ for (let downloadData of storeData.list) {
+ try {
+ let download = await lazy.Downloads.createDownload(downloadData);
+
+ // Insecure downloads that have not been dealt with on shutdown should
+ // get cleaned up and removed from the download list on restart unless
+ // they are very new
+ if (
+ download.error?.becauseBlockedByReputationCheck &&
+ download.error.reputationCheckVerdict == "Insecure" &&
+ Date.now() - download.startTime > MAX_INSECURE_DOWNLOAD_AGE_MS
+ ) {
+ removePromises.push(download.removePartialData());
+ storeChanges = true;
+ continue;
+ }
+
+ try {
+ if (!download.succeeded && !download.canceled && !download.error) {
+ // Try to restart the download if it was in progress during the
+ // previous session. Ignore errors.
+ download.start().catch(() => {});
+ } else {
+ // If the download was not in progress, try to update the current
+ // progress from disk. This is relevant in case we retained
+ // partially downloaded data.
+ await download.refresh();
+ }
+ } finally {
+ // Add the download to the list if we succeeded in creating it,
+ // after we have updated its initial state.
+ await this.list.add(download);
+ }
+ } catch (ex) {
+ // If an item is unrecognized, don't prevent others from being loaded.
+ console.error(ex);
+ }
+ }
+
+ if (storeChanges) {
+ try {
+ await Promise.all(removePromises);
+ await this.save();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ })();
+ },
+
+ /**
+ * Saves persistent downloads from the list to the file.
+ *
+ * If an error occurs, the previous file is not deleted.
+ *
+ * @return {Promise}
+ * @resolves When the operation finished successfully.
+ * @rejects JavaScript exception.
+ */
+ save: function DS_save() {
+ return (async () => {
+ let downloads = await this.list.getAll();
+
+ // Take a static snapshot of the current state of all the downloads.
+ let storeData = { list: [] };
+ let atLeastOneDownload = false;
+ for (let download of downloads) {
+ try {
+ if (!this.onsaveitem(download)) {
+ continue;
+ }
+
+ let serializable = download.toSerializable();
+ if (!serializable) {
+ // This item cannot be persisted across sessions.
+ continue;
+ }
+ storeData.list.push(serializable);
+ atLeastOneDownload = true;
+ } catch (ex) {
+ // If an item cannot be converted to a serializable form, don't
+ // prevent others from being saved.
+ console.error(ex);
+ }
+ }
+
+ if (atLeastOneDownload) {
+ // Create or overwrite the file if there are downloads to save.
+ let bytes = lazy.gTextEncoder.encode(JSON.stringify(storeData));
+ await IOUtils.write(this.path, bytes, {
+ tmpPath: this.path + ".tmp",
+ });
+ } else {
+ // Remove the file if there are no downloads to save at all.
+ try {
+ await IOUtils.remove(this.path);
+ } catch (ex) {
+ if (!(ex.name == "NotFoundError" || ex.name == "NotAllowedError")) {
+ throw 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.
+ }
+ }
+ })();
+ },
+};
diff --git a/toolkit/components/downloads/DownloadUIHelper.sys.mjs b/toolkit/components/downloads/DownloadUIHelper.sys.mjs
new file mode 100644
index 0000000000..0d1d0e0a31
--- /dev/null
+++ b/toolkit/components/downloads/DownloadUIHelper.sys.mjs
@@ -0,0 +1,240 @@
+/* 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/. */
+
+/**
+ * Provides functions to handle status and messages in the user interface.
+ */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+// BrowserWindowTracker and PrivateBrowsingUtils are only used when opening downloaded files into a browser window
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["toolkit/downloads/downloadUI.ftl"], true)
+);
+
+/**
+ * Provides functions to handle status and messages in the user interface.
+ */
+export var DownloadUIHelper = {
+ /**
+ * Returns an object that can be used to display prompts related to downloads.
+ *
+ * The prompts may be either anchored to a specified window, or anchored to
+ * the most recently active window, for example if the prompt is displayed in
+ * response to global notifications that are not associated with any window.
+ *
+ * @param aParent
+ * If specified, should reference the nsIDOMWindow to which the prompts
+ * should be attached. If omitted, the prompts will be attached to the
+ * most recently active window.
+ *
+ * @return A DownloadPrompter object.
+ */
+ getPrompter(aParent) {
+ return new DownloadPrompter(aParent || null);
+ },
+
+ /**
+ * Open the given file as a file: URI in the active window
+ *
+ * @param nsIFile file The downloaded file
+ * @param options.chromeWindow Optional chrome window where we could open the file URI
+ * @param options.openWhere String indicating how to open the URI.
+ * One of "window", "tab", "tabshifted"
+ * @param options.isPrivate Open in private window or not
+ * @param options.browsingContextId BrowsingContext ID of the initiating document
+ * @param options.userContextId UserContextID of the initiating document
+ */
+ loadFileIn(
+ file,
+ {
+ chromeWindow: browserWin,
+ openWhere = "tab",
+ isPrivate,
+ userContextId = 0,
+ browsingContextId = 0,
+ } = {}
+ ) {
+ let fileURI = Services.io.newFileURI(file);
+ let allowPrivate =
+ isPrivate || lazy.PrivateBrowsingUtils.permanentPrivateBrowsing;
+
+ if (
+ !browserWin ||
+ browserWin.document.documentElement.getAttribute("windowtype") !==
+ "navigator:browser"
+ ) {
+ // we'll need a private window for a private download, or if we're in private-only mode
+ // but otherwise we want to open files in a non-private window
+ browserWin = lazy.BrowserWindowTracker.getTopWindow({
+ private: allowPrivate,
+ });
+ }
+ // if there is no suitable browser window, we'll need to open one and ignore any other `openWhere` value
+ // this can happen if the library dialog is the only open window
+ if (!browserWin) {
+ // There is no browser window open, so open a new one.
+ let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ let strURI = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ strURI.data = fileURI.spec;
+ args.appendElement(strURI);
+ let features = "chrome,dialog=no,all";
+ if (isPrivate) {
+ features += ",private";
+ }
+ browserWin = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ null,
+ features,
+ args
+ );
+ return;
+ }
+
+ // a browser window will have the helpers from utilityOverlay.js
+ let browsingContext = browserWin?.BrowsingContext.get(browsingContextId);
+ browserWin.openTrustedLinkIn(fileURI.spec, openWhere, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ private: isPrivate,
+ userContextId,
+ openerBrowser: browsingContext?.top?.embedderElement,
+ });
+ },
+};
+
+/**
+ * Allows displaying prompts related to downloads.
+ *
+ * @param aParent
+ * The nsIDOMWindow to which prompts should be attached, or null to
+ * attach prompts to the most recently active window.
+ */
+var DownloadPrompter = function (aParent) {
+ this._prompter = Services.ww.getNewPrompter(aParent);
+};
+
+DownloadPrompter.prototype = {
+ /**
+ * Constants with the different type of prompts.
+ */
+ ON_QUIT: "prompt-on-quit",
+ ON_OFFLINE: "prompt-on-offline",
+ ON_LEAVE_PRIVATE_BROWSING: "prompt-on-leave-private-browsing",
+
+ /**
+ * nsIPrompt instance for displaying messages.
+ */
+ _prompter: null,
+
+ /**
+ * Displays a warning message box that informs that the specified file is
+ * executable, and asks whether the user wants to launch it.
+ *
+ * @param path
+ * String containing the full path to the file to be opened.
+ *
+ * @resolves Boolean indicating whether the launch operation can continue.
+ */
+ async confirmLaunchExecutable(path) {
+ const kPrefSkipConfirm = "browser.download.skipConfirmLaunchExecutable";
+
+ // Always launch in case we have no prompter implementation.
+ if (!this._prompter) {
+ return true;
+ }
+
+ try {
+ if (Services.prefs.getBoolPref(kPrefSkipConfirm)) {
+ return true;
+ }
+ } catch (ex) {
+ // If the preference does not exist, continue with the prompt.
+ }
+
+ const title = lazy.l10n.formatValueSync(
+ "download-ui-file-executable-security-warning-title"
+ );
+ const message = lazy.l10n.formatValueSync(
+ "download-ui-file-executable-security-warning",
+ { executable: PathUtils.filename(path) }
+ );
+ return this._prompter.confirm(title, message);
+ },
+
+ /**
+ * Displays a warning message box that informs that there are active
+ * downloads, and asks whether the user wants to cancel them or not.
+ *
+ * @param aDownloadsCount
+ * The current downloads count.
+ * @param aPromptType
+ * The type of prompt notification depending on the observer.
+ *
+ * @return False to cancel the downloads and continue, true to abort the
+ * operation.
+ */
+ confirmCancelDownloads: function DP_confirmCancelDownload(
+ aDownloadsCount,
+ aPromptType
+ ) {
+ // Always continue in case we have no prompter implementation, or if there
+ // are no active downloads.
+ if (!this._prompter || aDownloadsCount <= 0) {
+ return false;
+ }
+
+ let message, cancelButton;
+
+ switch (aPromptType) {
+ case this.ON_QUIT:
+ message =
+ AppConstants.platform == "macosx"
+ ? "download-ui-confirm-quit-cancel-downloads-mac"
+ : "download-ui-confirm-quit-cancel-downloads";
+ cancelButton = "download-ui-dont-quit-button";
+ break;
+
+ case this.ON_OFFLINE:
+ message = "download-ui-confirm-offline-cancel-downloads";
+ cancelButton = "download-ui-dont-go-offline-button";
+ break;
+
+ case this.ON_LEAVE_PRIVATE_BROWSING:
+ message =
+ "download-ui-confirm-leave-private-browsing-windows-cancel-downloads";
+ cancelButton = "download-ui-dont-leave-private-browsing-button";
+ break;
+ }
+
+ const buttonFlags =
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_1;
+
+ let rv = this._prompter.confirmEx(
+ lazy.l10n.formatValueSync("download-ui-confirm-title"),
+ lazy.l10n.formatValueSync(message, { downloadsCount: aDownloadsCount }),
+ buttonFlags,
+ lazy.l10n.formatValueSync("download-ui-cancel-downloads-ok", {
+ downloadsCount: aDownloadsCount,
+ }),
+ lazy.l10n.formatValueSync(cancelButton),
+ null,
+ null,
+ {}
+ );
+ return rv == 1;
+ },
+};
diff --git a/toolkit/components/downloads/Downloads.sys.mjs b/toolkit/components/downloads/Downloads.sys.mjs
new file mode 100644
index 0000000000..1443a69e55
--- /dev/null
+++ b/toolkit/components/downloads/Downloads.sys.mjs
@@ -0,0 +1,294 @@
+/* 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 entry point to get references to all the back-end objects.
+ */
+
+import { Integration } from "resource://gre/modules/Integration.sys.mjs";
+
+import {
+ Download,
+ DownloadError,
+} from "resource://gre/modules/DownloadCore.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadCombinedList: "resource://gre/modules/DownloadList.sys.mjs",
+ DownloadList: "resource://gre/modules/DownloadList.sys.mjs",
+ DownloadSummary: "resource://gre/modules/DownloadList.sys.mjs",
+});
+
+Integration.downloads.defineESModuleGetter(
+ lazy,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+/**
+ * This object is exposed directly to the consumers of this JavaScript module,
+ * and provides the only entry point to get references to back-end objects.
+ */
+export const Downloads = {
+ /**
+ * Work on downloads that were not started from a private browsing window.
+ */
+ get PUBLIC() {
+ return "{Downloads.PUBLIC}";
+ },
+ /**
+ * Work on downloads that were started from a private browsing window.
+ */
+ get PRIVATE() {
+ return "{Downloads.PRIVATE}";
+ },
+ /**
+ * Work on both Downloads.PRIVATE and Downloads.PUBLIC downloads.
+ */
+ get ALL() {
+ return "{Downloads.ALL}";
+ },
+
+ /**
+ * Creates a new Download object.
+ *
+ * @param properties
+ * Provides the initial properties for the newly created download.
+ * This matches the serializable representation of a Download object.
+ * Some of the most common properties in this object include:
+ * {
+ * source: String containing the URI for the download source.
+ * Alternatively, may be an nsIURI, a DownloadSource object,
+ * 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: String or nsIReferrerInfo object represents the
+ * referrerInfo of the download source. Can be
+ * omitted or null for example when the download
+ * source is not HTTP.
+ * cookieJarSettings: The nsICookieJarSettings object represents
+ * the cookieJarSetting of the download source.
+ * Can be omitted or null if the download source
+ * is not from a document.
+ * },
+ * target: String containing the path of the target file.
+ * Alternatively, may be an nsIFile, a DownloadTarget object,
+ * or an object with the following properties:
+ * {
+ * path: String containing the path of the target file.
+ * },
+ * saver: String representing the class of the download operation.
+ * If omitted, defaults to "copy". Alternatively, may be the
+ * serializable representation of a DownloadSaver object.
+ * }
+ *
+ * @return {Promise}
+ * @resolves The newly created Download object.
+ * @rejects JavaScript exception.
+ */
+ async createDownload(properties) {
+ return Download.fromSerializable(properties);
+ },
+
+ /**
+ * Downloads data from a remote network location to a local file.
+ *
+ * This download method does not provide user interface, or the ability to
+ * cancel or restart the download programmatically. For that, you should
+ * obtain a reference to a Download object using the createDownload function.
+ *
+ * Since the download cannot be restarted, any partially downloaded data will
+ * not be kept in case the download fails.
+ *
+ * @param source
+ * String containing the URI for the download source. Alternatively,
+ * may be an nsIURI or a DownloadSource object.
+ * @param target
+ * String containing the path of the target file. Alternatively, may
+ * be an nsIFile or a DownloadTarget object.
+ * @param options
+ * An optional object used to control the behavior of this function.
+ * You may pass an object with a subset of the following fields:
+ * {
+ * isPrivate: Indicates whether the download originated from a
+ * private window.
+ * }
+ *
+ * @return {Promise}
+ * @resolves When the download has finished successfully.
+ * @rejects JavaScript exception if the download failed.
+ */
+ async fetch(source, target, options) {
+ const download = await this.createDownload({ source, target });
+
+ if (options?.isPrivate) {
+ download.source.isPrivate = options.isPrivate;
+ }
+ return download.start();
+ },
+
+ /**
+ * Retrieves the specified type of DownloadList object. There is one download
+ * list for each type, and this method always retrieves a reference to the
+ * same download list when called with the same argument.
+ *
+ * Calling this function may cause the list of public downloads to be reloaded
+ * from the previous session, if it wasn't loaded already.
+ *
+ * @param type
+ * This can be Downloads.PUBLIC, Downloads.PRIVATE, or Downloads.ALL.
+ * Downloads added to the Downloads.PUBLIC and Downloads.PRIVATE lists
+ * are reflected in the Downloads.ALL list, and downloads added to the
+ * Downloads.ALL list are also added to either the Downloads.PUBLIC or
+ * the Downloads.PRIVATE list based on their properties.
+ *
+ * @return {Promise}
+ * @resolves The requested DownloadList or DownloadCombinedList object.
+ * @rejects JavaScript exception.
+ */
+ async getList(type) {
+ if (!this._promiseListsInitialized) {
+ this._promiseListsInitialized = (async () => {
+ let publicList = new lazy.DownloadList();
+ let privateList = new lazy.DownloadList();
+ let combinedList = new lazy.DownloadCombinedList(
+ publicList,
+ privateList
+ );
+
+ try {
+ await lazy.DownloadIntegration.addListObservers(publicList, false);
+ await lazy.DownloadIntegration.addListObservers(privateList, true);
+ await lazy.DownloadIntegration.initializePublicDownloadList(
+ publicList
+ );
+ } catch (err) {
+ console.error(err);
+ }
+
+ let publicSummary = await this.getSummary(Downloads.PUBLIC);
+ let privateSummary = await this.getSummary(Downloads.PRIVATE);
+ let combinedSummary = await this.getSummary(Downloads.ALL);
+
+ await publicSummary.bindToList(publicList);
+ await privateSummary.bindToList(privateList);
+ await combinedSummary.bindToList(combinedList);
+
+ this._lists[Downloads.PUBLIC] = publicList;
+ this._lists[Downloads.PRIVATE] = privateList;
+ this._lists[Downloads.ALL] = combinedList;
+ })();
+ }
+
+ await this._promiseListsInitialized;
+
+ return this._lists[type];
+ },
+
+ /**
+ * Promise resolved when the initialization of the download lists has
+ * completed, or null if initialization has never been requested.
+ */
+ _promiseListsInitialized: null,
+
+ /**
+ * After initialization, this object is populated with one key for each type
+ * of download list that can be returned (Downloads.PUBLIC, Downloads.PRIVATE,
+ * or Downloads.ALL). The values are the DownloadList objects.
+ */
+ _lists: {},
+
+ /**
+ * Retrieves the specified type of DownloadSummary object. There is one
+ * download summary for each type, and this method always retrieves a
+ * reference to the same download summary when called with the same argument.
+ *
+ * Calling this function does not cause the list of public downloads to be
+ * reloaded from the previous session. The summary will behave as if no
+ * downloads are present until the getList method is called.
+ *
+ * @param type
+ * This can be Downloads.PUBLIC, Downloads.PRIVATE, or Downloads.ALL.
+ *
+ * @return {Promise}
+ * @resolves The requested DownloadList or DownloadCombinedList object.
+ * @rejects JavaScript exception.
+ */
+ async getSummary(type) {
+ if (
+ type != Downloads.PUBLIC &&
+ type != Downloads.PRIVATE &&
+ type != Downloads.ALL
+ ) {
+ throw new Error("Invalid type argument.");
+ }
+
+ if (!(type in this._summaries)) {
+ this._summaries[type] = new lazy.DownloadSummary();
+ }
+
+ return this._summaries[type];
+ },
+
+ /**
+ * This object is populated by the getSummary method with one key for each
+ * type of object that can be returned (Downloads.PUBLIC, Downloads.PRIVATE,
+ * or Downloads.ALL). The values are the DownloadSummary objects.
+ */
+ _summaries: {},
+
+ /**
+ * Returns the system downloads directory asynchronously.
+ * Mac OSX:
+ * User downloads directory
+ * XP/2K:
+ * My Documents/Downloads
+ * Vista and others:
+ * User downloads directory
+ * Linux:
+ * XDG user dir spec, with a fallback to Home/Downloads
+ * Android:
+ * standard downloads directory i.e. /sdcard
+ *
+ * @return {Promise}
+ * @resolves The downloads directory string path.
+ */
+ getSystemDownloadsDirectory() {
+ return lazy.DownloadIntegration.getSystemDownloadsDirectory();
+ },
+
+ /**
+ * Returns the preferred downloads directory based on the user preferences
+ * in the current profile asynchronously.
+ *
+ * @return {Promise}
+ * @resolves The downloads directory string path.
+ */
+ getPreferredDownloadsDirectory() {
+ return lazy.DownloadIntegration.getPreferredDownloadsDirectory();
+ },
+
+ /**
+ * Returns the temporary directory where downloads are placed before the
+ * final location is chosen, or while the document is opened temporarily
+ * with an external application. This may or may not be the system temporary
+ * directory, based on the platform asynchronously.
+ *
+ * @return {Promise}
+ * @resolves The downloads directory string path.
+ */
+ getTemporaryDownloadsDirectory() {
+ return lazy.DownloadIntegration.getTemporaryDownloadsDirectory();
+ },
+
+ /**
+ * Constructor for a DownloadError object. When you catch an exception during
+ * a download, you can use this to verify if "ex instanceof Downloads.Error",
+ * before reading the exception properties with the error details.
+ */
+ Error: DownloadError,
+};
diff --git a/toolkit/components/downloads/components.conf b/toolkit/components/downloads/components.conf
new file mode 100644
index 0000000000..63223bfe75
--- /dev/null
+++ b/toolkit/components/downloads/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{1b4c85df-cbdd-4bb6-b04e-613caece083c}',
+ 'contract_ids': ['@mozilla.org/transfer;1'],
+ 'esModule': 'resource://gre/modules/DownloadLegacy.sys.mjs',
+ 'constructor': 'DownloadLegacyTransfer',
+ },
+]
diff --git a/toolkit/components/downloads/moz.build b/toolkit/components/downloads/moz.build
new file mode 100644
index 0000000000..a6eea0a2d4
--- /dev/null
+++ b/toolkit/components/downloads/moz.build
@@ -0,0 +1,55 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"]
+
+TEST_HARNESS_FILES.xpcshell.toolkit.components.downloads.test.data += [
+ "test/data/empty.txt",
+ "test/data/source.txt",
+]
+
+XPIDL_SOURCES += [
+ "mozIDownloadPlatform.idl",
+]
+
+XPIDL_MODULE = "downloads"
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"]
+
+SOURCES += [
+ "DownloadPlatform.cpp",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ LOCAL_INCLUDES += [
+ "/widget/windows",
+ ]
+
+EXTRA_JS_MODULES += [
+ "DownloadCore.sys.mjs",
+ "DownloadIntegration.sys.mjs",
+ "DownloadLegacy.sys.mjs",
+ "DownloadList.sys.mjs",
+ "DownloadPaths.sys.mjs",
+ "Downloads.sys.mjs",
+ "DownloadStore.sys.mjs",
+ "DownloadUIHelper.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+if CONFIG["MOZ_PLACES"]:
+ EXTRA_JS_MODULES += [
+ "DownloadHistory.sys.mjs",
+ ]
+
+FINAL_LIBRARY = "xul"
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Downloads API")
diff --git a/toolkit/components/downloads/mozIDownloadPlatform.idl b/toolkit/components/downloads/mozIDownloadPlatform.idl
new file mode 100644
index 0000000000..30f1cc1f24
--- /dev/null
+++ b/toolkit/components/downloads/mozIDownloadPlatform.idl
@@ -0,0 +1,62 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIFile;
+
+[scriptable, uuid(9f556e4a-d9b3-46c3-9f8f-d0db1ac6c8c1)]
+interface mozIDownloadPlatform : nsISupports
+{
+ /**
+ * Perform platform specific operations when a download is done.
+ *
+ * Windows:
+ * Add the download to the recent documents list
+ * Set the file to be indexed for searching
+ * Mac:
+ * Bounce the downloads dock icon
+ * GTK:
+ * Add the download to the recent documents list
+ * Save the source uri in the downloaded file's metadata
+ * Android:
+ * Scan media
+ *
+ * @param aSource
+ * Source URI of the download
+ * @param aReferrer
+ * Referrer URI of the download
+ * @param aTarget
+ * Downloaded file
+ * @param aContentType
+ * The source's content type
+ * @param aIsPrivate
+ * True for private downloads
+ * @return Promise that resolves once operations have completed.
+ */
+ [implicit_jscontext]
+ Promise downloadDone(in nsIURI aSource, in nsIURI aReferrer, in nsIFile aTarget,
+ in ACString aContentType, in boolean aIsPrivate);
+
+ /**
+ * Security Zone constants. Used by mapUrlToZone().
+ */
+ const unsigned long ZONE_MY_COMPUTER = 0;
+ const unsigned long ZONE_INTRANET = 1;
+ const unsigned long ZONE_TRUSTED = 2;
+ const unsigned long ZONE_INTERNET = 3;
+ const unsigned long ZONE_RESTRICTED = 4;
+
+ /**
+ * Proxy for IInternetSecurityManager::MapUrlToZone().
+ *
+ * Windows only.
+ *
+ * @param aURL
+ * URI of the download
+ * @return Security Zone corresponding to aURL.
+ */
+ unsigned long mapUrlToZone(in AString aURL);
+};
diff --git a/toolkit/components/downloads/test/data/empty.txt b/toolkit/components/downloads/test/data/empty.txt
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/components/downloads/test/data/empty.txt
diff --git a/toolkit/components/downloads/test/data/source.txt b/toolkit/components/downloads/test/data/source.txt
new file mode 100644
index 0000000000..2156cb8c03
--- /dev/null
+++ b/toolkit/components/downloads/test/data/source.txt
@@ -0,0 +1 @@
+This test string is downloaded. \ No newline at end of file
diff --git a/toolkit/components/downloads/test/unit/common_test_Download.js b/toolkit/components/downloads/test/unit/common_test_Download.js
new file mode 100644
index 0000000000..1360271686
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/common_test_Download.js
@@ -0,0 +1,2753 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This script is loaded by "test_DownloadCore.js" and "test_DownloadLegacy.js"
+ * with different values of the gUseLegacySaver variable, to apply tests to both
+ * the "copy" and "legacy" saver implementations.
+ */
+
+/* import-globals-from head.js */
+/* global gUseLegacySaver */
+
+"use strict";
+
+// Globals
+
+const kDeleteTempFileOnExit = "browser.helperApps.deleteTempFileOnExit";
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+/**
+ * Creates and starts a new download, using either DownloadCopySaver or
+ * DownloadLegacySaver based on the current test run.
+ *
+ * @return {Promise}
+ * @resolves The newly created Download object. The download may be in progress
+ * or already finished. The promiseDownloadStopped function can be
+ * used to wait for completion.
+ * @rejects JavaScript exception.
+ */
+function promiseStartDownload(aSourceUrl) {
+ if (gUseLegacySaver) {
+ return promiseStartLegacyDownload(aSourceUrl);
+ }
+
+ return promiseNewDownload(aSourceUrl).then(download => {
+ download.start().catch(() => {});
+ return download;
+ });
+}
+
+/**
+ * Checks that the actual data written to disk matches the expected data as well
+ * as the properties of the given DownloadTarget object.
+ *
+ * @param downloadTarget
+ * The DownloadTarget object whose details have to be verified.
+ * @param expectedContents
+ * String containing the octets that are expected in the file.
+ *
+ * @return {Promise}
+ * @resolves When the properties have been verified.
+ * @rejects JavaScript exception.
+ */
+var promiseVerifyTarget = async function (downloadTarget, expectedContents) {
+ Assert.ok(downloadTarget.exists);
+ Assert.equal(
+ await expectNonZeroDownloadTargetSize(downloadTarget),
+ expectedContents.length
+ );
+ await promiseVerifyContents(downloadTarget.path, expectedContents);
+};
+
+/**
+ * This is a temporary workaround for frequent intermittent Bug 1760112.
+ * For some reason the download target size is not updated, even if the code
+ * is "apparently" already executing and awaiting for refresh().
+ * TODO(Bug 1814364): Figure out a proper fix for this.
+ */
+async function expectNonZeroDownloadTargetSize(downloadTarget) {
+ todo_check_true(downloadTarget.size, "Size should not be zero.");
+ if (!downloadTarget.size) {
+ await downloadTarget.refresh();
+ }
+ return downloadTarget.size;
+}
+
+/**
+ * Waits for an attempt to launch a file, and returns the nsIMIMEInfo used for
+ * the launch, or null if the file was launched with the default handler.
+ */
+function waitForFileLaunched() {
+ return new Promise(resolve => {
+ let waitFn = base => ({
+ launchFile(file, mimeInfo) {
+ Integration.downloads.unregister(waitFn);
+ if (
+ !mimeInfo ||
+ mimeInfo.preferredAction == Ci.nsIMIMEInfo.useSystemDefault
+ ) {
+ resolve(null);
+ } else {
+ resolve(mimeInfo);
+ }
+ return Promise.resolve();
+ },
+ });
+ Integration.downloads.register(waitFn);
+ });
+}
+
+/**
+ * Waits for an attempt to show the directory where a file is located, and
+ * returns the path of the file.
+ */
+function waitForDirectoryShown() {
+ return new Promise(resolve => {
+ let waitFn = base => ({
+ showContainingDirectory(path) {
+ Integration.downloads.unregister(waitFn);
+ resolve(path);
+ return Promise.resolve();
+ },
+ });
+ Integration.downloads.register(waitFn);
+ });
+}
+
+// Tests
+
+/**
+ * Executes a download and checks its basic properties after construction.
+ * The download is started by constructing the simplest Download object with
+ * the "copy" saver, or using the legacy nsITransfer interface.
+ */
+add_task(async function test_basic() {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can check its basic properties before it starts.
+ download = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt") },
+ target: { path: targetFile.path },
+ saver: { type: "copy" },
+ });
+
+ Assert.equal(download.source.url, httpUrl("source.txt"));
+ Assert.equal(download.target.path, targetFile.path);
+
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, the download is already started when it
+ // is created, thus we must check its basic properties while in progress.
+ download = await promiseStartLegacyDownload(null, { targetFile });
+
+ Assert.equal(download.source.url, httpUrl("source.txt"));
+ Assert.equal(download.target.path, targetFile.path);
+
+ await promiseDownloadStopped(download);
+ }
+
+ // Check additional properties on the finished download.
+ Assert.equal(download.source.referrerInfo, null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+});
+
+/**
+ * Executes a download with the tryToKeepPartialData property set, and ensures
+ * that the file is saved correctly. When testing DownloadLegacySaver, the
+ * download is executed using the nsIExternalHelperAppService component.
+ */
+add_task(async function test_basic_tryToKeepPartialData() {
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ continueResponses();
+ await promiseDownloadStopped(download);
+
+ // The target file should now have been created, and the ".part" file deleted.
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ Assert.equal(32, download.saver.getSha256Hash().length);
+});
+
+/**
+ * Tests that the channelIsForDownload property is set for the network request,
+ * and that the request is marked as throttleable.
+ */
+add_task(async function test_channelIsForDownload_classFlags() {
+ let downloadChannel = null;
+
+ // We use a different method based on whether we are testing legacy downloads.
+ if (!gUseLegacySaver) {
+ let download = await Downloads.createDownload({
+ source: {
+ url: httpUrl("interruptible_resumable.txt"),
+ async adjustChannel(channel) {
+ downloadChannel = channel;
+ },
+ },
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ });
+ await download.start();
+ } else {
+ // Start a download using nsIExternalHelperAppService, but ensure it cannot
+ // finish before we retrieve the "request" property.
+ mustInterruptResponses();
+ let download = await promiseStartExternalHelperAppServiceDownload();
+ downloadChannel = download.saver.request;
+ continueResponses();
+ await promiseDownloadStopped(download);
+ }
+
+ Assert.ok(
+ downloadChannel.QueryInterface(Ci.nsIHttpChannelInternal)
+ .channelIsForDownload
+ );
+
+ // Throttleable is the only class flag assigned to downloads.
+ Assert.equal(
+ downloadChannel.QueryInterface(Ci.nsIClassOfService).classFlags,
+ Ci.nsIClassOfService.Throttleable
+ );
+});
+
+/**
+ * Tests the permissions of the final target file once the download finished.
+ */
+add_task(async function test_unix_permissions() {
+ // This test is only executed on some Desktop systems.
+ if (
+ Services.appinfo.OS != "Darwin" &&
+ Services.appinfo.OS != "Linux" &&
+ Services.appinfo.OS != "WINNT"
+ ) {
+ info("Skipping test.");
+ return;
+ }
+
+ let launcherPath = getTempFile("app-launcher").path;
+
+ for (let autoDelete of [false, true]) {
+ for (let isPrivate of [false, true]) {
+ for (let launchWhenSucceeded of [false, true]) {
+ info(
+ "Checking " +
+ JSON.stringify({ autoDelete, isPrivate, launchWhenSucceeded })
+ );
+
+ Services.prefs.setBoolPref(kDeleteTempFileOnExit, autoDelete);
+
+ let download;
+ if (!gUseLegacySaver) {
+ download = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt"), isPrivate },
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ launchWhenSucceeded,
+ launcherPath,
+ });
+ await download.start();
+ } else {
+ download = await promiseStartLegacyDownload(httpUrl("source.txt"), {
+ isPrivate,
+ launchWhenSucceeded,
+ launcherPath: launchWhenSucceeded && launcherPath,
+ });
+ await promiseDownloadStopped(download);
+ }
+
+ let isTemporary = launchWhenSucceeded && isPrivate;
+ let stat = await IOUtils.stat(download.target.path);
+ if (Services.appinfo.OS == "WINNT") {
+ // On Windows
+ // Temporary downloads should be read-only
+ Assert.equal(stat.permissions, isTemporary ? 0o444 : 0o666);
+ } else {
+ // On Linux, Mac
+ // Temporary downloads should be read-only and not accessible to other
+ // users, while permanently downloaded files should be readable and
+ // writable as specified by the system umask.
+ Assert.equal(
+ stat.permissions,
+ isTemporary ? 0o400 : 0o666 & ~Services.sysinfo.getProperty("umask")
+ );
+ }
+ }
+ }
+ }
+
+ // Clean up the changes to the preference.
+ Services.prefs.clearUserPref(kDeleteTempFileOnExit);
+});
+
+/**
+ * Tests the zone information of the final target once the download finished.
+ */
+add_task(async function test_windows_zoneInformation() {
+ // This test is only executed on Windows, and in order to work correctly it
+ // requires the local user applicaton data directory to be on an NTFS file
+ // system. We use this directory because it is more likely to be on the local
+ // system installation drive, while the temporary directory used by the test
+ // environment is on the same drive as the test sources.
+ if (Services.appinfo.OS != "WINNT") {
+ info("Skipping test.");
+ return;
+ }
+
+ let normalTargetFile = await IOUtils.getFile(
+ Services.dirsvc.get("LocalAppData", Ci.nsIFile).path,
+ "xpcshell-download-test.txt"
+ );
+
+ // The template file name lenght is more than MAX_PATH characters. The final
+ // full path will be shortened to MAX_PATH length by the createUnique call.
+ let longTargetFile = await IOUtils.getFile(
+ Services.dirsvc.get("LocalAppData", Ci.nsIFile).path,
+ "T".repeat(256) + ".txt"
+ );
+ longTargetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ const httpSourceUrl = httpUrl("source.txt");
+ const dataSourceUrl = "data:text/html," + TEST_DATA_SHORT;
+
+ function createReferrerInfo(
+ aReferrer,
+ aRefererPolicy = Ci.nsIReferrerInfo.EMPTY
+ ) {
+ return new ReferrerInfo(aRefererPolicy, true, NetUtil.newURI(aReferrer));
+ }
+
+ const tests = [
+ {
+ expectedZoneId:
+ "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=" + httpSourceUrl + "\r\n",
+ },
+ {
+ targetFile: longTargetFile,
+ expectedZoneId:
+ "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=" + httpSourceUrl + "\r\n",
+ },
+ {
+ sourceUrl: dataSourceUrl,
+ expectedZoneId:
+ "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=about:internet\r\n",
+ },
+ {
+ options: {
+ referrerInfo: createReferrerInfo(
+ TEST_REFERRER_URL,
+ Ci.nsIReferrerInfo.UNSAFE_URL
+ ),
+ },
+ expectedZoneId:
+ "[ZoneTransfer]\r\nZoneId=3\r\n" +
+ "ReferrerUrl=" +
+ TEST_REFERRER_URL +
+ "\r\n" +
+ "HostUrl=" +
+ httpSourceUrl +
+ "\r\n",
+ },
+ {
+ options: { referrerInfo: createReferrerInfo(dataSourceUrl) },
+ expectedZoneId:
+ "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=" + httpSourceUrl + "\r\n",
+ },
+ {
+ options: {
+ referrerInfo: createReferrerInfo("http://example.com/a\rb\nc"),
+ },
+ expectedZoneId:
+ "[ZoneTransfer]\r\nZoneId=3\r\n" +
+ "ReferrerUrl=http://example.com/\r\n" +
+ "HostUrl=" +
+ httpSourceUrl +
+ "\r\n",
+ },
+ {
+ options: { isPrivate: true },
+ expectedZoneId: "[ZoneTransfer]\r\nZoneId=3\r\n",
+ },
+ {
+ options: {
+ referrerInfo: createReferrerInfo(TEST_REFERRER_URL),
+ isPrivate: true,
+ },
+ expectedZoneId: "[ZoneTransfer]\r\nZoneId=3\r\n",
+ },
+ ];
+ for (const test of tests) {
+ const sourceUrl = test.sourceUrl || httpSourceUrl;
+ const targetFile = test.targetFile || normalTargetFile;
+ info(targetFile.path);
+ try {
+ if (!gUseLegacySaver) {
+ let download = await Downloads.createDownload({
+ source: test.options
+ ? Object.assign({ url: sourceUrl }, test.options)
+ : sourceUrl,
+ target: targetFile.path,
+ });
+ await download.start();
+ } else {
+ let download = await promiseStartLegacyDownload(
+ sourceUrl,
+ Object.assign({ targetFile }, test.options || {})
+ );
+ await promiseDownloadStopped(download);
+ }
+ await promiseVerifyContents(targetFile.path, TEST_DATA_SHORT);
+
+ let path = targetFile.path + ":Zone.Identifier";
+ if (Services.appinfo.OS === "WINNT") {
+ path = PathUtils.toExtendedWindowsPath(path);
+ }
+
+ Assert.equal(await IOUtils.readUTF8(path), test.expectedZoneId);
+ } finally {
+ await IOUtils.remove(targetFile.path);
+ }
+ }
+});
+
+/**
+ * Checks the referrer for downloads.
+ */
+add_task(async function test_referrer() {
+ let sourcePath = "/test_referrer.txt";
+ let sourceUrl = httpUrl("test_referrer.txt");
+ let dataSourceUrl = "data:text/html,<html><body></body></html>";
+
+ function cleanup() {
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+
+ Assert.ok(aRequest.hasHeader("Referer"));
+ Assert.equal(aRequest.getHeader("Referer"), TEST_REFERRER_URL);
+ });
+ let download;
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.UNSAFE_URL,
+ true,
+ NetUtil.newURI(TEST_REFERRER_URL)
+ );
+
+ if (!gUseLegacySaver) {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ let targetPath = targetFile.path;
+
+ download = await Downloads.createDownload({
+ source: { url: sourceUrl, referrerInfo },
+ target: targetPath,
+ });
+
+ Assert.ok(download.source.referrerInfo.equals(referrerInfo));
+ await download.start();
+
+ download = await Downloads.createDownload({
+ source: { url: sourceUrl, referrerInfo, isPrivate: true },
+ target: targetPath,
+ });
+ Assert.ok(download.source.referrerInfo.equals(referrerInfo));
+ await download.start();
+
+ // Test the download still works for non-HTTP channel with referrer.
+ download = await Downloads.createDownload({
+ source: { url: dataSourceUrl, referrerInfo },
+ target: targetPath,
+ });
+ Assert.ok(download.source.referrerInfo.equals(referrerInfo));
+ await download.start();
+ } else {
+ download = await promiseStartLegacyDownload(sourceUrl, {
+ referrerInfo,
+ });
+ await promiseDownloadStopped(download);
+ checkEqualReferrerInfos(download.source.referrerInfo, referrerInfo);
+
+ download = await promiseStartLegacyDownload(sourceUrl, {
+ referrerInfo,
+ isPrivate: true,
+ });
+ await promiseDownloadStopped(download);
+ checkEqualReferrerInfos(download.source.referrerInfo, referrerInfo);
+
+ download = await promiseStartLegacyDownload(dataSourceUrl, {
+ referrerInfo,
+ });
+ await promiseDownloadStopped(download);
+ Assert.equal(download.source.referrerInfo, null);
+ }
+
+ cleanup();
+});
+
+/**
+ * Checks the adjustChannel callback for downloads.
+ */
+add_task(async function test_adjustChannel() {
+ const sourcePath = "/test_post.txt";
+ const sourceUrl = httpUrl("test_post.txt");
+ const targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ const customHeader = { name: "X-Answer", value: "42" };
+ const postData = "Don't Panic";
+
+ function cleanup() {
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ gHttpServer.registerPathHandler(sourcePath, aRequest => {
+ Assert.equal(aRequest.method, "POST");
+
+ Assert.ok(aRequest.hasHeader(customHeader.name));
+ Assert.equal(aRequest.getHeader(customHeader.name), customHeader.value);
+
+ const stream = aRequest.bodyInputStream;
+ const body = NetUtil.readInputStreamToString(stream, stream.available());
+ Assert.equal(body, postData);
+ });
+
+ function adjustChannel(channel) {
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ channel.setRequestHeader(customHeader.name, customHeader.value, false);
+
+ const stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData(postData, postData.length);
+
+ channel.QueryInterface(Ci.nsIUploadChannel2);
+ channel.explicitSetUploadStream(stream, null, -1, "POST", false);
+
+ return Promise.resolve();
+ }
+
+ const download = await Downloads.createDownload({
+ source: { url: sourceUrl, adjustChannel },
+ target: targetPath,
+ });
+ Assert.equal(download.source.adjustChannel, adjustChannel);
+ Assert.equal(download.toSerializable(), null);
+ await download.start();
+
+ cleanup();
+});
+
+/**
+ * Checks initial and final state and progress for a successful download.
+ */
+add_task(async function test_initial_final_state() {
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can check its state before it starts.
+ download = await promiseNewDownload();
+
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+ Assert.equal(download.progress, 0);
+ Assert.ok(download.startTime === null);
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, the download is already started when it
+ // is created, thus we cannot check its initial state.
+ download = await promiseStartLegacyDownload();
+ await promiseDownloadStopped(download);
+ }
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+ Assert.equal(download.progress, 100);
+ Assert.ok(isValidDate(download.startTime));
+ Assert.ok(download.target.exists);
+ Assert.equal(
+ await expectNonZeroDownloadTargetSize(download.target),
+ TEST_DATA_SHORT.length
+ );
+});
+
+/**
+ * Checks the notification of the final download state.
+ */
+add_task(async function test_final_state_notified() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+
+ let onchangeNotified = false;
+ let lastNotifiedStopped;
+ let lastNotifiedProgress;
+ download.onchange = function () {
+ onchangeNotified = true;
+ lastNotifiedStopped = download.stopped;
+ lastNotifiedProgress = download.progress;
+ };
+
+ // Allow the download to complete.
+ let promiseAttempt = download.start();
+ continueResponses();
+ await promiseAttempt;
+
+ // The view should have been notified before the download completes.
+ Assert.ok(onchangeNotified);
+ Assert.ok(lastNotifiedStopped);
+ Assert.equal(lastNotifiedProgress, 100);
+});
+
+/**
+ * Checks intermediate progress for a successful download.
+ */
+add_task(async function test_intermediate_progress() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+
+ await promiseDownloadMidway(download);
+
+ Assert.ok(download.hasProgress);
+ Assert.equal(download.currentBytes, TEST_DATA_SHORT.length);
+ Assert.equal(download.totalBytes, TEST_DATA_SHORT.length * 2);
+
+ // The final file size should not be computed for in-progress downloads.
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ // Continue after the first chunk of data is fully received.
+ continueResponses();
+ await promiseDownloadStopped(download);
+
+ Assert.ok(download.stopped);
+ Assert.equal(download.progress, 100);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+});
+
+/**
+ * Downloads a file with a "Content-Length" of 0 and checks the progress.
+ */
+add_task(async function test_empty_progress() {
+ let download = await promiseStartDownload(httpUrl("empty.txt"));
+ await promiseDownloadStopped(download);
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.hasProgress);
+ Assert.equal(download.progress, 100);
+ Assert.equal(download.currentBytes, 0);
+ Assert.equal(download.totalBytes, 0);
+
+ // We should have received the content type even for an empty file.
+ Assert.equal(download.contentType, "text/plain");
+
+ Assert.equal((await IOUtils.stat(download.target.path)).size, 0);
+ Assert.ok(download.target.exists);
+ Assert.equal(download.target.size, 0);
+});
+
+/**
+ * Downloads a file with a "Content-Length" of 0 with the tryToKeepPartialData
+ * property set, and ensures that the file is saved correctly.
+ */
+add_task(async function test_empty_progress_tryToKeepPartialData() {
+ // Start a new download and configure it to keep partially downloaded data.
+ let download;
+ if (!gUseLegacySaver) {
+ let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ download = await Downloads.createDownload({
+ source: httpUrl("empty.txt"),
+ target: { path: targetFilePath, partFilePath: targetFilePath + ".part" },
+ });
+ download.tryToKeepPartialData = true;
+ download.start().catch(() => {});
+ } else {
+ // Start a download using nsIExternalHelperAppService, that is configured
+ // to keep partially downloaded data by default.
+ download = await promiseStartExternalHelperAppServiceDownload(
+ httpUrl("empty.txt")
+ );
+ }
+ await promiseDownloadStopped(download);
+
+ // The target file should now have been created, and the ".part" file deleted.
+ Assert.equal((await IOUtils.stat(download.target.path)).size, 0);
+ Assert.ok(download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ Assert.equal(32, download.saver.getSha256Hash().length);
+});
+
+/**
+ * Downloads an empty file with no "Content-Length" and checks the progress.
+ */
+add_task(async function test_empty_noprogress() {
+ let sourcePath = "/test_empty_noprogress.txt";
+ let sourceUrl = httpUrl("test_empty_noprogress.txt");
+ let deferRequestReceived = Promise.withResolvers();
+
+ // Register an interruptible handler that notifies us when the request occurs.
+ function cleanup() {
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ registerInterruptibleHandler(
+ sourcePath,
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ deferRequestReceived.resolve();
+ },
+ function secondPart(aRequest, aResponse) {}
+ );
+
+ // Start the download, without allowing the request to finish.
+ mustInterruptResponses();
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can hook its onchange callback that will be notified when the
+ // download starts.
+ download = await promiseNewDownload(sourceUrl);
+
+ download.onchange = function () {
+ if (!download.stopped) {
+ Assert.ok(!download.hasProgress);
+ Assert.equal(download.currentBytes, 0);
+ Assert.equal(download.totalBytes, 0);
+ }
+ };
+
+ download.start().catch(() => {});
+ } else {
+ // When testing DownloadLegacySaver, the download is already started when it
+ // is created, and it may have already made all needed property change
+ // notifications, thus there is no point in checking the onchange callback.
+ download = await promiseStartLegacyDownload(sourceUrl);
+ }
+
+ // Wait for the request to be received by the HTTP server, but don't allow the
+ // request to finish yet. Before checking the download state, wait for the
+ // events to be processed by the client.
+ await deferRequestReceived.promise;
+ await promiseExecuteSoon();
+
+ // Check that this download has no progress report.
+ Assert.ok(!download.stopped);
+ Assert.ok(!download.hasProgress);
+ Assert.equal(download.currentBytes, 0);
+ Assert.equal(download.totalBytes, 0);
+
+ // Now allow the response to finish.
+ continueResponses();
+ await promiseDownloadStopped(download);
+
+ // We should have received the content type even if no progress is reported.
+ Assert.equal(download.contentType, "text/plain");
+
+ // Verify the state of the completed download.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.hasProgress);
+ Assert.equal(download.progress, 100);
+ Assert.equal(download.currentBytes, 0);
+ Assert.equal(download.totalBytes, 0);
+ Assert.ok(download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ Assert.equal((await IOUtils.stat(download.target.path)).size, 0);
+});
+
+/**
+ * Calls the "start" method two times before the download is finished.
+ */
+add_task(async function test_start_twice() {
+ mustInterruptResponses();
+
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can start the download later during the test.
+ download = await promiseNewDownload(httpUrl("interruptible.txt"));
+ } else {
+ // When testing DownloadLegacySaver, the download is already started when it
+ // is created. Effectively, we are starting the download three times.
+ download = await promiseStartLegacyDownload(httpUrl("interruptible.txt"));
+ }
+
+ // Call the start method two times.
+ let promiseAttempt1 = download.start();
+ let promiseAttempt2 = download.start();
+
+ // Allow the download to finish.
+ continueResponses();
+
+ // Both promises should now be resolved.
+ await promiseAttempt1;
+ await promiseAttempt2;
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+});
+
+/**
+ * Cancels a download and verifies that its state is reported correctly.
+ */
+add_task(async function test_cancel_midway() {
+ mustInterruptResponses();
+
+ // In this test case, we execute different checks that are only possible with
+ // DownloadCopySaver or DownloadLegacySaver respectively.
+ let download;
+ let options = {};
+ if (!gUseLegacySaver) {
+ download = await promiseNewDownload(httpUrl("interruptible.txt"));
+ } else {
+ download = await promiseStartLegacyDownload(
+ httpUrl("interruptible.txt"),
+ options
+ );
+ }
+
+ // Cancel the download after receiving the first part of the response.
+ let deferCancel = Promise.withResolvers();
+ let onchange = function () {
+ if (!download.stopped && !download.canceled && download.progress == 50) {
+ // Cancel the download immediately during the notification.
+ deferCancel.resolve(download.cancel());
+
+ // The state change happens immediately after calling "cancel", but
+ // temporary files or part files may still exist at this point.
+ Assert.ok(download.canceled);
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress. This may happen
+ // when using DownloadLegacySaver.
+ download.onchange = onchange;
+ onchange();
+
+ let promiseAttempt;
+ if (!gUseLegacySaver) {
+ promiseAttempt = download.start();
+ }
+
+ // Wait on the promise returned by the "cancel" method to ensure that the
+ // cancellation process finished and temporary files were removed.
+ await deferCancel.promise;
+
+ if (gUseLegacySaver) {
+ // The nsIWebBrowserPersist instance should have been canceled now.
+ Assert.equal(options.outPersist.result, Cr.NS_ERROR_ABORT);
+ }
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.canceled);
+ Assert.ok(download.error === null);
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+
+ // Progress properties are not reset by canceling.
+ Assert.equal(download.progress, 50);
+ Assert.equal(download.totalBytes, TEST_DATA_SHORT.length * 2);
+ Assert.equal(download.currentBytes, TEST_DATA_SHORT.length);
+
+ if (!gUseLegacySaver) {
+ // The promise returned by "start" should have been rejected meanwhile.
+ try {
+ await promiseAttempt;
+ do_throw("The download should have been canceled.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ Assert.ok(!ex.becauseSourceFailed);
+ Assert.ok(!ex.becauseTargetFailed);
+ }
+ }
+});
+
+/**
+ * Cancels a download while keeping partially downloaded data, and verifies that
+ * both the target file and the ".part" file are deleted.
+ */
+add_task(async function test_cancel_midway_tryToKeepPartialData() {
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+
+ Assert.ok(await IOUtils.exists(download.target.path));
+ Assert.ok(await IOUtils.exists(download.target.partFilePath));
+
+ await download.cancel();
+ await download.removePartialData();
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.canceled);
+ Assert.ok(download.error === null);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+});
+
+/**
+ * Cancels a download right after starting it.
+ */
+add_task(async function test_cancel_immediately() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+
+ let promiseAttempt = download.start();
+ Assert.ok(!download.stopped);
+
+ let promiseCancel = download.cancel();
+ Assert.ok(download.canceled);
+
+ // At this point, we don't know whether the download has already stopped or
+ // is still waiting for cancellation. We can wait on the promise returned
+ // by the "start" method to know for sure.
+ try {
+ await promiseAttempt;
+ do_throw("The download should have been canceled.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ Assert.ok(!ex.becauseSourceFailed);
+ Assert.ok(!ex.becauseTargetFailed);
+ }
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.canceled);
+ Assert.ok(download.error === null);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+
+ // Check that the promise returned by the "cancel" method has been resolved.
+ await promiseCancel;
+});
+
+/**
+ * Cancels and restarts a download sequentially.
+ */
+add_task(async function test_cancel_midway_restart() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+
+ // The first time, cancel the download midway.
+ await promiseDownloadMidway(download);
+ await download.cancel();
+
+ Assert.ok(download.stopped);
+
+ // The second time, we'll provide the entire interruptible response.
+ continueResponses();
+ download.onchange = null;
+ let promiseAttempt = download.start();
+
+ // Download state should have already been reset.
+ Assert.ok(!download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ // For the following test, we rely on the network layer reporting its progress
+ // asynchronously. Otherwise, there is nothing stopping the restarted
+ // download from reaching the same progress as the first request already.
+ Assert.equal(download.progress, 0);
+ Assert.equal(download.totalBytes, 0);
+ Assert.equal(download.currentBytes, 0);
+
+ await promiseAttempt;
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+});
+
+/**
+ * Cancels a download and restarts it from where it stopped.
+ */
+add_task(async function test_cancel_midway_restart_tryToKeepPartialData() {
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ await download.cancel();
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.hasPartialData);
+
+ // We should have kept the partial data and an empty target file placeholder.
+ Assert.ok(await IOUtils.exists(download.target.path));
+ await promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT);
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ // Verify that the server sent the response from the start.
+ Assert.equal(gMostRecentFirstBytePos, 0);
+
+ // The second time, we'll request and obtain the second part of the response,
+ // but we still stop when half of the remaining progress is reached.
+ let deferMidway = Promise.withResolvers();
+ download.onchange = function () {
+ if (
+ !download.stopped &&
+ !download.canceled &&
+ download.currentBytes == Math.floor((TEST_DATA_SHORT.length * 3) / 2)
+ ) {
+ download.onchange = null;
+ deferMidway.resolve();
+ }
+ };
+
+ mustInterruptResponses();
+ let promiseAttempt = download.start();
+
+ // Continue when the number of bytes we received is correct, then check that
+ // progress is at about 75 percent. The exact figure may vary because of
+ // rounding issues, since the total number of bytes in the response might not
+ // be a multiple of four.
+ await deferMidway.promise;
+ Assert.ok(download.progress > 72 && download.progress < 78);
+
+ // Now we allow the download to finish.
+ continueResponses();
+ await promiseAttempt;
+
+ // Check that the server now sent the second part only.
+ Assert.equal(gMostRecentFirstBytePos, TEST_DATA_SHORT.length);
+
+ // The target file should now have been created, and the ".part" file deleted.
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+});
+
+/**
+ * Cancels a download while keeping partially downloaded data, then removes the
+ * data and restarts the download from the beginning.
+ */
+add_task(async function test_cancel_midway_restart_removePartialData() {
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ await download.cancel();
+ await download.removePartialData();
+
+ Assert.ok(!download.hasPartialData);
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ // The second time, we'll request and obtain the entire response again.
+ continueResponses();
+ await download.start();
+
+ // Verify that the server sent the response from the start.
+ Assert.equal(gMostRecentFirstBytePos, 0);
+
+ // The target file should now have been created, and the ".part" file deleted.
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+});
+
+/**
+ * Cancels a download while keeping partially downloaded data, then removes the
+ * data and restarts the download from the beginning without keeping the partial
+ * data anymore.
+ */
+add_task(
+ async function test_cancel_midway_restart_tryToKeepPartialData_false() {
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ await download.cancel();
+
+ download.tryToKeepPartialData = false;
+
+ // The above property change does not affect existing partial data.
+ Assert.ok(download.hasPartialData);
+ await promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT);
+
+ await download.removePartialData();
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+
+ // Restart the download from the beginning.
+ mustInterruptResponses();
+ download.start().catch(() => {});
+
+ await promiseDownloadMidway(download);
+ await promisePartFileReady(download);
+
+ // While the download is in progress, we should still have a ".part" file.
+ Assert.ok(!download.hasPartialData);
+ Assert.ok(await IOUtils.exists(download.target.path));
+ Assert.ok(await IOUtils.exists(download.target.partFilePath));
+
+ // On Unix, verify that the file with the partially downloaded data is not
+ // accessible by other users on the system.
+ if (Services.appinfo.OS == "Darwin" || Services.appinfo.OS == "Linux") {
+ Assert.equal(
+ (await IOUtils.stat(download.target.partFilePath)).permissions,
+ 0o600
+ );
+ }
+
+ await download.cancel();
+
+ // The ".part" file should be deleted now that the download is canceled.
+ Assert.ok(!download.hasPartialData);
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+
+ // The third time, we'll request and obtain the entire response again.
+ continueResponses();
+ await download.start();
+
+ // Verify that the server sent the response from the start.
+ Assert.equal(gMostRecentFirstBytePos, 0);
+
+ // The target file should now have been created, and the ".part" file deleted.
+ await promiseVerifyTarget(
+ download.target,
+ TEST_DATA_SHORT + TEST_DATA_SHORT
+ );
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ }
+);
+
+/**
+ * Cancels a download right after starting it, then restarts it immediately.
+ */
+add_task(async function test_cancel_immediately_restart_immediately() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+ let promiseAttempt = download.start();
+
+ Assert.ok(!download.stopped);
+
+ download.cancel();
+ Assert.ok(download.canceled);
+
+ let promiseRestarted = download.start();
+ Assert.ok(!download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ // For the following test, we rely on the network layer reporting its progress
+ // asynchronously. Otherwise, there is nothing stopping the restarted
+ // download from reaching the same progress as the first request already.
+ Assert.equal(download.hasProgress, false);
+ Assert.equal(download.progress, 0);
+ Assert.equal(download.totalBytes, 0);
+ Assert.equal(download.currentBytes, 0);
+
+ // Ensure the next request is now allowed to complete, regardless of whether
+ // the canceled request was received by the server or not.
+ continueResponses();
+ try {
+ await promiseAttempt;
+ // If we get here, it means that the first attempt actually succeeded. In
+ // fact, this could be a valid outcome, because the cancellation request may
+ // not have been processed in time before the download finished.
+ info("The download should have been canceled.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ Assert.ok(!ex.becauseSourceFailed);
+ Assert.ok(!ex.becauseTargetFailed);
+ }
+
+ await promiseRestarted;
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+});
+
+/**
+ * Cancels a download midway, then restarts it immediately.
+ */
+add_task(async function test_cancel_midway_restart_immediately() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+ let promiseAttempt = download.start();
+
+ // The first time, cancel the download midway.
+ await promiseDownloadMidway(download);
+ download.cancel();
+ Assert.ok(download.canceled);
+
+ let promiseRestarted = download.start();
+ Assert.ok(!download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ // For the following test, we rely on the network layer reporting its progress
+ // asynchronously. Otherwise, there is nothing stopping the restarted
+ // download from reaching the same progress as the first request already.
+ Assert.equal(download.hasProgress, false);
+ Assert.equal(download.progress, 0);
+ Assert.equal(download.totalBytes, 0);
+ Assert.equal(download.currentBytes, 0);
+
+ // The second request is allowed to complete.
+ continueResponses();
+ try {
+ await promiseAttempt;
+ do_throw("The download should have been canceled.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ Assert.ok(!ex.becauseSourceFailed);
+ Assert.ok(!ex.becauseTargetFailed);
+ }
+
+ await promiseRestarted;
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+});
+
+/**
+ * Calls the "cancel" method on a successful download.
+ */
+add_task(async function test_cancel_successful() {
+ let download = await promiseStartDownload();
+ await promiseDownloadStopped(download);
+
+ // The cancel method should succeed with no effect.
+ await download.cancel();
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+});
+
+/**
+ * Calls the "cancel" method two times in a row.
+ */
+add_task(async function test_cancel_twice() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+
+ let promiseAttempt = download.start();
+ Assert.ok(!download.stopped);
+
+ let promiseCancel1 = download.cancel();
+ Assert.ok(download.canceled);
+ let promiseCancel2 = download.cancel();
+
+ try {
+ await promiseAttempt;
+ do_throw("The download should have been canceled.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ Assert.ok(!ex.becauseSourceFailed);
+ Assert.ok(!ex.becauseTargetFailed);
+ }
+
+ // Both promises should now be resolved.
+ await promiseCancel1;
+ await promiseCancel2;
+
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(download.canceled);
+ Assert.ok(download.error === null);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+});
+
+/**
+ * Checks the "refresh" method for succeeded downloads.
+ */
+add_task(async function test_refresh_succeeded() {
+ let download = await promiseStartDownload();
+ await promiseDownloadStopped(download);
+
+ // The DownloadTarget properties should be the same after calling "refresh".
+ await download.refresh();
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+
+ // If the file is removed, only the "exists" property should change, and the
+ // "size" property should keep its previous value.
+ await IOUtils.move(download.target.path, `${download.target.path}.old`);
+ await download.refresh();
+ Assert.ok(!download.target.exists);
+ Assert.equal(
+ await expectNonZeroDownloadTargetSize(download.target),
+ TEST_DATA_SHORT.length
+ );
+
+ // The DownloadTarget properties should be restored when the file is put back.
+ await IOUtils.move(`${download.target.path}.old`, download.target.path);
+ await download.refresh();
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+});
+
+/**
+ * Checks that a download cannot be restarted after the "finalize" method.
+ */
+add_task(async function test_finalize() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible.txt"));
+
+ let promiseFinalized = download.finalize();
+
+ try {
+ await download.start();
+ do_throw("It should not be possible to restart after finalization.");
+ } catch (ex) {}
+
+ await promiseFinalized;
+
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(download.canceled);
+ Assert.ok(download.error === null);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+});
+
+/**
+ * Checks that the "finalize" method can remove partially downloaded data.
+ */
+add_task(async function test_finalize_tryToKeepPartialData() {
+ // Check finalization without removing partial data.
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ await download.finalize();
+
+ Assert.ok(download.hasPartialData);
+ Assert.ok(await IOUtils.exists(download.target.path));
+ Assert.ok(await IOUtils.exists(download.target.partFilePath));
+
+ // Clean up.
+ await download.removePartialData();
+
+ // Check finalization while removing partial data.
+ download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ await download.finalize(true);
+
+ Assert.ok(!download.hasPartialData);
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+});
+
+/**
+ * Checks that whenSucceeded returns a promise that is resolved after a restart.
+ */
+add_task(async function test_whenSucceeded_after_restart() {
+ mustInterruptResponses();
+
+ let promiseSucceeded;
+
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can verify getting a reference before the first download attempt.
+ download = await promiseNewDownload(httpUrl("interruptible.txt"));
+ promiseSucceeded = download.whenSucceeded();
+ download.start().catch(() => {});
+ } else {
+ // When testing DownloadLegacySaver, the download is already started when it
+ // is created, thus we cannot get the reference before the first attempt.
+ download = await promiseStartLegacyDownload(httpUrl("interruptible.txt"));
+ promiseSucceeded = download.whenSucceeded();
+ }
+
+ // Cancel the first download attempt.
+ await download.cancel();
+
+ // The second request is allowed to complete.
+ continueResponses();
+ download.start().catch(() => {});
+
+ // Wait for the download to finish by waiting on the whenSucceeded promise.
+ await promiseSucceeded;
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+});
+
+/**
+ * Ensures download error details are reported on network failures.
+ */
+add_task(async function test_error_source() {
+ let serverSocket = startFakeServer();
+ try {
+ let sourceUrl = "http://localhost:" + serverSocket.port + "/source.txt";
+
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we want to check that the promise
+ // returned by the "start" method is rejected.
+ download = await promiseNewDownload(sourceUrl);
+
+ Assert.ok(download.error === null);
+
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, we cannot be sure whether we are
+ // testing the promise returned by the "start" method or we are testing
+ // the "error" property checked by promiseDownloadStopped. This happens
+ // because we don't have control over when the download is started.
+ download = await promiseStartLegacyDownload(sourceUrl);
+ await promiseDownloadStopped(download);
+ }
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) {
+ throw ex;
+ }
+ // A specific error object is thrown when reading from the source fails.
+ }
+
+ // Check the properties now that the download stopped.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error !== null);
+ Assert.ok(download.error.becauseSourceFailed);
+ Assert.ok(!download.error.becauseTargetFailed);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+ } finally {
+ serverSocket.close();
+ }
+});
+
+/**
+ * Ensures a download error is reported when receiving less bytes than what was
+ * specified in the Content-Length header.
+ */
+add_task(async function test_error_source_partial() {
+ let sourceUrl = httpUrl("shorter-than-content-length-http-1-1.txt");
+
+ let enforcePref = Services.prefs.getBoolPref(
+ "network.http.enforce-framing.http1"
+ );
+ Services.prefs.setBoolPref("network.http.enforce-framing.http1", true);
+
+ function cleanup() {
+ Services.prefs.setBoolPref(
+ "network.http.enforce-framing.http1",
+ enforcePref
+ );
+ }
+ registerCleanupFunction(cleanup);
+
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we want to check that the promise
+ // returned by the "start" method is rejected.
+ download = await promiseNewDownload(sourceUrl);
+
+ Assert.ok(download.error === null);
+
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, we cannot be sure whether we are
+ // testing the promise returned by the "start" method or we are testing
+ // the "error" property checked by promiseDownloadStopped. This happens
+ // because we don't have control over when the download is started.
+ download = await promiseStartLegacyDownload(sourceUrl);
+ await promiseDownloadStopped(download);
+ }
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) {
+ throw ex;
+ }
+ // A specific error object is thrown when reading from the source fails.
+ }
+
+ // Check the properties now that the download stopped.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error !== null);
+ Assert.ok(download.error.becauseSourceFailed);
+ Assert.ok(!download.error.becauseTargetFailed);
+ Assert.equal(download.error.result, Cr.NS_ERROR_NET_PARTIAL_TRANSFER);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+});
+
+/**
+ * Ensures a download error is reported when an RST packet is received.
+ */
+add_task(async function test_error_source_netreset() {
+ if (AppConstants.platform == "win") {
+ return;
+ }
+
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we want to check that the promise
+ // returned by the "start" method is rejected.
+ download = await promiseNewDownload(httpUrl("netreset.txt"));
+
+ Assert.ok(download.error === null);
+
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, we cannot be sure whether we are
+ // testing the promise returned by the "start" method or we are testing
+ // the "error" property checked by promiseDownloadStopped. This happens
+ // because we don't have control over when the download is started.
+ download = await promiseStartLegacyDownload(httpUrl("netreset.txt"));
+ await promiseDownloadStopped(download);
+ }
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) {
+ throw ex;
+ }
+ // A specific error object is thrown when reading from the source fails.
+ }
+
+ // Check the properties now that the download stopped.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error !== null);
+ Assert.ok(download.error.becauseSourceFailed);
+ Assert.ok(!download.error.becauseTargetFailed);
+ Assert.equal(download.error.result, Cr.NS_ERROR_NET_RESET);
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+});
+
+/**
+ * Ensures download error details are reported on local writing failures.
+ */
+add_task(async function test_error_target() {
+ // Create a file without write access permissions before downloading.
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0);
+ try {
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we want to check that the promise
+ // returned by the "start" method is rejected.
+ download = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: targetFile,
+ });
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, we cannot be sure whether we are
+ // testing the promise returned by the "start" method or we are testing
+ // the "error" property checked by promiseDownloadStopped. This happens
+ // because we don't have control over when the download is started.
+ download = await promiseStartLegacyDownload(null, { targetFile });
+ await promiseDownloadStopped(download);
+ }
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) {
+ throw ex;
+ }
+ // A specific error object is thrown when writing to the target fails.
+ }
+
+ // Check the properties now that the download stopped.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error !== null);
+ Assert.ok(download.error.becauseTargetFailed);
+ Assert.ok(!download.error.becauseSourceFailed);
+
+ // Check unserializing a download with an errorObj and restarting it will
+ // clear the errorObj initially.
+ let serializable = download.toSerializable();
+ Assert.ok(serializable.errorObj, "Ensure we have an errorObj initially");
+ let reserialized = JSON.parse(JSON.stringify(serializable));
+ download = await Downloads.createDownload(reserialized);
+ let promise = download.start().catch(() => {});
+ serializable = download.toSerializable();
+ Assert.ok(!serializable.errorObj, "Ensure we didn't persist the errorObj");
+ await promise;
+ } finally {
+ // Restore the default permissions to allow deleting the file on Windows.
+ if (targetFile.exists()) {
+ targetFile.permissions = FileUtils.PERMS_FILE;
+ targetFile.remove(false);
+ }
+ }
+});
+
+/**
+ * Restarts a failed download.
+ */
+add_task(async function test_error_restart() {
+ let download;
+
+ // Create a file without write access permissions before downloading.
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0);
+ try {
+ // Use DownloadCopySaver or DownloadLegacySaver based on the test run,
+ // specifying the target file we created.
+ if (!gUseLegacySaver) {
+ download = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: targetFile,
+ });
+ download.start().catch(() => {});
+ } else {
+ download = await promiseStartLegacyDownload(null, { targetFile });
+ }
+ await promiseDownloadStopped(download);
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) {
+ throw ex;
+ }
+ // A specific error object is thrown when writing to the target fails.
+ } finally {
+ // Restore the default permissions to allow deleting the file on Windows.
+ if (targetFile.exists()) {
+ targetFile.permissions = FileUtils.PERMS_FILE;
+
+ // Also for Windows, rename the file before deleting. This makes the
+ // current file name available immediately for a new file, while deleting
+ // in place prevents creation of a file with the same name for some time.
+ targetFile.moveTo(null, targetFile.leafName + ".delete.tmp");
+ targetFile.remove(false);
+ }
+ }
+
+ // Restart the download and wait for completion.
+ await download.start();
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+ Assert.ok(download.error === null);
+ Assert.equal(download.progress, 100);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+});
+
+/**
+ * Executes download in both public and private modes.
+ */
+add_task(async function test_public_and_private() {
+ let sourcePath = "/test_public_and_private.txt";
+ let sourceUrl = httpUrl("test_public_and_private.txt");
+ let testCount = 0;
+
+ // Apply pref to allow all cookies.
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 0);
+
+ function cleanup() {
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+ Services.cookies.removeAll();
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+
+ if (testCount == 0) {
+ // No cookies should exist for first public download.
+ Assert.ok(!aRequest.hasHeader("Cookie"));
+ aResponse.setHeader("Set-Cookie", "foobar=1", false);
+ testCount++;
+ } else if (testCount == 1) {
+ // The cookie should exists for second public download.
+ Assert.ok(aRequest.hasHeader("Cookie"));
+ Assert.equal(aRequest.getHeader("Cookie"), "foobar=1");
+ testCount++;
+ } else if (testCount == 2) {
+ // No cookies should exist for first private download.
+ Assert.ok(!aRequest.hasHeader("Cookie"));
+ }
+ });
+
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ await Downloads.fetch(sourceUrl, targetFile);
+ await Downloads.fetch(sourceUrl, targetFile);
+
+ if (!gUseLegacySaver) {
+ let download = await Downloads.createDownload({
+ source: { url: sourceUrl, isPrivate: true },
+ target: targetFile,
+ });
+ await download.start();
+ } else {
+ let download = await promiseStartLegacyDownload(sourceUrl, {
+ isPrivate: true,
+ });
+ await promiseDownloadStopped(download);
+ }
+
+ cleanup();
+});
+
+/**
+ * Checks the startTime gets updated even after a restart.
+ */
+add_task(async function test_cancel_immediately_restart_and_check_startTime() {
+ let download = await promiseStartDownload();
+
+ let startTime = download.startTime;
+ Assert.ok(isValidDate(download.startTime));
+
+ await download.cancel();
+ Assert.equal(download.startTime.getTime(), startTime.getTime());
+
+ // Wait for a timeout.
+ await promiseTimeout(10);
+
+ await download.start();
+ Assert.ok(download.startTime.getTime() > startTime.getTime());
+});
+
+/**
+ * Executes download with content-encoding.
+ */
+add_task(async function test_with_content_encoding() {
+ let sourcePath = "/test_with_content_encoding.txt";
+ let sourceUrl = httpUrl("test_with_content_encoding.txt");
+
+ function cleanup() {
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader("Content-Encoding", "gzip", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT_GZIP_ENCODED.length,
+ false
+ );
+
+ let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
+ bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED);
+ });
+
+ let download = await promiseStartDownload(sourceUrl);
+ await promiseDownloadStopped(download);
+
+ Assert.equal(download.progress, 100);
+ Assert.equal(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length);
+
+ // Ensure the content matches the decoded test data.
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+
+ cleanup();
+});
+
+/**
+ * Checks that the file is not decoded if the extension matches the encoding.
+ */
+add_task(async function test_with_content_encoding_ignore_extension() {
+ let sourcePath = "/test_with_content_encoding_ignore_extension.gz";
+ let sourceUrl = httpUrl("test_with_content_encoding_ignore_extension.gz");
+
+ function cleanup() {
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader("Content-Encoding", "gzip", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT_GZIP_ENCODED.length,
+ false
+ );
+
+ let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
+ bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED);
+ });
+
+ let download = await promiseStartDownload(sourceUrl);
+ await promiseDownloadStopped(download);
+
+ Assert.equal(download.progress, 100);
+ Assert.equal(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length);
+ Assert.equal(
+ await expectNonZeroDownloadTargetSize(download.target),
+ TEST_DATA_SHORT_GZIP_ENCODED.length
+ );
+
+ // Ensure the content matches the encoded test data. We convert the data to a
+ // string before executing the content check.
+ await promiseVerifyTarget(
+ download.target,
+ String.fromCharCode.apply(String, TEST_DATA_SHORT_GZIP_ENCODED)
+ );
+
+ cleanup();
+});
+
+/**
+ * Cancels and restarts a download sequentially with content-encoding.
+ */
+add_task(async function test_cancel_midway_restart_with_content_encoding() {
+ mustInterruptResponses();
+
+ let download = await promiseStartDownload(httpUrl("interruptible_gzip.txt"));
+
+ // The first time, cancel the download midway.
+ await new Promise(resolve => {
+ let onchange = function () {
+ if (
+ !download.stopped &&
+ !download.canceled &&
+ download.currentBytes == TEST_DATA_SHORT_GZIP_ENCODED_FIRST.length
+ ) {
+ resolve(download.cancel());
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ download.onchange = onchange;
+ onchange();
+ });
+
+ Assert.ok(download.stopped);
+
+ // The second time, we'll provide the entire interruptible response.
+ continueResponses();
+ download.onchange = null;
+ await download.start();
+
+ Assert.equal(download.progress, 100);
+ Assert.equal(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length);
+
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT);
+});
+
+/**
+ * Download with parental controls enabled.
+ */
+add_task(async function test_blocked_parental_controls() {
+ let blockFn = base => ({
+ shouldBlockForParentalControls: () => Promise.resolve(true),
+ });
+
+ Integration.downloads.register(blockFn);
+ function cleanup() {
+ Integration.downloads.unregister(blockFn);
+ }
+ registerCleanupFunction(cleanup);
+
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we want to check that the promise
+ // returned by the "start" method is rejected.
+ download = await promiseNewDownload();
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, we cannot be sure whether we are
+ // testing the promise returned by the "start" method or we are testing
+ // the "error" property checked by promiseDownloadStopped. This happens
+ // because we don't have control over when the download is started.
+ download = await promiseStartLegacyDownload();
+ await promiseDownloadStopped(download);
+ }
+ do_throw("The download should have blocked.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) {
+ throw ex;
+ }
+ Assert.ok(ex.becauseBlockedByParentalControls);
+ Assert.ok(download.error.becauseBlockedByParentalControls);
+ }
+
+ // Now that the download stopped, the target file should not exist.
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+
+ cleanup();
+});
+
+/**
+ * Test a download that will be blocked by Windows parental controls by
+ * resulting in an HTTP status code of 450.
+ */
+add_task(async function test_blocked_parental_controls_httpstatus450() {
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ download = await promiseNewDownload(httpUrl("parentalblocked.zip"));
+ await download.start();
+ } else {
+ download = await promiseStartLegacyDownload(
+ httpUrl("parentalblocked.zip")
+ );
+ await promiseDownloadStopped(download);
+ }
+ do_throw("The download should have blocked.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) {
+ throw ex;
+ }
+ Assert.ok(ex.becauseBlockedByParentalControls);
+ Assert.ok(download.error.becauseBlockedByParentalControls);
+ Assert.ok(download.stopped);
+ }
+
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+});
+
+/**
+ * Check that DownloadCopySaver can always retrieve the hash.
+ * DownloadLegacySaver can only retrieve the hash when
+ * nsIExternalHelperAppService is invoked.
+ */
+add_task(async function test_getSha256Hash() {
+ if (!gUseLegacySaver) {
+ let download = await promiseStartDownload(httpUrl("source.txt"));
+ await promiseDownloadStopped(download);
+ Assert.ok(download.stopped);
+ Assert.equal(32, download.saver.getSha256Hash().length);
+ }
+});
+
+/**
+ * Checks that application reputation blocks the download and the target file
+ * does not exist.
+ */
+add_task(async function test_blocked_applicationReputation() {
+ let download = await promiseBlockedDownload({
+ keepPartialData: false,
+ keepBlockedData: false,
+ });
+
+ // Now that the download is blocked, the target file should not exist.
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+
+ // There should also be no blocked data in this case
+ Assert.ok(!download.hasBlockedData);
+});
+
+/**
+ * Checks that if a download restarts while processing an application reputation
+ * request, the status is handled correctly.
+ */
+add_task(async function test_blocked_applicationReputation_race() {
+ let isFirstShouldBlockCall = true;
+
+ let blockFn = base => ({
+ shouldBlockForReputationCheck(download) {
+ if (isFirstShouldBlockCall) {
+ isFirstShouldBlockCall = false;
+
+ // 2. Cancel and restart the download before the first attempt has a
+ // chance to finish.
+ download.cancel();
+ download.removePartialData();
+ download.start();
+
+ // 3. Allow the first attempt to finish with a blocked response.
+ return Promise.resolve({
+ shouldBlock: true,
+ verdict: Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+ });
+ }
+
+ // 4/5. Don't block the download the second time. The race condition would
+ // occur with the first attempt regardless of whether the second one
+ // is blocked, but not blocking here makes the test simpler.
+ return Promise.resolve({
+ shouldBlock: false,
+ verdict: "",
+ });
+ },
+ shouldKeepBlockedData: () => Promise.resolve(true),
+ });
+
+ Integration.downloads.register(blockFn);
+ function cleanup() {
+ Integration.downloads.unregister(blockFn);
+ }
+ registerCleanupFunction(cleanup);
+
+ let download;
+
+ try {
+ // 1. Start the download and get a reference to the promise asociated with
+ // the first attempt, before allowing the response to continue.
+ download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+ let firstAttempt = promiseDownloadStopped(download);
+ continueResponses();
+
+ // 4/5. Wait for the first attempt to be completed. The result of this
+ // should appear as a cancellation.
+ await firstAttempt;
+
+ do_throw("The first attempt should have been canceled.");
+ } catch (ex) {
+ // The "becauseBlocked" property should be false.
+ if (!(ex instanceof Downloads.Error) || ex.becauseBlocked) {
+ throw ex;
+ }
+ }
+
+ // 6. Wait for the second attempt to be completed.
+ await promiseDownloadStopped(download);
+
+ // 7. At this point, "hasBlockedData" should be false.
+ Assert.ok(!download.hasBlockedData);
+
+ cleanup();
+});
+
+/**
+ * Checks that application reputation blocks the download but maintains the
+ * blocked data, which will be deleted when the block is confirmed.
+ */
+add_task(async function test_blocked_applicationReputation_confirmBlock() {
+ let download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ Assert.ok(download.hasBlockedData);
+ Assert.equal((await IOUtils.stat(download.target.path)).size, 0);
+ Assert.ok(await IOUtils.exists(download.target.partFilePath));
+
+ await download.confirmBlock();
+
+ // After confirming the block the download should be in a failed state and
+ // have no downloaded data left on disk.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(!download.hasBlockedData);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+});
+
+/**
+ * Checks that application reputation blocks the download but maintains the
+ * blocked data, which will be used to complete the download when unblocking.
+ */
+add_task(async function test_blocked_applicationReputation_unblock() {
+ let download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ useLegacySaver: gUseLegacySaver,
+ });
+
+ Assert.ok(download.hasBlockedData);
+ Assert.equal((await IOUtils.stat(download.target.path)).size, 0);
+ Assert.ok(await IOUtils.exists(download.target.partFilePath));
+
+ await download.unblock();
+
+ // After unblocking the download should have succeeded and be
+ // present at the final path.
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.hasBlockedData);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT);
+
+ // The only indication the download was previously blocked is the
+ // existence of the error, so we make sure it's still set.
+ Assert.ok(download.error instanceof Downloads.Error);
+ Assert.ok(download.error.becauseBlocked);
+ Assert.ok(download.error.becauseBlockedByReputationCheck);
+});
+
+/**
+ * Check that calling cancel on a blocked download will not cause errors
+ */
+add_task(async function test_blocked_applicationReputation_cancel() {
+ let download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ // This call should succeed on a blocked download.
+ await download.cancel();
+
+ // Calling cancel should not have changed the current state, the download
+ // should still be blocked.
+ Assert.ok(download.error.becauseBlockedByReputationCheck);
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(download.hasBlockedData);
+});
+
+/**
+ * Checks that unblock and confirmBlock cannot race on a blocked download
+ */
+add_task(async function test_blocked_applicationReputation_decisionRace() {
+ let download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ let unblockPromise = download.unblock();
+ let confirmBlockPromise = download.confirmBlock();
+
+ await confirmBlockPromise.then(
+ () => {
+ do_throw("confirmBlock should have failed.");
+ },
+ () => {}
+ );
+
+ await unblockPromise;
+
+ // After unblocking the download should have succeeded and be
+ // present at the final path.
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.hasBlockedData);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ Assert.ok(await IOUtils.exists(download.target.path));
+
+ download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ confirmBlockPromise = download.confirmBlock();
+ unblockPromise = download.unblock();
+
+ await unblockPromise.then(
+ () => {
+ do_throw("unblock should have failed.");
+ },
+ () => {}
+ );
+
+ await confirmBlockPromise;
+
+ // After confirming the block the download should be in a failed state and
+ // have no downloaded data left on disk.
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(!download.hasBlockedData);
+ Assert.equal(false, await IOUtils.exists(download.target.partFilePath));
+ Assert.equal(false, await IOUtils.exists(download.target.path));
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+});
+
+/**
+ * Checks that unblocking a blocked download fails if the blocked data has been
+ * removed.
+ */
+add_task(async function test_blocked_applicationReputation_unblock() {
+ let download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ Assert.ok(download.hasBlockedData);
+ Assert.ok(await IOUtils.exists(download.target.partFilePath));
+
+ // Remove the blocked data without telling the download.
+ await IOUtils.remove(download.target.partFilePath);
+
+ let unblockPromise = download.unblock();
+ await unblockPromise.then(
+ () => {
+ do_throw("unblock should have failed.");
+ },
+ () => {}
+ );
+
+ // Even though unblocking failed the download state should have been updated
+ // to reflect the lack of blocked data.
+ Assert.ok(!download.hasBlockedData);
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+ Assert.ok(!download.target.exists);
+ Assert.equal(download.target.size, 0);
+});
+
+/**
+ * download.showContainingDirectory() action
+ */
+add_task(async function test_showContainingDirectory() {
+ let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+
+ let download = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt") },
+ target: "",
+ });
+
+ let promiseDirectoryShown = waitForDirectoryShown();
+ await download.showContainingDirectory();
+ let path = await promiseDirectoryShown;
+ try {
+ new FileUtils.File(path);
+ do_throw("Should have failed because of an invalid path.");
+ } catch (ex) {
+ if (!(ex instanceof Components.Exception)) {
+ throw ex;
+ }
+ // Invalid paths on Windows are reported with NS_ERROR_FAILURE,
+ // but with NS_ERROR_FILE_UNRECOGNIZED_PATH on Mac/Linux
+ let validResult =
+ ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH ||
+ ex.result == Cr.NS_ERROR_FAILURE;
+ Assert.ok(validResult);
+ }
+
+ download = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt") },
+ target: targetPath,
+ });
+
+ promiseDirectoryShown = waitForDirectoryShown();
+ download.showContainingDirectory();
+ await promiseDirectoryShown;
+});
+
+/**
+ * download.launch() action
+ */
+add_task(async function test_launch() {
+ let customLauncher = getTempFile("app-launcher");
+
+ // Test both with and without setting a custom application.
+ for (let launcherPath of [null, customLauncher.path]) {
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can test that file is not launched if download.succeeded is not set.
+ download = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ launcherPath,
+ launchWhenSucceeded: true,
+ });
+
+ try {
+ await download.launch();
+ do_throw("Can't launch download file as it has not completed yet");
+ } catch (ex) {
+ Assert.equal(
+ ex.message,
+ "launch can only be called if the download succeeded"
+ );
+ }
+
+ Assert.ok(download.launchWhenSucceeded);
+ await download.start();
+ } else {
+ // When testing DownloadLegacySaver, the download is already started when
+ // it is created, thus we don't test calling "launch" before starting.
+ download = await promiseStartLegacyDownload(httpUrl("source.txt"), {
+ launcherPath,
+ launchWhenSucceeded: true,
+ });
+ Assert.ok(download.launchWhenSucceeded);
+ await promiseDownloadStopped(download);
+ }
+
+ let promiseFileLaunched = waitForFileLaunched();
+ download.launch();
+ let result = await promiseFileLaunched;
+
+ // Verify that the results match the test case.
+ if (!launcherPath) {
+ // This indicates that the default handler has been chosen.
+ Assert.ok(result === null);
+ } else {
+ // Check the nsIMIMEInfo instance that would have been used for launching.
+ Assert.equal(result.preferredAction, Ci.nsIMIMEInfo.useHelperApp);
+ Assert.ok(
+ result.preferredApplicationHandler
+ .QueryInterface(Ci.nsILocalHandlerApp)
+ .executable.equals(customLauncher)
+ );
+ }
+ }
+});
+
+/**
+ * Test passing an invalid path as the launcherPath property.
+ */
+add_task(async function test_launcherPath_invalid() {
+ let download = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt") },
+ target: { path: getTempFile(TEST_TARGET_FILE_NAME).path },
+ launcherPath: " ",
+ });
+
+ let promiseDownloadLaunched = new Promise(resolve => {
+ let waitFn = base => {
+ let launchOverride = {
+ launchDownload() {
+ Integration.downloads.unregister(waitFn);
+ let superPromise = super.launchDownload(...arguments);
+ resolve(superPromise);
+ return superPromise;
+ },
+ };
+ Object.setPrototypeOf(launchOverride, base);
+ return launchOverride;
+ };
+ Integration.downloads.register(waitFn);
+ });
+
+ await download.start();
+ try {
+ download.launch();
+ await promiseDownloadLaunched;
+ do_throw("Can't launch file with invalid custom launcher");
+ } catch (ex) {
+ if (!(ex instanceof Components.Exception)) {
+ throw ex;
+ }
+ // Invalid paths on Windows are reported with NS_ERROR_FAILURE,
+ // but with NS_ERROR_FILE_UNRECOGNIZED_PATH on Mac/Linux
+ let validResult =
+ ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH ||
+ ex.result == Cr.NS_ERROR_FAILURE;
+ Assert.ok(validResult);
+ }
+});
+
+/**
+ * Tests that download.launch() is automatically called after
+ * the download finishes if download.launchWhenSucceeded = true
+ */
+add_task(async function test_launchWhenSucceeded() {
+ let customLauncher = getTempFile("app-launcher");
+
+ // Test both with and without setting a custom application.
+ for (let launcherPath of [null, customLauncher.path]) {
+ let promiseFileLaunched = waitForFileLaunched();
+
+ if (!gUseLegacySaver) {
+ let download = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ launchWhenSucceeded: true,
+ launcherPath,
+ });
+ await download.start();
+ } else {
+ let download = await promiseStartLegacyDownload(httpUrl("source.txt"), {
+ launcherPath,
+ launchWhenSucceeded: true,
+ });
+ await promiseDownloadStopped(download);
+ }
+
+ let result = await promiseFileLaunched;
+
+ // Verify that the results match the test case.
+ if (!launcherPath) {
+ // This indicates that the default handler has been chosen.
+ Assert.ok(result === null);
+ } else {
+ // Check the nsIMIMEInfo instance that would have been used for launching.
+ Assert.equal(result.preferredAction, Ci.nsIMIMEInfo.useHelperApp);
+ Assert.ok(
+ result.preferredApplicationHandler
+ .QueryInterface(Ci.nsILocalHandlerApp)
+ .executable.equals(customLauncher)
+ );
+ }
+ }
+});
+
+/**
+ * Tests that the proper content type is set for a normal download.
+ */
+add_task(async function test_contentType() {
+ let download = await promiseStartDownload(httpUrl("source.txt"));
+ await promiseDownloadStopped(download);
+
+ Assert.equal("text/plain", download.contentType);
+});
+
+/**
+ * Tests that the serialization/deserialization of the startTime Date
+ * object works correctly.
+ */
+add_task(async function test_toSerializable_startTime() {
+ let download1 = await promiseStartDownload(httpUrl("source.txt"));
+ await promiseDownloadStopped(download1);
+
+ let serializable = download1.toSerializable();
+ let reserialized = JSON.parse(JSON.stringify(serializable));
+
+ let download2 = await Downloads.createDownload(reserialized);
+
+ Assert.equal(download1.startTime.constructor.name, "Date");
+ Assert.equal(download2.startTime.constructor.name, "Date");
+ Assert.equal(download1.startTime.toJSON(), download2.startTime.toJSON());
+});
+
+/**
+ * Checks that downloads are added to browsing history when they start.
+ */
+add_task(async function test_history() {
+ mustInterruptResponses();
+
+ let sourceUrl = httpUrl("interruptible.txt");
+
+ // We will wait for the visit to be notified during the download.
+ await PlacesUtils.history.clear();
+ let promiseVisit = promiseWaitForVisit(sourceUrl);
+
+ // Start a download that is not allowed to finish yet.
+ let download = await promiseStartDownload(sourceUrl);
+ let expectedFile = new FileUtils.File(download.target.path);
+ let expectedFileURI = Services.io.newFileURI(expectedFile);
+ let promiseAnnotation = waitForAnnotation(
+ sourceUrl,
+ "downloads/destinationFileURI",
+ expectedFileURI.spec
+ );
+
+ // The history and annotation notifications should be received before the download completes.
+ let [time, transitionType, lastKnownTitle] = await promiseVisit;
+ await promiseAnnotation;
+
+ Assert.equal(time, download.startTime.getTime());
+ Assert.equal(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD);
+ Assert.equal(lastKnownTitle, expectedFile.leafName);
+
+ let pageInfo = await PlacesUtils.history.fetch(sourceUrl, {
+ includeAnnotations: true,
+ });
+ Assert.equal(
+ pageInfo.annotations.get("downloads/destinationFileURI"),
+ expectedFileURI.spec,
+ "Should have saved the correct download target annotation."
+ );
+
+ // Restart and complete the download after clearing history.
+ await PlacesUtils.history.clear();
+ download.cancel();
+ continueResponses();
+ await download.start();
+
+ // The restart should not have added a new history visit.
+ Assert.equal(false, await PlacesUtils.history.hasVisits(sourceUrl));
+});
+
+/**
+ * Checks that downloads started by nsIHelperAppService are added to the
+ * browsing history when they start.
+ */
+add_task(async function test_history_tryToKeepPartialData() {
+ // We will wait for the visit to be notified during the download.
+ await PlacesUtils.history.clear();
+ let promiseVisit = promiseWaitForVisit(
+ httpUrl("interruptible_resumable.txt")
+ );
+
+ // Start a download that is not allowed to finish yet.
+ let beforeStartTimeMs = Date.now();
+ let download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver: gUseLegacySaver,
+ });
+
+ // The history notifications should be received before the download completes.
+ let [time, transitionType] = await promiseVisit;
+ Assert.equal(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD);
+
+ // The time set by nsIHelperAppService may be different than the start time in
+ // the download object, thus we only check that it is a meaningful time. Note
+ // that we subtract one second from the earliest time to account for rounding.
+ Assert.ok(time >= beforeStartTimeMs - 1000);
+
+ // Complete the download before finishing the test.
+ continueResponses();
+ await promiseDownloadStopped(download);
+});
+
+/**
+ * Checks that finished downloads are not removed.
+ */
+add_task(async function test_download_cancel_retry_finalize() {
+ // Start a download that is not allowed to finish yet.
+ let sourceUrl = httpUrl("interruptible.txt");
+ let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ mustInterruptResponses();
+ let download1 = await Downloads.createDownload({
+ source: sourceUrl,
+ target: { path: targetFilePath, partFilePath: targetFilePath + ".part" },
+ });
+ download1.start().catch(() => {});
+ await promiseDownloadMidway(download1);
+ await promisePartFileReady(download1);
+
+ // Cancel the download and make sure that the partial data do not exist.
+ await download1.cancel();
+ Assert.equal(targetFilePath, download1.target.path);
+ Assert.equal(false, await IOUtils.exists(download1.target.path));
+ Assert.equal(false, await IOUtils.exists(download1.target.partFilePath));
+ continueResponses();
+
+ // Download the same file again with a different download session.
+ let download2 = await Downloads.createDownload({
+ source: sourceUrl,
+ target: { path: targetFilePath, partFilePath: targetFilePath + ".part" },
+ });
+ download2.start().catch(() => {});
+
+ // Wait for download to be completed.
+ await promiseDownloadStopped(download2);
+ Assert.equal(targetFilePath, download2.target.path);
+ Assert.ok(await IOUtils.exists(download2.target.path));
+ Assert.equal(false, await IOUtils.exists(download2.target.partFilePath));
+
+ // Finalize the first download session.
+ await download1.finalize(true);
+
+ // The complete download should not have been removed.
+ Assert.ok(await IOUtils.exists(download2.target.path));
+ Assert.equal(false, await IOUtils.exists(download2.target.partFilePath));
+});
+
+/**
+ * Checks that confirmBlock does not clobber unrelated safe files.
+ */
+add_task(async function test_blocked_removeByHand_confirmBlock() {
+ let download1 = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ Assert.ok(download1.hasBlockedData);
+ Assert.equal((await IOUtils.stat(download1.target.path)).size, 0);
+ Assert.ok(await IOUtils.exists(download1.target.partFilePath));
+
+ // Remove the placeholder without telling the download.
+ await IOUtils.remove(download1.target.path);
+ Assert.equal(false, await IOUtils.exists(download1.target.path));
+
+ // Download a file with the same name as the blocked download.
+ let download2 = await Downloads.createDownload({
+ source: httpUrl("interruptible_resumable.txt"),
+ target: {
+ path: download1.target.path,
+ partFilePath: download1.target.path + ".part",
+ },
+ });
+ download2.start().catch(() => {});
+
+ // Wait for download to be completed.
+ await promiseDownloadStopped(download2);
+ Assert.equal(download1.target.path, download2.target.path);
+ Assert.ok(await IOUtils.exists(download2.target.path));
+
+ // Remove the blocked download.
+ await download1.confirmBlock();
+
+ // After confirming the complete download should not have been removed.
+ Assert.ok(await IOUtils.exists(download2.target.path));
+});
+
+/**
+ * Tests that the temp download files are removed on exit and exiting private
+ * mode after they have been launched.
+ */
+add_task(async function test_launchWhenSucceeded_deleteTempFileOnExit() {
+ let customLauncherPath = getTempFile("app-launcher").path;
+ let autoDeleteTargetPathOne = getTempFile(TEST_TARGET_FILE_NAME).path;
+ let autoDeleteTargetPathTwo = getTempFile(TEST_TARGET_FILE_NAME).path;
+ let noAutoDeleteTargetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+
+ let autoDeleteDownloadOne = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt"), isPrivate: true },
+ target: autoDeleteTargetPathOne,
+ launchWhenSucceeded: true,
+ launcherPath: customLauncherPath,
+ });
+ await autoDeleteDownloadOne.start();
+
+ Services.prefs.setBoolPref(kDeleteTempFileOnExit, true);
+ let autoDeleteDownloadTwo = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: autoDeleteTargetPathTwo,
+ launchWhenSucceeded: true,
+ launcherPath: customLauncherPath,
+ });
+ await autoDeleteDownloadTwo.start();
+
+ Services.prefs.setBoolPref(kDeleteTempFileOnExit, false);
+ let noAutoDeleteDownload = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: noAutoDeleteTargetPath,
+ launchWhenSucceeded: true,
+ launcherPath: customLauncherPath,
+ });
+ await noAutoDeleteDownload.start();
+
+ Services.prefs.clearUserPref(kDeleteTempFileOnExit);
+
+ Assert.ok(await IOUtils.exists(autoDeleteTargetPathOne));
+ Assert.ok(await IOUtils.exists(autoDeleteTargetPathTwo));
+ Assert.ok(await IOUtils.exists(noAutoDeleteTargetPath));
+
+ // Simulate leaving private browsing mode
+ Services.obs.notifyObservers(null, "last-pb-context-exited");
+ Assert.equal(false, await IOUtils.exists(autoDeleteTargetPathOne));
+
+ // Simulate browser shutdown
+ let expire = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsIObserver);
+ expire.observe(null, "profile-before-change", null);
+
+ // The file should still exist following the simulated shutdown.
+ Assert.ok(await IOUtils.exists(autoDeleteTargetPathTwo));
+ Assert.ok(await IOUtils.exists(noAutoDeleteTargetPath));
+});
+
+add_task(async function test_partitionKey() {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ Services.prefs.setBoolPref("privacy.partition.network_state", true);
+
+ function promiseVerifyDownloadChannel(url, partitionKey) {
+ return TestUtils.topicObserved(
+ "http-on-modify-request",
+ (subject, data) => {
+ let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (httpChannel.URI.spec != url) {
+ return false;
+ }
+
+ let reqLoadInfo = httpChannel.loadInfo;
+ let cookieJarSettings = reqLoadInfo.cookieJarSettings;
+
+ // Check the partitionKey of the cookieJarSettings.
+ Assert.equal(cookieJarSettings.partitionKey, partitionKey);
+
+ return true;
+ }
+ );
+ }
+
+ let test_url = httpUrl("source.txt");
+ let uri = Services.io.newURI(test_url);
+ let cookieJarSettings = Cc["@mozilla.org/cookieJarSettings;1"].createInstance(
+ Ci.nsICookieJarSettings
+ );
+ cookieJarSettings.initWithURI(uri, false);
+ let expectedPartitionKey = cookieJarSettings.partitionKey;
+
+ let verifyPromise;
+
+ let download;
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we have control over the download, thus
+ // we can check its basic properties before it starts.
+ download = await Downloads.createDownload({
+ source: { url: test_url, cookieJarSettings },
+ target: { path: targetFile.path },
+ saver: { type: "copy" },
+ });
+
+ Assert.equal(download.source.url, test_url);
+ Assert.equal(download.target.path, targetFile.path);
+
+ verifyPromise = promiseVerifyDownloadChannel(
+ test_url,
+ expectedPartitionKey
+ );
+
+ await download.start();
+ } else {
+ verifyPromise = promiseVerifyDownloadChannel(
+ test_url,
+ expectedPartitionKey
+ );
+
+ // When testing DownloadLegacySaver, the download is already started when it
+ // is created, thus we must check its basic properties while in progress.
+ download = await promiseStartLegacyDownload(null, {
+ targetFile,
+ cookieJarSettings,
+ });
+
+ Assert.equal(download.source.url, test_url);
+ Assert.equal(download.target.path, targetFile.path);
+
+ await promiseDownloadStopped(download);
+ }
+
+ await verifyPromise;
+
+ Services.prefs.clearUserPref("privacy.partition.network_state");
+});
diff --git a/toolkit/components/downloads/test/unit/head.js b/toolkit/components/downloads/test/unit/head.js
new file mode 100644
index 0000000000..19d85b6f7c
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/head.js
@@ -0,0 +1,1183 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Provides infrastructure for automated download components tests.
+ */
+
+"use strict";
+
+var { Integration } = ChromeUtils.importESModule(
+ "resource://gre/modules/Integration.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ HttpServer: "resource://testing-common/httpd.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gExternalHelperAppService",
+ "@mozilla.org/uriloader/external-helper-app-service;1",
+ Ci.nsIExternalHelperAppService
+);
+
+/* global DownloadIntegration */
+Integration.downloads.defineESModuleGetter(
+ this,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const ServerSocket = Components.Constructor(
+ "@mozilla.org/network/server-socket;1",
+ "nsIServerSocket",
+ "init"
+);
+const BinaryOutputStream = Components.Constructor(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+const ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+);
+
+const TEST_TARGET_FILE_NAME = "test-download.txt";
+const TEST_STORE_FILE_NAME = "test-downloads.json";
+
+// We are testing an HTTPS referrer with HTTP downloads in order to verify that
+// the default policy will not prevent the referrer from being passed around.
+const TEST_REFERRER_URL = "https://www.example.com/referrer.html";
+
+const TEST_DATA_SHORT = "This test string is downloaded.";
+// Generate using gzipCompressString in TelemetryController.sys.mjs.
+const TEST_DATA_SHORT_GZIP_ENCODED_FIRST = [
+ 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 11, 201, 200, 44, 86, 40, 73, 45, 46, 81, 40,
+ 46, 41, 202, 204,
+];
+const TEST_DATA_SHORT_GZIP_ENCODED_SECOND = [
+ 75, 87, 0, 114, 83, 242, 203, 243, 114, 242, 19, 83, 82, 83, 244, 0, 151, 222,
+ 109, 43, 31, 0, 0, 0,
+];
+const TEST_DATA_SHORT_GZIP_ENCODED = TEST_DATA_SHORT_GZIP_ENCODED_FIRST.concat(
+ TEST_DATA_SHORT_GZIP_ENCODED_SECOND
+);
+
+/**
+ * All the tests are implemented with add_task, this starts them automatically.
+ */
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+// Support functions
+
+/**
+ * HttpServer object initialized before tests start.
+ */
+var gHttpServer;
+
+/**
+ * Given a file name, returns a string containing an URI that points to the file
+ * on the currently running instance of the test HTTP server.
+ */
+function httpUrl(aFileName) {
+ return (
+ "http://www.example.com:" +
+ gHttpServer.identity.primaryPort +
+ "/" +
+ aFileName
+ );
+}
+
+/**
+ * Returns a reference to a temporary file that is guaranteed not to exist and
+ * is cleaned up later. See FileTestUtils.getTempFile for details.
+ */
+function getTempFile(leafName) {
+ return FileTestUtils.getTempFile(leafName);
+}
+
+/**
+ * Check for file existence.
+ * @param {string} path The file path.
+ */
+async function fileExists(path) {
+ try {
+ return (await IOUtils.stat(path)).type == "regular";
+ } catch (ex) {
+ return false;
+ }
+}
+
+/**
+ * Waits for pending events to be processed.
+ *
+ * @return {Promise}
+ * @resolves When pending events have been processed.
+ * @rejects Never.
+ */
+function promiseExecuteSoon() {
+ return new Promise(resolve => {
+ executeSoon(resolve);
+ });
+}
+
+/**
+ * Waits for a pending events to be processed after a timeout.
+ *
+ * @return {Promise}
+ * @resolves When pending events have been processed.
+ * @rejects Never.
+ */
+function promiseTimeout(aTime) {
+ return new Promise(resolve => {
+ do_timeout(aTime, resolve);
+ });
+}
+
+/**
+ * Waits for a new history visit to be notified for the specified URI.
+ *
+ * @param aUrl
+ * String containing the URI that will be visited.
+ *
+ * @return {Promise}
+ * @resolves Array [aTime, aTransitionType] from page-visited places event.
+ * @rejects Never.
+ */
+function promiseWaitForVisit(aUrl) {
+ return new Promise(resolve => {
+ function listener(aEvents) {
+ Assert.equal(aEvents.length, 1);
+ let event = aEvents[0];
+ Assert.equal(event.type, "page-visited");
+ if (event.url == aUrl) {
+ PlacesObservers.removeListener(["page-visited"], listener);
+ resolve([event.visitTime, event.transitionType, event.lastKnownTitle]);
+ }
+ }
+ PlacesObservers.addListener(["page-visited"], listener);
+ });
+}
+
+/**
+ * Creates a new Download object, setting a temporary file as the target.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("source.txt").
+ *
+ * @return {Promise}
+ * @resolves The newly created Download object.
+ * @rejects JavaScript exception.
+ */
+function promiseNewDownload(aSourceUrl) {
+ return Downloads.createDownload({
+ source: aSourceUrl || httpUrl("source.txt"),
+ target: getTempFile(TEST_TARGET_FILE_NAME),
+ });
+}
+
+/**
+ * Starts a new download using the nsIWebBrowserPersist interface, and controls
+ * it using the legacy nsITransfer interface.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("source.txt").
+ * @param aOptions
+ * An optional object used to control the behavior of this function.
+ * You may pass an object with a subset of the following fields:
+ * {
+ * isPrivate: Boolean indicating whether the download originated from a
+ * private window.
+ * referrerInfo: referrerInfo for the download source.
+ * cookieJarSettings: cookieJarSettings for the download source.
+ * targetFile: nsIFile for the target, or null to use a temporary file.
+ * outPersist: Receives a reference to the created nsIWebBrowserPersist
+ * instance.
+ * launchWhenSucceeded: Boolean indicating whether the target should
+ * be launched when it has completed successfully.
+ * launcherPath: String containing the path of the custom executable to
+ * use to launch the target of the download.
+ * }
+ *
+ * @return {Promise}
+ * @resolves The Download object created as a consequence of controlling the
+ * download through the legacy nsITransfer interface.
+ * @rejects Never. The current test fails in case of exceptions.
+ */
+function promiseStartLegacyDownload(aSourceUrl, aOptions) {
+ let sourceURI = NetUtil.newURI(aSourceUrl || httpUrl("source.txt"));
+ let targetFile =
+ (aOptions && aOptions.targetFile) || getTempFile(TEST_TARGET_FILE_NAME);
+
+ let persist = Cc[
+ "@mozilla.org/embedding/browser/nsWebBrowserPersist;1"
+ ].createInstance(Ci.nsIWebBrowserPersist);
+ if (aOptions) {
+ aOptions.outPersist = persist;
+ }
+
+ let fileExtension = null,
+ mimeInfo = null;
+ let match = sourceURI.pathQueryRef.match(/\.([^.\/]+)$/);
+ if (match) {
+ fileExtension = match[1];
+ }
+
+ if (fileExtension) {
+ try {
+ mimeInfo = gMIMEService.getFromTypeAndExtension(null, fileExtension);
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.saveToDisk;
+ } catch (ex) {}
+ }
+
+ if (aOptions && aOptions.launcherPath) {
+ Assert.ok(mimeInfo != null);
+
+ let localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.executable = new FileUtils.File(aOptions.launcherPath);
+
+ mimeInfo.preferredApplicationHandler = localHandlerApp;
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
+ }
+
+ if (aOptions && aOptions.launchWhenSucceeded) {
+ Assert.ok(mimeInfo != null);
+
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
+ }
+
+ // Apply decoding if required by the "Content-Encoding" header.
+ persist.persistFlags &= ~Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_CONVERSION;
+ persist.persistFlags |=
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
+
+ let transfer = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
+
+ return new Promise(resolve => {
+ Downloads.getList(Downloads.ALL)
+ .then(function (aList) {
+ // Temporarily register a view that will get notified when the download we
+ // are controlling becomes visible in the list of downloads.
+ aList
+ .addView({
+ onDownloadAdded(aDownload) {
+ aList.removeView(this).catch(do_report_unexpected_exception);
+
+ // Remove the download to keep the list empty for the next test. This
+ // also allows the caller to register the "onchange" event directly.
+ let promise = aList.remove(aDownload);
+
+ // When the download object is ready, make it available to the caller.
+ promise.then(
+ () => resolve(aDownload),
+ do_report_unexpected_exception
+ );
+ },
+ })
+ .catch(do_report_unexpected_exception);
+
+ let isPrivate = aOptions && aOptions.isPrivate;
+ let referrerInfo = aOptions ? aOptions.referrerInfo : null;
+ let cookieJarSettings = aOptions ? aOptions.cookieJarSettings : null;
+ let classification =
+ aOptions?.downloadClassification ??
+ Ci.nsITransfer.DOWNLOAD_ACCEPTABLE;
+ // Initialize the components so they reference each other. This will cause
+ // the Download object to be created and added to the public downloads.
+ transfer.init(
+ sourceURI,
+ null,
+ NetUtil.newURI(targetFile),
+ null,
+ mimeInfo,
+ null,
+ null,
+ persist,
+ isPrivate,
+ classification,
+ null
+ );
+ persist.progressListener = transfer;
+
+ // Start the actual download process.
+ persist.saveURI(
+ sourceURI,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ 0,
+ referrerInfo,
+ cookieJarSettings,
+ null,
+ null,
+ targetFile,
+ Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ isPrivate
+ );
+ })
+ .catch(do_report_unexpected_exception);
+ });
+}
+
+/**
+ * Starts a new download using the nsIHelperAppService interface, and controls
+ * it using the legacy nsITransfer interface. The source of the download will
+ * be "interruptible_resumable.txt" and partially downloaded data will be kept.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("interruptible_resumable.txt").
+ *
+ * @return {Promise}
+ * @resolves The Download object created as a consequence of controlling the
+ * download through the legacy nsITransfer interface.
+ * @rejects Never. The current test fails in case of exceptions.
+ */
+function promiseStartExternalHelperAppServiceDownload(aSourceUrl) {
+ let sourceURI = NetUtil.newURI(
+ aSourceUrl || httpUrl("interruptible_resumable.txt")
+ );
+
+ return new Promise(resolve => {
+ Downloads.getList(Downloads.PUBLIC)
+ .then(function (aList) {
+ // Temporarily register a view that will get notified when the download we
+ // are controlling becomes visible in the list of downloads.
+ aList
+ .addView({
+ onDownloadAdded(aDownload) {
+ aList.removeView(this).catch(do_report_unexpected_exception);
+
+ // Remove the download to keep the list empty for the next test. This
+ // also allows the caller to register the "onchange" event directly.
+ let promise = aList.remove(aDownload);
+
+ // When the download object is ready, make it available to the caller.
+ promise.then(
+ () => resolve(aDownload),
+ do_report_unexpected_exception
+ );
+ },
+ })
+ .catch(do_report_unexpected_exception);
+
+ let channel = NetUtil.newChannel({
+ uri: sourceURI,
+ loadUsingSystemPrincipal: true,
+ });
+
+ // Start the actual download process.
+ channel.asyncOpen({
+ contentListener: null,
+
+ onStartRequest(aRequest) {
+ let requestChannel = aRequest.QueryInterface(Ci.nsIChannel);
+ this.contentListener = gExternalHelperAppService.doContent(
+ requestChannel.contentType,
+ aRequest,
+ null,
+ true
+ );
+ this.contentListener.onStartRequest(aRequest);
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ this.contentListener.onStopRequest(aRequest, aStatusCode);
+ },
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ this.contentListener.onDataAvailable(
+ aRequest,
+ aInputStream,
+ aOffset,
+ aCount
+ );
+ },
+ });
+ })
+ .catch(do_report_unexpected_exception);
+ });
+}
+
+/**
+ * Waits for a download to reach half of its progress, in case it has not
+ * reached the expected progress already.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has reached half of its progress.
+ * @rejects Never.
+ */
+function promiseDownloadMidway(aDownload) {
+ return new Promise(resolve => {
+ // Wait for the download to reach half of its progress.
+ let onchange = function () {
+ if (
+ !aDownload.stopped &&
+ !aDownload.canceled &&
+ aDownload.progress == 50
+ ) {
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a download to make any amount of progress.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has transfered any number of bytes.
+ * @rejects Never.
+ */
+function promiseDownloadStarted(aDownload) {
+ return new Promise(resolve => {
+ // Wait for the download to transfer some amount of bytes.
+ let onchange = function () {
+ if (
+ !aDownload.stopped &&
+ !aDownload.canceled &&
+ aDownload.currentBytes > 0
+ ) {
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a download to finish.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download succeeded or errored.
+ * @rejects Never.
+ */
+function promiseDownloadFinished(aDownload) {
+ return new Promise(resolve => {
+ // Wait for the download to finish.
+ let onchange = function () {
+ if (aDownload.succeeded || aDownload.error) {
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a download to finish, in case it has not finished already.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has finished successfully.
+ * @rejects JavaScript exception if the download failed.
+ */
+function promiseDownloadStopped(aDownload) {
+ if (!aDownload.stopped) {
+ // The download is in progress, wait for the current attempt to finish and
+ // report any errors that may occur.
+ return aDownload.start();
+ }
+
+ if (aDownload.succeeded) {
+ return Promise.resolve();
+ }
+
+ // The download failed or was canceled.
+ return Promise.reject(aDownload.error || new Error("Download canceled."));
+}
+
+/**
+ * Returns a new public or private DownloadList object.
+ *
+ * @param aIsPrivate
+ * True for the private list, false or undefined for the public list.
+ *
+ * @return {Promise}
+ * @resolves The newly created DownloadList object.
+ * @rejects JavaScript exception.
+ */
+function promiseNewList(aIsPrivate) {
+ // We need to clear all the internal state for the list and summary objects,
+ // since all the objects are interdependent internally.
+ Downloads._promiseListsInitialized = null;
+ Downloads._lists = {};
+ Downloads._summaries = {};
+
+ return Downloads.getList(aIsPrivate ? Downloads.PRIVATE : Downloads.PUBLIC);
+}
+
+/**
+ * Ensures that the given file contents are equal to the given string.
+ *
+ * @param aPath
+ * String containing the path of the file whose contents should be
+ * verified.
+ * @param aExpectedContents
+ * String containing the octets that are expected in the file.
+ *
+ * @return {Promise}
+ * @resolves When the operation completes.
+ * @rejects Never.
+ */
+async function promiseVerifyContents(aPath, aExpectedContents) {
+ let file = new FileUtils.File(aPath);
+
+ if (!(await IOUtils.exists(aPath))) {
+ do_throw("File does not exist: " + aPath);
+ }
+
+ if ((await IOUtils.stat(aPath)).size == 0) {
+ do_throw("File is empty: " + aPath);
+ }
+
+ await new Promise(resolve => {
+ NetUtil.asyncFetch(
+ { uri: NetUtil.newURI(file), loadUsingSystemPrincipal: true },
+ function (aInputStream, aStatus) {
+ Assert.ok(Components.isSuccessCode(aStatus));
+ let contents = NetUtil.readInputStreamToString(
+ aInputStream,
+ aInputStream.available()
+ );
+ if (
+ contents.length > TEST_DATA_SHORT.length * 2 ||
+ /[^\x20-\x7E]/.test(contents)
+ ) {
+ // Do not print the entire content string to the test log.
+ Assert.equal(contents.length, aExpectedContents.length);
+ Assert.ok(contents == aExpectedContents);
+ } else {
+ // Print the string if it is short and made of printable characters.
+ Assert.equal(contents, aExpectedContents);
+ }
+ resolve();
+ }
+ );
+ });
+}
+
+/**
+ * Creates and starts a new download, configured to keep partial data, and
+ * returns only when the first part of "interruptible_resumable.txt" has been
+ * saved to disk. You must call "continueResponses" to allow the interruptible
+ * request to continue.
+ *
+ * This function uses either DownloadCopySaver or DownloadLegacySaver based on
+ * the current test run.
+ *
+ * @param aOptions
+ * An optional object used to control the behavior of this function.
+ * You may pass an object with a subset of the following fields:
+ * {
+ * useLegacySaver: Boolean indicating whether to launch a legacy download.
+ * }
+ *
+ * @return {Promise}
+ * @resolves The newly created Download object, still in progress.
+ * @rejects JavaScript exception.
+ */
+async function promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver = false,
+} = {}) {
+ mustInterruptResponses();
+
+ // Start a new download and configure it to keep partially downloaded data.
+ let download;
+ if (!useLegacySaver) {
+ let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ download = await Downloads.createDownload({
+ source: httpUrl("interruptible_resumable.txt"),
+ target: {
+ path: targetFilePath,
+ partFilePath: targetFilePath + ".part",
+ },
+ });
+ download.tryToKeepPartialData = true;
+ download.start().catch(() => {});
+ } else {
+ // Start a download using nsIExternalHelperAppService, that is configured
+ // to keep partially downloaded data by default.
+ download = await promiseStartExternalHelperAppServiceDownload();
+ }
+
+ await promiseDownloadMidway(download);
+ await promisePartFileReady(download);
+
+ return download;
+}
+
+/**
+ * This function should be called after the progress notification for a download
+ * is received, and waits for the worker thread of BackgroundFileSaver to
+ * receive the data to be written to the ".part" file on disk.
+ *
+ * @return {Promise}
+ * @resolves When the ".part" file has been written to disk.
+ * @rejects JavaScript exception.
+ */
+async function promisePartFileReady(aDownload) {
+ // We don't have control over the file output code in BackgroundFileSaver.
+ // After we receive the download progress notification, we may only check
+ // that the ".part" file has been created, while its size cannot be
+ // determined because the file is currently open.
+ try {
+ do {
+ await promiseTimeout(50);
+ } while (!(await IOUtils.exists(aDownload.target.partFilePath)));
+ } catch (ex) {
+ if (!(ex instanceof IOUtils.Error)) {
+ throw ex;
+ }
+ // This indicates that the file has been created and cannot be accessed.
+ // The specific error might vary with the platform.
+ info("Expected exception while checking existence: " + ex.toString());
+ // Wait some more time to allow the write to complete.
+ await promiseTimeout(100);
+ }
+}
+
+/**
+ * Create a download which will be reputation blocked.
+ *
+ * @param options
+ * {
+ * keepPartialData: bool,
+ * keepBlockedData: bool,
+ * useLegacySaver: bool,
+ * verdict: string indicating the detailed reason for the block,
+ * }
+ * @return {Promise}
+ * @resolves The reputation blocked download.
+ * @rejects JavaScript exception.
+ */
+async function promiseBlockedDownload({
+ keepPartialData,
+ keepBlockedData,
+ useLegacySaver,
+ verdict = Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+} = {}) {
+ let blockFn = base => ({
+ shouldBlockForReputationCheck: () =>
+ Promise.resolve({
+ shouldBlock: true,
+ verdict,
+ }),
+ shouldKeepBlockedData: () => Promise.resolve(keepBlockedData),
+ });
+
+ Integration.downloads.register(blockFn);
+ function cleanup() {
+ Integration.downloads.unregister(blockFn);
+ }
+ registerCleanupFunction(cleanup);
+
+ let download;
+
+ try {
+ if (keepPartialData) {
+ download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver,
+ });
+ continueResponses();
+ } else if (useLegacySaver) {
+ download = await promiseStartLegacyDownload();
+ } else {
+ download = await promiseNewDownload();
+ await download.start();
+ do_throw("The download should have blocked.");
+ }
+
+ await promiseDownloadStopped(download);
+ do_throw("The download should have blocked.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) {
+ throw ex;
+ }
+ Assert.ok(ex.becauseBlockedByReputationCheck);
+ Assert.equal(ex.reputationCheckVerdict, verdict);
+ Assert.ok(download.error.becauseBlockedByReputationCheck);
+ Assert.equal(download.error.reputationCheckVerdict, verdict);
+ }
+
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+
+ cleanup();
+ return download;
+}
+
+/**
+ * Starts a socket listener that closes each incoming connection.
+ *
+ * @returns nsIServerSocket that listens for connections. Call its "close"
+ * method to stop listening and free the server port.
+ */
+function startFakeServer() {
+ let serverSocket = new ServerSocket(-1, true, -1);
+ serverSocket.asyncListen({
+ onSocketAccepted(aServ, aTransport) {
+ aTransport.close(Cr.NS_BINDING_ABORTED);
+ },
+ onStopListening() {},
+ });
+ return serverSocket;
+}
+
+/**
+ * This is an internal reference that should not be used directly by tests.
+ */
+var _gDeferResponses = Promise.withResolvers();
+
+/**
+ * Ensures that all the interruptible requests started after this function is
+ * called won't complete until the continueResponses function is called.
+ *
+ * Normally, the internal HTTP server returns all the available data as soon as
+ * a request is received. In order for some requests to be served one part at a
+ * time, special interruptible handlers are registered on the HTTP server. This
+ * allows testing events or actions that need to happen in the middle of a
+ * download.
+ *
+ * For example, the handler accessible at the httpUri("interruptible.txt")
+ * address returns the TEST_DATA_SHORT text, then it may block until the
+ * continueResponses method is called. At this point, the handler sends the
+ * TEST_DATA_SHORT text again to complete the response.
+ *
+ * If an interruptible request is started before the function is called, it may
+ * or may not be blocked depending on the actual sequence of events.
+ */
+function mustInterruptResponses() {
+ // If there are pending blocked requests, allow them to complete. This is
+ // done to prevent requests from being blocked forever, but should not affect
+ // the test logic, since previously started requests should not be monitored
+ // on the client side anymore.
+ _gDeferResponses.resolve();
+
+ info("Interruptible responses will be blocked midway.");
+ _gDeferResponses = Promise.withResolvers();
+}
+
+/**
+ * Allows all the current and future interruptible requests to complete.
+ */
+function continueResponses() {
+ info("Interruptible responses are now allowed to continue.");
+ _gDeferResponses.resolve();
+}
+
+/**
+ * Registers an interruptible response handler.
+ *
+ * @param aPath
+ * Path passed to nsIHttpServer.registerPathHandler.
+ * @param aFirstPartFn
+ * This function is called when the response is received, with the
+ * aRequest and aResponse arguments of the server.
+ * @param aSecondPartFn
+ * This function is called with the aRequest and aResponse arguments of
+ * the server, when the continueResponses function is called.
+ */
+function registerInterruptibleHandler(aPath, aFirstPartFn, aSecondPartFn) {
+ gHttpServer.registerPathHandler(aPath, function (aRequest, aResponse) {
+ info("Interruptible request started.");
+
+ // Process the first part of the response.
+ aResponse.processAsync();
+ aFirstPartFn(aRequest, aResponse);
+
+ // Wait on the current deferred object, then finish the request.
+ _gDeferResponses.promise
+ .then(function RIH_onSuccess() {
+ aSecondPartFn(aRequest, aResponse);
+ aResponse.finish();
+ info("Interruptible request finished.");
+ })
+ .catch(console.error);
+ });
+}
+
+/**
+ * Ensure the given date object is valid.
+ *
+ * @param aDate
+ * The date object to be checked. This value can be null.
+ */
+function isValidDate(aDate) {
+ return aDate && aDate.getTime && !isNaN(aDate.getTime());
+}
+
+/**
+ * Check actual ReferrerInfo is the same as expected.
+ * Because the actual download's referrer info's computedReferrer is computed
+ * from referrerPolicy and originalReferrer and is non-null, and the expected
+ * referrer info was constructed in isolation and therefore the computedReferrer
+ * is null, it isn't possible to use equals here. */
+function checkEqualReferrerInfos(aActualInfo, aExpectedInfo) {
+ Assert.equal(
+ !!aExpectedInfo.originalReferrer,
+ !!aActualInfo.originalReferrer
+ );
+ if (aExpectedInfo.originalReferrer && aActualInfo.originalReferrer) {
+ Assert.equal(
+ aExpectedInfo.originalReferrer.spec,
+ aActualInfo.originalReferrer.spec
+ );
+ }
+
+ Assert.equal(aExpectedInfo.sendReferrer, aActualInfo.sendReferrer);
+ Assert.equal(aExpectedInfo.referrerPolicy, aActualInfo.referrerPolicy);
+}
+
+/**
+ * Waits for the download annotations to be set for the given page, required
+ * because the addDownload method will add these to the database asynchronously.
+ */
+function waitForAnnotation(sourceUriSpec, annotationName, optionalValue) {
+ return TestUtils.waitForCondition(async () => {
+ let pageInfo = await PlacesUtils.history.fetch(sourceUriSpec, {
+ includeAnnotations: true,
+ });
+ if (optionalValue) {
+ return pageInfo?.annotations.get(annotationName) == optionalValue;
+ }
+ return pageInfo?.annotations.has(annotationName);
+ }, `Should have found annotation ${annotationName} for ${sourceUriSpec}`);
+}
+
+/**
+ * Position of the first byte served by the "interruptible_resumable.txt"
+ * handler during the most recent response.
+ */
+var gMostRecentFirstBytePos;
+
+// Initialization functions common to all tests
+
+add_setup(function test_common_initialize() {
+ // Start the HTTP server.
+ gHttpServer = new HttpServer();
+ gHttpServer.registerDirectory("/", do_get_file("../data"));
+ gHttpServer.start(-1);
+ registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ // Ensure all the pending HTTP requests have a chance to finish.
+ continueResponses();
+ // Stop the HTTP server, calling resolve when it's done.
+ gHttpServer.stop(resolve);
+ });
+ });
+
+ // Serve the downloads from a domain located in the Internet zone on Windows.
+ gHttpServer.identity.setPrimary(
+ "http",
+ "www.example.com",
+ gHttpServer.identity.primaryPort
+ );
+ Services.prefs.setCharPref("network.dns.localDomains", "www.example.com");
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("network.dns.localDomains");
+ });
+
+ // Cache locks might prevent concurrent requests to the same resource, and
+ // this may block tests that use the interruptible handlers.
+ Services.prefs.setBoolPref("browser.cache.disk.enable", false);
+ Services.prefs.setBoolPref("browser.cache.memory.enable", false);
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("browser.cache.disk.enable");
+ Services.prefs.clearUserPref("browser.cache.memory.enable");
+ });
+
+ // Allow relaxing default referrer.
+ Services.prefs.setBoolPref(
+ "network.http.referer.disallowCrossSiteRelaxingDefault",
+ false
+ );
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(
+ "network.http.referer.disallowCrossSiteRelaxingDefault"
+ );
+ });
+
+ registerInterruptibleHandler(
+ "/interruptible.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT.length * 2,
+ false
+ );
+ aResponse.write(TEST_DATA_SHORT);
+ },
+ function secondPart(aRequest, aResponse) {
+ aResponse.write(TEST_DATA_SHORT);
+ }
+ );
+
+ registerInterruptibleHandler(
+ "/interruptible_nosize.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.write(TEST_DATA_SHORT);
+ },
+ function secondPart(aRequest, aResponse) {
+ aResponse.write(TEST_DATA_SHORT);
+ }
+ );
+
+ registerInterruptibleHandler(
+ "/interruptible_resumable.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+
+ // Determine if only part of the data should be sent.
+ let data = TEST_DATA_SHORT + TEST_DATA_SHORT;
+ if (aRequest.hasHeader("Range")) {
+ var matches = aRequest
+ .getHeader("Range")
+ .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
+ var firstBytePos = matches[1] === undefined ? 0 : matches[1];
+ var lastBytePos =
+ matches[2] === undefined ? data.length - 1 : matches[2];
+ if (firstBytePos >= data.length) {
+ aResponse.setStatusLine(
+ aRequest.httpVersion,
+ 416,
+ "Requested Range Not Satisfiable"
+ );
+ aResponse.setHeader("Content-Range", "*/" + data.length, false);
+ aResponse.finish();
+ return;
+ }
+
+ aResponse.setStatusLine(aRequest.httpVersion, 206, "Partial Content");
+ aResponse.setHeader(
+ "Content-Range",
+ firstBytePos + "-" + lastBytePos + "/" + data.length,
+ false
+ );
+
+ data = data.substring(firstBytePos, lastBytePos + 1);
+
+ gMostRecentFirstBytePos = firstBytePos;
+ } else {
+ gMostRecentFirstBytePos = 0;
+ }
+
+ aResponse.setHeader("Content-Length", "" + data.length, false);
+
+ aResponse.write(data.substring(0, data.length / 2));
+
+ // Store the second part of the data on the response object, so that it
+ // can be used by the secondPart function.
+ aResponse.secondPartData = data.substring(data.length / 2);
+ },
+ function secondPart(aRequest, aResponse) {
+ aResponse.write(aResponse.secondPartData);
+ }
+ );
+
+ registerInterruptibleHandler(
+ "/interruptible_gzip.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader("Content-Encoding", "gzip", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT_GZIP_ENCODED.length
+ );
+
+ let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
+ bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_FIRST);
+ },
+ function secondPart(aRequest, aResponse) {
+ let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
+ bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_SECOND);
+ }
+ );
+
+ gHttpServer.registerPathHandler(
+ "/shorter-than-content-length-http-1-1.txt",
+ function (aRequest, aResponse) {
+ aResponse.processAsync();
+ aResponse.setStatusLine("1.1", 200, "OK");
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT.length * 2,
+ false
+ );
+ aResponse.write(TEST_DATA_SHORT);
+ aResponse.finish();
+ }
+ );
+
+ gHttpServer.registerPathHandler("/busy.txt", function (aRequest, aResponse) {
+ aResponse.setStatusLine("1.1", 504, "Gateway Timeout");
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader("Content-Length", "" + TEST_DATA_SHORT.length, false);
+ aResponse.write(TEST_DATA_SHORT);
+ });
+
+ gHttpServer.registerPathHandler("/redirect", function (aRequest, aResponse) {
+ aResponse.setStatusLine("1.1", 301, "Moved Permanently");
+ aResponse.setHeader("Location", httpUrl("busy.txt"), false);
+ aResponse.setHeader("Content-Type", "text/javascript", false);
+ aResponse.setHeader("Content-Length", "0", false);
+ });
+
+ // This URL will emulate being blocked by Windows Parental controls
+ gHttpServer.registerPathHandler(
+ "/parentalblocked.zip",
+ function (aRequest, aResponse) {
+ aResponse.setStatusLine(
+ aRequest.httpVersion,
+ 450,
+ "Blocked by Windows Parental Controls"
+ );
+ }
+ );
+
+ // This URL sends some data followed by an RST packet
+ gHttpServer.registerPathHandler(
+ "/netreset.txt",
+ function (aRequest, aResponse) {
+ info("Starting response that will be aborted.");
+ aResponse.processAsync();
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.write(TEST_DATA_SHORT);
+ promiseExecuteSoon()
+ .then(() => {
+ aResponse.abort(null, true);
+ aResponse.finish();
+ info("Aborting response with network reset.");
+ })
+ .then(null, console.error);
+ }
+ );
+
+ // During unit tests, most of the functions that require profile access or
+ // operating system features will be disabled. Individual tests may override
+ // them again to check for specific behaviors.
+ Integration.downloads.register(base => {
+ let override = {
+ loadPublicDownloadListFromStore: () => Promise.resolve(),
+ shouldKeepBlockedData: () => Promise.resolve(false),
+ shouldBlockForParentalControls: () => Promise.resolve(false),
+ shouldBlockForReputationCheck: () =>
+ Promise.resolve({
+ shouldBlock: false,
+ verdict: "",
+ }),
+ confirmLaunchExecutable: () => Promise.resolve(),
+ launchFile: () => Promise.resolve(),
+ showContainingDirectory: () => Promise.resolve(),
+ // This flag allows re-enabling the default observers during their tests.
+ allowObservers: false,
+ addListObservers() {
+ return this.allowObservers
+ ? super.addListObservers(...arguments)
+ : Promise.resolve();
+ },
+ // This flag allows re-enabling the download directory logic for its tests.
+ _allowDirectories: false,
+ set allowDirectories(value) {
+ this._allowDirectories = value;
+ // We have to invalidate the previously computed directory path.
+ this._downloadsDirectory = null;
+ },
+ _getDirectory(name) {
+ return super._getDirectory(this._allowDirectories ? name : "TmpD");
+ },
+ };
+ Object.setPrototypeOf(override, base);
+ return override;
+ });
+
+ // Make sure that downloads started using nsIExternalHelperAppService are
+ // saved to disk without asking for a destination interactively.
+ let mock = {
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
+ promptForSaveToFileAsync(
+ aLauncher,
+ aWindowContext,
+ aDefaultFileName,
+ aSuggestedFileExtension,
+ aForcePrompt
+ ) {
+ // The dialog should create the empty placeholder file.
+ let file = getTempFile(TEST_TARGET_FILE_NAME);
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ aLauncher.saveDestinationAvailable(file);
+ },
+ };
+
+ let cid = MockRegistrar.register(
+ "@mozilla.org/helperapplauncherdialog;1",
+ mock
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(cid);
+ });
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js b/toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js
new file mode 100644
index 0000000000..2484e591a4
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js
@@ -0,0 +1,113 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+);
+
+const errors = [
+ Downloads.Error.BLOCK_VERDICT_MALWARE,
+ Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED,
+ Downloads.Error.BLOCK_VERDICT_INSECURE,
+ Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+];
+
+add_task(async function test_confirm_block_download() {
+ for (let error of errors) {
+ info(`Testing block ${error} download`);
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD"
+ );
+
+ let download;
+ try {
+ info(`Create ${error} download`);
+ if (error == Downloads.Error.BLOCK_VERDICT_INSECURE) {
+ download = await promiseStartLegacyDownload(null, {
+ downloadClassification: Ci.nsITransfer.DOWNLOAD_POTENTIALLY_UNSAFE,
+ });
+ } else {
+ download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ useLegacySaver: false,
+ verdict: error,
+ });
+ }
+ await download.start();
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ }
+
+ // Test blocked download is recorded
+ TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 0, 1);
+
+ // Test confirm block
+ histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD"
+ );
+ info(`Block ${error} download`);
+ await download.confirmBlock();
+ TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 1, 1);
+ }
+});
+
+add_task(async function test_confirm_unblock_download() {
+ for (let error of errors) {
+ info(`Testing unblock ${error} download`);
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD"
+ );
+
+ let download;
+ try {
+ info(`Create ${error} download`);
+ if (error == Downloads.Error.BLOCK_VERDICT_INSECURE) {
+ download = await promiseStartLegacyDownload(null, {
+ downloadClassification: Ci.nsITransfer.DOWNLOAD_POTENTIALLY_UNSAFE,
+ });
+ } else {
+ download = await promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ useLegacySaver: false,
+ verdict: error,
+ });
+ }
+ await download.start();
+ do_throw("The download should have failed.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error)) {
+ throw ex;
+ }
+ }
+
+ // Test blocked download is recorded
+ TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 0, 1);
+
+ // Test unblock
+ histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD"
+ );
+ info(`Unblock ${error} download`);
+ let promise = new Promise(r => (download.onchange = r));
+ await download.unblock();
+ // The environment is not set up properly for performing a real download, cancel
+ // the unblocked download so it doesn't affect the next testcase.
+ await download.cancel();
+ await promise;
+ if (error == Downloads.Error.BLOCK_VERDICT_INSECURE) {
+ Assert.ok(!download.error, "Ensure we didn't set download.error");
+ }
+
+ TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 2, 1);
+ }
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadCore.js b/toolkit/components/downloads/test/unit/test_DownloadCore.js
new file mode 100644
index 0000000000..41aeaac33b
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadCore.js
@@ -0,0 +1,291 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the main download interfaces using DownloadCopySaver.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadError: "resource://gre/modules/DownloadCore.sys.mjs",
+});
+
+// Execution of common tests
+
+// This is used in common_test_Download.js
+// eslint-disable-next-line no-unused-vars
+var gUseLegacySaver = false;
+
+var scriptFile = do_get_file("common_test_Download.js");
+Services.scriptloader.loadSubScript(NetUtil.newURI(scriptFile).spec);
+
+// Tests
+
+/**
+ * The download should fail early if the source and the target are the same.
+ */
+add_task(async function test_error_target_downloadingToSameFile() {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ let download = await Downloads.createDownload({
+ source: NetUtil.newURI(targetFile),
+ target: targetFile,
+ });
+ await Assert.rejects(
+ download.start(),
+ ex => ex instanceof Downloads.Error && ex.becauseTargetFailed
+ );
+
+ Assert.ok(
+ await IOUtils.exists(download.target.path),
+ "The file should not have been deleted."
+ );
+});
+
+/**
+ * Tests allowHttpStatus allowing requests
+ */
+add_task(async function test_error_notfound() {
+ const targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ let called = false;
+ const download = await Downloads.createDownload({
+ source: {
+ url: httpUrl("notfound.gone"),
+ allowHttpStatus(aDownload, aStatusCode) {
+ Assert.strictEqual(download, aDownload, "Check Download objects");
+ Assert.strictEqual(aStatusCode, 404, "The status should be correct");
+ called = true;
+ return true;
+ },
+ },
+ target: targetFile,
+ });
+ await download.start();
+ Assert.ok(called, "allowHttpStatus should have been called");
+});
+
+/**
+ * Tests allowHttpStatus rejecting requests
+ */
+add_task(async function test_error_notfound_reject() {
+ const targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ let called = false;
+ const download = await Downloads.createDownload({
+ source: {
+ url: httpUrl("notfound.gone"),
+ allowHttpStatus(aDownload, aStatusCode) {
+ Assert.strictEqual(download, aDownload, "Check Download objects");
+ Assert.strictEqual(aStatusCode, 404, "The status should be correct");
+ called = true;
+ return false;
+ },
+ },
+ target: targetFile,
+ });
+ await Assert.rejects(
+ download.start(),
+ ex => ex instanceof Downloads.Error && ex.becauseSourceFailed,
+ "Download should have been rejected"
+ );
+ Assert.ok(called, "allowHttpStatus should have been called");
+});
+
+/**
+ * Tests allowHttpStatus rejecting requests other than 404
+ */
+add_task(async function test_error_busy_reject() {
+ const targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ let called = false;
+ const download = await Downloads.createDownload({
+ source: {
+ url: httpUrl("busy.txt"),
+ allowHttpStatus(aDownload, aStatusCode) {
+ Assert.strictEqual(download, aDownload, "Check Download objects");
+ Assert.strictEqual(aStatusCode, 504, "The status should be correct");
+ called = true;
+ return false;
+ },
+ },
+ target: targetFile,
+ });
+ await Assert.rejects(
+ download.start(),
+ ex => ex instanceof Downloads.Error && ex.becauseSourceFailed,
+ "Download should have been rejected"
+ );
+ Assert.ok(called, "allowHttpStatus should have been called");
+});
+
+/**
+ * Tests redirects are followed correctly, and the meta data corresponds
+ * to the correct, final response
+ */
+add_task(async function test_redirects() {
+ const targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ let called = false;
+ const download = await Downloads.createDownload({
+ source: {
+ url: httpUrl("redirect"),
+ allowHttpStatus(aDownload, aStatusCode) {
+ Assert.strictEqual(download, aDownload, "Check Download objects");
+ Assert.strictEqual(
+ aStatusCode,
+ 504,
+ "The status should be correct after a redirect"
+ );
+ called = true;
+ return true;
+ },
+ },
+ target: targetFile,
+ });
+ await download.start();
+ Assert.equal(
+ download.contentType,
+ "text/plain",
+ "Content-Type is correct after redirect"
+ );
+ Assert.equal(
+ download.totalBytes,
+ TEST_DATA_SHORT.length,
+ "Content-Length is correct after redirect"
+ );
+ Assert.equal(download.target.size, TEST_DATA_SHORT.length);
+ Assert.ok(called, "allowHttpStatus should have been called");
+});
+
+/**
+ * Tests the DownloadError object.
+ */
+add_task(function test_DownloadError() {
+ let error = new DownloadError({
+ result: Cr.NS_ERROR_NOT_RESUMABLE,
+ message: "Not resumable.",
+ });
+ Assert.equal(error.result, Cr.NS_ERROR_NOT_RESUMABLE);
+ Assert.equal(error.message, "Not resumable.");
+ Assert.ok(!error.becauseSourceFailed);
+ Assert.ok(!error.becauseTargetFailed);
+ Assert.ok(!error.becauseBlocked);
+ Assert.ok(!error.becauseBlockedByParentalControls);
+
+ error = new DownloadError({ message: "Unknown error." });
+ Assert.equal(error.result, Cr.NS_ERROR_FAILURE);
+ Assert.equal(error.message, "Unknown error.");
+
+ error = new DownloadError({ result: Cr.NS_ERROR_NOT_RESUMABLE });
+ Assert.equal(error.result, Cr.NS_ERROR_NOT_RESUMABLE);
+ Assert.ok(error.message.indexOf("Exception") > 0);
+
+ // becauseSourceFailed will be set, but not the unknown property.
+ error = new DownloadError({
+ message: "Unknown error.",
+ becauseSourceFailed: true,
+ becauseUnknown: true,
+ });
+ Assert.ok(error.becauseSourceFailed);
+ Assert.equal(false, "becauseUnknown" in error);
+
+ error = new DownloadError({
+ result: Cr.NS_ERROR_MALFORMED_URI,
+ inferCause: true,
+ });
+ Assert.equal(error.result, Cr.NS_ERROR_MALFORMED_URI);
+ Assert.ok(error.becauseSourceFailed);
+ Assert.ok(!error.becauseTargetFailed);
+ Assert.ok(!error.becauseBlocked);
+ Assert.ok(!error.becauseBlockedByParentalControls);
+
+ // This test does not set inferCause, so becauseSourceFailed will not be set.
+ error = new DownloadError({ result: Cr.NS_ERROR_MALFORMED_URI });
+ Assert.equal(error.result, Cr.NS_ERROR_MALFORMED_URI);
+ Assert.ok(!error.becauseSourceFailed);
+
+ error = new DownloadError({
+ result: Cr.NS_ERROR_FILE_INVALID_PATH,
+ inferCause: true,
+ });
+ Assert.equal(error.result, Cr.NS_ERROR_FILE_INVALID_PATH);
+ Assert.ok(!error.becauseSourceFailed);
+ Assert.ok(error.becauseTargetFailed);
+ Assert.ok(!error.becauseBlocked);
+ Assert.ok(!error.becauseBlockedByParentalControls);
+
+ error = new DownloadError({ becauseBlocked: true });
+ Assert.equal(error.message, "Download blocked.");
+ Assert.ok(!error.becauseSourceFailed);
+ Assert.ok(!error.becauseTargetFailed);
+ Assert.ok(error.becauseBlocked);
+ Assert.ok(!error.becauseBlockedByParentalControls);
+
+ error = new DownloadError({ becauseBlockedByParentalControls: true });
+ Assert.equal(error.message, "Download blocked.");
+ Assert.ok(!error.becauseSourceFailed);
+ Assert.ok(!error.becauseTargetFailed);
+ Assert.ok(error.becauseBlocked);
+ Assert.ok(error.becauseBlockedByParentalControls);
+});
+
+add_task(async function test_cancel_interrupted_download() {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+
+ let download = await Downloads.createDownload({
+ source: httpUrl("interruptible_resumable.txt"),
+ target: targetFile,
+ });
+
+ async function createAndCancelDownload() {
+ info("Create an interruptible download and cancel it midway");
+ mustInterruptResponses();
+ const promiseDownloaded = download.start();
+ await promiseDownloadMidway(download);
+ await download.cancel();
+
+ info("Unblock the interruptible download and wait for its annotation");
+ continueResponses();
+ await waitForAnnotation(
+ httpUrl("interruptible_resumable.txt"),
+ "downloads/destinationFileURI"
+ );
+
+ await Assert.rejects(
+ promiseDownloaded,
+ /DownloadError: Download canceled/,
+ "Got a download error as expected"
+ );
+ }
+
+ await new Promise(resolve => {
+ const DONE = "=== download xpcshell test console listener done ===";
+ const logDone = () => Services.console.logStringMessage(DONE);
+ const consoleListener = msg => {
+ if (msg == DONE) {
+ Services.console.unregisterListener(consoleListener);
+ resolve();
+ }
+ };
+ Services.console.reset();
+ Services.console.registerListener(consoleListener);
+
+ createAndCancelDownload().then(logDone);
+ });
+
+ info(
+ "Assert that nsIStreamListener.onDataAvailable has not been called after download.cancel"
+ );
+ let found = Services.console
+ .getMessageArray()
+ .map(m => m.message)
+ .filter(message => {
+ return message.includes("nsIStreamListener.onDataAvailable");
+ });
+ Assert.deepEqual(
+ found,
+ [],
+ "Expect no nsIStreamListener.onDataAvaialable error"
+ );
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadHistory.js b/toolkit/components/downloads/test/unit/test_DownloadHistory.js
new file mode 100644
index 0000000000..69eb1c4728
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadHistory.js
@@ -0,0 +1,273 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the DownloadHistory module.
+ */
+
+"use strict";
+
+const { DownloadHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadHistory.sys.mjs"
+);
+
+let baseDate = new Date("2000-01-01");
+
+/**
+ * Non-fatal assertion used to test whether the downloads in the list already
+ * match the expected state.
+ */
+function areEqual(a, b) {
+ if (a === b) {
+ Assert.equal(a, b);
+ return true;
+ }
+ info(a + " !== " + b);
+ return false;
+}
+
+/**
+ * This allows waiting for an expected list at various points during the test.
+ */
+class TestView {
+ constructor(expected) {
+ this.expected = [...expected];
+ this.downloads = [];
+ this.resolveWhenExpected = () => {};
+ }
+ onDownloadAdded(download, options = {}) {
+ if (options.insertBefore) {
+ let index = this.downloads.indexOf(options.insertBefore);
+ this.downloads.splice(index, 0, download);
+ } else {
+ this.downloads.push(download);
+ }
+ this.checkForExpectedDownloads();
+ }
+ onDownloadChanged(download) {
+ this.checkForExpectedDownloads();
+ }
+ onDownloadRemoved(download) {
+ let index = this.downloads.indexOf(download);
+ this.downloads.splice(index, 1);
+ this.checkForExpectedDownloads();
+ }
+ checkForExpectedDownloads() {
+ // Wait for all the expected downloads to be added or removed before doing
+ // the detailed tests. This is done to avoid creating irrelevant output.
+ if (this.downloads.length != this.expected.length) {
+ return;
+ }
+ for (let i = 0; i < this.downloads.length; i++) {
+ if (
+ this.downloads[i].source.url != this.expected[i].source.url ||
+ this.downloads[i].target.path != this.expected[i].target.path
+ ) {
+ return;
+ }
+ }
+ // Check and report the actual state of the downloads. Even if the items
+ // are in the expected order, the metadata for history downloads might not
+ // have been updated to the final state yet.
+ for (let i = 0; i < this.downloads.length; i++) {
+ let download = this.downloads[i];
+ let testDownload = this.expected[i];
+ info(
+ "Checking download source " +
+ download.source.url +
+ " with target " +
+ download.target.path
+ );
+ if (
+ !areEqual(download.succeeded, !!testDownload.succeeded) ||
+ !areEqual(download.canceled, !!testDownload.canceled) ||
+ !areEqual(download.hasPartialData, !!testDownload.hasPartialData) ||
+ !areEqual(!!download.error, !!testDownload.error)
+ ) {
+ return;
+ }
+ // If the above properties match, the error details should be correct.
+ if (download.error) {
+ if (testDownload.error.becauseSourceFailed) {
+ Assert.equal(download.error.message, "History download failed.");
+ }
+ Assert.equal(
+ download.error.becauseBlockedByParentalControls,
+ testDownload.error.becauseBlockedByParentalControls
+ );
+ Assert.equal(
+ download.error.becauseBlockedByReputationCheck,
+ testDownload.error.becauseBlockedByReputationCheck
+ );
+ }
+ }
+ this.resolveWhenExpected();
+ }
+ async waitForExpected() {
+ let promise = new Promise(resolve => (this.resolveWhenExpected = resolve));
+ this.checkForExpectedDownloads();
+ await promise;
+ }
+}
+
+/**
+ * Tests that various operations on session and history downloads are reflected
+ * by the DownloadHistoryList object, and that the order of results is correct.
+ */
+add_task(async function test_DownloadHistory() {
+ // Clean up at the beginning and at the end of the test.
+ async function cleanup() {
+ await PlacesUtils.history.clear();
+ }
+ registerCleanupFunction(cleanup);
+ await cleanup();
+
+ let testDownloads = [
+ // History downloads should appear in order at the beginning of the list.
+ { offset: 10, canceled: true },
+ { offset: 20, succeeded: true },
+ { offset: 30, error: { becauseSourceFailed: true } },
+ { offset: 40, error: { becauseBlockedByParentalControls: true } },
+ { offset: 50, error: { becauseBlockedByReputationCheck: true } },
+ // Session downloads should show up after all the history download, in the
+ // same order as they were added.
+ { offset: 45, canceled: true, inSession: true },
+ { offset: 35, canceled: true, hasPartialData: true, inSession: true },
+ { offset: 55, succeeded: true, inSession: true },
+ ];
+ const NEXT_OFFSET = 60;
+
+ let publicList = await promiseNewList();
+ let allList = await Downloads.getList(Downloads.ALL);
+
+ async function addTestDownload(properties) {
+ properties.source = {
+ url: httpUrl("source" + properties.offset),
+ isPrivate: properties.isPrivate,
+ };
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME + properties.offset);
+ properties.target = { path: targetFile.path };
+ properties.startTime = new Date(baseDate.getTime() + properties.offset);
+
+ let download = await Downloads.createDownload(properties);
+ if (properties.inSession) {
+ await allList.add(download);
+ }
+
+ if (properties.isPrivate) {
+ return;
+ }
+
+ // Add the download to history using the XPCOM service, then use the
+ // DownloadHistory module to save the associated metadata.
+ let promiseFileAnnotation = waitForAnnotation(
+ properties.source.url,
+ "downloads/destinationFileURI"
+ );
+ let promiseMetaAnnotation = waitForAnnotation(
+ properties.source.url,
+ "downloads/metaData"
+ );
+ let promiseVisit = promiseWaitForVisit(properties.source.url);
+ await DownloadHistory.addDownloadToHistory(download);
+ await promiseVisit;
+ await DownloadHistory.updateMetaData(download);
+ await Promise.all([promiseFileAnnotation, promiseMetaAnnotation]);
+ }
+
+ // Add all the test downloads to history.
+ for (let properties of testDownloads) {
+ await addTestDownload(properties);
+ }
+
+ // Initialize DownloadHistoryList only after having added the history and
+ // session downloads, and check that they are loaded in the correct order.
+ let historyList = await DownloadHistory.getList();
+ let view = new TestView(testDownloads);
+ await historyList.addView(view);
+ await view.waitForExpected();
+
+ // Remove a download from history and verify that the change is reflected.
+ let downloadToRemove = view.expected[1];
+ view.expected.splice(1, 1);
+ await PlacesUtils.history.remove(downloadToRemove.source.url);
+ await view.waitForExpected();
+
+ // Add a download to history and verify it's placed before session downloads,
+ // even if the start date is more recent.
+ let downloadToAdd = { offset: NEXT_OFFSET, canceled: true };
+ view.expected.splice(
+ view.expected.findIndex(d => d.inSession),
+ 0,
+ downloadToAdd
+ );
+ await addTestDownload(downloadToAdd);
+ await view.waitForExpected();
+
+ // Add a session download and verify it's placed after all session downloads,
+ // even if the start date is less recent.
+ let sessionDownloadToAdd = { offset: 0, inSession: true, succeeded: true };
+ view.expected.push(sessionDownloadToAdd);
+ await addTestDownload(sessionDownloadToAdd);
+ await view.waitForExpected();
+
+ // Add a session download for the same URI without a history entry, and verify
+ // it's visible and placed after all session downloads.
+ view.expected.push(sessionDownloadToAdd);
+ await publicList.add(await Downloads.createDownload(sessionDownloadToAdd));
+ await view.waitForExpected();
+
+ // Create a new DownloadHistoryList that also shows private downloads. Since
+ // we only have public downloads, the two lists should contain the same items.
+ let allHistoryList = await DownloadHistory.getList({ type: Downloads.ALL });
+ let allView = new TestView(view.expected);
+ await allHistoryList.addView(allView);
+ await allView.waitForExpected();
+
+ // Add a new private download and verify it appears only on the complete list.
+ let privateDownloadToAdd = {
+ offset: NEXT_OFFSET + 10,
+ inSession: true,
+ succeeded: true,
+ isPrivate: true,
+ };
+ allView.expected.push(privateDownloadToAdd);
+ await addTestDownload(privateDownloadToAdd);
+ await view.waitForExpected();
+ await allView.waitForExpected();
+
+ // Now test the maxHistoryResults parameter.
+ let allHistoryList2 = await DownloadHistory.getList({
+ type: Downloads.ALL,
+ maxHistoryResults: 3,
+ });
+ // Prepare the set of downloads to contain fewer history downloads by removing
+ // the oldest ones.
+ let allView2 = new TestView(allView.expected.slice(3));
+ await allHistoryList2.addView(allView2);
+ await allView2.waitForExpected();
+
+ // Create a dummy list and view like the previous limited one to just add and
+ // remove its view to make sure it doesn't break other lists' updates.
+ let dummyList = await DownloadHistory.getList({
+ type: Downloads.ALL,
+ maxHistoryResults: 3,
+ });
+ let dummyView = new TestView([]);
+ await dummyList.addView(dummyView);
+ await dummyList.removeView(dummyView);
+
+ // Clear history and check that session downloads with partial data remain.
+ // Private downloads are also not cleared when clearing history.
+ view.expected = view.expected.filter(d => d.hasPartialData);
+ allView.expected = allView.expected.filter(
+ d => d.hasPartialData || d.isPrivate
+ );
+ await PlacesUtils.history.clear();
+ await view.waitForExpected();
+ await allView.waitForExpected();
+
+ // Check that the dummy view above did not prevent the limited from updating.
+ allView2.expected = allView.expected;
+ await allView2.waitForExpected();
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js
new file mode 100644
index 0000000000..ea41038bf1
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { DownloadHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadHistory.sys.mjs"
+);
+const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+
+let baseDate = new Date("2000-01-01");
+
+/**
+ * This test is designed to ensure the cache of download history is correctly
+ * loaded and initialized. We do this by having the test as the only test in
+ * this file, and injecting data into the places database before we start.
+ */
+add_task(async function test_DownloadHistory_initialization() {
+ // Clean up at the beginning and at the end of the test.
+ async function cleanup() {
+ await PlacesUtils.history.clear();
+ }
+ registerCleanupFunction(cleanup);
+ await cleanup();
+
+ let testDownloads = [];
+ for (let i = 10; i <= 30; i += 10) {
+ let targetFile = getTempFile(`${TEST_TARGET_FILE_NAME}${i}`);
+ let download = {
+ source: {
+ url: httpUrl(`source${i}`),
+ isPrivate: false,
+ },
+ target: { path: targetFile.path },
+ endTime: baseDate.getTime() + i,
+ fileSize: 100 + i,
+ state: i / 10,
+ };
+
+ await PlacesTestUtils.addVisits([
+ {
+ uri: download.source.url,
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ },
+ ]);
+
+ let targetUri = Services.io.newFileURI(
+ new FileUtils.File(download.target.path)
+ );
+
+ await PlacesUtils.history.update({
+ annotations: new Map([
+ ["downloads/destinationFileURI", targetUri.spec],
+ [
+ "downloads/metaData",
+ JSON.stringify({
+ state: download.state,
+ endTime: download.endTime,
+ fileSize: download.fileSize,
+ }),
+ ],
+ ]),
+ url: download.source.url,
+ });
+
+ testDownloads.push(download);
+ }
+
+ // Initialize DownloadHistoryList only after having added the history and
+ // session downloads.
+ let historyList = await DownloadHistory.getList();
+ let downloads = await historyList.getAll();
+ Assert.equal(downloads.length, testDownloads.length);
+
+ for (let expected of testDownloads) {
+ let download = downloads.find(d => d.source.url == expected.source.url);
+
+ info(`Checking download ${expected.source.url}`);
+ Assert.ok(download, "Should have found the expected download");
+ Assert.equal(
+ download.endTime,
+ expected.endTime,
+ "Should have the correct end time"
+ );
+ Assert.equal(
+ download.target.size,
+ expected.fileSize,
+ "Should have the correct file size"
+ );
+ Assert.equal(
+ download.succeeded,
+ expected.state == 1,
+ "Should have the correct succeeded value"
+ );
+ Assert.equal(
+ download.canceled,
+ expected.state == 3,
+ "Should have the correct canceled value"
+ );
+ Assert.equal(
+ download.target.path,
+ expected.target.path,
+ "Should have the correct target path"
+ );
+ }
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js
new file mode 100644
index 0000000000..8b714a8fae
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { DownloadHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadHistory.sys.mjs"
+);
+
+/**
+ * This test is designed to ensure the cache of download history is correctly
+ * loaded and initialized via adding downloads. We do this by having the test as
+ * the only test in this file.
+ */
+add_task(async function test_initialization_via_addDownload() {
+ // Clean up at the beginning and at the end of the test.
+ async function cleanup() {
+ await PlacesUtils.history.clear();
+ }
+ registerCleanupFunction(cleanup);
+ await cleanup();
+
+ const download1FileLocation = getTempFile(`${TEST_TARGET_FILE_NAME}1`).path;
+ const download2FileLocation = getTempFile(`${TEST_TARGET_FILE_NAME}2`).path;
+ const download = {
+ source: {
+ url: httpUrl(`source1`),
+ isPrivate: false,
+ },
+ target: { path: download1FileLocation },
+ };
+
+ await DownloadHistory.addDownloadToHistory(download);
+
+ // Initialize DownloadHistoryList only after having added the history and
+ // session downloads.
+ let historyList = await DownloadHistory.getList();
+ let downloads = await historyList.getAll();
+ Assert.equal(downloads.length, 1, "Should have only one entry");
+
+ Assert.equal(
+ downloads[0].target.path,
+ download1FileLocation,
+ "Should have the correct target path"
+ );
+
+ // Now re-add the download but with a different target.
+ download.target.path = download2FileLocation;
+
+ await DownloadHistory.addDownloadToHistory(download);
+
+ historyList = await DownloadHistory.getList();
+ downloads = await historyList.getAll();
+ Assert.equal(downloads.length, 1, "Should still have only one entry");
+
+ Assert.equal(
+ downloads[0].target.path,
+ download2FileLocation,
+ "Should have the correct revised target path"
+ );
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadIntegration.js b/toolkit/components/downloads/test/unit/test_DownloadIntegration.js
new file mode 100644
index 0000000000..7ee34e8307
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadIntegration.js
@@ -0,0 +1,441 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the DownloadIntegration object.
+ */
+
+"use strict";
+
+// Globals
+
+/**
+ * Notifies the prompt observers and verify the expected downloads count.
+ *
+ * @param aIsPrivate
+ * Flag to know is test private observers.
+ * @param aExpectedCount
+ * the expected downloads count for quit and offline observers.
+ * @param aExpectedPBCount
+ * the expected downloads count for private browsing observer.
+ */
+function notifyPromptObservers(aIsPrivate, aExpectedCount, aExpectedPBCount) {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+
+ // Notify quit application requested observer.
+ DownloadIntegration._testPromptDownloads = -1;
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+ Assert.equal(DownloadIntegration._testPromptDownloads, aExpectedCount);
+
+ // Notify offline requested observer.
+ DownloadIntegration._testPromptDownloads = -1;
+ Services.obs.notifyObservers(cancelQuit, "offline-requested");
+ Assert.equal(DownloadIntegration._testPromptDownloads, aExpectedCount);
+
+ if (aIsPrivate) {
+ // Notify last private browsing requested observer.
+ DownloadIntegration._testPromptDownloads = -1;
+ Services.obs.notifyObservers(cancelQuit, "last-pb-context-exiting");
+ Assert.equal(DownloadIntegration._testPromptDownloads, aExpectedPBCount);
+ }
+
+ delete DownloadIntegration._testPromptDownloads;
+}
+
+// Tests
+
+/**
+ * Allows re-enabling the real download directory logic during one test.
+ */
+function allowDirectoriesInTest() {
+ DownloadIntegration.allowDirectories = true;
+ function cleanup() {
+ DownloadIntegration.allowDirectories = false;
+ }
+ registerCleanupFunction(cleanup);
+ return cleanup;
+}
+
+ChromeUtils.defineLazyGetter(this, "gStringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/downloads.properties"
+ );
+});
+
+/**
+ * Tests that getSystemDownloadsDirectory returns an existing directory or
+ * creates a new directory depending on the platform. Instead of the real
+ * directory, this test is executed in the temporary directory so we can safely
+ * delete the created folder to check whether it is created again.
+ */
+add_task(async function test_getSystemDownloadsDirectory_exists_or_creates() {
+ let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ let downloadDir;
+
+ // OSX / Linux / Windows but not XP/2k
+ if (
+ Services.appinfo.OS == "Darwin" ||
+ Services.appinfo.OS == "Linux" ||
+ (Services.appinfo.OS == "WINNT" &&
+ parseFloat(Services.sysinfo.getProperty("version")) >= 6)
+ ) {
+ downloadDir = await DownloadIntegration.getSystemDownloadsDirectory();
+ Assert.equal(downloadDir, tempDir.path);
+ Assert.ok(await IOUtils.exists(downloadDir));
+
+ let info = await IOUtils.stat(downloadDir);
+ Assert.equal(info.type, "directory");
+ } else {
+ let targetPath = PathUtils.join(
+ tempDir.path,
+ gStringBundle.GetStringFromName("downloadsFolder")
+ );
+ try {
+ await IOUtils.remove(targetPath);
+ } catch (e) {}
+ downloadDir = await DownloadIntegration.getSystemDownloadsDirectory();
+ Assert.equal(downloadDir, targetPath);
+ Assert.ok(await IOUtils.exists(downloadDir));
+
+ let info = await IOUtils.stat(downloadDir);
+ Assert.equal(info.type, "directory");
+ await IOUtils.remove(targetPath);
+ }
+});
+
+/**
+ * Tests that the real directory returned by getSystemDownloadsDirectory is not
+ * the one that is used during unit tests. Since this is the actual downloads
+ * directory of the operating system, we don't try to delete it afterwards.
+ */
+add_task(async function test_getSystemDownloadsDirectory_real() {
+ let fakeDownloadDir = await DownloadIntegration.getSystemDownloadsDirectory();
+
+ let cleanup = allowDirectoriesInTest();
+ let realDownloadDir = await DownloadIntegration.getSystemDownloadsDirectory();
+ cleanup();
+
+ Assert.notEqual(fakeDownloadDir, realDownloadDir);
+});
+
+/**
+ * Tests that the getPreferredDownloadsDirectory returns a valid download
+ * directory string path.
+ */
+add_task(async function test_getPreferredDownloadsDirectory() {
+ let cleanupDirectories = allowDirectoriesInTest();
+
+ let folderListPrefName = "browser.download.folderList";
+ let dirPrefName = "browser.download.dir";
+ function cleanupPrefs() {
+ Services.prefs.clearUserPref(folderListPrefName);
+ Services.prefs.clearUserPref(dirPrefName);
+ }
+ registerCleanupFunction(cleanupPrefs);
+
+ // For legacy cloudstorage users with folderListPrefName as 3,
+ // Should return the system downloads directory because the dir preference
+ // is not set.
+ Services.prefs.setIntPref(folderListPrefName, 3);
+ let systemDir = await DownloadIntegration.getSystemDownloadsDirectory();
+ let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, systemDir);
+
+ // Should return the system downloads directory.
+ Services.prefs.setIntPref(folderListPrefName, 1);
+ systemDir = await DownloadIntegration.getSystemDownloadsDirectory();
+ downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, systemDir);
+
+ // Should return the desktop directory.
+ Services.prefs.setIntPref(folderListPrefName, 0);
+ downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, Services.dirsvc.get("Desk", Ci.nsIFile).path);
+
+ // Should return the system downloads directory because the dir preference
+ // is not set.
+ Services.prefs.setIntPref(folderListPrefName, 2);
+ downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, systemDir);
+
+ // Should return the directory which is listed in the dir preference.
+ let time = new Date().getTime();
+ let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempDir.append(time);
+ Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir);
+ downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+ Assert.equal(downloadDir, tempDir.path);
+ Assert.ok(await IOUtils.exists(downloadDir));
+ await IOUtils.remove(tempDir.path);
+
+ // Should return the system downloads directory beacause the path is invalid
+ // in the dir preference.
+ tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempDir.append("dir_not_exist");
+ tempDir.append(time);
+ Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir);
+ downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.equal(downloadDir, systemDir);
+
+ // Should return the system downloads directory because the folderList
+ // preference is invalid
+ Services.prefs.setIntPref(folderListPrefName, 999);
+ downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.equal(downloadDir, systemDir);
+
+ cleanupPrefs();
+ cleanupDirectories();
+});
+
+/**
+ * Tests that the getTemporaryDownloadsDirectory returns a valid download
+ * directory string path.
+ */
+add_task(async function test_getTemporaryDownloadsDirectory() {
+ let cleanup = allowDirectoriesInTest();
+
+ let downloadDir = await DownloadIntegration.getTemporaryDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+
+ if ("nsILocalFileMac" in Ci) {
+ let preferredDownloadDir =
+ await DownloadIntegration.getPreferredDownloadsDirectory();
+ Assert.equal(downloadDir, preferredDownloadDir);
+ } else {
+ let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ Assert.equal(downloadDir, tempDir.path);
+ }
+
+ cleanup();
+});
+
+// Tests DownloadObserver
+
+/**
+ * Re-enables the default observers for the following tests.
+ *
+ * This takes effect the first time a DownloadList object is created, and lasts
+ * until this test file has completed.
+ */
+add_task(async function test_observers_setup() {
+ DownloadIntegration.allowObservers = true;
+ registerCleanupFunction(function () {
+ DownloadIntegration.allowObservers = false;
+ });
+});
+
+/**
+ * Tests notifications prompts when observers are notified if there are public
+ * and private active downloads.
+ */
+add_task(async function test_notifications() {
+ for (let isPrivate of [false, true]) {
+ mustInterruptResponses();
+
+ let list = await promiseNewList(isPrivate);
+ let download1 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let download2 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let download3 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let promiseAttempt1 = download1.start();
+ let promiseAttempt2 = download2.start();
+ download3.start().catch(() => {});
+
+ // Add downloads to list.
+ await list.add(download1);
+ await list.add(download2);
+ await list.add(download3);
+ // Cancel third download
+ await download3.cancel();
+
+ notifyPromptObservers(isPrivate, 2, 2);
+
+ // Allow the downloads to complete.
+ continueResponses();
+ await promiseAttempt1;
+ await promiseAttempt2;
+
+ // Clean up.
+ await list.remove(download1);
+ await list.remove(download2);
+ await list.remove(download3);
+ }
+});
+
+/**
+ * Tests that notifications prompts observers are not notified if there are no
+ * public or private active downloads.
+ */
+add_task(async function test_no_notifications() {
+ for (let isPrivate of [false, true]) {
+ let list = await promiseNewList(isPrivate);
+ let download1 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let download2 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ download1.start().catch(() => {});
+ download2.start().catch(() => {});
+
+ // Add downloads to list.
+ await list.add(download1);
+ await list.add(download2);
+
+ await download1.cancel();
+ await download2.cancel();
+
+ notifyPromptObservers(isPrivate, 0, 0);
+
+ // Clean up.
+ await list.remove(download1);
+ await list.remove(download2);
+ }
+});
+
+/**
+ * Tests notifications prompts when observers are notified if there are public
+ * and private active downloads at the same time.
+ */
+add_task(async function test_mix_notifications() {
+ mustInterruptResponses();
+
+ let publicList = await promiseNewList();
+ let privateList = await Downloads.getList(Downloads.PRIVATE);
+ let download1 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let download2 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let promiseAttempt1 = download1.start();
+ let promiseAttempt2 = download2.start();
+
+ // Add downloads to lists.
+ await publicList.add(download1);
+ await privateList.add(download2);
+
+ notifyPromptObservers(true, 2, 1);
+
+ // Allow the downloads to complete.
+ continueResponses();
+ await promiseAttempt1;
+ await promiseAttempt2;
+
+ // Clean up.
+ await publicList.remove(download1);
+ await privateList.remove(download2);
+});
+
+/**
+ * Tests suspending and resuming as well as going offline and then online again.
+ * The downloads should stop when suspending and start again when resuming.
+ */
+add_task(async function test_suspend_resume() {
+ // The default wake delay is 10 seconds, so set the wake delay to be much
+ // faster for these tests.
+ Services.prefs.setIntPref("browser.download.manager.resumeOnWakeDelay", 5);
+
+ let addDownload = function (list) {
+ return (async function () {
+ let download = await promiseNewDownload(httpUrl("interruptible.txt"));
+ download.start().catch(() => {});
+ list.add(download);
+ return download;
+ })();
+ };
+
+ let publicList = await promiseNewList();
+ let privateList = await promiseNewList(true);
+
+ let download1 = await addDownload(publicList);
+ let download2 = await addDownload(publicList);
+ let download3 = await addDownload(privateList);
+ let download4 = await addDownload(privateList);
+ let download5 = await addDownload(publicList);
+
+ // First, check that the downloads are all canceled when going to sleep.
+ Services.obs.notifyObservers(null, "sleep_notification");
+ Assert.ok(download1.canceled);
+ Assert.ok(download2.canceled);
+ Assert.ok(download3.canceled);
+ Assert.ok(download4.canceled);
+ Assert.ok(download5.canceled);
+
+ // Remove a download. It should not be started again.
+ publicList.remove(download5);
+ Assert.ok(download5.canceled);
+
+ // When waking up again, the downloads start again after the wake delay. To be
+ // more robust, don't check after a delay but instead just wait for the
+ // downloads to finish.
+ Services.obs.notifyObservers(null, "wake_notification");
+ await download1.whenSucceeded();
+ await download2.whenSucceeded();
+ await download3.whenSucceeded();
+ await download4.whenSucceeded();
+
+ // Downloads should no longer be canceled. However, as download5 was removed
+ // from the public list, it will not be restarted.
+ Assert.ok(!download1.canceled);
+ Assert.ok(download5.canceled);
+
+ // Create four new downloads and check for going offline and then online again.
+
+ download1 = await addDownload(publicList);
+ download2 = await addDownload(publicList);
+ download3 = await addDownload(privateList);
+ download4 = await addDownload(privateList);
+
+ // Going offline should cancel the downloads.
+ Services.obs.notifyObservers(null, "network:offline-about-to-go-offline");
+ Assert.ok(download1.canceled);
+ Assert.ok(download2.canceled);
+ Assert.ok(download3.canceled);
+ Assert.ok(download4.canceled);
+
+ // Going back online should start the downloads again.
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "online"
+ );
+ await download1.whenSucceeded();
+ await download2.whenSucceeded();
+ await download3.whenSucceeded();
+ await download4.whenSucceeded();
+
+ Services.prefs.clearUserPref("browser.download.manager.resumeOnWakeDelay");
+});
+
+/**
+ * Tests both the downloads list and the in-progress downloads are clear when
+ * private browsing observer is notified.
+ */
+add_task(async function test_exit_private_browsing() {
+ mustInterruptResponses();
+
+ let privateList = await promiseNewList(true);
+ let download1 = await promiseNewDownload(httpUrl("source.txt"));
+ let download2 = await promiseNewDownload(httpUrl("interruptible.txt"));
+ let promiseAttempt1 = download1.start();
+ download2.start();
+
+ // Add downloads to list.
+ await privateList.add(download1);
+ await privateList.add(download2);
+
+ // Complete the download.
+ await promiseAttempt1;
+
+ Assert.equal((await privateList.getAll()).length, 2);
+
+ // Simulate exiting the private browsing.
+ await new Promise(resolve => {
+ DownloadIntegration._testResolveClearPrivateList = resolve;
+ Services.obs.notifyObservers(null, "last-pb-context-exited");
+ });
+ delete DownloadIntegration._testResolveClearPrivateList;
+
+ Assert.equal((await privateList.getAll()).length, 0);
+
+ continueResponses();
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadLegacy.js b/toolkit/components/downloads/test/unit/test_DownloadLegacy.js
new file mode 100644
index 0000000000..972820f29e
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadLegacy.js
@@ -0,0 +1,100 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the integration with legacy interfaces for downloads.
+ */
+
+"use strict";
+
+// Execution of common tests
+
+// This is used in common_test_Download.js
+// eslint-disable-next-line no-unused-vars
+var gUseLegacySaver = true;
+
+var scriptFile = do_get_file("common_test_Download.js");
+Services.scriptloader.loadSubScript(NetUtil.newURI(scriptFile).spec);
+
+/**
+ * Checks the referrer for restart downloads.
+ * If the legacy download is stopped and restarted, the saving method
+ * is changed from DownloadLegacySaver to the DownloadCopySaver.
+ * The referrer header should be passed correctly.
+ */
+add_task(async function test_referrer_restart() {
+ let sourcePath = "/test_referrer_restart.txt";
+ let sourceUrl = httpUrl("test_referrer_restart.txt");
+
+ function cleanup() {
+ gHttpServer.registerPathHandler(sourcePath, null);
+ }
+ registerCleanupFunction(cleanup);
+
+ registerInterruptibleHandler(
+ sourcePath,
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ Assert.ok(aRequest.hasHeader("Referer"));
+ Assert.equal(aRequest.getHeader("Referer"), TEST_REFERRER_URL);
+
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT.length * 2,
+ false
+ );
+ aResponse.write(TEST_DATA_SHORT);
+ },
+ function secondPart(aRequest, aResponse) {
+ Assert.ok(aRequest.hasHeader("Referer"));
+ Assert.equal(aRequest.getHeader("Referer"), TEST_REFERRER_URL);
+
+ aResponse.write(TEST_DATA_SHORT);
+ }
+ );
+
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.UNSAFE_URL,
+ true,
+ NetUtil.newURI(TEST_REFERRER_URL)
+ );
+
+ async function restart_and_check_referrer(download) {
+ let promiseSucceeded = download.whenSucceeded();
+
+ // Cancel the first download attempt.
+ await promiseDownloadMidway(download);
+ await download.cancel();
+
+ // The second request is allowed to complete.
+ continueResponses();
+ download.start().catch(() => {});
+
+ // Wait for the download to finish by waiting on the whenSucceeded promise.
+ await promiseSucceeded;
+
+ Assert.ok(download.stopped);
+ Assert.ok(download.succeeded);
+ Assert.ok(!download.canceled);
+
+ checkEqualReferrerInfos(download.source.referrerInfo, referrerInfo);
+ }
+
+ mustInterruptResponses();
+
+ let download = await promiseStartLegacyDownload(sourceUrl, {
+ referrerInfo,
+ });
+ await restart_and_check_referrer(download);
+
+ mustInterruptResponses();
+ download = await promiseStartLegacyDownload(sourceUrl, {
+ referrerInfo,
+ isPrivate: true,
+ });
+ await restart_and_check_referrer(download);
+
+ cleanup();
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadList.js b/toolkit/components/downloads/test/unit/test_DownloadList.js
new file mode 100644
index 0000000000..f7d03900af
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadList.js
@@ -0,0 +1,677 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the DownloadList object.
+ */
+
+"use strict";
+
+// Globals
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+);
+
+/**
+ * Returns a Date in the past usable to add expirable visits.
+ *
+ * @note Expiration ignores any visit added in the last 7 days, but it's
+ * better be safe against DST issues, by going back one day more.
+ */
+function getExpirableDate() {
+ let dateObj = new Date();
+ // Normalize to midnight
+ dateObj.setHours(0);
+ dateObj.setMinutes(0);
+ dateObj.setSeconds(0);
+ dateObj.setMilliseconds(0);
+ return new Date(dateObj.getTime() - 8 * 86400000);
+}
+
+/**
+ * Adds an expirable history visit for a download.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("source.txt").
+ *
+ * @return {Promise}
+ * @rejects JavaScript exception.
+ */
+function promiseExpirableDownloadVisit(aSourceUrl) {
+ return PlacesUtils.history.insert({
+ url: aSourceUrl || httpUrl("source.txt"),
+ visits: [
+ {
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ date: getExpirableDate(),
+ },
+ ],
+ });
+}
+
+// Tests
+
+/**
+ * Checks the testing mechanism used to build different download lists.
+ */
+add_task(async function test_construction() {
+ let downloadListOne = await promiseNewList();
+ let downloadListTwo = await promiseNewList();
+ let privateDownloadListOne = await promiseNewList(true);
+ let privateDownloadListTwo = await promiseNewList(true);
+
+ Assert.notEqual(downloadListOne, downloadListTwo);
+ Assert.notEqual(privateDownloadListOne, privateDownloadListTwo);
+ Assert.notEqual(downloadListOne, privateDownloadListOne);
+});
+
+/**
+ * Checks the methods to add and retrieve items from the list.
+ */
+add_task(async function test_add_getAll() {
+ let list = await promiseNewList();
+
+ let downloadOne = await promiseNewDownload();
+ await list.add(downloadOne);
+
+ let itemsOne = await list.getAll();
+ Assert.equal(itemsOne.length, 1);
+ Assert.equal(itemsOne[0], downloadOne);
+
+ let downloadTwo = await promiseNewDownload();
+ await list.add(downloadTwo);
+
+ let itemsTwo = await list.getAll();
+ Assert.equal(itemsTwo.length, 2);
+ Assert.equal(itemsTwo[0], downloadOne);
+ Assert.equal(itemsTwo[1], downloadTwo);
+
+ // The first snapshot should not have been modified.
+ Assert.equal(itemsOne.length, 1);
+});
+
+/**
+ * Checks the method to remove items from the list.
+ */
+add_task(async function test_remove() {
+ let list = await promiseNewList();
+
+ await list.add(await promiseNewDownload());
+ await list.add(await promiseNewDownload());
+
+ let items = await list.getAll();
+ await list.remove(items[0]);
+
+ // Removing an item that was never added should not raise an error.
+ await list.remove(await promiseNewDownload());
+
+ items = await list.getAll();
+ Assert.equal(items.length, 1);
+});
+
+/**
+ * Tests that the "add", "remove", and "getAll" methods on the global
+ * DownloadCombinedList object combine the contents of the global DownloadList
+ * objects for public and private downloads.
+ */
+add_task(async function test_DownloadCombinedList_add_remove_getAll() {
+ let publicList = await promiseNewList();
+ let privateList = await Downloads.getList(Downloads.PRIVATE);
+ let combinedList = await Downloads.getList(Downloads.ALL);
+
+ let publicDownload = await promiseNewDownload();
+ let privateDownload = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt"), isPrivate: true },
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ });
+
+ await publicList.add(publicDownload);
+ await privateList.add(privateDownload);
+
+ Assert.equal((await combinedList.getAll()).length, 2);
+
+ await combinedList.remove(publicDownload);
+ await combinedList.remove(privateDownload);
+
+ Assert.equal((await combinedList.getAll()).length, 0);
+
+ await combinedList.add(publicDownload);
+ await combinedList.add(privateDownload);
+
+ Assert.equal((await publicList.getAll()).length, 1);
+ Assert.equal((await privateList.getAll()).length, 1);
+ Assert.equal((await combinedList.getAll()).length, 2);
+
+ await publicList.remove(publicDownload);
+ await privateList.remove(privateDownload);
+
+ Assert.equal((await combinedList.getAll()).length, 0);
+});
+
+/**
+ * Checks that views receive the download add and remove notifications, and that
+ * adding and removing views works as expected, both for a normal and a combined
+ * list.
+ */
+add_task(async function test_notifications_add_remove() {
+ for (let isCombined of [false, true]) {
+ // Force creating a new list for both the public and combined cases.
+ let list = await promiseNewList();
+ if (isCombined) {
+ list = await Downloads.getList(Downloads.ALL);
+ }
+
+ let downloadOne = await promiseNewDownload();
+ let downloadTwo = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt"), isPrivate: true },
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ });
+ await list.add(downloadOne);
+ await list.add(downloadTwo);
+
+ // Check that we receive add notifications for existing elements.
+ let addNotifications = 0;
+ let viewOne = {
+ onDownloadAdded(aDownload) {
+ // The first download to be notified should be the first that was added.
+ if (addNotifications == 0) {
+ Assert.equal(aDownload, downloadOne);
+ } else if (addNotifications == 1) {
+ Assert.equal(aDownload, downloadTwo);
+ }
+ addNotifications++;
+ },
+ };
+ await list.addView(viewOne);
+ Assert.equal(addNotifications, 2);
+
+ // Check that we receive add notifications for new elements.
+ await list.add(await promiseNewDownload());
+ Assert.equal(addNotifications, 3);
+
+ // Check that we receive remove notifications.
+ let removeNotifications = 0;
+ let viewTwo = {
+ onDownloadRemoved(aDownload) {
+ Assert.equal(aDownload, downloadOne);
+ removeNotifications++;
+ },
+ };
+ await list.addView(viewTwo);
+ await list.remove(downloadOne);
+ Assert.equal(removeNotifications, 1);
+
+ // We should not receive remove notifications after the view is removed.
+ await list.removeView(viewTwo);
+ await list.remove(downloadTwo);
+ Assert.equal(removeNotifications, 1);
+
+ // We should not receive add notifications after the view is removed.
+ await list.removeView(viewOne);
+ await list.add(await promiseNewDownload());
+ Assert.equal(addNotifications, 3);
+ }
+});
+
+/**
+ * Checks that views receive the download change notifications, both for a
+ * normal and a combined list.
+ */
+add_task(async function test_notifications_change() {
+ for (let isCombined of [false, true]) {
+ // Force creating a new list for both the public and combined cases.
+ let list = await promiseNewList();
+ if (isCombined) {
+ list = await Downloads.getList(Downloads.ALL);
+ }
+
+ let downloadOne = await promiseNewDownload();
+ let downloadTwo = await Downloads.createDownload({
+ source: { url: httpUrl("source.txt"), isPrivate: true },
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ });
+ await list.add(downloadOne);
+ await list.add(downloadTwo);
+
+ // Check that we receive change notifications.
+ let receivedOnDownloadChanged = false;
+ await list.addView({
+ onDownloadChanged(aDownload) {
+ Assert.equal(aDownload, downloadOne);
+ receivedOnDownloadChanged = true;
+ },
+ });
+ await downloadOne.start();
+ Assert.ok(receivedOnDownloadChanged);
+
+ // We should not receive change notifications after a download is removed.
+ receivedOnDownloadChanged = false;
+ await list.remove(downloadTwo);
+ await downloadTwo.start();
+ Assert.ok(!receivedOnDownloadChanged);
+ }
+});
+
+/**
+ * Checks that the reference to "this" is correct in the view callbacks.
+ */
+add_task(async function test_notifications_this() {
+ let list = await promiseNewList();
+
+ // Check that we receive change notifications.
+ let receivedOnDownloadAdded = false;
+ let receivedOnDownloadChanged = false;
+ let receivedOnDownloadRemoved = false;
+ let view = {
+ onDownloadAdded() {
+ Assert.equal(this, view);
+ receivedOnDownloadAdded = true;
+ },
+ onDownloadChanged() {
+ // Only do this check once.
+ if (!receivedOnDownloadChanged) {
+ Assert.equal(this, view);
+ receivedOnDownloadChanged = true;
+ }
+ },
+ onDownloadRemoved() {
+ Assert.equal(this, view);
+ receivedOnDownloadRemoved = true;
+ },
+ };
+ await list.addView(view);
+
+ let download = await promiseNewDownload();
+ await list.add(download);
+ await download.start();
+ await list.remove(download);
+
+ // Verify that we executed the checks.
+ Assert.ok(receivedOnDownloadAdded);
+ Assert.ok(receivedOnDownloadChanged);
+ Assert.ok(receivedOnDownloadRemoved);
+});
+
+/**
+ * Checks that download is removed on history expiration.
+ */
+add_task(async function test_history_expiration() {
+ mustInterruptResponses();
+
+ function cleanup() {
+ Services.prefs.clearUserPref("places.history.expiration.max_pages");
+ }
+ registerCleanupFunction(cleanup);
+
+ // Set max pages to 0 to make the download expire.
+ Services.prefs.setIntPref("places.history.expiration.max_pages", 0);
+
+ let list = await promiseNewList();
+ let downloadOne = await promiseNewDownload();
+ let downloadTwo = await promiseNewDownload(httpUrl("interruptible.txt"));
+
+ let deferred = Promise.withResolvers();
+ let removeNotifications = 0;
+ let downloadView = {
+ onDownloadRemoved(aDownload) {
+ if (++removeNotifications == 2) {
+ deferred.resolve();
+ }
+ },
+ };
+ await list.addView(downloadView);
+
+ // Work with one finished download and one canceled download.
+ await downloadOne.start();
+ downloadTwo.start().catch(() => {});
+ await downloadTwo.cancel();
+
+ // We must replace the visits added while executing the downloads with visits
+ // that are older than 7 days, otherwise they will not be expired.
+ await PlacesUtils.history.clear();
+ await promiseExpirableDownloadVisit();
+ await promiseExpirableDownloadVisit(httpUrl("interruptible.txt"));
+
+ // After clearing history, we can add the downloads to be removed to the list.
+ await list.add(downloadOne);
+ await list.add(downloadTwo);
+
+ // Force a history expiration.
+ Cc["@mozilla.org/places/expiration;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "places-debug-start-expiration", -1);
+
+ // Wait for both downloads to be removed.
+ await deferred.promise;
+
+ cleanup();
+});
+
+/**
+ * Checks all downloads are removed after clearing history.
+ */
+add_task(async function test_history_clear() {
+ let list = await promiseNewList();
+ let downloadOne = await promiseNewDownload();
+ let downloadTwo = await promiseNewDownload();
+ await list.add(downloadOne);
+ await list.add(downloadTwo);
+
+ let deferred = Promise.withResolvers();
+ let removeNotifications = 0;
+ let downloadView = {
+ onDownloadRemoved(aDownload) {
+ if (++removeNotifications == 2) {
+ deferred.resolve();
+ }
+ },
+ };
+ await list.addView(downloadView);
+
+ await downloadOne.start();
+ await downloadTwo.start();
+
+ await PlacesUtils.history.clear();
+
+ // Wait for the removal notifications that may still be pending.
+ await deferred.promise;
+});
+
+/**
+ * Tests the removeFinished method to ensure that it only removes
+ * finished downloads.
+ */
+add_task(async function test_removeFinished() {
+ let list = await promiseNewList();
+ let downloadOne = await promiseNewDownload();
+ let downloadTwo = await promiseNewDownload();
+ let downloadThree = await promiseNewDownload();
+ let downloadFour = await promiseNewDownload();
+ await list.add(downloadOne);
+ await list.add(downloadTwo);
+ await list.add(downloadThree);
+ await list.add(downloadFour);
+
+ let deferred = Promise.withResolvers();
+ let removeNotifications = 0;
+ let downloadView = {
+ onDownloadRemoved(aDownload) {
+ Assert.ok(
+ aDownload == downloadOne ||
+ aDownload == downloadTwo ||
+ aDownload == downloadThree
+ );
+ Assert.ok(removeNotifications < 3);
+ if (++removeNotifications == 3) {
+ deferred.resolve();
+ }
+ },
+ };
+ await list.addView(downloadView);
+
+ // Start three of the downloads, but don't start downloadTwo, then set
+ // downloadFour to have partial data. All downloads except downloadFour
+ // should be removed.
+ await downloadOne.start();
+ await downloadThree.start();
+ await downloadFour.start();
+ downloadFour.hasPartialData = true;
+
+ list.removeFinished();
+ await deferred.promise;
+
+ let downloads = await list.getAll();
+ Assert.equal(downloads.length, 1);
+});
+
+/**
+ * Tests that removeFinished method keeps the file that is currently downloading,
+ * even if it needs to remove failed download of the same file.
+ */
+add_task(async function test_removeFinished_keepsDownloadingFile() {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+
+ let oneDownload = await Downloads.createDownload({
+ source: httpUrl("empty.txt"),
+ target: targetFile.path,
+ });
+
+ let otherDownload = await Downloads.createDownload({
+ source: httpUrl("empty.txt"),
+ target: targetFile.path,
+ });
+
+ let list = await promiseNewList();
+ await list.add(oneDownload);
+ await list.add(otherDownload);
+
+ let deferred = Promise.withResolvers();
+ let downloadView = {
+ async onDownloadRemoved(aDownload) {
+ Assert.equal(aDownload, oneDownload);
+ await TestUtils.waitForCondition(() => oneDownload._finalizeExecuted);
+ deferred.resolve();
+ },
+ };
+ await list.addView(downloadView);
+
+ await oneDownload.start();
+ await otherDownload.start();
+
+ oneDownload.hasPartialData = otherDownload.hasPartialData = true;
+ oneDownload.error = "Download failed";
+
+ list.removeFinished();
+ await deferred.promise;
+
+ let downloads = await list.getAll();
+ Assert.equal(
+ downloads.length,
+ 1,
+ "Failed download should be removed, active download should be kept"
+ );
+
+ Assert.ok(
+ await IOUtils.exists(otherDownload.target.path),
+ "The file should not have been deleted."
+ );
+});
+
+/**
+ * Tests the global DownloadSummary objects for the public, private, and
+ * combined download lists.
+ */
+add_task(async function test_DownloadSummary() {
+ mustInterruptResponses();
+
+ let publicList = await promiseNewList();
+ let privateList = await Downloads.getList(Downloads.PRIVATE);
+
+ let publicSummary = await Downloads.getSummary(Downloads.PUBLIC);
+ let privateSummary = await Downloads.getSummary(Downloads.PRIVATE);
+ let combinedSummary = await Downloads.getSummary(Downloads.ALL);
+
+ // Add a public download that has succeeded.
+ let succeededPublicDownload = await promiseNewDownload();
+ await succeededPublicDownload.start();
+ await publicList.add(succeededPublicDownload);
+
+ // Add a public download that has been canceled midway.
+ let canceledPublicDownload = await promiseNewDownload(
+ httpUrl("interruptible.txt")
+ );
+ canceledPublicDownload.start().catch(() => {});
+ await promiseDownloadMidway(canceledPublicDownload);
+ await canceledPublicDownload.cancel();
+ await publicList.add(canceledPublicDownload);
+
+ // Add a public download that is in progress.
+ let inProgressPublicDownload = await promiseNewDownload(
+ httpUrl("interruptible.txt")
+ );
+ inProgressPublicDownload.start().catch(() => {});
+ await promiseDownloadMidway(inProgressPublicDownload);
+ await publicList.add(inProgressPublicDownload);
+
+ // Add a public download of unknown size that is in progress.
+ let inProgressSizelessPublicDownload = await promiseNewDownload(
+ httpUrl("interruptible_nosize.txt")
+ );
+ inProgressSizelessPublicDownload.start().catch(() => {});
+ await promiseDownloadStarted(inProgressSizelessPublicDownload);
+ await publicList.add(inProgressSizelessPublicDownload);
+
+ // Add a private download that is in progress.
+ let inProgressPrivateDownload = await Downloads.createDownload({
+ source: { url: httpUrl("interruptible.txt"), isPrivate: true },
+ target: getTempFile(TEST_TARGET_FILE_NAME).path,
+ });
+ inProgressPrivateDownload.start().catch(() => {});
+ await promiseDownloadMidway(inProgressPrivateDownload);
+ await privateList.add(inProgressPrivateDownload);
+
+ // Verify that the summary includes the total number of bytes and the
+ // currently transferred bytes only for the downloads that are not stopped.
+ // For simplicity, we assume that after a download is added to the list, its
+ // current state is immediately propagated to the summary object, which is
+ // true in the current implementation, though it is not guaranteed as all the
+ // download operations may happen asynchronously.
+ Assert.ok(!publicSummary.allHaveStopped);
+ Assert.ok(!publicSummary.allUnknownSize);
+ Assert.equal(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length * 3);
+ Assert.equal(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length * 2);
+
+ Assert.ok(!privateSummary.allHaveStopped);
+ Assert.ok(!privateSummary.allUnknownSize);
+ Assert.equal(privateSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2);
+ Assert.equal(privateSummary.progressCurrentBytes, TEST_DATA_SHORT.length);
+
+ Assert.ok(!combinedSummary.allHaveStopped);
+ Assert.ok(!combinedSummary.allUnknownSize);
+ Assert.equal(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length * 5);
+ Assert.equal(
+ combinedSummary.progressCurrentBytes,
+ TEST_DATA_SHORT.length * 3
+ );
+
+ await inProgressPublicDownload.cancel();
+
+ // Stopping the download should have excluded it from the summary, but we
+ // should still have one public download (with unknown size) and also one
+ // private download remaining.
+ Assert.ok(!publicSummary.allHaveStopped);
+ Assert.ok(publicSummary.allUnknownSize);
+ Assert.equal(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length);
+ Assert.equal(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length);
+
+ Assert.ok(!privateSummary.allHaveStopped);
+ Assert.ok(!privateSummary.allUnknownSize);
+ Assert.equal(privateSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2);
+ Assert.equal(privateSummary.progressCurrentBytes, TEST_DATA_SHORT.length);
+
+ Assert.ok(!combinedSummary.allHaveStopped);
+ Assert.ok(!combinedSummary.allUnknownSize);
+ Assert.equal(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length * 3);
+ Assert.equal(
+ combinedSummary.progressCurrentBytes,
+ TEST_DATA_SHORT.length * 2
+ );
+
+ await inProgressPrivateDownload.cancel();
+
+ // Stopping the private download should have excluded it from the summary, so
+ // now only the unknown size public download should remain.
+ Assert.ok(!publicSummary.allHaveStopped);
+ Assert.ok(publicSummary.allUnknownSize);
+ Assert.equal(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length);
+ Assert.equal(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length);
+
+ Assert.ok(privateSummary.allHaveStopped);
+ Assert.ok(privateSummary.allUnknownSize);
+ Assert.equal(privateSummary.progressTotalBytes, 0);
+ Assert.equal(privateSummary.progressCurrentBytes, 0);
+
+ Assert.ok(!combinedSummary.allHaveStopped);
+ Assert.ok(combinedSummary.allUnknownSize);
+ Assert.equal(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length);
+ Assert.equal(combinedSummary.progressCurrentBytes, TEST_DATA_SHORT.length);
+
+ await inProgressSizelessPublicDownload.cancel();
+
+ // All the downloads should be stopped now.
+ Assert.ok(publicSummary.allHaveStopped);
+ Assert.ok(publicSummary.allUnknownSize);
+ Assert.equal(publicSummary.progressTotalBytes, 0);
+ Assert.equal(publicSummary.progressCurrentBytes, 0);
+
+ Assert.ok(privateSummary.allHaveStopped);
+ Assert.ok(privateSummary.allUnknownSize);
+ Assert.equal(privateSummary.progressTotalBytes, 0);
+ Assert.equal(privateSummary.progressCurrentBytes, 0);
+
+ Assert.ok(combinedSummary.allHaveStopped);
+ Assert.ok(combinedSummary.allUnknownSize);
+ Assert.equal(combinedSummary.progressTotalBytes, 0);
+ Assert.equal(combinedSummary.progressCurrentBytes, 0);
+});
+
+/**
+ * Checks that views receive the summary change notification. This is tested on
+ * the combined summary when adding a public download, as we assume that if we
+ * pass the test in this case we will also pass it in the others.
+ */
+add_task(async function test_DownloadSummary_notifications() {
+ let list = await promiseNewList();
+ let summary = await Downloads.getSummary(Downloads.ALL);
+
+ let download = await promiseNewDownload();
+ await list.add(download);
+
+ // Check that we receive change notifications.
+ let receivedOnSummaryChanged = false;
+ await summary.addView({
+ onSummaryChanged() {
+ receivedOnSummaryChanged = true;
+ },
+ });
+ await download.start();
+ Assert.ok(receivedOnSummaryChanged);
+});
+
+/**
+ * Tests that if a new download is added, telemetry records the event and type of the file.
+ */
+add_task(async function test_downloadAddedTelemetry() {
+ Services.telemetry.clearEvents();
+ Services.telemetry.setEventRecordingEnabled("downloads", true);
+
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+
+ let download = await Downloads.createDownload({
+ source: httpUrl("empty.txt"),
+ target: targetFile.path,
+ });
+
+ let list = await Downloads.getList(Downloads.ALL);
+ await list.add(download);
+ download.start();
+ await promiseDownloadFinished(download);
+
+ TelemetryTestUtils.assertEvents([
+ {
+ category: "downloads",
+ method: "added",
+ object: "fileExtension",
+ value: "txt",
+ },
+ ]);
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadPaths.js b/toolkit/components/downloads/test/unit/test_DownloadPaths.js
new file mode 100644
index 0000000000..00fb070669
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadPaths.js
@@ -0,0 +1,189 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests for the "DownloadPaths.sys.mjs" JavaScript module.
+ */
+
+function testSanitize(leafName, expectedLeafName, options = {}) {
+ Assert.equal(DownloadPaths.sanitize(leafName, options), expectedLeafName);
+}
+
+function testSplitBaseNameAndExtension(aLeafName, [aBase, aExt]) {
+ var [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName);
+ Assert.equal(base, aBase);
+ Assert.equal(ext, aExt);
+
+ // If we modify the base name and concatenate it with the extension again,
+ // another roundtrip through the function should give a consistent result.
+ // The only exception is when we introduce an extension in a file name that
+ // didn't have one or that ended with one of the special cases like ".gz". If
+ // we avoid using a dot and we introduce at least another special character,
+ // the results are always consistent.
+ [base, ext] = DownloadPaths.splitBaseNameAndExtension("(" + base + ")" + ext);
+ Assert.equal(base, "(" + aBase + ")");
+ Assert.equal(ext, aExt);
+}
+
+function testCreateNiceUniqueFile(aTempFile, aExpectedLeafName) {
+ var createdFile = DownloadPaths.createNiceUniqueFile(aTempFile);
+ Assert.equal(createdFile.leafName, aExpectedLeafName);
+}
+
+add_task(async function test_sanitize() {
+ // Platform-dependent conversion of special characters to spaces.
+ const kSpecialChars = 'A:*?|""<<>>;,+=[]B][=+,;>><<""|?*:C';
+ if (AppConstants.platform == "android") {
+ testSanitize(kSpecialChars, "A B C");
+ testSanitize(" :: Website :: ", "Website");
+ testSanitize("* Website!", "Website!");
+ testSanitize("Website | Page!", "Website Page!");
+ testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_");
+ } else if (AppConstants.platform == "win") {
+ testSanitize(kSpecialChars, "A ;,+=[]B][=+,; C");
+ testSanitize(" :: Website :: ", "Website");
+ testSanitize("* Website!", "Website!");
+ testSanitize("Website | Page!", "Website Page!");
+ testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_");
+ } else if (AppConstants.platform == "macosx") {
+ testSanitize(kSpecialChars, "A ;,+=[]B][=+,; C");
+ testSanitize(" :: Website :: ", "Website");
+ testSanitize("* Website!", "Website!");
+ testSanitize("Website | Page!", "Website Page!");
+ testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_");
+ } else {
+ testSanitize(kSpecialChars, "A ;,+=[]B][=+,; C");
+ testSanitize(" :: Website :: ", "Website");
+ testSanitize("* Website!", "Website!");
+ testSanitize("Website | Page!", "Website Page!");
+ testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_");
+ }
+
+ // Conversion of consecutive runs of slashes and backslashes to underscores.
+ testSanitize("\\ \\\\Website\\/Page// /", "_ __Website__Page__ _");
+
+ // Removal of leading and trailing whitespace and dots after conversion.
+ testSanitize(" Website ", "Website");
+ testSanitize(". . Website . Page . .", "Website . Page");
+ testSanitize(" File . txt ", "File . txt");
+ testSanitize("\f\n\r\t\v\x00\x1f\x7f\x80\x9f\xa0 . txt", "txt");
+ testSanitize("\u1680\u180e\u2000\u2008\u200a . txt", "txt");
+ testSanitize("\u2028\u2029\u202f\u205f\u3000\ufeff . txt", "txt");
+
+ // Strings with whitespace and dots only.
+ testSanitize(".", "");
+ testSanitize("..", "");
+ testSanitize(" ", "");
+ testSanitize(" . ", "");
+
+ // Stripping of BIDI formatting characters.
+ testSanitize("\u200e \u202b\u202c\u202d\u202etest\x7f\u200f", "_ ____test _");
+ testSanitize("AB\x7f\u202a\x7f\u202a\x7fCD", "AB _ _ CD");
+
+ // Stripping of colons:
+ testSanitize("foo:bar", "foo bar");
+
+ // not compressing whitespaces.
+ testSanitize("foo : bar", "foo bar", { compressWhitespaces: false });
+
+ testSanitize("thing.lnk", "thing.lnk.download");
+ testSanitize("thing.lnk\n", "thing.lnk.download");
+ testSanitize("thing.lnk", "thing.lnk", {
+ allowInvalidFilenames: true,
+ });
+ testSanitize("thing.lnk\n", "thing.lnk", {
+ allowInvalidFilenames: true,
+ });
+ testSanitize("thing.URl", "thing.URl.download");
+ testSanitize("thing.URl \n", "thing.URl", {
+ allowInvalidFilenames: true,
+ });
+
+ testSanitize("thing and more .URl", "thing and more .URl", {
+ allowInvalidFilenames: true,
+ });
+ testSanitize("thing and more .URl ", "thing and more .URl", {
+ compressWhitespaces: false,
+ allowInvalidFilenames: true,
+ });
+
+ testSanitize("thing.local|", "thing.local.download");
+ testSanitize("thing.lo|cal", "thing.lo cal");
+ testSanitize('thing.local/*"', "thing.local_");
+
+ testSanitize("thing.desktoP", "thing.desktoP.download");
+ testSanitize("thing.desktoP \n", "thing.desktoP", {
+ allowInvalidFilenames: true,
+ });
+});
+
+add_task(async function test_splitBaseNameAndExtension() {
+ // Usual file names.
+ testSplitBaseNameAndExtension("base", ["base", ""]);
+ testSplitBaseNameAndExtension("base.ext", ["base", ".ext"]);
+ testSplitBaseNameAndExtension("base.application", ["base", ".application"]);
+ testSplitBaseNameAndExtension("base.x.Z", ["base", ".x.Z"]);
+ testSplitBaseNameAndExtension("base.ext.Z", ["base", ".ext.Z"]);
+ testSplitBaseNameAndExtension("base.ext.gz", ["base", ".ext.gz"]);
+ testSplitBaseNameAndExtension("base.ext.Bz2", ["base", ".ext.Bz2"]);
+ testSplitBaseNameAndExtension("base..ext", ["base.", ".ext"]);
+ testSplitBaseNameAndExtension("base..Z", ["base.", ".Z"]);
+ testSplitBaseNameAndExtension("base. .Z", ["base. ", ".Z"]);
+ testSplitBaseNameAndExtension("base.base.Bz2", ["base.base", ".Bz2"]);
+ testSplitBaseNameAndExtension("base .ext", ["base ", ".ext"]);
+
+ // Corner cases. A name ending with a dot technically has no extension, but
+ // we consider the ending dot separately from the base name so that modifying
+ // the latter never results in an extension being introduced accidentally.
+ // Names beginning with a dot are hidden files on Unix-like platforms and if
+ // their name doesn't contain another dot they should have no extension, but
+ // on Windows the whole name is considered as an extension.
+ testSplitBaseNameAndExtension("base.", ["base", "."]);
+ testSplitBaseNameAndExtension(".ext", ["", ".ext"]);
+
+ // Unusual file names (not recommended as input to the function).
+ testSplitBaseNameAndExtension("base. ", ["base", ". "]);
+ testSplitBaseNameAndExtension("base ", ["base ", ""]);
+ testSplitBaseNameAndExtension("", ["", ""]);
+ testSplitBaseNameAndExtension(" ", [" ", ""]);
+ testSplitBaseNameAndExtension(" . ", [" ", ". "]);
+ testSplitBaseNameAndExtension(" .. ", [" .", ". "]);
+ testSplitBaseNameAndExtension(" .ext", [" ", ".ext"]);
+ testSplitBaseNameAndExtension(" .ext. ", [" .ext", ". "]);
+ testSplitBaseNameAndExtension(" .ext.gz ", [" .ext", ".gz "]);
+});
+
+add_task(async function test_createNiceUniqueFile() {
+ var destDir = FileTestUtils.getTempFile("destdir");
+ destDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ // Single extension.
+ var tempFile = destDir.clone();
+ tempFile.append("test.txt");
+ testCreateNiceUniqueFile(tempFile, "test.txt");
+ testCreateNiceUniqueFile(tempFile, "test(1).txt");
+ testCreateNiceUniqueFile(tempFile, "test(2).txt");
+
+ // Double extension.
+ tempFile.leafName = "test.tar.gz";
+ testCreateNiceUniqueFile(tempFile, "test.tar.gz");
+ testCreateNiceUniqueFile(tempFile, "test(1).tar.gz");
+ testCreateNiceUniqueFile(tempFile, "test(2).tar.gz");
+
+ // Test automatic shortening of long file names. We don't know exactly how
+ // many characters are removed, because it depends on the name of the folder
+ // where the file is located.
+ tempFile.leafName = new Array(256).join("T") + ".txt";
+ var newFile = DownloadPaths.createNiceUniqueFile(tempFile);
+ Assert.ok(newFile.leafName.length < tempFile.leafName.length);
+ Assert.equal(newFile.leafName.slice(-4), ".txt");
+
+ // Creating a valid file name from an invalid one is not always possible.
+ tempFile.append("file-under-long-directory.txt");
+ try {
+ DownloadPaths.createNiceUniqueFile(tempFile);
+ do_throw("Exception expected with a long parent directory name.");
+ } catch (e) {
+ // An exception is expected, but we don't know which one exactly.
+ }
+});
diff --git a/toolkit/components/downloads/test/unit/test_DownloadStore.js b/toolkit/components/downloads/test/unit/test_DownloadStore.js
new file mode 100644
index 0000000000..9353b63060
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_DownloadStore.js
@@ -0,0 +1,458 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the DownloadStore object.
+ */
+
+"use strict";
+
+// Globals
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadError: "resource://gre/modules/DownloadCore.sys.mjs",
+ DownloadStore: "resource://gre/modules/DownloadStore.sys.mjs",
+});
+
+/**
+ * Returns a new DownloadList object with an associated DownloadStore.
+ *
+ * @param aStorePath
+ * String pointing to the file to be associated with the DownloadStore,
+ * or undefined to use a non-existing temporary file. In this case, the
+ * temporary file is deleted when the test file execution finishes.
+ *
+ * @return {Promise}
+ * @resolves Array [ Newly created DownloadList , associated DownloadStore ].
+ * @rejects JavaScript exception.
+ */
+function promiseNewListAndStore(aStorePath) {
+ return promiseNewList().then(function (aList) {
+ let path = aStorePath || getTempFile(TEST_STORE_FILE_NAME).path;
+ let store = new DownloadStore(aList, path);
+ return [aList, store];
+ });
+}
+
+// Tests
+
+/**
+ * Saves downloads to a file, then reloads them.
+ */
+add_task(async function test_save_reload() {
+ let [listForSave, storeForSave] = await promiseNewListAndStore();
+ let [listForLoad, storeForLoad] = await promiseNewListAndStore(
+ storeForSave.path
+ );
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ NetUtil.newURI(TEST_REFERRER_URL)
+ );
+
+ listForSave.add(await promiseNewDownload(httpUrl("source.txt")));
+ listForSave.add(
+ await Downloads.createDownload({
+ source: { url: httpUrl("empty.txt"), referrerInfo },
+ target: getTempFile(TEST_TARGET_FILE_NAME),
+ })
+ );
+
+ // If we used a callback to adjust the channel, the download should
+ // not be serialized because we can't recreate it across sessions.
+ let adjustedDownload = await Downloads.createDownload({
+ source: {
+ url: httpUrl("empty.txt"),
+ adjustChannel: () => Promise.resolve(),
+ },
+ target: getTempFile(TEST_TARGET_FILE_NAME),
+ });
+ listForSave.add(adjustedDownload);
+
+ let legacyDownload = await promiseStartLegacyDownload();
+ await legacyDownload.cancel();
+ listForSave.add(legacyDownload);
+
+ await storeForSave.save();
+ await storeForLoad.load();
+
+ // Remove the adjusted download because it should not appear here.
+ listForSave.remove(adjustedDownload);
+
+ let itemsForSave = await listForSave.getAll();
+ let itemsForLoad = await listForLoad.getAll();
+
+ Assert.equal(itemsForSave.length, itemsForLoad.length);
+
+ // Downloads should be reloaded in the same order.
+ for (let i = 0; i < itemsForSave.length; i++) {
+ // The reloaded downloads are different objects.
+ Assert.notEqual(itemsForSave[i], itemsForLoad[i]);
+
+ // The reloaded downloads have the same properties.
+ Assert.equal(itemsForSave[i].source.url, itemsForLoad[i].source.url);
+ Assert.equal(
+ !!itemsForSave[i].source.referrerInfo,
+ !!itemsForLoad[i].source.referrerInfo
+ );
+ if (
+ itemsForSave[i].source.referrerInfo &&
+ itemsForLoad[i].source.referrerInfo
+ ) {
+ Assert.ok(
+ itemsForSave[i].source.referrerInfo.equals(
+ itemsForLoad[i].source.referrerInfo
+ )
+ );
+ }
+ Assert.equal(itemsForSave[i].target.path, itemsForLoad[i].target.path);
+ Assert.equal(
+ itemsForSave[i].saver.toSerializable(),
+ itemsForLoad[i].saver.toSerializable()
+ );
+ }
+});
+
+/**
+ * Checks that saving an empty list deletes any existing file.
+ */
+add_task(async function test_save_empty() {
+ let [, store] = await promiseNewListAndStore();
+
+ await IOUtils.write(store.path, new Uint8Array());
+
+ await store.save();
+
+ let successful;
+ try {
+ await IOUtils.read(store.path);
+ successful = true;
+ } catch (ex) {
+ successful = ex.name != "NotFoundError";
+ }
+
+ ok(!successful, "File should not exist");
+
+ // If the file does not exist, saving should not generate exceptions.
+ await store.save();
+});
+
+/**
+ * Checks that loading from a missing file results in an empty list.
+ */
+add_task(async function test_load_empty() {
+ let [list, store] = await promiseNewListAndStore();
+
+ let succeesful;
+ try {
+ await IOUtils.read(store.path);
+ succeesful = true;
+ } catch (ex) {
+ succeesful = ex.name != "NotFoundError";
+ }
+
+ ok(!succeesful, "File should not exist");
+
+ let items = await list.getAll();
+ Assert.equal(items.length, 0);
+});
+
+/**
+ * Loads downloads from a string in a predefined format. The purpose of this
+ * test is to verify that the JSON format used in previous versions can be
+ * loaded, assuming the file is reloaded on the same platform.
+ */
+add_task(async function test_load_string_predefined() {
+ let [list, store] = await promiseNewListAndStore();
+
+ // The platform-dependent file name should be generated dynamically.
+ let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ let filePathLiteral = JSON.stringify(targetPath);
+ let sourceUriLiteral = JSON.stringify(httpUrl("source.txt"));
+ let emptyUriLiteral = JSON.stringify(httpUrl("empty.txt"));
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ NetUtil.newURI(TEST_REFERRER_URL)
+ );
+ let referrerInfoLiteral = JSON.stringify(
+ E10SUtils.serializeReferrerInfo(referrerInfo)
+ );
+
+ let string =
+ '{"list":[{"source":' +
+ sourceUriLiteral +
+ "," +
+ '"target":' +
+ filePathLiteral +
+ "}," +
+ '{"source":{"url":' +
+ emptyUriLiteral +
+ "," +
+ '"referrerInfo":' +
+ referrerInfoLiteral +
+ "}," +
+ '"target":' +
+ filePathLiteral +
+ "}]}";
+
+ await IOUtils.write(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ await store.load();
+
+ let items = await list.getAll();
+
+ Assert.equal(items.length, 2);
+
+ Assert.equal(items[0].source.url, httpUrl("source.txt"));
+ Assert.equal(items[0].target.path, targetPath);
+
+ Assert.equal(items[1].source.url, httpUrl("empty.txt"));
+
+ checkEqualReferrerInfos(items[1].source.referrerInfo, referrerInfo);
+ Assert.equal(items[1].target.path, targetPath);
+});
+
+/**
+ * Loads downloads from a well-formed JSON string containing unrecognized data.
+ */
+add_task(async function test_load_string_unrecognized() {
+ let [list, store] = await promiseNewListAndStore();
+
+ // The platform-dependent file name should be generated dynamically.
+ let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ let filePathLiteral = JSON.stringify(targetPath);
+ let sourceUriLiteral = JSON.stringify(httpUrl("source.txt"));
+
+ let string =
+ '{"list":[{"source":null,' +
+ '"target":null},' +
+ '{"source":{"url":' +
+ sourceUriLiteral +
+ "}," +
+ '"target":{"path":' +
+ filePathLiteral +
+ "}," +
+ '"saver":{"type":"copy"}}]}';
+
+ await IOUtils.write(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ await store.load();
+
+ let items = await list.getAll();
+
+ Assert.equal(items.length, 1);
+
+ Assert.equal(items[0].source.url, httpUrl("source.txt"));
+ Assert.equal(items[0].target.path, targetPath);
+});
+
+/**
+ * Loads downloads from a malformed JSON string.
+ */
+add_task(async function test_load_string_malformed() {
+ let [list, store] = await promiseNewListAndStore();
+
+ let string =
+ '{"list":[{"source":null,"target":null},' +
+ '{"source":{"url":"about:blank"}}}';
+
+ await IOUtils.write(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ try {
+ await store.load();
+ do_throw("Exception expected when JSON data is malformed.");
+ } catch (ex) {
+ if (ex.name != "SyntaxError") {
+ throw ex;
+ }
+ info("The expected SyntaxError exception was thrown.");
+ }
+
+ let items = await list.getAll();
+
+ Assert.equal(items.length, 0);
+});
+
+/**
+ * Saves downloads with unknown properties to a file and then reloads
+ * them to ensure that these properties are preserved.
+ */
+add_task(async function test_save_reload_unknownProperties() {
+ let [listForSave, storeForSave] = await promiseNewListAndStore();
+ let [listForLoad, storeForLoad] = await promiseNewListAndStore(
+ storeForSave.path
+ );
+
+ let download1 = await promiseNewDownload(httpUrl("source.txt"));
+ // startTime should be ignored as it is a known property, and error
+ // is ignored by serialization
+ download1._unknownProperties = {
+ peanut: "butter",
+ orange: "marmalade",
+ startTime: 77,
+ error: { message: "Passed" },
+ };
+ listForSave.add(download1);
+
+ let download2 = await promiseStartLegacyDownload();
+ await download2.cancel();
+ download2._unknownProperties = { number: 5, object: { test: "string" } };
+ listForSave.add(download2);
+
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ NetUtil.newURI(TEST_REFERRER_URL)
+ );
+ let download3 = await Downloads.createDownload({
+ source: {
+ url: httpUrl("empty.txt"),
+ referrerInfo,
+ source1: "download3source1",
+ source2: "download3source2",
+ },
+ target: {
+ path: getTempFile(TEST_TARGET_FILE_NAME).path,
+ target1: "download3target1",
+ target2: "download3target2",
+ },
+ saver: {
+ type: "copy",
+ saver1: "download3saver1",
+ saver2: "download3saver2",
+ },
+ });
+ listForSave.add(download3);
+
+ await storeForSave.save();
+ await storeForLoad.load();
+
+ let itemsForSave = await listForSave.getAll();
+ let itemsForLoad = await listForLoad.getAll();
+
+ Assert.equal(itemsForSave.length, itemsForLoad.length);
+
+ Assert.equal(Object.keys(itemsForLoad[0]._unknownProperties).length, 2);
+ Assert.equal(itemsForLoad[0]._unknownProperties.peanut, "butter");
+ Assert.equal(itemsForLoad[0]._unknownProperties.orange, "marmalade");
+ Assert.equal(false, "startTime" in itemsForLoad[0]._unknownProperties);
+ Assert.equal(false, "error" in itemsForLoad[0]._unknownProperties);
+
+ Assert.equal(Object.keys(itemsForLoad[1]._unknownProperties).length, 2);
+ Assert.equal(itemsForLoad[1]._unknownProperties.number, 5);
+ Assert.equal(itemsForLoad[1]._unknownProperties.object.test, "string");
+
+ Assert.equal(
+ Object.keys(itemsForLoad[2].source._unknownProperties).length,
+ 2
+ );
+ Assert.equal(
+ itemsForLoad[2].source._unknownProperties.source1,
+ "download3source1"
+ );
+ Assert.equal(
+ itemsForLoad[2].source._unknownProperties.source2,
+ "download3source2"
+ );
+
+ Assert.equal(
+ Object.keys(itemsForLoad[2].target._unknownProperties).length,
+ 2
+ );
+ Assert.equal(
+ itemsForLoad[2].target._unknownProperties.target1,
+ "download3target1"
+ );
+ Assert.equal(
+ itemsForLoad[2].target._unknownProperties.target2,
+ "download3target2"
+ );
+
+ Assert.equal(Object.keys(itemsForLoad[2].saver._unknownProperties).length, 2);
+ Assert.equal(
+ itemsForLoad[2].saver._unknownProperties.saver1,
+ "download3saver1"
+ );
+ Assert.equal(
+ itemsForLoad[2].saver._unknownProperties.saver2,
+ "download3saver2"
+ );
+});
+
+/**
+ * Saves insecure downloads to a file, then reloads the file and checks if they
+ * are still there.
+ */
+add_task(async function test_insecure_download_deletion() {
+ let [listForSave, storeForSave] = await promiseNewListAndStore();
+ let [listForLoad, storeForLoad] = await promiseNewListAndStore(
+ storeForSave.path
+ );
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ NetUtil.newURI(TEST_REFERRER_URL)
+ );
+
+ const createTestDownload = async startTime => {
+ // Create a valid test download and start it so it creates a file
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ let download = await Downloads.createDownload({
+ source: { url: httpUrl("empty.txt"), referrerInfo },
+ target: targetFile.path,
+ startTime: new Date().toISOString(),
+ contentType: "application/zip",
+ });
+ await download.start();
+
+ // Add an "Insecure Download" error and overwrite the start time for
+ // serialization
+ download.hasBlockedData = true;
+ download.error = DownloadError.fromSerializable({
+ becauseBlockedByReputationCheck: true,
+ reputationCheckVerdict: "Insecure",
+ });
+ download.startTime = startTime;
+
+ let targetPath = download.target.path;
+
+ // Add download to store, save, load and retrieve deserialized download list
+ listForSave.add(download);
+ await storeForSave.save();
+ await storeForLoad.load();
+ let loadedDownloadList = await listForLoad.getAll();
+
+ return [loadedDownloadList, targetPath];
+ };
+
+ // Insecure downloads that are older than 5 minutes should get removed from
+ // the download-store and the file should get deleted. (360000 = 6 minutes)
+ let [loadedDownloadList1, targetPath1] = await createTestDownload(
+ new Date(Date.now() - 360000)
+ );
+
+ Assert.equal(loadedDownloadList1.length, 0, "Download should be removed");
+ Assert.ok(
+ !(await IOUtils.exists(targetPath1)),
+ "The file should have been deleted."
+ );
+
+ // Insecure downloads that are newer than 5 minutes should stay in the
+ // download store and the file should remain.
+ let [loadedDownloadList2, targetPath2] = await createTestDownload(new Date());
+
+ Assert.equal(loadedDownloadList2.length, 1, "Download should be kept");
+ Assert.ok(
+ await IOUtils.exists(targetPath2),
+ "The file should have not been deleted."
+ );
+});
diff --git a/toolkit/components/downloads/test/unit/test_Download_noext_win.js b/toolkit/components/downloads/test/unit/test_Download_noext_win.js
new file mode 100644
index 0000000000..0e226229f6
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_Download_noext_win.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ info("Get a file without extension");
+ let noExtFile = getTempFile("test_bug_1661365");
+ Assert.ok(!noExtFile.leafName.includes("."), "Sanity check the filename");
+ info("Create an exe file with the same name");
+ await IOUtils.remove(noExtFile.path + ".exe", { ignoreAbsent: true });
+ let exeFile = new FileUtils.File(noExtFile.path + ".exe");
+ exeFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ Assert.ok(await fileExists(exeFile.path), "Sanity check the exe exists.");
+ Assert.equal(
+ exeFile.leafName,
+ noExtFile.leafName + ".exe",
+ "Sanity check the file names."
+ );
+ registerCleanupFunction(async function () {
+ await IOUtils.remove(noExtFile.path, { ignoreAbsent: true });
+ await IOUtils.remove(exeFile.path, { ignoreAbsent: true });
+ });
+
+ info("Download to the no-extension file");
+ let download = await Downloads.createDownload({
+ source: httpUrl("source.txt"),
+ target: noExtFile,
+ });
+ await download.start();
+
+ Assert.ok(
+ await fileExists(download.target.path),
+ "The file should have been created."
+ );
+ Assert.ok(await fileExists(exeFile.path), "Sanity check the exe exists.");
+
+ info("Launch should open the containing folder");
+ let promiseShowInFolder = waitForDirectoryShown();
+ download.launch();
+ Assert.equal(await promiseShowInFolder, noExtFile.path);
+});
+
+/**
+ * Waits for an attempt to show the directory where a file is located, and
+ * returns the path of the file.
+ */
+function waitForDirectoryShown() {
+ return new Promise(resolve => {
+ let waitFn = base => ({
+ showContainingDirectory(path) {
+ Integration.downloads.unregister(waitFn);
+ resolve(path);
+ return Promise.resolve();
+ },
+ });
+ Integration.downloads.register(waitFn);
+ });
+}
diff --git a/toolkit/components/downloads/test/unit/test_Downloads.js b/toolkit/components/downloads/test/unit/test_Downloads.js
new file mode 100644
index 0000000000..b99f823008
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/test_Downloads.js
@@ -0,0 +1,153 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the functions located directly in the "Downloads" object.
+ */
+
+"use strict";
+
+// Tests
+
+/**
+ * Tests that the createDownload function exists and can be called. More
+ * detailed tests are implemented separately for the DownloadCore module.
+ */
+add_task(async function test_createDownload() {
+ // Creates a simple Download object without starting the download.
+ await Downloads.createDownload({
+ source: { url: "about:blank" },
+ target: { path: getTempFile(TEST_TARGET_FILE_NAME).path },
+ saver: { type: "copy" },
+ });
+});
+
+/**
+ * Tests createDownload for private download.
+ */
+add_task(async function test_createDownload_private() {
+ let download = await Downloads.createDownload({
+ source: { url: "about:blank", isPrivate: true },
+ target: { path: getTempFile(TEST_TARGET_FILE_NAME).path },
+ saver: { type: "copy" },
+ });
+ Assert.ok(download.source.isPrivate);
+});
+
+/**
+ * Tests createDownload for normal (public) download.
+ */
+add_task(async function test_createDownload_public() {
+ let tempPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ let download = await Downloads.createDownload({
+ source: { url: "about:blank", isPrivate: false },
+ target: { path: tempPath },
+ saver: { type: "copy" },
+ });
+ Assert.ok(!download.source.isPrivate);
+
+ download = await Downloads.createDownload({
+ source: { url: "about:blank" },
+ target: { path: tempPath },
+ saver: { type: "copy" },
+ });
+ Assert.ok(!download.source.isPrivate);
+});
+
+/**
+ * Tests "fetch" with nsIURI and nsIFile as arguments.
+ */
+add_task(async function test_fetch_uri_file_arguments() {
+ let targetFile = getTempFile(TEST_TARGET_FILE_NAME);
+ await Downloads.fetch(NetUtil.newURI(httpUrl("source.txt")), targetFile);
+ await promiseVerifyContents(targetFile.path, TEST_DATA_SHORT);
+});
+
+/**
+ * Tests "fetch" with DownloadSource and DownloadTarget as arguments.
+ */
+add_task(async function test_fetch_object_arguments() {
+ let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ await Downloads.fetch({ url: httpUrl("source.txt") }, { path: targetPath });
+ await promiseVerifyContents(targetPath, TEST_DATA_SHORT);
+});
+
+/**
+ * Tests "fetch" with string arguments.
+ */
+add_task(async function test_fetch_string_arguments() {
+ let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ await Downloads.fetch(httpUrl("source.txt"), targetPath);
+ await promiseVerifyContents(targetPath, TEST_DATA_SHORT);
+
+ targetPath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ await Downloads.fetch(httpUrl("source.txt"), targetPath);
+ await promiseVerifyContents(targetPath, TEST_DATA_SHORT);
+});
+
+/**
+ * Tests that the getList function returns the same list when called multiple
+ * times with the same argument, but returns different lists when called with
+ * different arguments. More detailed tests are implemented separately for the
+ * DownloadList module.
+ */
+add_task(async function test_getList() {
+ let publicListOne = await Downloads.getList(Downloads.PUBLIC);
+ let privateListOne = await Downloads.getList(Downloads.PRIVATE);
+
+ let publicListTwo = await Downloads.getList(Downloads.PUBLIC);
+ let privateListTwo = await Downloads.getList(Downloads.PRIVATE);
+
+ Assert.equal(publicListOne, publicListTwo);
+ Assert.equal(privateListOne, privateListTwo);
+
+ Assert.notEqual(publicListOne, privateListOne);
+});
+
+/**
+ * Tests that the getSummary function returns the same summary when called
+ * multiple times with the same argument, but returns different summaries when
+ * called with different arguments. More detailed tests are implemented
+ * separately for the DownloadSummary object in the DownloadList module.
+ */
+add_task(async function test_getSummary() {
+ let publicSummaryOne = await Downloads.getSummary(Downloads.PUBLIC);
+ let privateSummaryOne = await Downloads.getSummary(Downloads.PRIVATE);
+
+ let publicSummaryTwo = await Downloads.getSummary(Downloads.PUBLIC);
+ let privateSummaryTwo = await Downloads.getSummary(Downloads.PRIVATE);
+
+ Assert.equal(publicSummaryOne, publicSummaryTwo);
+ Assert.equal(privateSummaryOne, privateSummaryTwo);
+
+ Assert.notEqual(publicSummaryOne, privateSummaryOne);
+});
+
+/**
+ * Tests that the getSystemDownloadsDirectory returns a non-empty download
+ * directory string.
+ */
+add_task(async function test_getSystemDownloadsDirectory() {
+ let downloadDir = await Downloads.getSystemDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+});
+
+/**
+ * Tests that the getPreferredDownloadsDirectory returns a non-empty download
+ * directory string.
+ */
+add_task(async function test_getPreferredDownloadsDirectory() {
+ let downloadDir = await Downloads.getPreferredDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+});
+
+/**
+ * Tests that the getTemporaryDownloadsDirectory returns a non-empty download
+ * directory string.
+ */
+add_task(async function test_getTemporaryDownloadsDirectory() {
+ let downloadDir = await Downloads.getTemporaryDownloadsDirectory();
+ Assert.notEqual(downloadDir, "");
+});
diff --git a/toolkit/components/downloads/test/unit/xpcshell.toml b/toolkit/components/downloads/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..073a4a8eeb
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/xpcshell.toml
@@ -0,0 +1,33 @@
+[DEFAULT]
+head = "head.js"
+skip-if = ["os == 'android'"]
+
+# Note: The "tail.js" file is not defined in the "tail" key because it calls
+# the "add_test_task" function, that does not work properly in tail files.
+support-files = ["common_test_Download.js"]
+
+["test_DownloadBlockedTelemetry.js"]
+
+["test_DownloadCore.js"]
+
+["test_DownloadHistory.js"]
+
+["test_DownloadHistory_initialization.js"]
+
+["test_DownloadHistory_initialization2.js"]
+
+["test_DownloadIntegration.js"]
+
+["test_DownloadLegacy.js"]
+run-sequentially = "very high failure rate in parallel"
+
+["test_DownloadList.js"]
+
+["test_DownloadPaths.js"]
+
+["test_DownloadStore.js"]
+
+["test_Download_noext_win.js"]
+run-if = ["os == 'win'"] # Windows-specific test.
+
+["test_Downloads.js"]