/* -*- 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", DownloadUtils: "resource://gre/modules/DownloadUtils.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", }); 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 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 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(console.error); } }, /** * 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); }); // 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(console.error); }, // 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(console.error); } 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(console.error); }, /** * Removes an object previously added using addView. * * @param aView * DownloadsView object to be removed. */ removeView(aView) { this._promiseList.then(list => list.removeView(aView)).catch(console.error); }, // 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" && 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);