Source code

Revision control

Copy as Markdown

Other Tools

/* 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 collections of Download objects and aggregate views on them.
*/
const FILE_EXTENSIONS = [
"aac",
"adt",
"adts",
"accdb",
"accde",
"accdr",
"accdt",
"aif",
"aifc",
"aiff",
"apng",
"aspx",
"avi",
"avif",
"bat",
"bin",
"bmp",
"cab",
"cda",
"csv",
"dif",
"dll",
"doc",
"docm",
"docx",
"dot",
"dotx",
"eml",
"eps",
"exe",
"flac",
"flv",
"gif",
"htm",
"html",
"ico",
"ini",
"iso",
"jar",
"jfif",
"jpg",
"jpeg",
"json",
"m4a",
"mdb",
"mid",
"midi",
"mov",
"mp3",
"mp4",
"mpeg",
"mpg",
"msi",
"mui",
"oga",
"ogg",
"ogv",
"opus",
"pdf",
"pjpeg",
"pjp",
"png",
"pot",
"potm",
"potx",
"ppam",
"pps",
"ppsm",
"ppsx",
"ppt",
"pptm",
"pptx",
"psd",
"pst",
"pub",
"rar",
"rdf",
"rtf",
"shtml",
"sldm",
"sldx",
"svg",
"swf",
"sys",
"tif",
"tiff",
"tmp",
"txt",
"vob",
"vsd",
"vsdm",
"vsdx",
"vss",
"vssm",
"vst",
"vstm",
"vstx",
"wav",
"wbk",
"webm",
"webp",
"wks",
"wma",
"wmd",
"wmv",
"wmz",
"wms",
"wpd",
"wp5",
"xht",
"xhtml",
"xla",
"xlam",
"xll",
"xlm",
"xls",
"xlsm",
"xlsx",
"xlt",
"xltm",
"xltx",
"xml",
"zip",
];
const TELEMETRY_EVENT_CATEGORY = "downloads";
/**
* Represents a collection of Download objects that can be viewed and managed by
* the user interface, and persisted across sessions.
*/
export class DownloadList {
constructor() {
/**
* Array of Download objects currently in the list.
*/
this._downloads = [];
/**
* Set of currently registered views.
*/
this._views = new Set();
}
/**
* 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.
*/
async getAll() {
return Array.from(this._downloads);
}
/**
* 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 download
* The Download object to add.
*
* @return {Promise}
* @resolves When the download has been added.
* @rejects JavaScript exception.
*/
async add(download) {
this._downloads.push(download);
download.onchange = this._change.bind(this, download);
this._notifyAllViews("onDownloadAdded", download);
}
/**
* 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 download
* The Download object to remove.
*
* @return {Promise}
* @resolves When the download has been removed.
* @rejects JavaScript exception.
*/
async remove(download) {
let index = this._downloads.indexOf(download);
if (index != -1) {
this._downloads.splice(index, 1);
download.onchange = null;
this._notifyAllViews("onDownloadRemoved", download);
}
}
/**
* This function is called when "onchange" events of downloads occur.
*
* @param download
* The Download object that changed.
*/
_change(download) {
this._notifyAllViews("onDownloadChanged", download);
}
/**
* 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 view
* The view object to add. The following methods may be defined:
* {
* onDownloadAdded: function (download) {
* // Called after download is added to the end of the list.
* },
* onDownloadChanged: function (download) {
* // Called after the properties of download change.
* },
* onDownloadRemoved: function (download) {
* // Called after download is removed from the list.
* },
* onDownloadBatchStarting: function () {
* // Called before multiple changes are made at the same time.
* },
* onDownloadBatchEnded: function () {
* // Called after all the changes have been made.
* },
* }
*
* @return {Promise}
* @resolves When the view has been registered and all the onDownloadAdded
* notifications for the existing downloads have been sent.
* @rejects JavaScript exception.
*/
async addView(view) {
this._views.add(view);
if ("onDownloadAdded" in view) {
this._notifyAllViews("onDownloadBatchStarting");
for (let download of this._downloads) {
try {
view.onDownloadAdded(download);
} catch (ex) {
console.error(ex);
}
}
this._notifyAllViews("onDownloadBatchEnded");
}
}
/**
* Removes a view that was previously added using addView.
*
* @param view
* 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.
*/
async removeView(view) {
this._views.delete(view);
}
/**
* Notifies all the views of a download addition, change, removal, or other
* event. The additional arguments are passed to the called method.
*
* @param methodName
* String containing the name of the method to call on the view.
*/
_notifyAllViews(methodName, ...args) {
for (let view of this._views) {
try {
if (methodName in view) {
view[methodName](...args);
}
} catch (ex) {
console.error(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 filterFn
* 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(filterFn) {
(async () => {
let list = await 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) &&
(!filterFn || filterFn(download))
) {
// Remove the download first, so that the views don't get the change
// notifications that may occur during finalization.
await this.remove(download);
// Find if a file with the same path is also downloading.
let sameFileIsDownloading = false;
for (let otherDownload of await this.getAll()) {
if (
download !== otherDownload &&
download.target.path == otherDownload.target.path &&
!otherDownload.error
) {
sameFileIsDownloading = true;
}
}
// 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.
let removePartialData = !sameFileIsDownloading;
download.finalize(removePartialData).catch(console.error);
}
}
})().catch(console.error);
}
}
/**
* 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 publicList
* Underlying DownloadList containing public downloads.
* @param privateList
* Underlying DownloadList containing private downloads.
*/
export class DownloadCombinedList extends DownloadList {
constructor(publicList, privateList) {
super();
/**
* Underlying DownloadList containing public downloads.
*/
this._publicList = publicList;
/**
* Underlying DownloadList containing private downloads.
*/
this._privateList = privateList;
publicList.addView(this).catch(console.error);
privateList.addView(this).catch(console.error);
}
/**
* 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 download
* The Download object to add.
*
* @return {Promise}
* @resolves When the download has been added.
* @rejects JavaScript exception.
*/
add(download) {
let extension = download.target.path.split(".").pop();
if (!FILE_EXTENSIONS.includes(extension)) {
extension = "other";
}
try {
Services.telemetry.recordEvent(
TELEMETRY_EVENT_CATEGORY,
"added",
"fileExtension",
extension,
{}
);
} catch (ex) {
console.error(
"DownloadsCommon: error recording telemetry event.",
ex.message
);
}
if (download.source.isPrivate) {
return this._privateList.add(download);
}
return this._publicList.add(download);
}
/**
* 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 download
* The Download object to remove.
*
* @return {Promise}
* @resolves When the download has been removed.
* @rejects JavaScript exception.
*/
remove(download) {
if (download.source.isPrivate) {
return this._privateList.remove(download);
}
return this._publicList.remove(download);
}
// DownloadList callback
onDownloadAdded(download) {
this._downloads.push(download);
this._notifyAllViews("onDownloadAdded", download);
}
// DownloadList callback
onDownloadChanged(download) {
this._notifyAllViews("onDownloadChanged", download);
}
// DownloadList callback
onDownloadRemoved(download) {
let index = this._downloads.indexOf(download);
if (index != -1) {
this._downloads.splice(index, 1);
}
this._notifyAllViews("onDownloadRemoved", download);
}
}
/**
* Provides an aggregated view on the contents of a DownloadList.
*/
export class DownloadSummary {
constructor() {
/**
* Array of Download objects that are currently part of the summary.
*/
this._downloads = [];
/**
* Set of currently registered views.
*/
this._views = new Set();
}
/**
* Underlying DownloadList whose contents should be summarized.
*/
_list = null;
/**
* Indicates whether all the downloads are currently stopped.
*/
allHaveStopped = true;
/**
* Indicates whether whether all downloads have an unknown final size.
*/
allUnknownSize = 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 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 list
* Underlying DownloadList whose contents should be summarized.
*
* @return {Promise}
* @resolves When the view on the underlying list has been registered.
* @rejects JavaScript exception.
*/
async bindToList(list) {
if (this._list) {
throw new Error("bindToList may be called only once.");
}
await list.addView(this);
// 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 = list;
this._onListChanged();
}
/**
* Adds a view that will be notified of changes to the summary. The newly
* added view will receive an initial onSummaryChanged notification.
*
* @param view
* 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.
*/
async addView(view) {
this._views.add(view);
if ("onSummaryChanged" in view) {
try {
view.onSummaryChanged();
} catch (ex) {
console.error(ex);
}
}
}
/**
* Removes a view that was previously added using addView.
*
* @param view
* 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.
*/
async removeView(view) {
this._views.delete(view);
}
/**
* 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() {
let allHaveStopped = true;
let allUnknownSize = 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;
if (download.hasProgress) {
allUnknownSize = false;
progressTotalBytes += download.totalBytes;
} else {
progressTotalBytes += download.currentBytes;
}
progressCurrentBytes += download.currentBytes;
}
}
// Exit now if the properties did not change.
if (
this.allHaveStopped == allHaveStopped &&
this.allUnknownSize == allUnknownSize &&
this.progressTotalBytes == progressTotalBytes &&
this.progressCurrentBytes == progressCurrentBytes
) {
return;
}
this.allHaveStopped = allHaveStopped;
this.allUnknownSize = allUnknownSize;
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) {
console.error(ex);
}
}
}
// DownloadList callback
onDownloadAdded(download) {
this._downloads.push(download);
if (this._list) {
this._onListChanged();
}
}
// DownloadList callback
onDownloadChanged() {
this._onListChanged();
}
// DownloadList callback
onDownloadRemoved(download) {
let index = this._downloads.indexOf(download);
if (index != -1) {
this._downloads.splice(index, 1);
}
this._onListChanged();
}
}