diff options
Diffstat (limited to 'browser/components/downloads/DownloadsCommon.sys.mjs')
-rw-r--r-- | browser/components/downloads/DownloadsCommon.sys.mjs | 1667 |
1 files changed, 1667 insertions, 0 deletions
diff --git a/browser/components/downloads/DownloadsCommon.sys.mjs b/browser/components/downloads/DownloadsCommon.sys.mjs new file mode 100644 index 0000000000..b687a8907a --- /dev/null +++ b/browser/components/downloads/DownloadsCommon.sys.mjs @@ -0,0 +1,1667 @@ +/* -*- 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 the Downloads panel shared methods and data access. + * + * This file includes the following constructors and global objects: + * + * DownloadsCommon + * This object is exposed directly to the consumers of this JavaScript module, + * and provides shared methods for all the instances of the user interface. + * + * DownloadsData + * Retrieves the list of past and completed downloads from the underlying + * Downloads API data, and provides asynchronous notifications allowing + * to build a consistent view of the available data. + * + * DownloadsIndicatorData + * This object registers itself with DownloadsData as a view, and transforms the + * notifications it receives into overall status data, that is then broadcast to + * the registered download status indicators. + */ + +// Globals + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadHistory: "resource://gre/modules/DownloadHistory.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + DownloadUtils: "resource://gre/modules/DownloadUtils.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gClipboardHelper: [ + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper", + ], + gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"], +}); + +XPCOMUtils.defineLazyGetter(lazy, "DownloadsLogger", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevelPref: "browser.download.loglevel", + prefix: "Downloads", + }; + return new ConsoleAPI(consoleOptions); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gAlwaysOpenPanel", + "browser.download.alwaysOpenPanel", + true +); + +const kDownloadsStringBundleUrl = + "chrome://browser/locale/downloads/downloads.properties"; + +const kDownloadsFluentStrings = new Localization( + ["browser/downloads.ftl"], + true +); + +const kDownloadsStringsRequiringFormatting = { + sizeWithUnits: true, + statusSeparator: true, + statusSeparatorBeforeNumber: true, +}; + +const kMaxHistoryResultsForLimitedView = 42; + +const kPrefBranch = Services.prefs.getBranch("browser.download."); + +const kGenericContentTypes = [ + "application/octet-stream", + "binary/octet-stream", + "application/unknown", +]; + +var PrefObserver = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + getPref(name) { + try { + switch (typeof this.prefs[name]) { + case "boolean": + return kPrefBranch.getBoolPref(name); + } + } catch (ex) {} + return this.prefs[name]; + }, + observe(aSubject, aTopic, aData) { + if (this.prefs.hasOwnProperty(aData)) { + delete this[aData]; + this[aData] = this.getPref(aData); + } + }, + register(prefs) { + this.prefs = prefs; + kPrefBranch.addObserver("", this, true); + for (let key in prefs) { + let name = key; + XPCOMUtils.defineLazyGetter(this, name, function() { + return PrefObserver.getPref(name); + }); + } + }, +}; + +PrefObserver.register({ + // prefName: defaultValue + animateNotifications: true, + openInSystemViewerContextMenuItem: true, + alwaysOpenInSystemViewerContextMenuItem: true, +}); + +// DownloadsCommon + +/** + * This object is exposed directly to the consumers of this JavaScript module, + * and provides shared methods for all the instances of the user interface. + */ +export var DownloadsCommon = { + // The following legacy constants are still returned by stateOfDownload, but + // individual properties of the Download object should normally be used. + DOWNLOAD_NOTSTARTED: -1, + DOWNLOAD_DOWNLOADING: 0, + DOWNLOAD_FINISHED: 1, + DOWNLOAD_FAILED: 2, + DOWNLOAD_CANCELED: 3, + DOWNLOAD_PAUSED: 4, + DOWNLOAD_BLOCKED_PARENTAL: 6, + DOWNLOAD_DIRTY: 8, + DOWNLOAD_BLOCKED_POLICY: 9, + + // The following are the possible values of the "attention" property. + ATTENTION_NONE: "", + ATTENTION_SUCCESS: "success", + ATTENTION_INFO: "info", + ATTENTION_WARNING: "warning", + ATTENTION_SEVERE: "severe", + + // Bit flags for the attentionSuppressed property. + SUPPRESS_NONE: 0, + SUPPRESS_PANEL_OPEN: 1, + SUPPRESS_ALL_DOWNLOADS_OPEN: 2, + SUPPRESS_CONTENT_AREA_DOWNLOADS_OPEN: 4, + + /** + * 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. + */ + get strings() { + let strings = {}; + let sb = Services.strings.createBundle(kDownloadsStringBundleUrl); + for (let string of sb.getSimpleEnumeration()) { + let stringName = string.key; + if (stringName in kDownloadsStringsRequiringFormatting) { + strings[stringName] = function() { + // Convert "arguments" to a real array before calling into XPCOM. + return sb.formatStringFromName(stringName, Array.from(arguments)); + }; + } else { + strings[stringName] = string.value; + } + } + delete this.strings; + return (this.strings = strings); + }, + + /** + * Indicates whether we should show visual notification on the indicator + * when a download event is triggered. + */ + get animateNotifications() { + return PrefObserver.animateNotifications; + }, + + /** + * Indicates whether or not to show the 'Open in system viewer' context menu item when appropriate + */ + get openInSystemViewerItemEnabled() { + return PrefObserver.openInSystemViewerContextMenuItem; + }, + + /** + * Indicates whether or not to show the 'Always open...' context menu item when appropriate + */ + get alwaysOpenInSystemViewerItemEnabled() { + return PrefObserver.alwaysOpenInSystemViewerContextMenuItem; + }, + + /** + * Get access to one of the DownloadsData, PrivateDownloadsData, or + * HistoryDownloadsData objects, depending on the privacy status of the + * specified window and on whether history downloads should be included. + * + * @param [optional] window + * The browser window which owns the download button. + * If not given, the privacy status will be assumed as non-private. + * @param [optional] history + * True to include history downloads when the window is public. + * @param [optional] privateAll + * Whether to force the public downloads data to be returned together + * with the private downloads data for a private window. + * @param [optional] limited + * True to limit the amount of downloads returned to + * `kMaxHistoryResultsForLimitedView`. + */ + getData(window, history = false, privateAll = false, limited = false) { + let isPrivate = + window && lazy.PrivateBrowsingUtils.isContentWindowPrivate(window); + if (isPrivate && !privateAll) { + return lazy.PrivateDownloadsData; + } + if (history) { + if (isPrivate && privateAll) { + return lazy.LimitedPrivateHistoryDownloadData; + } + return limited + ? lazy.LimitedHistoryDownloadsData + : lazy.HistoryDownloadsData; + } + return lazy.DownloadsData; + }, + + /** + * Initializes the Downloads back-end and starts receiving events for both the + * private and non-private downloads data objects. + */ + initializeAllDataLinks() { + lazy.DownloadsData.initializeDataLink(); + lazy.PrivateDownloadsData.initializeDataLink(); + }, + + /** + * Get access to one of the DownloadsIndicatorData or + * PrivateDownloadsIndicatorData objects, depending on the privacy status of + * the window in question. + */ + getIndicatorData(aWindow) { + if (lazy.PrivateBrowsingUtils.isContentWindowPrivate(aWindow)) { + return lazy.PrivateDownloadsIndicatorData; + } + return lazy.DownloadsIndicatorData; + }, + + /** + * Returns a reference to the DownloadsSummaryData singleton - creating one + * in the process if one hasn't been instantiated yet. + * + * @param aWindow + * The browser window which owns the download button. + * @param aNumToExclude + * The number of items on the top of the downloads list to exclude + * from the summary. + */ + getSummary(aWindow, aNumToExclude) { + if (lazy.PrivateBrowsingUtils.isContentWindowPrivate(aWindow)) { + if (this._privateSummary) { + return this._privateSummary; + } + return (this._privateSummary = new DownloadsSummaryData( + true, + aNumToExclude + )); + } + if (this._summary) { + return this._summary; + } + return (this._summary = new DownloadsSummaryData(false, aNumToExclude)); + }, + _summary: null, + _privateSummary: null, + + /** + * Returns the legacy state integer value for the provided Download object. + */ + stateOfDownload(download) { + // Collapse state using the correct priority. + if (!download.stopped) { + return DownloadsCommon.DOWNLOAD_DOWNLOADING; + } + if (download.succeeded) { + return DownloadsCommon.DOWNLOAD_FINISHED; + } + if (download.error) { + if (download.error.becauseBlockedByParentalControls) { + return DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL; + } + if (download.error.becauseBlockedByReputationCheck) { + return DownloadsCommon.DOWNLOAD_DIRTY; + } + return DownloadsCommon.DOWNLOAD_FAILED; + } + if (download.canceled) { + if (download.hasPartialData) { + return DownloadsCommon.DOWNLOAD_PAUSED; + } + return DownloadsCommon.DOWNLOAD_CANCELED; + } + return DownloadsCommon.DOWNLOAD_NOTSTARTED; + }, + + /** + * Removes a Download object from both session and history downloads. + */ + async deleteDownload(download) { + // Check hasBlockedData to avoid double counting if you click the X button + // in the Libarary view and then delete the download from the history. + if ( + download.error?.becauseBlockedByReputationCheck && + download.hasBlockedData + ) { + Services.telemetry + .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD") + .add(download.error.reputationCheckVerdict, 1); // confirm block + } + + // Remove the associated history element first, if any, so that the views + // that combine history and session downloads won't resurrect the history + // download into the view just before it is deleted permanently. + try { + await lazy.PlacesUtils.history.remove(download.source.url); + } catch (ex) { + console.error(ex); + } + let list = await lazy.Downloads.getList(lazy.Downloads.ALL); + await list.remove(download); + await download.finalize(true); + }, + + /** + * Deletes all files associated with a download, with or without removing it + * from the session downloads list and/or download history. + * + * @param download + * The download to delete and/or forget. + * @param clearHistory + * Optional. Removes history from session downloads list or history. + * 0 - Don't remove the download from session list or history. + * 1 - Remove the download from session list, but not history. + * 2 - Remove the download from both session list and history. + */ + async deleteDownloadFiles(download, clearHistory = 0) { + if (clearHistory > 1) { + try { + await lazy.PlacesUtils.history.remove(download.source.url); + } catch (ex) { + console.error(ex); + } + } + if (clearHistory > 0) { + let list = await lazy.Downloads.getList(lazy.Downloads.ALL); + await list.remove(download); + } + await download.manuallyRemoveData(); + if (clearHistory < 2) { + lazy.DownloadHistory.updateMetaData(download).catch(Cu.reportError); + } + }, + + /** + * Get a nsIMIMEInfo object for a download + */ + getMimeInfo(download) { + if (!download.succeeded) { + return null; + } + let contentType = download.contentType; + let url = Cc["@mozilla.org/network/standard-url-mutator;1"] + .createInstance(Ci.nsIURIMutator) + .setSpec("http://example.com") // construct the URL + .setFilePath(download.target.path) + .finalize() + .QueryInterface(Ci.nsIURL); + let fileExtension = url.fileExtension; + + // look at file extension if there's no contentType or it is generic + if (!contentType || kGenericContentTypes.includes(contentType)) { + try { + contentType = lazy.gMIMEService.getTypeFromExtension(fileExtension); + } catch (ex) { + DownloadsCommon.log( + "Cant get mimeType from file extension: ", + fileExtension + ); + } + } + if (!(contentType || fileExtension)) { + return null; + } + let mimeInfo = null; + try { + mimeInfo = lazy.gMIMEService.getFromTypeAndExtension( + contentType || "", + fileExtension || "" + ); + } catch (ex) { + DownloadsCommon.log( + "Can't get nsIMIMEInfo for contentType: ", + contentType, + "and fileExtension:", + fileExtension + ); + } + return mimeInfo; + }, + + /** + * Confirm if the download exists on the filesystem and is a given mime-type + */ + isFileOfType(download, mimeType) { + if (!(download.succeeded && download.target?.exists)) { + DownloadsCommon.log( + `isFileOfType returning false for mimeType: ${mimeType}, succeeded: ${download.succeeded}, exists: ${download.target?.exists}` + ); + return false; + } + let mimeInfo = DownloadsCommon.getMimeInfo(download); + return mimeInfo?.type === mimeType.toLowerCase(); + }, + + /** + * Copies the source URI of the given Download object to the clipboard. + */ + copyDownloadLink(download) { + lazy.gClipboardHelper.copyString( + download.source.originalUrl || download.source.url + ); + }, + + /** + * Given an iterable collection of Download objects, generates and returns + * statistics about that collection. + * + * @param downloads An iterable collection of Download objects. + * + * @return Object whose properties are the generated statistics. Currently, + * we return the following properties: + * + * numActive : The total number of downloads. + * numPaused : The total number of paused downloads. + * numDownloading : The total number of downloads being downloaded. + * totalSize : The total size of all downloads once completed. + * totalTransferred: The total amount of transferred data for these + * downloads. + * slowestSpeed : The slowest download rate. + * rawTimeLeft : The estimated time left for the downloads to + * complete. + * percentComplete : The percentage of bytes successfully downloaded. + */ + summarizeDownloads(downloads) { + let summary = { + numActive: 0, + numPaused: 0, + numDownloading: 0, + totalSize: 0, + totalTransferred: 0, + // slowestSpeed is Infinity so that we can use Math.min to + // find the slowest speed. We'll set this to 0 afterwards if + // it's still at Infinity by the time we're done iterating all + // download. + slowestSpeed: Infinity, + rawTimeLeft: -1, + percentComplete: -1, + }; + + for (let download of downloads) { + summary.numActive++; + + if (!download.stopped) { + summary.numDownloading++; + if (download.hasProgress && download.speed > 0) { + let sizeLeft = download.totalBytes - download.currentBytes; + summary.rawTimeLeft = Math.max( + summary.rawTimeLeft, + sizeLeft / download.speed + ); + summary.slowestSpeed = Math.min(summary.slowestSpeed, download.speed); + } + } else if (download.canceled && download.hasPartialData) { + summary.numPaused++; + } + + // Only add to total values if we actually know the download size. + if (download.succeeded) { + summary.totalSize += download.target.size; + summary.totalTransferred += download.target.size; + } else if (download.hasProgress) { + summary.totalSize += download.totalBytes; + summary.totalTransferred += download.currentBytes; + } + } + + if (summary.totalSize != 0) { + summary.percentComplete = Math.floor( + (summary.totalTransferred / summary.totalSize) * 100 + ); + } + + if (summary.slowestSpeed == Infinity) { + summary.slowestSpeed = 0; + } + + return summary; + }, + + /** + * If necessary, smooths the estimated number of seconds remaining for one + * or more downloads to complete. + * + * @param aSeconds + * Current raw estimate on number of seconds left for one or more + * downloads. This is a floating point value to help get sub-second + * accuracy for current and future estimates. + */ + smoothSeconds(aSeconds, aLastSeconds) { + // We apply an algorithm similar to the DownloadUtils.getTimeLeft function, + // though tailored to a single time estimation for all downloads. We never + // apply something if the new value is less than half the previous value. + let shouldApplySmoothing = aLastSeconds >= 0 && aSeconds > aLastSeconds / 2; + if (shouldApplySmoothing) { + // Apply hysteresis to favor downward over upward swings. Trust only 30% + // of the new value if lower, and 10% if higher (exponential smoothing). + let diff = aSeconds - aLastSeconds; + aSeconds = aLastSeconds + (diff < 0 ? 0.3 : 0.1) * diff; + + // If the new time is similar, reuse something close to the last time + // left, but subtract a little to provide forward progress. + diff = aSeconds - aLastSeconds; + let diffPercent = (diff / aLastSeconds) * 100; + if (Math.abs(diff) < 5 || Math.abs(diffPercent) < 5) { + aSeconds = aLastSeconds - (diff < 0 ? 0.4 : 0.2); + } + } + + // In the last few seconds of downloading, we are always subtracting and + // never adding to the time left. Ensure that we never fall below one + // second left until all downloads are actually finished. + return (aLastSeconds = Math.max(aSeconds, 1)); + }, + + /** + * Opens a downloaded file. + * + * @param downloadProperties + * A Download object or the initial properties of a serialized download + * @param options.openWhere + * Optional string indicating how to handle opening a download target file URI. + * One of "window", "tab", "tabshifted". + * @param options.useSystemDefault + * Optional value indicating how to handle launching this download, + * this call only. Will override the associated mimeInfo.preferredAction + * @return {Promise} + * @resolves When the instruction to launch the file has been + * successfully given to the operating system or handled internally + * @rejects JavaScript exception if there was an error trying to launch + * the file. + */ + async openDownload(download, options) { + // some download objects got serialized and need reconstituting + if (typeof download.launch !== "function") { + download = await lazy.Downloads.createDownload(download); + } + return download.launch(options).catch(ex => console.error(ex)); + }, + + /** + * Show a downloaded file in the system file manager. + * + * @param aFile + * a downloaded file. + */ + showDownloadedFile(aFile) { + if (!(aFile instanceof Ci.nsIFile)) { + throw new Error("aFile must be a nsIFile object"); + } + try { + // Show the directory containing the file and select the file. + aFile.reveal(); + } 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 = aFile.parent; + if (parent) { + this.showDirectory(parent); + } + } + }, + + /** + * Show the specified folder in the system file manager. + * + * @param aDirectory + * a directory to be opened with system file manager. + */ + showDirectory(aDirectory) { + if (!(aDirectory instanceof Ci.nsIFile)) { + throw new Error("aDirectory must be a nsIFile object"); + } + try { + aDirectory.launch(); + } catch (ex) { + // If launch fails (probably because it's not implemented), let + // the OS handler try to open the directory. + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI( + lazy.NetUtil.newURI(aDirectory), + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + }, + + /** + * Displays an alert message box which asks the user if they want to + * unblock the downloaded file or not. + * + * @param options + * An object with the following properties: + * { + * verdict: + * The detailed reason why the download was blocked, according to + * the "Downloads.Error.BLOCK_VERDICT_" constants. If an unknown + * reason is specified, "Downloads.Error.BLOCK_VERDICT_MALWARE" is + * assumed. + * window: + * The window with which this action is associated. + * dialogType: + * String that determines which actions are available: + * - "unblock" to offer just "unblock". + * - "chooseUnblock" to offer "unblock" and "confirmBlock". + * - "chooseOpen" to offer "open" and "confirmBlock". + * } + * + * @return {Promise} + * @resolves String representing the action that should be executed: + * - "open" to allow the download and open the file. + * - "unblock" to allow the download without opening the file. + * - "confirmBlock" to delete the blocked data permanently. + * - "cancel" to do nothing and cancel the operation. + */ + async confirmUnblockDownload({ verdict, window, dialogType }) { + let s = DownloadsCommon.strings; + + // All the dialogs have an action button and a cancel button, while only + // some of them have an additonal button to remove the file. The cancel + // button must always be the one at BUTTON_POS_1 because this is the value + // returned by confirmEx when using ESC or closing the dialog (bug 345067). + let title = s.unblockHeaderUnblock; + let firstButtonText = s.unblockButtonUnblock; + let firstButtonAction = "unblock"; + let buttonFlags = + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 + + Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1; + + switch (dialogType) { + case "unblock": + // Use only the unblock action. The default is to cancel. + buttonFlags += Ci.nsIPrompt.BUTTON_POS_1_DEFAULT; + break; + case "chooseUnblock": + // Use the unblock and remove file actions. The default is remove file. + buttonFlags += + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2 + + Ci.nsIPrompt.BUTTON_POS_2_DEFAULT; + break; + case "chooseOpen": + // Use the unblock and open file actions. The default is open file. + title = s.unblockHeaderOpen; + firstButtonText = s.unblockButtonOpen; + firstButtonAction = "open"; + buttonFlags += + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2 + + Ci.nsIPrompt.BUTTON_POS_0_DEFAULT; + break; + default: + console.error("Unexpected dialog type: " + dialogType); + return "cancel"; + } + + let message; + switch (verdict) { + case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: + message = s.unblockTypeUncommon2; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + message = s.unblockTypePotentiallyUnwanted2; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: + message = s.unblockInsecure2; + break; + default: + // Assume Downloads.Error.BLOCK_VERDICT_MALWARE + message = s.unblockTypeMalware; + break; + } + message += "\n\n" + s.unblockTip2; + + Services.ww.registerNotification(function onOpen(subj, topic) { + if (topic == "domwindowopened" && subj instanceof Ci.nsIDOMWindow) { + // Make sure to listen for "DOMContentLoaded" because it is fired + // before the "load" event. + subj.addEventListener( + "DOMContentLoaded", + function() { + if ( + subj.document.documentURI == + "chrome://global/content/commonDialog.xhtml" + ) { + Services.ww.unregisterNotification(onOpen); + let dialog = subj.document.getElementById("commonDialog"); + if (dialog) { + // Change the dialog to use a warning icon. + dialog.classList.add("alert-dialog"); + } + } + }, + { once: true } + ); + } + }); + + let rv = Services.prompt.confirmEx( + window, + title, + message, + buttonFlags, + firstButtonText, + null, + s.unblockButtonConfirmBlock, + null, + {} + ); + return [firstButtonAction, "cancel", "confirmBlock"][rv]; + }, +}; + +XPCOMUtils.defineLazyGetter(DownloadsCommon, "log", () => { + return lazy.DownloadsLogger.log.bind(lazy.DownloadsLogger); +}); +XPCOMUtils.defineLazyGetter(DownloadsCommon, "error", () => { + return lazy.DownloadsLogger.error.bind(lazy.DownloadsLogger); +}); + +/** + * Returns true if we are executing on Windows Vista or a later version. + */ +XPCOMUtils.defineLazyGetter(DownloadsCommon, "isWinVistaOrHigher", function() { + let os = Services.appinfo.OS; + if (os != "WINNT") { + return false; + } + return parseFloat(Services.sysinfo.getProperty("version")) >= 6; +}); + +// DownloadsData + +/** + * Retrieves the list of past and completed downloads from the underlying + * Downloads API data, and provides asynchronous notifications allowing to + * build a consistent view of the available data. + * + * Note that using this object does not automatically initialize the list of + * downloads. This is useful to display a neutral progress indicator in + * the main browser window until the autostart timeout elapses. + * + * This powers the DownloadsData, PrivateDownloadsData, and HistoryDownloadsData + * singleton objects. + */ +function DownloadsDataCtor({ isPrivate, isHistory, maxHistoryResults } = {}) { + this._isPrivate = !!isPrivate; + + // Contains all the available Download objects and their integer state. + this._oldDownloadStates = new WeakMap(); + + // For the history downloads list we don't need to register this as a view, + // but we have to ensure that the DownloadsData object is initialized before + // we register more views. This ensures that the view methods of DownloadsData + // are invoked before those of views registered on HistoryDownloadsData, + // allowing the endTime property to be set correctly. + if (isHistory) { + if (isPrivate) { + lazy.PrivateDownloadsData.initializeDataLink(); + } + lazy.DownloadsData.initializeDataLink(); + this._promiseList = lazy.DownloadsData._promiseList.then(() => { + // For history downloads in Private Browsing mode, we'll fetch the combined + // list of public and private downloads. + return lazy.DownloadHistory.getList({ + type: isPrivate ? lazy.Downloads.ALL : lazy.Downloads.PUBLIC, + maxHistoryResults, + }); + }); + return; + } + + // This defines "initializeDataLink" and "_promiseList" synchronously, then + // continues execution only when "initializeDataLink" is called, allowing the + // underlying data to be loaded only when actually needed. + this._promiseList = (async () => { + await new Promise(resolve => (this.initializeDataLink = resolve)); + let list = await lazy.Downloads.getList( + isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC + ); + await list.addView(this); + return list; + })(); +} + +DownloadsDataCtor.prototype = { + /** + * Starts receiving events for current downloads. + */ + initializeDataLink() {}, + + /** + * Promise resolved with the underlying DownloadList object once we started + * receiving events for current downloads. + */ + _promiseList: null, + + /** + * Iterator for all the available Download objects. This is empty until the + * data has been loaded using the JavaScript API for downloads. + */ + get _downloads() { + return ChromeUtils.nondeterministicGetWeakMapKeys(this._oldDownloadStates); + }, + + /** + * True if there are finished downloads that can be removed from the list. + */ + get canRemoveFinished() { + for (let download of this._downloads) { + // Stopped, paused, and failed downloads with partial data are removed. + if (download.stopped && !(download.canceled && download.hasPartialData)) { + return true; + } + } + return false; + }, + + /** + * Asks the back-end to remove finished downloads from the list. This method + * is only called after the data link has been initialized. + */ + removeFinished() { + lazy.Downloads.getList( + this._isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC + ) + .then(list => list.removeFinished()) + .catch(Cu.reportError); + }, + + // Integration with the asynchronous Downloads back-end + + onDownloadAdded(download) { + // Download objects do not store the end time of downloads, as the Downloads + // API does not need to persist this information for all platforms. Once a + // download terminates on a Desktop browser, it becomes a history download, + // for which the end time is stored differently, as a Places annotation. + download.endTime = Date.now(); + + this._oldDownloadStates.set( + download, + DownloadsCommon.stateOfDownload(download) + ); + if (download.error?.becauseBlockedByReputationCheck) { + this._notifyDownloadEvent("error"); + } + }, + + onDownloadChanged(download) { + let oldState = this._oldDownloadStates.get(download); + let newState = DownloadsCommon.stateOfDownload(download); + this._oldDownloadStates.set(download, newState); + + if (oldState != newState) { + if ( + download.succeeded || + (download.canceled && !download.hasPartialData) || + download.error + ) { + // Store the end time that may be displayed by the views. + download.endTime = Date.now(); + + // This state transition code should actually be located in a Downloads + // API module (bug 941009). + lazy.DownloadHistory.updateMetaData(download).catch(Cu.reportError); + } + + if ( + download.succeeded || + (download.error && download.error.becauseBlocked) + ) { + this._notifyDownloadEvent("finish"); + } + } + + if (!download.newDownloadNotified) { + download.newDownloadNotified = true; + this._notifyDownloadEvent("start", { + openDownloadsListOnStart: download.openDownloadsListOnStart, + }); + } + }, + + onDownloadRemoved(download) { + this._oldDownloadStates.delete(download); + }, + + // Registration of views + + /** + * Adds an object to be notified when the available download data changes. + * The specified object is initialized with the currently available downloads. + * + * @param aView + * DownloadsView object to be added. This reference must be passed to + * removeView before termination. + */ + addView(aView) { + this._promiseList.then(list => list.addView(aView)).catch(Cu.reportError); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsView object to be removed. + */ + removeView(aView) { + this._promiseList + .then(list => list.removeView(aView)) + .catch(Cu.reportError); + }, + + // Notifications sent to the most recent browser window only + + /** + * Set to true after the first download causes the downloads panel to be + * displayed. + */ + get panelHasShownBefore() { + try { + return Services.prefs.getBoolPref("browser.download.panel.shown"); + } catch (ex) {} + return false; + }, + + set panelHasShownBefore(aValue) { + Services.prefs.setBoolPref("browser.download.panel.shown", aValue); + }, + + /** + * Displays a new or finished download notification in the most recent browser + * window, if one is currently available with the required privacy type. + * @param {string} aType + * Set to "start" for new downloads, "finish" for completed downloads, + * "error" for downloads that failed and need attention + * @param {boolean} [openDownloadsListOnStart] + * (Only relevant when aType = "start") + * true (default) - open the downloads panel. + * false - only show an indicator notification. + */ + _notifyDownloadEvent(aType, { openDownloadsListOnStart = true } = {}) { + DownloadsCommon.log( + "Attempting to notify that a new download has started or finished." + ); + + // Show the panel in the most recent browser window, if present. + let browserWin = lazy.BrowserWindowTracker.getTopWindow({ + private: this._isPrivate, + }); + if (!browserWin) { + return; + } + + let shouldOpenDownloadsPanel = + aType == "start" && + Services.prefs.getBoolPref( + "browser.download.improvements_to_download_panel" + ) && + DownloadsCommon.summarizeDownloads(this._downloads).numDownloading <= 1 && + lazy.gAlwaysOpenPanel; + + // For new downloads after the first one, don't show the panel + // automatically, but provide a visible notification in the topmost browser + // window, if the status indicator is already visible. Also ensure that if + // openDownloadsListOnStart = false is passed, we always skip opening the + // panel. That's because this will only be passed if the download is started + // without user interaction or if a dialog was previously opened in the + // process of the download (e.g. unknown content type dialog). + if ( + aType != "error" && + ((this.panelHasShownBefore && !shouldOpenDownloadsPanel) || + !openDownloadsListOnStart || + browserWin != Services.focus.activeWindow) + ) { + DownloadsCommon.log("Showing new download notification."); + browserWin.DownloadsIndicatorView.showEventNotification(aType); + return; + } + this.panelHasShownBefore = true; + browserWin.DownloadsPanel.showPanel(); + }, +}; + +XPCOMUtils.defineLazyGetter(lazy, "HistoryDownloadsData", function() { + return new DownloadsDataCtor({ isHistory: true }); +}); + +XPCOMUtils.defineLazyGetter(lazy, "LimitedHistoryDownloadsData", function() { + return new DownloadsDataCtor({ + isHistory: true, + maxHistoryResults: kMaxHistoryResultsForLimitedView, + }); +}); + +XPCOMUtils.defineLazyGetter( + lazy, + "LimitedPrivateHistoryDownloadData", + function() { + return new DownloadsDataCtor({ + isPrivate: true, + isHistory: true, + maxHistoryResults: kMaxHistoryResultsForLimitedView, + }); + } +); + +XPCOMUtils.defineLazyGetter(lazy, "PrivateDownloadsData", function() { + return new DownloadsDataCtor({ isPrivate: true }); +}); + +XPCOMUtils.defineLazyGetter(lazy, "DownloadsData", function() { + return new DownloadsDataCtor(); +}); + +// DownloadsViewPrototype + +/** + * A prototype for an object that registers itself with DownloadsData as soon + * as a view is registered with it. + */ +const DownloadsViewPrototype = { + /** + * Contains all the available Download objects and their current state value. + * + * SUBCLASSES MUST OVERRIDE THIS PROPERTY. + */ + _oldDownloadStates: null, + + // Registration of views + + /** + * Array of view objects that should be notified when the available status + * data changes. + * + * SUBCLASSES MUST OVERRIDE THIS PROPERTY. + */ + _views: null, + + /** + * Determines whether this view object is over the private or non-private + * downloads. + * + * SUBCLASSES MUST OVERRIDE THIS PROPERTY. + */ + _isPrivate: false, + + /** + * Adds an object to be notified when the available status data changes. + * The specified object is initialized with the currently available status. + * + * @param aView + * View object to be added. This reference must be + * passed to removeView before termination. + */ + addView(aView) { + // Start receiving events when the first of our views is registered. + if (!this._views.length) { + if (this._isPrivate) { + lazy.PrivateDownloadsData.addView(this); + } else { + lazy.DownloadsData.addView(this); + } + } + + this._views.push(aView); + this.refreshView(aView); + }, + + /** + * Updates the properties of an object previously added using addView. + * + * @param aView + * View object to be updated. + */ + refreshView(aView) { + // Update immediately even if we are still loading data asynchronously. + // Subclasses must provide these two functions! + this._refreshProperties(); + this._updateView(aView); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * View object to be removed. + */ + removeView(aView) { + let index = this._views.indexOf(aView); + if (index != -1) { + this._views.splice(index, 1); + } + + // Stop receiving events when the last of our views is unregistered. + if (!this._views.length) { + if (this._isPrivate) { + lazy.PrivateDownloadsData.removeView(this); + } else { + lazy.DownloadsData.removeView(this); + } + } + }, + + // Callback functions from DownloadList + + /** + * Indicates whether we are still loading downloads data asynchronously. + */ + _loading: false, + + /** + * Called before multiple downloads are about to be loaded. + */ + onDownloadBatchStarting() { + this._loading = true; + }, + + /** + * Called after data loading finished. + */ + onDownloadBatchEnded() { + this._loading = false; + this._updateViews(); + }, + + /** + * Called when a new download data item is available, either during the + * asynchronous data load or when a new download is started. + * + * @param download + * Download object that was just added. + * + * @note Subclasses should override this and still call the base method. + */ + onDownloadAdded(download) { + this._oldDownloadStates.set( + download, + DownloadsCommon.stateOfDownload(download) + ); + }, + + /** + * Called when the overall state of a Download has changed. In particular, + * this is called only once when the download succeeds or is blocked + * permanently, and is never called if only the current progress changed. + * + * The onDownloadChanged notification will always be sent afterwards. + * + * @note Subclasses should override this. + */ + onDownloadStateChanged(download) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * Called every time any state property of a Download may have changed, + * including progress properties. + * + * Note that progress notification changes are throttled at the Downloads.sys.mjs + * API level, and there is no throttling mechanism in the front-end. + * + * @note Subclasses should override this and still call the base method. + */ + onDownloadChanged(download) { + let oldState = this._oldDownloadStates.get(download); + let newState = DownloadsCommon.stateOfDownload(download); + this._oldDownloadStates.set(download, newState); + + if (oldState != newState) { + this.onDownloadStateChanged(download); + } + }, + + /** + * Called when a data item is removed, ensures that the widget associated with + * the view item is removed from the user interface. + * + * @param download + * Download object that is being removed. + * + * @note Subclasses should override this. + */ + onDownloadRemoved(download) { + this._oldDownloadStates.delete(download); + }, + + /** + * Private function used to refresh the internal properties being sent to + * each registered view. + * + * @note Subclasses should override this. + */ + _refreshProperties() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * Private function used to refresh an individual view. + * + * @note Subclasses should override this. + */ + _updateView() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * Computes aggregate values and propagates the changes to our views. + */ + _updateViews() { + // Do not update the status indicators during batch loads of download items. + if (this._loading) { + return; + } + + this._refreshProperties(); + this._views.forEach(this._updateView, this); + }, +}; + +// DownloadsIndicatorData + +/** + * This object registers itself with DownloadsData as a view, and transforms the + * notifications it receives into overall status data, that is then broadcast to + * the registered download status indicators. + * + * Note that using this object does not automatically start the Download Manager + * service. Consumers will see an empty list of downloads until the service is + * actually started. This is useful to display a neutral progress indicator in + * the main browser window until the autostart timeout elapses. + */ +function DownloadsIndicatorDataCtor(aPrivate) { + this._oldDownloadStates = new WeakMap(); + this._isPrivate = aPrivate; + this._views = []; +} +DownloadsIndicatorDataCtor.prototype = { + /** + * Map of the relative severities of different attention states. + * Used in sorting the map of active downloads' attention states + * to determine the attention state to be displayed. + */ + _attentionPriority: new Map([ + [DownloadsCommon.ATTENTION_NONE, 0], + [DownloadsCommon.ATTENTION_SUCCESS, 1], + [DownloadsCommon.ATTENTION_INFO, 2], + [DownloadsCommon.ATTENTION_WARNING, 3], + [DownloadsCommon.ATTENTION_SEVERE, 4], + ]), + + /** + * Iterator for all the available Download objects. This is empty until the + * data has been loaded using the JavaScript API for downloads. + */ + get _downloads() { + return ChromeUtils.nondeterministicGetWeakMapKeys(this._oldDownloadStates); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsIndicatorView object to be removed. + */ + removeView(aView) { + DownloadsViewPrototype.removeView.call(this, aView); + + if (!this._views.length) { + this._itemCount = 0; + } + }, + + onDownloadAdded(download) { + DownloadsViewPrototype.onDownloadAdded.call(this, download); + this._itemCount++; + this._updateViews(); + }, + + onDownloadStateChanged(download) { + if (this._attentionSuppressed !== DownloadsCommon.SUPPRESS_NONE) { + return; + } + let attention; + if ( + !download.succeeded && + download.error && + download.error.reputationCheckVerdict + ) { + switch (download.error.reputationCheckVerdict) { + case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: + attention = DownloadsCommon.ATTENTION_INFO; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: // fall-through + case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: + case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM: + attention = DownloadsCommon.ATTENTION_WARNING; + break; + case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE: + attention = DownloadsCommon.ATTENTION_SEVERE; + break; + default: + attention = DownloadsCommon.ATTENTION_SEVERE; + console.error( + "Unknown reputation verdict: " + + download.error.reputationCheckVerdict + ); + } + } else if (download.succeeded) { + attention = DownloadsCommon.ATTENTION_SUCCESS; + } else if (download.error) { + attention = DownloadsCommon.ATTENTION_WARNING; + } + download.attention = attention; + this.updateAttention(); + }, + + onDownloadChanged(download) { + DownloadsViewPrototype.onDownloadChanged.call(this, download); + this._updateViews(); + }, + + onDownloadRemoved(download) { + DownloadsViewPrototype.onDownloadRemoved.call(this, download); + this._itemCount--; + this.updateAttention(); + this._updateViews(); + }, + + // Propagation of properties to our views + + // The following properties are updated by _refreshProperties and are then + // propagated to the views. See _refreshProperties for details. + _hasDownloads: false, + _percentComplete: -1, + + /** + * Indicates whether the download indicators should be highlighted. + */ + set attention(aValue) { + this._attention = aValue; + this._updateViews(); + }, + _attention: DownloadsCommon.ATTENTION_NONE, + + /** + * Indicates whether the user is interacting with downloads, thus the + * attention indication should not be shown even if requested. + */ + set attentionSuppressed(aFlags) { + this._attentionSuppressed = aFlags; + if (aFlags !== DownloadsCommon.SUPPRESS_NONE) { + for (let download of this._downloads) { + download.attention = DownloadsCommon.ATTENTION_NONE; + } + this.attention = DownloadsCommon.ATTENTION_NONE; + } + }, + get attentionSuppressed() { + return this._attentionSuppressed; + }, + _attentionSuppressed: DownloadsCommon.SUPPRESS_NONE, + + /** + * Set the indicator's attention to the most severe attention state among the + * unseen displayed downloads, or DownloadsCommon.ATTENTION_NONE if empty. + */ + updateAttention() { + let currentAttention = DownloadsCommon.ATTENTION_NONE; + let currentPriority = 0; + for (let download of this._downloads) { + let { attention } = download; + let priority = this._attentionPriority.get(attention); + if (priority > currentPriority) { + currentPriority = priority; + currentAttention = attention; + } + } + this.attention = currentAttention; + }, + + /** + * Updates the specified view with the current aggregate values. + * + * @param aView + * DownloadsIndicatorView object to be updated. + */ + _updateView(aView) { + aView.hasDownloads = this._hasDownloads; + aView.percentComplete = this._percentComplete; + aView.attention = + this.attentionSuppressed !== DownloadsCommon.SUPPRESS_NONE + ? DownloadsCommon.ATTENTION_NONE + : this._attention; + }, + + // Property updating based on current download status + + /** + * Number of download items that are available to be displayed. + */ + _itemCount: 0, + + /** + * A generator function for the Download objects this summary is currently + * interested in. This generator is passed off to summarizeDownloads in order + * to generate statistics about the downloads we care about - in this case, + * it's all active downloads. + */ + *_activeDownloads() { + let downloads = this._isPrivate + ? lazy.PrivateDownloadsData._downloads + : lazy.DownloadsData._downloads; + for (let download of downloads) { + if (!download.stopped || (download.canceled && download.hasPartialData)) { + yield download; + } + } + }, + + /** + * Computes aggregate values based on the current state of downloads. + */ + _refreshProperties() { + let summary = DownloadsCommon.summarizeDownloads(this._activeDownloads()); + + // Determine if the indicator should be shown or get attention. + this._hasDownloads = this._itemCount > 0; + + // Always show a progress bar if there are downloads in progress. + if (summary.percentComplete >= 0) { + this._percentComplete = summary.percentComplete; + } else if (summary.numDownloading > 0) { + this._percentComplete = 0; + } else { + this._percentComplete = -1; + } + }, +}; +Object.setPrototypeOf( + DownloadsIndicatorDataCtor.prototype, + DownloadsViewPrototype +); + +XPCOMUtils.defineLazyGetter(lazy, "PrivateDownloadsIndicatorData", function() { + return new DownloadsIndicatorDataCtor(true); +}); + +XPCOMUtils.defineLazyGetter(lazy, "DownloadsIndicatorData", function() { + return new DownloadsIndicatorDataCtor(false); +}); + +// DownloadsSummaryData + +/** + * DownloadsSummaryData is a view for DownloadsData that produces a summary + * of all downloads after a certain exclusion point aNumToExclude. For example, + * if there were 5 downloads in progress, and a DownloadsSummaryData was + * constructed with aNumToExclude equal to 3, then that DownloadsSummaryData + * would produce a summary of the last 2 downloads. + * + * @param aIsPrivate + * True if the browser window which owns the download button is a private + * window. + * @param aNumToExclude + * The number of items to exclude from the summary, starting from the + * top of the list. + */ +function DownloadsSummaryData(aIsPrivate, aNumToExclude) { + this._numToExclude = aNumToExclude; + // Since we can have multiple instances of DownloadsSummaryData, we + // override these values from the prototype so that each instance can be + // completely separated from one another. + this._loading = false; + + this._downloads = []; + + // Floating point value indicating the last number of seconds estimated until + // the longest download will finish. We need to store this value so that we + // don't continuously apply smoothing if the actual download state has not + // changed. This is set to -1 if the previous value is unknown. + this._lastRawTimeLeft = -1; + + // Last number of seconds estimated until all in-progress downloads with a + // known size and speed will finish. This value is stored to allow smoothing + // in case of small variations. This is set to -1 if the previous value is + // unknown. + this._lastTimeLeft = -1; + + // The following properties are updated by _refreshProperties and are then + // propagated to the views. + this._showingProgress = false; + this._details = ""; + this._description = ""; + this._numActive = 0; + this._percentComplete = -1; + + this._oldDownloadStates = new WeakMap(); + this._isPrivate = aIsPrivate; + this._views = []; +} + +DownloadsSummaryData.prototype = { + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsSummary view to be removed. + */ + removeView(aView) { + DownloadsViewPrototype.removeView.call(this, aView); + + if (!this._views.length) { + // Clear out our collection of Download objects. If we ever have + // another view registered with us, this will get re-populated. + this._downloads = []; + } + }, + + onDownloadAdded(download) { + DownloadsViewPrototype.onDownloadAdded.call(this, download); + this._downloads.unshift(download); + this._updateViews(); + }, + + onDownloadStateChanged() { + // Since the state of a download changed, reset the estimated time left. + this._lastRawTimeLeft = -1; + this._lastTimeLeft = -1; + }, + + onDownloadChanged(download) { + DownloadsViewPrototype.onDownloadChanged.call(this, download); + this._updateViews(); + }, + + onDownloadRemoved(download) { + DownloadsViewPrototype.onDownloadRemoved.call(this, download); + let itemIndex = this._downloads.indexOf(download); + this._downloads.splice(itemIndex, 1); + this._updateViews(); + }, + + // Propagation of properties to our views + + /** + * Updates the specified view with the current aggregate values. + * + * @param aView + * DownloadsIndicatorView object to be updated. + */ + _updateView(aView) { + aView.showingProgress = this._showingProgress; + aView.percentComplete = this._percentComplete; + aView.description = this._description; + aView.details = this._details; + }, + + // Property updating based on current download status + + /** + * A generator function for the Download objects this summary is currently + * interested in. This generator is passed off to summarizeDownloads in order + * to generate statistics about the downloads we care about - in this case, + * it's the downloads in this._downloads after the first few to exclude, + * which was set when constructing this DownloadsSummaryData instance. + */ + *_downloadsForSummary() { + if (this._downloads.length) { + for (let i = this._numToExclude; i < this._downloads.length; ++i) { + yield this._downloads[i]; + } + } + }, + + /** + * Computes aggregate values based on the current state of downloads. + */ + _refreshProperties() { + // Pre-load summary with default values. + let summary = DownloadsCommon.summarizeDownloads( + this._downloadsForSummary() + ); + + // Run sync to update view right away and get correct description. + // See refreshView for more details. + this._description = kDownloadsFluentStrings.formatValueSync( + "downloads-more-downloading", + { + count: summary.numDownloading, + } + ); + this._percentComplete = summary.percentComplete; + + // Only show the downloading items. + this._showingProgress = summary.numDownloading > 0; + + // Display the estimated time left, if present. + if (summary.rawTimeLeft == -1) { + // There are no downloads with a known time left. + this._lastRawTimeLeft = -1; + this._lastTimeLeft = -1; + this._details = ""; + } else { + // Compute the new time left only if state actually changed. + if (this._lastRawTimeLeft != summary.rawTimeLeft) { + this._lastRawTimeLeft = summary.rawTimeLeft; + this._lastTimeLeft = DownloadsCommon.smoothSeconds( + summary.rawTimeLeft, + this._lastTimeLeft + ); + } + [this._details] = lazy.DownloadUtils.getDownloadStatusNoRate( + summary.totalTransferred, + summary.totalSize, + summary.slowestSpeed, + this._lastTimeLeft + ); + } + }, +}; +Object.setPrototypeOf(DownloadsSummaryData.prototype, DownloadsViewPrototype); |