diff options
Diffstat (limited to 'toolkit/components/jsdownloads/src')
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadCore.jsm | 2871 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadImport.jsm | 193 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadIntegration.jsm | 1273 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadLegacy.js | 309 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadList.jsm | 559 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadPlatform.cpp | 275 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadPlatform.h | 34 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadStore.jsm | 203 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadUIHelper.jsm | 243 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/Downloads.jsm | 305 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/Downloads.manifest | 2 | ||||
-rw-r--r-- | toolkit/components/jsdownloads/src/moz.build | 31 |
12 files changed, 6298 insertions, 0 deletions
diff --git a/toolkit/components/jsdownloads/src/DownloadCore.jsm b/toolkit/components/jsdownloads/src/DownloadCore.jsm new file mode 100644 index 000000000..d89dd5805 --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm @@ -0,0 +1,2871 @@ +/* -*- 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(); +}; diff --git a/toolkit/components/jsdownloads/src/DownloadImport.jsm b/toolkit/components/jsdownloads/src/DownloadImport.jsm new file mode 100644 index 000000000..5fb7fd0c7 --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadImport.jsm @@ -0,0 +1,193 @@ +/* -*- 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "DownloadImport", +]; + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm") +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +/** + * These values come from the previous interface + * nsIDownloadManager, which has now been deprecated. + * These are the only types of download states that + * we will import. + */ +const DOWNLOAD_NOTSTARTED = -1; +const DOWNLOAD_DOWNLOADING = 0; +const DOWNLOAD_PAUSED = 4; +const DOWNLOAD_QUEUED = 5; + +// DownloadImport + +/** + * Provides an object that has a method to import downloads + * from the previous SQLite storage format. + * + * @param aList A DownloadList where each successfully + * imported download will be added. + * @param aPath The path to the database file. + */ +this.DownloadImport = function (aList, aPath) +{ + this.list = aList; + this.path = aPath; +} + +this.DownloadImport.prototype = { + /** + * Imports unfinished downloads from the previous SQLite storage + * format (supporting schemas 7 and up), to the new Download object + * format. Each imported download will be added to the DownloadList + * + * @return {Promise} + * @resolves When the operation has completed (i.e., every download + * from the previous database has been read and added to + * the DownloadList) + */ + import: function () { + return Task.spawn(function* task_DI_import() { + let connection = yield Sqlite.openConnection({ path: this.path }); + + try { + let schemaVersion = yield connection.getSchemaVersion(); + // We don't support schemas older than version 7 (from 2007) + // - Version 7 added the columns mimeType, preferredApplication + // and preferredAction in 2007 + // - Version 8 added the column autoResume in 2007 + // (if we encounter version 7 we will treat autoResume = false) + // - Version 9 is the last known version, which added a unique + // GUID text column that is not used here + if (schemaVersion < 7) { + throw new Error("Unable to import in-progress downloads because " + + "the existing profile is too old."); + } + + let rows = yield connection.execute("SELECT * FROM moz_downloads"); + + for (let row of rows) { + try { + // Get the DB row data + let source = row.getResultByName("source"); + let target = row.getResultByName("target"); + let tempPath = row.getResultByName("tempPath"); + let startTime = row.getResultByName("startTime"); + let state = row.getResultByName("state"); + let referrer = row.getResultByName("referrer"); + let maxBytes = row.getResultByName("maxBytes"); + let mimeType = row.getResultByName("mimeType"); + let preferredApplication = row.getResultByName("preferredApplication"); + let preferredAction = row.getResultByName("preferredAction"); + let entityID = row.getResultByName("entityID"); + + let autoResume = false; + try { + autoResume = (row.getResultByName("autoResume") == 1); + } catch (ex) { + // autoResume wasn't present in schema version 7 + } + + if (!source) { + throw new Error("Attempted to import a row with an empty " + + "source column."); + } + + let resumeDownload = false; + + switch (state) { + case DOWNLOAD_NOTSTARTED: + case DOWNLOAD_QUEUED: + case DOWNLOAD_DOWNLOADING: + resumeDownload = true; + break; + + case DOWNLOAD_PAUSED: + resumeDownload = autoResume; + break; + + default: + // We won't import downloads in other states + continue; + } + + // Transform the data + let targetPath = NetUtil.newURI(target) + .QueryInterface(Ci.nsIFileURL).file.path; + + let launchWhenSucceeded = (preferredAction != Ci.nsIMIMEInfo.saveToDisk); + + let downloadOptions = { + source: { + url: source, + referrer: referrer + }, + target: { + path: targetPath, + partFilePath: tempPath, + }, + saver: { + type: "copy", + entityID: entityID + }, + startTime: new Date(startTime / 1000), + totalBytes: maxBytes, + hasPartialData: !!tempPath, + tryToKeepPartialData: true, + launchWhenSucceeded: launchWhenSucceeded, + contentType: mimeType, + launcherPath: preferredApplication + }; + + // Paused downloads that should not be auto-resumed are considered + // in a "canceled" state. + if (!resumeDownload) { + downloadOptions.canceled = true; + } + + let download = yield Downloads.createDownload(downloadOptions); + + yield this.list.add(download); + + if (resumeDownload) { + download.start().catch(() => {}); + } else { + yield download.refresh(); + } + + } catch (ex) { + Cu.reportError("Error importing download: " + ex); + } + } + + } catch (ex) { + Cu.reportError(ex); + } finally { + yield connection.close(); + } + }.bind(this)); + } +} + diff --git a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm new file mode 100644 index 000000000..5fed9212a --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm @@ -0,0 +1,1273 @@ +/* -*- 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/. */ + +/** + * Provides functions to integrate with the host application, handling for + * example the global prompts on shutdown. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "DownloadIntegration", +]; + +//////////////////////////////////////////////////////////////////////////////// +//// 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, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadStore", + "resource://gre/modules/DownloadStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadImport", + "resource://gre/modules/DownloadImport.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadUIHelper", + "resource://gre/modules/DownloadUIHelper.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"); +#ifdef MOZ_PLACES +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +#endif +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, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gDownloadPlatform", + "@mozilla.org/toolkit/download-platform;1", + "mozIDownloadPlatform"); +XPCOMUtils.defineLazyServiceGetter(this, "gEnvironment", + "@mozilla.org/process/environment;1", + "nsIEnvironment"); +XPCOMUtils.defineLazyServiceGetter(this, "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService"); +XPCOMUtils.defineLazyServiceGetter(this, "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService"); +#ifdef MOZ_WIDGET_ANDROID +XPCOMUtils.defineLazyModuleGetter(this, "RuntimePermissions", + "resource://gre/modules/RuntimePermissions.jsm"); +#endif + +XPCOMUtils.defineLazyGetter(this, "gParentalControlsService", function() { + if ("@mozilla.org/parental-controls-service;1" in Cc) { + return Cc["@mozilla.org/parental-controls-service;1"] + .createInstance(Ci.nsIParentalControlsService); + } + return null; +}); + +XPCOMUtils.defineLazyServiceGetter(this, "gApplicationReputationService", + "@mozilla.org/downloads/application-reputation-service;1", + Ci.nsIApplicationReputationService); + +XPCOMUtils.defineLazyServiceGetter(this, "volumeService", + "@mozilla.org/telephony/volume-service;1", + "nsIVolumeService"); + +// We have to use the gCombinedDownloadIntegration identifier because, in this +// module only, the DownloadIntegration identifier refers to the base version. +Integration.downloads.defineModuleGetter(this, "gCombinedDownloadIntegration", + "resource://gre/modules/DownloadIntegration.jsm", + "DownloadIntegration"); + +const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", + "initWithCallback"); + +/** + * Indicates the delay between a change to the downloads data and the related + * save operation. + * + * For best efficiency, this value should be high enough that the input/output + * for opening or closing the target file does not overlap with the one for + * saving the list of downloads. + */ +const kSaveDelayMs = 1500; + +/** + * This pref indicates if we have already imported (or attempted to import) + * the downloads database from the previous SQLite storage. + */ +const kPrefImportedFromSqlite = "browser.download.importedFromSqlite"; + +/** + * List of observers to listen against + */ +const kObserverTopics = [ + "quit-application-requested", + "offline-requested", + "last-pb-context-exiting", + "last-pb-context-exited", + "sleep_notification", + "suspend_process_notification", + "wake_notification", + "resume_process_notification", + "network:offline-about-to-go-offline", + "network:offline-status-changed", + "xpcom-will-shutdown", +]; + +/** + * Maps nsIApplicationReputationService verdicts with the DownloadError ones. + */ +const kVerdictMap = { + [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS]: + Downloads.Error.BLOCK_VERDICT_MALWARE, + [Ci.nsIApplicationReputationService.VERDICT_UNCOMMON]: + Downloads.Error.BLOCK_VERDICT_UNCOMMON, + [Ci.nsIApplicationReputationService.VERDICT_POTENTIALLY_UNWANTED]: + Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED, + [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS_HOST]: + Downloads.Error.BLOCK_VERDICT_MALWARE, +}; + +//////////////////////////////////////////////////////////////////////////////// +//// DownloadIntegration + +/** + * Provides functions to integrate with the host application, handling for + * example the global prompts on shutdown. + */ +this.DownloadIntegration = { + /** + * Main DownloadStore object for loading and saving the list of persistent + * downloads, or null if the download list was never requested and thus it + * doesn't need to be persisted. + */ + _store: null, + + /** + * Returns whether data for blocked downloads should be kept on disk. + * Implementations which support unblocking downloads may return true to + * keep the blocked download on disk until its fate is decided. + * + * If a download is blocked and the partial data is kept the Download's + * 'hasBlockedData' property will be true. In this state Download.unblock() + * or Download.confirmBlock() may be used to either unblock the download or + * remove the downloaded data respectively. + * + * Even if shouldKeepBlockedData returns true, if the download did not use a + * partFile the blocked data will be removed - preventing the complete + * download from existing on disk with its final filename. + * + * @return boolean True if data should be kept. + */ + shouldKeepBlockedData() { + const FIREFOX_ID = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; + return Services.appinfo.ID == FIREFOX_ID; + }, + + /** + * Performs initialization of the list of persistent downloads, before its + * first use by the host application. This function may be called only once + * during the entire lifetime of the application. + * + * @param list + * DownloadList object to be initialized. + * + * @return {Promise} + * @resolves When the list has been initialized. + * @rejects JavaScript exception. + */ + initializePublicDownloadList: Task.async(function* (list) { + try { + yield this.loadPublicDownloadListFromStore(list); + } catch (ex) { + Cu.reportError(ex); + } + + // After the list of persistent downloads has been loaded, we can add the + // history observers, even if the load operation failed. This object is kept + // alive by the history service. + new DownloadHistoryObserver(list); + }), + + /** + * Called by initializePublicDownloadList to load the list of persistent + * downloads, before its first use by the host application. This function may + * be called only once during the entire lifetime of the application. + * + * @param list + * DownloadList object to be populated with the download objects + * serialized from the previous session. This list will be persisted + * to disk during the session lifetime. + * + * @return {Promise} + * @resolves When the list has been populated. + * @rejects JavaScript exception. + */ + loadPublicDownloadListFromStore: Task.async(function* (list) { + if (this._store) { + throw new Error("Initialization may be performed only once."); + } + + this._store = new DownloadStore(list, OS.Path.join( + OS.Constants.Path.profileDir, + "downloads.json")); + this._store.onsaveitem = this.shouldPersistDownload.bind(this); + + try { + if (this._importedFromSqlite) { + yield this._store.load(); + } else { + let sqliteDBpath = OS.Path.join(OS.Constants.Path.profileDir, + "downloads.sqlite"); + + if (yield OS.File.exists(sqliteDBpath)) { + let sqliteImport = new DownloadImport(list, sqliteDBpath); + yield sqliteImport.import(); + + let importCount = (yield list.getAll()).length; + if (importCount > 0) { + try { + yield this._store.save(); + } catch (ex) { } + } + + // No need to wait for the file removal. + OS.File.remove(sqliteDBpath).then(null, Cu.reportError); + } + + Services.prefs.setBoolPref(kPrefImportedFromSqlite, true); + + // Don't even report error here because this file is pre Firefox 3 + // and most likely doesn't exist. + OS.File.remove(OS.Path.join(OS.Constants.Path.profileDir, + "downloads.rdf")).catch(() => {}); + + } + } catch (ex) { + Cu.reportError(ex); + } + + // Add the view used for detecting changes to downloads to be persisted. + // We must do this after the list of persistent downloads has been loaded, + // even if the load operation failed. We wait for a complete initialization + // so other callers cannot modify the list without being detected. The + // DownloadAutoSaveView is kept alive by the underlying DownloadList. + yield new DownloadAutoSaveView(list, this._store).initialize(); + }), + + /** + * Determines if a Download object from the list of persistent downloads + * should be saved into a file, so that it can be restored across sessions. + * + * This function allows filtering out downloads that the host application is + * not interested in persisting across sessions, for example downloads that + * finished successfully. + * + * @param aDownload + * The Download object to be inspected. This is originally taken from + * the global DownloadList object for downloads that were not started + * from a private browsing window. The item may have been removed + * from the list since the save operation started, though in this case + * the save operation will be repeated later. + * + * @return True to save the download, false otherwise. + */ + shouldPersistDownload(aDownload) { + // On all platforms, we save all the downloads currently in progress, as + // well as stopped downloads for which we retained partially downloaded + // data or we have blocked data. + if (!aDownload.stopped || aDownload.hasPartialData || + aDownload.hasBlockedData) { + return true; + } +#ifdef MOZ_B2G + // On B2G we keep a few days of history. + let maxTime = Date.now() - + Services.prefs.getIntPref("dom.downloads.max_retention_days") * 24 * 60 * 60 * 1000; + return aDownload.startTime > maxTime; +#elif defined(MOZ_WIDGET_ANDROID) + // On Android we store all history. + return true; +#else + // On Desktop, stopped downloads for which we don't need to track the + // presence of a ".part" file are only retained in the browser history. + return false; +#endif + }, + + /** + * Returns the system downloads directory asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getSystemDownloadsDirectory: Task.async(function* () { + if (this._downloadsDirectory) { + return this._downloadsDirectory; + } + + let directoryPath = null; +#ifdef XP_MACOSX + directoryPath = this._getDirectory("DfltDwnld"); +#elifdef XP_WIN + // For XP/2K, use My Documents/Downloads. Other version uses + // the default Downloads directory. + let version = parseFloat(Services.sysinfo.getProperty("version")); + if (version < 6) { + directoryPath = yield this._createDownloadsDirectory("Pers"); + } else { + directoryPath = this._getDirectory("DfltDwnld"); + } +#elifdef XP_UNIX +#ifdef MOZ_WIDGET_ANDROID + // Android doesn't have a $HOME directory, and by default we only have + // write access to /data/data/org.mozilla.{$APP} and /sdcard + directoryPath = gEnvironment.get("DOWNLOADS_DIRECTORY"); + if (!directoryPath) { + throw new Components.Exception("DOWNLOADS_DIRECTORY is not set.", + Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH); + } +#else + // For Linux, use XDG download dir, with a fallback to Home/Downloads + // if the XDG user dirs are disabled. + try { + directoryPath = this._getDirectory("DfltDwnld"); + } catch(e) { + directoryPath = yield this._createDownloadsDirectory("Home"); + } +#endif +#else + directoryPath = yield this._createDownloadsDirectory("Home"); +#endif + + this._downloadsDirectory = directoryPath; + return this._downloadsDirectory; + }), + _downloadsDirectory: null, + + /** + * Returns the user downloads directory asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getPreferredDownloadsDirectory: Task.async(function* () { + let directoryPath = null; + let prefValue = 1; + + try { + prefValue = Services.prefs.getIntPref("browser.download.folderList"); + } catch(e) {} + + switch(prefValue) { + case 0: // Desktop + directoryPath = this._getDirectory("Desk"); + break; + case 1: // Downloads + directoryPath = yield this.getSystemDownloadsDirectory(); + break; + case 2: // Custom + try { + let directory = Services.prefs.getComplexValue("browser.download.dir", + Ci.nsIFile); + directoryPath = directory.path; + yield OS.File.makeDir(directoryPath, { ignoreExisting: true }); + } catch(ex) { + // Either the preference isn't set or the directory cannot be created. + directoryPath = yield this.getSystemDownloadsDirectory(); + } + break; + default: + directoryPath = yield this.getSystemDownloadsDirectory(); + } + return directoryPath; + }), + + /** + * Returns the temporary downloads directory asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getTemporaryDownloadsDirectory: Task.async(function* () { + let directoryPath = null; +#ifdef XP_MACOSX + directoryPath = yield this.getPreferredDownloadsDirectory(); +#elifdef MOZ_WIDGET_ANDROID + directoryPath = yield this.getSystemDownloadsDirectory(); +#elifdef MOZ_WIDGET_GONK + directoryPath = yield this.getSystemDownloadsDirectory(); +#else + directoryPath = this._getDirectory("TmpD"); +#endif + return directoryPath; + }), + + /** + * Checks to determine whether to block downloads for parental controls. + * + * aParam aDownload + * The download object. + * + * @return {Promise} + * @resolves The boolean indicates to block downloads or not. + */ + shouldBlockForParentalControls(aDownload) { + let isEnabled = gParentalControlsService && + gParentalControlsService.parentalControlsEnabled; + let shouldBlock = isEnabled && + gParentalControlsService.blockFileDownloadsEnabled; + + // Log the event if required by parental controls settings. + if (isEnabled && gParentalControlsService.loggingEnabled) { + gParentalControlsService.log(gParentalControlsService.ePCLog_FileDownload, + shouldBlock, + NetUtil.newURI(aDownload.source.url), null); + } + + return Promise.resolve(shouldBlock); + }, + + /** + * Checks to determine whether to block downloads for not granted runtime permissions. + * + * @return {Promise} + * @resolves The boolean indicates to block downloads or not. + */ + shouldBlockForRuntimePermissions() { +#ifdef MOZ_WIDGET_ANDROID + return RuntimePermissions.waitForPermissions(RuntimePermissions.WRITE_EXTERNAL_STORAGE) + .then(permissionGranted => !permissionGranted); +#else + return Promise.resolve(false); +#endif + }, + + /** + * Checks to determine whether to block downloads because they might be + * malware, based on application reputation checks. + * + * aParam aDownload + * The download object. + * + * @return {Promise} + * @resolves Object with the following properties: + * { + * shouldBlock: Whether the download should be blocked. + * verdict: Detailed reason for the block, according to the + * "Downloads.Error.BLOCK_VERDICT_" constants, or empty + * string if the reason is unknown. + * } + */ + shouldBlockForReputationCheck(aDownload) { + let hash; + let sigInfo; + let channelRedirects; + try { + hash = aDownload.saver.getSha256Hash(); + sigInfo = aDownload.saver.getSignatureInfo(); + channelRedirects = aDownload.saver.getRedirects(); + } catch (ex) { + // Bail if DownloadSaver doesn't have a hash or signature info. + return Promise.resolve({ + shouldBlock: false, + verdict: "", + }); + } + if (!hash || !sigInfo) { + return Promise.resolve({ + shouldBlock: false, + verdict: "", + }); + } + let deferred = Promise.defer(); + let aReferrer = null; + if (aDownload.source.referrer) { + aReferrer = NetUtil.newURI(aDownload.source.referrer); + } + gApplicationReputationService.queryReputation({ + sourceURI: NetUtil.newURI(aDownload.source.url), + referrerURI: aReferrer, + fileSize: aDownload.currentBytes, + sha256Hash: hash, + suggestedFileName: OS.Path.basename(aDownload.target.path), + signatureInfo: sigInfo, + redirects: channelRedirects }, + function onComplete(aShouldBlock, aRv, aVerdict) { + deferred.resolve({ + shouldBlock: aShouldBlock, + verdict: (aShouldBlock && kVerdictMap[aVerdict]) || "", + }); + }); + return deferred.promise; + }, + +#ifdef XP_WIN + /** + * Checks whether downloaded files should be marked as coming from + * Internet Zone. + * + * @return true if files should be marked + */ + _shouldSaveZoneInformation() { + let key = Cc["@mozilla.org/windows-registry-key;1"] + .createInstance(Ci.nsIWindowsRegKey); + try { + key.open(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Attachments", + Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE); + try { + return key.readIntValue("SaveZoneInformation") != 1; + } finally { + key.close(); + } + } catch (ex) { + // If the key is not present, files should be marked by default. + return true; + } + }, +#endif + + /** + * Performs platform-specific operations when a download is done. + * + * aParam aDownload + * The Download object. + * + * @return {Promise} + * @resolves When all the operations completed successfully. + * @rejects JavaScript exception if any of the operations failed. + */ + downloadDone: Task.async(function* (aDownload) { +#ifdef XP_WIN + // On Windows, we mark any file saved to the NTFS file system as coming + // from the Internet security zone unless Group Policy disables the + // feature. We do this by writing to the "Zone.Identifier" Alternate + // Data Stream directly, because the Save method of the + // IAttachmentExecute interface would trigger operations that may cause + // the application to hang, or other performance issues. + // The stream created in this way is forward-compatible with all the + // current and future versions of Windows. + if (this._shouldSaveZoneInformation()) { + let zone; + try { + zone = gDownloadPlatform.mapUrlToZone(aDownload.source.url); + } catch (e) { + // Default to Internet Zone if mapUrlToZone failed for + // whatever reason. + zone = Ci.mozIDownloadPlatform.ZONE_INTERNET; + } + try { + // Don't write zone IDs for Local, Intranet, or Trusted sites + // to match Windows behavior. + if (zone >= Ci.mozIDownloadPlatform.ZONE_INTERNET) { + let streamPath = aDownload.target.path + ":Zone.Identifier"; + let stream = yield OS.File.open( + streamPath, + { create: true }, + { winAllowLengthBeyondMaxPathWithCaveats: true } + ); + try { + yield stream.write(new TextEncoder().encode("[ZoneTransfer]\r\nZoneId=" + zone + "\r\n")); + } finally { + yield stream.close(); + } + } + } catch (ex) { + // If writing to the stream fails, we ignore the error and continue. + // The Windows API error 123 (ERROR_INVALID_NAME) is expected to + // occur when working on a file system that does not support + // Alternate Data Streams, like FAT32, thus we don't report this + // specific error. + if (!(ex instanceof OS.File.Error) || ex.winLastError != 123) { + Cu.reportError(ex); + } + } + } +#endif + + // The file with the partially downloaded data has restrictive permissions + // that don't allow other users on the system to access it. Now that the + // download is completed, we need to adjust permissions based on whether + // this is a permanently downloaded file or a temporary download to be + // opened read-only with an external application. + try { + // The following logic to determine whether this is a temporary download + // is due to the fact that "deleteTempFileOnExit" is false on Mac, where + // downloads to be opened with external applications are preserved in + // the "Downloads" folder like normal downloads. + let isTemporaryDownload = + aDownload.launchWhenSucceeded && (aDownload.source.isPrivate || + Services.prefs.getBoolPref("browser.helperApps.deleteTempFileOnExit")); + // Permanently downloaded files are made accessible by other users on + // this system, while temporary downloads are marked as read-only. + let options = {}; + if (isTemporaryDownload) { + options.unixMode = 0o400; + options.winAttributes = {readOnly: true}; + } else { + options.unixMode = 0o666; + } + // On Unix, the umask of the process is respected. + yield OS.File.setPermissions(aDownload.target.path, options); + } catch (ex) { + // We should report errors with making the permissions less restrictive + // or marking the file as read-only on Unix and Mac, but this should not + // prevent the download from completing. + // The setPermissions API error EPERM is expected to occur when working + // on a file system that does not support file permissions, like FAT32, + // thus we don't report this error. + if (!(ex instanceof OS.File.Error) || ex.unixErrno != OS.Constants.libc.EPERM) { + Cu.reportError(ex); + } + } + + let aReferrer = null; + if (aDownload.source.referrer) { + aReferrer = NetUtil.newURI(aDownload.source.referrer); + } + + gDownloadPlatform.downloadDone(NetUtil.newURI(aDownload.source.url), + aReferrer, + new FileUtils.File(aDownload.target.path), + aDownload.contentType, + aDownload.source.isPrivate); + }), + + /** + * Launches a file represented by the target of a download. This can + * open the file with the default application for the target MIME type + * or file extension, or with a custom application if + * aDownload.launcherPath is set. + * + * @param aDownload + * A Download object that contains the necessary information + * to launch the file. The relevant properties are: the target + * file, the contentType and the custom application chosen + * to launch it. + * + * @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. + */ + launchDownload: Task.async(function* (aDownload) { + let file = new FileUtils.File(aDownload.target.path); + +#ifndef XP_WIN + // Ask for confirmation if the file is executable, except on Windows where + // the operating system will show the prompt based on the security zone. + // We do this here, instead of letting the caller handle the prompt + // separately in the user interface layer, for two reasons. The first is + // because of its security nature, so that add-ons cannot forget to do + // this check. The second is that the system-level security prompt would + // be displayed at launch time in any case. + if (file.isExecutable() && + !(yield this.confirmLaunchExecutable(file.path))) { + return; + } +#endif + + // In case of a double extension, like ".tar.gz", we only + // consider the last one, because the MIME service cannot + // handle multiple extensions. + let fileExtension = null, mimeInfo = null; + let match = file.leafName.match(/\.([^.]+)$/); + if (match) { + fileExtension = match[1]; + } + + try { + // The MIME service might throw if contentType == "" and it can't find + // a MIME type for the given extension, so we'll treat this case as + // an unknown mimetype. + mimeInfo = gMIMEService.getFromTypeAndExtension(aDownload.contentType, + fileExtension); + } catch (e) { } + + if (aDownload.launcherPath) { + if (!mimeInfo) { + // This should not happen on normal circumstances because launcherPath + // is only set when we had an instance of nsIMIMEInfo to retrieve + // the custom application chosen by the user. + throw new Error( + "Unable to create nsIMIMEInfo to launch a custom application"); + } + + // Custom application chosen + let localHandlerApp = Cc["@mozilla.org/uriloader/local-handler-app;1"] + .createInstance(Ci.nsILocalHandlerApp); + localHandlerApp.executable = new FileUtils.File(aDownload.launcherPath); + + mimeInfo.preferredApplicationHandler = localHandlerApp; + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp; + + this.launchFile(file, mimeInfo); + return; + } + + // No custom application chosen, let's launch the file with the default + // handler. First, let's try to launch it through the MIME service. + if (mimeInfo) { + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useSystemDefault; + + try { + this.launchFile(file, mimeInfo); + return; + } catch (ex) { } + } + + // If it didn't work or if there was no MIME info available, + // let's try to directly launch the file. + try { + this.launchFile(file); + return; + } catch (ex) { } + + // If our previous attempts failed, try sending it through + // the system's external "file:" URL handler. + gExternalProtocolService.loadUrl(NetUtil.newURI(file)); + }), + + /** + * Asks for confirmation for launching the specified executable file. This + * can be overridden by regression tests to avoid the interactive prompt. + */ + confirmLaunchExecutable: Task.async(function* (path) { + // We don't anchor the prompt to a specific window intentionally, not + // only because this is the same behavior as the system-level prompt, + // but also because the most recently active window is the right choice + // in basically all cases. + return yield DownloadUIHelper.getPrompter().confirmLaunchExecutable(path); + }), + + /** + * Launches the specified file, unless overridden by regression tests. + */ + launchFile(file, mimeInfo) { + if (mimeInfo) { + mimeInfo.launchWithFile(file); + } else { + file.launch(); + } + }, + + /** + * Shows the containing folder of a file. + * + * @param aFilePath + * The path to the file. + * + * @return {Promise} + * @resolves When the instruction to open the containing folder has been + * successfully given to the operating system. Note that + * the OS might still take a while until the folder is actually + * opened. + * @rejects JavaScript exception if there was an error trying to open + * the containing folder. + */ + showContainingDirectory: Task.async(function* (aFilePath) { + let file = new FileUtils.File(aFilePath); + + try { + // Show the directory containing the file and select the file. + file.reveal(); + return; + } catch (ex) { } + + // If reveal fails for some reason (e.g., it's not implemented on unix + // or the file doesn't exist), try using the parent if we have it. + let parent = file.parent; + if (!parent) { + throw new Error( + "Unexpected reference to a top-level directory instead of a file"); + } + + try { + // Open the parent directory to show where the file should be. + parent.launch(); + return; + } catch (ex) { } + + // If launch also fails (probably because it's not implemented), let + // the OS handler try to open the parent. + gExternalProtocolService.loadUrl(NetUtil.newURI(parent)); + }), + + /** + * Calls the directory service, create a downloads directory and returns an + * nsIFile for the downloads directory. + * + * @return {Promise} + * @resolves The directory string path. + */ + _createDownloadsDirectory(aName) { + // We read the name of the directory from the list of translated strings + // that is kept by the UI helper module, even if this string is not strictly + // displayed in the user interface. + let directoryPath = OS.Path.join(this._getDirectory(aName), + DownloadUIHelper.strings.downloadsFolder); + + // Create the Downloads folder and ignore if it already exists. + return OS.File.makeDir(directoryPath, { ignoreExisting: true }) + .then(() => directoryPath); + }, + + /** + * Returns the string path for the given directory service location name. This + * can be overridden by regression tests to return the path of the system + * temporary directory in all cases. + */ + _getDirectory(name) { + return Services.dirsvc.get(name, Ci.nsIFile).path; + }, + + /** + * Register the downloads interruption observers. + * + * @param aList + * The public or private downloads list. + * @param aIsPrivate + * True if the list is private, false otherwise. + * + * @return {Promise} + * @resolves When the views and observers are added. + */ + addListObservers(aList, aIsPrivate) { + DownloadObserver.registerView(aList, aIsPrivate); + if (!DownloadObserver.observersAdded) { + DownloadObserver.observersAdded = true; + for (let topic of kObserverTopics) { + Services.obs.addObserver(DownloadObserver, topic, false); + } + } + return Promise.resolve(); + }, + + /** + * Force a save on _store if it exists. Used to ensure downloads do not + * persist after being sanitized on Android. + * + * @return {Promise} + * @resolves When _store.save() completes. + */ + forceSave() { + if (this._store) { + return this._store.save(); + } + return Promise.resolve(); + }, + + /** + * Checks if we have already imported (or attempted to import) + * the downloads database from the previous SQLite storage. + * + * @return boolean True if we the previous DB was imported. + */ + get _importedFromSqlite() { + try { + return Services.prefs.getBoolPref(kPrefImportedFromSqlite); + } catch (ex) { + return false; + } + }, +}; + +//////////////////////////////////////////////////////////////////////////////// +//// DownloadObserver + +this.DownloadObserver = { + /** + * Flag to determine if the observers have been added previously. + */ + observersAdded: false, + + /** + * Timer used to delay restarting canceled downloads upon waking and returning + * online. + */ + _wakeTimer: null, + + /** + * Set that contains the in progress publics downloads. + * It's kept updated when a public download is added, removed or changes its + * properties. + */ + _publicInProgressDownloads: new Set(), + + /** + * Set that contains the in progress private downloads. + * It's kept updated when a private download is added, removed or changes its + * properties. + */ + _privateInProgressDownloads: new Set(), + + /** + * Set that contains the downloads that have been canceled when going offline + * or to sleep. These are started again when returning online or waking. This + * list is not persisted so when exiting and restarting, the downloads will not + * be started again. + */ + _canceledOfflineDownloads: new Set(), + + /** + * Registers a view that updates the corresponding downloads state set, based + * on the aIsPrivate argument. The set is updated when a download is added, + * removed or changes its properties. + * + * @param aList + * The public or private downloads list. + * @param aIsPrivate + * True if the list is private, false otherwise. + */ + registerView: function DO_registerView(aList, aIsPrivate) { + let downloadsSet = aIsPrivate ? this._privateInProgressDownloads + : this._publicInProgressDownloads; + let downloadsView = { + onDownloadAdded: aDownload => { + if (!aDownload.stopped) { + downloadsSet.add(aDownload); + } + }, + onDownloadChanged: aDownload => { + if (aDownload.stopped) { + downloadsSet.delete(aDownload); + } else { + downloadsSet.add(aDownload); + } + }, + onDownloadRemoved: aDownload => { + downloadsSet.delete(aDownload); + // The download must also be removed from the canceled when offline set. + this._canceledOfflineDownloads.delete(aDownload); + } + }; + + // We register the view asynchronously. + aList.addView(downloadsView).then(null, Cu.reportError); + }, + + /** + * Wrapper that handles the test mode before calling the prompt that display + * a warning message box that informs that there are active downloads, + * and asks whether the user wants to cancel them or not. + * + * @param aCancel + * The observer notification subject. + * @param aDownloadsCount + * The current downloads count. + * @param aPrompter + * The prompter object that shows the confirm dialog. + * @param aPromptType + * The type of prompt notification depending on the observer. + */ + _confirmCancelDownloads: function DO_confirmCancelDownload( + aCancel, aDownloadsCount, aPrompter, aPromptType) { + // If user has already dismissed the request, then do nothing. + if ((aCancel instanceof Ci.nsISupportsPRBool) && aCancel.data) { + return; + } + // Handle test mode + if (gCombinedDownloadIntegration._testPromptDownloads) { + gCombinedDownloadIntegration._testPromptDownloads = aDownloadsCount; + return; + } + + aCancel.data = aPrompter.confirmCancelDownloads(aDownloadsCount, aPromptType); + }, + + /** + * Resume all downloads that were paused when going offline, used when waking + * from sleep or returning from being offline. + */ + _resumeOfflineDownloads: function DO_resumeOfflineDownloads() { + this._wakeTimer = null; + + for (let download of this._canceledOfflineDownloads) { + download.start().catch(() => {}); + } + }, + + //////////////////////////////////////////////////////////////////////////// + //// nsIObserver + + observe: function DO_observe(aSubject, aTopic, aData) { + let downloadsCount; + let p = DownloadUIHelper.getPrompter(); + switch (aTopic) { + case "quit-application-requested": + downloadsCount = this._publicInProgressDownloads.size + + this._privateInProgressDownloads.size; + this._confirmCancelDownloads(aSubject, downloadsCount, p, p.ON_QUIT); + break; + case "offline-requested": + downloadsCount = this._publicInProgressDownloads.size + + this._privateInProgressDownloads.size; + this._confirmCancelDownloads(aSubject, downloadsCount, p, p.ON_OFFLINE); + break; + case "last-pb-context-exiting": + downloadsCount = this._privateInProgressDownloads.size; + this._confirmCancelDownloads(aSubject, downloadsCount, p, + p.ON_LEAVE_PRIVATE_BROWSING); + break; + case "last-pb-context-exited": + let promise = Task.spawn(function() { + let list = yield Downloads.getList(Downloads.PRIVATE); + let downloads = yield list.getAll(); + + // We can remove the downloads and finalize them in parallel. + for (let download of downloads) { + list.remove(download).then(null, Cu.reportError); + download.finalize(true).then(null, Cu.reportError); + } + }); + // Handle test mode + if (gCombinedDownloadIntegration._testResolveClearPrivateList) { + gCombinedDownloadIntegration._testResolveClearPrivateList(promise); + } else { + promise.catch(ex => Cu.reportError(ex)); + } + break; + case "sleep_notification": + case "suspend_process_notification": + case "network:offline-about-to-go-offline": + for (let download of this._publicInProgressDownloads) { + download.cancel(); + this._canceledOfflineDownloads.add(download); + } + for (let download of this._privateInProgressDownloads) { + download.cancel(); + this._canceledOfflineDownloads.add(download); + } + break; + case "wake_notification": + case "resume_process_notification": + let wakeDelay = 10000; + try { + wakeDelay = Services.prefs.getIntPref("browser.download.manager.resumeOnWakeDelay"); + } catch(e) {} + + if (wakeDelay >= 0) { + this._wakeTimer = new Timer(this._resumeOfflineDownloads.bind(this), wakeDelay, + Ci.nsITimer.TYPE_ONE_SHOT); + } + break; + case "network:offline-status-changed": + if (aData == "online") { + this._resumeOfflineDownloads(); + } + break; + // We need to unregister observers explicitly before we reach the + // "xpcom-shutdown" phase, otherwise observers may be notified when some + // required services are not available anymore. We can't unregister + // observers on "quit-application", because this module is also loaded + // during "make package" automation, and the quit notification is not sent + // in that execution environment (bug 973637). + case "xpcom-will-shutdown": + for (let topic of kObserverTopics) { + Services.obs.removeObserver(this, topic); + } + break; + } + }, + + //////////////////////////////////////////////////////////////////////////// + //// nsISupports + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]) +}; + +//////////////////////////////////////////////////////////////////////////////// +//// DownloadHistoryObserver + +#ifdef MOZ_PLACES +/** + * Registers a Places observer so that operations on download history are + * reflected on the provided list of downloads. + * + * You do not need to keep a reference to this object in order to keep it alive, + * because the history service already keeps a strong reference to it. + * + * @param aList + * DownloadList object linked to this observer. + */ +this.DownloadHistoryObserver = function (aList) +{ + this._list = aList; + PlacesUtils.history.addObserver(this, false); +} + +this.DownloadHistoryObserver.prototype = { + /** + * DownloadList object linked to this observer. + */ + _list: null, + + //////////////////////////////////////////////////////////////////////////// + //// nsISupports + + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver]), + + //////////////////////////////////////////////////////////////////////////// + //// nsINavHistoryObserver + + onDeleteURI: function DL_onDeleteURI(aURI, aGUID) { + this._list.removeFinished(download => aURI.equals(NetUtil.newURI( + download.source.url))); + }, + + onClearHistory: function DL_onClearHistory() { + this._list.removeFinished(); + }, + + onTitleChanged: function () {}, + onBeginUpdateBatch: function () {}, + onEndUpdateBatch: function () {}, + onVisit: function () {}, + onPageChanged: function () {}, + onDeleteVisits: function () {}, +}; +#else +/** + * Empty implementation when we have no Places support, for example on B2G. + */ +this.DownloadHistoryObserver = function (aList) {} +#endif + +//////////////////////////////////////////////////////////////////////////////// +//// DownloadAutoSaveView + +/** + * This view can be added to a DownloadList object to trigger a save operation + * in the given DownloadStore object when a relevant change occurs. You should + * call the "initialize" method in order to register the view and load the + * current state from disk. + * + * You do not need to keep a reference to this object in order to keep it alive, + * because the DownloadList object already keeps a strong reference to it. + * + * @param aList + * The DownloadList object on which the view should be registered. + * @param aStore + * The DownloadStore object used for saving. + */ +this.DownloadAutoSaveView = function (aList, aStore) +{ + this._list = aList; + this._store = aStore; + this._downloadsMap = new Map(); + this._writer = new DeferredTask(() => this._store.save(), kSaveDelayMs); + AsyncShutdown.profileBeforeChange.addBlocker("DownloadAutoSaveView: writing data", + () => this._writer.finalize()); +} + +this.DownloadAutoSaveView.prototype = { + /** + * DownloadList object linked to this view. + */ + _list: null, + + /** + * The DownloadStore object used for saving. + */ + _store: null, + + /** + * True when the initial state of the downloads has been loaded. + */ + _initialized: false, + + /** + * Registers the view and loads the current state from disk. + * + * @return {Promise} + * @resolves When the view has been registered. + * @rejects JavaScript exception. + */ + initialize: function () + { + // We set _initialized to true after adding the view, so that + // onDownloadAdded doesn't cause a save to occur. + return this._list.addView(this).then(() => this._initialized = true); + }, + + /** + * This map contains only Download objects that should be saved to disk, and + * associates them with the result of their getSerializationHash function, for + * the purpose of detecting changes to the relevant properties. + */ + _downloadsMap: null, + + /** + * DeferredTask for the save operation. + */ + _writer: null, + + /** + * Called when the list of downloads changed, this triggers the asynchronous + * serialization of the list of downloads. + */ + saveSoon: function () + { + this._writer.arm(); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// DownloadList view + + onDownloadAdded: function (aDownload) + { + if (gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) { + this._downloadsMap.set(aDownload, aDownload.getSerializationHash()); + if (this._initialized) { + this.saveSoon(); + } + } + }, + + onDownloadChanged: function (aDownload) + { + if (!gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) { + if (this._downloadsMap.has(aDownload)) { + this._downloadsMap.delete(aDownload); + this.saveSoon(); + } + return; + } + + let hash = aDownload.getSerializationHash(); + if (this._downloadsMap.get(aDownload) != hash) { + this._downloadsMap.set(aDownload, hash); + this.saveSoon(); + } + }, + + onDownloadRemoved: function (aDownload) + { + if (this._downloadsMap.has(aDownload)) { + this._downloadsMap.delete(aDownload); + this.saveSoon(); + } + }, +}; diff --git a/toolkit/components/jsdownloads/src/DownloadLegacy.js b/toolkit/components/jsdownloads/src/DownloadLegacy.js new file mode 100644 index 000000000..fc9fb35d2 --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadLegacy.js @@ -0,0 +1,309 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This component implements the XPCOM interfaces required for integration with + * the legacy download components. + * + * New code is expected to use the "Downloads.jsm" module directly, without + * going through the interfaces implemented in this XPCOM component. These + * interfaces are only maintained for backwards compatibility with components + * that still work synchronously on the main thread. + */ + +"use strict"; + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); + +// DownloadLegacyTransfer + +/** + * nsITransfer implementation that provides a bridge to a Download object. + * + * Legacy downloads work differently than the JavaScript implementation. In the + * latter, the caller only provides the properties for the Download object and + * the entire process is handled by the "start" method. In the legacy + * implementation, the caller must create a separate object to execute the + * download, and then make the download visible to the user by hooking it up to + * an nsITransfer instance. + * + * Since nsITransfer instances may be created before the download system is + * initialized, and initialization as well as other operations are asynchronous, + * this implementation is able to delay all progress and status notifications it + * receives until the associated Download object is finally created. + * + * Conversely, the DownloadLegacySaver object can also receive execution and + * cancellation requests asynchronously, before or after it is connected to + * this nsITransfer instance. For that reason, those requests are communicated + * in a potentially deferred way, using promise objects. + * + * The component that executes the download implements nsICancelable to receive + * cancellation requests, but after cancellation it cannot be reused again. + * + * Since the components that execute the download may be different and they + * don't always give consistent results, this bridge takes care of enforcing the + * expectations, for example by ensuring the target file exists when the + * download is successful, even if the source has a size of zero bytes. + */ +function DownloadLegacyTransfer() +{ + this._deferDownload = Promise.defer(); +} + +DownloadLegacyTransfer.prototype = { + classID: Components.ID("{1b4c85df-cbdd-4bb6-b04e-613caece083c}"), + + // nsISupports + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsITransfer]), + + // nsIWebProgressListener + + onStateChange: function DLT_onStateChange(aWebProgress, aRequest, aStateFlags, + aStatus) + { + if (!Components.isSuccessCode(aStatus)) { + this._componentFailed = true; + } + + if ((aStateFlags & Ci.nsIWebProgressListener.STATE_START) && + (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) { + + let blockedByParentalControls = false; + // If it is a failed download, aRequest.responseStatus doesn't exist. + // (missing file on the server, network failure to download) + try { + // If the request's response has been blocked by Windows Parental Controls + // with an HTTP 450 error code, we must cancel the request synchronously. + blockedByParentalControls = aRequest instanceof Ci.nsIHttpChannel && + aRequest.responseStatus == 450; + } catch (e) { + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + } + } + + if (blockedByParentalControls) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + } + + // The main request has just started. Wait for the associated Download + // object to be available before notifying. + this._deferDownload.promise.then(download => { + // If the request was blocked, now that we have the download object we + // should set a flag that can be retrieved later when handling the + // cancellation so that the proper error can be thrown. + if (blockedByParentalControls) { + download._blockedByParentalControls = true; + } + + download.saver.onTransferStarted( + aRequest, + this._cancelable instanceof Ci.nsIHelperAppLauncher); + + // To handle asynchronous cancellation properly, we should hook up the + // handler only after we have been notified that the main request + // started. We will wait until the main request stopped before + // notifying that the download has been canceled. Since the request has + // not completed yet, deferCanceled is guaranteed to be set. + return download.saver.deferCanceled.promise.then(() => { + // Only cancel if the object executing the download is still running. + if (this._cancelable && !this._componentFailed) { + this._cancelable.cancel(Cr.NS_ERROR_ABORT); + if (this._cancelable instanceof Ci.nsIWebBrowserPersist) { + // This component will not send the STATE_STOP notification. + download.saver.onTransferFinished(aRequest, Cr.NS_ERROR_ABORT); + this._cancelable = null; + } + } + }); + }).then(null, Cu.reportError); + } else if ((aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) && + (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) { + // The last file has been received, or the download failed. Wait for the + // associated Download object to be available before notifying. + this._deferDownload.promise.then(download => { + // At this point, the hash has been set and we need to copy it to the + // DownloadSaver. + if (Components.isSuccessCode(aStatus)) { + download.saver.setSha256Hash(this._sha256Hash); + download.saver.setSignatureInfo(this._signatureInfo); + download.saver.setRedirects(this._redirects); + } + download.saver.onTransferFinished(aRequest, aStatus); + }).then(null, Cu.reportError); + + // Release the reference to the component executing the download. + this._cancelable = null; + } + }, + + onProgressChange: function DLT_onProgressChange(aWebProgress, aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress) + { + this.onProgressChange64(aWebProgress, aRequest, aCurSelfProgress, + aMaxSelfProgress, aCurTotalProgress, + aMaxTotalProgress); + }, + + onLocationChange: function () { }, + + onStatusChange: function DLT_onStatusChange(aWebProgress, aRequest, aStatus, + aMessage) + { + // The status change may optionally be received in addition to the state + // change, but if no network request actually started, it is possible that + // we only receive a status change with an error status code. + if (!Components.isSuccessCode(aStatus)) { + this._componentFailed = true; + + // Wait for the associated Download object to be available. + this._deferDownload.promise.then(function DLT_OSC_onDownload(aDownload) { + aDownload.saver.onTransferFinished(aRequest, aStatus); + }).then(null, Cu.reportError); + } + }, + + onSecurityChange: function () { }, + + // nsIWebProgressListener2 + + onProgressChange64: function DLT_onProgressChange64(aWebProgress, aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress) + { + // Wait for the associated Download object to be available. + this._deferDownload.promise.then(function DLT_OPC64_onDownload(aDownload) { + aDownload.saver.onProgressBytes(aCurTotalProgress, aMaxTotalProgress); + }).then(null, Cu.reportError); + }, + + onRefreshAttempted: function DLT_onRefreshAttempted(aWebProgress, aRefreshURI, + aMillis, aSameURI) + { + // Indicate that refreshes and redirects are allowed by default. However, + // note that download components don't usually call this method at all. + return true; + }, + + // nsITransfer + + init: function DLT_init(aSource, aTarget, aDisplayName, aMIMEInfo, aStartTime, + aTempFile, aCancelable, aIsPrivate) + { + this._cancelable = aCancelable; + + let launchWhenSucceeded = false, contentType = null, launcherPath = null; + + if (aMIMEInfo instanceof Ci.nsIMIMEInfo) { + launchWhenSucceeded = + aMIMEInfo.preferredAction != Ci.nsIMIMEInfo.saveToDisk; + contentType = aMIMEInfo.type; + + let appHandler = aMIMEInfo.preferredApplicationHandler; + if (aMIMEInfo.preferredAction == Ci.nsIMIMEInfo.useHelperApp && + appHandler instanceof Ci.nsILocalHandlerApp) { + launcherPath = appHandler.executable.path; + } + } + + // Create a new Download object associated to a DownloadLegacySaver, and + // wait for it to be available. This operation may cause the entire + // download system to initialize before the object is created. + Downloads.createDownload({ + source: { url: aSource.spec, isPrivate: aIsPrivate }, + target: { path: aTarget.QueryInterface(Ci.nsIFileURL).file.path, + partFilePath: aTempFile && aTempFile.path }, + saver: "legacy", + launchWhenSucceeded: launchWhenSucceeded, + contentType: contentType, + launcherPath: launcherPath + }).then(function DLT_I_onDownload(aDownload) { + // Legacy components keep partial data when they use a ".part" file. + if (aTempFile) { + aDownload.tryToKeepPartialData = true; + } + + // Start the download before allowing it to be controlled. Ignore errors. + aDownload.start().catch(() => {}); + + // Start processing all the other events received through nsITransfer. + this._deferDownload.resolve(aDownload); + + // Add the download to the list, allowing it to be seen and canceled. + return Downloads.getList(Downloads.ALL).then(list => list.add(aDownload)); + }.bind(this)).then(null, Cu.reportError); + }, + + setSha256Hash: function (hash) + { + this._sha256Hash = hash; + }, + + setSignatureInfo: function (signatureInfo) + { + this._signatureInfo = signatureInfo; + }, + + setRedirects: function (redirects) + { + this._redirects = redirects; + }, + + // Private methods and properties + + /** + * This deferred object contains a promise that is resolved with the Download + * object associated with this nsITransfer instance, when it is available. + */ + _deferDownload: null, + + /** + * Reference to the component that is executing the download. This component + * allows cancellation through its nsICancelable interface. + */ + _cancelable: null, + + /** + * Indicates that the component that executes the download has notified a + * failure condition. In this case, we should never use the component methods + * that cancel the download. + */ + _componentFailed: false, + + /** + * Save the SHA-256 hash in raw bytes of the downloaded file. + */ + _sha256Hash: null, + + /** + * Save the signature info in a serialized protobuf of the downloaded file. + */ + _signatureInfo: null, +}; + +// Module + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DownloadLegacyTransfer]); diff --git a/toolkit/components/jsdownloads/src/DownloadList.jsm b/toolkit/components/jsdownloads/src/DownloadList.jsm new file mode 100644 index 000000000..f725bd3de --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadList.jsm @@ -0,0 +1,559 @@ +/* -*- 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: + * + * DownloadList + * Represents a collection of Download objects that can be viewed and managed by + * the user interface, and persisted across sessions. + * + * DownloadCombinedList + * Provides a unified, unordered list combining public and private downloads. + * + * DownloadSummary + * Provides an aggregated view on the contents of a DownloadList. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "DownloadList", + "DownloadCombinedList", + "DownloadSummary", +]; + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +// DownloadList + +/** + * Represents a collection of Download objects that can be viewed and managed by + * the user interface, and persisted across sessions. + */ +this.DownloadList = function () +{ + this._downloads = []; + this._views = new Set(); +} + +this.DownloadList.prototype = { + /** + * Array of Download objects currently in the list. + */ + _downloads: null, + + /** + * Retrieves a snapshot of the downloads that are currently in the list. The + * returned array does not change when downloads are added or removed, though + * the Download objects it contains are still updated in real time. + * + * @return {Promise} + * @resolves An array of Download objects. + * @rejects JavaScript exception. + */ + getAll: function DL_getAll() { + return Promise.resolve(Array.slice(this._downloads, 0)); + }, + + /** + * Adds a new download to the end of the items list. + * + * @note When a download is added to the list, its "onchange" event is + * registered by the list, thus it cannot be used to monitor the + * download. To receive change notifications for downloads that are + * added to the list, use the addView method to register for + * onDownloadChanged notifications. + * + * @param aDownload + * The Download object to add. + * + * @return {Promise} + * @resolves When the download has been added. + * @rejects JavaScript exception. + */ + add: function DL_add(aDownload) { + this._downloads.push(aDownload); + aDownload.onchange = this._change.bind(this, aDownload); + this._notifyAllViews("onDownloadAdded", aDownload); + + return Promise.resolve(); + }, + + /** + * Removes a download from the list. If the download was already removed, + * this method has no effect. + * + * This method does not change the state of the download, to allow adding it + * to another list, or control it directly. If you want to dispose of the + * download object, you should cancel it afterwards, and remove any partially + * downloaded data if needed. + * + * @param aDownload + * The Download object to remove. + * + * @return {Promise} + * @resolves When the download has been removed. + * @rejects JavaScript exception. + */ + remove: function DL_remove(aDownload) { + let index = this._downloads.indexOf(aDownload); + if (index != -1) { + this._downloads.splice(index, 1); + aDownload.onchange = null; + this._notifyAllViews("onDownloadRemoved", aDownload); + } + + return Promise.resolve(); + }, + + /** + * This function is called when "onchange" events of downloads occur. + * + * @param aDownload + * The Download object that changed. + */ + _change: function DL_change(aDownload) { + this._notifyAllViews("onDownloadChanged", aDownload); + }, + + /** + * Set of currently registered views. + */ + _views: null, + + /** + * Adds a view that will be notified of changes to downloads. The newly added + * view will receive onDownloadAdded notifications for all the downloads that + * are already in the list. + * + * @param aView + * The view object to add. The following methods may be defined: + * { + * onDownloadAdded: function (aDownload) { + * // Called after aDownload is added to the end of the list. + * }, + * onDownloadChanged: function (aDownload) { + * // Called after the properties of aDownload change. + * }, + * onDownloadRemoved: function (aDownload) { + * // Called after aDownload is removed from the list. + * }, + * } + * + * @return {Promise} + * @resolves When the view has been registered and all the onDownloadAdded + * notifications for the existing downloads have been sent. + * @rejects JavaScript exception. + */ + addView: function DL_addView(aView) + { + this._views.add(aView); + + if ("onDownloadAdded" in aView) { + for (let download of this._downloads) { + try { + aView.onDownloadAdded(download); + } catch (ex) { + Cu.reportError(ex); + } + } + } + + return Promise.resolve(); + }, + + /** + * Removes a view that was previously added using addView. + * + * @param aView + * The view object to remove. + * + * @return {Promise} + * @resolves When the view has been removed. At this point, the removed view + * will not receive any more notifications. + * @rejects JavaScript exception. + */ + removeView: function DL_removeView(aView) + { + this._views.delete(aView); + + return Promise.resolve(); + }, + + /** + * Notifies all the views of a download addition, change, or removal. + * + * @param aMethodName + * String containing the name of the method to call on the view. + * @param aDownload + * The Download object that changed. + */ + _notifyAllViews: function (aMethodName, aDownload) { + for (let view of this._views) { + try { + if (aMethodName in view) { + view[aMethodName](aDownload); + } + } catch (ex) { + Cu.reportError(ex); + } + } + }, + + /** + * Removes downloads from the list that have finished, have failed, or have + * been canceled without keeping partial data. A filter function may be + * specified to remove only a subset of those downloads. + * + * This method finalizes each removed download, ensuring that any partially + * downloaded data associated with it is also removed. + * + * @param aFilterFn + * The filter function is called with each download as its only + * argument, and should return true to remove the download and false + * to keep it. This parameter may be null or omitted to have no + * additional filter. + */ + removeFinished: function DL_removeFinished(aFilterFn) { + Task.spawn(function* () { + let list = yield this.getAll(); + for (let download of list) { + // Remove downloads that have been canceled, even if the cancellation + // operation hasn't completed yet so we don't check "stopped" here. + // Failed downloads with partial data are also removed. + if (download.stopped && (!download.hasPartialData || download.error) && + (!aFilterFn || aFilterFn(download))) { + // Remove the download first, so that the views don't get the change + // notifications that may occur during finalization. + yield this.remove(download); + // Ensure that the download is stopped and no partial data is kept. + // This works even if the download state has changed meanwhile. We + // don't need to wait for the procedure to be complete before + // processing the other downloads in the list. + download.finalize(true).then(null, Cu.reportError); + } + } + }.bind(this)).then(null, Cu.reportError); + }, +}; + +// DownloadCombinedList + +/** + * Provides a unified, unordered list combining public and private downloads. + * + * Download objects added to this list are also added to one of the two + * underlying lists, based on their "source.isPrivate" property. Views on this + * list will receive notifications for both public and private downloads. + * + * @param aPublicList + * Underlying DownloadList containing public downloads. + * @param aPrivateList + * Underlying DownloadList containing private downloads. + */ +this.DownloadCombinedList = function (aPublicList, aPrivateList) +{ + DownloadList.call(this); + this._publicList = aPublicList; + this._privateList = aPrivateList; + aPublicList.addView(this).then(null, Cu.reportError); + aPrivateList.addView(this).then(null, Cu.reportError); +} + +this.DownloadCombinedList.prototype = { + __proto__: DownloadList.prototype, + + /** + * Underlying DownloadList containing public downloads. + */ + _publicList: null, + + /** + * Underlying DownloadList containing private downloads. + */ + _privateList: null, + + /** + * Adds a new download to the end of the items list. + * + * @note When a download is added to the list, its "onchange" event is + * registered by the list, thus it cannot be used to monitor the + * download. To receive change notifications for downloads that are + * added to the list, use the addView method to register for + * onDownloadChanged notifications. + * + * @param aDownload + * The Download object to add. + * + * @return {Promise} + * @resolves When the download has been added. + * @rejects JavaScript exception. + */ + add: function (aDownload) + { + if (aDownload.source.isPrivate) { + return this._privateList.add(aDownload); + } + return this._publicList.add(aDownload); + }, + + /** + * Removes a download from the list. If the download was already removed, + * this method has no effect. + * + * This method does not change the state of the download, to allow adding it + * to another list, or control it directly. If you want to dispose of the + * download object, you should cancel it afterwards, and remove any partially + * downloaded data if needed. + * + * @param aDownload + * The Download object to remove. + * + * @return {Promise} + * @resolves When the download has been removed. + * @rejects JavaScript exception. + */ + remove: function (aDownload) + { + if (aDownload.source.isPrivate) { + return this._privateList.remove(aDownload); + } + return this._publicList.remove(aDownload); + }, + + // DownloadList view + + onDownloadAdded: function (aDownload) + { + this._downloads.push(aDownload); + this._notifyAllViews("onDownloadAdded", aDownload); + }, + + onDownloadChanged: function (aDownload) + { + this._notifyAllViews("onDownloadChanged", aDownload); + }, + + onDownloadRemoved: function (aDownload) + { + let index = this._downloads.indexOf(aDownload); + if (index != -1) { + this._downloads.splice(index, 1); + } + this._notifyAllViews("onDownloadRemoved", aDownload); + }, +}; + +// DownloadSummary + +/** + * Provides an aggregated view on the contents of a DownloadList. + */ +this.DownloadSummary = function () +{ + this._downloads = []; + this._views = new Set(); +} + +this.DownloadSummary.prototype = { + /** + * Array of Download objects that are currently part of the summary. + */ + _downloads: null, + + /** + * Underlying DownloadList whose contents should be summarized. + */ + _list: null, + + /** + * This method may be called once to bind this object to a DownloadList. + * + * Views on the summarized data can be registered before this object is bound + * to an actual list. This allows the summary to be used without requiring + * the initialization of the DownloadList first. + * + * @param aList + * Underlying DownloadList whose contents should be summarized. + * + * @return {Promise} + * @resolves When the view on the underlying list has been registered. + * @rejects JavaScript exception. + */ + bindToList: function (aList) + { + if (this._list) { + throw new Error("bindToList may be called only once."); + } + + return aList.addView(this).then(() => { + // Set the list reference only after addView has returned, so that we don't + // send a notification to our views for each download that is added. + this._list = aList; + this._onListChanged(); + }); + }, + + /** + * Set of currently registered views. + */ + _views: null, + + /** + * Adds a view that will be notified of changes to the summary. The newly + * added view will receive an initial onSummaryChanged notification. + * + * @param aView + * The view object to add. The following methods may be defined: + * { + * onSummaryChanged: function () { + * // Called after any property of the summary has changed. + * }, + * } + * + * @return {Promise} + * @resolves When the view has been registered and the onSummaryChanged + * notification has been sent. + * @rejects JavaScript exception. + */ + addView: function (aView) + { + this._views.add(aView); + + if ("onSummaryChanged" in aView) { + try { + aView.onSummaryChanged(); + } catch (ex) { + Cu.reportError(ex); + } + } + + return Promise.resolve(); + }, + + /** + * Removes a view that was previously added using addView. + * + * @param aView + * The view object to remove. + * + * @return {Promise} + * @resolves When the view has been removed. At this point, the removed view + * will not receive any more notifications. + * @rejects JavaScript exception. + */ + removeView: function (aView) + { + this._views.delete(aView); + + return Promise.resolve(); + }, + + /** + * Indicates whether all the downloads are currently stopped. + */ + allHaveStopped: true, + + /** + * Indicates the total number of bytes to be transferred before completing all + * the downloads that are currently in progress. + * + * For downloads that do not have a known final size, the number of bytes + * currently transferred is reported as part of this property. + * + * This is zero if no downloads are currently in progress. + */ + progressTotalBytes: 0, + + /** + * Number of bytes currently transferred as part of all the downloads that are + * currently in progress. + * + * This is zero if no downloads are currently in progress. + */ + progressCurrentBytes: 0, + + /** + * This function is called when any change in the list of downloads occurs, + * and will recalculate the summary and notify the views in case the + * aggregated properties are different. + */ + _onListChanged: function () { + let allHaveStopped = true; + let progressTotalBytes = 0; + let progressCurrentBytes = 0; + + // Recalculate the aggregated state. See the description of the individual + // properties for an explanation of the summarization logic. + for (let download of this._downloads) { + if (!download.stopped) { + allHaveStopped = false; + progressTotalBytes += download.hasProgress ? download.totalBytes + : download.currentBytes; + progressCurrentBytes += download.currentBytes; + } + } + + // Exit now if the properties did not change. + if (this.allHaveStopped == allHaveStopped && + this.progressTotalBytes == progressTotalBytes && + this.progressCurrentBytes == progressCurrentBytes) { + return; + } + + this.allHaveStopped = allHaveStopped; + this.progressTotalBytes = progressTotalBytes; + this.progressCurrentBytes = progressCurrentBytes; + + // Notify all the views that our properties changed. + for (let view of this._views) { + try { + if ("onSummaryChanged" in view) { + view.onSummaryChanged(); + } + } catch (ex) { + Cu.reportError(ex); + } + } + }, + + // DownloadList view + + onDownloadAdded: function (aDownload) + { + this._downloads.push(aDownload); + if (this._list) { + this._onListChanged(); + } + }, + + onDownloadChanged: function (aDownload) + { + this._onListChanged(); + }, + + onDownloadRemoved: function (aDownload) + { + let index = this._downloads.indexOf(aDownload); + if (index != -1) { + this._downloads.splice(index, 1); + } + this._onListChanged(); + }, +}; diff --git a/toolkit/components/jsdownloads/src/DownloadPlatform.cpp b/toolkit/components/jsdownloads/src/DownloadPlatform.cpp new file mode 100644 index 000000000..1506b7c30 --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadPlatform.cpp @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "DownloadPlatform.h" +#include "nsAutoPtr.h" +#include "nsNetUtil.h" +#include "nsString.h" +#include "nsINestedURI.h" +#include "nsIProtocolHandler.h" +#include "nsIURI.h" +#include "nsIFile.h" +#include "nsIObserverService.h" +#include "nsISupportsPrimitives.h" +#include "nsDirectoryServiceDefs.h" + +#include "mozilla/Preferences.h" +#include "mozilla/Services.h" + +#define PREF_BDM_ADDTORECENTDOCS "browser.download.manager.addToRecentDocs" + +#ifdef XP_WIN +#include <shlobj.h> +#include <urlmon.h> +#include "nsILocalFileWin.h" +#endif + +#ifdef XP_MACOSX +#include <CoreFoundation/CoreFoundation.h> +#include "../../../../xpcom/io/CocoaFileUtils.h" +#endif + +#ifdef MOZ_WIDGET_ANDROID +#include "FennecJNIWrappers.h" +#endif + +#ifdef MOZ_WIDGET_GTK +#include <gtk/gtk.h> +#endif + +using namespace mozilla; + +DownloadPlatform *DownloadPlatform::gDownloadPlatformService = nullptr; + +NS_IMPL_ISUPPORTS(DownloadPlatform, mozIDownloadPlatform); + +DownloadPlatform* DownloadPlatform::GetDownloadPlatform() +{ + if (!gDownloadPlatformService) { + gDownloadPlatformService = new DownloadPlatform(); + } + + NS_ADDREF(gDownloadPlatformService); + +#if defined(MOZ_WIDGET_GTK) + g_type_init(); +#endif + + return gDownloadPlatformService; +} + +#ifdef MOZ_ENABLE_GIO +static void gio_set_metadata_done(GObject *source_obj, GAsyncResult *res, gpointer user_data) +{ + GError *err = nullptr; + g_file_set_attributes_finish(G_FILE(source_obj), res, nullptr, &err); + if (err) { +#ifdef DEBUG + NS_DebugBreak(NS_DEBUG_WARNING, "Set file metadata failed: ", err->message, __FILE__, __LINE__); +#endif + g_error_free(err); + } +} +#endif + +#ifdef XP_MACOSX +// Caller is responsible for freeing any result (CF Create Rule) +CFURLRef CreateCFURLFromNSIURI(nsIURI *aURI) { + nsAutoCString spec; + if (aURI) { + aURI->GetSpec(spec); + } + + CFStringRef urlStr = ::CFStringCreateWithCString(kCFAllocatorDefault, + spec.get(), + kCFStringEncodingUTF8); + if (!urlStr) { + return NULL; + } + + CFURLRef url = ::CFURLCreateWithString(kCFAllocatorDefault, + urlStr, + NULL); + + ::CFRelease(urlStr); + + return url; +} +#endif + +nsresult DownloadPlatform::DownloadDone(nsIURI* aSource, nsIURI* aReferrer, nsIFile* aTarget, + const nsACString& aContentType, bool aIsPrivate) +{ +#if defined(XP_WIN) || defined(XP_MACOSX) || defined(MOZ_WIDGET_ANDROID) \ + || defined(MOZ_WIDGET_GTK) || defined(MOZ_WIDGET_GONK) + + nsAutoString path; + if (aTarget && NS_SUCCEEDED(aTarget->GetPath(path))) { +#if defined(XP_WIN) || defined(MOZ_WIDGET_GTK) || defined(MOZ_WIDGET_ANDROID) + // On Windows and Gtk, add the download to the system's "recent documents" + // list, with a pref to disable. + { + bool addToRecentDocs = Preferences::GetBool(PREF_BDM_ADDTORECENTDOCS); +#ifdef MOZ_WIDGET_ANDROID + if (jni::IsFennec() && addToRecentDocs) { + java::DownloadsIntegration::ScanMedia(path, aContentType); + } +#else + if (addToRecentDocs && !aIsPrivate) { +#ifdef XP_WIN + ::SHAddToRecentDocs(SHARD_PATHW, path.get()); +#elif defined(MOZ_WIDGET_GTK) + GtkRecentManager* manager = gtk_recent_manager_get_default(); + + gchar* uri = g_filename_to_uri(NS_ConvertUTF16toUTF8(path).get(), + nullptr, nullptr); + if (uri) { + gtk_recent_manager_add_item(manager, uri); + g_free(uri); + } +#endif + } +#endif +#ifdef MOZ_ENABLE_GIO + // Use GIO to store the source URI for later display in the file manager. + GFile* gio_file = g_file_new_for_path(NS_ConvertUTF16toUTF8(path).get()); + nsCString source_uri; + nsresult rv = aSource->GetSpec(source_uri); + NS_ENSURE_SUCCESS(rv, rv); + GFileInfo *file_info = g_file_info_new(); + g_file_info_set_attribute_string(file_info, "metadata::download-uri", source_uri.get()); + g_file_set_attributes_async(gio_file, + file_info, + G_FILE_QUERY_INFO_NONE, + G_PRIORITY_DEFAULT, + nullptr, gio_set_metadata_done, nullptr); + g_object_unref(file_info); + g_object_unref(gio_file); +#endif + } +#endif + +#ifdef XP_MACOSX + // On OS X, make the downloads stack bounce. + CFStringRef observedObject = ::CFStringCreateWithCString(kCFAllocatorDefault, + NS_ConvertUTF16toUTF8(path).get(), + kCFStringEncodingUTF8); + CFNotificationCenterRef center = ::CFNotificationCenterGetDistributedCenter(); + ::CFNotificationCenterPostNotification(center, CFSTR("com.apple.DownloadFileFinished"), + observedObject, nullptr, TRUE); + ::CFRelease(observedObject); + + // Add OS X origin and referrer file metadata + CFStringRef pathCFStr = NULL; + if (!path.IsEmpty()) { + pathCFStr = ::CFStringCreateWithCharacters(kCFAllocatorDefault, + (const UniChar*)path.get(), + path.Length()); + } + if (pathCFStr) { + bool isFromWeb = IsURLPossiblyFromWeb(aSource); + + CFURLRef sourceCFURL = CreateCFURLFromNSIURI(aSource); + CFURLRef referrerCFURL = CreateCFURLFromNSIURI(aReferrer); + + CocoaFileUtils::AddOriginMetadataToFile(pathCFStr, + sourceCFURL, + referrerCFURL); + CocoaFileUtils::AddQuarantineMetadataToFile(pathCFStr, + sourceCFURL, + referrerCFURL, + isFromWeb); + + ::CFRelease(pathCFStr); + if (sourceCFURL) { + ::CFRelease(sourceCFURL); + } + if (referrerCFURL) { + ::CFRelease(referrerCFURL); + } + } +#endif + } + +#endif + + return NS_OK; +} + +nsresult DownloadPlatform::MapUrlToZone(const nsAString& aURL, + uint32_t* aZone) +{ +#ifdef XP_WIN + RefPtr<IInternetSecurityManager> inetSecMgr; + if (FAILED(CoCreateInstance(CLSID_InternetSecurityManager, NULL, + CLSCTX_ALL, IID_IInternetSecurityManager, + getter_AddRefs(inetSecMgr)))) { + return NS_ERROR_UNEXPECTED; + } + + DWORD zone; + if (inetSecMgr->MapUrlToZone(PromiseFlatString(aURL).get(), + &zone, 0) != S_OK) { + return NS_ERROR_UNEXPECTED; + } else { + *aZone = zone; + } + + return NS_OK; +#else + return NS_ERROR_NOT_IMPLEMENTED; +#endif +} + +// Check if a URI is likely to be web-based, by checking its URI flags. +// If in doubt (e.g. if anything fails during the check) claims things +// are from the web. +bool DownloadPlatform::IsURLPossiblyFromWeb(nsIURI* aURI) +{ + nsCOMPtr<nsIIOService> ios = do_GetIOService(); + nsCOMPtr<nsIURI> uri = aURI; + if (!ios) { + return true; + } + + while (uri) { + // We're not using nsIIOService::ProtocolHasFlags because it doesn't + // take per-URI flags into account. We're also not using + // NS_URIChainHasFlags because we're checking for *any* of 3 flags + // to be present on *all* of the nested URIs, which it can't do. + nsAutoCString scheme; + nsresult rv = uri->GetScheme(scheme); + if (NS_FAILED(rv)) { + return true; + } + nsCOMPtr<nsIProtocolHandler> ph; + rv = ios->GetProtocolHandler(scheme.get(), getter_AddRefs(ph)); + if (NS_FAILED(rv)) { + return true; + } + uint32_t flags; + rv = ph->DoGetProtocolFlags(uri, &flags); + if (NS_FAILED(rv)) { + return true; + } + // If not dangerous to load, not a UI resource and not a local file, + // assume this is from the web: + if (!(flags & nsIProtocolHandler::URI_DANGEROUS_TO_LOAD) && + !(flags & nsIProtocolHandler::URI_IS_UI_RESOURCE) && + !(flags & nsIProtocolHandler::URI_IS_LOCAL_FILE)) { + return true; + } + // Otherwise, check if the URI is nested, and if so go through + // the loop again: + nsCOMPtr<nsINestedURI> nestedURI = do_QueryInterface(uri); + uri = nullptr; + if (nestedURI) { + rv = nestedURI->GetInnerURI(getter_AddRefs(uri)); + if (NS_FAILED(rv)) { + return true; + } + } + } + return false; +} diff --git a/toolkit/components/jsdownloads/src/DownloadPlatform.h b/toolkit/components/jsdownloads/src/DownloadPlatform.h new file mode 100644 index 000000000..ef3c7554f --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadPlatform.h @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef __DownloadPlatform_h__ +#define __DownloadPlatform_h__ + +#include "mozIDownloadPlatform.h" + +#include "nsCOMPtr.h" +class nsIURI; + +class DownloadPlatform : public mozIDownloadPlatform +{ +protected: + + virtual ~DownloadPlatform() { } + +public: + + NS_DECL_ISUPPORTS + NS_DECL_MOZIDOWNLOADPLATFORM + + DownloadPlatform() { } + + static DownloadPlatform *gDownloadPlatformService; + + static DownloadPlatform* GetDownloadPlatform(); + +private: + static bool IsURLPossiblyFromWeb(nsIURI* aURI); +}; + +#endif diff --git a/toolkit/components/jsdownloads/src/DownloadStore.jsm b/toolkit/components/jsdownloads/src/DownloadStore.jsm new file mode 100644 index 000000000..765a45c5a --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadStore.jsm @@ -0,0 +1,203 @@ +/* -*- 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/. */ + +/** + * Handles serialization of Download objects and persistence into a file, so + * that the state of downloads can be restored across sessions. + * + * The file is stored in JSON format, without indentation. With indentation + * applied, the file would look like this: + * + * { + * "list": [ + * { + * "source": "http://www.example.com/download.txt", + * "target": "/home/user/Downloads/download.txt" + * }, + * { + * "source": { + * "url": "http://www.example.com/download.txt", + * "referrer": "http://www.example.com/referrer.html" + * }, + * "target": "/home/user/Downloads/download-2.txt" + * } + * ] + * } + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "DownloadStore", +]; + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm") +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyGetter(this, "gTextDecoder", function () { + return new TextDecoder(); +}); + +XPCOMUtils.defineLazyGetter(this, "gTextEncoder", function () { + return new TextEncoder(); +}); + +// DownloadStore + +/** + * Handles serialization of Download objects and persistence into a file, so + * that the state of downloads can be restored across sessions. + * + * @param aList + * DownloadList object to be populated or serialized. + * @param aPath + * String containing the file path where data should be saved. + */ +this.DownloadStore = function (aList, aPath) +{ + this.list = aList; + this.path = aPath; +} + +this.DownloadStore.prototype = { + /** + * DownloadList object to be populated or serialized. + */ + list: null, + + /** + * String containing the file path where data should be saved. + */ + path: "", + + /** + * This function is called with a Download object as its first argument, and + * should return true if the item should be saved. + */ + onsaveitem: () => true, + + /** + * Loads persistent downloads from the file to the list. + * + * @return {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + load: function DS_load() + { + return Task.spawn(function* task_DS_load() { + let bytes; + try { + bytes = yield OS.File.read(this.path); + } catch (ex) { + if (!(ex instanceof OS.File.Error) || !ex.becauseNoSuchFile) { + throw ex; + } + // If the file does not exist, there are no downloads to load. + return; + } + + let storeData = JSON.parse(gTextDecoder.decode(bytes)); + + // Create live downloads based on the static snapshot. + for (let downloadData of storeData.list) { + try { + let download = yield Downloads.createDownload(downloadData); + try { + if (!download.succeeded && !download.canceled && !download.error) { + // Try to restart the download if it was in progress during the + // previous session. Ignore errors. + download.start().catch(() => {}); + } else { + // If the download was not in progress, try to update the current + // progress from disk. This is relevant in case we retained + // partially downloaded data. + yield download.refresh(); + } + } finally { + // Add the download to the list if we succeeded in creating it, + // after we have updated its initial state. + yield this.list.add(download); + } + } catch (ex) { + // If an item is unrecognized, don't prevent others from being loaded. + Cu.reportError(ex); + } + } + }.bind(this)); + }, + + /** + * Saves persistent downloads from the list to the file. + * + * If an error occurs, the previous file is not deleted. + * + * @return {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + save: function DS_save() + { + return Task.spawn(function* task_DS_save() { + let downloads = yield this.list.getAll(); + + // Take a static snapshot of the current state of all the downloads. + let storeData = { list: [] }; + let atLeastOneDownload = false; + for (let download of downloads) { + try { + if (!this.onsaveitem(download)) { + continue; + } + + let serializable = download.toSerializable(); + if (!serializable) { + // This item cannot be persisted across sessions. + continue; + } + storeData.list.push(serializable); + atLeastOneDownload = true; + } catch (ex) { + // If an item cannot be converted to a serializable form, don't + // prevent others from being saved. + Cu.reportError(ex); + } + } + + if (atLeastOneDownload) { + // Create or overwrite the file if there are downloads to save. + let bytes = gTextEncoder.encode(JSON.stringify(storeData)); + yield OS.File.writeAtomic(this.path, bytes, + { tmpPath: this.path + ".tmp" }); + } else { + // Remove the file if there are no downloads to save at all. + try { + yield OS.File.remove(this.path); + } catch (ex) { + if (!(ex instanceof OS.File.Error) || + !(ex.becauseNoSuchFile || ex.becauseAccessDenied)) { + throw ex; + } + // On Windows, we may get an access denied error instead of a no such + // file error if the file existed before, and was recently deleted. + } + } + }.bind(this)); + }, +}; diff --git a/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm b/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm new file mode 100644 index 000000000..f5102b4a8 --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm @@ -0,0 +1,243 @@ +/* -*- 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/. */ + +/** + * Provides functions to handle status and messages in the user interface. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "DownloadUIHelper", +]; + +// Globals + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/AppConstants.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"); + +const kStringBundleUrl = + "chrome://mozapps/locale/downloads/downloads.properties"; + +const kStringsRequiringFormatting = { + fileExecutableSecurityWarning: true, + cancelDownloadsOKTextMultiple: true, + quitCancelDownloadsAlertMsgMultiple: true, + quitCancelDownloadsAlertMsgMacMultiple: true, + offlineCancelDownloadsAlertMsgMultiple: true, + leavePrivateBrowsingWindowsCancelDownloadsAlertMsgMultiple2: true +}; + +// DownloadUIHelper + +/** + * Provides functions to handle status and messages in the user interface. + */ +this.DownloadUIHelper = { + /** + * Returns an object that can be used to display prompts related to downloads. + * + * The prompts may be either anchored to a specified window, or anchored to + * the most recently active window, for example if the prompt is displayed in + * response to global notifications that are not associated with any window. + * + * @param aParent + * If specified, should reference the nsIDOMWindow to which the prompts + * should be attached. If omitted, the prompts will be attached to the + * most recently active window. + * + * @return A DownloadPrompter object. + */ + getPrompter: function (aParent) + { + return new DownloadPrompter(aParent || null); + }, +}; + +/** + * Returns an object whose keys are the string names from the downloads string + * bundle, and whose values are either the translated strings or functions + * returning formatted strings. + */ +XPCOMUtils.defineLazyGetter(DownloadUIHelper, "strings", function () { + let strings = {}; + let sb = Services.strings.createBundle(kStringBundleUrl); + let enumerator = sb.getSimpleEnumeration(); + while (enumerator.hasMoreElements()) { + let string = enumerator.getNext().QueryInterface(Ci.nsIPropertyElement); + let stringName = string.key; + if (stringName in kStringsRequiringFormatting) { + strings[stringName] = function () { + // Convert "arguments" to a real array before calling into XPCOM. + return sb.formatStringFromName(stringName, + Array.slice(arguments, 0), + arguments.length); + }; + } else { + strings[stringName] = string.value; + } + } + return strings; +}); + +// DownloadPrompter + +/** + * Allows displaying prompts related to downloads. + * + * @param aParent + * The nsIDOMWindow to which prompts should be attached, or null to + * attach prompts to the most recently active window. + */ +this.DownloadPrompter = function (aParent) +{ + if (AppConstants.MOZ_B2G) { + // On B2G there is no prompter implementation. + this._prompter = null; + } else { + this._prompter = Services.ww.getNewPrompter(aParent); + } +} + +this.DownloadPrompter.prototype = { + /** + * Constants with the different type of prompts. + */ + ON_QUIT: "prompt-on-quit", + ON_OFFLINE: "prompt-on-offline", + ON_LEAVE_PRIVATE_BROWSING: "prompt-on-leave-private-browsing", + + /** + * nsIPrompt instance for displaying messages. + */ + _prompter: null, + + /** + * Displays a warning message box that informs that the specified file is + * executable, and asks whether the user wants to launch it. The user is + * given the option of disabling future instances of this warning. + * + * @param aPath + * String containing the full path to the file to be opened. + * + * @return {Promise} + * @resolves Boolean indicating whether the launch operation can continue. + * @rejects JavaScript exception. + */ + confirmLaunchExecutable: function (aPath) + { + const kPrefAlertOnEXEOpen = "browser.download.manager.alertOnEXEOpen"; + + try { + // Always launch in case we have no prompter implementation. + if (!this._prompter) { + return Promise.resolve(true); + } + + try { + if (!Services.prefs.getBoolPref(kPrefAlertOnEXEOpen)) { + return Promise.resolve(true); + } + } catch (ex) { + // If the preference does not exist, continue with the prompt. + } + + let leafName = OS.Path.basename(aPath); + + let s = DownloadUIHelper.strings; + let checkState = { value: false }; + let shouldLaunch = this._prompter.confirmCheck( + s.fileExecutableSecurityWarningTitle, + s.fileExecutableSecurityWarning(leafName, leafName), + s.fileExecutableSecurityWarningDontAsk, + checkState); + + if (shouldLaunch) { + Services.prefs.setBoolPref(kPrefAlertOnEXEOpen, !checkState.value); + } + + return Promise.resolve(shouldLaunch); + } catch (ex) { + return Promise.reject(ex); + } + }, + + /** + * Displays a warning message box that informs that there are active + * downloads, and asks whether the user wants to cancel them or not. + * + * @param aDownloadsCount + * The current downloads count. + * @param aPromptType + * The type of prompt notification depending on the observer. + * + * @return False to cancel the downloads and continue, true to abort the + * operation. + */ + confirmCancelDownloads: function DP_confirmCancelDownload(aDownloadsCount, + aPromptType) + { + // Always continue in case we have no prompter implementation, or if there + // are no active downloads. + if (!this._prompter || aDownloadsCount <= 0) { + return false; + } + + let s = DownloadUIHelper.strings; + let buttonFlags = (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0) + + (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_1); + let okButton = aDownloadsCount > 1 ? s.cancelDownloadsOKTextMultiple(aDownloadsCount) + : s.cancelDownloadsOKText; + let title, message, cancelButton; + + switch (aPromptType) { + case this.ON_QUIT: + title = s.quitCancelDownloadsAlertTitle; + if (AppConstants.platform != "macosx") { + message = aDownloadsCount > 1 + ? s.quitCancelDownloadsAlertMsgMultiple(aDownloadsCount) + : s.quitCancelDownloadsAlertMsg; + cancelButton = s.dontQuitButtonWin; + } else { + message = aDownloadsCount > 1 + ? s.quitCancelDownloadsAlertMsgMacMultiple(aDownloadsCount) + : s.quitCancelDownloadsAlertMsgMac; + cancelButton = s.dontQuitButtonMac; + } + break; + case this.ON_OFFLINE: + title = s.offlineCancelDownloadsAlertTitle; + message = aDownloadsCount > 1 + ? s.offlineCancelDownloadsAlertMsgMultiple(aDownloadsCount) + : s.offlineCancelDownloadsAlertMsg; + cancelButton = s.dontGoOfflineButton; + break; + case this.ON_LEAVE_PRIVATE_BROWSING: + title = s.leavePrivateBrowsingCancelDownloadsAlertTitle; + message = aDownloadsCount > 1 + ? s.leavePrivateBrowsingWindowsCancelDownloadsAlertMsgMultiple2(aDownloadsCount) + : s.leavePrivateBrowsingWindowsCancelDownloadsAlertMsg2; + cancelButton = s.dontLeavePrivateBrowsingButton2; + break; + } + + let rv = this._prompter.confirmEx(title, message, buttonFlags, okButton, + cancelButton, null, null, {}); + return (rv == 1); + } +}; diff --git a/toolkit/components/jsdownloads/src/Downloads.jsm b/toolkit/components/jsdownloads/src/Downloads.jsm new file mode 100644 index 000000000..9511dc4ca --- /dev/null +++ b/toolkit/components/jsdownloads/src/Downloads.jsm @@ -0,0 +1,305 @@ +/* -*- 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/. */ + +/** + * Main entry point to get references to all the back-end objects. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "Downloads", +]; + +// 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"); +Cu.import("resource://gre/modules/DownloadCore.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadCombinedList", + "resource://gre/modules/DownloadList.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadList", + "resource://gre/modules/DownloadList.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadSummary", + "resource://gre/modules/DownloadList.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadUIHelper", + "resource://gre/modules/DownloadUIHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +Integration.downloads.defineModuleGetter(this, "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.jsm"); + +// Downloads + +/** + * This object is exposed directly to the consumers of this JavaScript module, + * and provides the only entry point to get references to back-end objects. + */ +this.Downloads = { + /** + * Work on downloads that were not started from a private browsing window. + */ + get PUBLIC() { + return "{Downloads.PUBLIC}"; + }, + /** + * Work on downloads that were started from a private browsing window. + */ + get PRIVATE() { + return "{Downloads.PRIVATE}"; + }, + /** + * Work on both Downloads.PRIVATE and Downloads.PUBLIC downloads. + */ + get ALL() { + return "{Downloads.ALL}"; + }, + + /** + * Creates a new Download object. + * + * @param aProperties + * Provides the initial properties for the newly created download. + * This matches the serializable representation of a Download object. + * Some of the most common properties in this object include: + * { + * source: String containing the URI for the download source. + * Alternatively, may be an nsIURI, a DownloadSource object, + * or an object with the following properties: + * { + * url: String containing the URI for the download source. + * isPrivate: Indicates whether the download originated from a + * private window. If omitted, the download is public. + * 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. + * }, + * target: String containing the path of the target file. + * Alternatively, may be an nsIFile, a DownloadTarget object, + * or an object with the following properties: + * { + * path: String containing the path of the target file. + * }, + * saver: String representing the class of the download operation. + * If omitted, defaults to "copy". Alternatively, may be the + * serializable representation of a DownloadSaver object. + * } + * + * @return {Promise} + * @resolves The newly created Download object. + * @rejects JavaScript exception. + */ + createDownload: function D_createDownload(aProperties) + { + try { + return Promise.resolve(Download.fromSerializable(aProperties)); + } catch (ex) { + return Promise.reject(ex); + } + }, + + /** + * Downloads data from a remote network location to a local file. + * + * This download method does not provide user interface, or the ability to + * cancel or restart the download programmatically. For that, you should + * obtain a reference to a Download object using the createDownload function. + * + * Since the download cannot be restarted, any partially downloaded data will + * not be kept in case the download fails. + * + * @param aSource + * String containing the URI for the download source. Alternatively, + * may be an nsIURI or a DownloadSource object. + * @param aTarget + * String containing the path of the target file. Alternatively, may + * be an nsIFile or a DownloadTarget object. + * @param aOptions + * An optional object used to control the behavior of this function. + * You may pass an object with a subset of the following fields: + * { + * isPrivate: Indicates whether the download originated from a + * private window. + * } + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects JavaScript exception if the download failed. + */ + fetch: function (aSource, aTarget, aOptions) { + return this.createDownload({ + source: aSource, + target: aTarget, + }).then(function D_SD_onSuccess(aDownload) { + if (aOptions && ("isPrivate" in aOptions)) { + aDownload.source.isPrivate = aOptions.isPrivate; + } + return aDownload.start(); + }); + }, + + /** + * Retrieves the specified type of DownloadList object. There is one download + * list for each type, and this method always retrieves a reference to the + * same download list when called with the same argument. + * + * Calling this function may cause the list of public downloads to be reloaded + * from the previous session, if it wasn't loaded already. + * + * @param aType + * This can be Downloads.PUBLIC, Downloads.PRIVATE, or Downloads.ALL. + * Downloads added to the Downloads.PUBLIC and Downloads.PRIVATE lists + * are reflected in the Downloads.ALL list, and downloads added to the + * Downloads.ALL list are also added to either the Downloads.PUBLIC or + * the Downloads.PRIVATE list based on their properties. + * + * @return {Promise} + * @resolves The requested DownloadList or DownloadCombinedList object. + * @rejects JavaScript exception. + */ + getList: function (aType) + { + if (!this._promiseListsInitialized) { + this._promiseListsInitialized = Task.spawn(function* () { + let publicList = new DownloadList(); + let privateList = new DownloadList(); + let combinedList = new DownloadCombinedList(publicList, privateList); + + try { + yield DownloadIntegration.addListObservers(publicList, false); + yield DownloadIntegration.addListObservers(privateList, true); + yield DownloadIntegration.initializePublicDownloadList(publicList); + } catch (ex) { + Cu.reportError(ex); + } + + let publicSummary = yield this.getSummary(Downloads.PUBLIC); + let privateSummary = yield this.getSummary(Downloads.PRIVATE); + let combinedSummary = yield this.getSummary(Downloads.ALL); + + yield publicSummary.bindToList(publicList); + yield privateSummary.bindToList(privateList); + yield combinedSummary.bindToList(combinedList); + + this._lists[Downloads.PUBLIC] = publicList; + this._lists[Downloads.PRIVATE] = privateList; + this._lists[Downloads.ALL] = combinedList; + }.bind(this)); + } + + return this._promiseListsInitialized.then(() => this._lists[aType]); + }, + + /** + * Promise resolved when the initialization of the download lists has + * completed, or null if initialization has never been requested. + */ + _promiseListsInitialized: null, + + /** + * After initialization, this object is populated with one key for each type + * of download list that can be returned (Downloads.PUBLIC, Downloads.PRIVATE, + * or Downloads.ALL). The values are the DownloadList objects. + */ + _lists: {}, + + /** + * Retrieves the specified type of DownloadSummary object. There is one + * download summary for each type, and this method always retrieves a + * reference to the same download summary when called with the same argument. + * + * Calling this function does not cause the list of public downloads to be + * reloaded from the previous session. The summary will behave as if no + * downloads are present until the getList method is called. + * + * @param aType + * This can be Downloads.PUBLIC, Downloads.PRIVATE, or Downloads.ALL. + * + * @return {Promise} + * @resolves The requested DownloadList or DownloadCombinedList object. + * @rejects JavaScript exception. + */ + getSummary: function (aType) + { + if (aType != Downloads.PUBLIC && aType != Downloads.PRIVATE && + aType != Downloads.ALL) { + throw new Error("Invalid aType argument."); + } + + if (!(aType in this._summaries)) { + this._summaries[aType] = new DownloadSummary(); + } + + return Promise.resolve(this._summaries[aType]); + }, + + /** + * This object is populated by the getSummary method with one key for each + * type of object that can be returned (Downloads.PUBLIC, Downloads.PRIVATE, + * or Downloads.ALL). The values are the DownloadSummary objects. + */ + _summaries: {}, + + /** + * Returns the system downloads directory asynchronously. + * Mac OSX: + * User downloads directory + * XP/2K: + * My Documents/Downloads + * Vista and others: + * User downloads directory + * Linux: + * XDG user dir spec, with a fallback to Home/Downloads + * Android: + * standard downloads directory i.e. /sdcard + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getSystemDownloadsDirectory: function D_getSystemDownloadsDirectory() { + return DownloadIntegration.getSystemDownloadsDirectory(); + }, + + /** + * Returns the preferred downloads directory based on the user preferences + * in the current profile asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getPreferredDownloadsDirectory: function D_getPreferredDownloadsDirectory() { + return DownloadIntegration.getPreferredDownloadsDirectory(); + }, + + /** + * Returns the temporary directory where downloads are placed before the + * final location is chosen, or while the document is opened temporarily + * with an external application. This may or may not be the system temporary + * directory, based on the platform asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getTemporaryDownloadsDirectory: function D_getTemporaryDownloadsDirectory() { + return DownloadIntegration.getTemporaryDownloadsDirectory(); + }, + + /** + * Constructor for a DownloadError object. When you catch an exception during + * a download, you can use this to verify if "ex instanceof Downloads.Error", + * before reading the exception properties with the error details. + */ + Error: DownloadError, +}; diff --git a/toolkit/components/jsdownloads/src/Downloads.manifest b/toolkit/components/jsdownloads/src/Downloads.manifest new file mode 100644 index 000000000..03d4ed4a6 --- /dev/null +++ b/toolkit/components/jsdownloads/src/Downloads.manifest @@ -0,0 +1,2 @@ +component {1b4c85df-cbdd-4bb6-b04e-613caece083c} DownloadLegacy.js +contract @mozilla.org/transfer;1 {1b4c85df-cbdd-4bb6-b04e-613caece083c} diff --git a/toolkit/components/jsdownloads/src/moz.build b/toolkit/components/jsdownloads/src/moz.build new file mode 100644 index 000000000..87abed62e --- /dev/null +++ b/toolkit/components/jsdownloads/src/moz.build @@ -0,0 +1,31 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +SOURCES += [ + 'DownloadPlatform.cpp', +] + +EXTRA_COMPONENTS += [ + 'DownloadLegacy.js', + 'Downloads.manifest', +] + +EXTRA_JS_MODULES += [ + 'DownloadCore.jsm', + 'DownloadImport.jsm', + 'DownloadList.jsm', + 'Downloads.jsm', + 'DownloadStore.jsm', + 'DownloadUIHelper.jsm', +] + +EXTRA_PP_JS_MODULES += [ + 'DownloadIntegration.jsm', +] + +FINAL_LIBRARY = 'xul' + +CXXFLAGS += CONFIG['TK_CFLAGS'] |