Bug 836443 - Automatically stop and restart downloads. r=enn
--- a/toolkit/components/jsdownloads/src/DownloadCore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm
@@ -198,17 +198,17 @@ Download.prototype = {
speed: 0,
/**
* Indicates whether, at this time, there is any partially downloaded data
* that can be used when restarting a failed or canceled download.
*
* 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.
*/
hasPartialData: false,
/**
* This can be set to a function that is called after other properties change.
@@ -377,16 +377,17 @@ Download.prototype = {
try {
// Execute the actual download through the saver object.
yield this.saver.execute(DS_setProgressBytes.bind(this),
DS_setProperties.bind(this));
// 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
// download was canceled or failed because of other reasons.
if (this._promiseCanceled) {
throw new DownloadError(Cr.NS_ERROR_FAILURE, "Download canceled.");
}
@@ -618,16 +619,57 @@ Download.prototype = {
* @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;
+ }
+
+ // 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.
*/
_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
@@ -756,30 +798,64 @@ Download.prototype = {
// 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.
let saver = this.saver.toSerializable();
if (saver !== "copy") {
serializable.saver = saver;
}
- if (this.launcherPath) {
- serializable.launcherPath = this.launcherPath;
+ if (!this.stopped) {
+ serializable.stopped = false;
+ }
+
+ if (this.error && ("message" in this.error)) {
+ serializable.error = { message: this.error.message };
}
- if (this.launchWhenSucceeded) {
- serializable.launchWhenSucceeded = true;
- }
+ // These are serialized unless they are false, null, or empty strings.
+ let propertiesToSerialize = [
+ "succeeded",
+ "canceled",
+ "startTime",
+ "totalBytes",
+ "hasPartialData",
+ "tryToKeepPartialData",
+ "launcherPath",
+ "launchWhenSucceeded",
+ "contentType",
+ ];
- if (this.contentType) {
- serializable.contentType = this.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;
+ },
};
/**
* 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.
*
@@ -811,26 +887,38 @@ Download.fromSerializable = function (aS
}
if ("saver" in aSerializable) {
download.saver = DownloadSaver.fromSerializable(aSerializable.saver);
} else {
download.saver = DownloadSaver.fromSerializable("copy");
}
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;
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadSource
@@ -1186,16 +1274,23 @@ DownloadCopySaver.prototype = {
/**
* 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,
/**
+ * 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;
@@ -1423,33 +1518,41 @@ DownloadCopySaver.prototype = {
}.bind(this));
},
/**
* Implements "DownloadSaver.toSerializable".
*/
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 };
},
};
/**
* Creates a new DownloadCopySaver object, with its initial state derived from
* its serializable representation.
*
* @param aSerializable
* Serializable representation of a DownloadCopySaver object.
*
* @return The newly created DownloadCopySaver object.
*/
DownloadCopySaver.fromSerializable = function (aSerializable) {
- // 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;
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadLegacySaver
/**
* Saver object that integrates with the legacy nsITransfer interface.
*
@@ -1570,16 +1673,23 @@ DownloadLegacySaver.prototype = {
/**
* 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)
{
// 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) {
@@ -1669,17 +1779,17 @@ DownloadLegacySaver.prototype = {
* 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 "copy";
+ return DownloadCopySaver.prototype.toSerializable.call(this);
},
};
/**
* 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.
*/
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -59,16 +59,41 @@ XPCOMUtils.defineLazyGetter(this, "gPare
return null;
});
XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() {
return Services.strings.
createBundle("chrome://mozapps/locale/downloads/downloads.properties");
});
+const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer",
+ "initWithCallback");
+
+/**
+ * Indicates the delay between a change to the downloads data and the related
+ * save operation. 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
/**
* Provides functions to integrate with the host application, handling for
* example the global prompts on shutdown.
*/
this.DownloadIntegration = {
@@ -100,17 +125,17 @@ this.DownloadIntegration = {
/**
* 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 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.
* @rejects JavaScript exception.
*/
loadPersistent: function DI_loadPersistent(aList)
{
if (this.dontLoad) {
@@ -119,17 +144,49 @@ this.DownloadIntegration = {
if (this._store) {
throw new Error("loadPersistent may be called only once.");
}
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;
},
/**
* Returns the system downloads directory asynchronously.
*
* @return {Promise}
* @resolves The nsIFile of downloads directory.
*/
@@ -488,28 +545,35 @@ this.DownloadIntegration = {
* 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: function DI_addListObservers(aList, aIsPrivate) {
+ if (this.dontLoad) {
+ return Promise.resolve();
+ }
+
DownloadObserver.registerView(aList, aIsPrivate);
if (!DownloadObserver.observersAdded) {
DownloadObserver.observersAdded = true;
Services.obs.addObserver(DownloadObserver, "quit-application-requested", true);
Services.obs.addObserver(DownloadObserver, "offline-requested", true);
Services.obs.addObserver(DownloadObserver, "last-pb-context-exiting", true);
}
return Promise.resolve();
}
};
-let DownloadObserver = {
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadObserver
+
+this.DownloadObserver = {
/**
* Flag to determine if the observers have been added previously.
*/
observersAdded: false,
/**
* Set that contains the in progress publics downloads.
* It's keep updated when a public download is added, removed or change its
@@ -652,8 +716,139 @@ let DownloadObserver = {
},
////////////////////////////////////////////////////////////////////////////
//// nsISupports
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();
+ }
+ },
+};
--- a/toolkit/components/jsdownloads/src/DownloadList.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadList.jsm
@@ -166,16 +166,21 @@ DownloadList.prototype = {
* },
* onDownloadChanged: function (aDownload) {
* // Called after the properties of aDownload change.
* },
* onDownloadRemoved: function (aDownload) {
* // 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)
{
this._views.add(aView);
if (aView.onDownloadAdded) {
for (let download of this._downloads) {
try {
--- a/toolkit/components/jsdownloads/src/DownloadStore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadStore.jsm
@@ -84,16 +84,22 @@ DownloadStore.prototype = {
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()
{
@@ -106,17 +112,33 @@ DownloadStore.prototype = {
return;
}
let storeData = JSON.parse(gTextDecoder.decode(bytes));
// 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);
}
}
}.bind(this));
},
@@ -134,16 +156,19 @@ DownloadStore.prototype = {
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;
+ }
storeData.list.push(download.toSerializable());
atLeastOneDownload = true;
} catch (ex) {
// If an item cannot be converted to a serializable form, don't
// prevent others from being saved.
Cu.reportError(ex);
}
}