diff options
Diffstat (limited to 'toolkit/components/downloads/DownloadList.sys.mjs')
-rw-r--r-- | toolkit/components/downloads/DownloadList.sys.mjs | 667 |
1 files changed, 667 insertions, 0 deletions
diff --git a/toolkit/components/downloads/DownloadList.sys.mjs b/toolkit/components/downloads/DownloadList.sys.mjs new file mode 100644 index 0000000000..695b611dc8 --- /dev/null +++ b/toolkit/components/downloads/DownloadList.sys.mjs @@ -0,0 +1,667 @@ +/* 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(download) { + this._onListChanged(); + } + + // DownloadList callback + onDownloadRemoved(download) { + let index = this._downloads.indexOf(download); + if (index != -1) { + this._downloads.splice(index, 1); + } + this._onListChanged(); + } +} |