diff options
Diffstat (limited to 'toolkit/components/downloads/DownloadCore.sys.mjs')
-rw-r--r-- | toolkit/components/downloads/DownloadCore.sys.mjs | 3086 |
1 files changed, 3086 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..cca7e59fe7 --- /dev/null +++ b/toolkit/components/downloads/DownloadCore.sys.mjs @@ -0,0 +1,3086 @@ +/* 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", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +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 = lazy.PromiseUtils.defer(); +}; + +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 = lazy.PromiseUtils.defer(); + 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 = lazy.PromiseUtils.defer(); + + 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 = lazy.PromiseUtils.defer(); + this.deferCanceled = lazy.PromiseUtils.defer(); +}; + +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(); +}; |