From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../downloads/DownloadSpamProtection.sys.mjs | 300 ++++ .../components/downloads/DownloadsCommon.sys.mjs | 1642 +++++++++++++++++++ .../downloads/DownloadsMacFinderProgress.sys.mjs | 84 + .../components/downloads/DownloadsTaskbar.sys.mjs | 220 +++ .../components/downloads/DownloadsViewUI.sys.mjs | 1201 ++++++++++++++ .../downloads/DownloadsViewableInternally.sys.mjs | 351 ++++ .../downloads/content/allDownloadsView.js | 949 +++++++++++ .../downloads/content/contentAreaDownloadsView.css | 8 + .../downloads/content/contentAreaDownloadsView.js | 49 + .../content/contentAreaDownloadsView.xhtml | 48 + browser/components/downloads/content/downloads.css | 106 ++ browser/components/downloads/content/downloads.js | 1722 ++++++++++++++++++++ .../downloads/content/downloadsCommands.inc.xhtml | 29 + .../downloads/content/downloadsCommands.js | 17 + .../content/downloadsContextMenu.inc.xhtml | 50 + .../downloads/content/downloadsPanel.inc.xhtml | 198 +++ browser/components/downloads/content/indicator.js | 670 ++++++++ browser/components/downloads/jar.mn | 13 + browser/components/downloads/moz.build | 30 + .../components/downloads/test/browser/blank.JPG | Bin 0 -> 631 bytes .../components/downloads/test/browser/browser.ini | 65 + .../test/browser/browser_about_downloads.js | 44 + .../test/browser/browser_basic_functionality.js | 59 + .../browser/browser_confirm_unblock_download.js | 110 ++ .../test/browser/browser_download_is_clickable.js | 78 + .../browser/browser_download_opens_on_click.js | 89 + .../test/browser/browser_download_opens_policy.js | 104 ++ .../test/browser/browser_download_overwrite.js | 126 ++ .../browser/browser_download_spam_protection.js | 220 +++ .../test/browser/browser_download_starts_in_tmp.js | 264 +++ .../test/browser/browser_downloads_autohide.js | 517 ++++++ ...loads_context_menu_always_open_similar_files.js | 236 +++ .../browser_downloads_context_menu_delete_file.js | 253 +++ .../browser_downloads_context_menu_selection.js | 139 ++ .../test/browser/browser_downloads_keynav.js | 255 +++ .../test/browser/browser_downloads_panel_block.js | 185 +++ .../browser_downloads_panel_context_menu.js | 421 +++++ .../browser/browser_downloads_panel_ctrl_click.js | 35 + .../browser_downloads_panel_disable_items.js | 171 ++ .../browser/browser_downloads_panel_dontshow.js | 126 ++ .../test/browser/browser_downloads_panel_focus.js | 108 ++ .../test/browser/browser_downloads_panel_height.js | 35 + .../test/browser/browser_downloads_panel_opens.js | 674 ++++++++ .../test/browser/browser_downloads_pauseResume.js | 49 + .../test/browser/browser_first_download_panel.js | 68 + .../test/browser/browser_go_to_download_page.js | 93 ++ .../browser/browser_iframe_gone_mid_download.js | 72 + .../test/browser/browser_image_mimetype_issues.js | 135 ++ .../test/browser/browser_indicatorDrop.js | 38 + .../downloads/test/browser/browser_libraryDrop.js | 39 + .../test/browser/browser_library_clearall.js | 122 ++ .../test/browser/browser_library_select_all.js | 77 + .../test/browser/browser_overflow_anchor.js | 59 + .../test/browser/browser_pdfjs_preview.js | 753 +++++++++ .../downloads/test/browser/browser_tempfilename.js | 88 + browser/components/downloads/test/browser/foo.txt | 1 + .../downloads/test/browser/foo.txt^headers^ | 2 + browser/components/downloads/test/browser/head.js | 448 +++++ .../downloads/test/browser/not-really-a-jpeg.jpeg | Bin 0 -> 42 bytes .../test/browser/not-really-a-jpeg.jpeg^headers^ | 2 + .../downloads/test/browser/test_spammy_page.html | 26 + browser/components/downloads/test/unit/head.js | 67 + .../test/unit/test_DownloadLastDir_basics.js | 140 ++ .../test/unit/test_DownloadsCommon_getMimeInfo.js | 168 ++ .../test/unit/test_DownloadsCommon_isFileOfType.js | 147 ++ .../test/unit/test_DownloadsViewableInternally.js | 277 ++++ .../components/downloads/test/unit/xpcshell.ini | 9 + 67 files changed, 14881 insertions(+) create mode 100644 browser/components/downloads/DownloadSpamProtection.sys.mjs create mode 100644 browser/components/downloads/DownloadsCommon.sys.mjs create mode 100644 browser/components/downloads/DownloadsMacFinderProgress.sys.mjs create mode 100644 browser/components/downloads/DownloadsTaskbar.sys.mjs create mode 100644 browser/components/downloads/DownloadsViewUI.sys.mjs create mode 100644 browser/components/downloads/DownloadsViewableInternally.sys.mjs create mode 100644 browser/components/downloads/content/allDownloadsView.js create mode 100644 browser/components/downloads/content/contentAreaDownloadsView.css create mode 100644 browser/components/downloads/content/contentAreaDownloadsView.js create mode 100644 browser/components/downloads/content/contentAreaDownloadsView.xhtml create mode 100644 browser/components/downloads/content/downloads.css create mode 100644 browser/components/downloads/content/downloads.js create mode 100644 browser/components/downloads/content/downloadsCommands.inc.xhtml create mode 100644 browser/components/downloads/content/downloadsCommands.js create mode 100644 browser/components/downloads/content/downloadsContextMenu.inc.xhtml create mode 100644 browser/components/downloads/content/downloadsPanel.inc.xhtml create mode 100644 browser/components/downloads/content/indicator.js create mode 100644 browser/components/downloads/jar.mn create mode 100644 browser/components/downloads/moz.build create mode 100644 browser/components/downloads/test/browser/blank.JPG create mode 100644 browser/components/downloads/test/browser/browser.ini create mode 100644 browser/components/downloads/test/browser/browser_about_downloads.js create mode 100644 browser/components/downloads/test/browser/browser_basic_functionality.js create mode 100644 browser/components/downloads/test/browser/browser_confirm_unblock_download.js create mode 100644 browser/components/downloads/test/browser/browser_download_is_clickable.js create mode 100644 browser/components/downloads/test/browser/browser_download_opens_on_click.js create mode 100644 browser/components/downloads/test/browser/browser_download_opens_policy.js create mode 100644 browser/components/downloads/test/browser/browser_download_overwrite.js create mode 100644 browser/components/downloads/test/browser/browser_download_spam_protection.js create mode 100644 browser/components/downloads/test/browser/browser_download_starts_in_tmp.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_autohide.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_context_menu_always_open_similar_files.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_context_menu_delete_file.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_context_menu_selection.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_keynav.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_block.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_ctrl_click.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_disable_items.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_dontshow.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_focus.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_height.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_panel_opens.js create mode 100644 browser/components/downloads/test/browser/browser_downloads_pauseResume.js create mode 100644 browser/components/downloads/test/browser/browser_first_download_panel.js create mode 100644 browser/components/downloads/test/browser/browser_go_to_download_page.js create mode 100644 browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js create mode 100644 browser/components/downloads/test/browser/browser_image_mimetype_issues.js create mode 100644 browser/components/downloads/test/browser/browser_indicatorDrop.js create mode 100644 browser/components/downloads/test/browser/browser_libraryDrop.js create mode 100644 browser/components/downloads/test/browser/browser_library_clearall.js create mode 100644 browser/components/downloads/test/browser/browser_library_select_all.js create mode 100644 browser/components/downloads/test/browser/browser_overflow_anchor.js create mode 100644 browser/components/downloads/test/browser/browser_pdfjs_preview.js create mode 100644 browser/components/downloads/test/browser/browser_tempfilename.js create mode 100644 browser/components/downloads/test/browser/foo.txt create mode 100644 browser/components/downloads/test/browser/foo.txt^headers^ create mode 100644 browser/components/downloads/test/browser/head.js create mode 100644 browser/components/downloads/test/browser/not-really-a-jpeg.jpeg create mode 100644 browser/components/downloads/test/browser/not-really-a-jpeg.jpeg^headers^ create mode 100644 browser/components/downloads/test/browser/test_spammy_page.html create mode 100644 browser/components/downloads/test/unit/head.js create mode 100644 browser/components/downloads/test/unit/test_DownloadLastDir_basics.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsViewableInternally.js create mode 100644 browser/components/downloads/test/unit/xpcshell.ini (limited to 'browser/components/downloads') diff --git a/browser/components/downloads/DownloadSpamProtection.sys.mjs b/browser/components/downloads/DownloadSpamProtection.sys.mjs new file mode 100644 index 0000000000..fa0cb97476 --- /dev/null +++ b/browser/components/downloads/DownloadSpamProtection.sys.mjs @@ -0,0 +1,300 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Provides functions to prevent multiple automatic downloads. + */ + +import { + Download, + DownloadError, +} from "resource://gre/modules/DownloadCore.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadList: "resource://gre/modules/DownloadList.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +/** + * Each window tracks download spam independently, so one of these objects is + * constructed for each window. This is responsible for tracking the spam and + * updating the window's downloads UI accordingly. + */ +class WindowSpamProtection { + constructor(window) { + this._window = window; + } + + /** + * This map stores blocked spam downloads for the window, keyed by the + * download's source URL. This is done so we can track the number of times a + * given download has been blocked. + * @type {Map} + */ + _downloadSpamForUrl = new Map(); + + /** + * This set stores views that are waiting to have download notification + * listeners attached. They will be attached when the spamList is created + * (i.e. when the first spam download is blocked). + * @type {Set} + */ + _pendingViews = new Set(); + + /** + * Set to true when we first start _blocking downloads in the window. This is + * used to lazily load the spamList. Spam downloads are rare enough that many + * sessions will have no blocked downloads. So we don't want to create a + * DownloadList unless we actually need it. + * @type {Boolean} + */ + _blocking = false; + + /** + * A per-window DownloadList for blocked spam downloads. Registered views will + * be sent notifications about downloads in this list, so that blocked spam + * downloads can be represented in the UI. If spam downloads haven't been + * blocked in the window, this will be undefined. See DownloadList.sys.mjs. + * @type {DownloadList | undefined} + */ + get spamList() { + if (!this._blocking) { + return undefined; + } + if (!this._spamList) { + this._spamList = new lazy.DownloadList(); + } + return this._spamList; + } + + /** + * A per-window downloads indicator whose state depends on notifications from + * DownloadLists registered in the window (for example, the visual state of + * the downloads toolbar button). See DownloadsCommon.sys.mjs for more details. + * @type {DownloadsIndicatorData} + */ + get indicator() { + if (!this._indicator) { + this._indicator = lazy.DownloadsCommon.getIndicatorData(this._window); + } + return this._indicator; + } + + /** + * Add a blocked download to the spamList or increment the count of an + * existing blocked download, then notify listeners about this. + * @param {String} url + */ + addDownloadSpam(url) { + this._blocking = true; + // Start listening on registered downloads views, if any exist. + this._maybeAddViews(); + // If this URL is already paired with a DownloadSpam object, increment its + // blocked downloads count by 1 and don't open the downloads panel. + if (this._downloadSpamForUrl.has(url)) { + let downloadSpam = this._downloadSpamForUrl.get(url); + downloadSpam.blockedDownloadsCount += 1; + this.indicator.onDownloadStateChanged(downloadSpam); + return; + } + // Otherwise, create a new DownloadSpam object for the URL, add it to the + // spamList, and open the downloads panel. + let downloadSpam = new DownloadSpam(url); + this.spamList.add(downloadSpam); + this._downloadSpamForUrl.set(url, downloadSpam); + this._notifyDownloadSpamAdded(downloadSpam); + } + + /** + * Notify the downloads panel that a new download has been added to the + * spamList. This is invoked when a new DownloadSpam object is created. + * @param {DownloadSpam} downloadSpam + */ + _notifyDownloadSpamAdded(downloadSpam) { + let hasActiveDownloads = lazy.DownloadsCommon.summarizeDownloads( + this.indicator._activeDownloads() + ).numDownloading; + if ( + !hasActiveDownloads && + this._window === lazy.BrowserWindowTracker.getTopWindow() + ) { + // If there are no active downloads, open the downloads panel. + this._window.DownloadsPanel.showPanel(); + } else { + // Otherwise, flash a taskbar/dock icon notification if available. + this._window.getAttention(); + } + this.indicator.onDownloadAdded(downloadSpam); + } + + /** + * Remove the download spam data for a given source URL. + * @param {String} url + */ + removeDownloadSpamForUrl(url) { + if (this._downloadSpamForUrl.has(url)) { + let downloadSpam = this._downloadSpamForUrl.get(url); + this.spamList.remove(downloadSpam); + this.indicator.onDownloadRemoved(downloadSpam); + this._downloadSpamForUrl.delete(url); + } + } + + /** + * Set up a downloads view (e.g. the downloads panel) to receive notifications + * about downloads in the spamList. + * @param {Object} view An object that implements handlers for download + * related notifications, like onDownloadAdded. + */ + registerView(view) { + if (!view || this.spamList?._views.has(view)) { + return; + } + this._pendingViews.add(view); + this._maybeAddViews(); + } + + /** + * If any downloads have been blocked in the window, add download notification + * listeners for each downloads view that has been registered. + */ + _maybeAddViews() { + if (this.spamList) { + for (let view of this._pendingViews) { + if (!this.spamList._views.has(view)) { + this.spamList.addView(view); + } + } + this._pendingViews.clear(); + } + } + + /** + * Remove download notification listeners for all views. This is invoked when + * the window is closed. + */ + removeAllViews() { + if (this.spamList) { + for (let view of this.spamList._views) { + this.spamList.removeView(view); + } + } + this._pendingViews.clear(); + } +} + +/** + * Responsible for detecting events related to downloads spam and notifying the + * relevant window's WindowSpamProtection object. This is a singleton object, + * constructed by DownloadIntegration.sys.mjs when the first download is blocked. + */ +export class DownloadSpamProtection { + /** + * Stores spam protection data per-window. + * @type {WeakMap} + */ + _forWindowMap = new WeakMap(); + + /** + * Add download spam data for a given source URL in the window where the + * download was blocked. This is invoked when a download is blocked by + * nsExternalAppHandler::IsDownloadSpam + * @param {String} url + * @param {Window} window + */ + update(url, window) { + if (window == null) { + lazy.DownloadsCommon.log( + "Download spam blocked in a non-chrome window. URL: ", + url + ); + return; + } + // Get the spam protection object for a given window or create one if it + // does not already exist. Also attach notification listeners to any pending + // downloads views. + let wsp = + this._forWindowMap.get(window) ?? new WindowSpamProtection(window); + this._forWindowMap.set(window, wsp); + wsp.addDownloadSpam(url); + } + + /** + * Get the spam list for a given window (provided it exists). + * @param {Window} window + * @returns {DownloadList} + */ + getSpamListForWindow(window) { + return this._forWindowMap.get(window)?.spamList; + } + + /** + * Remove the download spam data for a given source URL in the passed window, + * if any exists. + * @param {String} url + * @param {Window} window + */ + removeDownloadSpamForWindow(url, window) { + let wsp = this._forWindowMap.get(window); + wsp?.removeDownloadSpamForUrl(url); + } + + /** + * Create the spam protection object for a given window (if not already + * created) and prepare to start listening for notifications on the passed + * downloads view. The bulk of resources won't be expended until a download is + * blocked. To add multiple views, call this method multiple times. + * @param {Object} view An object that implements handlers for download + * related notifications, like onDownloadAdded. + * @param {Window} window + */ + register(view, window) { + let wsp = + this._forWindowMap.get(window) ?? new WindowSpamProtection(window); + // Try setting up the view now; it will be deferred if there's no spam. + wsp.registerView(view); + this._forWindowMap.set(window, wsp); + } + + /** + * Remove the spam protection object for a window when it is closed. + * @param {Window} window + */ + unregister(window) { + let wsp = this._forWindowMap.get(window); + if (wsp) { + // Stop listening on the view if it was previously set up. + wsp.removeAllViews(); + this._forWindowMap.delete(window); + } + } +} + +/** + * Represents a special Download object for download spam. + * @extends Download + */ +class DownloadSpam extends Download { + constructor(url) { + super(); + this.hasBlockedData = true; + this.stopped = true; + this.error = new DownloadError({ + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM, + }); + this.target = { path: "" }; + this.source = { url }; + this.blockedDownloadsCount = 1; + } +} diff --git a/browser/components/downloads/DownloadsCommon.sys.mjs b/browser/components/downloads/DownloadsCommon.sys.mjs new file mode 100644 index 0000000000..c797be3ce7 --- /dev/null +++ b/browser/components/downloads/DownloadsCommon.sys.mjs @@ -0,0 +1,1642 @@ +/* -*- 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); diff --git a/browser/components/downloads/DownloadsMacFinderProgress.sys.mjs b/browser/components/downloads/DownloadsMacFinderProgress.sys.mjs new file mode 100644 index 0000000000..64e1dc4b8d --- /dev/null +++ b/browser/components/downloads/DownloadsMacFinderProgress.sys.mjs @@ -0,0 +1,84 @@ +/* -*- 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 download progress indicator of the macOS Finder. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +export var DownloadsMacFinderProgress = { + /** + * Maps the path of the download, to the according progress indicator instance. + */ + _finderProgresses: null, + + /** + * This method is called after a new browser window on macOS is opened, it + * registers for receiving download events for the progressbar of the Finder. + */ + register() { + // Ensure to register only once per process and not for every window. + if (!this._finderProgresses) { + this._finderProgresses = new Map(); + lazy.Downloads.getList(lazy.Downloads.ALL).then(list => + list.addView(this) + ); + } + }, + + onDownloadAdded(download) { + if (download.stopped) { + return; + } + + let finderProgress = Cc[ + "@mozilla.org/widget/macfinderprogress;1" + ].createInstance(Ci.nsIMacFinderProgress); + + let path = download.target.path; + + finderProgress.init(path, () => { + download.cancel().catch(console.error); + download.removePartialData().catch(console.error); + }); + + if (download.hasProgress) { + finderProgress.updateProgress(download.currentBytes, download.totalBytes); + } else { + finderProgress.updateProgress(0, 0); + } + this._finderProgresses.set(path, finderProgress); + }, + + onDownloadChanged(download) { + let path = download.target.path; + let finderProgress = this._finderProgresses.get(path); + if (!finderProgress) { + // The download is not tracked, it may have been restarted, + // thus forward the call to onDownloadAdded to check if it should be tracked. + this.onDownloadAdded(download); + } else if (download.stopped) { + finderProgress.end(); + this._finderProgresses.delete(path); + } else { + finderProgress.updateProgress(download.currentBytes, download.totalBytes); + } + }, + + onDownloadRemoved(download) { + let path = download.target.path; + let finderProgress = this._finderProgresses.get(path); + if (finderProgress) { + finderProgress.end(); + this._finderProgresses.delete(path); + } + }, +}; diff --git a/browser/components/downloads/DownloadsTaskbar.sys.mjs b/browser/components/downloads/DownloadsTaskbar.sys.mjs new file mode 100644 index 0000000000..64029ae543 --- /dev/null +++ b/browser/components/downloads/DownloadsTaskbar.sys.mjs @@ -0,0 +1,220 @@ +/* -*- 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 download progress indicator in the taskbar. + */ + +// Globals + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "gWinTaskbar", function () { + if (!("@mozilla.org/windows-taskbar;1" in Cc)) { + return null; + } + let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( + Ci.nsIWinTaskbar + ); + return winTaskbar.available && winTaskbar; +}); + +XPCOMUtils.defineLazyGetter(lazy, "gMacTaskbarProgress", function () { + return ( + "@mozilla.org/widget/macdocksupport;1" in Cc && + Cc["@mozilla.org/widget/macdocksupport;1"].getService(Ci.nsITaskbarProgress) + ); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gGtkTaskbarProgress", function () { + return ( + "@mozilla.org/widget/taskbarprogress/gtk;1" in Cc && + Cc["@mozilla.org/widget/taskbarprogress/gtk;1"].getService( + Ci.nsIGtkTaskbarProgress + ) + ); +}); + +// DownloadsTaskbar + +/** + * Handles the download progress indicator in the taskbar. + */ +export var DownloadsTaskbar = { + /** + * Underlying DownloadSummary providing the aggregate download information, or + * null if the indicator has never been initialized. + */ + _summary: null, + + /** + * nsITaskbarProgress object to which download information is dispatched. + * This can be null if the indicator has never been initialized or if the + * indicator is currently hidden on Windows. + */ + _taskbarProgress: null, + + /** + * This method is called after a new browser window is opened, and ensures + * that the download progress indicator is displayed in the taskbar. + * + * On Windows, the indicator is attached to the first browser window that + * calls this method. When the window is closed, the indicator is moved to + * another browser window, if available, in no particular order. When there + * are no browser windows visible, the indicator is hidden. + * + * On Mac OS X, the indicator is initialized globally when this method is + * called for the first time. Subsequent calls have no effect. + * + * @param aBrowserWindow + * nsIDOMWindow object of the newly opened browser window to which the + * indicator may be attached. + */ + registerIndicator(aBrowserWindow) { + if (!this._taskbarProgress) { + if (lazy.gMacTaskbarProgress) { + // On Mac OS X, we have to register the global indicator only once. + this._taskbarProgress = lazy.gMacTaskbarProgress; + // Free the XPCOM reference on shutdown, to prevent detecting a leak. + Services.obs.addObserver(() => { + this._taskbarProgress = null; + lazy.gMacTaskbarProgress = null; + }, "quit-application-granted"); + } else if (lazy.gWinTaskbar) { + // On Windows, the indicator is currently hidden because we have no + // previous browser window, thus we should attach the indicator now. + this._attachIndicator(aBrowserWindow); + } else if (lazy.gGtkTaskbarProgress) { + this._taskbarProgress = lazy.gGtkTaskbarProgress; + + this._attachGtkTaskbarProgress(aBrowserWindow); + } else { + // The taskbar indicator is not available on this platform. + return; + } + } + + // Ensure that the DownloadSummary object will be created asynchronously. + if (!this._summary) { + lazy.Downloads.getSummary(lazy.Downloads.ALL) + .then(summary => { + // In case the method is re-entered, we simply ignore redundant + // invocations of the callback, instead of keeping separate state. + if (this._summary) { + return undefined; + } + this._summary = summary; + return this._summary.addView(this); + }) + .catch(console.error); + } + }, + + /** + * On Windows, attaches the taskbar indicator to the specified browser window. + */ + _attachIndicator(aWindow) { + // Activate the indicator on the specified window. + let { docShell } = aWindow.browsingContext.topChromeWindow; + this._taskbarProgress = lazy.gWinTaskbar.getTaskbarProgress(docShell); + + // If the DownloadSummary object has already been created, we should update + // the state of the new indicator, otherwise it will be updated as soon as + // the DownloadSummary view is registered. + if (this._summary) { + this.onSummaryChanged(); + } + + aWindow.addEventListener("unload", () => { + // Locate another browser window, excluding the one being closed. + let browserWindow = lazy.BrowserWindowTracker.getTopWindow(); + if (browserWindow) { + // Move the progress indicator to the other browser window. + this._attachIndicator(browserWindow); + } else { + // The last browser window has been closed. We remove the reference to + // the taskbar progress object so that the indicator will be registered + // again on the next browser window that is opened. + this._taskbarProgress = null; + } + }); + }, + + /** + * In gtk3, the window itself implements the progress interface. + */ + _attachGtkTaskbarProgress(aWindow) { + // Set the current window. + this._taskbarProgress.setPrimaryWindow(aWindow); + + // If the DownloadSummary object has already been created, we should update + // the state of the new indicator, otherwise it will be updated as soon as + // the DownloadSummary view is registered. + if (this._summary) { + this.onSummaryChanged(); + } + + aWindow.addEventListener("unload", () => { + // Locate another browser window, excluding the one being closed. + let browserWindow = lazy.BrowserWindowTracker.getTopWindow(); + if (browserWindow) { + // Move the progress indicator to the other browser window. + this._attachGtkTaskbarProgress(browserWindow); + } else { + // The last browser window has been closed. We remove the reference to + // the taskbar progress object so that the indicator will be registered + // again on the next browser window that is opened. + this._taskbarProgress = null; + } + }); + }, + + // DownloadSummary view + + onSummaryChanged() { + // If the last browser window has been closed, we have no indicator any more. + if (!this._taskbarProgress) { + return; + } + + if (this._summary.allHaveStopped || this._summary.progressTotalBytes == 0) { + this._taskbarProgress.setProgressState( + Ci.nsITaskbarProgress.STATE_NO_PROGRESS, + 0, + 0 + ); + } else if (this._summary.allUnknownSize) { + this._taskbarProgress.setProgressState( + Ci.nsITaskbarProgress.STATE_INDETERMINATE, + 0, + 0 + ); + } else { + // For a brief moment before completion, some download components may + // report more transferred bytes than the total number of bytes. Thus, + // ensure that we never break the expectations of the progress indicator. + let progressCurrentBytes = Math.min( + this._summary.progressTotalBytes, + this._summary.progressCurrentBytes + ); + this._taskbarProgress.setProgressState( + Ci.nsITaskbarProgress.STATE_NORMAL, + progressCurrentBytes, + this._summary.progressTotalBytes + ); + } + }, +}; diff --git a/browser/components/downloads/DownloadsViewUI.sys.mjs b/browser/components/downloads/DownloadsViewUI.sys.mjs new file mode 100644 index 0000000000..0e0f5a7af3 --- /dev/null +++ b/browser/components/downloads/DownloadsViewUI.sys.mjs @@ -0,0 +1,1201 @@ +/* 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/. */ + +/* + * This module is imported by code that uses the "download.xml" binding, and + * provides prototypes for objects that handle input and display information. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "handlerSvc", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gReputationService", + "@mozilla.org/reputationservice/application-reputation-service;1", + Ci.nsIApplicationReputationService +); + +import { Integration } from "resource://gre/modules/Integration.sys.mjs"; + +Integration.downloads.defineESModuleGetter( + lazy, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +var gDownloadElementButtons = { + cancel: { + commandName: "downloadsCmd_cancel", + l10nId: "downloads-cmd-cancel", + descriptionL10nId: "downloads-cancel-download", + panelL10nId: "downloads-cmd-cancel-panel", + iconClass: "downloadIconCancel", + }, + retry: { + commandName: "downloadsCmd_retry", + l10nId: "downloads-cmd-retry", + descriptionL10nId: "downloads-retry-download", + panelL10nId: "downloads-cmd-retry-panel", + iconClass: "downloadIconRetry", + }, + show: { + commandName: "downloadsCmd_show", + l10nId: "downloads-cmd-show-button-2", + descriptionL10nId: "downloads-cmd-show-description-2", + panelL10nId: "downloads-cmd-show-panel-2", + iconClass: "downloadIconShow", + }, + subviewOpenOrRemoveFile: { + commandName: "downloadsCmd_showBlockedInfo", + l10nId: "downloads-cmd-choose-open", + descriptionL10nId: "downloads-show-more-information", + panelL10nId: "downloads-cmd-choose-open-panel", + iconClass: "downloadIconSubviewArrow", + }, + askOpenOrRemoveFile: { + commandName: "downloadsCmd_chooseOpen", + l10nId: "downloads-cmd-choose-open", + panelL10nId: "downloads-cmd-choose-open-panel", + iconClass: "downloadIconShow", + }, + askRemoveFileOrAllow: { + commandName: "downloadsCmd_chooseUnblock", + l10nId: "downloads-cmd-choose-unblock", + panelL10nId: "downloads-cmd-choose-unblock-panel", + iconClass: "downloadIconShow", + }, + removeFile: { + commandName: "downloadsCmd_confirmBlock", + l10nId: "downloads-cmd-remove-file", + panelL10nId: "downloads-cmd-remove-file-panel", + iconClass: "downloadIconCancel", + }, +}; + +/** + * Associates each document with a pre-built DOM fragment representing the + * download list item. This is then cloned to create each individual list item. + * This is stored on the document to prevent leaks that would occur if a single + * instance created by one document's DOMParser was stored globally. + */ +var gDownloadListItemFragments = new WeakMap(); + +export var DownloadsViewUI = { + /** + * Returns true if the given string is the name of a command that can be + * handled by the Downloads user interface, including standard commands. + */ + isCommandName(name) { + return name.startsWith("cmd_") || name.startsWith("downloadsCmd_"); + }, + + /** + * Get source url of the download without'http' or'https' prefix. + */ + getStrippedUrl(download) { + return lazy.UrlbarUtils.stripPrefixAndTrim(download?.source?.url, { + stripHttp: true, + stripHttps: true, + })[0]; + }, + + /** + * Returns the user-facing label for the given Download object. This is + * normally the leaf name of the download target file. In case this is a very + * old history download for which the target file is unknown, the download + * source URI is displayed. + */ + getDisplayName(download) { + if ( + download.error?.reputationCheckVerdict == + lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM + ) { + let l10n = { + id: "downloads-blocked-from-url", + args: { url: DownloadsViewUI.getStrippedUrl(download) }, + }; + return { l10n }; + } + return download.target.path + ? PathUtils.filename(download.target.path) + : download.source.url; + }, + + /** + * Given a Download object, returns a string representing its file size with + * an appropriate measurement unit, for example "1.5 MB", or an empty string + * if the size is unknown. + */ + getSizeWithUnits(download) { + if (download.target.size === undefined) { + return ""; + } + + let [size, unit] = lazy.DownloadUtils.convertByteUnits( + download.target.size + ); + return lazy.DownloadsCommon.strings.sizeWithUnits(size, unit); + }, + + /** + * Given a context menu and a download element on which it is invoked, + * update items in the context menu to reflect available options for + * that download element. + */ + updateContextMenuForElement(contextMenu, element) { + // Get the state and ensure only the appropriate items are displayed. + let state = parseInt(element.getAttribute("state"), 10); + + const document = contextMenu.ownerDocument; + + const { + DOWNLOAD_NOTSTARTED, + DOWNLOAD_DOWNLOADING, + DOWNLOAD_FINISHED, + DOWNLOAD_FAILED, + DOWNLOAD_CANCELED, + DOWNLOAD_PAUSED, + DOWNLOAD_BLOCKED_PARENTAL, + DOWNLOAD_DIRTY, + DOWNLOAD_BLOCKED_POLICY, + } = lazy.DownloadsCommon; + + contextMenu.querySelector(".downloadPauseMenuItem").hidden = + state != DOWNLOAD_DOWNLOADING; + + contextMenu.querySelector(".downloadResumeMenuItem").hidden = + state != DOWNLOAD_PAUSED; + + // Only show "unblock" for blocked (dirty) items that have not been + // confirmed and have temporary data: + contextMenu.querySelector(".downloadUnblockMenuItem").hidden = + state != DOWNLOAD_DIRTY || !element.classList.contains("temporary-block"); + + // Can only remove finished/failed/canceled/blocked downloads. + contextMenu.querySelector(".downloadRemoveFromHistoryMenuItem").hidden = ![ + DOWNLOAD_FINISHED, + DOWNLOAD_FAILED, + DOWNLOAD_CANCELED, + DOWNLOAD_BLOCKED_PARENTAL, + DOWNLOAD_DIRTY, + DOWNLOAD_BLOCKED_POLICY, + ].includes(state); + + // Can reveal downloads with data on the file system using the relevant OS + // tool (Explorer, Finder, appropriate Linux file system viewer): + contextMenu.querySelector(".downloadShowMenuItem").hidden = + ![ + DOWNLOAD_NOTSTARTED, + DOWNLOAD_DOWNLOADING, + DOWNLOAD_FINISHED, + DOWNLOAD_PAUSED, + ].includes(state) || + (state == DOWNLOAD_FINISHED && !element.hasAttribute("exists")); + + // Show the separator if we're showing either unblock or reveal menu items. + contextMenu.querySelector(".downloadCommandsSeparator").hidden = + contextMenu.querySelector(".downloadUnblockMenuItem").hidden && + contextMenu.querySelector(".downloadShowMenuItem").hidden; + + let download = element._shell.download; + let mimeInfo = lazy.DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault, defaultDescription } = mimeInfo + ? mimeInfo + : {}; + + // Hide the "Delete" item if there's no file data to delete. + contextMenu.querySelector(".downloadDeleteFileMenuItem").hidden = + download.deleted || + !(download.target?.exists || download.target?.partFileExists); + + // Hide the "Go To Download Page" item if there's no referrer. Ideally the + // Downloads API will require a referrer (see bug 1723712) to create a + // download, but this fallback will ensure any failures aren't user facing. + contextMenu.querySelector(".downloadOpenReferrerMenuItem").hidden = + !download.source.referrerInfo?.originalReferrer; + + // Hide the "use system viewer" and "always use system viewer" items + // if the feature is disabled or this download doesn't support it: + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + let canViewInternally = element.hasAttribute("viewable-internally"); + useSystemViewerItem.hidden = + !lazy.DownloadsCommon.openInSystemViewerItemEnabled || + !canViewInternally || + !download.target?.exists; + + alwaysUseSystemViewerItem.hidden = + !lazy.DownloadsCommon.alwaysOpenInSystemViewerItemEnabled || + !canViewInternally; + + // Set menuitem labels to display the system viewer's name. Stop the l10n + // mutation observer temporarily since we're going to synchronously + // translate the elements to avoid translation delay. See bug 1737951 & bug + // 1746748. This can be simplified when they're resolved. + try { + document.l10n.pauseObserving(); + // Handler descriptions longer than 40 characters will be skipped to avoid + // unreasonably stretching the context menu. + if (defaultDescription && defaultDescription.length < 40) { + document.l10n.setAttributes( + useSystemViewerItem, + "downloads-cmd-use-system-default-named", + { handler: defaultDescription } + ); + document.l10n.setAttributes( + alwaysUseSystemViewerItem, + "downloads-cmd-always-use-system-default-named", + { handler: defaultDescription } + ); + } else { + // In the unlikely event that defaultDescription is somehow missing/invalid, + // fall back to the static "Open In System Viewer" label. + document.l10n.setAttributes( + useSystemViewerItem, + "downloads-cmd-use-system-default" + ); + document.l10n.setAttributes( + alwaysUseSystemViewerItem, + "downloads-cmd-always-use-system-default" + ); + } + } finally { + document.l10n.resumeObserving(); + } + document.l10n.translateElements([ + useSystemViewerItem, + alwaysUseSystemViewerItem, + ]); + + // If non default mime-type or cannot be opened internally, display + // "always open similar files" item instead so that users can add a new + // mimetype to about:preferences table and set to open with system default. + let alwaysOpenSimilarFilesItem = contextMenu.querySelector( + ".downloadAlwaysOpenSimilarFilesMenuItem" + ); + + /** + * In HelperAppDlg.sys.mjs, we determine whether or not an "always open..." checkbox + * should appear in the unknownContentType window. Here, we use similar checks to + * determine if we should show the "always open similar files" context menu item. + * + * Note that we also read the content type using mimeInfo to detect better and available + * mime types, given a file extension. Some sites default to "application/octet-stream", + * further limiting what file types can be added to about:preferences, even for file types + * that are in fact capable of being handled with a default application. + * + * There are also cases where download.contentType is undefined (ex. when opening + * the context menu on a previously downloaded item via download history). + * Using mimeInfo ensures that content type exists and prevents intermittence. + */ + // + let filename = PathUtils.filename(download.target.path); + + let isExemptExecutableExtension = + Services.policies.isExemptExecutableExtension( + download.source.originalUrl || download.source.url, + filename?.split(".").at(-1) + ); + + let shouldNotRememberChoice = + !mimeInfo?.type || + mimeInfo.type === "application/octet-stream" || + mimeInfo.type === "application/x-msdownload" || + mimeInfo.type === "application/x-msdos-program" || + (lazy.gReputationService.isExecutable(filename) && + !isExemptExecutableExtension) || + (mimeInfo.type === "text/plain" && + lazy.gReputationService.isBinary(download.target.path)); + + alwaysOpenSimilarFilesItem.hidden = + canViewInternally || + state !== DOWNLOAD_FINISHED || + shouldNotRememberChoice; + + // Update checkbox for "always open..." options. + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + alwaysOpenSimilarFilesItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + alwaysOpenSimilarFilesItem.removeAttribute("checked"); + } + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + DownloadsViewUI, + "clearHistoryOnDelete", + "browser.download.clearHistoryOnDelete", + 0 +); + +DownloadsViewUI.BaseView = class { + canClearDownloads(nodeContainer) { + // Downloads can be cleared if there's at least one removable download in + // the list (either a history download or a completed session download). + // Because history downloads are always removable and are listed after the + // session downloads, check from bottom to top. + for (let elt = nodeContainer.lastChild; elt; elt = elt.previousSibling) { + // Stopped, paused, and failed downloads with partial data are removed. + let download = elt._shell.download; + if (download.stopped && !(download.canceled && download.hasPartialData)) { + return true; + } + } + return false; + } +}; + +/** + * A download element shell is responsible for handling the commands and the + * displayed data for a single element that uses the "download.xml" binding. + * + * The information to display is obtained through the associated Download object + * from the JavaScript API for downloads, and commands are executed using a + * combination of Download methods and DownloadsCommon.sys.mjs helper functions. + * + * Specialized versions of this shell must be defined, and they are required to + * implement the "download" property or getter. Currently these objects are the + * HistoryDownloadElementShell and the DownloadsViewItem for the panel. The + * history view may use a HistoryDownload object in place of a Download object. + */ +DownloadsViewUI.DownloadElementShell = function () {}; + +DownloadsViewUI.DownloadElementShell.prototype = { + /** + * The richlistitem for the download, initialized by the derived object. + */ + element: null, + + /** + * Manages the "active" state of the shell. By default all the shells are + * inactive, thus their UI is not updated. They must be activated when + * entering the visible area. + */ + ensureActive() { + if (!this._active) { + this._active = true; + this.connect(); + this.onChanged(); + } + }, + get active() { + return !!this._active; + }, + + connect() { + let document = this.element.ownerDocument; + let downloadListItemFragment = gDownloadListItemFragments.get(document); + // When changing the markup within the fragment, please ensure that + // the functions within DownloadsView still operate correctly. + if (!downloadListItemFragment) { + let MozXULElement = document.defaultView.MozXULElement; + downloadListItemFragment = MozXULElement.parseXULToFragment(` + + + + + + + + + + +