/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * This file includes the following constructors and global objects: * * Download * 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. * * DownloadSource * Represents the source of a download, for example a document or an URI. * * DownloadTarget * Represents the target of a download, for example a file in the global * downloads directory, or a file in the system temporary directory. * * DownloadError * Provides detailed information about a download failure. * * DownloadSaver * Template for an object that actually transfers the data for the download. * * DownloadCopySaver * Saver object that simply copies the entire source file to the target. * * DownloadLegacySaver * Saver object that integrates with the legacy nsITransfer interface. * * DownloadPDFSaver * This DownloadSaver type creates a PDF file from the current document in a * given window, specified using the windowRef property of the DownloadSource * object associated with the download. */ "use strict"; this.EXPORTED_SYMBOLS = [ "Download", "DownloadSource", "DownloadTarget", "DownloadError", "DownloadSaver", "DownloadCopySaver", "DownloadLegacySaver", "DownloadPDFSaver", ]; // Globals const Cc = Components.classes; const Ci = Components.interfaces; const Cu = Components.utils; const Cr = Components.results; Cu.import("resource://gre/modules/Integration.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm") XPCOMUtils.defineLazyModuleGetter(this, "Promise", "resource://gre/modules/Promise.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"); XPCOMUtils.defineLazyServiceGetter(this, "gDownloadHistory", "@mozilla.org/browser/download-history;1", Ci.nsIDownloadHistory); XPCOMUtils.defineLazyServiceGetter(this, "gExternalAppLauncher", "@mozilla.org/uriloader/external-helper-app-service;1", Ci.nsPIExternalAppLauncher); XPCOMUtils.defineLazyServiceGetter(this, "gExternalHelperAppService", "@mozilla.org/uriloader/external-helper-app-service;1", Ci.nsIExternalHelperAppService); XPCOMUtils.defineLazyServiceGetter(this, "gPrintSettingsService", "@mozilla.org/gfx/printsettings-service;1", Ci.nsIPrintSettingsService); Integration.downloads.defineModuleGetter(this, "DownloadIntegration", "resource://gre/modules/DownloadIntegration.jsm"); 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]; } } } /** * 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; // Download /** * 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. */ this.Download = function () { this._deferSucceeded = Promise.defer(); } this.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, /** * 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, /** * 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) { Cu.reportError(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, /** * 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; this.hasProgress = false; this.hasBlockedData = false; this.progress = 0; this.totalBytes = 0; this.currentBytes = 0; this.startTime = new Date(); // Create a new deferred object and an associated promise before starting // the actual download. We store it on the download as the current attempt. let deferAttempt = Promise.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(Task.spawn(function* task_D_start() { // Wait upon any pending operation before restarting. if (this._promiseCanceled) { yield this._promiseCanceled; } if (this._promiseRemovePartialData) { try { yield 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 { // Disallow download if parental controls service restricts it. if (yield DownloadIntegration.shouldBlockForParentalControls(this)) { throw new DownloadError({ becauseBlockedByParentalControls: true }); } // Disallow download if needed runtime permissions have not been granted // by user. if (yield DownloadIntegration.shouldBlockForRuntimePermissions()) { throw new DownloadError({ becauseBlockedByRuntimePermissions: 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 undefined; } // Execute the actual download through the saver object. this._saverExecuting = true; yield this.saver.execute(DS_setProgressBytes.bind(this), DS_setProperties.bind(this)); // Now that the actual saving finished, read the actual file size on // disk, that may be different from the amount of data transferred. yield 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) { try { yield OS.File.remove(this.target.path); } catch (ex) { Cu.reportError(ex); } this.target.exists = false; this.target.size = 0; // 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); } 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) { yield this._succeed(); } } } }.bind(this))); // 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. */ _succeed: Task.async(function* () { yield DownloadIntegration.downloadDone(this); this._deferSucceeded.resolve(); if (this.launchWhenSucceeded) { this.launch().then(null, Cu.reportError); // 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) { gExternalAppLauncher.deleteTemporaryPrivateFileWhenPossible( new FileUtils.File(this.target.path)); } else if (Services.prefs.getBoolPref( "browser.helperApps.deleteTempFileOnExit")) { gExternalAppLauncher.deleteTemporaryFileOnExit( new 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: function() { if (this._promiseUnblock) { return this._promiseUnblock; } if (this._promiseConfirmBlock) { return Promise.reject(new Error( "Download block has been confirmed, cannot unblock.")); } if (!this.hasBlockedData) { return Promise.reject(new Error( "unblock may only be called on Downloads with blocked data.")); } this._promiseUnblock = Task.spawn(function* () { try { yield OS.File.move(this.target.partFilePath, this.target.path); yield this.target.refresh(); } catch (ex) { yield this.refresh(); this._promiseUnblock = null; throw ex; } this.succeeded = true; this.hasBlockedData = false; this._notifyChange(); yield this._succeed(); }.bind(this)); 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: function() { if (this._promiseConfirmBlock) { return this._promiseConfirmBlock; } if (this._promiseUnblock) { return Promise.reject(new Error( "Download is being unblocked, cannot confirmBlock.")); } if (!this.hasBlockedData) { return Promise.reject(new Error( "confirmBlock may only be called on Downloads with blocked data.")); } this._promiseConfirmBlock = Task.spawn(function* () { try { yield OS.File.remove(this.target.partFilePath); } catch (ex) { yield this.refresh(); this._promiseConfirmBlock = null; throw ex; } this.hasBlockedData = false; this._notifyChange(); }.bind(this)); 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. * * @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: function () { if (!this.succeeded) { return Promise.reject( new Error("launch can only be called if the download succeeded") ); } return DownloadIntegration.launchDownload(this); }, /* * 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 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. let deferCanceled = Promise.defer(); this._currentAttempt.then(() => deferCanceled.resolve(), () => deferCanceled.resolve()); this._promiseCanceled = deferCanceled.promise; // 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: function () { if (!this.canceled && !this.error) { return Promise.resolve(); } let promiseRemovePartialData = this._promiseRemovePartialData; if (!promiseRemovePartialData) { let deferRemovePartialData = Promise.defer(); promiseRemovePartialData = deferRemovePartialData.promise; this._promiseRemovePartialData = promiseRemovePartialData; deferRemovePartialData.resolve( Task.spawn(function* task_D_removePartialData() { try { // Wait upon any pending cancellation request. if (this._promiseCanceled) { yield this._promiseCanceled; } // Ask the saver object to remove any partial data. yield this.saver.removePartialData(); // For completeness, clear the number of bytes transferred. if (this.currentBytes != 0 || this.hasPartialData) { this.currentBytes = 0; this.hasPartialData = false; this._notifyChange(); } } finally { this._promiseRemovePartialData = null; } }.bind(this))); } return promiseRemovePartialData; }, /** * 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: function () { return Task.spawn(function* () { if (!this.stopped || this._finalized) { return; } if (this.succeeded) { let oldExists = this.target.exists; let oldSize = this.target.size; yield 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 = yield OS.File.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 instanceof OS.File.Error) || !ex.becauseNoSuchFile) { throw ex; } // Ignore the result if the state has changed meanwhile. if (!this.stopped || this._finalized) { return; } this.hasBlockedData = false; this.hasPartialData = false; } this._notifyChange(); } }.bind(this)).then(null, Cu.reportError); }, /** * True if the "finalize" method has been called. This prevents the download * from starting again after having been stopped. */ _finalized: 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: function (aRemovePartialData) { // Prevents the download from starting again after having been stopped. this._finalized = true; 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(); return this.removePartialData(); } // Just cancel the download, in case it is currently in progress. return this.cancel(); }, /** * 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 (changeMade) { this._notifyChange(); } }, /** * Returns a static representation of the current object state. * * @return A JavaScript object that can be serialized to JSON. */ toSerializable: function () { 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: function () { // 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", ]; /** * Creates a new Download object from a serializable representation. This * function is used by the createDownload method of Downloads.jsm 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.indexOf(property) == -1 && property != "startTime" && property != "source" && property != "target" && property != "error" && property != "saver"); return download; }; // DownloadSource /** * Represents the source of a download, for example a document or an URI. */ this.DownloadSource = function () {} this.DownloadSource.prototype = { /** * String containing the URI for the download source. */ url: 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, /** * String containing the referrer URI of the download source, or null if no * referrer should be sent or the download source is not HTTP. */ referrer: 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, /** * Returns a static representation of the current object state. * * @return A JavaScript object that can be serialized to JSON. */ toSerializable: function () { if (this.adjustChannel) { // If the callback was used, we can't reproduce this across sessions. return null; } // Simplify the representation if we don't have other details. if (!this.isPrivate && !this.referrer && !this._unknownProperties) { return this.url; } let serializable = { url: this.url }; if (this.isPrivate) { serializable.isPrivate = true; } if (this.referrer) { serializable.referrer = this.referrer; } serializeUnknownProperties(this, serializable); 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. * referrer: String containing the referrer URI of the download source. * Can be omitted or null if no referrer should be sent or * the download source is not HTTP. * 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. * } * * @return The newly created DownloadSource object. */ this.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 if (aSerializable instanceof Ci.nsIDOMWindow) { source.url = aSerializable.location.href; source.isPrivate = PrivateBrowsingUtils.isContentWindowPrivate(aSerializable); source.windowRef = Cu.getWeakReference(aSerializable); } else { // Convert String objects to primitive strings at this point. source.url = aSerializable.url.toString(); if ("isPrivate" in aSerializable) { source.isPrivate = aSerializable.isPrivate; } if ("referrer" in aSerializable) { source.referrer = aSerializable.referrer; } if ("adjustChannel" in aSerializable) { source.adjustChannel = aSerializable.adjustChannel; } deserializeUnknownProperties(source, aSerializable, property => property != "url" && property != "isPrivate" && property != "referrer"); } return source; }; // DownloadTarget /** * Represents the target of a download, for example a file in the global * downloads directory, or a file in the system temporary directory. */ this.DownloadTarget = function () {} this.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, /** * 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. */ refresh: Task.async(function* () { try { this.size = (yield OS.File.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 instanceof OS.File.Error && ex.becauseNoSuchFile)) { Cu.reportError(ex); } this.exists = false; } }), /** * Returns a static representation of the current object state. * * @return A JavaScript object that can be serialized to JSON. */ toSerializable: function () { // 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. */ this.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; }; // DownloadError /** * 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. */ this.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 || aProperties.becauseBlockedByRuntimePermissions) { 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.becauseBlockedByRuntimePermissions) { this.becauseBlocked = true; this.becauseBlockedByRuntimePermissions = true; } 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. */ this.DownloadError.BLOCK_VERDICT_MALWARE = "Malware"; this.DownloadError.BLOCK_VERDICT_POTENTIALLY_UNWANTED = "PotentiallyUnwanted"; this.DownloadError.BLOCK_VERDICT_UNCOMMON = "Uncommon"; this.DownloadError.prototype = { __proto__: Error.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, /** * Indicates the download was blocked because a runtime permission required to * download files was not granted. * * This does not apply to all systems. On Android this flag is set to true if * a needed runtime permission (storage) has not been granted by the user. */ becauseBlockedByRuntimePermissions: 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: function () { let serializable = { result: this.result, message: this.message, becauseSourceFailed: this.becauseSourceFailed, becauseTargetFailed: this.becauseTargetFailed, becauseBlocked: this.becauseBlocked, becauseBlockedByParentalControls: this.becauseBlockedByParentalControls, becauseBlockedByReputationCheck: this.becauseBlockedByReputationCheck, becauseBlockedByRuntimePermissions: this.becauseBlockedByRuntimePermissions, reputationCheckVerdict: this.reputationCheckVerdict, }; serializeUnknownProperties(this, serializable); return serializable; }, }; /** * Creates a new DownloadError object from its serializable representation. * * @param aSerializable * Serializable representation of a DownloadError object. * * @return The newly created DownloadError object. */ this.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 != "becauseBlockedByRuntimePermissions" && property != "reputationCheckVerdict"); return e; }; // DownloadSaver /** * Template for an object that actually transfers the data for the download. */ this.DownloadSaver = function () {} this.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. */ execute: function DS_execute(aSetProgressBytesFn, aSetPropertiesFn) { throw new Error("Not implemented."); }, /** * Cancels the download. */ cancel: function DS_cancel() { throw new Error("Not implemented."); }, /** * Removes any partial data kept as part of a canceled or failed 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. * * @return {Promise} * @resolves When the operation has finished successfully. * @rejects JavaScript exception. */ removePartialData: function DS_removePartialData() { return Promise.resolve(); }, /** * 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: function () { if (this.download.source.isPrivate) { return; } let sourceUri = NetUtil.newURI(this.download.source.url); let referrer = this.download.source.referrer; let referrerUri = referrer ? NetUtil.newURI(referrer) : null; let targetUri = NetUtil.newURI(new FileUtils.File( this.download.target.path)); // The start time is always available when we reach this point. let startPRTime = this.download.startTime.getTime() * 1000; try { gDownloadHistory.addDownload(sourceUri, referrerUri, startPRTime, targetUri); } catch (ex) { if (!(ex instanceof Components.Exception) || ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { throw ex; } // // Under normal operation the download history service may not // be available. We don't want all downloads that are public to fail // when this happens so we'll ignore this error and this error only! // } }, /** * Returns a static representation of the current object state. * * @return A JavaScript object that can be serialized to JSON. */ toSerializable: function () { throw new Error("Not implemented."); }, /** * Returns the SHA-256 hash of the downloaded file, if it exists. */ getSha256Hash: function () { throw new Error("Not implemented."); }, getSignatureInfo: function () { 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. */ this.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; case "pdf": saver = DownloadPDFSaver.fromSerializable(serializable); break; default: throw new Error("Unrecoginzed download saver type."); } return saver; }; // DownloadCopySaver /** * Saver object that simply copies the entire source file to the target. */ this.DownloadCopySaver = function () {} this.DownloadCopySaver.prototype = { __proto__: DownloadSaver.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 nsIArray of nsIX509CertList 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". */ execute: function DCS_execute(aSetProgressBytesFn, aSetPropertiesFn) { let copySaver = this; this._canceled = false; let download = this.download; let targetPath = download.target.path; let partFilePath = download.target.partFilePath; let keepPartialData = download.tryToKeepPartialData; return Task.spawn(function* task_DCS_execute() { // 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. let file = yield OS.File.open(targetPath, { write: true }); yield file.close(); } catch (ex) { if (!(ex instanceof OS.File.Error)) { 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 using the toString method. let error = new DownloadError({ message: ex.toString() }); error.becauseTargetFailed = true; throw error; } try { let deferSaveComplete = Promise.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(); try { // When the operation completes, reflect the status in the promise // returned by this download execution function. backgroundFileSaver.observer = { onTargetChange: function () { }, 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; }, }; // Create a channel from the source, and listen to progress // notifications. let channel = NetUtil.newChannel({ uri: download.source.url, loadUsingSystemPrincipal: true, }); if (channel instanceof Ci.nsIPrivateBrowsingChannel) { channel.setPrivate(download.source.isPrivate); } if (channel instanceof Ci.nsIHttpChannel && download.source.referrer) { channel.referrer = NetUtil.newURI(download.source.referrer); } // 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; if (channel instanceof Ci.nsIResumableChannel && this.entityID && partFilePath && keepPartialData) { try { let stat = yield OS.File.stat(partFilePath); channel.resumeAt(stat.size, this.entityID); resumeAttempted = true; resumeFromBytes = stat.size; } catch (ex) { if (!(ex instanceof OS.File.Error) || !ex.becauseNoSuchFile) { throw ex; } } } channel.notificationCallbacks = { QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor]), getInterface: XPCOMUtils.generateQI([Ci.nsIProgressEventSink]), onProgress: function DCSE_onProgress(aRequest, aContext, aProgress, aProgressMax) { let currentBytes = resumeFromBytes + aProgress; let totalBytes = aProgressMax == -1 ? -1 : (resumeFromBytes + aProgressMax); aSetProgressBytesFn(currentBytes, totalBytes, aProgress > 0 && partFilePath && keepPartialData); }, onStatus: function () { }, }; // If the callback was set, handle it now before opening the channel. if (download.source.adjustChannel) { yield download.source.adjustChannel(channel); } // Open the channel, directing output to the background file saver. backgroundFileSaver.QueryInterface(Ci.nsIStreamListener); channel.asyncOpen2({ onStartRequest: function (aRequest, aContext) { backgroundFileSaver.onStartRequest(aRequest, aContext); // Check if the request's response has been blocked by Windows // Parental Controls with an HTTP 450 error code. if (aRequest instanceof Ci.nsIHttpChannel && 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; } aSetPropertiesFn({ contentType: channel.contentType }); // Ensure we report the value of "Content-Length", if available, // even if the download doesn't generate any progress events // later. if (channel.contentLength >= 0) { aSetProgressBytesFn(0, channel.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 (channel instanceof Ci.nsIEncodedChannel && channel.contentEncodings) { let uri = channel.URI; if (uri instanceof Ci.nsIURL && uri.fileExtension) { // Only the first, outermost encoding is considered. let encoding = channel.contentEncodings.getNext(); if (encoding) { channel.applyConversion = 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 FileUtils.File(partFilePath), keepPartialData); } else { // Set the final target file, and delete it on failure. backgroundFileSaver.setTarget(new FileUtils.File(targetPath), false); } }.bind(copySaver), onStopRequest: function (aRequest, aContext, aStatusCode) { try { backgroundFileSaver.onStopRequest(aRequest, aContext, 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); } } }.bind(copySaver), onDataAvailable: function (aRequest, aContext, aInputStream, aOffset, aCount) { backgroundFileSaver.onDataAvailable(aRequest, aContext, aInputStream, aOffset, aCount); }.bind(copySaver), }); // 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. yield deferSaveComplete.promise; yield this._checkReputationAndMove(aSetPropertiesFn); } catch (ex) { // Ensure we always remove the placeholder for the final target file on // failure, independently of which code path failed. In some cases, the // background file saver may have already removed the file. try { yield OS.File.remove(targetPath); } catch (e2) { // If we failed during the operation, we report the error but use the // original one as the failure reason of the download. Note that 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. if (!(e2 instanceof OS.File.Error && (e2.becauseNoSuchFile || e2.becauseAccessDenied))) { Cu.reportError(e2); } } throw ex; } }.bind(this)); }, /** * 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. */ _checkReputationAndMove: Task.async(function* (aSetPropertiesFn) { let download = this.download; let targetPath = this.download.target.path; let partFilePath = this.download.target.partFilePath; let { shouldBlock, verdict } = yield DownloadIntegration.shouldBlockForReputationCheck(download); if (shouldBlock) { 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 (!DownloadIntegration.shouldKeepBlockedData() || !partFilePath) { try { yield OS.File.remove(partFilePath || targetPath); } catch (ex) { Cu.reportError(ex); } } else { newProperties.hasBlockedData = true; } aSetPropertiesFn(newProperties); throw new DownloadError({ becauseBlockedByReputationCheck: true, reputationCheckVerdict: verdict, }); } if (partFilePath) { yield OS.File.move(partFilePath, targetPath); } }), /** * 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.removePartialData". */ removePartialData: function () { return Task.spawn(function* task_DCS_removePartialData() { if (this.download.target.partFilePath) { try { yield OS.File.remove(this.download.target.partFilePath); } catch (ex) { if (!(ex instanceof OS.File.Error) || !ex.becauseNoSuchFile) { throw ex; } } } }.bind(this)); }, /** * Implements "DownloadSaver.toSerializable". */ toSerializable: function () { // 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: function () { return this._sha256Hash; }, /* * Implements DownloadSaver.getSignatureInfo. */ getSignatureInfo: function () { return this._signatureInfo; }, /* * Implements DownloadSaver.getRedirects. */ getRedirects: function () { return this._redirects; } }; /** * 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. */ this.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; }; // DownloadLegacySaver /** * Saver object that integrates with the legacy nsITransfer interface. * * For more background on the process, see the DownloadLegacyTransfer object. */ this.DownloadLegacySaver = function () { this.deferExecuted = Promise.defer(); this.deferCanceled = Promise.defer(); } this.DownloadLegacySaver.prototype = { __proto__: DownloadSaver.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 nsIArray of nsIX509CertList 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. * @param aAlreadyAddedToHistory * Indicates that the nsIExternalHelperAppService component already * added the download to the browsing history, unless it was started * from a private browsing window. When this parameter is false, the * download is added to the browsing history here. Private downloads * are never added to history even if this parameter is false. */ onTransferStarted: function (aRequest, aAlreadyAddedToHistory) { // 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 referrer at this time. if (aRequest instanceof Ci.nsIHttpChannel && aRequest.referrer) { this.download.source.referrer = aRequest.referrer.spec; } if (!aAlreadyAddedToHistory) { this.addToHistory(); } }, /** * Called by the nsITransfer implementation when the request has finished. * * @param aRequest * nsIRequest associated to the status update. * @param aStatus * Status code received by the nsITransfer implementation. */ onTransferFinished: function DLS_onTransferFinished(aRequest, aStatus) { // Store a reference to the request, used when handling completion. this.request = aRequest; 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". */ execute: function DLS_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; } return this.copySaver.execute.apply(this.copySaver, arguments); } this.setProgressBytesFn = aSetProgressBytesFn; if (this.progressWasNotified) { this.onProgressBytes(this.currentBytes, this.totalBytes); } return Task.spawn(function* task_DLS_execute() { try { // Wait for the component that executes the download to finish. yield 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. let file = yield OS.File.open(this.download.target.path, { create: true }); yield file.close(); } catch (ex) { if (!(ex instanceof OS.File.Error) || !ex.becauseExists) { throw ex; } } } yield this._checkReputationAndMove(aSetPropertiesFn); } catch (ex) { // Ensure we always remove the final target file on failure, // independently of which code path failed. In some cases, the // component executing the download may have already removed the file. try { yield OS.File.remove(this.download.target.path); } catch (e2) { // If we failed during the operation, we report the error but use the // original one as the failure reason of the download. Note that 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. if (!(e2 instanceof OS.File.Error && (e2.becauseNoSuchFile || e2.becauseAccessDenied))) { Cu.reportError(e2); } } // 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; } }.bind(this)); }, _checkReputationAndMove: function () { 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.removePartialData". */ removePartialData: function () { // DownloadCopySaver and DownloadLeagcySaver 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.removePartialData.call(this); }, /** * Implements "DownloadSaver.toSerializable". */ toSerializable: function () { // 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: function () { if (this.copySaver) { return this.copySaver.getSha256Hash(); } return this._sha256Hash; }, /** * Called by the nsITransfer implementation when the hash is available. */ setSha256Hash: function (hash) { this._sha256Hash = hash; }, /** * Implements "DownloadSaver.getSignatureInfo". */ getSignatureInfo: function () { if (this.copySaver) { return this.copySaver.getSignatureInfo(); } return this._signatureInfo; }, /** * Called by the nsITransfer implementation when the hash is available. */ setSignatureInfo: function (signatureInfo) { this._signatureInfo = signatureInfo; }, /** * Implements "DownloadSaver.getRedirects". */ getRedirects: function () { if (this.copySaver) { return this.copySaver.getRedirects(); } return this._redirects; }, /** * Called by the nsITransfer implementation when the redirect chain is * available. */ setRedirects: function (redirects) { this._redirects = redirects; }, }; /** * 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. */ this.DownloadLegacySaver.fromSerializable = function () { return new DownloadLegacySaver(); }; // DownloadPDFSaver /** * This DownloadSaver type creates a PDF file from the current document in a * given window, specified using the windowRef property of the DownloadSource * object associated with the download. * * In order to prevent the download from saving a different document than the one * originally loaded in the window, any attempt to restart the download will fail. * * Since this DownloadSaver type requires a live document as a source, it cannot * be persisted across sessions, unless the download already succeeded. */ this.DownloadPDFSaver = function () { } this.DownloadPDFSaver.prototype = { __proto__: DownloadSaver.prototype, /** * An nsIWebBrowserPrint instance for printing this page. * This is null when saving has not started or has completed, * or while the operation is being canceled. */ _webBrowserPrint: null, /** * Implements "DownloadSaver.execute". */ execute: function (aSetProgressBytesFn, aSetPropertiesFn) { return Task.spawn(function* task_DCS_execute() { if (!this.download.source.windowRef) { throw new DownloadError({ message: "PDF saver must be passed an open window, and cannot be restarted.", becauseSourceFailed: true, }); } let win = this.download.source.windowRef.get(); // Set windowRef to null to avoid re-trying. this.download.source.windowRef = null; if (!win) { throw new DownloadError({ message: "PDF saver can't save a window that has been closed.", becauseSourceFailed: true, }); } this.addToHistory(); let targetPath = this.download.target.path; // An empty target file must exist for the PDF printer to work correctly. let file = yield OS.File.open(targetPath, { truncate: true }); yield file.close(); let printSettings = gPrintSettingsService.newPrintSettings; printSettings.printToFile = true; printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; printSettings.toFileName = targetPath; printSettings.printSilent = true; printSettings.showPrintProgress = false; printSettings.printBGImages = true; printSettings.printBGColors = true; printSettings.printFrameType = Ci.nsIPrintSettings.kFramesAsIs; printSettings.headerStrCenter = ""; printSettings.headerStrLeft = ""; printSettings.headerStrRight = ""; printSettings.footerStrCenter = ""; printSettings.footerStrLeft = ""; printSettings.footerStrRight = ""; this._webBrowserPrint = win.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebBrowserPrint); try { yield new Promise((resolve, reject) => { this._webBrowserPrint.print(printSettings, { onStateChange: function (webProgress, request, stateFlags, status) { if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { if (!Components.isSuccessCode(status)) { reject(new DownloadError({ result: status, inferCause: true })); } else { resolve(); } } }, onProgressChange: function (webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) { aSetProgressBytesFn(curTotalProgress, maxTotalProgress, false); }, onLocationChange: function () {}, onStatusChange: function () {}, onSecurityChange: function () {}, }); }); } finally { // Remove the print object to avoid leaks this._webBrowserPrint = null; } let fileInfo = yield OS.File.stat(targetPath); aSetProgressBytesFn(fileInfo.size, fileInfo.size, false); }.bind(this)); }, /** * Implements "DownloadSaver.cancel". */ cancel: function DCS_cancel() { if (this._webBrowserPrint) { this._webBrowserPrint.cancel(); this._webBrowserPrint = null; } }, /** * Implements "DownloadSaver.toSerializable". */ toSerializable: function () { if (this.download.succeeded) { return DownloadCopySaver.prototype.toSerializable.call(this); } // This object needs a window to recreate itself. If it didn't succeded // it will not be possible to restart. Returning null here will // prevent us from serializing it at all. return null; }, }; /** * Creates a new DownloadPDFSaver object, with its initial state derived from * its serializable representation. * * @param aSerializable * Serializable representation of a DownloadPDFSaver object. * * @return The newly created DownloadPDFSaver object. */ this.DownloadPDFSaver.fromSerializable = function (aSerializable) { return new DownloadPDFSaver(); };