From 9981fae81fff245b92a4a660ba5494f57eccf8b2 Mon Sep 17 00:00:00 2001 From: Paolo Amadini Date: Fri, 16 Aug 2013 11:02:18 +0200 Subject: [PATCH] Bug 836443 - Automatically stop and restart downloads. r=enn --- .../jsdownloads/src/DownloadCore.jsm | 150 +++++++++++-- .../jsdownloads/src/DownloadIntegration.jsm | 201 +++++++++++++++++- .../jsdownloads/src/DownloadList.jsm | 5 + .../jsdownloads/src/DownloadStore.jsm | 27 ++- 4 files changed, 359 insertions(+), 24 deletions(-) diff --git a/toolkit/components/jsdownloads/src/DownloadCore.jsm b/toolkit/components/jsdownloads/src/DownloadCore.jsm index 31007b784042..bb5ae1e62b27 100644 --- a/toolkit/components/jsdownloads/src/DownloadCore.jsm +++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm @@ -203,7 +203,7 @@ Download.prototype = { * * 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 not relevant anymore. + * 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. @@ -382,6 +382,7 @@ Download.prototype = { // Update the status properties for a successful download. this.progress = 100; this.succeeded = true; + this.hasPartialData = false; } catch (ex) { // Fail with a generic status code on cancellation, so that the caller // is forced to actually check the status properties to see if the @@ -622,6 +623,47 @@ Download.prototype = { 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; + } + + // Update the current progress from disk if we retained partial data. + if (this.hasPartialData && this.target.partFilePath) { + 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); + } + 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. @@ -761,20 +803,54 @@ Download.prototype = { serializable.saver = saver; } - if (this.launcherPath) { - serializable.launcherPath = this.launcherPath; + if (!this.stopped) { + serializable.stopped = false; } - if (this.launchWhenSucceeded) { - serializable.launchWhenSucceeded = true; + if (this.error && ("message" in this.error)) { + serializable.error = { message: this.error.message }; } - if (this.contentType) { - serializable.contentType = this.contentType; + // These are serialized unless they are false, null, or empty strings. + let propertiesToSerialize = [ + "succeeded", + "canceled", + "startTime", + "totalBytes", + "hasPartialData", + "tryToKeepPartialData", + "launcherPath", + "launchWhenSucceeded", + "contentType", + ]; + + for (let property of propertiesToSerialize) { + if (this[property]) { + serializable[property] = this[property]; + } } 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; + }, }; /** @@ -816,16 +892,28 @@ Download.fromSerializable = function (aSerializable) { } download.saver.download = download; - if ("launchWhenSucceeded" in aSerializable) { - download.launchWhenSucceeded = !!aSerializable.launchWhenSucceeded; + let propertiesToDeserialize = [ + "startTime", + "totalBytes", + "hasPartialData", + "tryToKeepPartialData", + "launcherPath", + "launchWhenSucceeded", + "contentType", + ]; + + // If the download should not be restarted automatically, update its state to + // reflect success or failure during a previous session. + if (!("stopped" in aSerializable) || aSerializable.stopped) { + propertiesToDeserialize.push("succeeded"); + propertiesToDeserialize.push("canceled"); + propertiesToDeserialize.push("error"); } - if ("contentType" in aSerializable) { - download.contentType = aSerializable.contentType; - } - - if ("launcherPath" in aSerializable) { - download.launcherPath = aSerializable.launcherPath; + for (let property of propertiesToDeserialize) { + if (property in aSerializable) { + download[property] = aSerializable[property]; + } } return download; @@ -1190,6 +1278,13 @@ DownloadCopySaver.prototype = { */ _canceled: 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". */ @@ -1428,8 +1523,13 @@ DownloadCopySaver.prototype = { */ toSerializable: function () { - // Simplify the representation since we don't have other details for now. - return "copy"; + // Simplify the representation if we don't have other details. + if (!this.entityID) { + return "copy"; + } + + return { type: "copy", + entityID: this.entityID }; }, }; @@ -1443,8 +1543,11 @@ DownloadCopySaver.prototype = { * @return The newly created DownloadCopySaver object. */ DownloadCopySaver.fromSerializable = function (aSerializable) { - // We don't have other state details for now. - return new DownloadCopySaver(); + let saver = new DownloadCopySaver(); + if ("entityID" in aSerializable) { + saver.entityID = aSerializable.entityID; + } + return saver; }; //////////////////////////////////////////////////////////////////////////////// @@ -1574,6 +1677,13 @@ DownloadLegacySaver.prototype = { */ 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". */ @@ -1674,7 +1784,7 @@ DownloadLegacySaver.prototype = { // 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 "copy"; + return DownloadCopySaver.prototype.toSerializable.call(this); }, }; diff --git a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm index cbf48f0454c4..842a1228aa73 100644 --- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm +++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm @@ -64,6 +64,31 @@ XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() { createBundle("chrome://mozapps/locale/downloads/downloads.properties"); }); +const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", + "initWithCallback"); + +/** + * Indicates the delay between a change to the downloads data and the related + * save operation. This value is the result of a delicate trade-off, assuming + * the host application uses the browser history instead of the download store + * to save completed downloads. + * + * If a download takes less than this interval to complete (for example, saving + * a page that is already displayed), then no input/output is triggered by the + * download store except for an existence check, resulting in the best possible + * efficiency. + * + * Conversely, if the browser is closed before this interval has passed, the + * download will not be saved. This prevents it from being restored in the next + * session, and if there is partial data associated with it, then the ".part" + * file will not be deleted when the browser starts again. + * + * In all cases, 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; + //////////////////////////////////////////////////////////////////////////////// //// DownloadIntegration @@ -105,7 +130,7 @@ this.DownloadIntegration = { * @param aList * 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 or when the session terminates. + * to disk during the session lifetime. * * @return {Promise} * @resolves When the list has been populated. @@ -124,7 +149,39 @@ this.DownloadIntegration = { this._store = new DownloadStore(aList, OS.Path.join( OS.Constants.Path.profileDir, "downloads.json")); - return this._store.load(); + this._store.onsaveitem = this.shouldPersistDownload.bind(this); + + // Load the list of persistent downloads, then add the DownloadAutoSaveView + // even if the load operation failed. + return this._store.load().then(null, Cu.reportError).then(() => { + new DownloadAutoSaveView(aList, this._store); + }); + }, + + /** + * 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: function (aDownload) + { + // In the default implementation, we save all the downloads currently in + // progress, as well as stopped downloads for which we retained partially + // downloaded data. Stopped downloads for which we don't need to track the + // presence of a ".part" file are only retained in the browser history. + return aDownload.hasPartialData || !aDownload.stopped; }, /** @@ -493,6 +550,10 @@ this.DownloadIntegration = { * @resolves When the views and observers are added. */ addListObservers: function DI_addListObservers(aList, aIsPrivate) { + if (this.dontLoad) { + return Promise.resolve(); + } + DownloadObserver.registerView(aList, aIsPrivate); if (!DownloadObserver.observersAdded) { DownloadObserver.observersAdded = true; @@ -504,7 +565,10 @@ this.DownloadIntegration = { } }; -let DownloadObserver = { +//////////////////////////////////////////////////////////////////////////////// +//// DownloadObserver + +this.DownloadObserver = { /** * Flag to determine if the observers have been added previously. */ @@ -657,3 +721,134 @@ let DownloadObserver = { QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]) }; + +//////////////////////////////////////////////////////////////////////////////// +//// 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. + * + * @param aStore + * The DownloadStore object used for saving. + */ +function DownloadAutoSaveView(aList, aStore) { + this._store = aStore; + this._downloadsMap = new Map(); + + // We set _initialized to true after adding the view, so that onDownloadAdded + // doesn't cause a save to occur. + aList.addView(this); + this._initialized = true; +} + +DownloadAutoSaveView.prototype = { + /** + * True when the initial state of the downloads has been loaded. + */ + _initialized: false, + + /** + * The DownloadStore object used for saving. + */ + _store: null, + + /** + * 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, + + /** + * This is set to true when the save operation should be triggered. This is + * required so that a new operation can be scheduled while the current one is + * in progress, without re-entering the save method. + */ + _shouldSave: false, + + /** + * nsITimer used for triggering the save operation after a delay, or null if + * saving has finished and there is no operation scheduled for execution. + * + * The logic here is different from the DeferredTask module in that multiple + * requests will never delay the operation for longer than the expected time + * (no grace delay), and the operation is never re-entered during execution. + */ + _timer: null, + + /** + * Timer callback used to serialize the list of downloads. + */ + _save: function () + { + Task.spawn(function () { + // Any save request received during execution will be handled later. + this._shouldSave = false; + + // Execute the asynchronous save operation. + try { + yield this._store.save(); + } catch (ex) { + Cu.reportError(ex); + } + + // Handle requests received during the operation. + this._timer = null; + if (this._shouldSave) { + this.saveSoon(); + } + }.bind(this)).then(null, Cu.reportError); + }, + + /** + * Called when the list of downloads changed, this triggers the asynchronous + * serialization of the list of downloads. + */ + saveSoon: function () + { + this._shouldSave = true; + if (!this._timer) { + this._timer = new Timer(this._save.bind(this), kSaveDelayMs, + Ci.nsITimer.TYPE_ONE_SHOT); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// DownloadList view + + onDownloadAdded: function (aDownload) + { + if (DownloadIntegration.shouldPersistDownload(aDownload)) { + this._downloadsMap.set(aDownload, aDownload.getSerializationHash()); + if (this._initialized) { + this.saveSoon(); + } + } + }, + + onDownloadChanged: function (aDownload) + { + if (!DownloadIntegration.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/DownloadList.jsm b/toolkit/components/jsdownloads/src/DownloadList.jsm index 3f6943e1f1e0..41cbff15dc52 100644 --- a/toolkit/components/jsdownloads/src/DownloadList.jsm +++ b/toolkit/components/jsdownloads/src/DownloadList.jsm @@ -171,6 +171,11 @@ DownloadList.prototype = { * // Called after aDownload is removed from the list. * }, * } + * + * @note The onDownloadAdded notifications are sent synchronously. This + * allows for a complete initialization of the view used for detecting + * changes to downloads to be persisted, before other callers get a + * chance to modify them. */ addView: function DL_addView(aView) { diff --git a/toolkit/components/jsdownloads/src/DownloadStore.jsm b/toolkit/components/jsdownloads/src/DownloadStore.jsm index 6b3c9bab487e..1a11509e4b91 100644 --- a/toolkit/components/jsdownloads/src/DownloadStore.jsm +++ b/toolkit/components/jsdownloads/src/DownloadStore.jsm @@ -88,6 +88,12 @@ DownloadStore.prototype = { */ 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. * @@ -111,7 +117,23 @@ DownloadStore.prototype = { // Create live downloads based on the static snapshot. for (let downloadData of storeData.list) { try { - this.list.add(yield Downloads.createDownload(downloadData)); + let download = yield Downloads.createDownload(downloadData); + try { + if (("stopped" in downloadData) && !downloadData.stopped) { + // Try to restart the download if it was in progress during the + // previous session. + download.start(); + } 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. + this.list.add(download); + } } catch (ex) { // If an item is unrecognized, don't prevent others from being loaded. Cu.reportError(ex); @@ -139,6 +161,9 @@ DownloadStore.prototype = { let atLeastOneDownload = false; for (let download of downloads) { try { + if (!this.onsaveitem(download)) { + continue; + } storeData.list.push(download.toSerializable()); atLeastOneDownload = true; } catch (ex) {