summaryrefslogtreecommitdiffstats
path: root/browser/components/downloads
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /browser/components/downloads
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/downloads')
-rw-r--r--browser/components/downloads/DownloadSpamProtection.sys.mjs300
-rw-r--r--browser/components/downloads/DownloadsCommon.sys.mjs1642
-rw-r--r--browser/components/downloads/DownloadsMacFinderProgress.sys.mjs84
-rw-r--r--browser/components/downloads/DownloadsTaskbar.sys.mjs220
-rw-r--r--browser/components/downloads/DownloadsViewUI.sys.mjs1201
-rw-r--r--browser/components/downloads/DownloadsViewableInternally.sys.mjs351
-rw-r--r--browser/components/downloads/content/allDownloadsView.js949
-rw-r--r--browser/components/downloads/content/contentAreaDownloadsView.css8
-rw-r--r--browser/components/downloads/content/contentAreaDownloadsView.js49
-rw-r--r--browser/components/downloads/content/contentAreaDownloadsView.xhtml48
-rw-r--r--browser/components/downloads/content/downloads.css106
-rw-r--r--browser/components/downloads/content/downloads.js1722
-rw-r--r--browser/components/downloads/content/downloadsCommands.inc.xhtml29
-rw-r--r--browser/components/downloads/content/downloadsCommands.js17
-rw-r--r--browser/components/downloads/content/downloadsContextMenu.inc.xhtml50
-rw-r--r--browser/components/downloads/content/downloadsPanel.inc.xhtml198
-rw-r--r--browser/components/downloads/content/indicator.js670
-rw-r--r--browser/components/downloads/jar.mn13
-rw-r--r--browser/components/downloads/moz.build30
-rw-r--r--browser/components/downloads/test/browser/blank.JPGbin0 -> 631 bytes
-rw-r--r--browser/components/downloads/test/browser/browser.ini65
-rw-r--r--browser/components/downloads/test/browser/browser_about_downloads.js44
-rw-r--r--browser/components/downloads/test/browser/browser_basic_functionality.js59
-rw-r--r--browser/components/downloads/test/browser/browser_confirm_unblock_download.js110
-rw-r--r--browser/components/downloads/test/browser/browser_download_is_clickable.js78
-rw-r--r--browser/components/downloads/test/browser/browser_download_opens_on_click.js89
-rw-r--r--browser/components/downloads/test/browser/browser_download_opens_policy.js104
-rw-r--r--browser/components/downloads/test/browser/browser_download_overwrite.js126
-rw-r--r--browser/components/downloads/test/browser/browser_download_spam_protection.js220
-rw-r--r--browser/components/downloads/test/browser/browser_download_starts_in_tmp.js264
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_autohide.js517
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_context_menu_always_open_similar_files.js236
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_context_menu_delete_file.js253
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_context_menu_selection.js139
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_keynav.js255
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_block.js185
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js421
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_ctrl_click.js35
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_disable_items.js171
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_dontshow.js126
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_focus.js108
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_height.js35
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_panel_opens.js674
-rw-r--r--browser/components/downloads/test/browser/browser_downloads_pauseResume.js49
-rw-r--r--browser/components/downloads/test/browser/browser_first_download_panel.js68
-rw-r--r--browser/components/downloads/test/browser/browser_go_to_download_page.js93
-rw-r--r--browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js72
-rw-r--r--browser/components/downloads/test/browser/browser_image_mimetype_issues.js135
-rw-r--r--browser/components/downloads/test/browser/browser_indicatorDrop.js38
-rw-r--r--browser/components/downloads/test/browser/browser_libraryDrop.js39
-rw-r--r--browser/components/downloads/test/browser/browser_library_clearall.js122
-rw-r--r--browser/components/downloads/test/browser/browser_library_select_all.js77
-rw-r--r--browser/components/downloads/test/browser/browser_overflow_anchor.js59
-rw-r--r--browser/components/downloads/test/browser/browser_pdfjs_preview.js753
-rw-r--r--browser/components/downloads/test/browser/browser_tempfilename.js88
-rw-r--r--browser/components/downloads/test/browser/foo.txt1
-rw-r--r--browser/components/downloads/test/browser/foo.txt^headers^2
-rw-r--r--browser/components/downloads/test/browser/head.js448
-rw-r--r--browser/components/downloads/test/browser/not-really-a-jpeg.jpegbin0 -> 42 bytes
-rw-r--r--browser/components/downloads/test/browser/not-really-a-jpeg.jpeg^headers^2
-rw-r--r--browser/components/downloads/test/browser/test_spammy_page.html26
-rw-r--r--browser/components/downloads/test/unit/head.js67
-rw-r--r--browser/components/downloads/test/unit/test_DownloadLastDir_basics.js140
-rw-r--r--browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js168
-rw-r--r--browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js147
-rw-r--r--browser/components/downloads/test/unit/test_DownloadsViewableInternally.js277
-rw-r--r--browser/components/downloads/test/unit/xpcshell.ini9
67 files changed, 14881 insertions, 0 deletions
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<String, DownloadSpam>}
+ */
+ _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<Object>}
+ */
+ _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<Window, WindowSpamProtection>}
+ */
+ _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(`
+ <hbox class="downloadMainArea" flex="1" align="center">
+ <image class="downloadTypeIcon"/>
+ <vbox class="downloadContainer" flex="1" pack="center">
+ <description class="downloadTarget" crop="center"/>
+ <description class="downloadDetails downloadDetailsNormal"
+ crop="end"/>
+ <description class="downloadDetails downloadDetailsHover"
+ crop="end"/>
+ <description class="downloadDetails downloadDetailsButtonHover"
+ crop="end"/>
+ </vbox>
+ <image class="downloadBlockedBadge" />
+ </hbox>
+ <button class="downloadButton"/>
+ `);
+ gDownloadListItemFragments.set(document, downloadListItemFragment);
+ }
+ this.element.setAttribute("active", true);
+ this.element.setAttribute("orient", "horizontal");
+ this.element.addEventListener("click", ev => {
+ ev.target.ownerGlobal.DownloadsView.onDownloadClick(ev);
+ });
+ this.element.appendChild(
+ document.importNode(downloadListItemFragment, true)
+ );
+ let downloadButton = this.element.querySelector(".downloadButton");
+ downloadButton.addEventListener("command", function (event) {
+ event.target.ownerGlobal.DownloadsView.onDownloadButton(event);
+ });
+ for (let [propertyName, selector] of [
+ ["_downloadTypeIcon", ".downloadTypeIcon"],
+ ["_downloadTarget", ".downloadTarget"],
+ ["_downloadDetailsNormal", ".downloadDetailsNormal"],
+ ["_downloadDetailsHover", ".downloadDetailsHover"],
+ ["_downloadDetailsButtonHover", ".downloadDetailsButtonHover"],
+ ["_downloadButton", ".downloadButton"],
+ ]) {
+ this[propertyName] = this.element.querySelector(selector);
+ }
+
+ // HTML elements can be created directly without using parseXULToFragment.
+ let progress = (this._downloadProgress = document.createElementNS(
+ HTML_NS,
+ "progress"
+ ));
+ progress.className = "downloadProgress";
+ progress.setAttribute("max", "100");
+ this._downloadTarget.insertAdjacentElement("afterend", progress);
+ },
+
+ /**
+ * URI string for the file type icon displayed in the download element.
+ */
+ get image() {
+ if (!this.download.target.path) {
+ // Old history downloads may not have a target path.
+ return "moz-icon://.unknown?size=32";
+ }
+
+ // When a download that was previously in progress finishes successfully, it
+ // means that the target file now exists and we can extract its specific
+ // icon, for example from a Windows executable. To ensure that the icon is
+ // reloaded, however, we must change the URI used by the XUL image element,
+ // for example by adding a query parameter. This only works if we add one of
+ // the parameters explicitly supported by the nsIMozIconURI interface.
+ return (
+ "moz-icon://" +
+ this.download.target.path +
+ "?size=32" +
+ (this.download.succeeded ? "&state=normal" : "")
+ );
+ },
+
+ get browserWindow() {
+ return lazy.BrowserWindowTracker.getTopWindow();
+ },
+
+ /**
+ * Updates the display name and icon.
+ *
+ * @param displayName
+ * This is usually the full file name of the download without the path.
+ * @param icon
+ * URL of the icon to load, generally from the "image" property.
+ */
+ showDisplayNameAndIcon(displayName, icon) {
+ if (displayName.l10n) {
+ let document = this.element.ownerDocument;
+ document.l10n.setAttributes(
+ this._downloadTarget,
+ displayName.l10n.id,
+ displayName.l10n.args
+ );
+ } else {
+ this._downloadTarget.setAttribute("value", displayName);
+ this._downloadTarget.setAttribute("tooltiptext", displayName);
+ }
+ this._downloadTypeIcon.setAttribute("src", icon);
+ },
+
+ /**
+ * Updates the displayed progress bar.
+ *
+ * @param mode
+ * Either "normal" or "undetermined".
+ * @param value
+ * Percentage of the progress bar to display, from 0 to 100.
+ * @param paused
+ * True to display the progress bar style for paused downloads.
+ */
+ showProgress(mode, value, paused) {
+ if (mode == "undetermined") {
+ this._downloadProgress.removeAttribute("value");
+ } else {
+ this._downloadProgress.setAttribute("value", value);
+ }
+ this._downloadProgress.toggleAttribute("paused", !!paused);
+ },
+
+ /**
+ * Updates the full status line.
+ *
+ * @param status
+ * Status line of the Downloads Panel or the Downloads View.
+ * @param hoverStatus
+ * Label to show in the Downloads Panel when the mouse pointer is over
+ * the main area of the item. If not specified, this will be the same
+ * as the status line. This is ignored in the Downloads View. Type is
+ * either l10n object or string literal.
+ */
+ showStatus(status, hoverStatus = status) {
+ let document = this.element.ownerDocument;
+ if (status?.l10n) {
+ document.l10n.setAttributes(
+ this._downloadDetailsNormal,
+ status.l10n.id,
+ status.l10n.args
+ );
+ } else {
+ this._downloadDetailsNormal.removeAttribute("data-l10n-id");
+ this._downloadDetailsNormal.setAttribute("value", status);
+ this._downloadDetailsNormal.setAttribute("tooltiptext", status);
+ }
+ if (hoverStatus?.l10n) {
+ document.l10n.setAttributes(
+ this._downloadDetailsHover,
+ hoverStatus.l10n.id,
+ hoverStatus.l10n.args
+ );
+ } else {
+ this._downloadDetailsHover.removeAttribute("data-l10n-id");
+ this._downloadDetailsHover.setAttribute("value", hoverStatus);
+ }
+ },
+
+ /**
+ * Updates the status line combining the given state label with other labels.
+ *
+ * @param stateLabel
+ * Label representing the state of the download, for example "Failed".
+ * In the Downloads Panel, this is the only text displayed when the
+ * the mouse pointer is not over the main area of the item. In the
+ * Downloads View, this label is combined with the host and date, for
+ * example "Failed - example.com - 1:45 PM".
+ * @param hoverStatus
+ * Label to show in the Downloads Panel when the mouse pointer is over
+ * the main area of the item. If not specified, this will be the
+ * state label combined with the host and date. This is ignored in the
+ * Downloads View. Type is either l10n object or string literal.
+ */
+ showStatusWithDetails(stateLabel, hoverStatus) {
+ if (stateLabel.l10n) {
+ this.showStatus(stateLabel, hoverStatus);
+ return;
+ }
+ let [displayHost] = lazy.DownloadUtils.getURIHost(this.download.source.url);
+ let [displayDate] = lazy.DownloadUtils.getReadableDates(
+ new Date(this.download.endTime)
+ );
+
+ let firstPart = lazy.DownloadsCommon.strings.statusSeparator(
+ stateLabel,
+ displayHost
+ );
+ let fullStatus = lazy.DownloadsCommon.strings.statusSeparator(
+ firstPart,
+ displayDate
+ );
+
+ if (!this.isPanel) {
+ this.showStatus(fullStatus);
+ } else {
+ this.showStatus(stateLabel, hoverStatus || fullStatus);
+ }
+ },
+
+ /**
+ * Updates the main action button and makes it visible.
+ *
+ * @param type
+ * One of the presets defined in gDownloadElementButtons.
+ */
+ showButton(type) {
+ let { commandName, l10nId, descriptionL10nId, panelL10nId, iconClass } =
+ gDownloadElementButtons[type];
+
+ this.buttonCommandName = commandName;
+ let stringId = this.isPanel ? panelL10nId : l10nId;
+ let document = this.element.ownerDocument;
+ document.l10n.setAttributes(this._downloadButton, stringId);
+ if (this.isPanel && descriptionL10nId) {
+ document.l10n.setAttributes(
+ this._downloadDetailsButtonHover,
+ descriptionL10nId
+ );
+ }
+ this._downloadButton.setAttribute("class", "downloadButton " + iconClass);
+ this._downloadButton.removeAttribute("hidden");
+ },
+
+ hideButton() {
+ this._downloadButton.hidden = true;
+ },
+
+ lastEstimatedSecondsLeft: Infinity,
+
+ /**
+ * This is called when a major state change occurs in the download, but is not
+ * called for every progress update in order to improve performance.
+ */
+ _updateState() {
+ this.showDisplayNameAndIcon(
+ DownloadsViewUI.getDisplayName(this.download),
+ this.image
+ );
+ this.element.setAttribute(
+ "state",
+ lazy.DownloadsCommon.stateOfDownload(this.download)
+ );
+
+ if (!this.download.stopped) {
+ // When the download becomes in progress, we make all the major changes to
+ // the user interface here. The _updateStateInner function takes care of
+ // displaying the right button type for all other state changes.
+ this.showButton("cancel");
+
+ // If there was a verdict set but the download is running we can assume
+ // that the verdict has been overruled and can be removed.
+ this.element.removeAttribute("verdict");
+ }
+
+ // Since state changed, reset the time left estimation.
+ this.lastEstimatedSecondsLeft = Infinity;
+
+ this._updateStateInner();
+ },
+
+ /**
+ * This is called for all changes in the download, including progress updates.
+ * For major state changes, _updateState is called first, but several elements
+ * are still updated here. When the download is in progress, this function
+ * takes a faster path with less element updates to improve performance.
+ */
+ _updateStateInner() {
+ let progressPaused = false;
+
+ this.element.classList.toggle("openWhenFinished", !this.download.stopped);
+
+ if (!this.download.stopped) {
+ // The download is in progress, so we don't change the button state
+ // because the _updateState function already did it. We still need to
+ // update all elements that may change during the download.
+ let totalBytes = this.download.hasProgress
+ ? this.download.totalBytes
+ : -1;
+ let [status, newEstimatedSecondsLeft] =
+ lazy.DownloadUtils.getDownloadStatus(
+ this.download.currentBytes,
+ totalBytes,
+ this.download.speed,
+ this.lastEstimatedSecondsLeft
+ );
+ this.lastEstimatedSecondsLeft = newEstimatedSecondsLeft;
+
+ if (this.download.launchWhenSucceeded) {
+ status = lazy.DownloadUtils.getFormattedTimeStatus(
+ newEstimatedSecondsLeft
+ );
+ }
+ let hoverStatus = {
+ l10n: { id: "downloading-file-click-to-open" },
+ };
+ this.showStatus(status, hoverStatus);
+ } else {
+ let verdict = "";
+
+ // The download is not in progress, so we update the user interface based
+ // on other properties. The order in which we check the properties of the
+ // Download object is the same used by stateOfDownload.
+ if (this.download.deleted) {
+ this.showDeletedOrMissing();
+ } else if (this.download.succeeded) {
+ lazy.DownloadsCommon.log(
+ "_updateStateInner, target exists? ",
+ this.download.target.path,
+ this.download.target.exists
+ );
+ if (this.download.target.exists) {
+ // This is a completed download, and the target file still exists.
+ this.element.setAttribute("exists", "true");
+
+ this.element.toggleAttribute(
+ "viewable-internally",
+ lazy.DownloadIntegration.shouldViewDownloadInternally(
+ lazy.DownloadsCommon.getMimeInfo(this.download)?.type
+ )
+ );
+
+ let sizeWithUnits = DownloadsViewUI.getSizeWithUnits(this.download);
+ if (this.isPanel) {
+ // In the Downloads Panel, we show the file size after the state
+ // label, for example "Completed - 1.5 MB". When the pointer is over
+ // the main area of the item, this label is replaced with a
+ // description of the default action, which opens the file.
+ let status = lazy.DownloadsCommon.strings.stateCompleted;
+ if (sizeWithUnits) {
+ status = lazy.DownloadsCommon.strings.statusSeparator(
+ status,
+ sizeWithUnits
+ );
+ }
+ this.showStatus(status, { l10n: { id: "downloads-open-file" } });
+ } else {
+ // In the Downloads View, we show the file size in place of the
+ // state label, for example "1.5 MB - example.com - 1:45 PM".
+ this.showStatusWithDetails(
+ sizeWithUnits || lazy.DownloadsCommon.strings.sizeUnknown
+ );
+ }
+ this.showButton("show");
+ } else {
+ // This is a completed download, but the target file does not exist
+ // anymore, so the main action of opening the file is unavailable.
+ this.showDeletedOrMissing();
+ }
+ } else if (this.download.error) {
+ if (this.download.error.becauseBlockedByParentalControls) {
+ // This download was blocked permanently by parental controls.
+ this.showStatusWithDetails(
+ lazy.DownloadsCommon.strings.stateBlockedParentalControls
+ );
+ this.hideButton();
+ } else if (this.download.error.becauseBlockedByReputationCheck) {
+ verdict = this.download.error.reputationCheckVerdict;
+ let hover = "";
+ if (!this.download.hasBlockedData) {
+ // This download was blocked permanently by reputation check.
+ this.hideButton();
+ } else if (this.isPanel) {
+ // This download was blocked temporarily by reputation check. In the
+ // Downloads Panel, a subview can be used to remove the file or open
+ // the download anyways.
+ this.showButton("subviewOpenOrRemoveFile");
+ hover = { l10n: { id: "downloads-show-more-information" } };
+ } else {
+ // This download was blocked temporarily by reputation check. In the
+ // Downloads View, the interface depends on the threat severity.
+ switch (verdict) {
+ case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON:
+ case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE:
+ case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED:
+ // Keep the option the user chose on the save dialogue
+ if (this.download.launchWhenSucceeded) {
+ this.showButton("askOpenOrRemoveFile");
+ } else {
+ this.showButton("askRemoveFileOrAllow");
+ }
+ break;
+ case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM:
+ this.showButton("askRemoveFileOrAllow");
+ break;
+ default:
+ // Assume Downloads.Error.BLOCK_VERDICT_MALWARE
+ this.showButton("removeFile");
+ break;
+ }
+ }
+ this.showStatusWithDetails(this.rawBlockedTitleAndDetails[0], hover);
+ } else {
+ // This download failed without being blocked, and can be restarted.
+ this.showStatusWithDetails(lazy.DownloadsCommon.strings.stateFailed);
+ this.showButton("retry");
+ }
+ } else if (this.download.canceled) {
+ if (this.download.hasPartialData) {
+ // This download was paused. The main action button will cancel the
+ // download, and in both the Downloads Panel and the Downlods View the
+ // status includes the size, for example "Paused - 1.1 MB".
+ let totalBytes = this.download.hasProgress
+ ? this.download.totalBytes
+ : -1;
+ let transfer = lazy.DownloadUtils.getTransferTotal(
+ this.download.currentBytes,
+ totalBytes
+ );
+ this.showStatus(
+ lazy.DownloadsCommon.strings.statusSeparatorBeforeNumber(
+ lazy.DownloadsCommon.strings.statePaused,
+ transfer
+ )
+ );
+ this.showButton("cancel");
+ progressPaused = true;
+ } else {
+ // This download was canceled.
+ this.showStatusWithDetails(
+ lazy.DownloadsCommon.strings.stateCanceled
+ );
+ this.showButton("retry");
+ }
+ } else {
+ // This download was added to the global list before it started. While
+ // we still support this case, at the moment it can only be triggered by
+ // internally developed add-ons and regression tests, and should not
+ // happen unless there is a bug. This means the stateStarting string can
+ // probably be removed when converting the localization to Fluent.
+ this.showStatus(lazy.DownloadsCommon.strings.stateStarting);
+ this.showButton("cancel");
+ }
+
+ // These attributes are only set in this slower code path, because they
+ // are irrelevant for downloads that are in progress.
+ if (verdict) {
+ this.element.setAttribute("verdict", verdict);
+ } else {
+ this.element.removeAttribute("verdict");
+ }
+
+ this.element.classList.toggle(
+ "temporary-block",
+ !!this.download.hasBlockedData
+ );
+ }
+
+ // These attributes are set in all code paths, because they are relevant for
+ // downloads that are in progress and for other states.
+ if (this.download.hasProgress) {
+ this.showProgress("normal", this.download.progress, progressPaused);
+ } else {
+ this.showProgress("undetermined", 100, progressPaused);
+ }
+ },
+
+ /**
+ * Returns [title, [details1, details2]] for blocked downloads.
+ * The title or details could be raw strings or l10n objects.
+ */
+ get rawBlockedTitleAndDetails() {
+ let s = lazy.DownloadsCommon.strings;
+ if (
+ !this.download.error ||
+ !this.download.error.becauseBlockedByReputationCheck
+ ) {
+ return [null, null];
+ }
+ switch (this.download.error.reputationCheckVerdict) {
+ case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON:
+ return [s.blockedUncommon2, [s.unblockTypeUncommon2, s.unblockTip2]];
+ case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE:
+ return [
+ s.blockedPotentiallyInsecure,
+ [s.unblockInsecure2, s.unblockTip2],
+ ];
+ case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED:
+ return [
+ s.blockedPotentiallyUnwanted,
+ [s.unblockTypePotentiallyUnwanted2, s.unblockTip2],
+ ];
+ case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE:
+ return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]];
+
+ case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM:
+ let title = {
+ id: "downloads-files-not-downloaded",
+ args: {
+ num: this.download.blockedDownloadsCount,
+ },
+ };
+ let details = {
+ id: "downloads-blocked-download-detailed-info",
+ args: { url: DownloadsViewUI.getStrippedUrl(this.download) },
+ };
+ return [{ l10n: title }, [{ l10n: details }, null]];
+ }
+ throw new Error(
+ "Unexpected reputationCheckVerdict: " +
+ this.download.error.reputationCheckVerdict
+ );
+ },
+
+ showDeletedOrMissing() {
+ this.element.removeAttribute("exists");
+ let label =
+ lazy.DownloadsCommon.strings[
+ this.download.deleted ? "fileDeleted" : "fileMovedOrMissing"
+ ];
+ this.showStatusWithDetails(label, label);
+ this.hideButton();
+ },
+
+ /**
+ * Shows the appropriate unblock dialog based on the verdict, and executes the
+ * action selected by the user in the dialog, which may involve unblocking,
+ * opening or removing the file.
+ *
+ * @param window
+ * The window to which the dialog should be anchored.
+ * @param dialogType
+ * Can be "unblock", "chooseUnblock", or "chooseOpen".
+ */
+ confirmUnblock(window, dialogType) {
+ lazy.DownloadsCommon.confirmUnblockDownload({
+ verdict: this.download.error.reputationCheckVerdict,
+ window,
+ dialogType,
+ })
+ .then(action => {
+ if (action == "open") {
+ return this.unblockAndOpenDownload();
+ } else if (action == "unblock") {
+ return this.download.unblock();
+ } else if (action == "confirmBlock") {
+ return this.download.confirmBlock();
+ }
+ return Promise.resolve();
+ })
+ .catch(console.error);
+ },
+
+ /**
+ * Unblocks the downloaded file and opens it.
+ *
+ * @return A promise that's resolved after the file has been opened.
+ */
+ unblockAndOpenDownload() {
+ return this.download.unblock().then(() => this.downloadsCmd_open());
+ },
+
+ unblockAndSave() {
+ return this.download.unblock();
+ },
+ /**
+ * Returns the name of the default command to use for the current state of the
+ * download, when there is a double click or another default interaction. If
+ * there is no default command for the current state, returns an empty string.
+ * The commands are implemented as functions on this object or derived ones.
+ */
+ get currentDefaultCommandName() {
+ switch (lazy.DownloadsCommon.stateOfDownload(this.download)) {
+ case lazy.DownloadsCommon.DOWNLOAD_NOTSTARTED:
+ return "downloadsCmd_cancel";
+ case lazy.DownloadsCommon.DOWNLOAD_FAILED:
+ case lazy.DownloadsCommon.DOWNLOAD_CANCELED:
+ return "downloadsCmd_retry";
+ case lazy.DownloadsCommon.DOWNLOAD_PAUSED:
+ return "downloadsCmd_pauseResume";
+ case lazy.DownloadsCommon.DOWNLOAD_FINISHED:
+ return "downloadsCmd_open";
+ case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL:
+ return "downloadsCmd_openReferrer";
+ case lazy.DownloadsCommon.DOWNLOAD_DIRTY:
+ return "downloadsCmd_showBlockedInfo";
+ }
+ return "";
+ },
+
+ /**
+ * Returns true if the specified command can be invoked on the current item.
+ * The commands are implemented as functions on this object or derived ones.
+ *
+ * @param aCommand
+ * Name of the command to check, for example "downloadsCmd_retry".
+ */
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "downloadsCmd_retry":
+ return this.download.canceled || !!this.download.error;
+ case "downloadsCmd_pauseResume":
+ return this.download.hasPartialData && !this.download.error;
+ case "downloadsCmd_openReferrer":
+ return (
+ !!this.download.source.referrerInfo &&
+ !!this.download.source.referrerInfo.originalReferrer
+ );
+ case "downloadsCmd_confirmBlock":
+ case "downloadsCmd_chooseUnblock":
+ case "downloadsCmd_chooseOpen":
+ case "downloadsCmd_unblock":
+ case "downloadsCmd_unblockAndSave":
+ case "downloadsCmd_unblockAndOpen":
+ return this.download.hasBlockedData;
+ case "downloadsCmd_cancel":
+ return this.download.hasPartialData || !this.download.stopped;
+ case "downloadsCmd_open":
+ case "downloadsCmd_open:current":
+ case "downloadsCmd_open:tab":
+ case "downloadsCmd_open:tabshifted":
+ case "downloadsCmd_open:window":
+ case "downloadsCmd_alwaysOpenSimilarFiles":
+ // This property is false if the download did not succeed.
+ return this.download.target.exists;
+
+ case "downloadsCmd_show":
+ case "downloadsCmd_deleteFile":
+ let { target } = this.download;
+ return (
+ !this.download.deleted && (target.exists || target.partFileExists)
+ );
+
+ case "downloadsCmd_delete":
+ case "cmd_delete":
+ // We don't want in-progress downloads to be removed accidentally.
+ return this.download.stopped;
+ case "downloadsCmd_openInSystemViewer":
+ case "downloadsCmd_alwaysOpenInSystemViewer":
+ return lazy.DownloadIntegration.shouldViewDownloadInternally(
+ lazy.DownloadsCommon.getMimeInfo(this.download)?.type
+ );
+ }
+ return DownloadsViewUI.isCommandName(aCommand) && !!this[aCommand];
+ },
+
+ doCommand(aCommand) {
+ // split off an optional command "modifier" into an argument,
+ // e.g. "downloadsCmd_open:window"
+ let [command, modifier] = aCommand.split(":");
+ if (DownloadsViewUI.isCommandName(command)) {
+ this[command](modifier);
+ }
+ },
+
+ onButton() {
+ this.doCommand(this.buttonCommandName);
+ },
+
+ downloadsCmd_cancel() {
+ // This is the correct way to avoid race conditions when cancelling.
+ this.download.cancel().catch(() => {});
+ this.download
+ .removePartialData()
+ .catch(console.error)
+ .finally(() => this.download.target.refresh());
+ },
+
+ downloadsCmd_confirmBlock() {
+ this.download.confirmBlock().catch(console.error);
+ },
+
+ downloadsCmd_open(openWhere = "tab") {
+ lazy.DownloadsCommon.openDownload(this.download, {
+ openWhere,
+ });
+ },
+
+ downloadsCmd_openReferrer() {
+ this.element.ownerGlobal.openURL(
+ this.download.source.referrerInfo.originalReferrer
+ );
+ },
+
+ downloadsCmd_pauseResume() {
+ if (this.download.stopped) {
+ this.download.start();
+ } else {
+ this.download.cancel();
+ }
+ },
+
+ downloadsCmd_show() {
+ let file = new lazy.FileUtils.File(this.download.target.path);
+ lazy.DownloadsCommon.showDownloadedFile(file);
+ },
+
+ downloadsCmd_retry() {
+ if (this.download.start) {
+ // Errors when retrying are already reported as download failures.
+ this.download.start().catch(() => {});
+ return;
+ }
+
+ let window = this.browserWindow || this.element.ownerGlobal;
+ let document = window.document;
+
+ // Do not suggest a file name if we don't know the original target.
+ let targetPath = this.download.target.path
+ ? PathUtils.filename(this.download.target.path)
+ : null;
+ window.DownloadURL(this.download.source.url, targetPath, document);
+ },
+
+ downloadsCmd_delete() {
+ // Alias for the 'cmd_delete' command, because it may clash with another
+ // controller which causes unexpected behavior as different codepaths claim
+ // ownership.
+ this.cmd_delete();
+ },
+
+ cmd_delete() {
+ lazy.DownloadsCommon.deleteDownload(this.download).catch(console.error);
+ },
+
+ async downloadsCmd_deleteFile() {
+ // Remove the download from the session and history downloads, delete part files.
+ await lazy.DownloadsCommon.deleteDownloadFiles(
+ this.download,
+ DownloadsViewUI.clearHistoryOnDelete
+ );
+ },
+
+ downloadsCmd_openInSystemViewer() {
+ // For this interaction only, pass a flag to override the preferredAction for this
+ // mime-type and open using the system viewer
+ lazy.DownloadsCommon.openDownload(this.download, {
+ useSystemDefault: true,
+ }).catch(console.error);
+ },
+
+ downloadsCmd_alwaysOpenInSystemViewer() {
+ // this command toggles between setting preferredAction for this mime-type to open
+ // using the system viewer, or to open the file in browser.
+ const mimeInfo = lazy.DownloadsCommon.getMimeInfo(this.download);
+ if (!mimeInfo) {
+ throw new Error(
+ "Can't open download with unknown mime-type in system viewer"
+ );
+ }
+ if (mimeInfo.preferredAction !== mimeInfo.useSystemDefault) {
+ // User has selected to open this mime-type with the system viewer from now on
+ lazy.DownloadsCommon.log(
+ "downloadsCmd_alwaysOpenInSystemViewer command for download: ",
+ this.download,
+ "switching to use system default for " + mimeInfo.type
+ );
+ mimeInfo.preferredAction = mimeInfo.useSystemDefault;
+ mimeInfo.alwaysAskBeforeHandling = false;
+ } else {
+ lazy.DownloadsCommon.log(
+ "downloadsCmd_alwaysOpenInSystemViewer command for download: ",
+ this.download,
+ "currently uses system default, switching to handleInternally"
+ );
+ // User has selected to not open this mime-type with the system viewer
+ mimeInfo.preferredAction = mimeInfo.handleInternally;
+ }
+ lazy.handlerSvc.store(mimeInfo);
+ lazy.DownloadsCommon.openDownload(this.download).catch(console.error);
+ },
+
+ downloadsCmd_alwaysOpenSimilarFiles() {
+ const mimeInfo = lazy.DownloadsCommon.getMimeInfo(this.download);
+ if (!mimeInfo) {
+ throw new Error("Can't open download with unknown mime-type");
+ }
+
+ // User has selected to always open this mime-type from now on and will add this
+ // mime-type to our preferences table with the system default option. Open the
+ // file immediately after selecting the menu item like alwaysOpenInSystemViewer.
+ if (mimeInfo.preferredAction !== mimeInfo.useSystemDefault) {
+ mimeInfo.preferredAction = mimeInfo.useSystemDefault;
+ lazy.handlerSvc.store(mimeInfo);
+ lazy.DownloadsCommon.openDownload(this.download).catch(console.error);
+ } else {
+ // Otherwise, if user unchecks this option after already enabling it from the
+ // context menu, resort to saveToDisk.
+ mimeInfo.preferredAction = mimeInfo.saveToDisk;
+ lazy.handlerSvc.store(mimeInfo);
+ }
+ },
+};
diff --git a/browser/components/downloads/DownloadsViewableInternally.sys.mjs b/browser/components/downloads/DownloadsViewableInternally.sys.mjs
new file mode 100644
index 0000000000..9684e28537
--- /dev/null
+++ b/browser/components/downloads/DownloadsViewableInternally.sys.mjs
@@ -0,0 +1,351 @@
+/* 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/. */
+
+/*
+ * TODO: This is based on what PdfJs was already doing, it would be
+ * best to use this over there as well to reduce duplication and
+ * inconsistency.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "HandlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "MIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Integration: "resource://gre/modules/Integration.sys.mjs",
+});
+
+const PREF_BRANCH = "browser.download.viewableInternally.";
+export const PREF_ENABLED_TYPES = PREF_BRANCH + "enabledTypes";
+export const PREF_BRANCH_WAS_REGISTERED = PREF_BRANCH + "typeWasRegistered.";
+
+export const PREF_BRANCH_PREVIOUS_ACTION =
+ PREF_BRANCH + "previousHandler.preferredAction.";
+
+export const PREF_BRANCH_PREVIOUS_ASK =
+ PREF_BRANCH + "previousHandler.alwaysAskBeforeHandling.";
+
+export let DownloadsViewableInternally = {
+ /**
+ * Initially add/remove handlers, watch pref, register with Integration.downloads.
+ */
+ register() {
+ // Watch the pref
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_enabledTypes",
+ PREF_ENABLED_TYPES,
+ "",
+ () => this._updateAllHandlers(),
+ pref => {
+ let itemStr = pref.trim();
+ return itemStr ? itemStr.split(",").map(s => s.trim()) : [];
+ }
+ );
+
+ for (let handlerType of this._downloadTypesViewableInternally) {
+ if (handlerType.initAvailable) {
+ handlerType.initAvailable();
+ }
+ }
+
+ // Initially update handlers
+ this._updateAllHandlers();
+
+ // Register the check for use in DownloadIntegration
+ lazy.Integration.downloads.register(base => ({
+ shouldViewDownloadInternally:
+ this._shouldViewDownloadInternally.bind(this),
+ }));
+ },
+
+ /**
+ * MIME types to handle with an internal viewer, for downloaded files.
+ *
+ * |extension| is an extenson that will be viewable, as an alternative for
+ * the MIME type itself. It is also used more generally to identify this
+ * type: It is part of a pref name to indicate the handler was set up once,
+ * and it is the string present in |PREF_ENABLED_TYPES| to enable the type.
+ *
+ * |mimeTypes| are the types that will be viewable. A handler is set up for
+ * the first element in the array.
+ *
+ * If |managedElsewhere| is falsy, |_updateAllHandlers()| will set
+ * up or remove handlers for the type, and |_shouldViewDownloadInternally()|
+ * will check for it in |PREF_ENABLED_TYPES|.
+ *
+ * |available| is used to check whether this type should have
+ * handleInternally handlers set up, and if false then
+ * |_shouldViewDownloadInternally()| will also return false for this
+ * type. If |available| would change, |DownloadsViewableInternally._updateHandler()|
+ * should be called for the type.
+ *
+ * |initAvailable()| is an opportunity to initially set |available|, set up
+ * observers to change it when prefs change, etc.
+ *
+ */
+ _downloadTypesViewableInternally: [
+ {
+ extension: "xml",
+ mimeTypes: ["text/xml", "application/xml"],
+ available: true,
+ managedElsewhere: true,
+ },
+ {
+ extension: "svg",
+ mimeTypes: ["image/svg+xml"],
+
+ initAvailable() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "available",
+ "svg.disabled",
+ true,
+ () => DownloadsViewableInternally._updateHandler(this),
+ // transform disabled to enabled/available
+ disabledPref => !disabledPref
+ );
+ },
+ // available getter is set by initAvailable()
+ managedElsewhere: true,
+ },
+ {
+ extension: "webp",
+ mimeTypes: ["image/webp"],
+ initAvailable() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "available",
+ "image.webp.enabled",
+ false,
+ () => DownloadsViewableInternally._updateHandler(this)
+ );
+ },
+ // available getter is set by initAvailable()
+ },
+ {
+ extension: "avif",
+ mimeTypes: ["image/avif"],
+ initAvailable() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "available",
+ "image.avif.enabled",
+ false,
+ () => DownloadsViewableInternally._updateHandler(this)
+ );
+ },
+ // available getter is set by initAvailable()
+ },
+ {
+ extension: "jxl",
+ mimeTypes: ["image/jxl"],
+ initAvailable() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "available",
+ "image.jxl.enabled",
+ false,
+ () => DownloadsViewableInternally._updateHandler(this)
+ );
+ },
+ // available getter is set by initAvailable()
+ },
+ {
+ extension: "pdf",
+ mimeTypes: ["application/pdf"],
+ // PDF uses pdfjs.disabled rather than PREF_ENABLED_TYPES.
+ // pdfjs.disabled isn't checked here because PdfJs's own _becomeHandler
+ // and _unbecomeHandler manage the handler if the pref is set, and there
+ // is an explicit check in nsUnknownContentTypeDialog.shouldShowInternalHandlerOption
+ available: true,
+ managedElsewhere: true,
+ },
+ ],
+
+ /*
+ * Implementation for DownloadIntegration.shouldViewDownloadInternally
+ */
+ _shouldViewDownloadInternally(aMimeType, aExtension) {
+ if (!aMimeType) {
+ return false;
+ }
+
+ return this._downloadTypesViewableInternally.some(handlerType => {
+ if (
+ !handlerType.managedElsewhere &&
+ !this._enabledTypes.includes(handlerType.extension)
+ ) {
+ return false;
+ }
+
+ return (
+ (handlerType.mimeTypes.includes(aMimeType) ||
+ handlerType.extension == aExtension?.toLowerCase()) &&
+ handlerType.available
+ );
+ });
+ },
+
+ _makeFakeHandler(aMimeType, aExtension) {
+ // Based on PdfJs gPdfFakeHandlerInfo.
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIMIMEInfo"]),
+ getFileExtensions() {
+ return [aExtension];
+ },
+ possibleApplicationHandlers: Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ ),
+ extensionExists(ext) {
+ return ext == aExtension;
+ },
+ alwaysAskBeforeHandling: false,
+ preferredAction: Ci.nsIHandlerInfo.handleInternally,
+ type: aMimeType,
+ };
+ },
+
+ _saveSettings(handlerInfo, handlerType) {
+ Services.prefs.setIntPref(
+ PREF_BRANCH_PREVIOUS_ACTION + handlerType.extension,
+ handlerInfo.preferredAction
+ );
+ Services.prefs.setBoolPref(
+ PREF_BRANCH_PREVIOUS_ASK + handlerType.extension,
+ handlerInfo.alwaysAskBeforeHandling
+ );
+ },
+
+ _restoreSettings(handlerInfo, handlerType) {
+ const prevActionPref = PREF_BRANCH_PREVIOUS_ACTION + handlerType.extension;
+ if (Services.prefs.prefHasUserValue(prevActionPref)) {
+ handlerInfo.alwaysAskBeforeHandling = Services.prefs.getBoolPref(
+ PREF_BRANCH_PREVIOUS_ASK + handlerType.extension
+ );
+ handlerInfo.preferredAction = Services.prefs.getIntPref(prevActionPref);
+ lazy.HandlerService.store(handlerInfo);
+ } else {
+ // Nothing to restore, just remove the handler.
+ lazy.HandlerService.remove(handlerInfo);
+ }
+ },
+
+ _clearSavedSettings(extension) {
+ Services.prefs.clearUserPref(PREF_BRANCH_PREVIOUS_ACTION + extension);
+ Services.prefs.clearUserPref(PREF_BRANCH_PREVIOUS_ASK + extension);
+ },
+
+ _updateAllHandlers() {
+ // Set up or remove handlers for each type, if not done already
+ for (const handlerType of this._downloadTypesViewableInternally) {
+ if (!handlerType.managedElsewhere) {
+ this._updateHandler(handlerType);
+ }
+ }
+ },
+
+ _updateHandler(handlerType) {
+ const wasRegistered = Services.prefs.getBoolPref(
+ PREF_BRANCH_WAS_REGISTERED + handlerType.extension,
+ false
+ );
+
+ const toBeRegistered =
+ this._enabledTypes.includes(handlerType.extension) &&
+ handlerType.available;
+
+ if (toBeRegistered && !wasRegistered) {
+ this._becomeHandler(handlerType);
+ } else if (!toBeRegistered && wasRegistered) {
+ this._unbecomeHandler(handlerType);
+ }
+ },
+
+ _becomeHandler(handlerType) {
+ // Set up an empty handler with only a preferred action, to avoid
+ // having to ask the OS about handlers on startup.
+ let fakeHandlerInfo = this._makeFakeHandler(
+ handlerType.mimeTypes[0],
+ handlerType.extension
+ );
+ if (!lazy.HandlerService.exists(fakeHandlerInfo)) {
+ lazy.HandlerService.store(fakeHandlerInfo);
+ } else {
+ const handlerInfo = lazy.MIMEService.getFromTypeAndExtension(
+ handlerType.mimeTypes[0],
+ handlerType.extension
+ );
+
+ if (handlerInfo.preferredAction != Ci.nsIHandlerInfo.handleInternally) {
+ // Save the previous settings of preferredAction and
+ // alwaysAskBeforeHandling in case we need to revert them.
+ // Even if we don't force preferredAction here, the user could
+ // set handleInternally manually.
+ this._saveSettings(handlerInfo, handlerType);
+ } else {
+ // handleInternally shouldn't already have been set, the best we
+ // can do to restore is to remove the handler, so make sure
+ // the settings are clear.
+ this._clearSavedSettings(handlerType.extension);
+ }
+
+ // Replace the preferred action if it didn't indicate an external viewer.
+ // Note: This is a point of departure from PdfJs, which always replaces
+ // the preferred action.
+ if (
+ handlerInfo.preferredAction != Ci.nsIHandlerInfo.useHelperApp &&
+ handlerInfo.preferredAction != Ci.nsIHandlerInfo.useSystemDefault
+ ) {
+ handlerInfo.preferredAction = Ci.nsIHandlerInfo.handleInternally;
+ handlerInfo.alwaysAskBeforeHandling = false;
+
+ lazy.HandlerService.store(handlerInfo);
+ }
+ }
+
+ // Note that we set up for this type so a) we don't keep replacing the
+ // handler and b) so it can be cleared later.
+ Services.prefs.setBoolPref(
+ PREF_BRANCH_WAS_REGISTERED + handlerType.extension,
+ true
+ );
+ },
+
+ _unbecomeHandler(handlerType) {
+ let handlerInfo;
+ try {
+ handlerInfo = lazy.MIMEService.getFromTypeAndExtension(
+ handlerType.mimeTypes[0],
+ handlerType.extension
+ );
+ } catch (ex) {
+ // Allow the handler lookup to fail.
+ }
+ // Restore preferred action if it is still handleInternally
+ // (possibly just removing the handler if nothing was saved for it).
+ if (handlerInfo?.preferredAction == Ci.nsIHandlerInfo.handleInternally) {
+ this._restoreSettings(handlerInfo, handlerType);
+ }
+
+ // In any case we do not control this handler now.
+ this._clearSavedSettings(handlerType.extension);
+ Services.prefs.clearUserPref(
+ PREF_BRANCH_WAS_REGISTERED + handlerType.extension
+ );
+ },
+};
diff --git a/browser/components/downloads/content/allDownloadsView.js b/browser/components/downloads/content/allDownloadsView.js
new file mode 100644
index 0000000000..9245127b0e
--- /dev/null
+++ b/browser/components/downloads/content/allDownloadsView.js
@@ -0,0 +1,949 @@
+/* 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/. */
+/* eslint-env mozilla/browser-window */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+/**
+ * A download element shell is responsible for handling the commands and the
+ * displayed data for a single download view element.
+ *
+ * The shell may contain a session download, a history download, or both. When
+ * both a history and a session download are present, the session download gets
+ * priority and its information is displayed.
+ *
+ * On construction, a new richlistitem is created, and can be accessed through
+ * the |element| getter. The shell doesn't insert the item in a richlistbox, the
+ * caller must do it and remove the element when it's no longer needed.
+ *
+ * The caller is also responsible for forwarding status notifications, calling
+ * the onChanged method.
+ *
+ * @param download
+ * The Download object from the DownloadHistoryList.
+ */
+function HistoryDownloadElementShell(download) {
+ this._download = download;
+
+ this.element = document.createXULElement("richlistitem");
+ this.element._shell = this;
+
+ this.element.classList.add("download");
+ this.element.classList.add("download-state");
+}
+
+HistoryDownloadElementShell.prototype = {
+ /**
+ * Overrides the base getter to return the Download or HistoryDownload object
+ * for displaying information and executing commands in the user interface.
+ */
+ get download() {
+ return this._download;
+ },
+
+ onStateChanged() {
+ // Since the state changed, we may need to check the target file again.
+ this._targetFileChecked = false;
+
+ this._updateState();
+
+ if (this.element.selected) {
+ goUpdateDownloadCommands();
+ } else {
+ // If a state change occurs in an item that is not currently selected,
+ // this is the only command that may be affected.
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ onChanged() {
+ // There is nothing to do if the item has always been invisible.
+ if (!this.active) {
+ return;
+ }
+
+ let newState = DownloadsCommon.stateOfDownload(this.download);
+ if (this._downloadState !== newState) {
+ this._downloadState = newState;
+ this.onStateChanged();
+ } else {
+ this._updateStateInner();
+ }
+ },
+ _downloadState: null,
+
+ isCommandEnabled(aCommand) {
+ // The only valid command for inactive elements is cmd_delete.
+ if (!this.active && aCommand != "cmd_delete") {
+ return false;
+ }
+ return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call(
+ this,
+ aCommand
+ );
+ },
+
+ downloadsCmd_unblock() {
+ this.confirmUnblock(window, "unblock");
+ },
+ downloadsCmd_unblockAndSave() {
+ this.confirmUnblock(window, "unblock");
+ },
+
+ downloadsCmd_chooseUnblock() {
+ this.confirmUnblock(window, "chooseUnblock");
+ },
+
+ downloadsCmd_chooseOpen() {
+ this.confirmUnblock(window, "chooseOpen");
+ },
+
+ // Returns whether or not the download handled by this shell should
+ // show up in the search results for the given term. Both the display
+ // name for the download and the url are searched.
+ matchesSearchTerm(aTerm) {
+ if (!aTerm) {
+ return true;
+ }
+ aTerm = aTerm.toLowerCase();
+ let displayName = DownloadsViewUI.getDisplayName(this.download);
+ return (
+ displayName.toLowerCase().includes(aTerm) ||
+ (this.download.source.originalUrl || this.download.source.url)
+ .toLowerCase()
+ .includes(aTerm)
+ );
+ },
+
+ // Handles double-click and return keypress on the element (the keypress
+ // listener is set in the DownloadsPlacesView object).
+ doDefaultCommand(event) {
+ let command = this.currentDefaultCommandName;
+ if (
+ command == "downloadsCmd_open" &&
+ event &&
+ (event.shiftKey || event.ctrlKey || event.metaKey || event.button == 1)
+ ) {
+ // We adjust the command for supported modifiers to suggest where the download may
+ // be opened.
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let openWhere = browserWin
+ ? browserWin.whereToOpenLink(event, false, true)
+ : "window";
+ if (["window", "tabshifted", "tab"].includes(openWhere)) {
+ command += ":" + openWhere;
+ }
+ }
+
+ if (command && this.isCommandEnabled(command)) {
+ this.doCommand(command);
+ }
+ },
+
+ /**
+ * This method is called by the outer download view, after the controller
+ * commands have already been updated. In case we did not check for the
+ * existence of the target file already, we can do it now and then update
+ * the commands as needed.
+ */
+ onSelect() {
+ if (!this.active) {
+ return;
+ }
+
+ // If this is a history download for which no target file information is
+ // available, we cannot retrieve information about the target file.
+ if (!this.download.target.path) {
+ return;
+ }
+
+ // Start checking for existence. This may be done twice if onSelect is
+ // called again before the information is collected.
+ if (!this._targetFileChecked) {
+ this.download
+ .refresh()
+ .catch(console.error)
+ .then(() => {
+ // Do not try to check for existence again even if this failed.
+ this._targetFileChecked = true;
+ });
+ }
+ },
+};
+Object.setPrototypeOf(
+ HistoryDownloadElementShell.prototype,
+ DownloadsViewUI.DownloadElementShell.prototype
+);
+
+/**
+ * Relays commands from the download.xml binding to the selected items.
+ */
+var DownloadsView = {
+ onDownloadButton(event) {
+ event.target.closest("richlistitem")._shell.onButton();
+ },
+
+ onDownloadClick() {},
+};
+
+/**
+ * A Downloads Places View is a places view designed to show a places query
+ * for history downloads alongside the session downloads.
+ *
+ * As we don't use the places controller, some methods implemented by other
+ * places views are not implemented by this view.
+ *
+ * A richlistitem in this view can represent either a past download or a session
+ * download, or both. Session downloads are shown first in the view, and as long
+ * as they exist they "collapses" their history "counterpart" (So we don't show two
+ * items for every download).
+ */
+function DownloadsPlacesView(
+ aRichListBox,
+ aActive = true,
+ aSuppressionFlag = DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN
+) {
+ this._richlistbox = aRichListBox;
+ this._richlistbox._placesView = this;
+ window.controllers.insertControllerAt(0, this);
+
+ // Map downloads to their element shells.
+ this._viewItemsForDownloads = new WeakMap();
+
+ this._searchTerm = "";
+
+ this._active = aActive;
+
+ // Register as a downloads view. The places data will be initialized by
+ // the places setter.
+ this._initiallySelectedElement = null;
+ this._downloadsData = DownloadsCommon.getData(window.opener || window, true);
+ this._waitingForInitialData = true;
+ this._downloadsData.addView(this);
+
+ // Pause the download indicator as user is interacting with downloads. This is
+ // skipped on about:downloads because it handles this by itself.
+ if (aSuppressionFlag === DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN) {
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ aSuppressionFlag;
+ }
+
+ // Make sure to unregister the view if the window is closed.
+ window.addEventListener(
+ "unload",
+ () => {
+ window.controllers.removeController(this);
+ // Unpause the main window's download indicator.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed &=
+ ~aSuppressionFlag;
+ this._downloadsData.removeView(this);
+ this.result = null;
+ },
+ true
+ );
+ // Resizing the window may change items visibility.
+ window.addEventListener(
+ "resize",
+ () => {
+ this._ensureVisibleElementsAreActive(true);
+ },
+ true
+ );
+}
+
+DownloadsPlacesView.prototype = {
+ get associatedElement() {
+ return this._richlistbox;
+ },
+
+ get active() {
+ return this._active;
+ },
+ set active(val) {
+ this._active = val;
+ if (this._active) {
+ this._ensureVisibleElementsAreActive(true);
+ }
+ },
+
+ /**
+ * Ensure the custom element contents are created and shown for each
+ * visible element in the list.
+ *
+ * @param debounce whether to use a short timeout rather than running
+ * immediately. The default is running immediately. If you
+ * pass `true`, we'll run on a 10ms timeout. This is used to
+ * avoid running this code lots while scrolling or resizing.
+ */
+ _ensureVisibleElementsAreActive(debounce = false) {
+ if (
+ !this.active ||
+ (debounce && this._ensureVisibleTimer) ||
+ !this._richlistbox.firstChild
+ ) {
+ return;
+ }
+
+ if (debounce) {
+ this._ensureVisibleTimer = setTimeout(() => {
+ this._internalEnsureVisibleElementsAreActive();
+ }, 10);
+ } else {
+ this._internalEnsureVisibleElementsAreActive();
+ }
+ },
+
+ _internalEnsureVisibleElementsAreActive() {
+ // If there are no children, we can't do anything so bail out.
+ // However, avoid clearing the timer because there may be children
+ // when the timer fires.
+ if (!this._richlistbox.firstChild) {
+ // If we were called asynchronously (debounced), we need to delete
+ // the timer variable to ensure we are called again if another
+ // debounced call comes in.
+ delete this._ensureVisibleTimer;
+ return;
+ }
+
+ if (this._ensureVisibleTimer) {
+ clearTimeout(this._ensureVisibleTimer);
+ delete this._ensureVisibleTimer;
+ }
+
+ let rlbRect = this._richlistbox.getBoundingClientRect();
+ let winUtils = window.windowUtils;
+ let nodes = winUtils.nodesFromRect(
+ rlbRect.left,
+ rlbRect.top,
+ 0,
+ rlbRect.width,
+ rlbRect.height,
+ 0,
+ true,
+ false,
+ false
+ );
+ // nodesFromRect returns nodes in z-index order, and for the same z-index
+ // sorts them in inverted DOM order, thus starting from the one that would
+ // be on top.
+ let firstVisibleNode, lastVisibleNode;
+ for (let node of nodes) {
+ if (node.localName === "richlistitem" && node._shell) {
+ node._shell.ensureActive();
+ // The first visible node is the last match.
+ firstVisibleNode = node;
+ // While the last visible node is the first match.
+ if (!lastVisibleNode) {
+ lastVisibleNode = node;
+ }
+ }
+ }
+
+ // Also activate the first invisible nodes in both boundaries (that is,
+ // above and below the visible area) to ensure proper keyboard navigation
+ // in both directions.
+ let nodeBelowVisibleArea = lastVisibleNode && lastVisibleNode.nextSibling;
+ if (nodeBelowVisibleArea && nodeBelowVisibleArea._shell) {
+ nodeBelowVisibleArea._shell.ensureActive();
+ }
+
+ let nodeAboveVisibleArea =
+ firstVisibleNode && firstVisibleNode.previousSibling;
+ if (nodeAboveVisibleArea && nodeAboveVisibleArea._shell) {
+ nodeAboveVisibleArea._shell.ensureActive();
+ }
+ },
+
+ _place: "",
+ get place() {
+ return this._place;
+ },
+ set place(val) {
+ if (this._place == val) {
+ // XXXmano: places.js relies on this behavior (see Bug 822203).
+ this.searchTerm = "";
+ } else {
+ this._place = val;
+ }
+ },
+
+ get selectedNodes() {
+ return Array.prototype.filter.call(
+ this._richlistbox.selectedItems,
+ element => element._shell.download.placesNode
+ );
+ },
+
+ get selectedNode() {
+ let selectedNodes = this.selectedNodes;
+ return selectedNodes.length == 1 ? selectedNodes[0] : null;
+ },
+
+ get hasSelection() {
+ return !!this.selectedNodes.length;
+ },
+
+ get controller() {
+ return this._richlistbox.controller;
+ },
+
+ get searchTerm() {
+ return this._searchTerm;
+ },
+ set searchTerm(aValue) {
+ if (this._searchTerm != aValue) {
+ // Always clear selection on a new search, since the user is starting a
+ // different workflow. This also solves the fact we could end up
+ // retaining selection on hidden elements.
+ this._richlistbox.clearSelection();
+ for (let element of this._richlistbox.childNodes) {
+ element.hidden = !element._shell.matchesSearchTerm(aValue);
+ }
+ this._ensureVisibleElementsAreActive();
+ }
+ this._searchTerm = aValue;
+ },
+
+ /**
+ * When the view loads, we want to select the first item.
+ * However, because session downloads, for which the data is loaded
+ * asynchronously, always come first in the list, and because the list
+ * may (or may not) already contain history downloads at that point, it
+ * turns out that by the time we can select the first item, the user may
+ * have already started using the view.
+ * To make things even more complicated, in other cases, the places data
+ * may be loaded after the session downloads data. Thus we cannot rely on
+ * the order in which the data comes in.
+ * We work around this by attempting to select the first element twice,
+ * once after the places data is loaded and once when the session downloads
+ * data is done loading. However, if the selection has changed in-between,
+ * we assume the user has already started using the view and give up.
+ */
+ _ensureInitialSelection() {
+ // Either they're both null, or the selection has not changed in between.
+ if (this._richlistbox.selectedItem == this._initiallySelectedElement) {
+ let firstDownloadElement = this._richlistbox.firstChild;
+ if (firstDownloadElement != this._initiallySelectedElement) {
+ // We may be called before _ensureVisibleElementsAreActive,
+ // therefore, ensure the first item is activated.
+ firstDownloadElement._shell.ensureActive();
+ this._richlistbox.selectedItem = firstDownloadElement;
+ this._richlistbox.currentItem = firstDownloadElement;
+ this._initiallySelectedElement = firstDownloadElement;
+ }
+ }
+ },
+
+ /**
+ * DocumentFragment object that contains all the new elements added during a
+ * batch operation, or null if no batch is in progress.
+ *
+ * Since newest downloads are displayed at the top, elements are normally
+ * prepended to the fragment, and then the fragment is prepended to the list.
+ */
+ batchFragment: null,
+
+ onDownloadBatchStarting() {
+ this.batchFragment = document.createDocumentFragment();
+
+ this.oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+ this._richlistbox.suppressOnSelect = true;
+ },
+
+ onDownloadBatchEnded() {
+ this._richlistbox.suppressOnSelect = this.oldSuppressOnSelect;
+ delete this.oldSuppressOnSelect;
+
+ if (this.batchFragment.childElementCount) {
+ this._prependBatchFragment();
+ }
+ this.batchFragment = null;
+
+ this._ensureInitialSelection();
+ this._ensureVisibleElementsAreActive();
+ goUpdateDownloadCommands();
+ if (this._waitingForInitialData) {
+ this._waitingForInitialData = false;
+ this._richlistbox.dispatchEvent(
+ new CustomEvent("InitialDownloadsLoaded")
+ );
+ }
+ },
+
+ _prependBatchFragment() {
+ // Workaround multiple reflows hang by removing the richlistbox
+ // and adding it back when we're done.
+
+ // Hack for bug 836283: reset xbl fields to their old values after the
+ // binding is reattached to avoid breaking the selection state
+ let xblFields = new Map();
+ for (let key of Object.getOwnPropertyNames(this._richlistbox)) {
+ let value = this._richlistbox[key];
+ xblFields.set(key, value);
+ }
+
+ let oldActiveElement = document.activeElement;
+ let parentNode = this._richlistbox.parentNode;
+ let nextSibling = this._richlistbox.nextSibling;
+ parentNode.removeChild(this._richlistbox);
+ this._richlistbox.prepend(this.batchFragment);
+ parentNode.insertBefore(this._richlistbox, nextSibling);
+ if (oldActiveElement && oldActiveElement != document.activeElement) {
+ oldActiveElement.focus();
+ }
+
+ for (let [key, value] of xblFields) {
+ this._richlistbox[key] = value;
+ }
+ },
+
+ onDownloadAdded(download, { insertBefore } = {}) {
+ let shell = new HistoryDownloadElementShell(download);
+ this._viewItemsForDownloads.set(download, shell);
+
+ // Since newest downloads are displayed at the top, either prepend the new
+ // element or insert it after the one indicated by the insertBefore option.
+ if (insertBefore) {
+ this._viewItemsForDownloads
+ .get(insertBefore)
+ .element.insertAdjacentElement("afterend", shell.element);
+ } else {
+ (this.batchFragment || this._richlistbox).prepend(shell.element);
+ }
+
+ if (this.searchTerm) {
+ shell.element.hidden = !shell.matchesSearchTerm(this.searchTerm);
+ }
+
+ // Don't update commands and visible elements during a batch change.
+ if (!this.batchFragment) {
+ this._ensureVisibleElementsAreActive();
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ onDownloadChanged(download) {
+ this._viewItemsForDownloads.get(download).onChanged();
+ },
+
+ onDownloadRemoved(download) {
+ let element = this._viewItemsForDownloads.get(download).element;
+
+ // If the element was selected exclusively, select its next
+ // sibling first, if not, try for previous sibling, if any.
+ if (
+ (element.nextSibling || element.previousSibling) &&
+ this._richlistbox.selectedItems &&
+ this._richlistbox.selectedItems.length == 1 &&
+ this._richlistbox.selectedItems[0] == element
+ ) {
+ this._richlistbox.selectItem(
+ element.nextSibling || element.previousSibling
+ );
+ }
+
+ this._richlistbox.removeItemFromSelection(element);
+ element.remove();
+
+ // Don't update commands and visible elements during a batch change.
+ if (!this.batchFragment) {
+ this._ensureVisibleElementsAreActive();
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ // nsIController
+ supportsCommand(aCommand) {
+ // Firstly, determine if this is a command that we can handle.
+ if (!DownloadsViewUI.isCommandName(aCommand)) {
+ return false;
+ }
+ if (
+ !(aCommand in this) &&
+ !(aCommand in HistoryDownloadElementShell.prototype)
+ ) {
+ return false;
+ }
+ // If this function returns true, other controllers won't get a chance to
+ // process the command even if isCommandEnabled returns false, so it's
+ // important to check if the list is focused here to handle common commands
+ // like copy and paste correctly. The clear downloads command, instead, is
+ // specific to the downloads list but can be invoked from the toolbar, so we
+ // can just return true unconditionally.
+ return (
+ aCommand == "downloadsCmd_clearDownloads" ||
+ document.activeElement == this._richlistbox
+ );
+ },
+
+ // nsIController
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "cmd_copy":
+ return Array.prototype.some.call(
+ this._richlistbox.selectedItems,
+ element => {
+ const { source } = element._shell.download;
+ return !!(source?.originalUrl || source?.url);
+ }
+ );
+ case "downloadsCmd_openReferrer":
+ case "downloadShowMenuItem":
+ return this._richlistbox.selectedItems.length == 1;
+ case "cmd_selectAll":
+ return true;
+ case "cmd_paste":
+ return this._canDownloadClipboardURL();
+ case "downloadsCmd_clearDownloads":
+ return this.canClearDownloads(this._richlistbox);
+ default:
+ return Array.prototype.every.call(
+ this._richlistbox.selectedItems,
+ element => element._shell.isCommandEnabled(aCommand)
+ );
+ }
+ },
+
+ _copySelectedDownloadsToClipboard() {
+ let urls = Array.from(this._richlistbox.selectedItems, element => {
+ const { source } = element._shell.download;
+ return source?.originalUrl || source?.url;
+ }).filter(Boolean);
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(urls.join("\n"));
+ },
+
+ _getURLFromClipboardData() {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(null);
+
+ let flavors = ["text/x-moz-url", "text/plain"];
+ flavors.forEach(trans.addDataFlavor);
+
+ Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard);
+
+ // Getting the data or creating the nsIURI might fail.
+ try {
+ let data = {};
+ trans.getAnyTransferData({}, data);
+ let [url, name] = data.value
+ .QueryInterface(Ci.nsISupportsString)
+ .data.split("\n");
+ if (url) {
+ return [NetUtil.newURI(url).spec, name];
+ }
+ } catch (ex) {}
+
+ return ["", ""];
+ },
+
+ _canDownloadClipboardURL() {
+ let [url /* ,name */] = this._getURLFromClipboardData();
+ return url != "";
+ },
+
+ _downloadURLFromClipboard() {
+ let [url, name] = this._getURLFromClipboardData();
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let initiatingDoc = browserWin ? browserWin.document : document;
+ DownloadURL(url, name, initiatingDoc);
+ },
+
+ // nsIController
+ doCommand(aCommand) {
+ // Commands may be invoked with keyboard shortcuts even if disabled.
+ if (!this.isCommandEnabled(aCommand)) {
+ return;
+ }
+
+ // If this command is not selection-specific, execute it.
+ if (aCommand in this) {
+ this[aCommand]();
+ return;
+ }
+
+ // Cloning the nodelist into an array to get a frozen list of selected items.
+ // Otherwise, the selectedItems nodelist is live and doCommand may alter the
+ // selection while we are trying to do one particular action, like removing
+ // items from history.
+ let selectedElements = [...this._richlistbox.selectedItems];
+ for (let element of selectedElements) {
+ element._shell.doCommand(aCommand);
+ }
+ },
+
+ // nsIController
+ onEvent() {},
+
+ cmd_copy() {
+ this._copySelectedDownloadsToClipboard();
+ },
+
+ cmd_selectAll() {
+ if (!this.searchTerm) {
+ this._richlistbox.selectAll();
+ return;
+ }
+ // If there is a filtering search term, some rows are hidden and should not
+ // be selected.
+ let oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+ this._richlistbox.suppressOnSelect = true;
+ this._richlistbox.clearSelection();
+ var item = this._richlistbox.getItemAtIndex(0);
+ while (item) {
+ if (!item.hidden) {
+ this._richlistbox.addItemToSelection(item);
+ }
+ item = this._richlistbox.getNextItem(item, 1);
+ }
+ this._richlistbox.suppressOnSelect = oldSuppressOnSelect;
+ },
+
+ cmd_paste() {
+ this._downloadURLFromClipboard();
+ },
+
+ downloadsCmd_clearDownloads() {
+ this._downloadsData.removeFinished();
+ if (this._place) {
+ PlacesUtils.history
+ .removeVisitsByFilter({
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ })
+ .catch(console.error);
+ }
+ // There may be no selection or focus change as a result
+ // of these change, and we want the command updated immediately.
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ },
+
+ onContextMenu(aEvent) {
+ let element = this._richlistbox.selectedItem;
+ if (!element || !element._shell) {
+ return false;
+ }
+
+ let contextMenu = document.getElementById("downloadsContextMenu");
+ DownloadsViewUI.updateContextMenuForElement(contextMenu, element);
+ // Hide the copy location item if there is somehow no URL. We have to do
+ // this here instead of in DownloadsViewUI because DownloadsView doesn't
+ // allow selecting multiple downloads, so in that view the menuitem will be
+ // shown according to whether just the selected item has a source URL.
+ contextMenu.querySelector(".downloadCopyLocationMenuItem").hidden =
+ !Array.prototype.some.call(
+ this._richlistbox.selectedItems,
+ el => !!el._shell.download.source?.url
+ );
+
+ let download = element._shell.download;
+ if (!download.stopped) {
+ // The hasPartialData property of a download may change at any time after
+ // it has started, so ensure we update the related command now.
+ goUpdateCommand("downloadsCmd_pauseResume");
+ }
+
+ return true;
+ },
+
+ onKeyPress(aEvent) {
+ let selectedElements = this._richlistbox.selectedItems;
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ // In the content tree, opening bookmarks by pressing return is only
+ // supported when a single item is selected. To be consistent, do the
+ // same here.
+ if (selectedElements.length == 1) {
+ let element = selectedElements[0];
+ if (element._shell) {
+ element._shell.doDefaultCommand(aEvent);
+ }
+ }
+ } else if (aEvent.charCode == " ".charCodeAt(0)) {
+ let atLeastOneDownloadToggled = false;
+ // Pause/Resume every selected download
+ for (let element of selectedElements) {
+ if (element._shell.isCommandEnabled("downloadsCmd_pauseResume")) {
+ element._shell.doCommand("downloadsCmd_pauseResume");
+ atLeastOneDownloadToggled = true;
+ }
+ }
+
+ if (atLeastOneDownloadToggled) {
+ aEvent.preventDefault();
+ }
+ }
+ },
+
+ onDoubleClick(aEvent) {
+ if (aEvent.button != 0) {
+ return;
+ }
+
+ let selectedElements = this._richlistbox.selectedItems;
+ if (selectedElements.length != 1) {
+ return;
+ }
+
+ let element = selectedElements[0];
+ if (element._shell) {
+ element._shell.doDefaultCommand(aEvent);
+ }
+ },
+
+ onScroll() {
+ this._ensureVisibleElementsAreActive(true);
+ },
+
+ onSelect() {
+ goUpdateDownloadCommands();
+
+ let selectedElements = this._richlistbox.selectedItems;
+ for (let elt of selectedElements) {
+ if (elt._shell) {
+ elt._shell.onSelect();
+ }
+ }
+ },
+
+ onDragStart(aEvent) {
+ // TODO Bug 831358: Support d&d for multiple selection.
+ // For now, we just drag the first element.
+ let selectedItem = this._richlistbox.selectedItem;
+ if (!selectedItem) {
+ return;
+ }
+
+ let targetPath = selectedItem._shell.download.target.path;
+ if (!targetPath) {
+ return;
+ }
+
+ // We must check for existence synchronously because this is a DOM event.
+ let file = new FileUtils.File(targetPath);
+ if (!file.exists()) {
+ return;
+ }
+
+ let dt = aEvent.dataTransfer;
+ dt.mozSetDataAt("application/x-moz-file", file, 0);
+ let url = Services.io.newFileURI(file).spec;
+ dt.setData("text/uri-list", url);
+ dt.setData("text/plain", url);
+ dt.effectAllowed = "copyMove";
+ dt.addElement(selectedItem);
+ },
+
+ onDragOver(aEvent) {
+ let types = aEvent.dataTransfer.types;
+ if (
+ types.includes("text/uri-list") ||
+ types.includes("text/x-moz-url") ||
+ types.includes("text/plain")
+ ) {
+ aEvent.preventDefault();
+ }
+ },
+
+ onDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ // If dragged item is from our source, do not try to
+ // redownload already downloaded file.
+ if (dt.mozGetDataAt("application/x-moz-file", 0)) {
+ return;
+ }
+
+ let links = Services.droppedLinkHandler.dropLinks(aEvent);
+ if (!links.length) {
+ return;
+ }
+ aEvent.preventDefault();
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let initiatingDoc = browserWin ? browserWin.document : document;
+ for (let link of links) {
+ if (link.url.startsWith("about:")) {
+ continue;
+ }
+ DownloadURL(link.url, link.name, initiatingDoc);
+ }
+ },
+};
+Object.setPrototypeOf(
+ DownloadsPlacesView.prototype,
+ DownloadsViewUI.BaseView.prototype
+);
+
+for (let methodName of ["load", "applyFilter", "selectNode", "selectItems"]) {
+ DownloadsPlacesView.prototype[methodName] = function () {
+ throw new Error(
+ "|" + methodName + "| is not implemented by the downloads view."
+ );
+ };
+}
+
+function goUpdateDownloadCommands() {
+ function updateCommandsForObject(object) {
+ for (let name in object) {
+ if (DownloadsViewUI.isCommandName(name)) {
+ goUpdateCommand(name);
+ }
+ }
+ }
+ updateCommandsForObject(DownloadsPlacesView.prototype);
+ updateCommandsForObject(HistoryDownloadElementShell.prototype);
+}
+
+document.addEventListener("DOMContentLoaded", function () {
+ let richListBox = document.getElementById("downloadsListBox");
+ richListBox.addEventListener("scroll", function (event) {
+ return this._placesView.onScroll();
+ });
+ richListBox.addEventListener("keypress", function (event) {
+ return this._placesView.onKeyPress(event);
+ });
+ richListBox.addEventListener("dblclick", function (event) {
+ return this._placesView.onDoubleClick(event);
+ });
+ richListBox.addEventListener("contextmenu", function (event) {
+ return this._placesView.onContextMenu(event);
+ });
+ richListBox.addEventListener("dragstart", function (event) {
+ this._placesView.onDragStart(event);
+ });
+ let dropNode = richListBox;
+ // In about:downloads, also allow drops if the list is empty, by
+ // adding the listener to the document, as the richlistbox is
+ // hidden when it is empty.
+ if (document.documentElement.id == "contentAreaDownloadsView") {
+ dropNode = richListBox.parentNode;
+ }
+ dropNode.addEventListener("dragover", function (event) {
+ richListBox._placesView.onDragOver(event);
+ });
+ dropNode.addEventListener("drop", function (event) {
+ richListBox._placesView.onDrop(event);
+ });
+ richListBox.addEventListener("select", function (event) {
+ this._placesView.onSelect();
+ });
+ richListBox.addEventListener("focus", goUpdateDownloadCommands);
+ richListBox.addEventListener("blur", goUpdateDownloadCommands);
+});
diff --git a/browser/components/downloads/content/contentAreaDownloadsView.css b/browser/components/downloads/content/contentAreaDownloadsView.css
new file mode 100644
index 0000000000..805d13251a
--- /dev/null
+++ b/browser/components/downloads/content/contentAreaDownloadsView.css
@@ -0,0 +1,8 @@
+/* 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/. */
+
+#downloadsListBox:not(:empty) + #downloadsListEmptyDescription,
+#downloadsListBox:empty {
+ display: none;
+}
diff --git a/browser/components/downloads/content/contentAreaDownloadsView.js b/browser/components/downloads/content/contentAreaDownloadsView.js
new file mode 100644
index 0000000000..62c81fc147
--- /dev/null
+++ b/browser/components/downloads/content/contentAreaDownloadsView.js
@@ -0,0 +1,49 @@
+/* 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/. */
+
+/* import-globals-from allDownloadsView.js */
+
+const { PrivateBrowsingUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"
+);
+
+var ContentAreaDownloadsView = {
+ init() {
+ let box = document.getElementById("downloadsListBox");
+ let suppressionFlag = DownloadsCommon.SUPPRESS_CONTENT_AREA_DOWNLOADS_OPEN;
+ box.addEventListener(
+ "InitialDownloadsLoaded",
+ () => {
+ // Set focus to Downloads list once it is created
+ // And prevent it from showing the focus ring around the richlistbox (Bug 1702694)
+ document
+ .getElementById("downloadsListBox")
+ .focus({ focusVisible: false });
+ // Pause the indicator if the browser is active.
+ if (document.visibilityState === "visible") {
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ suppressionFlag;
+ }
+ },
+ { once: true }
+ );
+ let view = new DownloadsPlacesView(box, true, suppressionFlag);
+ document.addEventListener("visibilitychange", aEvent => {
+ let indicator = DownloadsCommon.getIndicatorData(window);
+ if (document.visibilityState === "visible") {
+ indicator.attentionSuppressed |= suppressionFlag;
+ } else {
+ indicator.attentionSuppressed &= ~suppressionFlag;
+ }
+ });
+ // Do not display the Places downloads in private windows
+ if (!PrivateBrowsingUtils.isContentWindowPrivate(window)) {
+ view.place = "place:transition=7&sort=4";
+ }
+ },
+};
+
+window.onload = function () {
+ ContentAreaDownloadsView.init();
+};
diff --git a/browser/components/downloads/content/contentAreaDownloadsView.xhtml b/browser/components/downloads/content/contentAreaDownloadsView.xhtml
new file mode 100644
index 0000000000..4db5d79824
--- /dev/null
+++ b/browser/components/downloads/content/contentAreaDownloadsView.xhtml
@@ -0,0 +1,48 @@
+<?xml version="1.0"?>
+
+# 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/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://browser/content/downloads/contentAreaDownloadsView.css"?>
+<?xml-stylesheet href="chrome://browser/skin/downloads/contentAreaDownloadsView.css"?>
+<?xml-stylesheet href="chrome://browser/content/downloads/downloads.css"?>
+<?xml-stylesheet href="chrome://browser/skin/downloads/allDownloadsView.css"?>
+
+<!DOCTYPE window>
+
+<window id="contentAreaDownloadsView"
+ data-l10n-id="downloads-window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ csp="default-src chrome:; img-src chrome: moz-icon:; object-src 'none'">
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="browser/downloads.ftl" />
+ </linkset>
+
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://browser/content/downloads/contentAreaDownloadsView.js"/>
+ <script src="chrome://browser/content/downloads/allDownloadsView.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+#include ../../../../toolkit/content/editMenuKeys.inc.xhtml
+#ifdef XP_MACOSX
+ <keyset id="editMenuKeysExtra">
+ <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/>
+ </keyset>
+#endif
+
+ <richlistbox flex="1"
+ seltype="multiple"
+ id="downloadsListBox"
+ class="allDownloadsListBox"
+ context="downloadsContextMenu"/>
+ <description id="downloadsListEmptyDescription"
+ data-l10n-id="downloads-list-empty"/>
+#include downloadsCommands.inc.xhtml
+#include downloadsContextMenu.inc.xhtml
+</window>
diff --git a/browser/components/downloads/content/downloads.css b/browser/components/downloads/content/downloads.css
new file mode 100644
index 0000000000..a29144638c
--- /dev/null
+++ b/browser/components/downloads/content/downloads.css
@@ -0,0 +1,106 @@
+/* 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/. */
+
+/*** Downloads Panel ***/
+
+#downloadsListBox > richlistitem:not([selected]) button {
+ /* Only focus buttons in the selected item. */
+ -moz-user-focus: none;
+}
+
+#downloadsSummary:not([inprogress]) > vbox > #downloadsSummaryProgress,
+#downloadsSummary:not([inprogress]) > vbox > #downloadsSummaryDetails,
+#downloadsFooter:not([showingsummary]) #downloadsSummary {
+ display: none;
+}
+
+#downloadsFooter[showingsummary] > stack:hover > #downloadsSummary,
+#downloadsFooter[showingsummary] > stack:not(:hover) > #downloadsFooterButtons {
+ /* If we used "visibility: hidden;" then the mouseenter event of
+ #downloadsHistory wouldn't be triggered immediately, and the hover styling
+ of the button would not apply until the mouse is moved again.
+
+ "-moz-user-focus: ignore;" prevents the elements with "opacity: 0;" from
+ being focused with the keyboard. */
+ opacity: 0;
+ -moz-user-focus: ignore;
+}
+
+/*** Downloads View ***/
+
+#downloadsListBox.allDownloadsListBox > richlistitem button {
+ /* These buttons should never get focus, as that would "disable"
+ the downloads view controller (it's only used when the richlistbox
+ is focused). */
+ -moz-user-focus: none;
+}
+
+/*** Visibility of controls inside download items ***/
+.download-state[buttonhidden] > .downloadButton {
+ display: none;
+}
+
+.download-state:not([state="6"],/* Blocked (parental) */
+ [state="8"],/* Blocked (dirty) */
+ [state="9"] /* Blocked (policy) */)
+ .downloadBlockedBadge,
+
+.download-state:not([state="-1"],/* Starting (initial) */
+ [state="5"], /* Starting (queued) */
+ [state="0"], /* Downloading */
+ [state="4"], /* Paused */
+ [state="7"] /* Scanning */)
+ .downloadProgress {
+ display: none;
+}
+
+/*** Visibility of download button labels ***/
+
+.download-state:not([state="-1"],/* Starting (initial) */
+ [state="5"], /* Starting (queued) */
+ [state="0"], /* Downloading */
+ [state="4"] /* Paused */)
+ .downloadCancel,
+
+.download-state:not([state="2"], /* Failed */
+ [state="3"] /* Canceled */)
+ .downloadRetry,
+
+.download-state:not([state="1"] /* Finished */)
+ .downloadShow {
+ display: none;
+}
+
+/*** Downloads panel ***/
+
+#downloadsPanel[hasdownloads] #emptyDownloads,
+#downloadsPanel:not([hasdownloads]) #downloadsListBox {
+ display: none;
+}
+
+/*** Downloads panel multiview (main view and blocked-downloads subview) ***/
+
+/* Make the panel wide enough to show the download list items without improperly
+ truncating them. */
+#downloadsPanel-multiView > .panel-viewcontainer,
+#downloadsPanel-multiView > .panel-viewcontainer > .panel-viewstack {
+ max-width: unset;
+}
+
+#downloadsPanel-blockedSubview,
+#downloadsPanel-mainView {
+ font: caption;
+ min-width: 37em;
+ padding: 0.62em;
+}
+
+#downloadsHistory,
+#downloadsFooterButtons {
+ margin: 0;
+}
+
+.downloadMainArea,
+.downloadContainer {
+ min-width: 0;
+}
diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js
new file mode 100644
index 0000000000..5554c7e2ab
--- /dev/null
+++ b/browser/components/downloads/content/downloads.js
@@ -0,0 +1,1722 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* 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/. */
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Handles the Downloads panel user interface for each browser window.
+ *
+ * This file includes the following constructors and global objects:
+ *
+ * DownloadsPanel
+ * Main entry point for the downloads panel interface.
+ *
+ * DownloadsView
+ * Builds and updates the downloads list widget, responding to changes in the
+ * download state and real-time data. In addition, handles part of the user
+ * interaction events raised by the downloads list widget.
+ *
+ * DownloadsViewItem
+ * Builds and updates a single item in the downloads list widget, responding to
+ * changes in the download state and real-time data, and handles the user
+ * interaction events related to a single item in the downloads list widgets.
+ *
+ * DownloadsViewController
+ * Handles part of the user interaction events raised by the downloads list
+ * widget, in particular the "commands" that apply to multiple items, and
+ * dispatches the commands that apply to individual items.
+ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+const { Integration } = ChromeUtils.importESModule(
+ "resource://gre/modules/Integration.sys.mjs"
+);
+
+/* global DownloadIntegration */
+Integration.downloads.defineESModuleGetter(
+ this,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+// DownloadsPanel
+
+/**
+ * Main entry point for the downloads panel interface.
+ */
+var DownloadsPanel = {
+ // Initialization and termination
+
+ /**
+ * Timeout that re-enables previously disabled download items in the downloads panel
+ * after some time has passed.
+ */
+ _delayTimeout: null,
+
+ /**
+ * Internal state of the downloads panel, based on one of the kState
+ * constants. This is not the same state as the XUL panel element.
+ */
+ _state: 0,
+
+ /** The panel is not linked to downloads data yet. */
+ get kStateUninitialized() {
+ return 0;
+ },
+ /** This object is linked to data, but the panel is invisible. */
+ get kStateHidden() {
+ return 1;
+ },
+ /** The panel will be shown as soon as possible. */
+ get kStateWaitingData() {
+ return 2;
+ },
+ /** The panel is open. */
+ get kStateShown() {
+ return 3;
+ },
+
+ /**
+ * Starts loading the download data in background, without opening the panel.
+ * Use showPanel instead to load the data and open the panel at the same time.
+ */
+ initialize() {
+ DownloadsCommon.log(
+ "Attempting to initialize DownloadsPanel for a window."
+ );
+
+ if (DownloadIntegration.downloadSpamProtection) {
+ DownloadIntegration.downloadSpamProtection.register(
+ DownloadsView,
+ window
+ );
+ }
+
+ if (this._state != this.kStateUninitialized) {
+ DownloadsCommon.log("DownloadsPanel is already initialized.");
+ return;
+ }
+ this._state = this.kStateHidden;
+
+ window.addEventListener("unload", this.onWindowUnload);
+
+ // Load and resume active downloads if required. If there are downloads to
+ // be shown in the panel, they will be loaded asynchronously.
+ DownloadsCommon.initializeAllDataLinks();
+
+ // Now that data loading has eventually started, load the required XUL
+ // elements and initialize our views.
+
+ this.panel.hidden = false;
+ DownloadsViewController.initialize();
+ DownloadsCommon.log("Attaching DownloadsView...");
+ DownloadsCommon.getData(window).addView(DownloadsView);
+ DownloadsCommon.getSummary(window, DownloadsView.kItemCountLimit).addView(
+ DownloadsSummary
+ );
+
+ DownloadsCommon.log(
+ "DownloadsView attached - the panel for this window",
+ "should now see download items come in."
+ );
+ DownloadsPanel._attachEventListeners();
+ DownloadsCommon.log("DownloadsPanel initialized.");
+ },
+
+ /**
+ * Closes the downloads panel and frees the internal resources related to the
+ * downloads. The downloads panel can be reopened later, even after this
+ * function has been called.
+ */
+ terminate() {
+ DownloadsCommon.log("Attempting to terminate DownloadsPanel for a window.");
+ if (this._state == this.kStateUninitialized) {
+ DownloadsCommon.log(
+ "DownloadsPanel was never initialized. Nothing to do."
+ );
+ return;
+ }
+
+ window.removeEventListener("unload", this.onWindowUnload);
+
+ // Ensure that the panel is closed before shutting down.
+ this.hidePanel();
+
+ DownloadsViewController.terminate();
+ DownloadsCommon.getData(window).removeView(DownloadsView);
+ DownloadsCommon.getSummary(
+ window,
+ DownloadsView.kItemCountLimit
+ ).removeView(DownloadsSummary);
+ this._unattachEventListeners();
+
+ if (DownloadIntegration.downloadSpamProtection) {
+ DownloadIntegration.downloadSpamProtection.unregister(window);
+ }
+
+ this._state = this.kStateUninitialized;
+
+ DownloadsSummary.active = false;
+ DownloadsCommon.log("DownloadsPanel terminated.");
+ },
+
+ // Panel interface
+
+ /**
+ * Main panel element in the browser window.
+ */
+ get panel() {
+ delete this.panel;
+ return (this.panel = document.getElementById("downloadsPanel"));
+ },
+
+ /**
+ * Starts opening the downloads panel interface, anchored to the downloads
+ * button of the browser window. The list of downloads to display is
+ * initialized the first time this method is called, and the panel is shown
+ * only when data is ready.
+ */
+ showPanel(openedManually = false, isKeyPress = false) {
+ Services.telemetry.scalarAdd("downloads.panel_shown", 1);
+ DownloadsCommon.log("Opening the downloads panel.");
+
+ this._openedManually = openedManually;
+ this._preventFocusRing = !openedManually || !isKeyPress;
+
+ if (this.isPanelShowing) {
+ DownloadsCommon.log("Panel is already showing - focusing instead.");
+ this._focusPanel();
+ return;
+ }
+
+ // As a belt-and-suspenders check, ensure the button is not hidden.
+ DownloadsButton.unhide();
+
+ this.initialize();
+ // Delay displaying the panel because this function will sometimes be
+ // called while another window is closing (like the window for selecting
+ // whether to save or open the file), and that would cause the panel to
+ // close immediately.
+ setTimeout(() => this._openPopupIfDataReady(), 0);
+
+ DownloadsCommon.log("Waiting for the downloads panel to appear.");
+ this._state = this.kStateWaitingData;
+ },
+
+ /**
+ * Hides the downloads panel, if visible, but keeps the internal state so that
+ * the panel can be reopened quickly if required.
+ */
+ hidePanel() {
+ DownloadsCommon.log("Closing the downloads panel.");
+
+ if (!this.isPanelShowing) {
+ DownloadsCommon.log("Downloads panel is not showing - nothing to do.");
+ return;
+ }
+
+ PanelMultiView.hidePopup(this.panel);
+
+ // Ensure that we allow the panel to be reopened. Note that, if the popup
+ // was open, then the onPopupHidden event handler has already updated the
+ // current state, otherwise we must update the state ourselves.
+ this._state = this.kStateHidden;
+ DownloadsCommon.log("Downloads panel is now closed.");
+ },
+
+ /**
+ * Indicates whether the panel is shown or will be shown.
+ */
+ get isPanelShowing() {
+ return (
+ this._state == this.kStateWaitingData || this._state == this.kStateShown
+ );
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "mousemove":
+ if (
+ !DownloadsView.contextMenuOpen &&
+ !DownloadsView.subViewOpen &&
+ this.panel.contains(document.activeElement)
+ ) {
+ // Let mouse movement remove focus rings and reset focus in the panel.
+ // This behavior is copied from PanelMultiView.
+ document.activeElement.blur();
+ DownloadsView.richListBox.removeAttribute("force-focus-visible");
+ this._preventFocusRing = true;
+ this._focusPanel();
+ }
+ break;
+ case "keydown":
+ this._onKeyDown(aEvent);
+ break;
+ case "keypress":
+ this._onKeyPress(aEvent);
+ break;
+ case "focus":
+ case "select":
+ this._onSelect(aEvent);
+ break;
+ }
+ },
+
+ // Callback functions from DownloadsView
+
+ /**
+ * Called after data loading finished.
+ */
+ onViewLoadCompleted() {
+ this._openPopupIfDataReady();
+ },
+
+ // User interface event functions
+
+ onWindowUnload() {
+ // This function is registered as an event listener, we can't use "this".
+ DownloadsPanel.terminate();
+ },
+
+ onPopupShown(aEvent) {
+ // Ignore events raised by nested popups.
+ if (aEvent.target != aEvent.currentTarget) {
+ return;
+ }
+
+ DownloadsCommon.log("Downloads panel has shown.");
+ this._state = this.kStateShown;
+
+ // Since at most one popup is open at any given time, we can set globally.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ DownloadsCommon.SUPPRESS_PANEL_OPEN;
+
+ // Ensure that the first item is selected when the panel is focused.
+ if (DownloadsView.richListBox.itemCount > 0) {
+ DownloadsView.richListBox.selectedIndex = 0;
+ }
+
+ this._focusPanel();
+ },
+
+ onPopupHidden(aEvent) {
+ // Ignore events raised by nested popups.
+ if (aEvent.target != aEvent.currentTarget) {
+ return;
+ }
+
+ DownloadsCommon.log("Downloads panel has hidden.");
+
+ if (this._delayTimeout) {
+ DownloadsView.richListBox.removeAttribute("disabled");
+ clearTimeout(this._delayTimeout);
+ this._stopWatchingForSpammyDownloadActivation();
+ this._delayTimeout = null;
+ }
+
+ DownloadsView.richListBox.removeAttribute("force-focus-visible");
+
+ // Since at most one popup is open at any given time, we can set globally.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed &=
+ ~DownloadsCommon.SUPPRESS_PANEL_OPEN;
+
+ // Allow the anchor to be hidden.
+ DownloadsButton.releaseAnchor();
+
+ // Allow the panel to be reopened.
+ this._state = this.kStateHidden;
+ },
+
+ // Related operations
+
+ /**
+ * Shows or focuses the user interface dedicated to downloads history.
+ */
+ showDownloadsHistory() {
+ DownloadsCommon.log("Showing download history.");
+ // Hide the panel before showing another window, otherwise focus will return
+ // to the browser window when the panel closes automatically.
+ this.hidePanel();
+
+ BrowserDownloadsUI();
+ },
+
+ // Internal functions
+
+ /**
+ * Attach event listeners to a panel element. These listeners should be
+ * removed in _unattachEventListeners. This is called automatically after the
+ * panel has successfully loaded.
+ */
+ _attachEventListeners() {
+ // Handle keydown to support accel-V.
+ this.panel.addEventListener("keydown", this);
+ // Handle keypress to be able to preventDefault() events before they reach
+ // the richlistbox, for keyboard navigation.
+ this.panel.addEventListener("keypress", this);
+ this.panel.addEventListener("mousemove", this);
+ DownloadsView.richListBox.addEventListener("focus", this);
+ DownloadsView.richListBox.addEventListener("select", this);
+ },
+
+ /**
+ * Unattach event listeners that were added in _attachEventListeners. This
+ * is called automatically on panel termination.
+ */
+ _unattachEventListeners() {
+ this.panel.removeEventListener("keydown", this);
+ this.panel.removeEventListener("keypress", this);
+ this.panel.removeEventListener("mousemove", this);
+ DownloadsView.richListBox.removeEventListener("focus", this);
+ DownloadsView.richListBox.removeEventListener("select", this);
+ },
+
+ _onKeyPress(aEvent) {
+ // Handle unmodified keys only.
+ if (aEvent.altKey || aEvent.ctrlKey || aEvent.shiftKey || aEvent.metaKey) {
+ return;
+ }
+
+ // Pass keypress events to the richlistbox view when it's focused.
+ if (document.activeElement === DownloadsView.richListBox) {
+ DownloadsView.onDownloadKeyPress(aEvent);
+ }
+ },
+
+ /**
+ * Keydown listener that listens for the keys to start key focusing, as well
+ * as the the accel-V "paste" event, which initiates a file download if the
+ * pasted item can be resolved to a URI.
+ */
+ _onKeyDown(aEvent) {
+ if (DownloadsView.richListBox.hasAttribute("disabled")) {
+ this._handlePotentiallySpammyDownloadActivation(aEvent);
+ return;
+ }
+
+ let richListBox = DownloadsView.richListBox;
+
+ // If the user has pressed the up or down cursor key, force-enable focus
+ // indicators for the richlistbox. :focus-visible doesn't work in this case
+ // because the the focused element may not change here if the richlistbox
+ // already had focus. The force-focus-visible attribute will be removed
+ // again if the user moves the mouse on the panel or if the panel is closed.
+ if (
+ aEvent.keyCode == aEvent.DOM_VK_UP ||
+ aEvent.keyCode == aEvent.DOM_VK_DOWN
+ ) {
+ richListBox.setAttribute("force-focus-visible", "true");
+ }
+
+ // If the footer is focused and the downloads list has at least 1 element
+ // in it, focus the last element in the list when going up.
+ if (aEvent.keyCode == aEvent.DOM_VK_UP && richListBox.firstElementChild) {
+ if (
+ document
+ .getElementById("downloadsFooter")
+ .contains(document.activeElement)
+ ) {
+ richListBox.selectedItem = richListBox.lastElementChild;
+ richListBox.focus();
+ aEvent.preventDefault();
+ return;
+ }
+ }
+
+ if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ // If the last element in the list is selected, or the footer is already
+ // focused, focus the footer.
+ if (
+ DownloadsView.canChangeSelectedItem &&
+ (richListBox.selectedItem === richListBox.lastElementChild ||
+ document
+ .getElementById("downloadsFooter")
+ .contains(document.activeElement))
+ ) {
+ richListBox.selectedIndex = -1;
+ DownloadsFooter.focus();
+ aEvent.preventDefault();
+ return;
+ }
+ }
+
+ let pasting =
+ aEvent.keyCode == aEvent.DOM_VK_V && aEvent.getModifierState("Accel");
+
+ if (!pasting) {
+ return;
+ }
+
+ DownloadsCommon.log("Received a paste event.");
+
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(null);
+ let flavors = ["text/x-moz-url", "text/plain"];
+ flavors.forEach(trans.addDataFlavor);
+ Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard);
+ // Getting the data or creating the nsIURI might fail
+ try {
+ let data = {};
+ trans.getAnyTransferData({}, data);
+ let [url, name] = data.value
+ .QueryInterface(Ci.nsISupportsString)
+ .data.split("\n");
+ if (!url) {
+ return;
+ }
+
+ let uri = NetUtil.newURI(url);
+ DownloadsCommon.log("Pasted URL seems valid. Starting download.");
+ DownloadURL(uri.spec, name, document);
+ } catch (ex) {}
+ },
+
+ _onSelect() {
+ let richlistbox = DownloadsView.richListBox;
+ richlistbox.itemChildren.forEach(item => {
+ let button = item.querySelector("button");
+ if (item.selected) {
+ button.removeAttribute("tabindex");
+ } else {
+ button.setAttribute("tabindex", -1);
+ }
+ });
+ },
+
+ /**
+ * Move focus to the main element in the downloads panel, unless another
+ * element in the panel is already focused.
+ */
+ _focusPanel() {
+ // We may be invoked while the panel is still waiting to be shown.
+ if (this._state != this.kStateShown) {
+ return;
+ }
+
+ if (
+ document.activeElement &&
+ (this.panel.contains(document.activeElement) ||
+ this.panel.shadowRoot.contains(document.activeElement))
+ ) {
+ return;
+ }
+ let focusOptions = {};
+ if (this._preventFocusRing) {
+ focusOptions.focusVisible = false;
+ }
+ if (DownloadsView.richListBox.itemCount > 0) {
+ if (DownloadsView.canChangeSelectedItem) {
+ DownloadsView.richListBox.selectedIndex = 0;
+ }
+ DownloadsView.richListBox.focus(focusOptions);
+ } else {
+ DownloadsFooter.focus(focusOptions);
+ }
+ },
+
+ _delayPopupItems() {
+ DownloadsView.richListBox.setAttribute("disabled", true);
+ this._startWatchingForSpammyDownloadActivation();
+
+ this._refreshDelayTimer();
+ },
+
+ _refreshDelayTimer() {
+ // If timeout already exists, overwrite it to avoid multiple timeouts.
+ if (this._delayTimeout) {
+ clearTimeout(this._delayTimeout);
+ }
+
+ let delay = Services.prefs.getIntPref("security.dialog_enable_delay");
+ this._delayTimeout = setTimeout(() => {
+ DownloadsView.richListBox.removeAttribute("disabled");
+ this._stopWatchingForSpammyDownloadActivation();
+ this._focusPanel();
+ this._delayTimeout = null;
+ }, delay);
+ },
+
+ _startWatchingForSpammyDownloadActivation() {
+ Services.els.addSystemEventListener(window, "keydown", this, true);
+ },
+
+ _lastBeepTime: 0,
+ _handlePotentiallySpammyDownloadActivation(aEvent) {
+ if (aEvent.key == "Enter" || aEvent.key == " ") {
+ // Throttle our beeping to a maximum of once per second, otherwise it
+ // appears on Win10 that beeps never make it through at all.
+ if (Date.now() - this._lastBeepTime > 1000) {
+ Cc["@mozilla.org/sound;1"].getService(Ci.nsISound).beep();
+ this._lastBeepTime = Date.now();
+ }
+
+ this._refreshDelayTimer();
+ }
+ },
+
+ _stopWatchingForSpammyDownloadActivation() {
+ Services.els.removeSystemEventListener(window, "keydown", this, true);
+ },
+
+ /**
+ * Opens the downloads panel when data is ready to be displayed.
+ */
+ _openPopupIfDataReady() {
+ // We don't want to open the popup if we already displayed it, or if we are
+ // still loading data.
+ if (this._state != this.kStateWaitingData || DownloadsView.loading) {
+ return;
+ }
+
+ // At this point, if the window is minimized, opening the panel could fail
+ // without any notification, and there would be no way to either open or
+ // close the panel any more. To prevent this, check if the window is
+ // minimized and in that case force the panel to the closed state.
+ if (window.windowState == window.STATE_MINIMIZED) {
+ this._state = this.kStateHidden;
+ return;
+ }
+
+ // Ensure the anchor is visible. If that is not possible, show the panel
+ // anchored to the top area of the window, near the default anchor position.
+ let anchor = DownloadsButton.getAnchor();
+
+ if (!anchor) {
+ DownloadsCommon.error("Downloads button cannot be found.");
+ this._state = this.kStateHidden;
+ return;
+ }
+
+ let onBookmarksToolbar = !!anchor.closest("#PersonalToolbar");
+ this.panel.classList.toggle("bookmarks-toolbar", onBookmarksToolbar);
+
+ // When the panel is opened, we check if the target files of visible items
+ // still exist, and update the allowed items interactions accordingly. We
+ // do these checks on a background thread, and don't prevent the panel to
+ // be displayed while these checks are being performed.
+ for (let viewItem of DownloadsView._visibleViewItems.values()) {
+ viewItem.download.refresh().catch(console.error);
+ }
+
+ DownloadsCommon.log("Opening downloads panel popup.");
+
+ // Delay displaying the panel because this function will sometimes be
+ // called while another window is closing (like the window for selecting
+ // whether to save or open the file), and that would cause the panel to
+ // close immediately.
+ setTimeout(() => {
+ PanelMultiView.openPopup(
+ this.panel,
+ anchor,
+ "bottomright topright",
+ 0,
+ 0,
+ false,
+ null
+ ).catch(e => {
+ console.error(e);
+ this._state = this.kStateHidden;
+ });
+
+ if (!this._openedManually) {
+ this._delayPopupItems();
+ }
+ }, 0);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsPanel", DownloadsPanel);
+
+// DownloadsView
+
+/**
+ * Builds and updates the downloads list widget, responding to changes in the
+ * download state and real-time data. In addition, handles part of the user
+ * interaction events raised by the downloads list widget.
+ */
+var DownloadsView = {
+ // Functions handling download items in the list
+
+ /**
+ * Maximum number of items shown by the list at any given time.
+ */
+ kItemCountLimit: 5,
+
+ /**
+ * Indicates whether there is a DownloadsBlockedSubview open.
+ */
+ subViewOpen: false,
+
+ /**
+ * Indicates whether we are still loading downloads data asynchronously.
+ */
+ loading: false,
+
+ /**
+ * Ordered array of all Download objects. We need to keep this array because
+ * only a limited number of items are shown at once, and if an item that is
+ * currently visible is removed from the list, we might need to take another
+ * item from the array and make it appear at the bottom.
+ */
+ _downloads: [],
+
+ /**
+ * Associates the visible Download objects with their corresponding
+ * DownloadsViewItem object. There is a limited number of view items in the
+ * panel at any given time.
+ */
+ _visibleViewItems: new Map(),
+
+ /**
+ * Called when the number of items in the list changes.
+ */
+ _itemCountChanged() {
+ DownloadsCommon.log(
+ "The downloads item count has changed - we are tracking",
+ this._downloads.length,
+ "downloads in total."
+ );
+ let count = this._downloads.length;
+ let hiddenCount = count - this.kItemCountLimit;
+
+ if (count > 0) {
+ DownloadsCommon.log(
+ "Setting the panel's hasdownloads attribute to true."
+ );
+ DownloadsPanel.panel.setAttribute("hasdownloads", "true");
+ } else {
+ DownloadsCommon.log("Removing the panel's hasdownloads attribute.");
+ DownloadsPanel.panel.removeAttribute("hasdownloads");
+ }
+
+ // If we've got some hidden downloads, we should activate the
+ // DownloadsSummary. The DownloadsSummary will determine whether or not
+ // it's appropriate to actually display the summary.
+ DownloadsSummary.active = hiddenCount > 0;
+ },
+
+ /**
+ * Element corresponding to the list of downloads.
+ */
+ get richListBox() {
+ delete this.richListBox;
+ return (this.richListBox = document.getElementById("downloadsListBox"));
+ },
+
+ /**
+ * Element corresponding to the button for showing more downloads.
+ */
+ get downloadsHistory() {
+ delete this.downloadsHistory;
+ return (this.downloadsHistory =
+ document.getElementById("downloadsHistory"));
+ },
+
+ // Callback functions from DownloadsData
+
+ /**
+ * Called before multiple downloads are about to be loaded.
+ */
+ onDownloadBatchStarting() {
+ DownloadsCommon.log("onDownloadBatchStarting called for DownloadsView.");
+ this.loading = true;
+ },
+
+ /**
+ * Called after data loading finished.
+ */
+ onDownloadBatchEnded() {
+ DownloadsCommon.log("onDownloadBatchEnded called for DownloadsView.");
+
+ this.loading = false;
+
+ // We suppressed item count change notifications during the batch load, at
+ // this point we should just call the function once.
+ this._itemCountChanged();
+
+ // Notify the panel that all the initially available downloads have been
+ // loaded. This ensures that the interface is visible, if still required.
+ DownloadsPanel.onViewLoadCompleted();
+ },
+
+ /**
+ * Called when a new download data item is available, either during the
+ * asynchronous data load or when a new download is started.
+ *
+ * @param aDownload
+ * Download object that was just added.
+ */
+ onDownloadAdded(download) {
+ DownloadsCommon.log("A new download data item was added");
+
+ this._downloads.unshift(download);
+
+ // The newly added item is visible in the panel and we must add the
+ // corresponding element. If the list overflows, remove the last item from
+ // the panel to make room for the new one that we just added at the top.
+ this._addViewItem(download, true);
+ if (this._downloads.length > this.kItemCountLimit) {
+ this._removeViewItem(this._downloads[this.kItemCountLimit]);
+ }
+
+ // For better performance during batch loads, don't update the count for
+ // every item, because the interface won't be visible until load finishes.
+ if (!this.loading) {
+ this._itemCountChanged();
+ }
+ },
+
+ onDownloadChanged(download) {
+ let viewItem = this._visibleViewItems.get(download);
+ if (viewItem) {
+ viewItem.onChanged();
+ }
+ },
+
+ /**
+ * 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.
+ */
+ onDownloadRemoved(download) {
+ DownloadsCommon.log("A download data item was removed.");
+
+ let itemIndex = this._downloads.indexOf(download);
+ this._downloads.splice(itemIndex, 1);
+
+ if (itemIndex < this.kItemCountLimit) {
+ // The item to remove is visible in the panel.
+ this._removeViewItem(download);
+ if (this._downloads.length >= this.kItemCountLimit) {
+ // Reinsert the next item into the panel.
+ this._addViewItem(this._downloads[this.kItemCountLimit - 1], false);
+ }
+ }
+
+ this._itemCountChanged();
+ },
+
+ /**
+ * Associates each richlistitem for a download with its corresponding
+ * DownloadsViewItem object.
+ */
+ _itemsForElements: new Map(),
+
+ itemForElement(element) {
+ return this._itemsForElements.get(element);
+ },
+
+ /**
+ * Creates a new view item associated with the specified data item, and adds
+ * it to the top or the bottom of the list.
+ */
+ _addViewItem(download, aNewest) {
+ DownloadsCommon.log(
+ "Adding a new DownloadsViewItem to the downloads list.",
+ "aNewest =",
+ aNewest
+ );
+
+ let element = document.createXULElement("richlistitem");
+ element.setAttribute("align", "center");
+
+ let viewItem = new DownloadsViewItem(download, element);
+ this._visibleViewItems.set(download, viewItem);
+ this._itemsForElements.set(element, viewItem);
+ if (aNewest) {
+ this.richListBox.insertBefore(
+ element,
+ this.richListBox.firstElementChild
+ );
+ } else {
+ this.richListBox.appendChild(element);
+ }
+ viewItem.ensureActive();
+ },
+
+ /**
+ * Removes the view item associated with the specified data item.
+ */
+ _removeViewItem(download) {
+ DownloadsCommon.log(
+ "Removing a DownloadsViewItem from the downloads list."
+ );
+ let element = this._visibleViewItems.get(download).element;
+ let previousSelectedIndex = this.richListBox.selectedIndex;
+ this.richListBox.removeChild(element);
+ if (previousSelectedIndex != -1) {
+ this.richListBox.selectedIndex = Math.min(
+ previousSelectedIndex,
+ this.richListBox.itemCount - 1
+ );
+ }
+ this._visibleViewItems.delete(download);
+ this._itemsForElements.delete(element);
+ },
+
+ // User interface event functions
+
+ onDownloadClick(aEvent) {
+ // Handle primary clicks in the main area only:
+ if (aEvent.button == 0 && aEvent.target.closest(".downloadMainArea")) {
+ let target = aEvent.target;
+ while (target.nodeName != "richlistitem") {
+ target = target.parentNode;
+ }
+ let download = DownloadsView.itemForElement(target).download;
+ if (download.succeeded) {
+ download._launchedFromPanel = true;
+ }
+ let command = "downloadsCmd_open";
+ if (download.hasBlockedData) {
+ command = "downloadsCmd_showBlockedInfo";
+ } else if (aEvent.shiftKey || aEvent.ctrlKey || aEvent.metaKey) {
+ // We adjust the command for supported modifiers to suggest where the download
+ // may be opened
+ let openWhere = target.ownerGlobal.whereToOpenLink(aEvent, false, true);
+ if (["tab", "window", "tabshifted"].includes(openWhere)) {
+ command += ":" + openWhere;
+ }
+ }
+ // Toggle opening the file after the download has completed
+ if (!download.stopped && command.startsWith("downloadsCmd_open")) {
+ download.launchWhenSucceeded = !download.launchWhenSucceeded;
+ download._launchedFromPanel = download.launchWhenSucceeded;
+ }
+
+ DownloadsCommon.log("onDownloadClick, resolved command: ", command);
+ goDoCommand(command);
+ }
+ },
+
+ onDownloadButton(event) {
+ let target = event.target.closest("richlistitem");
+ DownloadsView.itemForElement(target).onButton();
+ },
+
+ /**
+ * Handles keypress events on a download item.
+ */
+ onDownloadKeyPress(aEvent) {
+ // Pressing the key on buttons should not invoke the action because the
+ // event has already been handled by the button itself.
+ if (
+ aEvent.originalTarget.hasAttribute("command") ||
+ aEvent.originalTarget.hasAttribute("oncommand")
+ ) {
+ return;
+ }
+
+ if (aEvent.charCode == " ".charCodeAt(0)) {
+ aEvent.preventDefault();
+ goDoCommand("downloadsCmd_pauseResume");
+ return;
+ }
+
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ let readyToDownload = !DownloadsView.richListBox.disabled;
+ if (readyToDownload) {
+ goDoCommand("downloadsCmd_doDefault");
+ }
+ }
+ },
+
+ get contextMenu() {
+ let menu = document.getElementById("downloadsContextMenu");
+ if (menu) {
+ delete this.contextMenu;
+ this.contextMenu = menu;
+ }
+ return menu;
+ },
+
+ /**
+ * Indicates whether there is an open contextMenu for a download item.
+ */
+ get contextMenuOpen() {
+ return this.contextMenu.state != "closed";
+ },
+
+ /**
+ * Whether it's possible to change the currently selected item.
+ */
+ get canChangeSelectedItem() {
+ // When the context menu or a subview are open, the selected item should
+ // not change.
+ return !this.contextMenuOpen && !this.subViewOpen;
+ },
+
+ /**
+ * Mouse listeners to handle selection on hover.
+ */
+ onDownloadMouseOver(aEvent) {
+ let item = aEvent.target.closest("richlistitem,richlistbox");
+ if (item.localName != "richlistitem") {
+ return;
+ }
+
+ if (aEvent.target.classList.contains("downloadButton")) {
+ item.classList.add("downloadHoveringButton");
+ }
+
+ item.classList.toggle(
+ "hoveringMainArea",
+ aEvent.target.closest(".downloadMainArea")
+ );
+
+ if (this.canChangeSelectedItem) {
+ this.richListBox.selectedItem = item;
+ }
+ },
+
+ onDownloadMouseOut(aEvent) {
+ let item = aEvent.target.closest("richlistitem,richlistbox");
+ if (item.localName != "richlistitem") {
+ return;
+ }
+
+ if (aEvent.target.classList.contains("downloadButton")) {
+ item.classList.remove("downloadHoveringButton");
+ }
+
+ // If the destination element is outside of the richlistitem, clear the
+ // selection.
+ if (this.canChangeSelectedItem && !item.contains(aEvent.relatedTarget)) {
+ this.richListBox.selectedIndex = -1;
+ }
+ },
+
+ onDownloadContextMenu(aEvent) {
+ let element = aEvent.originalTarget.closest("richlistitem");
+ if (!element) {
+ aEvent.preventDefault();
+ return;
+ }
+ // Ensure the selected item is the expected one, so commands and the
+ // context menu are updated appropriately.
+ this.richListBox.selectedItem = element;
+ DownloadsViewController.updateCommands();
+
+ DownloadsViewUI.updateContextMenuForElement(this.contextMenu, element);
+ // Hide the copy location item if there is somehow no URL. We have to do
+ // this here instead of in DownloadsViewUI because DownloadsPlacesView
+ // allows selecting multiple downloads, so in that view the menuitem will be
+ // shown according to whether at least one of the selected items has a URL.
+ this.contextMenu.querySelector(".downloadCopyLocationMenuItem").hidden =
+ !element._shell.download.source?.url;
+ },
+
+ onDownloadDragStart(aEvent) {
+ let element = aEvent.target.closest("richlistitem");
+ if (!element) {
+ return;
+ }
+
+ // We must check for existence synchronously because this is a DOM event.
+ let file = new FileUtils.File(
+ DownloadsView.itemForElement(element).download.target.path
+ );
+ if (!file.exists()) {
+ return;
+ }
+
+ let dataTransfer = aEvent.dataTransfer;
+ dataTransfer.mozSetDataAt("application/x-moz-file", file, 0);
+ dataTransfer.effectAllowed = "copyMove";
+ let spec = NetUtil.newURI(file).spec;
+ dataTransfer.setData("text/uri-list", spec);
+ dataTransfer.setData("text/plain", spec);
+ dataTransfer.addElement(element);
+
+ aEvent.stopPropagation();
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsView", DownloadsView);
+
+// DownloadsViewItem
+
+/**
+ * Builds and updates a single item in the downloads list widget, responding to
+ * changes in the download state and real-time data, and handles the user
+ * interaction events related to a single item in the downloads list widgets.
+ *
+ * @param download
+ * Download object to be associated with the view item.
+ * @param aElement
+ * XUL element corresponding to the single download item in the view.
+ */
+
+class DownloadsViewItem extends DownloadsViewUI.DownloadElementShell {
+ constructor(download, aElement) {
+ super();
+
+ this.download = download;
+ this.element = aElement;
+ this.element._shell = this;
+
+ this.element.setAttribute("type", "download");
+ this.element.classList.add("download-state");
+
+ this.isPanel = true;
+ }
+
+ onChanged() {
+ let newState = DownloadsCommon.stateOfDownload(this.download);
+ if (this.downloadState !== newState) {
+ this.downloadState = newState;
+ this._updateState();
+ } else {
+ this._updateStateInner();
+ }
+ }
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "downloadsCmd_open":
+ case "downloadsCmd_open:current":
+ case "downloadsCmd_open:tab":
+ case "downloadsCmd_open:tabshifted":
+ case "downloadsCmd_open:window":
+ case "downloadsCmd_alwaysOpenSimilarFiles": {
+ if (!this.download.succeeded) {
+ return false;
+ }
+
+ let file = new FileUtils.File(this.download.target.path);
+ return file.exists();
+ }
+ case "downloadsCmd_show": {
+ let file = new FileUtils.File(this.download.target.path);
+ if (file.exists()) {
+ return true;
+ }
+
+ if (!this.download.target.partFilePath) {
+ return false;
+ }
+
+ let partFile = new FileUtils.File(this.download.target.partFilePath);
+ return partFile.exists();
+ }
+ case "downloadsCmd_copyLocation":
+ return !!this.download.source?.url;
+ case "cmd_delete":
+ case "downloadsCmd_doDefault":
+ return true;
+ case "downloadsCmd_showBlockedInfo":
+ return this.download.hasBlockedData;
+ }
+ return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call(
+ this,
+ aCommand
+ );
+ }
+
+ doCommand(aCommand) {
+ if (this.isCommandEnabled(aCommand)) {
+ let [command, modifier] = aCommand.split(":");
+ // split off an optional command "modifier" into an argument,
+ // e.g. "downloadsCmd_open:window"
+ this[command](modifier);
+ }
+ }
+
+ // Item commands
+
+ downloadsCmd_unblock() {
+ DownloadsPanel.hidePanel();
+ this.confirmUnblock(window, "unblock");
+ }
+
+ downloadsCmd_chooseUnblock() {
+ DownloadsPanel.hidePanel();
+ this.confirmUnblock(window, "chooseUnblock");
+ }
+
+ downloadsCmd_unblockAndOpen() {
+ DownloadsPanel.hidePanel();
+ this.unblockAndOpenDownload().catch(console.error);
+ }
+ downloadsCmd_unblockAndSave() {
+ DownloadsPanel.hidePanel();
+ this.unblockAndSave();
+ }
+
+ downloadsCmd_open(openWhere) {
+ super.downloadsCmd_open(openWhere);
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ // Otherwise, we'd have to wait for the file-type handler to execute
+ // before the panel would close. This also helps to prevent the user from
+ // accidentally opening a file several times.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_openInSystemViewer() {
+ super.downloadsCmd_openInSystemViewer();
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_alwaysOpenInSystemViewer() {
+ super.downloadsCmd_alwaysOpenInSystemViewer();
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_alwaysOpenSimilarFiles() {
+ super.downloadsCmd_alwaysOpenSimilarFiles();
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_show() {
+ let file = new FileUtils.File(this.download.target.path);
+ DownloadsCommon.showDownloadedFile(file);
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ // Otherwise, we'd have to wait for the operating system file manager
+ // window to open before the panel closed. This also helps to prevent the
+ // user from opening the containing folder several times.
+ DownloadsPanel.hidePanel();
+ }
+
+ async downloadsCmd_deleteFile() {
+ await super.downloadsCmd_deleteFile();
+ // Protects against an unusual edge case where the user:
+ // 1) downloads a file with Firefox; 2) deletes the file from outside of Firefox, e.g., a file manager;
+ // 3) downloads the same file from the same source; 4) opens the downloads panel and uses the menuitem to delete one of those 2 files;
+ // Under those conditions, Firefox will make 2 view items even though there's only 1 file.
+ // Using this method will only delete the view item it was called on, because this instance is not aware of other view items with identical targets.
+ // So the remaining view item needs to be refreshed to hide the "Delete" option.
+ // That example only concerns 2 duplicate view items but you can have an arbitrary number, so iterate over all items...
+ for (let viewItem of DownloadsView._visibleViewItems.values()) {
+ viewItem.download.refresh().catch(console.error);
+ }
+ // Don't use DownloadsPanel.hidePanel for this method because it will remove
+ // the view item from the list, which is already sufficient feedback.
+ }
+
+ downloadsCmd_showBlockedInfo() {
+ DownloadsBlockedSubview.toggle(
+ this.element,
+ ...this.rawBlockedTitleAndDetails
+ );
+ }
+
+ downloadsCmd_openReferrer() {
+ openURL(this.download.source.referrerInfo.originalReferrer);
+ }
+
+ downloadsCmd_copyLocation() {
+ DownloadsCommon.copyDownloadLink(this.download);
+ }
+
+ downloadsCmd_doDefault() {
+ let defaultCommand = this.currentDefaultCommandName;
+ if (defaultCommand && this.isCommandEnabled(defaultCommand)) {
+ this.doCommand(defaultCommand);
+ }
+ }
+}
+
+// DownloadsViewController
+
+/**
+ * Handles part of the user interaction events raised by the downloads list
+ * widget, in particular the "commands" that apply to multiple items, and
+ * dispatches the commands that apply to individual items.
+ */
+var DownloadsViewController = {
+ // Initialization and termination
+
+ initialize() {
+ window.controllers.insertControllerAt(0, this);
+ },
+
+ terminate() {
+ window.controllers.removeController(this);
+ },
+
+ // nsIController
+
+ supportsCommand(aCommand) {
+ if (aCommand === "downloadsCmd_clearList") {
+ return true;
+ }
+ // Firstly, determine if this is a command that we can handle.
+ if (!DownloadsViewUI.isCommandName(aCommand)) {
+ return false;
+ }
+ // Strip off any :modifier suffix before checking if the command name is
+ // a method on our view
+ let [command] = aCommand.split(":");
+ if (!(command in this) && !(command in DownloadsViewItem.prototype)) {
+ return false;
+ }
+ // The currently supported commands depend on whether the blocked subview is
+ // showing. If it is, then take the following path.
+ if (DownloadsView.subViewOpen) {
+ let blockedSubviewCmds = [
+ "downloadsCmd_unblockAndOpen",
+ "cmd_delete",
+ "downloadsCmd_unblockAndSave",
+ ];
+ return blockedSubviewCmds.includes(aCommand);
+ }
+ // If the blocked subview is not showing, then determine if focus is on a
+ // control in the downloads list.
+ let element = document.commandDispatcher.focusedElement;
+ while (element && element != DownloadsView.richListBox) {
+ element = element.parentNode;
+ }
+ // We should handle the command only if the downloads list is among the
+ // ancestors of the focused element.
+ return !!element;
+ },
+
+ isCommandEnabled(aCommand) {
+ // Handle commands that are not selection-specific.
+ if (aCommand == "downloadsCmd_clearList") {
+ return DownloadsCommon.getData(window).canRemoveFinished;
+ }
+
+ // Other commands are selection-specific.
+ let element = DownloadsView.richListBox.selectedItem;
+ return (
+ element &&
+ DownloadsView.itemForElement(element).isCommandEnabled(aCommand)
+ );
+ },
+
+ doCommand(aCommand) {
+ // If this command is not selection-specific, execute it.
+ if (aCommand in this) {
+ this[aCommand]();
+ return;
+ }
+
+ // Other commands are selection-specific.
+ let element = DownloadsView.richListBox.selectedItem;
+ if (element) {
+ // The doCommand function also checks if the command is enabled.
+ DownloadsView.itemForElement(element).doCommand(aCommand);
+ }
+ },
+
+ onEvent() {},
+
+ // Other functions
+
+ updateCommands() {
+ function updateCommandsForObject(object) {
+ for (let name in object) {
+ if (DownloadsViewUI.isCommandName(name)) {
+ goUpdateCommand(name);
+ }
+ }
+ }
+ updateCommandsForObject(this);
+ updateCommandsForObject(DownloadsViewItem.prototype);
+ },
+
+ // Selection-independent commands
+
+ downloadsCmd_clearList() {
+ DownloadsCommon.getData(window).removeFinished();
+ },
+};
+
+XPCOMUtils.defineConstant(
+ this,
+ "DownloadsViewController",
+ DownloadsViewController
+);
+
+// DownloadsSummary
+
+/**
+ * Manages the summary at the bottom of the downloads panel list if the number
+ * of items in the list exceeds the panels limit.
+ */
+var DownloadsSummary = {
+ /**
+ * Sets the active state of the summary. When active, the summary subscribes
+ * to the DownloadsCommon DownloadsSummaryData singleton.
+ *
+ * @param aActive
+ * Set to true to activate the summary.
+ */
+ set active(aActive) {
+ if (aActive == this._active || !this._summaryNode) {
+ return;
+ }
+ if (aActive) {
+ DownloadsCommon.getSummary(
+ window,
+ DownloadsView.kItemCountLimit
+ ).refreshView(this);
+ } else {
+ DownloadsFooter.showingSummary = false;
+ }
+
+ this._active = aActive;
+ },
+
+ /**
+ * Returns the active state of the downloads summary.
+ */
+ get active() {
+ return this._active;
+ },
+
+ _active: false,
+
+ /**
+ * Sets whether or not we show the progress bar.
+ *
+ * @param aShowingProgress
+ * True if we should show the progress bar.
+ */
+ set showingProgress(aShowingProgress) {
+ if (aShowingProgress) {
+ this._summaryNode.setAttribute("inprogress", "true");
+ } else {
+ this._summaryNode.removeAttribute("inprogress");
+ }
+ // If progress isn't being shown, then we simply do not show the summary.
+ DownloadsFooter.showingSummary = aShowingProgress;
+ },
+
+ /**
+ * Sets the amount of progress that is visible in the progress bar.
+ *
+ * @param aValue
+ * A value between 0 and 100 to represent the progress of the
+ * summarized downloads.
+ */
+ set percentComplete(aValue) {
+ if (this._progressNode) {
+ this._progressNode.setAttribute("value", aValue);
+ }
+ },
+
+ /**
+ * Sets the description for the download summary.
+ *
+ * @param aValue
+ * A string representing the description of the summarized
+ * downloads.
+ */
+ set description(aValue) {
+ if (this._descriptionNode) {
+ this._descriptionNode.setAttribute("value", aValue);
+ this._descriptionNode.setAttribute("tooltiptext", aValue);
+ }
+ },
+
+ /**
+ * Sets the details for the download summary, such as the time remaining,
+ * the amount of bytes transferred, etc.
+ *
+ * @param aValue
+ * A string representing the details of the summarized
+ * downloads.
+ */
+ set details(aValue) {
+ if (this._detailsNode) {
+ this._detailsNode.setAttribute("value", aValue);
+ this._detailsNode.setAttribute("tooltiptext", aValue);
+ }
+ },
+
+ /**
+ * Focuses the root element of the summary.
+ */
+ focus(focusOptions) {
+ if (this._summaryNode) {
+ this._summaryNode.focus(focusOptions);
+ }
+ },
+
+ /**
+ * Respond to keydown events on the Downloads Summary node.
+ *
+ * @param aEvent
+ * The keydown event being handled.
+ */
+ onKeyDown(aEvent) {
+ if (
+ aEvent.charCode == " ".charCodeAt(0) ||
+ aEvent.keyCode == KeyEvent.DOM_VK_RETURN
+ ) {
+ DownloadsPanel.showDownloadsHistory();
+ }
+ },
+
+ /**
+ * Respond to click events on the Downloads Summary node.
+ *
+ * @param aEvent
+ * The click event being handled.
+ */
+ onClick(aEvent) {
+ DownloadsPanel.showDownloadsHistory();
+ },
+
+ /**
+ * Element corresponding to the root of the downloads summary.
+ */
+ get _summaryNode() {
+ let node = document.getElementById("downloadsSummary");
+ if (!node) {
+ return null;
+ }
+ delete this._summaryNode;
+ return (this._summaryNode = node);
+ },
+
+ /**
+ * Element corresponding to the progress bar in the downloads summary.
+ */
+ get _progressNode() {
+ let node = document.getElementById("downloadsSummaryProgress");
+ if (!node) {
+ return null;
+ }
+ delete this._progressNode;
+ return (this._progressNode = node);
+ },
+
+ /**
+ * Element corresponding to the main description of the downloads
+ * summary.
+ */
+ get _descriptionNode() {
+ let node = document.getElementById("downloadsSummaryDescription");
+ if (!node) {
+ return null;
+ }
+ delete this._descriptionNode;
+ return (this._descriptionNode = node);
+ },
+
+ /**
+ * Element corresponding to the secondary description of the downloads
+ * summary.
+ */
+ get _detailsNode() {
+ let node = document.getElementById("downloadsSummaryDetails");
+ if (!node) {
+ return null;
+ }
+ delete this._detailsNode;
+ return (this._detailsNode = node);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsSummary", DownloadsSummary);
+
+// DownloadsFooter
+
+/**
+ * Manages events sent to to the footer vbox, which contains both the
+ * DownloadsSummary as well as the "Show all downloads" button.
+ */
+var DownloadsFooter = {
+ /**
+ * Focuses the appropriate element within the footer. If the summary
+ * is visible, focus it. If not, focus the "Show all downloads"
+ * button.
+ */
+ focus(focusOptions) {
+ if (this._showingSummary) {
+ DownloadsSummary.focus(focusOptions);
+ } else {
+ DownloadsView.downloadsHistory.focus(focusOptions);
+ }
+ },
+
+ _showingSummary: false,
+
+ /**
+ * Sets whether or not the Downloads Summary should be displayed in the
+ * footer. If not, the "Show all downloads" button is shown instead.
+ */
+ set showingSummary(aValue) {
+ if (this._footerNode) {
+ if (aValue) {
+ this._footerNode.setAttribute("showingsummary", "true");
+ } else {
+ this._footerNode.removeAttribute("showingsummary");
+ }
+ this._showingSummary = aValue;
+ }
+ },
+
+ /**
+ * Element corresponding to the footer of the downloads panel.
+ */
+ get _footerNode() {
+ let node = document.getElementById("downloadsFooter");
+ if (!node) {
+ return null;
+ }
+ delete this._footerNode;
+ return (this._footerNode = node);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsFooter", DownloadsFooter);
+
+// DownloadsBlockedSubview
+
+/**
+ * Manages the blocked subview that slides in when you click a blocked download.
+ */
+var DownloadsBlockedSubview = {
+ /**
+ * Elements in the subview.
+ */
+ get elements() {
+ let idSuffixes = [
+ "title",
+ "details1",
+ "details2",
+ "unblockButton",
+ "deleteButton",
+ ];
+ let elements = idSuffixes.reduce((memo, s) => {
+ memo[s] = document.getElementById("downloadsPanel-blockedSubview-" + s);
+ return memo;
+ }, {});
+ delete this.elements;
+ return (this.elements = elements);
+ },
+
+ /**
+ * The blocked-download richlistitem element that was clicked to show the
+ * subview. If the subview is not showing, this is undefined.
+ */
+ element: undefined,
+
+ /**
+ * Slides in the blocked subview.
+ *
+ * @param element
+ * The blocked-download richlistitem element that was clicked.
+ * @param title
+ * The title to show in the subview.
+ * @param details
+ * An array of strings with information about the block.
+ */
+ toggle(element, title, details) {
+ DownloadsView.subViewOpen = true;
+ DownloadsViewController.updateCommands();
+ const { download } = DownloadsView.itemForElement(element);
+
+ let e = this.elements;
+ let s = DownloadsCommon.strings;
+
+ title.l10n
+ ? document.l10n.setAttributes(e.title, title.l10n.id, title.l10n.args)
+ : (e.title.textContent = title);
+
+ details[0].l10n
+ ? document.l10n.setAttributes(
+ e.details1,
+ details[0].l10n.id,
+ details[0].l10n.args
+ )
+ : (e.details1.textContent = details[0]);
+
+ e.details2.textContent = details[1];
+
+ if (download.launchWhenSucceeded) {
+ e.unblockButton.label = s.unblockButtonOpen;
+ e.unblockButton.command = "downloadsCmd_unblockAndOpen";
+ } else {
+ e.unblockButton.label = s.unblockButtonUnblock;
+ e.unblockButton.command = "downloadsCmd_unblockAndSave";
+ }
+
+ e.deleteButton.label = s.unblockButtonConfirmBlock;
+
+ let verdict = element.getAttribute("verdict");
+ this.subview.setAttribute("verdict", verdict);
+
+ this.mainView.addEventListener("ViewShown", this);
+ DownloadsPanel.panel.addEventListener("popuphidden", this);
+ this.panelMultiView.showSubView(this.subview);
+
+ // Without this, the mainView is more narrow than the panel once all
+ // downloads are removed from the panel.
+ this.mainView.style.minWidth = window.getComputedStyle(this.subview).width;
+ },
+
+ handleEvent(event) {
+ // This is called when the main view is shown or the panel is hidden.
+ DownloadsView.subViewOpen = false;
+ this.mainView.removeEventListener("ViewShown", this);
+ DownloadsPanel.panel.removeEventListener("popuphidden", this);
+ // Focus the proper element if we're going back to the main panel.
+ if (event.type == "ViewShown") {
+ DownloadsPanel.showPanel();
+ }
+ },
+
+ /**
+ * Deletes the download and hides the entire panel.
+ */
+ confirmBlock() {
+ goDoCommand("cmd_delete");
+ DownloadsPanel.hidePanel();
+ },
+};
+
+XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "panelMultiView", () =>
+ document.getElementById("downloadsPanel-multiView")
+);
+XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "mainView", () =>
+ document.getElementById("downloadsPanel-mainView")
+);
+XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "subview", () =>
+ document.getElementById("downloadsPanel-blockedSubview")
+);
+
+XPCOMUtils.defineConstant(
+ this,
+ "DownloadsBlockedSubview",
+ DownloadsBlockedSubview
+);
diff --git a/browser/components/downloads/content/downloadsCommands.inc.xhtml b/browser/components/downloads/content/downloadsCommands.inc.xhtml
new file mode 100644
index 0000000000..2b144f319e
--- /dev/null
+++ b/browser/components/downloads/content/downloadsCommands.inc.xhtml
@@ -0,0 +1,29 @@
+# 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/.
+
+<script src="chrome://browser/content/downloads/downloadsCommands.js"/>
+
+<commandset id="downloadCommands"
+ commandupdater="true"
+ events="focus,select,contextmenu">
+ <command id="downloadsCmd_pauseResume"/>
+ <command id="downloadsCmd_cancel"/>
+ <command id="downloadsCmd_unblock"/>
+ <command id="downloadsCmd_chooseUnblock"/>
+ <command id="downloadsCmd_chooseOpen"/>
+ <command id="downloadsCmd_confirmBlock"/>
+ <command id="downloadsCmd_open"/>
+ <command id="downloadsCmd_open:current"/>
+ <command id="downloadsCmd_open:tab"/>
+ <command id="downloadsCmd_open:tabshifted"/>
+ <command id="downloadsCmd_open:window"/>
+ <command id="downloadsCmd_show"/>
+ <command id="downloadsCmd_retry"/>
+ <command id="downloadsCmd_openReferrer"/>
+ <command id="downloadsCmd_clearDownloads"/>
+ <command id="downloadsCmd_openInSystemViewer"/>
+ <command id="downloadsCmd_alwaysOpenInSystemViewer"/>
+ <command id="downloadsCmd_alwaysOpenSimilarFiles"/>
+ <command id="downloadsCmd_deleteFile"/>
+</commandset>
diff --git a/browser/components/downloads/content/downloadsCommands.js b/browser/components/downloads/content/downloadsCommands.js
new file mode 100644
index 0000000000..fd7dfce351
--- /dev/null
+++ b/browser/components/downloads/content/downloadsCommands.js
@@ -0,0 +1,17 @@
+/* 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/. */
+
+/* import-globals-from allDownloadsView.js */
+/* import-globals-from /toolkit/content/globalOverlay.js */
+
+document.addEventListener("DOMContentLoaded", function () {
+ let downloadCommands = document.getElementById("downloadCommands");
+ downloadCommands.addEventListener("commandupdate", function () {
+ goUpdateDownloadCommands();
+ });
+ downloadCommands.addEventListener("command", function (event) {
+ let { id } = event.target;
+ goDoCommand(id);
+ });
+});
diff --git a/browser/components/downloads/content/downloadsContextMenu.inc.xhtml b/browser/components/downloads/content/downloadsContextMenu.inc.xhtml
new file mode 100644
index 0000000000..61d730c9d9
--- /dev/null
+++ b/browser/components/downloads/content/downloadsContextMenu.inc.xhtml
@@ -0,0 +1,50 @@
+# 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/.
+
+<menupopup id="downloadsContextMenu" class="download-state">
+
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadPauseMenuItem"
+ data-l10n-id="downloads-cmd-pause"/>
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadResumeMenuItem"
+ data-l10n-id="downloads-cmd-resume"/>
+ <menuitem command="downloadsCmd_unblock"
+ class="downloadUnblockMenuItem"
+ data-l10n-id="downloads-cmd-unblock"/>
+ <menuitem command="downloadsCmd_openInSystemViewer"
+ class="downloadUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenInSystemViewer"
+ type="checkbox"
+ class="downloadAlwaysUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-always-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenSimilarFiles"
+ type="checkbox"
+ class="downloadAlwaysOpenSimilarFilesMenuItem"
+ data-l10n-id="downloads-cmd-always-open-similar-files"/>
+ <menuitem command="downloadsCmd_show"
+ class="downloadShowMenuItem"
+ data-l10n-id="downloads-cmd-show-menuitem-2"/>
+
+ <menuseparator class="downloadCommandsSeparator"/>
+
+ <menuitem command="downloadsCmd_openReferrer"
+ class="downloadOpenReferrerMenuItem"
+ data-l10n-id="downloads-cmd-go-to-download-page"/>
+ <menuitem command="cmd_copy"
+ class="downloadCopyLocationMenuItem"
+ data-l10n-id="downloads-cmd-copy-download-link"/>
+
+ <menuseparator/>
+
+ <menuitem command="downloadsCmd_deleteFile"
+ class="downloadDeleteFileMenuItem"
+ data-l10n-id="downloads-cmd-delete-file"/>
+ <menuitem command="cmd_delete"
+ class="downloadRemoveFromHistoryMenuItem"
+ data-l10n-id="downloads-cmd-remove-from-history"/>
+ <menuitem command="downloadsCmd_clearDownloads"
+ data-l10n-id="downloads-cmd-clear-downloads"/>
+</menupopup>
diff --git a/browser/components/downloads/content/downloadsPanel.inc.xhtml b/browser/components/downloads/content/downloadsPanel.inc.xhtml
new file mode 100644
index 0000000000..e358b4bf6d
--- /dev/null
+++ b/browser/components/downloads/content/downloadsPanel.inc.xhtml
@@ -0,0 +1,198 @@
+<!-- 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/. -->
+
+<commandset commandupdater="true" events="richlistbox-select"
+ oncommandupdate="goUpdateCommand('cmd_delete');">
+ <command id="downloadsCmd_doDefault"
+ oncommand="goDoCommand('downloadsCmd_doDefault')"/>
+ <command id="downloadsCmd_pauseResume"
+ oncommand="goDoCommand('downloadsCmd_pauseResume')"/>
+ <command id="downloadsCmd_cancel"
+ oncommand="goDoCommand('downloadsCmd_cancel')"/>
+ <command id="downloadsCmd_unblock"
+ oncommand="goDoCommand('downloadsCmd_unblock')"/>
+ <command id="downloadsCmd_chooseUnblock"
+ oncommand="goDoCommand('downloadsCmd_chooseUnblock')"/>
+ <command id="downloadsCmd_unblockAndOpen"
+ oncommand="goDoCommand('downloadsCmd_unblockAndOpen')"/>
+ <command id="downloadsCmd_unblockAndSave"
+ oncommand="goDoCommand('downloadsCmd_unblockAndSave')"/>
+ <command id="downloadsCmd_confirmBlock"
+ oncommand="goDoCommand('downloadsCmd_confirmBlock')"/>
+ <command id="downloadsCmd_open"
+ oncommand="goDoCommand('downloadsCmd_open')"/>
+ <command id="downloadsCmd_open:current"
+ oncommand="goDoCommand('downloadsCmd_open:current')"/>
+ <command id="downloadsCmd_open:tab"
+ oncommand="goDoCommand('downloadsCmd_open:tab')"/>
+ <command id="downloadsCmd_open:tabshifted"
+ oncommand="goDoCommand('downloadsCmd_open:tabshifted')"/>
+ <command id="downloadsCmd_open:window"
+ oncommand="goDoCommand('downloadsCmd_open:window')"/>
+ <command id="downloadsCmd_show"
+ oncommand="goDoCommand('downloadsCmd_show')"/>
+ <command id="downloadsCmd_retry"
+ oncommand="goDoCommand('downloadsCmd_retry')"/>
+ <command id="downloadsCmd_openReferrer"
+ oncommand="goDoCommand('downloadsCmd_openReferrer')"/>
+ <command id="downloadsCmd_copyLocation"
+ oncommand="goDoCommand('downloadsCmd_copyLocation')"/>
+ <command id="downloadsCmd_clearList"
+ oncommand="goDoCommand('downloadsCmd_clearList')"/>
+ <command id="downloadsCmd_openInSystemViewer"
+ oncommand="goDoCommand('downloadsCmd_openInSystemViewer')"/>
+ <command id="downloadsCmd_alwaysOpenInSystemViewer"
+ oncommand="goDoCommand('downloadsCmd_alwaysOpenInSystemViewer')"/>
+ <command id="downloadsCmd_alwaysOpenSimilarFiles"
+ oncommand="goDoCommand('downloadsCmd_alwaysOpenSimilarFiles')"/>
+ <command id="downloadsCmd_deleteFile"
+ oncommand="goDoCommand('downloadsCmd_deleteFile')"/>
+</commandset>
+
+<!-- For accessibility to screen readers, we use a label on the panel instead
+ of the anchor because the panel can also be displayed without an anchor. -->
+<panel id="downloadsPanel"
+ data-l10n-id="downloads-panel"
+ class="panel-no-padding"
+ role="group"
+ type="arrow"
+ orient="vertical"
+ onpopupshown="DownloadsPanel.onPopupShown(event);"
+ onpopuphidden="DownloadsPanel.onPopupHidden(event);"
+ hidden="true">
+
+ <linkset>
+ <html:link rel="localization" href="browser/downloads.ftl" />
+ </linkset>
+
+ <!-- The following popup menu should be a child of the panel element,
+ otherwise flickering may occur when the cursor is moved over the area
+ of a disabled menu item that overlaps the panel. See bug 492960. -->
+ <menupopup id="downloadsContextMenu"
+ class="download-state">
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadPauseMenuItem"
+ data-l10n-id="downloads-cmd-pause"/>
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadResumeMenuItem"
+ data-l10n-id="downloads-cmd-resume"/>
+ <menuitem command="downloadsCmd_unblock"
+ class="downloadUnblockMenuItem"
+ data-l10n-id="downloads-cmd-unblock"/>
+ <menuitem command="downloadsCmd_openInSystemViewer"
+ class="downloadUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenInSystemViewer"
+ type="checkbox"
+ class="downloadAlwaysUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-always-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenSimilarFiles"
+ type="checkbox"
+ class="downloadAlwaysOpenSimilarFilesMenuItem"
+ data-l10n-id="downloads-cmd-always-open-similar-files"/>
+ <menuitem command="downloadsCmd_show"
+ class="downloadShowMenuItem"
+ data-l10n-id="downloads-cmd-show-menuitem-2"/>
+
+ <menuseparator class="downloadCommandsSeparator"/>
+
+ <menuitem command="downloadsCmd_openReferrer"
+ class="downloadOpenReferrerMenuItem"
+ data-l10n-id="downloads-cmd-go-to-download-page"/>
+ <menuitem command="downloadsCmd_copyLocation"
+ class="downloadCopyLocationMenuItem"
+ data-l10n-id="downloads-cmd-copy-download-link"/>
+
+ <menuseparator/>
+
+ <menuitem command="downloadsCmd_deleteFile"
+ class="downloadDeleteFileMenuItem"
+ data-l10n-id="downloads-cmd-delete-file"/>
+ <menuitem command="cmd_delete"
+ class="downloadRemoveFromHistoryMenuItem"
+ data-l10n-id="downloads-cmd-remove-from-history"/>
+ <menuitem command="downloadsCmd_clearList"
+ data-l10n-id="downloads-cmd-clear-list"/>
+ <menuitem command="downloadsCmd_clearDownloads"
+ hidden="true"
+ data-l10n-id="downloads-cmd-clear-downloads"/>
+ </menupopup>
+
+ <panelmultiview id="downloadsPanel-multiView"
+ mainViewId="downloadsPanel-mainView"
+ disablekeynav="true">
+
+ <panelview id="downloadsPanel-mainView">
+ <vbox class="panel-view-body-unscrollable">
+ <richlistbox id="downloadsListBox"
+ data-l10n-id="downloads-panel-items"
+ data-l10n-attrs="style"
+ context="downloadsContextMenu"
+ onmouseover="DownloadsView.onDownloadMouseOver(event);"
+ onmouseout="DownloadsView.onDownloadMouseOut(event);"
+ oncontextmenu="DownloadsView.onDownloadContextMenu(event);"
+ ondragstart="DownloadsView.onDownloadDragStart(event);"/>
+ <description id="emptyDownloads"
+ data-l10n-id="downloads-panel-empty"/>
+ </vbox>
+ <vbox id="downloadsFooter">
+ <stack>
+ <hbox id="downloadsSummary"
+ align="center"
+ orient="horizontal"
+ onkeydown="DownloadsSummary.onKeyDown(event);"
+ onclick="DownloadsSummary.onClick(event);">
+ <image class="downloadTypeIcon" />
+ <vbox pack="center"
+ flex="1"
+ class="downloadContainer">
+ <description id="downloadsSummaryDescription"/>
+ <html:progress id="downloadsSummaryProgress"
+ class="downloadProgress"
+ max="100"/>
+ <description id="downloadsSummaryDetails"
+ crop="end"/>
+ </vbox>
+ </hbox>
+ <vbox id="downloadsFooterButtons">
+ <toolbarseparator />
+ <button id="downloadsHistory"
+ data-l10n-id="downloads-history"
+ class="downloadsPanelFooterButton subviewbutton panel-subview-footer-button toolbarbutton-1"
+ flex="1"
+ oncommand="DownloadsPanel.showDownloadsHistory();"
+ pack="start"/>
+ </vbox>
+ </stack>
+ </vbox>
+ </panelview>
+
+ <panelview id="downloadsPanel-blockedSubview"
+ data-l10n-id="downloads-details"
+ class="PanelUI-subView">
+ <vbox class="panel-view-body-unscrollable">
+ <hbox class="downloadsPanel-blockedSubview-title-box">
+ <description id="downloadsPanel-blockedSubview-title"/>
+ <image class="downloadsPanel-blockedSubview-image"/>
+ </hbox>
+ <description id="downloadsPanel-blockedSubview-details1"/>
+ <description id="downloadsPanel-blockedSubview-details2"/>
+ </vbox>
+ <hbox id="downloadsPanel-blockedSubview-buttons"
+ class="panel-footer"
+ align="stretch">
+ <button id="downloadsPanel-blockedSubview-unblockButton"
+ class="downloadsPanelFooterButton"
+ command="downloadsCmd_unblockAndOpen"
+ flex="1"/>
+ <button id="downloadsPanel-blockedSubview-deleteButton"
+ class="downloadsPanelFooterButton"
+ oncommand="DownloadsBlockedSubview.confirmBlock();"
+ default="true"
+ flex="1"/>
+ </hbox>
+ </panelview>
+ </panelmultiview>
+
+</panel>
diff --git a/browser/components/downloads/content/indicator.js b/browser/components/downloads/content/indicator.js
new file mode 100644
index 0000000000..d0c4dc4163
--- /dev/null
+++ b/browser/components/downloads/content/indicator.js
@@ -0,0 +1,670 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* 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/. */
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Handles the indicator that displays the progress of ongoing downloads, which
+ * is also used as the anchor for the downloads panel.
+ *
+ * This module includes the following constructors and global objects:
+ *
+ * DownloadsButton
+ * Main entry point for the downloads indicator. Depending on how the toolbars
+ * have been customized, this object determines if we should show a fully
+ * functional indicator, a placeholder used during customization and in the
+ * customization palette, or a neutral view as a temporary anchor for the
+ * downloads panel.
+ *
+ * DownloadsIndicatorView
+ * Builds and updates the actual downloads status widget, responding to changes
+ * in the global status data, or provides a neutral view if the indicator is
+ * removed from the toolbars and only used as a temporary anchor. In addition,
+ * handles the user interaction events raised by the widget.
+ */
+
+"use strict";
+
+// DownloadsButton
+
+/**
+ * Main entry point for the downloads indicator. Depending on how the toolbars
+ * have been customized, this object determines if we should show a fully
+ * functional indicator, a placeholder used during customization and in the
+ * customization palette, or a neutral view as a temporary anchor for the
+ * downloads panel.
+ */
+const DownloadsButton = {
+ /**
+ * Returns a reference to the downloads button position placeholder, or null
+ * if not available because it has been removed from the toolbars.
+ */
+ get _placeholder() {
+ return document.getElementById("downloads-button");
+ },
+
+ /**
+ * Indicates whether toolbar customization is in progress.
+ */
+ _customizing: false,
+
+ /**
+ * This function is called asynchronously just after window initialization.
+ *
+ * NOTE: This function should limit the input/output it performs to improve
+ * startup time.
+ */
+ initializeIndicator() {
+ DownloadsIndicatorView.ensureInitialized();
+ },
+
+ /**
+ * Determines the position where the indicator should appear, and moves its
+ * associated element to the new position.
+ *
+ * @return Anchor element, or null if the indicator is not visible.
+ */
+ _getAnchorInternal() {
+ let indicator = DownloadsIndicatorView.indicator;
+ if (!indicator) {
+ // Exit now if the button is not in the document.
+ return null;
+ }
+
+ indicator.open = this._anchorRequested;
+
+ let widget = CustomizableUI.getWidget("downloads-button");
+ // Determine if the indicator is located on an invisible toolbar.
+ if (
+ !isElementVisible(indicator.parentNode) &&
+ widget.areaType == CustomizableUI.TYPE_TOOLBAR
+ ) {
+ return null;
+ }
+
+ return DownloadsIndicatorView.indicatorAnchor;
+ },
+
+ /**
+ * Indicates whether we should try and show the indicator temporarily as an
+ * anchor for the panel, even if the indicator would be hidden by default.
+ */
+ _anchorRequested: false,
+
+ /**
+ * Ensures that there is an anchor available for the panel.
+ *
+ * @return Anchor element where the panel should be anchored, or null if an
+ * anchor is not available (for example because both the tab bar and
+ * the navigation bar are hidden).
+ */
+ getAnchor() {
+ // Do not allow anchoring the panel to the element while customizing.
+ if (this._customizing) {
+ return null;
+ }
+
+ this._anchorRequested = true;
+ return this._getAnchorInternal();
+ },
+
+ /**
+ * Allows the temporary anchor to be hidden.
+ */
+ releaseAnchor() {
+ this._anchorRequested = false;
+ this._getAnchorInternal();
+ },
+
+ /**
+ * Unhide the button. Generally, this only needs to use the placeholder.
+ * However, when starting customize mode, if the button is in the palette,
+ * we need to unhide it before customize mode is entered, otherwise it
+ * gets ignored by customize mode. To do this, we pass true for
+ * `includePalette`. We don't always look in the palette because it's
+ * inefficient (compared to getElementById), shouldn't be necessary, and
+ * if _placeholder returned the node even if in the palette, other checks
+ * would break.
+ *
+ * @param includePalette whether to search the palette, too. Defaults to false.
+ */
+ unhide(includePalette = false) {
+ let button = this._placeholder;
+ let wasHidden = false;
+ if (!button && includePalette) {
+ button = gNavToolbox.palette.querySelector("#downloads-button");
+ }
+ if (button && button.hasAttribute("hidden")) {
+ button.removeAttribute("hidden");
+ if (this._navBar.contains(button)) {
+ this._navBar.setAttribute("downloadsbuttonshown", "true");
+ }
+ wasHidden = true;
+ }
+ return wasHidden;
+ },
+
+ hide() {
+ let button = this._placeholder;
+ if (this.autoHideDownloadsButton && button && button.closest("toolbar")) {
+ DownloadsPanel.hidePanel();
+ button.hidden = true;
+ this._navBar.removeAttribute("downloadsbuttonshown");
+ }
+ },
+
+ startAutoHide() {
+ if (DownloadsIndicatorView.hasDownloads) {
+ this.unhide();
+ } else {
+ this.hide();
+ }
+ },
+
+ checkForAutoHide() {
+ let button = this._placeholder;
+ if (
+ !this._customizing &&
+ this.autoHideDownloadsButton &&
+ button &&
+ button.closest("toolbar")
+ ) {
+ this.startAutoHide();
+ } else {
+ this.unhide();
+ }
+ },
+
+ // Callback from CustomizableUI when nodes get moved around.
+ // We use this to track whether our node has moved somewhere
+ // where we should (not) autohide it.
+ onWidgetAfterDOMChange(node) {
+ if (node == this._placeholder) {
+ this.checkForAutoHide();
+ }
+ },
+
+ /**
+ * This function is called when toolbar customization starts.
+ *
+ * During customization, we never show the actual download progress indication
+ * or the event notifications, but we show a neutral placeholder. The neutral
+ * placeholder is an ordinary button defined in the browser window that can be
+ * moved freely between the toolbars and the customization palette.
+ */
+ onCustomizeStart(win) {
+ if (win == window) {
+ // Prevent the indicator from being displayed as a temporary anchor
+ // during customization, even if requested using the getAnchor method.
+ this._customizing = true;
+ this._anchorRequested = false;
+ this.unhide(true);
+ }
+ },
+
+ onCustomizeEnd(win) {
+ if (win == window) {
+ this._customizing = false;
+ this.checkForAutoHide();
+ DownloadsIndicatorView.afterCustomize();
+ }
+ },
+
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "autoHideDownloadsButton",
+ "browser.download.autohideButton",
+ true,
+ this.checkForAutoHide.bind(this)
+ );
+
+ CustomizableUI.addListener(this);
+ this.checkForAutoHide();
+ },
+
+ uninit() {
+ CustomizableUI.removeListener(this);
+ },
+
+ get _tabsToolbar() {
+ delete this._tabsToolbar;
+ return (this._tabsToolbar = document.getElementById("TabsToolbar"));
+ },
+
+ get _navBar() {
+ delete this._navBar;
+ return (this._navBar = document.getElementById("nav-bar"));
+ },
+};
+
+Object.defineProperty(this, "DownloadsButton", {
+ value: DownloadsButton,
+ enumerable: true,
+ writable: false,
+});
+
+// DownloadsIndicatorView
+
+/**
+ * Builds and updates the actual downloads status widget, responding to changes
+ * in the global status data, or provides a neutral view if the indicator is
+ * removed from the toolbars and only used as a temporary anchor. In addition,
+ * handles the user interaction events raised by the widget.
+ */
+const DownloadsIndicatorView = {
+ /**
+ * True when the view is connected with the underlying downloads data.
+ */
+ _initialized: false,
+
+ /**
+ * True when the user interface elements required to display the indicator
+ * have finished loading in the browser window, and can be referenced.
+ */
+ _operational: false,
+
+ /**
+ * Prepares the downloads indicator to be displayed.
+ */
+ ensureInitialized() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ window.addEventListener("unload", this);
+ window.addEventListener("visibilitychange", this);
+ DownloadsCommon.getIndicatorData(window).addView(this);
+ },
+
+ /**
+ * Frees the internal resources related to the indicator.
+ */
+ ensureTerminated() {
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ window.removeEventListener("unload", this);
+ window.removeEventListener("visibilitychange", this);
+ DownloadsCommon.getIndicatorData(window).removeView(this);
+
+ // Reset the view properties, so that a neutral indicator is displayed if we
+ // are visible only temporarily as an anchor.
+ this.percentComplete = 0;
+ this.attention = DownloadsCommon.ATTENTION_NONE;
+ },
+
+ /**
+ * Ensures that the user interface elements required to display the indicator
+ * are loaded.
+ */
+ _ensureOperational() {
+ if (this._operational) {
+ return;
+ }
+
+ // If we don't have a _placeholder, there's no chance that everything
+ // will load correctly: bail (and don't set _operational to true!)
+ if (!DownloadsButton._placeholder) {
+ return;
+ }
+
+ this._operational = true;
+
+ // If the view is initialized, we need to update the elements now that
+ // they are finally available in the document.
+ if (this._initialized) {
+ DownloadsCommon.getIndicatorData(window).refreshView(this);
+ }
+ },
+
+ // Direct control functions
+
+ /**
+ * Set to the type ("start" or "finish") when display of a notification is in-progress
+ */
+ _currentNotificationType: null,
+
+ /**
+ * Set to the type ("start" or "finish") when a notification arrives while we
+ * are waiting for the timeout of the previous notification
+ */
+ _nextNotificationType: null,
+
+ /**
+ * Check if the panel containing aNode is open.
+ * @param aNode
+ * the node whose panel we're interested in.
+ */
+ _isAncestorPanelOpen(aNode) {
+ while (aNode && aNode.localName != "panel") {
+ aNode = aNode.parentNode;
+ }
+ return aNode && aNode.state == "open";
+ },
+
+ /**
+ * Display or enqueue a visual notification of a relevant event, like a new download.
+ *
+ * @param aType
+ * Set to "start" for new downloads, "finish" for completed downloads.
+ */
+ showEventNotification(aType) {
+ if (!this._initialized) {
+ return;
+ }
+
+ // enqueue this notification while the current one is being displayed
+ if (this._currentNotificationType) {
+ // only queue up the notification if it is different to the current one
+ if (this._currentNotificationType != aType) {
+ this._nextNotificationType = aType;
+ }
+ } else {
+ this._showNotification(aType);
+ }
+ },
+
+ /**
+ * If the status indicator is visible in its assigned position, shows for a
+ * brief time a visual notification of a relevant event, like a new download.
+ *
+ * @param aType
+ * Set to "start" for new downloads, "finish" for completed downloads.
+ */
+ _showNotification(aType) {
+ let anchor = DownloadsButton._placeholder;
+ if (!anchor || !isElementVisible(anchor.parentNode)) {
+ // Our container isn't visible, so can't show the animation:
+ return;
+ }
+
+ if (anchor.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) {
+ // User has prefers-reduced-motion enabled, so we shouldn't show the animation.
+ return;
+ }
+
+ anchor.setAttribute("notification", aType);
+ anchor.setAttribute("animate", "");
+
+ // are we animating from an initially-hidden state?
+ anchor.toggleAttribute("washidden", !!this._wasHidden);
+ delete this._wasHidden;
+
+ this._currentNotificationType = aType;
+
+ const onNotificationAnimEnd = event => {
+ if (
+ event.animationName !== "downloadsButtonNotification" &&
+ event.animationName !== "downloadsButtonFinishedNotification"
+ ) {
+ return;
+ }
+ anchor.removeEventListener("animationend", onNotificationAnimEnd);
+
+ requestAnimationFrame(() => {
+ anchor.removeAttribute("notification");
+ anchor.removeAttribute("animate");
+
+ requestAnimationFrame(() => {
+ let nextType = this._nextNotificationType;
+ this._currentNotificationType = null;
+ this._nextNotificationType = null;
+ if (nextType && isElementVisible(anchor.parentNode)) {
+ this._showNotification(nextType);
+ }
+ });
+ });
+ };
+ anchor.addEventListener("animationend", onNotificationAnimEnd);
+ },
+
+ // Callback functions from DownloadsIndicatorData
+
+ /**
+ * Indicates whether the indicator should be shown because there are some
+ * downloads to be displayed.
+ */
+ set hasDownloads(aValue) {
+ if (this._hasDownloads != aValue || (!this._operational && aValue)) {
+ this._hasDownloads = aValue;
+
+ // If there is at least one download, ensure that the view elements are
+ // operational
+ if (aValue) {
+ this._wasHidden = DownloadsButton.unhide();
+ this._ensureOperational();
+ } else {
+ DownloadsButton.checkForAutoHide();
+ }
+ }
+ },
+ get hasDownloads() {
+ return this._hasDownloads;
+ },
+ _hasDownloads: false,
+
+ /**
+ * Progress indication to display, from 0 to 100, or -1 if unknown.
+ * Progress is not visible if the current progress is unknown.
+ */
+ set percentComplete(aValue) {
+ if (!this._operational) {
+ return;
+ }
+ aValue = Math.min(100, aValue);
+ if (this._percentComplete !== aValue) {
+ // Initial progress may fire before the start event gets to us.
+ // To avoid flashing, trip the start event first.
+ if (this._percentComplete < 0 && aValue >= 0) {
+ this.showEventNotification("start");
+ }
+ this._percentComplete = aValue;
+ this._refreshAttention();
+ this._maybeScheduleProgressUpdate();
+ }
+ },
+
+ _maybeScheduleProgressUpdate() {
+ if (
+ this.indicator &&
+ !this._progressRaf &&
+ document.visibilityState == "visible"
+ ) {
+ this._progressRaf = requestAnimationFrame(() => {
+ // indeterminate downloads (unknown content-length) will show up as aValue = 0
+ if (this._percentComplete >= 0) {
+ if (!this.indicator.hasAttribute("progress")) {
+ this.indicator.setAttribute("progress", "true");
+ }
+ // For arrow type only: Set the % complete on the pie-chart.
+ // We use a minimum of 10% to ensure something is always visible
+ this._progressIcon.style.setProperty(
+ "--download-progress-pcent",
+ `${Math.max(10, this._percentComplete)}%`
+ );
+ } else {
+ this.indicator.removeAttribute("progress");
+ this._progressIcon.style.setProperty(
+ "--download-progress-pcent",
+ "0%"
+ );
+ }
+ this._progressRaf = null;
+ });
+ }
+ },
+ _percentComplete: -1,
+
+ /**
+ * Set when the indicator should draw user attention to itself.
+ */
+ set attention(aValue) {
+ if (!this._operational) {
+ return;
+ }
+ if (this._attention != aValue) {
+ this._attention = aValue;
+ this._refreshAttention();
+ }
+ },
+
+ _refreshAttention() {
+ // Check if the downloads button is in the menu panel, to determine which
+ // button needs to get a badge.
+ let widgetGroup = CustomizableUI.getWidget("downloads-button");
+ let inMenu = widgetGroup.areaType == CustomizableUI.TYPE_PANEL;
+
+ // For arrow-Styled indicator, suppress success attention if we have
+ // progress in toolbar
+ let suppressAttention =
+ !inMenu &&
+ this._attention == DownloadsCommon.ATTENTION_SUCCESS &&
+ this._percentComplete >= 0;
+
+ if (
+ suppressAttention ||
+ this._attention == DownloadsCommon.ATTENTION_NONE
+ ) {
+ this.indicator.removeAttribute("attention");
+ } else {
+ this.indicator.setAttribute("attention", this._attention);
+ }
+ },
+ _attention: DownloadsCommon.ATTENTION_NONE,
+
+ // User interface event functions
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this.ensureTerminated();
+ break;
+
+ case "visibilitychange":
+ this._maybeScheduleProgressUpdate();
+ break;
+ }
+ },
+
+ onCommand(aEvent) {
+ if (
+ // On Mac, ctrl-click will send a context menu event from the widget, so
+ // we don't want to bring up the panel when ctrl key is pressed.
+ (aEvent.type == "mousedown" &&
+ (aEvent.button != 0 ||
+ (AppConstants.platform == "macosx" && aEvent.ctrlKey))) ||
+ (aEvent.type == "keypress" && aEvent.key != " " && aEvent.key != "Enter")
+ ) {
+ return;
+ }
+
+ DownloadsPanel.showPanel(
+ /* openedManually */ true,
+ aEvent.type.startsWith("key")
+ );
+ aEvent.stopPropagation();
+ },
+
+ onDragOver(aEvent) {
+ browserDragAndDrop.dragOver(aEvent);
+ },
+
+ onDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ // If dragged item is from our source, do not try to
+ // redownload already downloaded file.
+ if (dt.mozGetDataAt("application/x-moz-file", 0)) {
+ return;
+ }
+
+ let links = browserDragAndDrop.dropLinks(aEvent);
+ if (!links.length) {
+ return;
+ }
+ let sourceDoc = dt.mozSourceNode
+ ? dt.mozSourceNode.ownerDocument
+ : document;
+ let handled = false;
+ for (let link of links) {
+ if (link.url.startsWith("about:")) {
+ continue;
+ }
+ saveURL(
+ link.url,
+ null,
+ link.name,
+ null,
+ true,
+ true,
+ null,
+ null,
+ sourceDoc
+ );
+ handled = true;
+ }
+ if (handled) {
+ aEvent.preventDefault();
+ }
+ },
+
+ _indicator: null,
+ __progressIcon: null,
+
+ /**
+ * Returns a reference to the main indicator element, or null if the element
+ * is not present in the browser window yet.
+ */
+ get indicator() {
+ if (!this._indicator) {
+ this._indicator = document.getElementById("downloads-button");
+ }
+
+ return this._indicator;
+ },
+
+ get indicatorAnchor() {
+ let widgetGroup = CustomizableUI.getWidget("downloads-button");
+ if (widgetGroup.areaType == CustomizableUI.TYPE_PANEL) {
+ let overflowIcon = widgetGroup.forWindow(window).anchor;
+ return overflowIcon.icon;
+ }
+
+ return this.indicator.badgeStack;
+ },
+
+ get _progressIcon() {
+ return (
+ this.__progressIcon ||
+ (this.__progressIcon = document.getElementById(
+ "downloads-indicator-progress-inner"
+ ))
+ );
+ },
+
+ _onCustomizedAway() {
+ this._indicator = null;
+ this.__progressIcon = null;
+ },
+
+ afterCustomize() {
+ // If the cached indicator is not the one currently in the document,
+ // invalidate our references
+ if (this._indicator != document.getElementById("downloads-button")) {
+ this._onCustomizedAway();
+ this._operational = false;
+ this.ensureTerminated();
+ this.ensureInitialized();
+ }
+ },
+};
+
+Object.defineProperty(this, "DownloadsIndicatorView", {
+ value: DownloadsIndicatorView,
+ enumerable: true,
+ writable: false,
+});
diff --git a/browser/components/downloads/jar.mn b/browser/components/downloads/jar.mn
new file mode 100644
index 0000000000..5a42863a2c
--- /dev/null
+++ b/browser/components/downloads/jar.mn
@@ -0,0 +1,13 @@
+# 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/.
+
+browser.jar:
+ content/browser/downloads/downloads.css (content/downloads.css)
+ content/browser/downloads/downloads.js (content/downloads.js)
+ content/browser/downloads/indicator.js (content/indicator.js)
+ content/browser/downloads/allDownloadsView.js (content/allDownloadsView.js)
+* content/browser/downloads/contentAreaDownloadsView.xhtml (content/contentAreaDownloadsView.xhtml)
+ content/browser/downloads/contentAreaDownloadsView.js (content/contentAreaDownloadsView.js)
+ content/browser/downloads/contentAreaDownloadsView.css (content/contentAreaDownloadsView.css)
+ content/browser/downloads/downloadsCommands.js (content/downloadsCommands.js)
diff --git a/browser/components/downloads/moz.build b/browser/components/downloads/moz.build
new file mode 100644
index 0000000000..b57a240c4e
--- /dev/null
+++ b/browser/components/downloads/moz.build
@@ -0,0 +1,30 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("*"):
+ BUG_COMPONENT = ("Firefox", "Downloads Panel")
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "DownloadsCommon.sys.mjs",
+ "DownloadSpamProtection.sys.mjs",
+ "DownloadsTaskbar.sys.mjs",
+ "DownloadsViewableInternally.sys.mjs",
+ "DownloadsViewUI.sys.mjs",
+]
+
+toolkit = CONFIG["MOZ_WIDGET_TOOLKIT"]
+
+if toolkit == "cocoa":
+ EXTRA_JS_MODULES += ["DownloadsMacFinderProgress.sys.mjs"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Downloads Panel")
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
diff --git a/browser/components/downloads/test/browser/blank.JPG b/browser/components/downloads/test/browser/blank.JPG
new file mode 100644
index 0000000000..1cda9a53dc
--- /dev/null
+++ b/browser/components/downloads/test/browser/blank.JPG
Binary files differ
diff --git a/browser/components/downloads/test/browser/browser.ini b/browser/components/downloads/test/browser/browser.ini
new file mode 100644
index 0000000000..b7f4acb542
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser.ini
@@ -0,0 +1,65 @@
+[DEFAULT]
+support-files = head.js
+
+[browser_about_downloads.js]
+[browser_basic_functionality.js]
+[browser_confirm_unblock_download.js]
+[browser_download_is_clickable.js]
+[browser_download_opens_on_click.js]
+[browser_download_opens_policy.js]
+[browser_download_overwrite.js]
+support-files =
+ foo.txt
+ foo.txt^headers^
+ !/toolkit/content/tests/browser/common/mockTransfer.js
+[browser_download_spam_protection.js]
+skip-if =
+ os == "linux" && bits == 64 # bug 1743263 & Bug 1742678
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+support-files = test_spammy_page.html
+[browser_download_starts_in_tmp.js]
+[browser_downloads_autohide.js]
+[browser_downloads_context_menu_always_open_similar_files.js]
+[browser_downloads_context_menu_delete_file.js]
+[browser_downloads_context_menu_selection.js]
+[browser_downloads_keynav.js]
+[browser_downloads_panel_block.js]
+[browser_downloads_panel_context_menu.js]
+skip-if =
+ os == "win" && os_version == "10.0" && bits == 64 && !debug # Bug 1719949
+ win10_2004 && bits == 32 && debug # Bug 1727925
+[browser_downloads_panel_ctrl_click.js]
+[browser_downloads_panel_disable_items.js]
+support-files =
+ foo.txt
+ foo.txt^headers^
+[browser_downloads_panel_dontshow.js]
+[browser_downloads_panel_focus.js]
+[browser_downloads_panel_height.js]
+[browser_downloads_panel_opens.js]
+skip-if =
+ os == "linux" && verify && !debug # For some reason linux opt verify builds time out.
+support-files =
+ foo.txt
+ foo.txt^headers^
+[browser_downloads_pauseResume.js]
+[browser_first_download_panel.js]
+skip-if =
+ os == "linux" # Bug 949434
+[browser_go_to_download_page.js]
+[browser_iframe_gone_mid_download.js]
+[browser_image_mimetype_issues.js]
+https_first_disabled = true
+support-files =
+ not-really-a-jpeg.jpeg
+ not-really-a-jpeg.jpeg^headers^
+ blank.JPG
+[browser_indicatorDrop.js]
+[browser_libraryDrop.js]
+skip-if = (os == 'win' && os_version == '10.0' && ccov) # Bug 1306510
+[browser_library_clearall.js]
+[browser_library_select_all.js]
+[browser_overflow_anchor.js]
+skip-if = os == "linux" # Bug 952422
+[browser_pdfjs_preview.js]
+[browser_tempfilename.js]
diff --git a/browser/components/downloads/test/browser/browser_about_downloads.js b/browser/components/downloads/test/browser/browser_about_downloads.js
new file mode 100644
index 0000000000..87e17ee293
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_about_downloads.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Ensure about:downloads actually works.
+ */
+add_task(async function test_about_downloads() {
+ await task_resetState();
+ registerCleanupFunction(task_resetState);
+
+ await setDownloadDir();
+
+ await task_addDownloads([
+ { state: DownloadsCommon.DOWNLOAD_FINISHED },
+ { state: DownloadsCommon.DOWNLOAD_PAUSED },
+ ]);
+
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let downloadsLoaded = BrowserTestUtils.waitForEvent(
+ browser,
+ "InitialDownloadsLoaded",
+ true
+ );
+ BrowserTestUtils.loadURIString(browser, "about:downloads");
+ await downloadsLoaded;
+ await SpecialPowers.spawn(browser, [], async function () {
+ let box = content.document.getElementById("downloadsListBox");
+ ok(box, "Should have list of downloads");
+ is(box.children.length, 2, "Should have 2 downloads.");
+ for (let kid of box.children) {
+ let desc = kid.querySelector(".downloadTarget");
+ // This would just be an `is` check, but stray temp files
+ // if this test (or another in this dir) ever fails could throw that off.
+ ok(
+ /^dm-ui-test(-\d+)?.file$/.test(desc.value),
+ `Label '${desc.value}' should match 'dm-ui-test.file'`
+ );
+ }
+ ok(box.firstChild.selected, "First item should be selected.");
+ });
+ });
+});
diff --git a/browser/components/downloads/test/browser/browser_basic_functionality.js b/browser/components/downloads/test/browser/browser_basic_functionality.js
new file mode 100644
index 0000000000..769f41cccf
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_basic_functionality.js
@@ -0,0 +1,59 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+});
+
+/**
+ * Make sure the downloads panel can display items in the right order and
+ * contains the expected data.
+ */
+add_task(async function test_basic_functionality() {
+ // Display one of each download state.
+ const DownloadData = [
+ { state: DownloadsCommon.DOWNLOAD_NOTSTARTED },
+ { state: DownloadsCommon.DOWNLOAD_PAUSED },
+ { state: DownloadsCommon.DOWNLOAD_FINISHED },
+ { state: DownloadsCommon.DOWNLOAD_FAILED },
+ { state: DownloadsCommon.DOWNLOAD_CANCELED },
+ ];
+
+ // Wait for focus first
+ await promiseFocus();
+
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ // For testing purposes, show all the download items at once.
+ var originalCountLimit = DownloadsView.kItemCountLimit;
+ DownloadsView.kItemCountLimit = DownloadData.length;
+ registerCleanupFunction(function () {
+ DownloadsView.kItemCountLimit = originalCountLimit;
+ });
+
+ // Populate the downloads database with the data required by this test.
+ await task_addDownloads(DownloadData);
+
+ // Open the user interface and wait for data to be fully loaded.
+ await task_openPanel();
+
+ // Test item data and count. This also tests the ordering of the display.
+ let richlistbox = document.getElementById("downloadsListBox");
+ /* disabled for failing intermittently (bug 767828)
+ is(richlistbox.itemChildren.length, DownloadData.length,
+ "There is the correct number of richlistitems");
+ */
+ let itemCount = richlistbox.itemChildren.length;
+ for (let i = 0; i < itemCount; i++) {
+ let element = richlistbox.itemChildren[itemCount - i - 1];
+ let download = DownloadsView.itemForElement(element).download;
+ is(
+ DownloadsCommon.stateOfDownload(download),
+ DownloadData[i].state,
+ "Download states match up"
+ );
+ }
+});
diff --git a/browser/components/downloads/test/browser/browser_confirm_unblock_download.js b/browser/components/downloads/test/browser/browser_confirm_unblock_download.js
new file mode 100644
index 0000000000..d88fa9a0e5
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_confirm_unblock_download.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests the dialog which allows the user to unblock a downloaded file.
+
+registerCleanupFunction(() => {});
+
+async function assertDialogResult({ args, buttonToClick, expectedResult }) {
+ let promise = BrowserTestUtils.promiseAlertDialog(buttonToClick);
+ is(
+ await DownloadsCommon.confirmUnblockDownload(args),
+ expectedResult,
+ `Expect ${expectedResult} from ${buttonToClick}`
+ );
+ await promise;
+}
+
+/**
+ * Tests the "unblock" dialog, for each of the possible verdicts.
+ */
+add_task(async function test_unblock_dialog_unblock() {
+ for (let verdict of [
+ Downloads.Error.BLOCK_VERDICT_MALWARE,
+ Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED,
+ Downloads.Error.BLOCK_VERDICT_INSECURE,
+ Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+ ]) {
+ let args = { verdict, window, dialogType: "unblock" };
+
+ // Test both buttons.
+ await assertDialogResult({
+ args,
+ buttonToClick: "accept",
+ expectedResult: "unblock",
+ });
+ await assertDialogResult({
+ args,
+ buttonToClick: "cancel",
+ expectedResult: "cancel",
+ });
+ }
+});
+
+/**
+ * Tests the "chooseUnblock" dialog for potentially unwanted downloads.
+ */
+add_task(async function test_chooseUnblock_dialog() {
+ for (let verdict of [
+ Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED,
+ Downloads.Error.BLOCK_VERDICT_INSECURE,
+ ]) {
+ let args = {
+ verdict,
+ window,
+ dialogType: "chooseUnblock",
+ };
+
+ // Test each of the three buttons.
+ await assertDialogResult({
+ args,
+ buttonToClick: "accept",
+ expectedResult: "unblock",
+ });
+ await assertDialogResult({
+ args,
+ buttonToClick: "cancel",
+ expectedResult: "cancel",
+ });
+ await assertDialogResult({
+ args,
+ buttonToClick: "extra1",
+ expectedResult: "confirmBlock",
+ });
+ }
+});
+
+/**
+ * Tests the "chooseOpen" dialog for uncommon downloads.
+ */
+add_task(async function test_chooseOpen_dialog() {
+ for (let verdict of [
+ Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+ Downloads.Error.BLOCK_VERDICT_INSECURE,
+ ]) {
+ let args = {
+ verdict,
+ window,
+ dialogType: "chooseOpen",
+ };
+
+ // Test each of the three buttons.
+ await assertDialogResult({
+ args,
+ buttonToClick: "accept",
+ expectedResult: "open",
+ });
+ await assertDialogResult({
+ args,
+ buttonToClick: "cancel",
+ expectedResult: "cancel",
+ });
+ await assertDialogResult({
+ args,
+ buttonToClick: "extra1",
+ expectedResult: "confirmBlock",
+ });
+ }
+});
diff --git a/browser/components/downloads/test/browser/browser_download_is_clickable.js b/browser/components/downloads/test/browser/browser_download_is_clickable.js
new file mode 100644
index 0000000000..421a214df8
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_download_is_clickable.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+});
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_task(async function test_download_clickable() {
+ Services.telemetry.clearScalars();
+
+ startServer();
+ mustInterruptResponses();
+ let download = await promiseInterruptibleDownload();
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ await publicList.add(download);
+
+ registerCleanupFunction(async function () {
+ await task_resetState();
+ Services.telemetry.clearScalars();
+ });
+
+ download.start();
+
+ await promiseDownloadHasProgress(download, 50);
+
+ await task_openPanel();
+
+ let listbox = document.getElementById("downloadsListBox");
+ ok(listbox, "Download list box present");
+
+ await TestUtils.waitForCondition(() => {
+ return listbox.childElementCount == 1;
+ });
+
+ info("All downloads show in the listbox.itemChildren ", listbox.itemChildren);
+
+ ok(
+ listbox.itemChildren[0].classList.contains("openWhenFinished"),
+ "Download should have clickable style when in progress"
+ );
+
+ ok(!download.launchWhenSucceeded, "launchWhenSucceeded should set to false");
+
+ ok(!download._launchedFromPanel, "LaunchFromPanel should set to false");
+
+ EventUtils.synthesizeMouseAtCenter(listbox.itemChildren[0], {});
+ ok(
+ download.launchWhenSucceeded,
+ "Should open the file when download is finished"
+ );
+ ok(download._launchedFromPanel, "File was scheduled to launch from panel");
+
+ EventUtils.synthesizeMouseAtCenter(listbox.itemChildren[0], {});
+
+ ok(
+ !download.launchWhenSucceeded,
+ "Should NOT open the file when download is finished"
+ );
+
+ ok(!download._launchedFromPanel, "File launch from panel was reset");
+
+ continueResponses();
+ await download.refresh();
+ await promiseDownloadHasProgress(download, 100);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "downloads.file_opened",
+ undefined,
+ "File opened from panel should not be incremented"
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_download_opens_on_click.js b/browser/components/downloads/test/browser/browser_download_opens_on_click.js
new file mode 100644
index 0000000000..1259e197e0
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_download_opens_on_click.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+});
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_task(async function test_download_opens_on_click() {
+ Services.telemetry.clearScalars();
+
+ startServer();
+ mustInterruptResponses();
+ let download = await promiseInterruptibleDownload();
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ await publicList.add(download);
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+
+ let waitForLaunchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = () => {
+ ok(true, "The file should be launched with an external application");
+ resolve();
+ };
+ });
+
+ registerCleanupFunction(async function () {
+ DownloadIntegration.launchFile = oldLaunchFile;
+ await task_resetState();
+ Services.telemetry.clearScalars();
+ });
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "downloads.file_opened",
+ undefined,
+ "File opened from panel should not be initialized"
+ );
+
+ download.start();
+
+ await promiseDownloadHasProgress(download, 50);
+
+ await task_openPanel();
+
+ let listbox = document.getElementById("downloadsListBox");
+ ok(listbox, "Download list box present");
+
+ await TestUtils.waitForCondition(() => {
+ return listbox.childElementCount == 1;
+ });
+
+ info("All downloads show in the listbox.itemChildren ", listbox.itemChildren);
+
+ ok(
+ listbox.itemChildren[0].classList.contains("openWhenFinished"),
+ "Download should have clickable style when in progress"
+ );
+
+ ok(!download.launchWhenSucceeded, "launchWhenSucceeded should set to false");
+
+ ok(!download._launchedFromPanel, "LaunchFromPanel should set to false");
+
+ EventUtils.synthesizeMouseAtCenter(listbox.itemChildren[0], {});
+
+ ok(
+ download.launchWhenSucceeded,
+ "Should open the file when download is finished"
+ );
+ ok(download._launchedFromPanel, "File was scheduled to launch from panel");
+
+ continueResponses();
+ await download.refresh();
+ await promiseDownloadHasProgress(download, 100);
+
+ await waitForLaunchFileCalled;
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "downloads.file_opened",
+ 1,
+ "File opened from panel should be incremented"
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_download_opens_policy.js b/browser/components/downloads/test/browser/browser_download_opens_policy.js
new file mode 100644
index 0000000000..97d9bef1db
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_download_opens_policy.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+});
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_task(async function test_download_opens_on_click() {
+ Services.telemetry.clearScalars();
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExemptDomainFileTypePairsFromFileTypeDownloadWarnings: [
+ {
+ file_extension: "jnlp",
+ domains: ["localhost"],
+ },
+ ],
+ },
+ });
+
+ startServer();
+ mustInterruptResponses();
+ let download = await promiseInterruptibleDownload(".jnlp");
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ await publicList.add(download);
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+
+ let waitForLaunchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = () => {
+ ok(true, "The file should be launched with an external application");
+ resolve();
+ };
+ });
+
+ registerCleanupFunction(async function () {
+ DownloadIntegration.launchFile = oldLaunchFile;
+ await task_resetState();
+ Services.telemetry.clearScalars();
+ });
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "downloads.file_opened",
+ undefined,
+ "File opened from panel should not be initialized"
+ );
+
+ download.start();
+
+ await promiseDownloadHasProgress(download, 50);
+
+ await task_openPanel();
+
+ let listbox = document.getElementById("downloadsListBox");
+ ok(listbox, "Download list box present");
+
+ await TestUtils.waitForCondition(() => {
+ return listbox.childElementCount == 1;
+ });
+
+ info("All downloads show in the listbox.itemChildren ", listbox.itemChildren);
+
+ ok(
+ listbox.itemChildren[0].classList.contains("openWhenFinished"),
+ "Download should have clickable style when in progress"
+ );
+
+ ok(!download.launchWhenSucceeded, "launchWhenSucceeded should set to false");
+
+ ok(!download._launchedFromPanel, "LaunchFromPanel should set to false");
+
+ EventUtils.synthesizeMouseAtCenter(listbox.itemChildren[0], {});
+
+ ok(
+ download.launchWhenSucceeded,
+ "Should open the file when download is finished"
+ );
+ ok(download._launchedFromPanel, "File was scheduled to launch from panel");
+
+ continueResponses();
+ await download.refresh();
+ await promiseDownloadHasProgress(download, 100);
+
+ await waitForLaunchFileCalled;
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "downloads.file_opened",
+ 1,
+ "File opened from panel should be incremented"
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_download_overwrite.js b/browser/components/downloads/test/browser/browser_download_overwrite.js
new file mode 100644
index 0000000000..7be16aa565
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_download_overwrite.js
@@ -0,0 +1,126 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+add_setup(async function () {
+ // head.js has helpers that write to a nice unique file we can use.
+ await createDownloadedFile(gTestTargetFile.path, "Hello.\n");
+ ok(gTestTargetFile.exists(), "We created a test file.");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", false],
+ ],
+ });
+ // Set up the file picker.
+ let destDir = gTestTargetFile.parent;
+
+ MockFilePicker.displayDirectory = destDir;
+ MockFilePicker.showCallback = function (fp) {
+ MockFilePicker.setFiles([gTestTargetFile]);
+ return MockFilePicker.returnOK;
+ };
+ registerCleanupFunction(function () {
+ MockFilePicker.cleanup();
+ if (gTestTargetFile.exists()) {
+ gTestTargetFile.remove(false);
+ }
+ });
+});
+
+// If we download a file and the user accepts overwriting an existing one,
+// we shouldn't first delete that file before moving the .part file into
+// place.
+add_task(async function test_overwrite_does_not_delete_first() {
+ let unregisteredTransfer = false;
+ let transferCompletePromise = new Promise(resolve => {
+ mockTransferCallback = resolve;
+ });
+ mockTransferRegisterer.register();
+
+ registerCleanupFunction(function () {
+ if (!unregisteredTransfer) {
+ mockTransferRegisterer.unregister();
+ }
+ });
+
+ // Now try and download a thing to the file:
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ opening: TEST_ROOT + "foo.txt",
+ waitForLoad: false,
+ waitForStateStop: true,
+ },
+ async function () {
+ ok(await transferCompletePromise, "download should succeed");
+ ok(
+ gTestTargetFile.exists(),
+ "File should still exist and not have been deleted."
+ );
+ // Note: the download transfer is fake so data won't have been written to
+ // the file, so we can't verify that the download actually overwrites data
+ // like this.
+ mockTransferRegisterer.unregister();
+ unregisteredTransfer = true;
+ }
+ );
+});
+
+// If we download a file and the user accepts overwriting an existing one,
+// we should successfully overwrite its contents.
+add_task(async function test_overwrite_works() {
+ let publicDownloads = await Downloads.getList(Downloads.PUBLIC);
+ // First ensure we catch the download finishing.
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicDownloads.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ publicDownloads.removeView(this);
+ publicDownloads.removeFinished();
+ resolve(download);
+ }
+ },
+ });
+ });
+ // Now try and download a thing to the file:
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ opening: TEST_ROOT + "foo.txt",
+ waitForLoad: false,
+ waitForStateStop: true,
+ },
+ async function () {
+ info("wait for download to finish");
+ let download = await downloadFinishedPromise;
+ ok(download.succeeded, "Download should succeed");
+ ok(
+ gTestTargetFile.exists(),
+ "File should still exist and not have been deleted."
+ );
+ let contents = new TextDecoder().decode(
+ await IOUtils.read(gTestTargetFile.path)
+ );
+ info("Got: " + contents);
+ ok(contents.startsWith("Dummy"), "The file was overwritten.");
+ }
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_download_spam_protection.js b/browser/components/downloads/test/browser/browser_download_spam_protection.js
new file mode 100644
index 0000000000..8095fff18e
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_download_spam_protection.js
@@ -0,0 +1,220 @@
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadSpamProtection: "resource:///modules/DownloadSpamProtection.sys.mjs",
+ PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+const TEST_URI = "https://example.com";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ TEST_URI
+);
+
+add_setup(async function () {
+ // Create temp directory
+ let time = new Date().getTime();
+ let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempDir.append(time);
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir);
+
+ PermissionTestUtils.add(
+ TEST_URI,
+ "automatic-download",
+ Services.perms.UNKNOWN_ACTION
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.enable_spam_prevention", true]],
+ clear: [
+ ["browser.download.alwaysOpenPanel"],
+ ["browser.download.always_ask_before_handling_new_types"],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ await IOUtils.remove(tempDir.path, { recursive: true });
+ });
+});
+
+add_task(async function check_download_spam_ui() {
+ await task_resetState();
+
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ registerCleanupFunction(async () => {
+ for (let win of [browserWin, browserWin2]) {
+ win.DownloadsPanel.hidePanel();
+ DownloadIntegration.downloadSpamProtection.removeDownloadSpamForWindow(
+ TEST_URI,
+ win
+ );
+ }
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ await publicList.removeFinished();
+ BrowserTestUtils.removeTab(newTab);
+ await BrowserTestUtils.closeWindow(browserWin2);
+ });
+ let observedBlockedDownloads = 0;
+ let gotAllBlockedDownloads = TestUtils.topicObserved(
+ "blocked-automatic-download",
+ () => {
+ return ++observedBlockedDownloads >= 99;
+ }
+ );
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ browserWin.gBrowser,
+ TEST_PATH + "test_spammy_page.html"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ {},
+ newTab.linkedBrowser
+ );
+
+ info("Waiting on all blocked downloads");
+ await gotAllBlockedDownloads;
+
+ let { downloadSpamProtection } = DownloadIntegration;
+ let spamList = downloadSpamProtection.getSpamListForWindow(browserWin);
+ is(
+ spamList._downloads[0].blockedDownloadsCount,
+ 99,
+ "99 blocked downloads recorded"
+ );
+ ok(
+ spamList._downloads[0].error.becauseBlockedByReputationCheck,
+ "Download blocked because of reputation"
+ );
+ is(
+ spamList._downloads[0].error.reputationCheckVerdict,
+ "DownloadSpam",
+ "Verdict is DownloadSpam"
+ );
+
+ browserWin.focus();
+ await BrowserTestUtils.waitForPopupEvent(
+ browserWin.DownloadsPanel.panel,
+ "shown"
+ );
+
+ ok(browserWin.DownloadsPanel.isPanelShowing, "Download panel should open");
+ await Downloads.getList(Downloads.PUBLIC);
+
+ let listbox = browserWin.document.getElementById("downloadsListBox");
+ ok(listbox, "Download list box present");
+
+ await TestUtils.waitForCondition(() => {
+ return listbox.childElementCount == 2 && !listbox.getAttribute("disabled");
+ }, "2 downloads = 1 allowed download and 1 for 99 downloads blocked");
+
+ let spamElement = listbox.itemChildren[0].classList.contains(
+ "temporary-block"
+ )
+ ? listbox.itemChildren[0]
+ : listbox.itemChildren[1];
+
+ ok(spamElement.classList.contains("temporary-block"), "Download is blocked");
+
+ info("Testing spam protection in a second window");
+
+ browserWin.DownloadsPanel.hidePanel();
+ DownloadIntegration.downloadSpamProtection.removeDownloadSpamForWindow(
+ TEST_URI,
+ browserWin
+ );
+
+ ok(
+ !browserWin.DownloadsPanel.isPanelShowing,
+ "Download panel should be closed in first window"
+ );
+ is(
+ listbox.childElementCount,
+ 1,
+ "First window's download list should have one item - the download that wasn't blocked"
+ );
+
+ let browserWin2 = await BrowserTestUtils.openNewBrowserWindow();
+ let observedBlockedDownloads2 = 0;
+ let gotAllBlockedDownloads2 = TestUtils.topicObserved(
+ "blocked-automatic-download",
+ () => {
+ return ++observedBlockedDownloads2 >= 100;
+ }
+ );
+
+ let newTab2 = await BrowserTestUtils.openNewForegroundTab(
+ browserWin2.gBrowser,
+ TEST_PATH + "test_spammy_page.html"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ {},
+ newTab2.linkedBrowser
+ );
+
+ info("Waiting on all blocked downloads in second window");
+ await gotAllBlockedDownloads2;
+
+ let spamList2 = downloadSpamProtection.getSpamListForWindow(browserWin2);
+ is(
+ spamList2._downloads[0].blockedDownloadsCount,
+ 100,
+ "100 blocked downloads recorded in second window"
+ );
+ ok(
+ !spamList._downloads[0]?.blockedDownloadsCount,
+ "No blocked downloads in first window"
+ );
+
+ browserWin2.focus();
+ await BrowserTestUtils.waitForPopupEvent(
+ browserWin2.DownloadsPanel.panel,
+ "shown"
+ );
+
+ ok(
+ browserWin2.DownloadsPanel.isPanelShowing,
+ "Download panel should open in second window"
+ );
+
+ ok(
+ !browserWin.DownloadsPanel.isPanelShowing,
+ "Download panel should not open in first window"
+ );
+
+ let listbox2 = browserWin2.document.getElementById("downloadsListBox");
+ ok(listbox2, "Download list box present");
+
+ await TestUtils.waitForCondition(() => {
+ return (
+ listbox2.childElementCount == 2 && !listbox2.getAttribute("disabled")
+ );
+ }, "2 downloads = 1 allowed download from first window, and 1 for 100 downloads blocked in second window");
+
+ is(
+ listbox.childElementCount,
+ 1,
+ "First window's download list should still have one item - the download that wasn't blocked"
+ );
+
+ let spamElement2 = listbox2.itemChildren[0].classList.contains(
+ "temporary-block"
+ )
+ ? listbox2.itemChildren[0]
+ : listbox2.itemChildren[1];
+
+ ok(spamElement2.classList.contains("temporary-block"), "Download is blocked");
+});
diff --git a/browser/components/downloads/test/browser/browser_download_starts_in_tmp.js b/browser/components/downloads/test/browser/browser_download_starts_in_tmp.js
new file mode 100644
index 0000000000..1301e8fa1b
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_download_starts_in_tmp.js
@@ -0,0 +1,264 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+// Need to start the server before `httpUrl` works.
+startServer();
+const DOWNLOAD_URL = httpUrl("interruptible.txt");
+
+let gDownloadDir;
+
+let gExternalHelperAppService = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+].getService(Ci.nsIExternalHelperAppService);
+gExternalHelperAppService.QueryInterface(Ci.nsIObserver);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.start_downloads_in_tmp_dir", true],
+ ["browser.helperApps.deleteTempFileOnExit", true],
+ ],
+ });
+ registerCleanupFunction(task_resetState);
+ gDownloadDir = new FileUtils.File(await setDownloadDir());
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("browser.download.folderList");
+ });
+});
+
+add_task(async function test_download_asking_starts_in_tmp() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", true]],
+ });
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadStarted = new Promise(resolve => {
+ let view = {
+ onDownloadAdded(download) {
+ list.removeView(view);
+ resolve(download);
+ },
+ };
+ list.addView(view);
+ });
+ // Wait for the download prompting dialog
+ let dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ win => win.document.documentURI == UCT_URI
+ );
+ serveInterruptibleAsDownload();
+ mustInterruptResponses();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: DOWNLOAD_URL,
+ waitForLoad: false,
+ waitForStop: true,
+ },
+ async function () {
+ let dialogWin = await dialogPromise;
+ let tempFile = dialogWin.dialog.mLauncher.targetFile;
+ ok(
+ !tempFile.parent.equals(gDownloadDir),
+ "Should not have put temp file in the downloads dir."
+ );
+
+ let dialogEl = dialogWin.document.querySelector("dialog");
+ dialogEl.getButton("accept").disabled = false;
+ dialogEl.acceptDialog();
+ let download = await downloadStarted;
+ is(
+ PathUtils.parent(download.target.path),
+ gDownloadDir.path,
+ "Should have put final file in the downloads dir."
+ );
+ continueResponses();
+ await download.whenSucceeded();
+ await IOUtils.remove(download.target.path);
+ }
+ );
+ await list.removeFinished();
+});
+
+add_task(async function test_download_asking_and_opening_opens_from_tmp() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", true]],
+ });
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadStarted = new Promise(resolve => {
+ let view = {
+ onDownloadAdded(download) {
+ list.removeView(view);
+ resolve(download);
+ },
+ };
+ list.addView(view);
+ });
+ // Wait for the download prompting dialog
+ let dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ win => win.document.documentURI == UCT_URI
+ );
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let promiseLaunchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = file => {
+ ok(true, "The file should be launched with an external application");
+ resolve(file);
+ DownloadIntegration.launchFile = oldLaunchFile;
+ };
+ });
+ registerCleanupFunction(() => {
+ DownloadIntegration.launchFile = oldLaunchFile;
+ });
+
+ serveInterruptibleAsDownload();
+ mustInterruptResponses();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: DOWNLOAD_URL,
+ waitForLoad: false,
+ waitForStop: true,
+ },
+ async function () {
+ let dialogWin = await dialogPromise;
+ let tempFile = dialogWin.dialog.mLauncher.targetFile;
+ ok(
+ !tempFile.parent.equals(gDownloadDir),
+ "Should not have put temp file in the downloads dir."
+ );
+
+ dialogWin.document.getElementById("open").click();
+ let dialogEl = dialogWin.document.querySelector("dialog");
+ dialogEl.getButton("accept").disabled = false;
+ dialogEl.acceptDialog();
+ let download = await downloadStarted;
+ isnot(
+ PathUtils.parent(download.target.path),
+ gDownloadDir.path,
+ "Should not have put final file in the downloads dir when it's supposed to be automatically opened."
+ );
+ continueResponses();
+ await download.whenSucceeded();
+ await download.refresh();
+ isnot(
+ PathUtils.parent(download.target.path),
+ gDownloadDir.path,
+ "Once finished the download should not be in the downloads dir when it's supposed to be automatically opened."
+ );
+ let file = await promiseLaunchFileCalled;
+ ok(
+ !file.parent.equals(gDownloadDir),
+ "Should not have put opened file in the downloads dir."
+ );
+
+ // Pretend that we've quit so we wipe all the files:
+ gExternalHelperAppService.observe(null, "profile-before-change", "");
+ // Now the file should go away, but that's async...
+
+ let f = new FileUtils.File(download.target.path);
+ await TestUtils.waitForCondition(
+ () => !f.exists(),
+ "Temp file should be removed",
+ 500
+ ).catch(err => ok(false, err));
+ ok(!f.exists(), "Temp file should be removed.");
+
+ await IOUtils.remove(download.target.path);
+ }
+ );
+ await list.removeFinished();
+});
+
+// Check that if we open the file automatically, it opens from the temp dir.
+add_task(async function test_download_automatically_opened_from_tmp() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+ serveInterruptibleAsDownload();
+ mustInterruptResponses();
+
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadStarted = new Promise(resolve => {
+ let view = {
+ onDownloadAdded(download) {
+ list.removeView(view);
+ resolve(download);
+ },
+ };
+ list.addView(view);
+ });
+
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ let txtHandlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ txtHandlerInfo.preferredAction = Ci.nsIHandlerInfo.useSystemDefault;
+ txtHandlerInfo.alwaysAskBeforeHandling = false;
+ handlerSvc.store(txtHandlerInfo);
+ registerCleanupFunction(() => handlerSvc.remove(txtHandlerInfo));
+
+ let oldLaunchFile = DownloadIntegration.launchFile;
+ let promiseLaunchFileCalled = new Promise(resolve => {
+ DownloadIntegration.launchFile = file => {
+ ok(true, "The file should be launched with an external application");
+ resolve(file);
+ DownloadIntegration.launchFile = oldLaunchFile;
+ };
+ });
+ registerCleanupFunction(() => {
+ DownloadIntegration.launchFile = oldLaunchFile;
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: DOWNLOAD_URL,
+ waitForLoad: false,
+ waitForStop: true,
+ },
+ async function () {
+ let download = await downloadStarted;
+ isnot(
+ PathUtils.parent(download.target.partFilePath),
+ gDownloadDir.path,
+ "Should not start the download in the downloads dir."
+ );
+ continueResponses();
+ await download.whenSucceeded();
+ isnot(
+ PathUtils.parent(download.target.path),
+ gDownloadDir.path,
+ "Should not have put final file in the downloads dir."
+ );
+ let file = await promiseLaunchFileCalled;
+ ok(
+ !file.parent.equals(gDownloadDir),
+ "Should not have put opened file in the downloads dir."
+ );
+
+ // Pretend that we've quit so we wipe all the files:
+ gExternalHelperAppService.observe(null, "profile-before-change", "");
+ // Now the file should go away, but that's async...
+
+ let f = new FileUtils.File(download.target.path);
+ await TestUtils.waitForCondition(
+ () => !f.exists(),
+ "Temp file should be removed",
+ 500
+ ).catch(err => ok(false, err));
+ ok(!f.exists(), "Temp file should be removed.");
+
+ await IOUtils.remove(download.target.path);
+ }
+ );
+
+ handlerSvc.remove(txtHandlerInfo);
+ await list.removeFinished();
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_autohide.js b/browser/components/downloads/test/browser/browser_downloads_autohide.js
new file mode 100644
index 0000000000..9e3f8b6107
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_autohide.js
@@ -0,0 +1,517 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const kDownloadAutoHidePref = "browser.download.autohideButton";
+
+registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref(kDownloadAutoHidePref);
+ if (document.documentElement.hasAttribute("customizing")) {
+ await gCustomizeMode.reset();
+ await promiseCustomizeEnd();
+ } else {
+ CustomizableUI.reset();
+ }
+});
+
+add_setup(async () => {
+ // Disable window occlusion. See bug 1733955 / bug 1779559.
+ if (navigator.platform.indexOf("Win") == 0) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["widget.windows.window_occlusion_tracking.enabled", false]],
+ });
+ }
+});
+
+add_task(async function checkStateDuringPrefFlips() {
+ ok(
+ Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ "Should be autohiding the button by default"
+ );
+ ok(
+ !DownloadsIndicatorView.hasDownloads,
+ "Should be no downloads when starting the test"
+ );
+ let downloadsButton = document.getElementById("downloads-button");
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden in the toolbar"
+ );
+ await gCustomizeMode.addToPanel(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button shouldn't be hidden in the panel"
+ );
+ ok(
+ !Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ "Pref got set to false when the user moved the button"
+ );
+ gCustomizeMode.addToToolbar(downloadsButton);
+ ok(
+ !Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ "Pref remains false when the user moved the button"
+ );
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, true);
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden again in the toolbar " +
+ "now that we flipped the pref"
+ );
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button shouldn't be hidden with autohide turned off"
+ );
+ await gCustomizeMode.addToPanel(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button shouldn't be hidden with autohide turned off " +
+ "after moving it to the panel"
+ );
+ gCustomizeMode.addToToolbar(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button shouldn't be hidden with autohide turned off " +
+ "after moving it back to the toolbar"
+ );
+ await gCustomizeMode.addToPanel(downloadsButton);
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, true);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still not be hidden with autohide turned back on " +
+ "because it's in the panel"
+ );
+ // Use CUI directly instead of the customize mode APIs,
+ // to avoid tripping the "automatically turn off autohide" code.
+ CustomizableUI.addWidgetToArea("downloads-button", "nav-bar");
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden again in the toolbar"
+ );
+ gCustomizeMode.removeFromArea(downloadsButton);
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
+ // Can't use gCustomizeMode.addToToolbar here because it doesn't work for
+ // palette items if the window isn't in customize mode:
+ CustomizableUI.addWidgetToArea(
+ downloadsButton.id,
+ CustomizableUI.AREA_NAVBAR
+ );
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be unhidden again in the toolbar " +
+ "even if the pref was flipped while the button was in the palette"
+ );
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, true);
+});
+
+add_task(async function checkStateInCustomizeMode() {
+ ok(
+ Services.prefs.getBoolPref("browser.download.autohideButton"),
+ "Should be autohiding the button"
+ );
+ let downloadsButton = document.getElementById("downloads-button");
+ await promiseCustomizeStart();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode."
+ );
+ await promiseCustomizeEnd();
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden if it's in the toolbar " +
+ "after customize mode without any moves."
+ );
+ await promiseCustomizeStart();
+ await gCustomizeMode.addToPanel(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode when moved to the panel"
+ );
+ gCustomizeMode.addToToolbar(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode when moved back to the toolbar"
+ );
+ gCustomizeMode.removeFromArea(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode when in the palette"
+ );
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, true);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode " +
+ "even when flipping the autohide pref"
+ );
+ await gCustomizeMode.addToPanel(downloadsButton);
+ await promiseCustomizeEnd();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown after customize mode when moved to the panel"
+ );
+ await promiseCustomizeStart();
+ gCustomizeMode.addToToolbar(downloadsButton);
+ await promiseCustomizeEnd();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the toolbar after " +
+ "customize mode because we moved it."
+ );
+ await promiseCustomizeStart();
+ await gCustomizeMode.reset();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the toolbar in customize mode after a reset."
+ );
+ await gCustomizeMode.undoReset();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the toolbar in customize mode " +
+ "when undoing the reset."
+ );
+ await gCustomizeMode.addToPanel(downloadsButton);
+ await gCustomizeMode.reset();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the toolbar in customize mode " +
+ "after a reset moved it."
+ );
+ await gCustomizeMode.undoReset();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the panel in customize mode " +
+ "when undoing the reset."
+ );
+ await gCustomizeMode.reset();
+ await promiseCustomizeEnd();
+});
+
+add_task(async function checkStateInCustomizeModeMultipleWindows() {
+ ok(
+ Services.prefs.getBoolPref("browser.download.autohideButton"),
+ "Should be autohiding the button"
+ );
+ let downloadsButton = document.getElementById("downloads-button");
+ await promiseCustomizeStart();
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode."
+ );
+ let otherWin = await BrowserTestUtils.openNewBrowserWindow();
+ let otherDownloadsButton =
+ otherWin.document.getElementById("downloads-button");
+ ok(
+ otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be hidden in the other window."
+ );
+
+ // Use CUI directly instead of the customize mode APIs,
+ // to avoid tripping the "automatically turn off autohide" code.
+ CustomizableUI.addWidgetToArea(
+ "downloads-button",
+ CustomizableUI.AREA_FIXED_OVERFLOW_PANEL
+ );
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be shown in customize mode."
+ );
+ ok(
+ !otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the other window too because it's in a panel."
+ );
+
+ CustomizableUI.addWidgetToArea(
+ "downloads-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be shown in customize mode."
+ );
+ ok(
+ otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be hidden again in the other window."
+ );
+
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode"
+ );
+ ok(
+ !otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the other window with the pref flipped"
+ );
+
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, true);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be shown in customize mode " +
+ "even when flipping the autohide pref"
+ );
+ ok(
+ otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be hidden in the other window with the pref flipped again"
+ );
+
+ await gCustomizeMode.addToPanel(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be shown in customize mode."
+ );
+ ok(
+ !otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be shown in the other window too because it's in a panel."
+ );
+
+ gCustomizeMode.removeFromArea(downloadsButton);
+ ok(
+ !Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ "Autohide pref turned off by moving the button"
+ );
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be shown in customize mode."
+ );
+ // Don't need to assert in the other window - button is gone there.
+
+ await gCustomizeMode.reset();
+ ok(
+ Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ "Autohide pref reset by reset()"
+ );
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be shown in customize mode."
+ );
+ ok(
+ otherDownloadsButton.hasAttribute("hidden"),
+ "Button should be hidden in the other window."
+ );
+ ok(
+ otherDownloadsButton.closest("#nav-bar"),
+ "Button should be back in the nav bar in the other window."
+ );
+
+ await promiseCustomizeEnd();
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden again outside of customize mode"
+ );
+ await BrowserTestUtils.closeWindow(otherWin);
+});
+
+add_task(async function checkStateForDownloads() {
+ ok(
+ Services.prefs.getBoolPref("browser.download.autohideButton"),
+ "Should be autohiding the button"
+ );
+ let downloadsButton = document.getElementById("downloads-button");
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden when there are no downloads."
+ );
+
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_DOWNLOADING }]);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be unhidden when there are downloads."
+ );
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ for (let download of downloads) {
+ publicList.remove(download);
+ }
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden when the download is removed"
+ );
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_DOWNLOADING }]);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should be unhidden when there are downloads."
+ );
+
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, false);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be unhidden."
+ );
+
+ downloads = await publicList.getAll();
+ for (let download of downloads) {
+ publicList.remove(download);
+ }
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still be unhidden because the pref was flipped."
+ );
+ Services.prefs.setBoolPref(kDownloadAutoHidePref, true);
+ ok(
+ downloadsButton.hasAttribute("hidden"),
+ "Button should be hidden now that the pref flipped back " +
+ "because there were already no downloads."
+ );
+
+ gCustomizeMode.addToPanel(downloadsButton);
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should not be hidden in the panel."
+ );
+
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_DOWNLOADING }]);
+
+ downloads = await publicList.getAll();
+ for (let download of downloads) {
+ publicList.remove(download);
+ }
+
+ ok(
+ !downloadsButton.hasAttribute("hidden"),
+ "Button should still not be hidden in the panel " +
+ "when downloads count reaches 0 after being non-0."
+ );
+
+ CustomizableUI.reset();
+});
+
+/**
+ * Check that if the button is moved to the palette, we unhide it
+ * in customize mode even if it was always hidden. We use a new
+ * window to test this.
+ */
+add_task(async function checkStateWhenHiddenInPalette() {
+ ok(
+ Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ "Pref should be causing us to autohide"
+ );
+ gCustomizeMode.removeFromArea(document.getElementById("downloads-button"));
+ // In a new window, the button will have been hidden
+ let otherWin = await BrowserTestUtils.openNewBrowserWindow();
+ ok(
+ !otherWin.document.getElementById("downloads-button"),
+ "Button shouldn't be visible in the window"
+ );
+
+ let paletteButton =
+ otherWin.gNavToolbox.palette.querySelector("#downloads-button");
+ ok(paletteButton, "Button should exist in the palette");
+ if (paletteButton) {
+ ok(paletteButton.hidden, "Button will still have the hidden attribute");
+ await promiseCustomizeStart(otherWin);
+ ok(
+ !paletteButton.hidden,
+ "Button should no longer be hidden in customize mode"
+ );
+ ok(
+ otherWin.document.getElementById("downloads-button"),
+ "Button should be in the document now."
+ );
+ await promiseCustomizeEnd(otherWin);
+ // We purposefully don't assert anything about what happens next.
+ // It doesn't really matter if the button remains unhidden in
+ // the palette, and if we move it we'll unhide it then (the other
+ // tests check this).
+ }
+ await BrowserTestUtils.closeWindow(otherWin);
+ CustomizableUI.reset();
+});
+
+add_task(async function checkContextMenu() {
+ let contextMenu = document.getElementById("toolbar-context-menu");
+ let checkbox = document.getElementById(
+ "toolbar-context-autohide-downloads-button"
+ );
+ let button = document.getElementById("downloads-button");
+
+ is(
+ Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ true,
+ "Pref should be causing us to autohide"
+ );
+ is(
+ DownloadsIndicatorView.hasDownloads,
+ false,
+ "Should be no downloads when starting the test"
+ );
+ is(button.hidden, true, "Downloads button is hidden");
+
+ info("Simulate a download to show the downloads button.");
+ DownloadsIndicatorView.hasDownloads = true;
+ is(button.hidden, false, "Downloads button is visible");
+
+ info("Check context menu");
+ await openContextMenu(button);
+ is(checkbox.hidden, false, "Auto-hide checkbox is visible");
+ is(checkbox.getAttribute("checked"), "true", "Auto-hide is enabled");
+
+ info("Disable auto-hide via context menu");
+ clickCheckbox(checkbox);
+ is(
+ Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ false,
+ "Pref has been set to false"
+ );
+
+ info("Clear downloads");
+ DownloadsIndicatorView.hasDownloads = false;
+ is(button.hidden, false, "Downloads button is still visible");
+
+ info("Check context menu");
+ await openContextMenu(button);
+ is(checkbox.hidden, false, "Auto-hide checkbox is visible");
+ is(checkbox.hasAttribute("checked"), false, "Auto-hide is disabled");
+
+ info("Enable auto-hide via context menu");
+ clickCheckbox(checkbox);
+ is(button.hidden, true, "Downloads button is hidden");
+ is(
+ Services.prefs.getBoolPref(kDownloadAutoHidePref),
+ true,
+ "Pref has been set to true"
+ );
+
+ info("Check context menu in another button");
+ await openContextMenu(document.getElementById("reload-button"));
+ is(checkbox.hidden, true, "Auto-hide checkbox is hidden");
+ contextMenu.hidePopup();
+
+ info("Open popup directly");
+ contextMenu.openPopup();
+ is(checkbox.hidden, true, "Auto-hide checkbox is hidden");
+ contextMenu.hidePopup();
+});
+
+function promiseCustomizeStart(aWindow = window) {
+ return new Promise(resolve => {
+ aWindow.gNavToolbox.addEventListener("customizationready", resolve, {
+ once: true,
+ });
+ aWindow.gCustomizeMode.enter();
+ });
+}
+
+function promiseCustomizeEnd(aWindow = window) {
+ return new Promise(resolve => {
+ aWindow.gNavToolbox.addEventListener("aftercustomization", resolve, {
+ once: true,
+ });
+ aWindow.gCustomizeMode.exit();
+ });
+}
+
+function clickCheckbox(checkbox) {
+ // Clicking a checkbox toggles its checkedness first.
+ if (checkbox.getAttribute("checked") == "true") {
+ checkbox.removeAttribute("checked");
+ } else {
+ checkbox.setAttribute("checked", "true");
+ }
+ // Then it runs the command and closes the popup.
+ checkbox.doCommand();
+ checkbox.parentElement.hidePopup();
+}
diff --git a/browser/components/downloads/test/browser/browser_downloads_context_menu_always_open_similar_files.js b/browser/components/downloads/test/browser/browser_downloads_context_menu_always_open_similar_files.js
new file mode 100644
index 0000000000..6030d126c7
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_context_menu_always_open_similar_files.js
@@ -0,0 +1,236 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gMimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+let gHandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+);
+
+let gDownloadDir;
+const TestFiles = {};
+let downloads = [];
+const { handleInternally, saveToDisk, useSystemDefault, alwaysAsk } =
+ Ci.nsIHandlerInfo;
+
+function ensureMIMEState({ preferredAction, alwaysAskBeforeHandling = false }) {
+ const mimeInfo = gMimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ mimeInfo.preferredAction = preferredAction;
+ mimeInfo.alwaysAskBeforeHandling = alwaysAskBeforeHandling;
+ gHandlerSvc.store(mimeInfo);
+}
+
+async function createDownloadFile() {
+ if (!gDownloadDir) {
+ gDownloadDir = await setDownloadDir();
+ }
+ info("Created download directory: " + gDownloadDir);
+ TestFiles.txt = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded.txt"),
+ "Test file"
+ );
+ info("Created downloaded text file at:" + TestFiles.txt.path);
+
+ info("Setting path for download file");
+ // Set target for download file. Otherwise, file will default to .file instead of txt
+ // when we prepare our downloads - particularly in task_addDownloads().
+ let targetPath = PathUtils.join(PathUtils.tempDir, "downloaded.txt");
+ let target = new FileUtils.File(targetPath);
+ target.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ downloads.push({
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target,
+ });
+}
+
+async function prepareDownloadFiles(downloadList) {
+ // prepare downloads
+ await task_addDownloads(downloads);
+ let [download] = await downloadList.getAll();
+ info("Download succeeded? " + download.succeeded);
+ info("Download target exists? " + download.target.exists);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+ const originalOpenDownload = DownloadsCommon.openDownload;
+ // overwrite DownloadsCommon.openDownload to prevent file from opening during tests
+ DownloadsCommon.openDownload = async () => {
+ info("Overwriting openDownload for tests");
+ };
+
+ registerCleanupFunction(async () => {
+ DownloadsCommon.openDownload = originalOpenDownload;
+ info("Resetting downloads and closing downloads panel");
+ await task_resetState();
+ });
+
+ // remove download files, empty out collections
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ is(downloadCount, 0, "At the start of the test, there should be 0 downloads");
+
+ await createDownloadFile();
+ await prepareDownloadFiles(downloadList);
+});
+
+add_task(async function test_checkbox_useSystemDefault() {
+ // force mimetype pref
+ ensureMIMEState({ preferredAction: useSystemDefault });
+
+ await task_openPanel();
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ info("trigger the context menu");
+ let itemTarget = document.querySelector(
+ "#downloadsListBox richlistitem .downloadMainArea"
+ );
+
+ let contextMenu = await openContextMenu(itemTarget);
+ let alwaysOpenSimilarFilesItem = contextMenu.querySelector(
+ ".downloadAlwaysOpenSimilarFilesMenuItem"
+ );
+
+ ok(
+ !BrowserTestUtils.is_hidden(alwaysOpenSimilarFilesItem),
+ "alwaysOpenSimilarFiles should be visible"
+ );
+ ok(
+ alwaysOpenSimilarFilesItem.hasAttribute("checked"),
+ "alwaysOpenSimilarFiles should have checkbox attribute"
+ );
+
+ contextMenu.hidePopup();
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+});
+
+add_task(async function test_checkbox_saveToDisk() {
+ // force mimetype pref
+ ensureMIMEState({ preferredAction: saveToDisk });
+
+ await task_openPanel();
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ info("trigger the context menu");
+ let itemTarget = document.querySelector(
+ "#downloadsListBox richlistitem .downloadMainArea"
+ );
+
+ let contextMenu = await openContextMenu(itemTarget);
+ let alwaysOpenSimilarFilesItem = contextMenu.querySelector(
+ ".downloadAlwaysOpenSimilarFilesMenuItem"
+ );
+
+ ok(
+ !BrowserTestUtils.is_hidden(alwaysOpenSimilarFilesItem),
+ "alwaysOpenSimilarFiles should be visible"
+ );
+ ok(
+ !alwaysOpenSimilarFilesItem.hasAttribute("checked"),
+ "alwaysOpenSimilarFiles should not have checkbox attribute"
+ );
+
+ contextMenu.hidePopup();
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+});
+
+add_task(async function test_preferences_enable_alwaysOpenSimilarFiles() {
+ // Force mimetype pref
+ ensureMIMEState({ preferredAction: saveToDisk });
+
+ // open panel
+ await task_openPanel();
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ info("trigger the context menu");
+ let itemTarget = document.querySelector(
+ "#downloadsListBox richlistitem .downloadMainArea"
+ );
+
+ let contextMenu = await openContextMenu(itemTarget);
+ let alwaysOpenSimilarFilesItem = contextMenu.querySelector(
+ ".downloadAlwaysOpenSimilarFilesMenuItem"
+ );
+
+ alwaysOpenSimilarFilesItem.click();
+
+ await TestUtils.waitForCondition(() => {
+ let mimeInfo = gMimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ return mimeInfo.preferredAction === useSystemDefault;
+ });
+ let mimeInfo = gMimeSvc.getFromTypeAndExtension("text/plain", "txt");
+
+ is(
+ mimeInfo.preferredAction,
+ useSystemDefault,
+ "Preference should switch to useSystemDefault"
+ );
+
+ contextMenu.hidePopup();
+ DownloadsPanel.hidePanel();
+});
+
+add_task(async function test_preferences_disable_alwaysOpenSimilarFiles() {
+ // Force mimetype pref
+ ensureMIMEState({ preferredAction: useSystemDefault });
+
+ await task_openPanel();
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ info("trigger the context menu");
+ let itemTarget = document.querySelector(
+ "#downloadsListBox richlistitem .downloadMainArea"
+ );
+
+ let contextMenu = await openContextMenu(itemTarget);
+ let alwaysOpenSimilarFilesItem = contextMenu.querySelector(
+ ".downloadAlwaysOpenSimilarFilesMenuItem"
+ );
+
+ alwaysOpenSimilarFilesItem.click();
+
+ await TestUtils.waitForCondition(() => {
+ let mimeInfo = gMimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ return mimeInfo.preferredAction === saveToDisk;
+ });
+ let mimeInfo = gMimeSvc.getFromTypeAndExtension("text/plain", "txt");
+
+ is(
+ mimeInfo.preferredAction,
+ saveToDisk,
+ "Preference should switch to saveToDisk"
+ );
+
+ contextMenu.hidePopup();
+ DownloadsPanel.hidePanel();
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_context_menu_delete_file.js b/browser/components/downloads/test/browser/browser_downloads_context_menu_delete_file.js
new file mode 100644
index 0000000000..4615f0a369
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_context_menu_delete_file.js
@@ -0,0 +1,253 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var { DownloadHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadHistory.sys.mjs"
+);
+let gDownloadDir;
+let downloads = [];
+
+async function createDownloadFiles() {
+ if (!gDownloadDir) {
+ gDownloadDir = await setDownloadDir();
+ }
+ info("Created download directory: " + gDownloadDir);
+ info("Setting path for download file");
+ downloads.push({
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target: await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded.txt"),
+ "Test file"
+ ),
+ });
+ downloads.push({
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/javascript",
+ target: await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded.js"),
+ "Test file"
+ ),
+ });
+}
+
+add_setup(startServer);
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_download_deleteFile() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.alwaysOpenPanel", false],
+ ["browser.download.clearHistoryOnDelete", 2],
+ ],
+ });
+
+ // remove download files, empty out collections
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ is(downloadCount, 0, "At the start of the test, there should be 0 downloads");
+ await task_resetState();
+ await createDownloadFiles();
+ await task_addDownloads(downloads);
+ await task_openPanel();
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ info("trigger the context menu");
+ let itemTarget = document.querySelector(
+ "#downloadsListBox richlistitem .downloadMainArea"
+ );
+ let contextMenu = await openContextMenu(itemTarget);
+ let deleteFileItem = contextMenu.querySelector(
+ '[command="downloadsCmd_deleteFile"]'
+ );
+ ok(
+ !BrowserTestUtils.is_hidden(deleteFileItem),
+ "deleteFileItem should be visible"
+ );
+
+ let target1 = downloads[1].target;
+ ok(target1.exists(), "downloaded.txt should exist");
+ info(`file path: ${target1.path}`);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+
+ contextMenu.activateItem(deleteFileItem);
+
+ await TestUtils.waitForCondition(() => !target1.exists());
+
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == 1;
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.clearHistoryOnDelete", 0]],
+ });
+ info("trigger the context menu again");
+ let itemTarget2 = document.querySelector(
+ "#downloadsListBox richlistitem .downloadMainArea"
+ );
+ let contextMenu2 = await openContextMenu(itemTarget2);
+ ok(
+ !BrowserTestUtils.is_hidden(deleteFileItem),
+ "deleteFileItem should be visible"
+ );
+ let target2 = downloads[0].target;
+ ok(target2.exists(), "downloaded.js should exist");
+ info(`file path: ${target2.path}`);
+ contextMenu2.activateItem(deleteFileItem);
+ await TestUtils.waitForCondition(() => !target2.exists());
+
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ Assert.greater(
+ downloadsListBox.childElementCount,
+ 0,
+ "There should be a download in the list"
+ );
+
+ ok(
+ !DownloadsView.richListBox.selectedItem._shell.isCommandEnabled(
+ "downloadsCmd_deleteFile"
+ ),
+ "Delete file command should be disabled"
+ );
+
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+});
+
+add_task(async function test_about_downloads_deleteFile_for_history_download() {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+
+ if (!gDownloadDir) {
+ gDownloadDir = await setDownloadDir();
+ }
+
+ let targetFile = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "test-download.txt"),
+ "blah blah blah"
+ );
+ let endTime;
+ try {
+ endTime = targetFile.creationTime;
+ } catch (e) {
+ endTime = Date.now();
+ }
+ let download = {
+ source: {
+ url: httpUrl(targetFile.leafName),
+ isPrivate: false,
+ },
+ target: {
+ path: targetFile.path,
+ size: targetFile.fileSize,
+ },
+ succeeded: true,
+ stopped: true,
+ endTime,
+ fileSize: targetFile.fileSize,
+ state: 1,
+ };
+
+ function promiseWaitForVisit(aUrl) {
+ return new Promise(resolve => {
+ function listener(aEvents) {
+ Assert.equal(aEvents.length, 1);
+ let event = aEvents[0];
+ Assert.equal(event.type, "page-visited");
+ if (event.url == aUrl) {
+ PlacesObservers.removeListener(["page-visited"], listener);
+ resolve([
+ event.visitTime,
+ event.transitionType,
+ event.lastKnownTitle,
+ ]);
+ }
+ }
+ PlacesObservers.addListener(["page-visited"], listener);
+ });
+ }
+
+ function waitForAnnotation(sourceUriSpec, annotationName) {
+ return TestUtils.waitForCondition(async () => {
+ let pageInfo = await PlacesUtils.history.fetch(sourceUriSpec, {
+ includeAnnotations: true,
+ });
+ return pageInfo && pageInfo.annotations.has(annotationName);
+ }, `Should have found annotation ${annotationName} for ${sourceUriSpec}.`);
+ }
+
+ // Add the download to history using the XPCOM service, then use the
+ // DownloadHistory module to save the associated metadata.
+ let promiseFileAnnotation = waitForAnnotation(
+ download.source.url,
+ "downloads/destinationFileURI"
+ );
+ let promiseMetaAnnotation = waitForAnnotation(
+ download.source.url,
+ "downloads/metaData"
+ );
+ let promiseVisit = promiseWaitForVisit(download.source.url);
+ await DownloadHistory.addDownloadToHistory(download);
+ await promiseVisit;
+ await DownloadHistory.updateMetaData(download);
+ await Promise.all([promiseFileAnnotation, promiseMetaAnnotation]);
+
+ let win = await openLibrary("Downloads");
+ registerCleanupFunction(function () {
+ win?.close();
+ });
+
+ let box = win.document.getElementById("downloadsListBox");
+ ok(box, "Should have list of downloads");
+ is(box.children.length, 1, "Should have 1 download.");
+ let kid = box.firstChild;
+ let desc = kid.querySelector(".downloadTarget");
+ let dl = kid._shell.download;
+ // This would just be an `is` check, but stray temp files
+ // if this test (or another in this dir) ever fails could throw that off.
+ ok(
+ desc.value.includes("test-download"),
+ `Label '${desc.value}' should include 'test-download'`
+ );
+ ok(kid.selected, "First item should be selected.");
+ ok(dl.placesNode, "Download should have history.");
+ ok(targetFile.exists(), "Download target should exist.");
+ let contextMenu = win.document.getElementById("downloadsContextMenu");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ kid,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await popupShownPromise;
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.activateItem(
+ contextMenu.querySelector(".downloadDeleteFileMenuItem")
+ );
+ await popupHiddenPromise;
+ await TestUtils.waitForCondition(() => !targetFile.exists());
+ info("History download target deleted.");
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_context_menu_selection.js b/browser/components/downloads/test/browser/browser_downloads_context_menu_selection.js
new file mode 100644
index 0000000000..a4a8eacf36
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_context_menu_selection.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that the context menu refers to the triggering item, even if the
+ * selection was not set preemptively.
+ */
+
+async function createDownloadFiles() {
+ let dir = await setDownloadDir();
+ let downloads = [];
+ downloads.push({
+ state: DownloadsCommon.DOWNLOAD_FAILED,
+ contentType: "text/plain",
+ target: new FileUtils.File(PathUtils.join(dir, "does-not-exist.txt")),
+ });
+ downloads.push({
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target: await createDownloadedFile(PathUtils.join(dir, "file.txt"), "file"),
+ });
+ return downloads;
+}
+
+add_setup(async function setup() {
+ await PlacesUtils.history.clear();
+ await startServer();
+
+ registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+ });
+});
+
+add_task(async function test() {
+ // remove download files, empty out collections
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ Assert.equal(downloadCount, 0, "There should be 0 downloads");
+ await task_resetState();
+ let downloads = await createDownloadFiles();
+ await task_addDownloads(downloads);
+ await task_openPanel();
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ await TestUtils.waitForCondition(() => {
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ // Note we're not doing anything to set the selectedItem here, exactly to
+ // check the context menu doesn't depend on some selection prerequisite.
+
+ let first = downloadsListBox.querySelector("richlistitem");
+ let second = downloadsListBox.querySelector("richlistitem:nth-child(2)");
+
+ info("Check first item");
+ let firstDownload = DownloadsView.itemForElement(first).download;
+ is(
+ DownloadsCommon.stateOfDownload(firstDownload),
+ DownloadsCommon.DOWNLOAD_FINISHED,
+ "Download states match up"
+ );
+ // mousemove to the _other_ download, to ensure it doesn't confuse code.
+ EventUtils.synthesizeMouse(second, -5, -5, { type: "mousemove" });
+ await checkCommandsWithContextMenu(first, {
+ downloadsCmd_show: true,
+ cmd_delete: true,
+ });
+
+ info("Check second item");
+ let secondDownload = DownloadsView.itemForElement(second).download;
+ is(
+ DownloadsCommon.stateOfDownload(secondDownload),
+ DownloadsCommon.DOWNLOAD_FAILED,
+ "Download states match up"
+ );
+ // mousemove to the _other_ download, to ensure it doesn't confuse code.
+ EventUtils.synthesizeMouse(first, -5, -5, { type: "mousemove" });
+ await checkCommandsWithContextMenu(second, {
+ downloadsCmd_show: false,
+ cmd_delete: true,
+ });
+
+ info("Check we don't open a context menu between items.");
+ function listener() {
+ Assert.ok(false, "Should not open a context menu");
+ }
+ document.addEventListener("popupshown", listener);
+ let listRect = downloadsListBox.getBoundingClientRect();
+ let firstRect = first.getBoundingClientRect();
+ let secondRect = second.getBoundingClientRect();
+ let x = parseInt(firstRect.width / 2);
+ Assert.greater(
+ secondRect.y - firstRect.y - firstRect.height,
+ 1,
+ "There should be a gap of at least 1 px for this test"
+ );
+ let y = parseInt(firstRect.y - listRect.y + firstRect.height + 1);
+ info(`Right click at (${x}, ${y})`);
+ EventUtils.synthesizeMouse(downloadsListBox, x, y, {
+ type: "contextmenu",
+ button: 2,
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 100));
+ document.removeEventListener("popupshown", listener);
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+});
+
+async function checkCommandsWithContextMenu(element, commands) {
+ let contextMenu = await openContextMenu(element);
+ for (let command in commands) {
+ let enabled = commands[command];
+ let commandStatus = enabled ? "enabled" : "disabled";
+ info(`Checking command ${command} is ${commandStatus}`);
+
+ let commandElt = contextMenu.querySelector(`[command="${command}"]`);
+ Assert.equal(
+ !BrowserTestUtils.is_hidden(commandElt),
+ enabled,
+ `${command} should be ${enabled ? "visible" : "hidden"}`
+ );
+
+ Assert.strictEqual(
+ DownloadsView.richListBox.selectedItem._shell.isCommandEnabled(command),
+ enabled,
+ `${command} should be ${commandStatus}`
+ );
+ }
+ contextMenu.hidePopup();
+}
diff --git a/browser/components/downloads/test/browser/browser_downloads_keynav.js b/browser/components/downloads/test/browser/browser_downloads_keynav.js
new file mode 100644
index 0000000000..23acf20417
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_keynav.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+});
+
+function changeSelection(listbox, down) {
+ let selectPromise = BrowserTestUtils.waitForEvent(listbox, "select");
+ EventUtils.synthesizeKey(down ? "VK_DOWN" : "VK_UP", {});
+ return selectPromise;
+}
+
+add_task(async function test_downloads_keynav() {
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+
+ // Move the mouse pointer out of the way first so it doesn't
+ // interfere with the selection.
+ let listbox = document.getElementById("downloadsListBox");
+ EventUtils.synthesizeMouse(listbox, -5, -5, { type: "mousemove" });
+
+ let downloads = [];
+ for (let i = 0; i < 2; i++) {
+ downloads.push({ state: DownloadsCommon.DOWNLOAD_FINISHED });
+ }
+ downloads.push({ state: DownloadsCommon.DOWNLOAD_FAILED });
+ downloads.push({ state: DownloadsCommon.DOWNLOAD_BLOCKED });
+
+ await task_addDownloads(downloads);
+ await task_openPanel();
+
+ is(document.activeElement, listbox, "downloads list is focused");
+ is(listbox.selectedIndex, 0, "downloads list selected index starts at 0");
+
+ let footer = document.getElementById("downloadsHistory");
+
+ await changeSelection(listbox, true);
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after down to index 1"
+ );
+ is(
+ listbox.selectedIndex,
+ 1,
+ "downloads list selected index after down is pressed"
+ );
+
+ checkTabbing(listbox, 1);
+
+ await changeSelection(listbox, true);
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after down to index 2"
+ );
+ is(
+ listbox.selectedIndex,
+ 2,
+ "downloads list selected index after down to index 2"
+ );
+
+ checkTabbing(listbox, 2);
+
+ await changeSelection(listbox, true);
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after down to index 3"
+ );
+ is(
+ listbox.selectedIndex,
+ 3,
+ "downloads list selected index after down to index 3"
+ );
+
+ checkTabbing(listbox, 3);
+
+ await changeSelection(listbox, true);
+ is(document.activeElement, footer, "footer is focused");
+ is(
+ listbox.selectedIndex,
+ -1,
+ "downloads list selected index after down to footer"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", {});
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list should be focused after tab when footer is focused"
+ );
+ is(
+ listbox.selectedIndex,
+ 0,
+ "downloads list should be focused after tab when footer is focused selected index"
+ );
+
+ // Move back to the footer.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ is(document.activeElement, footer, "downloads footer is focused again");
+ is(
+ listbox.selectedIndex,
+ 0,
+ "downloads footer is focused again selected index"
+ );
+
+ EventUtils.synthesizeKey("VK_DOWN", {});
+ is(
+ document.activeElement,
+ footer,
+ "downloads footer is still focused after down past footer"
+ );
+ is(
+ listbox.selectedIndex,
+ -1,
+ "downloads footer is still focused selected index after down past footer"
+ );
+
+ await changeSelection(listbox, false);
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after up to index 3"
+ );
+ is(
+ listbox.selectedIndex,
+ 3,
+ "downloads list selected index after up to index 3"
+ );
+
+ await changeSelection(listbox, false);
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after up to index 2"
+ );
+ is(
+ listbox.selectedIndex,
+ 2,
+ "downloads list selected index after up to index 2"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(listbox.getItemAtIndex(0), {
+ type: "mousemove",
+ });
+ EventUtils.synthesizeMouseAtCenter(listbox.getItemAtIndex(1), {
+ type: "mousemove",
+ });
+ is(listbox.selectedIndex, 0, "downloads list selected index after mousemove");
+
+ checkTabbing(listbox, 0);
+
+ EventUtils.synthesizeKey("VK_UP", {});
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is still focused after up past start"
+ );
+ is(
+ listbox.selectedIndex,
+ 0,
+ "downloads list is still focused after up past start selected index"
+ );
+
+ // Move the mouse pointer out of the way again so we don't
+ // hover over an item unintentionally if this test is run in verify mode.
+ EventUtils.synthesizeMouse(listbox, -5, -5, { type: "mousemove" });
+
+ await task_resetState();
+});
+
+async function checkTabbing(listbox, buttonIndex) {
+ let button = listbox.getItemAtIndex(buttonIndex).querySelector("button");
+ let footer = document.getElementById("downloadsHistory");
+
+ listbox.clientWidth; // flush layout first
+
+ EventUtils.synthesizeKey("VK_TAB", {});
+ is(
+ document.activeElement,
+ button,
+ "downloads button is focused after tab is pressed"
+ );
+ is(
+ listbox.selectedIndex,
+ buttonIndex,
+ "downloads button selected index after tab is pressed"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", {});
+ is(
+ document.activeElement,
+ footer,
+ "downloads footer is focused after tab is pressed again"
+ );
+ is(
+ listbox.selectedIndex,
+ buttonIndex,
+ "downloads footer selected index after tab is pressed again"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", {});
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after tab is pressed yet again"
+ );
+ is(
+ listbox.selectedIndex,
+ buttonIndex,
+ "downloads list selected index after tab is pressed yet again"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ is(
+ document.activeElement,
+ footer,
+ "downloads footer is focused after shift+tab is pressed"
+ );
+ is(
+ listbox.selectedIndex,
+ buttonIndex,
+ "downloads footer selected index after shift+tab is pressed"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ is(
+ document.activeElement,
+ button,
+ "downloads button is focused after shift+tab is pressed again"
+ );
+ is(
+ listbox.selectedIndex,
+ buttonIndex,
+ "downloads button selected index after shift+tab is pressed again"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ is(
+ document.activeElement,
+ listbox,
+ "downloads list is focused after shift+tab is pressed yet again"
+ );
+ is(
+ listbox.selectedIndex,
+ buttonIndex,
+ "downloads list selected index after shift+tab is pressed yet again"
+ );
+}
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_block.js b/browser/components/downloads/test/browser/browser_downloads_panel_block.js
new file mode 100644
index 0000000000..d1791a5862
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_block.js
@@ -0,0 +1,185 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+add_task(async function mainTest() {
+ await task_resetState();
+
+ let verdicts = [
+ Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+ Downloads.Error.BLOCK_VERDICT_MALWARE,
+ Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED,
+ Downloads.Error.BLOCK_VERDICT_INSECURE,
+ ];
+ await task_addDownloads(verdicts.map(v => makeDownload(v)));
+
+ // Check that the richlistitem for each download is correct.
+ for (let i = 0; i < verdicts.length; i++) {
+ await openPanel();
+
+ // Handle items backwards, using lastElementChild, to ensure there's no
+ // code wrongly resetting the selection to the first item during the process.
+ let item = DownloadsView.richListBox.lastElementChild;
+
+ info("Open the panel and click the item to show the subview.");
+ let viewPromise = promiseViewShown(DownloadsBlockedSubview.subview);
+ EventUtils.synthesizeMouseAtCenter(item, {});
+ await viewPromise;
+
+ // Items are listed in newest-to-oldest order, so e.g. the first item's
+ // verdict is the last element in the verdicts array.
+ Assert.ok(
+ DownloadsBlockedSubview.subview.getAttribute("verdict"),
+ verdicts[verdicts.count - i - 1]
+ );
+
+ info("Go back to the main view.");
+ viewPromise = promiseViewShown(DownloadsBlockedSubview.mainView);
+ DownloadsBlockedSubview.panelMultiView.goBack();
+ await viewPromise;
+
+ info("Show the subview again.");
+ viewPromise = promiseViewShown(DownloadsBlockedSubview.subview);
+ EventUtils.synthesizeMouseAtCenter(item, {});
+ await viewPromise;
+
+ info("Click the Open button.");
+ // The download should be unblocked and then opened,
+ // i.e., unblockAndOpenDownload() should be called on the item. The panel
+ // should also be closed as a result, so wait for that too.
+ let unblockPromise = promiseUnblockAndSaveCalled(item);
+ let hidePromise = promisePanelHidden();
+ // Simulate a mousemove to ensure it's not wrongly being handled by the
+ // panel as the user changing download selection.
+ EventUtils.synthesizeMouseAtCenter(
+ DownloadsBlockedSubview.elements.unblockButton,
+ { type: "mousemove" }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ DownloadsBlockedSubview.elements.unblockButton,
+ {}
+ );
+ info("waiting for unblockOpen");
+ await unblockPromise;
+ info("waiting for hide panel");
+ await hidePromise;
+
+ window.focus();
+ await SimpleTest.promiseFocus(window);
+
+ info("Reopen the panel and show the subview again.");
+ await openPanel();
+ viewPromise = promiseViewShown(DownloadsBlockedSubview.subview);
+ EventUtils.synthesizeMouseAtCenter(item, {});
+ await viewPromise;
+
+ info("Click the Remove button.");
+ // The panel should close and the item should be removed from it.
+ hidePromise = promisePanelHidden();
+ EventUtils.synthesizeMouseAtCenter(
+ DownloadsBlockedSubview.elements.deleteButton,
+ {}
+ );
+ info("Waiting for hide panel");
+ await hidePromise;
+
+ info("Open the panel again and check the item is gone.");
+ await openPanel();
+ Assert.ok(!item.parentNode);
+
+ hidePromise = promisePanelHidden();
+ DownloadsPanel.hidePanel();
+ await hidePromise;
+ }
+
+ await task_resetState();
+});
+
+async function openPanel() {
+ // This function is insane but something intermittently causes the panel to be
+ // closed as soon as it's opening on Linux ASAN. Maybe it would also happen
+ // on other build machines if the test ran often enough. Not only is the
+ // panel closed, it's closed while it's opening, leaving DownloadsPanel._state
+ // such that when you try to open the panel again, it thinks it's already
+ // open, but it's not. The result is that the test times out.
+ //
+ // What this does is call DownloadsPanel.showPanel over and over again until
+ // the panel is really open. There are a few wrinkles:
+ //
+ // (1) When panel.state is "open", check four more times (for a total of five)
+ // before returning to make the panel stays open.
+ // (2) If the panel is not open, check the _state. It should be either
+ // kStateUninitialized or kStateHidden. If it's not, then the panel is in the
+ // process of opening -- or maybe it's stuck in that process -- so reset the
+ // _state to kStateHidden.
+ // (3) If the _state is not kStateUninitialized or kStateHidden, then it may
+ // actually be properly opening and not stuck at all. To avoid always closing
+ // the panel while it's properly opening, use an exponential backoff mechanism
+ // for retries.
+ //
+ // If all that fails, then the test will time out, but it would have timed out
+ // anyway.
+
+ await promiseFocus();
+ await new Promise(resolve => {
+ let verifyCount = 5;
+ let backoff = 0;
+ let iBackoff = 0;
+ let interval = setInterval(() => {
+ if (DownloadsPanel.panel && DownloadsPanel.panel.state == "open") {
+ if (verifyCount > 0) {
+ verifyCount--;
+ } else {
+ clearInterval(interval);
+ resolve();
+ }
+ } else if (iBackoff < backoff) {
+ // Keep backing off before trying again.
+ iBackoff++;
+ } else {
+ // Try (or retry) opening the panel.
+ verifyCount = 5;
+ backoff = Math.max(1, 2 * backoff);
+ iBackoff = 0;
+ if (DownloadsPanel._state != DownloadsPanel.kStateUninitialized) {
+ DownloadsPanel._state = DownloadsPanel.kStateHidden;
+ }
+ DownloadsPanel.showPanel();
+ }
+ }, 100);
+ });
+}
+
+function promisePanelHidden() {
+ return BrowserTestUtils.waitForEvent(DownloadsPanel.panel, "popuphidden");
+}
+
+function makeDownload(verdict) {
+ return {
+ state: DownloadsCommon.DOWNLOAD_DIRTY,
+ hasBlockedData: true,
+ errorObj: {
+ result: Cr.NS_ERROR_FAILURE,
+ message: "Download blocked.",
+ becauseBlocked: true,
+ becauseBlockedByReputationCheck: true,
+ reputationCheckVerdict: verdict,
+ },
+ };
+}
+
+function promiseViewShown(view) {
+ return BrowserTestUtils.waitForEvent(view, "ViewShown");
+}
+
+function promiseUnblockAndSaveCalled(item) {
+ return new Promise(resolve => {
+ let realFn = item._shell.unblockAndSave;
+ item._shell.unblockAndSave = async () => {
+ item._shell.unblockAndSave = realFn;
+ resolve();
+ };
+ });
+}
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js b/browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js
new file mode 100644
index 0000000000..6a85ba570b
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js
@@ -0,0 +1,421 @@
+/*
+ Coverage for context menu state for downloads in the Downloads Panel
+*/
+
+let gDownloadDir;
+const TestFiles = {};
+
+let ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+);
+
+// Load a new URI with a specific referrer.
+let exampleRefInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ Services.io.newURI("https://example.org")
+);
+
+const MENU_ITEMS = {
+ pause: ".downloadPauseMenuItem",
+ resume: ".downloadResumeMenuItem",
+ unblock: '[command="downloadsCmd_unblock"]',
+ openInSystemViewer: '[command="downloadsCmd_openInSystemViewer"]',
+ alwaysOpenInSystemViewer: '[command="downloadsCmd_alwaysOpenInSystemViewer"]',
+ alwaysOpenSimilarFiles: '[command="downloadsCmd_alwaysOpenSimilarFiles"]',
+ show: '[command="downloadsCmd_show"]',
+ commandsSeparator: "menuseparator,.downloadCommandsSeparator",
+ openReferrer: ".downloadOpenReferrerMenuItem",
+ copyLocation: ".downloadCopyLocationMenuItem",
+ separator: "menuseparator",
+ deleteFile: ".downloadDeleteFileMenuItem",
+ delete: '[command="cmd_delete"]',
+ clearList: '[command="downloadsCmd_clearList"]',
+ clearDownloads: '[command="downloadsCmd_clearDownloads"]',
+};
+
+const TestCasesNewMimetypes = [
+ {
+ name: "Completed txt download",
+ downloads: [
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ },
+ ],
+ expected: {
+ menu: [
+ MENU_ITEMS.alwaysOpenSimilarFiles,
+ MENU_ITEMS.show,
+ MENU_ITEMS.commandsSeparator,
+ MENU_ITEMS.openReferrer,
+ MENU_ITEMS.copyLocation,
+ MENU_ITEMS.separator,
+ MENU_ITEMS.deleteFile,
+ MENU_ITEMS.delete,
+ MENU_ITEMS.clearList,
+ ],
+ },
+ },
+ {
+ name: "Canceled txt download",
+ downloads: [
+ {
+ state: DownloadsCommon.DOWNLOAD_CANCELED,
+ contentType: "text/plain",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ },
+ ],
+ expected: {
+ menu: [
+ MENU_ITEMS.openReferrer,
+ MENU_ITEMS.copyLocation,
+ MENU_ITEMS.separator,
+ MENU_ITEMS.delete,
+ MENU_ITEMS.clearList,
+ ],
+ },
+ },
+ {
+ name: "Completed unknown ext download with application/octet-stream",
+ overrideExtension: "unknownExtension",
+ downloads: [
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "application/octet-stream",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ },
+ ],
+ expected: {
+ menu: [
+ MENU_ITEMS.show,
+ MENU_ITEMS.commandsSeparator,
+ MENU_ITEMS.openReferrer,
+ MENU_ITEMS.copyLocation,
+ MENU_ITEMS.separator,
+ MENU_ITEMS.deleteFile,
+ MENU_ITEMS.delete,
+ MENU_ITEMS.clearList,
+ ],
+ },
+ },
+ {
+ name: "Completed txt download with application/octet-stream",
+ overrideExtension: "txt",
+ downloads: [
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "application/octet-stream",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ },
+ ],
+ expected: {
+ menu: [
+ // Despite application/octet-stream content type, ensure
+ // alwaysOpenSimilarFiles still appears since txt files
+ // are supported file types.
+ MENU_ITEMS.alwaysOpenSimilarFiles,
+ MENU_ITEMS.show,
+ MENU_ITEMS.commandsSeparator,
+ MENU_ITEMS.openReferrer,
+ MENU_ITEMS.copyLocation,
+ MENU_ITEMS.separator,
+ MENU_ITEMS.deleteFile,
+ MENU_ITEMS.delete,
+ MENU_ITEMS.clearList,
+ ],
+ },
+ },
+];
+
+const TestCasesDeletedFile = [
+ {
+ name: "Download with file deleted",
+ downloads: [
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ deleted: true,
+ },
+ ],
+ expected: {
+ menu: [
+ MENU_ITEMS.alwaysOpenSimilarFiles,
+ MENU_ITEMS.openReferrer,
+ MENU_ITEMS.copyLocation,
+ MENU_ITEMS.separator,
+ MENU_ITEMS.delete,
+ MENU_ITEMS.clearList,
+ ],
+ },
+ },
+];
+
+const TestCasesMultipleFiles = [
+ {
+ name: "Multiple files",
+ downloads: [
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ },
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ contentType: "text/plain",
+ target: {},
+ source: {
+ referrerInfo: exampleRefInfo,
+ },
+ deleted: true,
+ },
+ ],
+ expected: {
+ menu: [
+ MENU_ITEMS.alwaysOpenSimilarFiles,
+ MENU_ITEMS.openReferrer,
+ MENU_ITEMS.copyLocation,
+ MENU_ITEMS.separator,
+ MENU_ITEMS.delete,
+ MENU_ITEMS.clearList,
+ ],
+ },
+ itemIndex: 1,
+ },
+];
+
+add_setup(async function () {
+ // remove download files, empty out collections
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ is(downloadCount, 0, "At the start of the test, there should be 0 downloads");
+
+ await task_resetState();
+ if (!gDownloadDir) {
+ gDownloadDir = await setDownloadDir();
+ }
+ info("Created download directory: " + gDownloadDir);
+
+ // create the downloaded files we'll need
+ TestFiles.pdf = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded.pdf"),
+ DATA_PDF
+ );
+ info("Created downloaded PDF file at:" + TestFiles.pdf.path);
+ TestFiles.txt = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded.txt"),
+ "Test file"
+ );
+ info("Created downloaded text file at:" + TestFiles.txt.path);
+ TestFiles.unknownExtension = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded.unknownExtension"),
+ "Test file"
+ );
+ info(
+ "Created downloaded unknownExtension file at:" +
+ TestFiles.unknownExtension.path
+ );
+ TestFiles.nonexistentFile = new FileUtils.File(
+ PathUtils.join(gDownloadDir, "nonexistent")
+ );
+ info(
+ "Created nonexistent downloaded file at:" + TestFiles.nonexistentFile.path
+ );
+});
+
+// non default mimetypes
+for (let testData of TestCasesNewMimetypes) {
+ if (testData.skip) {
+ info("Skipping test:" + testData.name);
+ continue;
+ }
+ // use the 'name' property of each test case as the test function name
+ // so we get useful logs
+ let tmp = {
+ async [testData.name]() {
+ await testDownloadContextMenu(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+for (let testData of TestCasesDeletedFile) {
+ if (testData.skip) {
+ info("Skipping test:" + testData.name);
+ continue;
+ }
+ // use the 'name' property of each test case as the test function name
+ // so we get useful logs
+ let tmp = {
+ async [testData.name]() {
+ await testDownloadContextMenu(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+for (let testData of TestCasesMultipleFiles) {
+ if (testData.skip) {
+ info("Skipping test:" + testData.name);
+ continue;
+ }
+ // use the 'name' property of each test case as the test function name
+ // so we get useful logs
+ let tmp = {
+ async [testData.name]() {
+ await testDownloadContextMenu(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+async function testDownloadContextMenu({
+ overrideExtension = null,
+ downloads = [],
+ expected,
+ itemIndex = 0,
+}) {
+ // prepare downloads
+ await prepareDownloads(downloads, overrideExtension);
+ let downloadList = await Downloads.getList(Downloads.PUBLIC);
+ let download = (await downloadList.getAll())[itemIndex];
+ info("Download succeeded? " + download.succeeded);
+ info("Download target exists? " + download.target.exists);
+
+ // open panel
+ await task_openPanel();
+ await TestUtils.waitForCondition(() => {
+ let downloadsListBox = document.getElementById("downloadsListBox");
+ downloadsListBox.removeAttribute("disabled");
+ return downloadsListBox.childElementCount == downloads.length;
+ });
+
+ let itemTarget = document
+ .querySelectorAll("#downloadsListBox richlistitem")
+ [itemIndex].querySelector(".downloadMainArea");
+ EventUtils.synthesizeMouse(itemTarget, 1, 1, { type: "mousemove" });
+ is(
+ DownloadsView.richListBox.selectedIndex,
+ 0,
+ "moving the mouse resets the richlistbox's selected index"
+ );
+
+ info("trigger the context menu");
+ let contextMenu = await openContextMenu(itemTarget);
+
+ // FIXME: This works in practice, but simulating the context menu opening
+ // doesn't seem to automatically set the selected index.
+ DownloadsView.richListBox.selectedIndex = itemIndex;
+ EventUtils.synthesizeMouse(itemTarget, 1, 1, { type: "mousemove" });
+ is(
+ DownloadsView.richListBox.selectedIndex,
+ itemIndex,
+ "selected index after opening the context menu and moving the mouse"
+ );
+
+ info("context menu should be open, verify its menu items");
+ let result = verifyContextMenu(contextMenu, expected.menu);
+
+ // close menus
+ contextMenu.hidePopup();
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+
+ ok(!result, "Expected no errors verifying context menu items");
+
+ // clean up downloads
+ await downloadList.removeFinished();
+}
+
+// ----------------------------------------------------------------------------
+// Helpers
+
+function verifyContextMenu(contextMenu, itemSelectors) {
+ // Ignore hidden nodes
+ let items = Array.from(contextMenu.children).filter(n =>
+ BrowserTestUtils.is_visible(n)
+ );
+ let menuAsText = items
+ .map(n => {
+ return n.nodeName == "menuseparator"
+ ? "---"
+ : `${n.label} (${n.command})`;
+ })
+ .join("\n");
+ info("Got actual context menu items: \n" + menuAsText);
+
+ try {
+ is(
+ items.length,
+ itemSelectors.length,
+ "Context menu has the expected number of items"
+ );
+ for (let i = 0; i < items.length; i++) {
+ let selector = itemSelectors[i];
+ ok(
+ items[i].matches(selector),
+ `Item at ${i} matches expected selector: ${selector}`
+ );
+ }
+ } catch (ex) {
+ return ex;
+ }
+ return null;
+}
+
+async function prepareDownloads(downloads, overrideExtension = null) {
+ for (let props of downloads) {
+ info(JSON.stringify(props));
+ if (props.state !== DownloadsCommon.DOWNLOAD_FINISHED) {
+ continue;
+ }
+ if (props.deleted) {
+ props.target = TestFiles.nonexistentFile;
+ continue;
+ }
+ switch (props.contentType) {
+ case "application/pdf":
+ props.target = TestFiles.pdf;
+ break;
+ case "text/plain":
+ props.target = TestFiles.txt;
+ break;
+ case "application/octet-stream":
+ props.target = TestFiles[overrideExtension];
+ break;
+ }
+ ok(props.target instanceof Ci.nsIFile, "download target is a nsIFile");
+ }
+ // If we'd just insert downloads as defined in the test case, they would
+ // appear reversed in the panel, because they will be in descending insertion
+ // order (newest at the top). The problem is we define an itemIndex based on
+ // the downloads array, and it would be weird to define it based on a
+ // reversed order. Short, we just reverse the array to preserve the order.
+ await task_addDownloads(downloads.reverse());
+}
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_ctrl_click.js b/browser/components/downloads/test/browser/browser_downloads_panel_ctrl_click.js
new file mode 100644
index 0000000000..57ef284bc1
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_ctrl_click.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_downloads_panel() {
+ // On macOS, ctrl-click shouldn't open the panel because this normally opens
+ // the context menu. This happens via the `contextmenu` event which is created
+ // by widget code, so our simulated clicks do not do so, so we can't test
+ // anything on macOS.
+ if (AppConstants.platform == "macosx") {
+ ok(true, "The test is ignored on Mac");
+ return;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ await promiseButtonShown("downloads-button");
+
+ const button = document.getElementById("downloads-button");
+ let shownPromise = promisePanelOpened();
+ // Should still open the panel when Ctrl key is pressed.
+ EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true });
+ await shownPromise;
+ is(DownloadsPanel.panel.state, "open", "Check that panel state is 'open'");
+
+ // Close download panel
+ DownloadsPanel.hidePanel();
+ is(
+ DownloadsPanel.panel.state,
+ "closed",
+ "Check that panel state is 'closed'"
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_disable_items.js b/browser/components/downloads/test/browser/browser_downloads_panel_disable_items.js
new file mode 100644
index 0000000000..d3b5b91b96
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_disable_items.js
@@ -0,0 +1,171 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_URI = "https://example.com";
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ TEST_URI
+);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.alwaysOpenPanel", true],
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["security.dialog_enable_delay", 1000],
+ ],
+ });
+ // Remove download files from previous tests
+ await task_resetState();
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ is(downloadCount, 0, "At the start of the test, there should be 0 downloads");
+});
+
+/**
+ * Tests that the download items remain enabled when we manually open
+ * the downloads panel by clicking the downloads button.
+ */
+add_task(async function test_downloads_panel_downloads_button() {
+ let panelOpenedPromise = promisePanelOpened();
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_FINISHED }]);
+ await panelOpenedPromise;
+
+ // The downloads panel will open automatically after task_addDownloads
+ // creates a download file. Let's close the panel and reopen it again
+ // (but this time manually) to ensure the download items are not disabled.
+ DownloadsPanel.hidePanel();
+
+ ok(!DownloadsPanel.isPanelShowing, "Downloads Panel should not be visible");
+
+ info("Manually open the download panel to view list of downloads");
+ let downloadsButton = document.getElementById("downloads-button");
+ EventUtils.synthesizeMouseAtCenter(downloadsButton, {});
+ let downloadsListBox = document.getElementById("downloadsListBox");
+
+ ok(downloadsListBox, "downloadsListBox richlistitem should be visible");
+ is(
+ downloadsListBox.childElementCount,
+ 1,
+ "downloadsListBox should have 1 download"
+ );
+ ok(
+ !downloadsListBox.getAttribute("disabled"),
+ "All download items in the downloads panel should not be disabled"
+ );
+
+ info("Cleaning up downloads");
+ await task_resetState();
+});
+
+/**
+ * Tests that the download items are disabled when the downloads panel is
+ * automatically opened as a result of a new download.
+ */
+add_task(async function test_downloads_panel_new_download() {
+ // Overwrite DownloadsCommon.openDownload to prevent file from opening during tests
+ const originalOpenDownload = DownloadsCommon.openDownload;
+ DownloadsCommon.openDownload = async () => {
+ ok(false, "openDownload was called when it was not expected");
+ };
+ let newTabPromise = BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "foo.txt",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ await promisePanelOpened();
+ let downloadsListBox = document.getElementById("downloadsListBox");
+
+ ok(downloadsListBox, "downloadsListBox richlistitem should be visible");
+ await BrowserTestUtils.waitForMutationCondition(
+ downloadsListBox,
+ { childList: true },
+ () => downloadsListBox.childElementCount == 1
+ );
+ info("downloadsListBox should have 1 download");
+ ok(
+ downloadsListBox.getAttribute("disabled"),
+ "All download items in the downloads panel should first be disabled"
+ );
+
+ let newTab = await newTabPromise;
+
+ // Press enter 6 times at 100ms intervals.
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ for (let i = 0; i < 5; i++) {
+ // There's no other way to allow some time to pass and ensure we're
+ // genuinely testing that these keypresses postpone the enabling of
+ // the items, so disable this check for this line:
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 100));
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ }
+ // Measure when we finished.
+ let keyTime = Date.now();
+
+ await BrowserTestUtils.waitForMutationCondition(
+ downloadsListBox,
+ { attributeFilter: ["disabled"] },
+ () => !downloadsListBox.hasAttribute("disabled")
+ );
+ Assert.greater(
+ Date.now(),
+ keyTime + 750,
+ "Should have waited at least another 750ms after this keypress."
+ );
+ let openedDownload = new Promise(resolve => {
+ DownloadsCommon.openDownload = async () => {
+ ok(true, "openDownload should have been called");
+ resolve();
+ };
+ });
+
+ info("All download items in the download panel should now be enabled");
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await openedDownload;
+
+ await task_resetState();
+ DownloadsCommon.openDownload = originalOpenDownload;
+ BrowserTestUtils.removeTab(newTab);
+});
+
+/**
+ * Tests that the disabled attribute does not exist when we close the
+ * downloads panel before the disabled state timeout resolves.
+ */
+add_task(async function test_downloads_panel_close_panel_early() {
+ info("Creating mock completed downloads");
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_FINISHED }]);
+
+ // The downloads panel may open automatically after task_addDownloads
+ // creates a download file. Let's close the panel and reopen it again
+ // (but this time manually).
+ DownloadsPanel.hidePanel();
+
+ ok(!DownloadsPanel.isPanelShowing, "Downloads Panel should not be visible");
+
+ info("Manually open the download panel to view list of downloads");
+ let downloadsButton = document.getElementById("downloads-button");
+ EventUtils.synthesizeMouseAtCenter(downloadsButton, {});
+ let downloadsListBox = document.getElementById("downloadsListBox");
+
+ ok(downloadsListBox, "downloadsListBox richlistitem should be visible");
+ is(
+ downloadsListBox.childElementCount,
+ 1,
+ "downloadsListBox should have 1 download"
+ );
+
+ DownloadsPanel.hidePanel();
+ await BrowserTestUtils.waitForCondition(
+ () => !downloadsListBox.getAttribute("disabled")
+ );
+ info("downloadsListBox 'disabled' attribute should not exist");
+
+ info("Cleaning up downloads");
+ await task_resetState();
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_dontshow.js b/browser/components/downloads/test/browser/browser_downloads_panel_dontshow.js
new file mode 100644
index 0000000000..28c7bc302f
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_dontshow.js
@@ -0,0 +1,126 @@
+// This test verifies that the download panel opens when a
+// download occurs but not when a user manually saves a page.
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+async function promiseDownloadFinished(list) {
+ return new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ download.launchWhenSucceeded = false;
+ if (download.succeeded || download.error) {
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+}
+
+function openTestPage() {
+ return BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ `https://www.example.com/document-builder.sjs?html=
+ <html><body>
+ <a id='normallink' href='https://www.example.com'>Link1</a>
+ <a id='downloadlink' href='https://www.example.com' download='file.txt'>Link2</a>
+ </body</html>
+ `
+ );
+}
+
+add_task(async function download_saveas_file() {
+ let tab = await openTestPage();
+
+ for (let testname of ["save link", "save page"]) {
+ if (testname == "save link") {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "#normallink",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShown;
+ }
+
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadFinishedPromise = promiseDownloadFinished(list);
+
+ let saveFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveFile.append("testsavedir");
+ if (!saveFile.exists()) {
+ saveFile.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+
+ await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ saveFile.append("sample");
+ MockFilePicker.setFiles([saveFile]);
+ setTimeout(() => {
+ resolve(fp.defaultString);
+ }, 0);
+ return Ci.nsIFilePicker.returnOK;
+ };
+
+ if (testname == "save link") {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let menuitem = document.getElementById("context-savelink");
+ menu.activateItem(menuitem);
+ } else if (testname == "save page") {
+ document.getElementById("Browser:SavePage").doCommand();
+ }
+ });
+
+ await downloadFinishedPromise;
+ is(
+ DownloadsPanel.panel.state,
+ "closed",
+ "downloads panel closed after download link after " + testname
+ );
+ }
+
+ await task_resetState();
+
+ MockFilePicker.cleanup();
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function download_link() {
+ let tab = await openTestPage();
+
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadFinishedPromise = promiseDownloadFinished(list);
+
+ let panelOpenedPromise = promisePanelOpened();
+
+ BrowserTestUtils.synthesizeMouse(
+ "#downloadlink",
+ 5,
+ 5,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ let download = await downloadFinishedPromise;
+ await panelOpenedPromise;
+
+ is(
+ DownloadsPanel.panel.state,
+ "open",
+ "downloads panel open after download link clicked"
+ );
+
+ DownloadsPanel.hidePanel();
+
+ await task_resetState();
+
+ BrowserTestUtils.removeTab(tab);
+
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {}
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_focus.js b/browser/components/downloads/test/browser/browser_downloads_panel_focus.js
new file mode 100644
index 0000000000..ecfae76b88
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_focus.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+
+ registerCleanupFunction(async () => {
+ info("Resetting downloads and closing downloads panel");
+ await task_resetState();
+ });
+
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ is(downloadCount, 0, "At the start of the test, there should be 0 downloads");
+});
+
+// Test that the top item in the panel always gets focus upon opening the panel.
+add_task(async function test_focus() {
+ info("creating a download and setting it to in progress");
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_DOWNLOADING }]);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ downloads[0].stopped = false;
+
+ info("waiting for the panel to open");
+ await task_openPanel();
+ await BrowserTestUtils.waitForCondition(
+ () => !DownloadsView.richListBox.getAttribute("disabled")
+ );
+
+ is(
+ DownloadsView.richListBox.itemCount,
+ 1,
+ "there should be exactly one download listed"
+ );
+ // Most of the time if we want to check which thing has focus, we can just ask
+ // Services.focus to tell us. But the downloads panel uses a <richlistbox>,
+ // and when an item in one of those has focus, the focus manager actually
+ // thinks that *the list itself* has focus, and everything below that is
+ // handled within the widget. So, the best we can do is check that the list is
+ // focused and then that the selected item within the list is correct.
+ is(
+ Services.focus.focusedElement,
+ DownloadsView.richListBox,
+ "the downloads list should have focus"
+ );
+ is(
+ DownloadsView.richListBox.itemChildren[0],
+ DownloadsView.richListBox.selectedItem,
+ "the focused item should be the only download in the list"
+ );
+
+ info("closing the panel and creating a second download");
+ DownloadsPanel.hidePanel();
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_DOWNLOADING }]);
+
+ info("waiting for the panel to open after starting the second download");
+ await task_openPanel();
+ await BrowserTestUtils.waitForCondition(
+ () => !DownloadsView.richListBox.getAttribute("disabled")
+ );
+
+ is(
+ DownloadsView.richListBox.itemCount,
+ 2,
+ "there should be two downloads listed"
+ );
+ is(
+ Services.focus.focusedElement,
+ DownloadsView.richListBox,
+ "the downloads list should have focus"
+ );
+ is(
+ DownloadsView.richListBox.itemChildren[0],
+ DownloadsView.richListBox.selectedItem,
+ "the focused item should be the first download in the list"
+ );
+
+ info("closing the panel and creating a third download");
+ DownloadsPanel.hidePanel();
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_DOWNLOADING }]);
+
+ info("waiting for the panel to open after starting the third download");
+ await task_openPanel();
+ await BrowserTestUtils.waitForCondition(
+ () => !DownloadsView.richListBox.getAttribute("disabled")
+ );
+
+ is(
+ DownloadsView.richListBox.itemCount,
+ 3,
+ "there should be three downloads listed"
+ );
+ is(
+ Services.focus.focusedElement,
+ DownloadsView.richListBox,
+ "the downloads list should have focus"
+ );
+ is(
+ DownloadsView.richListBox.itemChildren[0],
+ DownloadsView.richListBox.selectedItem,
+ "the focused item should be the first download in the list"
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_height.js b/browser/components/downloads/test/browser/browser_downloads_panel_height.js
new file mode 100644
index 0000000000..b154d20f84
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_height.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test exists because we use a <panelmultiview> element and it handles
+ * some of the height changes for us. We need to verify that the height is
+ * updated correctly if downloads are removed while the panel is hidden.
+ */
+add_task(async function test_height_reduced_after_removal() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ await promiseButtonShown("downloads-button");
+ // downloading two items since the download panel only shows up when at least one item is in it
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_FINISHED }]);
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_FINISHED }]);
+
+ await task_openPanel();
+ let panel = document.getElementById("downloadsPanel");
+ let heightBeforeRemoval = panel.getBoundingClientRect().height;
+
+ // We want to close the panel before we remove the download from the list.
+ DownloadsPanel.hidePanel();
+ await task_resetState();
+ // keep at least one item in the download list since the panel disabled when it is empty
+ await task_addDownloads([{ state: DownloadsCommon.DOWNLOAD_FINISHED }]);
+
+ await task_openPanel();
+ let heightAfterRemoval = panel.getBoundingClientRect().height;
+ Assert.greater(heightBeforeRemoval, heightAfterRemoval);
+
+ await task_resetState();
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_panel_opens.js b/browser/components/downloads/test/browser/browser_downloads_panel_opens.js
new file mode 100644
index 0000000000..499b5320da
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_panel_opens.js
@@ -0,0 +1,674 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let { MockFilePicker } = SpecialPowers;
+MockFilePicker.init(window);
+registerCleanupFunction(() => MockFilePicker.cleanup());
+
+/**
+ * Check that the downloads panel opens when a download is spoofed.
+ */
+async function checkPanelOpens() {
+ info("Waiting for panel to open.");
+ let promise = promisePanelOpened();
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+ is(
+ DownloadsPanel.isPanelShowing,
+ true,
+ "Panel state should indicate a preparation to be opened."
+ );
+ await promise;
+
+ is(DownloadsPanel.panel.state, "open", "Panel should be opened.");
+
+ DownloadsPanel.hidePanel();
+}
+
+/**
+ * Start a download and check that the downloads panel opens correctly according
+ * to the download parameter, openDownloadsListOnStart
+ * @param {boolean} [openDownloadsListOnStart]
+ * true (default) - open downloads panel when download starts
+ * false - no downloads panel; update indicator attention state
+ */
+async function downloadAndCheckPanel({ openDownloadsListOnStart = true } = {}) {
+ info("creating a download and setting it to in progress");
+ await task_addDownloads([
+ {
+ state: DownloadsCommon.DOWNLOAD_DOWNLOADING,
+ openDownloadsListOnStart,
+ },
+ ]);
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ downloads[0].stopped = false;
+
+ // Make sure we remove that download at the end of the test.
+ let oldShowEventNotification = DownloadsIndicatorView.showEventNotification;
+ registerCleanupFunction(async () => {
+ for (let download of downloads) {
+ await publicList.remove(download);
+ }
+ DownloadsIndicatorView.showEventNotification = oldShowEventNotification;
+ });
+
+ // Instead of the panel opening, the download notification should be shown.
+ let promiseDownloadStartedNotification = new Promise(resolve => {
+ DownloadsIndicatorView.showEventNotification = aType => {
+ if (aType == "start") {
+ resolve();
+ }
+ };
+ });
+
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start", {
+ openDownloadsListOnStart,
+ });
+ is(
+ DownloadsPanel.isPanelShowing,
+ false,
+ "Panel state should indicate it is not preparing to be opened"
+ );
+
+ info("waiting for download to start");
+ await promiseDownloadStartedNotification;
+
+ DownloadsIndicatorView.showEventNotification = oldShowEventNotification;
+ is(DownloadsPanel.panel.state, "closed", "Panel should be closed");
+}
+
+function clickCheckbox(checkbox) {
+ // Clicking a checkbox toggles its checkedness first.
+ if (checkbox.getAttribute("checked") == "true") {
+ checkbox.removeAttribute("checked");
+ } else {
+ checkbox.setAttribute("checked", "true");
+ }
+ // Then it runs the command and closes the popup.
+ checkbox.doCommand();
+ checkbox.parentElement.hidePopup();
+}
+
+/**
+ * Test that the downloads panel correctly opens or doesn't open based on
+ * whether the download triggered a dialog already. If askWhereToSave is true,
+ * we should get a file picker dialog. If preferredAction is alwaysAsk, we
+ * should get an unknown content type dialog. If neither of those is true, we
+ * should get no dialog at all, and expect the downloads panel to open.
+ * @param {boolean} [expectPanelToOpen] true - fail if panel doesn't open
+ * false (default) - fail if it opens
+ * @param {number} [preferredAction] Default download action:
+ * 0 (default) - save download to disk
+ * 1 - open UCT dialog first
+ * @param {boolean} [askWhereToSave] true - open file picker dialog
+ * false (default) - use download dir
+ */
+async function testDownloadsPanelAfterDialog({
+ expectPanelToOpen = false,
+ preferredAction,
+ askWhereToSave = false,
+} = {}) {
+ const { saveToDisk, alwaysAsk } = Ci.nsIHandlerInfo;
+ if (![saveToDisk, alwaysAsk].includes(preferredAction)) {
+ preferredAction = saveToDisk;
+ }
+ const openUCT = preferredAction === alwaysAsk;
+ const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ );
+ const MimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ const HandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+
+ for (let download of await publicList.getAll()) {
+ await publicList.remove(download);
+ }
+
+ // We need to test the changes from bug 1739348, where the helper app service
+ // sets a flag based on whether a file picker dialog was opened, and this flag
+ // determines whether the downloads panel will be opened as the download
+ // starts. We need to actually hit "Save" for the download to start, but we
+ // can't interact with the real file picker dialog. So this temporarily
+ // replaces it with a barebones component that plugs into the helper app
+ // service and tells it to start saving the file to the default path.
+ if (askWhereToSave) {
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+ MockFilePicker.showCallback = function (fp) {
+ // Get the default location from the helper app service.
+ let testFile = MockFilePicker.displayDirectory.clone();
+ testFile.append(fp.defaultString);
+ info("File picker download path: " + testFile.path);
+ MockFilePicker.setFiles([testFile]);
+ MockFilePicker.filterIndex = 0; // kSaveAsType_Complete
+ MockFilePicker.showCallback = null;
+ // Confirm that saving should proceed. The helper app service uses this
+ // value to determine whether to invoke launcher.saveDestinationAvailable
+ return MockFilePicker.returnOK;
+ };
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.useDownloadDir", !askWhereToSave],
+ ["browser.download.always_ask_before_handling_new_types", openUCT],
+ ["security.dialog_enable_delay", 0],
+ ],
+ });
+
+ // Configure the handler for the file according to parameters.
+ let mimeInfo = MimeSvc.getFromTypeAndExtension("text/plain", "txt");
+ let existed = HandlerSvc.exists(mimeInfo);
+ mimeInfo.alwaysAskBeforeHandling = openUCT;
+ mimeInfo.preferredAction = preferredAction;
+ HandlerSvc.store(mimeInfo);
+ registerCleanupFunction(async () => {
+ // Reset the handler to its original state.
+ if (existed) {
+ HandlerSvc.store(mimeInfo);
+ } else {
+ HandlerSvc.remove(mimeInfo);
+ }
+ await publicList.removeFinished();
+ BrowserTestUtils.removeTab(loadingTab);
+ });
+
+ let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let downloadFinishedPromise = new Promise(resolve => {
+ publicList.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or failed.");
+ publicList.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+ let panelOpenedPromise = expectPanelToOpen ? promisePanelOpened() : null;
+
+ // Open the tab that will trigger the download.
+ let loadingTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "foo.txt",
+ waitForLoad: false,
+ waitForStateStop: true,
+ });
+
+ // Wait for a UCT dialog if the handler was set up to open one.
+ if (openUCT) {
+ let dialogWindow = await dialogWindowPromise;
+ is(
+ dialogWindow.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialogWindow."
+ );
+ let doc = dialogWindow.document;
+ let dialog = doc.getElementById("unknownContentType");
+ let radio = doc.getElementById("save");
+ let button = dialog.getButton("accept");
+
+ await TestUtils.waitForCondition(
+ () => !button.disabled,
+ "Waiting for the UCT dialog's Accept button to be enabled."
+ );
+ ok(!radio.hidden, "The Save option should be visible");
+ // Make sure we aren't opening the file.
+ radio.click();
+ ok(radio.selected, "The Save option should be selected");
+ button.disabled = false;
+ dialog.acceptDialog();
+ }
+
+ info("Waiting for download to finish.");
+ let download = await downloadFinishedPromise;
+ ok(!download.error, "There should be no error.");
+ is(
+ DownloadsPanel.isPanelShowing,
+ expectPanelToOpen,
+ `Panel should${expectPanelToOpen ? " " : " not "}be showing.`
+ );
+ if (DownloadsPanel.isPanelShowing) {
+ await panelOpenedPromise;
+ let hiddenPromise = BrowserTestUtils.waitForPopupEvent(
+ DownloadsPanel.panel,
+ "hidden"
+ );
+ DownloadsPanel.hidePanel();
+ await hiddenPromise;
+ }
+ if (download?.target.exists) {
+ try {
+ info("Removing test file: " + download.target.path);
+ if (Services.appinfo.OS === "WINNT") {
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ /* ignore */
+ }
+ }
+ for (let dl of await publicList.getAll()) {
+ await publicList.remove(dl);
+ }
+ BrowserTestUtils.removeTab(loadingTab);
+}
+
+/**
+ * Make sure the downloads panel opens automatically with a new download.
+ */
+add_task(async function test_downloads_panel_opens() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.alwaysOpenPanel", true],
+ ],
+ });
+ await checkPanelOpens();
+});
+
+add_task(async function test_customizemode_doesnt_wreck_things() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.alwaysOpenPanel", true],
+ ],
+ });
+
+ // Enter customize mode:
+ let customizationReadyPromise = BrowserTestUtils.waitForEvent(
+ gNavToolbox,
+ "customizationready"
+ );
+ gCustomizeMode.enter();
+ await customizationReadyPromise;
+
+ info("Try to open the panel (will not work, in customize mode)");
+ let promise = promisePanelOpened();
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+ await TestUtils.waitForCondition(
+ () => DownloadsPanel._state == DownloadsPanel.kStateHidden,
+ "Should try to show but stop short and hide the panel"
+ );
+ is(
+ DownloadsPanel._state,
+ DownloadsPanel.kStateHidden,
+ "Should not start opening the panel."
+ );
+
+ let afterCustomizationPromise = BrowserTestUtils.waitForEvent(
+ gNavToolbox,
+ "aftercustomization"
+ );
+ gCustomizeMode.exit();
+ await afterCustomizationPromise;
+
+ // Avoid a failure on Linux where the window isn't active for some reason,
+ // which prevents the window's downloads panel from opening.
+ if (Services.focus.activeWindow != window) {
+ info("Main window is not active, trying to focus.");
+ await SimpleTest.promiseFocus(window);
+ is(Services.focus.activeWindow, window, "Main window should be active.");
+ }
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+ await TestUtils.waitForCondition(
+ () => DownloadsPanel.isPanelShowing,
+ "Panel state should indicate a preparation to be opened"
+ );
+ await promise;
+
+ is(DownloadsPanel.panel.state, "open", "Panel should be opened");
+
+ DownloadsPanel.hidePanel();
+});
+
+/**
+ * Make sure the downloads panel _does not_ open automatically if we set the
+ * pref telling it not to do that.
+ */
+add_task(async function test_downloads_panel_opening_pref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.alwaysOpenPanel", false],
+ ],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+ await downloadAndCheckPanel();
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Make sure the downloads panel _does not_ open automatically if we pass the
+ * parameter telling it not to do that to the download constructor.
+ */
+add_task(async function test_downloads_openDownloadsListOnStart_param() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.alwaysOpenPanel", true],
+ ],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+ await downloadAndCheckPanel({ openDownloadsListOnStart: false });
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Make sure the downloads panel _does not_ open automatically when an
+ * extension calls the browser.downloads.download API method while it is
+ * not handling user input, but that we do open it automatically when
+ * the same WebExtensions API is called while handling user input
+ * (See Bug 1759231)
+ */
+add_task(async function test_downloads_panel_on_webext_download_api() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.alwaysOpenPanel", true],
+ ],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background() {
+ async function startDownload(downloadOptions) {
+ /* globals browser */
+ const downloadId = await browser.downloads.download(downloadOptions);
+ const downloadDone = new Promise(resolve => {
+ browser.downloads.onChanged.addListener(function listener(delta) {
+ browser.test.log(`downloads.onChanged = ${JSON.stringify(delta)}`);
+ if (
+ delta.id == downloadId &&
+ delta.state?.current !== "in_progress"
+ ) {
+ browser.downloads.onChanged.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ browser.test.sendMessage("start-download:done");
+ await downloadDone;
+ await browser.downloads.removeFile(downloadId);
+ browser.test.sendMessage("removed-download-file");
+ }
+
+ browser.test.onMessage.addListener(
+ (msg, { withHandlingUserInput, downloadOptions }) => {
+ if (msg !== "start-download") {
+ browser.test.fail(`Got unexpected test message: ${msg}`);
+ return;
+ }
+
+ if (withHandlingUserInput) {
+ browser.test.withHandlingUserInput(() =>
+ startDownload(downloadOptions)
+ );
+ } else {
+ startDownload(downloadOptions);
+ }
+ }
+ );
+ },
+ });
+
+ await extension.startup();
+
+ startServer();
+
+ async function testExtensionDownloadCall({ withHandlingUserInput }) {
+ mustInterruptResponses();
+ let rnd = Math.random();
+ let url = httpUrl(`interruptible.txt?q=${rnd}`);
+
+ extension.sendMessage("start-download", {
+ withHandlingUserInput,
+ downloadOptions: { url },
+ });
+ await extension.awaitMessage("start-download:done");
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+
+ let download = downloads.find(d => d.source.url === url);
+ is(download.source.url, url, "download has the expected url");
+ is(
+ download.openDownloadsListOnStart,
+ withHandlingUserInput,
+ `download panel should ${withHandlingUserInput ? "open" : "stay closed"}`
+ );
+
+ continueResponses();
+ await extension.awaitMessage("removed-download-file");
+ }
+
+ info(
+ "Test extension downloads.download API method call without handling user input"
+ );
+ await testExtensionDownloadCall({ withHandlingUserInput: true });
+
+ info(
+ "Test extension downloads.download API method call while handling user input"
+ );
+ await testExtensionDownloadCall({ withHandlingUserInput: false });
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Make sure the downloads panel opens automatically with new download, only if
+ * no other downloads are in progress.
+ */
+add_task(async function test_downloads_panel_remains_closed() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+ await task_addDownloads([
+ { state: DownloadsCommon.DOWNLOAD_DOWNLOADING },
+ { state: DownloadsCommon.DOWNLOAD_DOWNLOADING },
+ ]);
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+
+ info("setting 2 downloads to be in progress");
+ downloads[0].stopped = false;
+ downloads[1].stopped = false;
+
+ let oldShowEventNotification = DownloadsIndicatorView.showEventNotification;
+
+ registerCleanupFunction(async () => {
+ // Remove all downloads created during the test.
+ for (let download of downloads) {
+ await publicList.remove(download);
+ }
+ DownloadsIndicatorView.showEventNotification = oldShowEventNotification;
+ });
+
+ let promiseDownloadStartedNotification = new Promise(resolve => {
+ // Instead of downloads panel opening, download notification should be shown.
+ DownloadsIndicatorView.showEventNotification = aType => {
+ if (aType == "start") {
+ DownloadsIndicatorView.showEventNotification = oldShowEventNotification;
+ resolve();
+ }
+ };
+ });
+
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+
+ is(
+ DownloadsPanel.isPanelShowing,
+ false,
+ "Panel state should NOT indicate a preparation to be opened"
+ );
+
+ await promiseDownloadStartedNotification;
+
+ is(DownloadsPanel.panel.state, "closed", "Panel should be closed");
+
+ for (let download of downloads) {
+ await publicList.remove(download);
+ }
+ is((await publicList.getAll()).length, 0, "Should have no downloads left.");
+});
+
+/**
+ * Make sure the downloads panel doesn't open if the window isn't in the
+ * foreground.
+ */
+add_task(async function test_downloads_panel_inactive_window() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+
+ let oldShowEventNotification = DownloadsIndicatorView.showEventNotification;
+
+ registerCleanupFunction(async () => {
+ DownloadsIndicatorView.showEventNotification = oldShowEventNotification;
+ });
+
+ let promiseDownloadStartedNotification = new Promise(resolve => {
+ // Instead of downloads panel opening, download notification should be shown.
+ DownloadsIndicatorView.showEventNotification = aType => {
+ if (aType == "start") {
+ DownloadsIndicatorView.showEventNotification = oldShowEventNotification;
+ resolve();
+ }
+ };
+ });
+
+ let testRunnerWindow = Array.from(Services.wm.getEnumerator("")).find(
+ someWin => someWin != window
+ );
+
+ await SimpleTest.promiseFocus(testRunnerWindow);
+
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+
+ is(
+ DownloadsPanel.isPanelShowing,
+ false,
+ "Panel state should NOT indicate a preparation to be opened"
+ );
+
+ await promiseDownloadStartedNotification;
+ await SimpleTest.promiseFocus(window);
+
+ is(DownloadsPanel.panel.state, "closed", "Panel should be closed");
+
+ testRunnerWindow = null;
+});
+
+/**
+ * When right-clicking the downloads toolbar button, there should be a menuitem
+ * for toggling alwaysOpenPanel. Check that it works correctly.
+ */
+add_task(async function test_alwaysOpenPanel_menuitem() {
+ const alwaysOpenPanelPref = "browser.download.alwaysOpenPanel";
+ let checkbox = document.getElementById(
+ "toolbar-context-always-open-downloads-panel"
+ );
+ let button = document.getElementById("downloads-button");
+
+ Services.prefs.clearUserPref(alwaysOpenPanelPref);
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ Services.prefs.clearUserPref(alwaysOpenPanelPref);
+ });
+
+ is(button.hidden, false, "Downloads button should not be hidden.");
+
+ info("Check context menu for downloads button.");
+ await openContextMenu(button);
+ is(checkbox.hidden, false, "Always Open checkbox is visible.");
+ is(checkbox.getAttribute("checked"), "true", "Always Open is enabled.");
+
+ info("Disable Always Open via context menu.");
+ clickCheckbox(checkbox);
+ is(
+ Services.prefs.getBoolPref(alwaysOpenPanelPref),
+ false,
+ "Always Open pref has been set to false."
+ );
+
+ await downloadAndCheckPanel();
+
+ await openContextMenu(button);
+ is(checkbox.hidden, false, "Always Open checkbox is visible.");
+ isnot(checkbox.getAttribute("checked"), "true", "Always Open is disabled.");
+
+ info("Enable Always Open via context menu");
+ clickCheckbox(checkbox);
+ is(
+ Services.prefs.getBoolPref(alwaysOpenPanelPref),
+ true,
+ "Pref has been set to true"
+ );
+
+ await checkPanelOpens();
+});
+
+/**
+ * Verify that the downloads panel opens if the download did not open a file
+ * picker or UCT dialog
+ */
+add_task(async function test_downloads_panel_after_no_dialogs() {
+ await testDownloadsPanelAfterDialog({ expectPanelToOpen: true });
+ ok(true, "Downloads panel opened because no dialogs were opened.");
+});
+
+/**
+ * Verify that the downloads panel doesn't open if the download opened an
+ * unknown content type dialog (e.g. action = always ask)
+ */
+add_task(async function test_downloads_panel_after_UCT_dialog() {
+ await testDownloadsPanelAfterDialog({
+ expectPanelToOpen: false,
+ preferredAction: Ci.nsIHandlerInfo.alwaysAsk,
+ });
+ ok(true, "Downloads panel suppressed after UCT dialog.");
+});
+
+/**
+ * Verify that the downloads panel doesn't open if the download opened a file
+ * picker dialog (e.g. useDownloadDir = false)
+ */
+add_task(async function test_downloads_panel_after_file_picker_dialog() {
+ await testDownloadsPanelAfterDialog({
+ expectPanelToOpen: false,
+ preferredAction: Ci.nsIHandlerInfo.saveToDisk,
+ askWhereToSave: true,
+ });
+ ok(true, "Downloads panel suppressed after file picker dialog.");
+});
+
+/**
+ * Verify that the downloads panel doesn't open if the download opened both
+ * dialogs (e.g. default action = always ask AND useDownloadDir = false)
+ */
+add_task(async function test_downloads_panel_after_both_dialogs() {
+ await testDownloadsPanelAfterDialog({
+ expectPanelToOpen: false,
+ preferredAction: Ci.nsIHandlerInfo.alwaysAsk,
+ askWhereToSave: true,
+ });
+ ok(true, "Downloads panel suppressed after UCT and file picker dialogs.");
+});
diff --git a/browser/components/downloads/test/browser/browser_downloads_pauseResume.js b/browser/components/downloads/test/browser/browser_downloads_pauseResume.js
new file mode 100644
index 0000000000..60a4a8a371
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_downloads_pauseResume.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+});
+
+add_task(async function test_downloads_library() {
+ let DownloadData = [];
+ for (let i = 0; i < 20; i++) {
+ DownloadData.push({ state: DownloadsCommon.DOWNLOAD_PAUSED });
+ }
+
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ // Populate the downloads database with the data required by this test.
+ await task_addDownloads(DownloadData);
+
+ let win = await openLibrary("Downloads");
+ registerCleanupFunction(function () {
+ win.close();
+ });
+
+ let listbox = win.document.getElementById("downloadsListBox");
+ ok(listbox, "Download list box present");
+
+ // Select one of the downloads.
+ listbox.itemChildren[0].click();
+ listbox.itemChildren[0]._shell._download.hasPartialData = true;
+
+ EventUtils.synthesizeKey(" ", {}, win);
+ is(
+ listbox.itemChildren[0]._shell._downloadState,
+ DownloadsCommon.DOWNLOAD_DOWNLOADING,
+ "Download state toggled from paused to downloading"
+ );
+
+ // there is no event to wait for in some cases, we need to wait for the keypress to potentially propagate
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+ is(
+ listbox.scrollTop,
+ 0,
+ "All downloads view did not scroll when spacebar event fired on a selected download"
+ );
+});
diff --git a/browser/components/downloads/test/browser/browser_first_download_panel.js b/browser/components/downloads/test/browser/browser_first_download_panel.js
new file mode 100644
index 0000000000..1beb33402a
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_first_download_panel.js
@@ -0,0 +1,68 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+/**
+ * Make sure the downloads panel only opens automatically on the first
+ * download it notices. All subsequent downloads, even across sessions, should
+ * not open the panel automatically.
+ */
+add_task(async function test_first_download_panel() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ await promiseButtonShown("downloads-button");
+ // Clear the download panel has shown preference first as this test is used to
+ // verify this preference's behaviour.
+ let oldPrefValue = Services.prefs.getBoolPref("browser.download.panel.shown");
+ Services.prefs.setBoolPref("browser.download.panel.shown", false);
+
+ registerCleanupFunction(async function () {
+ // Clean up when the test finishes.
+ await task_resetState();
+
+ // Set the preference instead of clearing it afterwards to ensure the
+ // right value is used no matter what the default was. This ensures the
+ // panel doesn't appear and affect other tests.
+ Services.prefs.setBoolPref("browser.download.panel.shown", oldPrefValue);
+ });
+
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ // With this set to false, we should automatically open the panel the first
+ // time a download is started.
+ DownloadsCommon.getData(window).panelHasShownBefore = false;
+
+ info("waiting for panel open");
+ let promise = promisePanelOpened();
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+ await promise;
+
+ // If we got here, that means the panel opened.
+ DownloadsPanel.hidePanel();
+
+ ok(
+ DownloadsCommon.getData(window).panelHasShownBefore,
+ "Should have recorded that the panel was opened on a download."
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.alwaysOpenPanel", false]],
+ });
+ // Next, make sure that if we start another download, we don't open the
+ // panel automatically.
+ let originalOnPopupShown = DownloadsPanel.onPopupShown;
+ DownloadsPanel.onPopupShown = function () {
+ originalOnPopupShown.apply(this, arguments);
+ ok(false, "Should not have opened the downloads panel.");
+ };
+
+ DownloadsCommon.getData(window)._notifyDownloadEvent("start");
+
+ // Wait 2 seconds to ensure that the panel does not open.
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ DownloadsPanel.onPopupShown = originalOnPopupShown;
+});
diff --git a/browser/components/downloads/test/browser/browser_go_to_download_page.js b/browser/components/downloads/test/browser/browser_go_to_download_page.js
new file mode 100644
index 0000000000..938d54ccb2
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_go_to_download_page.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+);
+
+const TEST_REFERRER = "https://example.com/";
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+});
+
+async function addDownload(referrerInfo) {
+ let startTimeMs = Date.now();
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadData = {
+ source: {
+ url: "http://www.example.com/test-download.txt",
+ referrerInfo,
+ },
+ target: {
+ path: gTestTargetFile.path,
+ },
+ startTime: new Date(startTimeMs++),
+ };
+ let download = await Downloads.createDownload(downloadData);
+ await publicList.add(download);
+ await download.start();
+}
+
+/**
+ * Make sure "Go To Download Page" is enabled and works as expected.
+ */
+add_task(async function test_go_to_download_page() {
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.NO_REFERRER,
+ true,
+ NetUtil.newURI(TEST_REFERRER)
+ );
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, TEST_REFERRER);
+
+ // Wait for focus first
+ await promiseFocus();
+
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ // Populate the downloads database with the data required by this test.
+ await addDownload(referrerInfo);
+
+ // Open the user interface and wait for data to be fully loaded.
+ await task_openPanel();
+
+ let win = await openLibrary("Downloads");
+ registerCleanupFunction(function () {
+ win.close();
+ });
+
+ let listbox = win.document.getElementById("downloadsListBox");
+ ok(listbox, "download list box present");
+
+ // Select one of the downloads.
+ listbox.itemChildren[0].click();
+
+ let contextMenu = win.document.getElementById("downloadsContextMenu");
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ listbox.itemChildren[0],
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await popupShownPromise;
+
+ // Find and click "Go To Download Page"
+ let goToDownloadButton = [...contextMenu.children].find(
+ child => child.command == "downloadsCmd_openReferrer"
+ );
+ contextMenu.activateItem(goToDownloadButton);
+
+ let newTab = await tabPromise;
+ ok(newTab, "Go To Download Page opened a new tab");
+ gBrowser.removeTab(newTab);
+});
diff --git a/browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js b/browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js
new file mode 100644
index 0000000000..a1b82fb9c2
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js
@@ -0,0 +1,72 @@
+const SAVE_PER_SITE_PREF = "browser.download.lastDir.savePerSite";
+
+function test_deleted_iframe(perSitePref, windowOptions = {}) {
+ return async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SAVE_PER_SITE_PREF, perSitePref]],
+ });
+ let { DownloadLastDir } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadLastDir.sys.mjs"
+ );
+
+ let win = await BrowserTestUtils.openNewBrowserWindow(windowOptions);
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "about:mozilla"
+ );
+
+ let doc = tab.linkedBrowser.contentDocument;
+ let iframe = doc.createElement("iframe");
+ doc.body.appendChild(iframe);
+
+ ok(iframe.contentWindow, "iframe should have a window");
+ let gDownloadLastDir = new DownloadLastDir(iframe.contentWindow);
+ let cw = iframe.contentWindow;
+ let promiseIframeWindowGone = new Promise((resolve, reject) => {
+ Services.obs.addObserver(function obs(subject, topic) {
+ if (subject == cw) {
+ Services.obs.removeObserver(obs, topic);
+ resolve();
+ }
+ }, "dom-window-destroyed");
+ });
+ iframe.remove();
+ await promiseIframeWindowGone;
+ cw = null;
+ ok(!iframe.contentWindow, "Managed to destroy iframe");
+
+ let someDir = "blah";
+ try {
+ someDir = await gDownloadLastDir.getFileAsync("http://www.mozilla.org/");
+ } catch (ex) {
+ ok(
+ false,
+ "Got an exception trying to get the directory where things should be saved."
+ );
+ console.error(ex);
+ }
+ // NB: someDir can legitimately be null here when set, hence the 'blah' workaround:
+ isnot(
+ someDir,
+ "blah",
+ "Should get a file even after the window was destroyed."
+ );
+
+ try {
+ gDownloadLastDir.setFile("http://www.mozilla.org/", null);
+ } catch (ex) {
+ ok(
+ false,
+ "Got an exception trying to set the directory where things should be saved."
+ );
+ console.error(ex);
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+ };
+}
+
+add_task(test_deleted_iframe(false));
+add_task(test_deleted_iframe(false));
+add_task(test_deleted_iframe(true, { private: true }));
+add_task(test_deleted_iframe(true, { private: true }));
diff --git a/browser/components/downloads/test/browser/browser_image_mimetype_issues.js b/browser/components/downloads/test/browser/browser_image_mimetype_issues.js
new file mode 100644
index 0000000000..b893a26d89
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_image_mimetype_issues.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+/*
+ * Popular websites implement image optimization as serving files with
+ * extension ".jpg" but content type "image/webp". If we save such images,
+ * we should actually save them with a .webp extension as that is what
+ * they are.
+ */
+
+/**
+ * Test the above with the "save image as" context menu.
+ */
+add_task(async function test_save_image_webp_with_jpeg_extension() {
+ await BrowserTestUtils.withNewTab(
+ `data:text/html,<img src="${TEST_ROOT}/not-really-a-jpeg.jpeg?convert=webp">`,
+ async browser => {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "img",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShown;
+
+ await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ ok(
+ fp.defaultString.endsWith("webp"),
+ `filepicker for image has "${fp.defaultString}", should end in webp`
+ );
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ let menuitem = menu.querySelector("#context-saveimage");
+ menu.activateItem(menuitem);
+ });
+ }
+ );
+});
+
+/**
+ * Test with the "save link as" context menu.
+ */
+add_task(async function test_save_link_webp_with_jpeg_extension() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", false],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ `data:text/html,<a href="${TEST_ROOT}/not-really-a-jpeg.jpeg?convert=webp">Nice image</a>`,
+ async browser => {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouse(
+ "a[href]",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShown;
+
+ await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ ok(
+ fp.defaultString.endsWith("webp"),
+ `filepicker for link has "${fp.defaultString}", should end in webp`
+ );
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ let menuitem = menu.querySelector("#context-savelink");
+ menu.activateItem(menuitem);
+ });
+ }
+ );
+});
+
+/**
+ * Test with the main "save page" command.
+ */
+add_task(async function test_save_page_on_image_document() {
+ await BrowserTestUtils.withNewTab(
+ `${TEST_ROOT}/not-really-a-jpeg.jpeg?convert=webp`,
+ async browser => {
+ await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ ok(
+ fp.defaultString.endsWith("webp"),
+ `filepicker for "save page" has "${fp.defaultString}", should end in webp`
+ );
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ document.getElementById("Browser:SavePage").doCommand();
+ });
+ }
+ );
+});
+
+/**
+ * Make sure that a valid JPEG image using the .JPG extension doesn't
+ * get it replaced with .jpeg.
+ */
+add_task(async function test_save_page_on_JPEG_image_document() {
+ await BrowserTestUtils.withNewTab(`${TEST_ROOT}/blank.JPG`, async browser => {
+ await new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ ok(
+ fp.defaultString.endsWith("JPG"),
+ `filepicker for "save page" has "${fp.defaultString}", should end in JPG`
+ );
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ document.getElementById("Browser:SavePage").doCommand();
+ });
+ });
+});
diff --git a/browser/components/downloads/test/browser/browser_indicatorDrop.js b/browser/components/downloads/test/browser/browser_indicatorDrop.js
new file mode 100644
index 0000000000..7957b96c43
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_indicatorDrop.js
@@ -0,0 +1,38 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_indicatorDrop() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ let downloadButton = document.getElementById("downloads-button");
+ ok(downloadButton, "download button present");
+ await promiseButtonShown(downloadButton.id);
+
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ await setDownloadDir();
+
+ startServer();
+
+ await simulateDropAndCheck(window, downloadButton, [httpUrl("file1.txt")]);
+ await simulateDropAndCheck(window, downloadButton, [
+ httpUrl("file1.txt"),
+ httpUrl("file2.txt"),
+ httpUrl("file3.txt"),
+ ]);
+});
diff --git a/browser/components/downloads/test/browser/browser_libraryDrop.js b/browser/components/downloads/test/browser/browser_libraryDrop.js
new file mode 100644
index 0000000000..bac8dfeffb
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_libraryDrop.js
@@ -0,0 +1,39 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+});
+
+add_task(async function test_indicatorDrop() {
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ await setDownloadDir();
+
+ startServer();
+
+ let win = await openLibrary("Downloads");
+ registerCleanupFunction(function () {
+ win.close();
+ });
+
+ let listBox = win.document.getElementById("downloadsListBox");
+ ok(listBox, "download list box present");
+
+ await simulateDropAndCheck(win, listBox, [httpUrl("file1.txt")]);
+ await simulateDropAndCheck(win, listBox, [
+ httpUrl("file1.txt"),
+ httpUrl("file2.txt"),
+ httpUrl("file3.txt"),
+ ]);
+});
diff --git a/browser/components/downloads/test/browser/browser_library_clearall.js b/browser/components/downloads/test/browser/browser_library_clearall.js
new file mode 100644
index 0000000000..022d1b6977
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_library_clearall.js
@@ -0,0 +1,122 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+});
+
+let win;
+
+function waitForChildren(element, callback) {
+ let MutationObserver = element.ownerGlobal.MutationObserver;
+ return new Promise(resolve => {
+ let observer = new MutationObserver(() => {
+ if (callback()) {
+ observer.disconnect();
+ resolve();
+ }
+ });
+ observer.observe(element, { childList: true });
+ });
+}
+
+async function waitForChildrenLength(element, length, callback) {
+ if (element.childElementCount != length) {
+ await waitForChildren(element, () => element.childElementCount == length);
+ }
+}
+
+registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+});
+
+async function testClearingDownloads(clearCallback) {
+ const DOWNLOAD_DATA = [
+ httpUrl("file1.txt"),
+ httpUrl("file2.txt"),
+ httpUrl("file3.txt"),
+ ];
+
+ let listbox = win.document.getElementById("downloadsListBox");
+ ok(listbox, "download list box present");
+
+ let promiseLength = waitForChildrenLength(listbox, DOWNLOAD_DATA.length);
+ await simulateDropAndCheck(win, listbox, DOWNLOAD_DATA);
+ await promiseLength;
+
+ let receivedNotifications = [];
+ const promiseNotification = PlacesTestUtils.waitForNotification(
+ "page-removed",
+ events => {
+ for (const { url, isRemovedFromStore } of events) {
+ Assert.ok(isRemovedFromStore);
+
+ if (DOWNLOAD_DATA.includes(url)) {
+ receivedNotifications.push(url);
+ }
+ }
+ return receivedNotifications.length == DOWNLOAD_DATA.length;
+ }
+ );
+
+ promiseLength = waitForChildrenLength(listbox, 0);
+ await clearCallback(listbox);
+ await promiseLength;
+
+ await promiseNotification;
+
+ Assert.deepEqual(
+ receivedNotifications.sort(),
+ DOWNLOAD_DATA.sort(),
+ "Should have received notifications for each URL"
+ );
+}
+
+add_setup(async function () {
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ await setDownloadDir();
+
+ startServer();
+
+ win = await openLibrary("Downloads");
+ registerCleanupFunction(function () {
+ win.close();
+ });
+});
+
+add_task(async function test_clear_downloads_toolbar() {
+ await testClearingDownloads(async () => {
+ win.document.getElementById("clearDownloadsButton").click();
+ });
+});
+
+add_task(async function test_clear_downloads_context_menu() {
+ await testClearingDownloads(async listbox => {
+ // Select one of the downloads.
+ listbox.itemChildren[0].click();
+
+ let contextMenu = win.document.getElementById("downloadsContextMenu");
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ listbox.itemChildren[0],
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await popupShownPromise;
+
+ // Find the clear context item.
+ let clearDownloadsButton = [...contextMenu.children].find(
+ child => child.command == "downloadsCmd_clearDownloads"
+ );
+ contextMenu.activateItem(clearDownloadsButton);
+ });
+});
diff --git a/browser/components/downloads/test/browser/browser_library_select_all.js b/browser/components/downloads/test/browser/browser_library_select_all.js
new file mode 100644
index 0000000000..3d2187b312
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_library_select_all.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let gDownloadDir;
+
+add_setup(async function () {
+ await task_resetState();
+
+ if (!gDownloadDir) {
+ gDownloadDir = await setDownloadDir();
+ }
+
+ await task_addDownloads([
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ target: await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded_one.txt"),
+ "Test file 1"
+ ),
+ },
+ {
+ state: DownloadsCommon.DOWNLOAD_FINISHED,
+ target: await createDownloadedFile(
+ PathUtils.join(gDownloadDir, "downloaded_two.txt"),
+ "Test file 2"
+ ),
+ },
+ ]);
+ registerCleanupFunction(async function () {
+ await task_resetState();
+ await PlacesUtils.history.clear();
+ });
+});
+
+add_task(async function test_select_all() {
+ let win = await openLibrary("Downloads");
+ registerCleanupFunction(() => {
+ win.close();
+ });
+
+ let listbox = win.document.getElementById("downloadsListBox");
+ Assert.ok(listbox, "download list box present");
+ listbox.focus();
+ await TestUtils.waitForCondition(
+ () => listbox.children.length == 2 && listbox.selectedItems.length == 1,
+ "waiting for both items to be present with one selected"
+ );
+ info("Select all the downloads");
+ win.goDoCommand("cmd_selectAll");
+ Assert.equal(
+ listbox.selectedItems.length,
+ listbox.children.length,
+ "All the items should be selected"
+ );
+
+ info("Search for a specific download");
+ let searchBox = win.document.getElementById("searchFilter");
+ searchBox.value = "_one";
+ win.PlacesSearchBox.search(searchBox.value);
+ await TestUtils.waitForCondition(() => {
+ let visibleItems = Array.from(listbox.children).filter(c => !c.hidden);
+ return (
+ visibleItems.length == 1 &&
+ visibleItems[0]._shell.download.target.path.includes("_one")
+ );
+ }, "Waiting for the search to complete");
+ Assert.equal(
+ listbox.selectedItems.length,
+ 0,
+ "Check previous selection has been cleared by the search"
+ );
+ info("Select all the downloads");
+ win.goDoCommand("cmd_selectAll");
+ Assert.equal(listbox.children.length, 2, "Both items are present");
+ Assert.equal(listbox.selectedItems.length, 1, "Only one item is selected");
+ Assert.ok(!listbox.selectedItem.hidden, "The selected item is not hidden");
+});
diff --git a/browser/components/downloads/test/browser/browser_overflow_anchor.js b/browser/components/downloads/test/browser/browser_overflow_anchor.js
new file mode 100644
index 0000000000..303bc81670
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_overflow_anchor.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+registerCleanupFunction(async function () {
+ // Clean up when the test finishes.
+ await task_resetState();
+});
+
+/**
+ * Make sure the downloads button and indicator overflows into the nav-bar
+ * chevron properly, and then when those buttons are clicked in the overflow
+ * panel that the downloads panel anchors to the chevron`s icon.
+ */
+add_task(async function test_overflow_anchor() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ // Ensure that state is reset in case previous tests didn't finish.
+ await task_resetState();
+
+ // The downloads button should not be overflowed to begin with.
+ let button = CustomizableUI.getWidget("downloads-button").forWindow(window);
+ ok(!button.overflowed, "Downloads button should not be overflowed.");
+ is(
+ button.node.getAttribute("cui-areatype"),
+ "toolbar",
+ "Button should know it's in the toolbar"
+ );
+
+ await gCustomizeMode.addToPanel(button.node);
+
+ let promise = promisePanelOpened();
+ EventUtils.sendMouseEvent({ type: "mousedown", button: 0 }, button.node);
+ info("waiting for panel to open");
+ await promise;
+
+ let panel = DownloadsPanel.panel;
+ let chevron = document.getElementById("nav-bar-overflow-button");
+
+ is(
+ panel.anchorNode,
+ chevron.icon,
+ "Panel should be anchored to the chevron`s icon."
+ );
+
+ DownloadsPanel.hidePanel();
+
+ gCustomizeMode.addToToolbar(button.node);
+
+ // Now try opening the panel again.
+ promise = promisePanelOpened();
+ EventUtils.sendMouseEvent({ type: "mousedown", button: 0 }, button.node);
+ await promise;
+
+ let downloadsAnchor = button.node.badgeStack;
+ is(panel.anchorNode, downloadsAnchor);
+
+ DownloadsPanel.hidePanel();
+});
diff --git a/browser/components/downloads/test/browser/browser_pdfjs_preview.js b/browser/components/downloads/test/browser/browser_pdfjs_preview.js
new file mode 100644
index 0000000000..cbd8516468
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_pdfjs_preview.js
@@ -0,0 +1,753 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let gDownloadDir;
+
+// The test is long, and it's not worth splitting it since all the tests share
+// the same boilerplate code.
+requestLongerTimeout(4);
+
+SimpleTest.requestFlakyTimeout(
+ "Giving a chance for possible last-pb-context-exited to occur (Bug 1329912)"
+);
+
+/*
+ Coverage for opening downloaded PDFs from download views
+*/
+
+const TestCases = [
+ {
+ name: "Download panel, default click behavior",
+ whichUI: "downloadPanel",
+ itemSelector: "#downloadsListBox richlistitem .downloadMainArea",
+ async userEvents(itemTarget, win) {
+ EventUtils.synthesizeMouseAtCenter(itemTarget, {}, win);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Download panel, system viewer menu items prefd off",
+ whichUI: "downloadPanel",
+ itemSelector: "#downloadsListBox richlistitem .downloadMainArea",
+ async userEvents(itemTarget, win) {
+ EventUtils.synthesizeMouseAtCenter(itemTarget, {}, win);
+ },
+ prefs: [
+ ["browser.download.openInSystemViewerContextMenuItem", false],
+ ["browser.download.alwaysOpenInSystemViewerContextMenuItem", false],
+ ],
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ useSystemMenuItemDisabled: true,
+ alwaysMenuItemDisabled: true,
+ },
+ },
+ {
+ name: "Download panel, open from keyboard",
+ whichUI: "downloadPanel",
+ itemSelector: "#downloadsListBox richlistitem .downloadMainArea",
+ async userEvents(itemTarget, win) {
+ itemTarget.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Download panel, open in new window",
+ whichUI: "downloadPanel",
+ itemSelector: "#downloadsListBox richlistitem .downloadMainArea",
+ async userEvents(itemTarget, win) {
+ EventUtils.synthesizeMouseAtCenter(itemTarget, { shiftKey: true }, win);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: true,
+ opensTab: false,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Download panel, open foreground tab", // duplicates the default behavior
+ whichUI: "downloadPanel",
+ itemSelector: "#downloadsListBox richlistitem .downloadMainArea",
+ async userEvents(itemTarget, win) {
+ EventUtils.synthesizeMouseAtCenter(
+ itemTarget,
+ { ctrlKey: true, metaKey: true },
+ win
+ );
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Download panel, open background tab",
+ whichUI: "downloadPanel",
+ itemSelector: "#downloadsListBox richlistitem .downloadMainArea",
+ async userEvents(itemTarget, win) {
+ EventUtils.synthesizeMouseAtCenter(
+ itemTarget,
+ { ctrlKey: true, metaKey: true, shiftKey: true },
+ win
+ );
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: false,
+ },
+ },
+
+ {
+ name: "Library all downloads dialog, default click behavior",
+ whichUI: "allDownloads",
+ async userEvents(itemTarget, win) {
+ // double click
+ await triggerDblclickOn(itemTarget, {}, win);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Library all downloads dialog, system viewer menu items prefd off",
+ whichUI: "allDownloads",
+ async userEvents(itemTarget, win) {
+ // double click
+ await triggerDblclickOn(itemTarget, {}, win);
+ },
+ prefs: [
+ ["browser.download.openInSystemViewerContextMenuItem", false],
+ ["browser.download.alwaysOpenInSystemViewerContextMenuItem", false],
+ ],
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ useSystemMenuItemDisabled: true,
+ alwaysMenuItemDisabled: true,
+ },
+ },
+ {
+ name: "Library all downloads dialog, open from keyboard",
+ whichUI: "allDownloads",
+ async userEvents(itemTarget, win) {
+ itemTarget.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Library all downloads dialog, open in new window",
+ whichUI: "allDownloads",
+ async userEvents(itemTarget, win) {
+ // double click
+ await triggerDblclickOn(itemTarget, { shiftKey: true }, win);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: true,
+ opensTab: false,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Library all downloads dialog, open foreground tab", // duplicates default behavior
+ whichUI: "allDownloads",
+ async userEvents(itemTarget, win) {
+ // double click
+ await triggerDblclickOn(
+ itemTarget,
+ { ctrlKey: true, metaKey: true },
+ win
+ );
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "Library all downloads dialog, open background tab",
+ whichUI: "allDownloads",
+ async userEvents(itemTarget, win) {
+ // double click
+ await triggerDblclickOn(
+ itemTarget,
+ { ctrlKey: true, metaKey: true, shiftKey: true },
+ win
+ );
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: false,
+ },
+ },
+ {
+ name: "about:downloads, default click behavior",
+ whichUI: "aboutDownloads",
+ itemSelector: "#downloadsListBox richlistitem .downloadContainer",
+ async userEvents(itemSelector, win) {
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, "about:downloads");
+ await contentTriggerDblclickOn(itemSelector, {}, browser);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "about:downloads, system viewer menu items prefd off",
+ whichUI: "aboutDownloads",
+ itemSelector: "#downloadsListBox richlistitem .downloadContainer",
+ async userEvents(itemSelector, win) {
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, "about:downloads");
+ await contentTriggerDblclickOn(itemSelector, {}, browser);
+ },
+ prefs: [
+ ["browser.download.openInSystemViewerContextMenuItem", false],
+ ["browser.download.alwaysOpenInSystemViewerContextMenuItem", false],
+ ],
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ useSystemMenuItemDisabled: true,
+ alwaysMenuItemDisabled: true,
+ },
+ },
+ {
+ name: "about:downloads, open in new window",
+ whichUI: "aboutDownloads",
+ itemSelector: "#downloadsListBox richlistitem .downloadContainer",
+ async userEvents(itemSelector, win) {
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, "about:downloads");
+ await contentTriggerDblclickOn(itemSelector, { shiftKey: true }, browser);
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: true,
+ opensTab: false,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "about:downloads, open in foreground tab",
+ whichUI: "aboutDownloads",
+ itemSelector: "#downloadsListBox richlistitem .downloadContainer",
+ async userEvents(itemSelector, win) {
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, "about:downloads");
+ await contentTriggerDblclickOn(
+ itemSelector,
+ { ctrlKey: true, metaKey: true },
+ browser
+ );
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: true,
+ },
+ },
+ {
+ name: "about:downloads, open in background tab",
+ whichUI: "aboutDownloads",
+ itemSelector: "#downloadsListBox richlistitem .downloadContainer",
+ async userEvents(itemSelector, win) {
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, "about:downloads");
+ await contentTriggerDblclickOn(
+ itemSelector,
+ { ctrlKey: true, metaKey: true, shiftKey: true },
+ browser
+ );
+ },
+ expected: {
+ downloadCount: 1,
+ newWindow: false,
+ opensTab: true,
+ tabSelected: false,
+ },
+ },
+ {
+ name: "Private download in about:downloads, opens in new private window",
+ skip: true, // Bug 1641770
+ whichUI: "aboutDownloads",
+ itemSelector: "#downloadsListBox richlistitem .downloadContainer",
+ async userEvents(itemSelector, win) {
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, "about:downloads");
+ await contentTriggerDblclickOn(itemSelector, { shiftKey: true }, browser);
+ },
+ isPrivate: true,
+ expected: {
+ downloadCount: 1,
+ newWindow: true,
+ opensTab: false,
+ tabSelected: true,
+ },
+ },
+];
+
+function triggerDblclickOn(target, modifiers = {}, win) {
+ let promise = BrowserTestUtils.waitForEvent(target, "dblclick");
+ EventUtils.synthesizeMouseAtCenter(
+ target,
+ Object.assign({ clickCount: 1 }, modifiers),
+ win
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ target,
+ Object.assign({ clickCount: 2 }, modifiers),
+ win
+ );
+ return promise;
+}
+
+function contentTriggerDblclickOn(selector, eventModifiers = {}, browser) {
+ return SpecialPowers.spawn(
+ browser,
+ [selector, eventModifiers],
+ async function (itemSelector, modifiers) {
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let itemTarget = content.document.querySelector(itemSelector);
+ ok(itemTarget, "Download item target exists");
+
+ let doubleClicked = ContentTaskUtils.waitForEvent(itemTarget, "dblclick");
+ // NOTE: we are using sendMouseEvent instead of synthesizeMouseAtCenter
+ // here to prevent an unexpected timeout failure in devedition builds
+ // due to the ContentTaskUtils.waitForEvent promise never been resolved.
+ EventUtils.sendMouseEvent(
+ { type: "dblclick", ...modifiers },
+ itemTarget,
+ content
+ );
+ info("Waiting for double-click content task");
+ await doubleClicked;
+ }
+ );
+}
+
+async function verifyContextMenu(contextMenu, expected = {}) {
+ info("verifyContextMenu with expected: " + JSON.stringify(expected, null, 2));
+ let alwaysMenuItem = contextMenu.querySelector(
+ ".downloadAlwaysUseSystemDefaultMenuItem"
+ );
+ let useSystemMenuItem = contextMenu.querySelector(
+ ".downloadUseSystemDefaultMenuItem"
+ );
+ info("Waiting for the context menu to show up");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(contextMenu),
+ "The context menu is visible"
+ );
+ await TestUtils.waitForTick();
+
+ info("Checking visibility of the system viewer menu items");
+ is(
+ BrowserTestUtils.is_hidden(useSystemMenuItem),
+ expected.useSystemMenuItemDisabled,
+ `The 'Use system viewer' menu item was ${
+ expected.useSystemMenuItemDisabled ? "hidden" : "visible"
+ }`
+ );
+ is(
+ BrowserTestUtils.is_hidden(alwaysMenuItem),
+ expected.alwaysMenuItemDisabled,
+ `The 'Use system viewer' menu item was ${
+ expected.alwaysMenuItemDisabled ? "hidden" : "visible"
+ }`
+ );
+
+ if (!expected.useSystemMenuItemDisabled && expected.alwaysChecked) {
+ is(
+ alwaysMenuItem.getAttribute("checked"),
+ "true",
+ "The 'Always...' menu item is checked"
+ );
+ } else if (!expected.useSystemMenuItemDisabled) {
+ ok(
+ !alwaysMenuItem.hasAttribute("checked"),
+ "The 'Always...' menu item not checked"
+ );
+ }
+}
+
+async function addPDFDownload(itemData) {
+ let startTimeMs = Date.now();
+ info("addPDFDownload with itemData: " + JSON.stringify(itemData, null, 2));
+
+ let downloadPathname = PathUtils.join(gDownloadDir, itemData.targetFilename);
+ delete itemData.targetFilename;
+
+ info("Creating saved download file at:" + downloadPathname);
+ let pdfFile = await createDownloadedFile(downloadPathname, DATA_PDF);
+ info("Created file at:" + pdfFile.path);
+
+ let downloadList = await Downloads.getList(
+ itemData.isPrivate ? Downloads.PRIVATE : Downloads.PUBLIC
+ );
+ let download = {
+ source: {
+ url: "https://example.com/some.pdf",
+ isPrivate: itemData.isPrivate,
+ },
+ target: {
+ path: pdfFile.path,
+ },
+ succeeded: DownloadsCommon.DOWNLOAD_FINISHED,
+ canceled: false,
+ error: null,
+ hasPartialData: false,
+ hasBlockedData: itemData.hasBlockedData || false,
+ startTime: new Date(startTimeMs++),
+ ...itemData,
+ };
+ if (itemData.errorObj) {
+ download.errorObj = itemData.errorObj;
+ }
+
+ await downloadList.add(await Downloads.createDownload(download));
+ return download;
+}
+
+async function testSetup() {
+ // remove download files, empty out collections
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloadCount = (await downloadList.getAll()).length;
+ is(downloadCount, 0, "At the start of the test, there should be 0 downloads");
+
+ await task_resetState();
+ if (!gDownloadDir) {
+ gDownloadDir = await setDownloadDir();
+ }
+ info("Created download directory: " + gDownloadDir);
+}
+
+async function openDownloadPanel(expectedItemCount) {
+ // Open the user interface and wait for data to be fully loaded.
+ let richlistbox = document.getElementById("downloadsListBox");
+ await task_openPanel();
+ await TestUtils.waitForCondition(
+ () =>
+ richlistbox.childElementCount == expectedItemCount &&
+ !richlistbox.getAttribute("disabled")
+ );
+}
+
+async function testOpenPDFPreview({
+ name,
+ whichUI,
+ downloadProperties,
+ itemSelector,
+ expected,
+ prefs = [],
+ userEvents,
+ isPrivate,
+}) {
+ info("Test case: " + name);
+ // Wait for focus first
+ await promiseFocus();
+ await testSetup();
+ if (prefs.length) {
+ await SpecialPowers.pushPrefEnv({
+ set: prefs,
+ });
+ }
+
+ // Populate downloads database with the data required by this test.
+ info("Adding download objects");
+ if (!downloadProperties) {
+ downloadProperties = {
+ targetFilename: "downloaded.pdf",
+ };
+ }
+ let download = await addPDFDownload({
+ ...downloadProperties,
+ isPrivate,
+ });
+ info("Got download pathname:" + download.target.path);
+ is(
+ !!download.source.isPrivate,
+ !!isPrivate,
+ `Added download is ${isPrivate ? "private" : "not private"} as expected`
+ );
+ let downloadList = await Downloads.getList(
+ isPrivate ? Downloads.PRIVATE : Downloads.PUBLIC
+ );
+ let downloads = await downloadList.getAll();
+ is(
+ downloads.length,
+ expected.downloadCount,
+ `${isPrivate ? "Private" : "Public"} list has expected ${
+ downloads.length
+ } downloads`
+ );
+
+ let pdfFileURI = NetUtil.newURI(new FileUtils.File(download.target.path));
+ info("pdfFileURI:" + pdfFileURI.spec);
+
+ let uiWindow = window;
+ let previewWindow = window;
+ // we never want to unload the test browser by loading the file: URI into it
+ await BrowserTestUtils.withNewTab("about:blank", async initialBrowser => {
+ let previewTab;
+ let previewHappened;
+
+ if (expected.newWindow) {
+ info(
+ "previewHappened will wait for new browser window with url: " +
+ pdfFileURI.spec
+ );
+ // wait for a new browser window
+ previewHappened = BrowserTestUtils.waitForNewWindow({
+ anyWindow: true,
+ url: pdfFileURI.spec,
+ });
+ } else if (expected.opensTab) {
+ // wait for a tab to be opened
+ info("previewHappened will wait for tab with URI:" + pdfFileURI.spec);
+ previewHappened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ pdfFileURI.spec,
+ false, // dont wait for load
+ true // any tab, not just the next one
+ );
+ } else {
+ info(
+ "previewHappened will wait to load " +
+ pdfFileURI.spec +
+ " into the current tab"
+ );
+ previewHappened = BrowserTestUtils.browserLoaded(
+ initialBrowser,
+ false,
+ pdfFileURI.spec
+ );
+ }
+
+ let itemTarget;
+ let contextMenu;
+
+ switch (whichUI) {
+ case "downloadPanel":
+ info("Opening download panel");
+ await openDownloadPanel(expected.downloadCount);
+ info("/Opening download panel");
+ itemTarget = document.querySelector(itemSelector);
+ contextMenu = uiWindow.document.querySelector("#downloadsContextMenu");
+
+ break;
+ case "allDownloads":
+ // we'll be interacting with the library dialog
+ uiWindow = await openLibrary("Downloads");
+
+ let listbox = uiWindow.document.getElementById("downloadsListBox");
+ ok(listbox, "download list box present");
+ // wait for the expected number of items in the view,
+ // and for the first item to be visible && clickable
+ await TestUtils.waitForCondition(() => {
+ return (
+ listbox.itemChildren.length == expected.downloadCount &&
+ BrowserTestUtils.is_visible(listbox.itemChildren[0])
+ );
+ });
+ itemTarget = listbox.itemChildren[0];
+ contextMenu = uiWindow.document.querySelector("#downloadsContextMenu");
+
+ break;
+ case "aboutDownloads":
+ info("Preparing about:downloads browser window");
+
+ // Because of bug 1329912, we sometimes get a bogus last-pb-context-exited notification
+ // which removes all the private downloads and about:downloads renders a empty list
+ // we'll allow time for that to happen before loading about:downloads
+ let pbExitedOrTimeout = isPrivate
+ ? new Promise(resolve => {
+ const topic = "last-pb-context-exited";
+ const ENOUGH_TIME = 1000;
+ function observer() {
+ info(`Bogus ${topic} observed`);
+ done();
+ }
+ function done() {
+ clearTimeout(timerId);
+ Services.obs.removeObserver(observer, topic);
+ resolve();
+ }
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ const timerId = setTimeout(done, ENOUGH_TIME);
+ Services.obs.addObserver(observer, "last-pb-context-exited");
+ })
+ : Promise.resolve();
+
+ if (isPrivate) {
+ uiWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ }
+ info(
+ "in aboutDownloads, initially there are tabs: " +
+ uiWindow.gBrowser.tabs.length
+ );
+
+ let browser = uiWindow.gBrowser.selectedBrowser;
+ await pbExitedOrTimeout;
+
+ info("Loading about:downloads");
+ let downloadsLoaded = BrowserTestUtils.waitForEvent(
+ browser,
+ "InitialDownloadsLoaded",
+ true
+ );
+ BrowserTestUtils.loadURIString(browser, "about:downloads");
+ await BrowserTestUtils.browserLoaded(browser);
+ info("waiting for downloadsLoaded");
+ await downloadsLoaded;
+
+ await ContentTask.spawn(
+ browser,
+ [expected.downloadCount],
+ async function awaitListItems(expectedCount) {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.getElementById("downloadsListBox")
+ .childElementCount == expectedCount,
+ `Await ${expectedCount} download list items`
+ );
+ }
+ );
+ break;
+ }
+
+ if (contextMenu) {
+ info("trigger the contextmenu");
+ await openContextMenu(itemTarget || itemSelector, uiWindow);
+ info("context menu should be open, verify its menu items");
+ let expectedValues = {
+ useSystemMenuItemDisabled: false,
+ alwaysMenuItemDisabled: false,
+ ...expected,
+ };
+ await verifyContextMenu(contextMenu, expectedValues);
+ contextMenu.hidePopup();
+ } else {
+ todo(contextMenu, "No context menu checks for test: " + name);
+ }
+
+ info("Executing user events");
+ await userEvents(itemTarget || itemSelector, uiWindow);
+
+ info("Waiting for previewHappened");
+ let results = await previewHappened;
+ if (expected.newWindow) {
+ previewWindow = results;
+ info("New window expected, got previewWindow? " + previewWindow);
+ }
+ previewTab =
+ previewWindow.gBrowser.tabs[previewWindow.gBrowser.tabs.length - 1];
+ ok(previewTab, "Got preview tab");
+
+ let isSelected = previewWindow.gBrowser.selectedTab == previewTab;
+ if (expected.tabSelected) {
+ ok(isSelected, "The preview tab was selected");
+ } else {
+ ok(!isSelected, "The preview tab was opened in the background");
+ }
+
+ is(
+ previewTab.linkedBrowser.currentURI.spec,
+ pdfFileURI.spec,
+ "previewTab has the expected currentURI"
+ );
+
+ is(
+ PrivateBrowsingUtils.isBrowserPrivate(previewTab.linkedBrowser),
+ !!isPrivate,
+ `The preview tab was ${isPrivate ? "private" : "not private"} as expected`
+ );
+
+ info("cleaning up");
+ if (whichUI == "downloadPanel") {
+ DownloadsPanel.hidePanel();
+ }
+ let lastPBContextExitedPromise = isPrivate
+ ? TestUtils.topicObserved("last-pb-context-exited").then(() =>
+ TestUtils.waitForTick()
+ )
+ : Promise.resolve();
+
+ info("Test opened a new UI window? " + (uiWindow !== window));
+ if (uiWindow !== window) {
+ info("Closing uiWindow");
+ await BrowserTestUtils.closeWindow(uiWindow);
+ }
+ if (expected.newWindow) {
+ // will also close the previewTab
+ await BrowserTestUtils.closeWindow(previewWindow);
+ } else {
+ await BrowserTestUtils.removeTab(previewTab);
+ }
+ info("Waiting for lastPBContextExitedPromise");
+ await lastPBContextExitedPromise;
+ });
+ await downloadList.removeFinished();
+ if (prefs.length) {
+ await SpecialPowers.popPrefEnv();
+ }
+}
+
+// register the tests
+for (let testData of TestCases) {
+ if (testData.skip) {
+ info("Skipping test:" + testData.name);
+ continue;
+ }
+ // use the 'name' property of each test case as the test function name
+ // so we get useful logs
+ let tmp = {
+ async [testData.name]() {
+ await testOpenPDFPreview(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
diff --git a/browser/components/downloads/test/browser/browser_tempfilename.js b/browser/components/downloads/test/browser/browser_tempfilename.js
new file mode 100644
index 0000000000..e4dae6d944
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_tempfilename.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_tempfilename() {
+ startServer();
+ let downloadURL = httpUrl("interruptible.txt");
+ let list = await Downloads.getList(Downloads.PUBLIC);
+ let downloadStarted = new Promise(resolve => {
+ let view = {
+ onDownloadAdded(download) {
+ list.removeView(view);
+ resolve(download);
+ },
+ };
+ list.addView(view);
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+
+ const MimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+ const HandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ let mimeInfo = MimeSvc.getFromTypeAndExtension(
+ HandlerSvc.getTypeFromExtension("txt"),
+ "txt"
+ );
+ let existed = HandlerSvc.exists(mimeInfo);
+ mimeInfo.alwaysAskBeforeHandling = false;
+ mimeInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ HandlerSvc.store(mimeInfo);
+
+ serveInterruptibleAsDownload();
+ mustInterruptResponses();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: downloadURL,
+ waitForLoad: false,
+ waitForStop: true,
+ },
+ async () => {
+ let download = await downloadStarted;
+ registerCleanupFunction(async () => {
+ if (existed) {
+ HandlerSvc.store(mimeInfo);
+ } else {
+ HandlerSvc.remove(mimeInfo);
+ }
+ await download.finalize(true);
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ await download.finalize();
+ await list.removeFinished();
+ });
+
+ let { partFilePath } = download.target;
+ Assert.stringContains(
+ partFilePath,
+ "interruptible",
+ "Should keep bit of original filename."
+ );
+ isnot(
+ PathUtils.filename(partFilePath),
+ "interruptible.txt.part",
+ "Should not just have original filename."
+ );
+ ok(
+ partFilePath.endsWith(".txt.part"),
+ `${PathUtils.filename(partFilePath)} should end with .txt.part`
+ );
+ let promiseFinished = download.whenSucceeded();
+ continueResponses();
+ await promiseFinished;
+ ok(
+ !(await IOUtils.exists(download.target.partFilePath)),
+ "Temp file should be gone."
+ );
+ }
+ );
+});
diff --git a/browser/components/downloads/test/browser/foo.txt b/browser/components/downloads/test/browser/foo.txt
new file mode 100644
index 0000000000..77e7195596
--- /dev/null
+++ b/browser/components/downloads/test/browser/foo.txt
@@ -0,0 +1 @@
+Dummy content for unknownContentType_dialog_layout_data.txt
diff --git a/browser/components/downloads/test/browser/foo.txt^headers^ b/browser/components/downloads/test/browser/foo.txt^headers^
new file mode 100644
index 0000000000..2a3c472e26
--- /dev/null
+++ b/browser/components/downloads/test/browser/foo.txt^headers^
@@ -0,0 +1,2 @@
+Content-Type: text/plain
+Content-Disposition: attachment
diff --git a/browser/components/downloads/test/browser/head.js b/browser/components/downloads/test/browser/head.js
new file mode 100644
index 0000000000..49b4d8d04c
--- /dev/null
+++ b/browser/components/downloads/test/browser/head.js
@@ -0,0 +1,448 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Provides infrastructure for automated download components tests.
+ */
+
+// Globals
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "HttpServer",
+ "resource://testing-common/httpd.js"
+);
+
+let gTestTargetFile = new FileUtils.File(
+ PathUtils.join(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "dm-ui-test.file"
+ )
+);
+
+gTestTargetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+Services.prefs.setIntPref("security.dialog_enable_delay", 0);
+
+// The file may have been already deleted when removing a paused download.
+// Also clear security.dialog_enable_delay pref.
+registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("security.dialog_enable_delay");
+
+ if (await IOUtils.exists(gTestTargetFile.path)) {
+ info("removing " + gTestTargetFile.path);
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(gTestTargetFile.path, 0o600);
+ }
+ await IOUtils.remove(gTestTargetFile.path);
+ }
+});
+
+const DATA_PDF = atob(
+ "JVBERi0xLjANCjEgMCBvYmo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PmVuZG9iaiAyIDAgb2JqPDwvVHlwZS9QYWdlcy9LaWRzWzMgMCBSXS9Db3VudCAxPj5lbmRvYmogMyAwIG9iajw8L1R5cGUvUGFnZS9NZWRpYUJveFswIDAgMyAzXT4+ZW5kb2JqDQp4cmVmDQowIDQNCjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxMCAwMDAwMCBuDQowMDAwMDAwMDUzIDAwMDAwIG4NCjAwMDAwMDAxMDIgMDAwMDAgbg0KdHJhaWxlcjw8L1NpemUgNC9Sb290IDEgMCBSPj4NCnN0YXJ0eHJlZg0KMTQ5DQolRU9G"
+);
+
+const TEST_DATA_SHORT = "This test string is downloaded.";
+
+/**
+ * This is an internal reference that should not be used directly by tests.
+ */
+var _gDeferResponses = PromiseUtils.defer();
+
+/**
+ * Ensures that all the interruptible requests started after this function is
+ * called won't complete until the continueResponses function is called.
+ *
+ * Normally, the internal HTTP server returns all the available data as soon as
+ * a request is received. In order for some requests to be served one part at a
+ * time, special interruptible handlers are registered on the HTTP server. This
+ * allows testing events or actions that need to happen in the middle of a
+ * download.
+ *
+ * For example, the handler accessible at the httpUri("interruptible.txt")
+ * address returns the TEST_DATA_SHORT text, then it may block until the
+ * continueResponses method is called. At this point, the handler sends the
+ * TEST_DATA_SHORT text again to complete the response.
+ *
+ * If an interruptible request is started before the function is called, it may
+ * or may not be blocked depending on the actual sequence of events.
+ */
+function mustInterruptResponses() {
+ // If there are pending blocked requests, allow them to complete. This is
+ // done to prevent requests from being blocked forever, but should not affect
+ // the test logic, since previously started requests should not be monitored
+ // on the client side anymore.
+ _gDeferResponses.resolve();
+
+ info("Interruptible responses will be blocked midway.");
+ _gDeferResponses = PromiseUtils.defer();
+}
+
+/**
+ * Allows all the current and future interruptible requests to complete.
+ */
+function continueResponses() {
+ info("Interruptible responses are now allowed to continue.");
+ _gDeferResponses.resolve();
+}
+
+/**
+ * Creates a download, which could be interrupted in the middle of it's progress.
+ */
+function promiseInterruptibleDownload(extension = ".txt") {
+ let interruptibleFile = FileUtils.getFile("TmpD", [
+ `interruptible${extension}`,
+ ]);
+ interruptibleFile.createUnique(
+ Ci.nsIFile.NORMAL_FILE_TYPE,
+ FileUtils.PERMS_FILE
+ );
+
+ registerCleanupFunction(async () => {
+ if (await IOUtils.exists(interruptibleFile.path)) {
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(interruptibleFile.path, 0o600);
+ }
+ await IOUtils.remove(interruptibleFile.path);
+ }
+ });
+
+ return Downloads.createDownload({
+ source: httpUrl("interruptible.txt"),
+ target: { path: interruptibleFile.path },
+ });
+}
+
+// Asynchronous support subroutines
+
+async function createDownloadedFile(pathname, contents) {
+ let file = new FileUtils.File(pathname);
+ if (file.exists()) {
+ info(`File at ${pathname} already exists`);
+ }
+ // No post-test cleanup necessary; tmp downloads directory is already removed after each test
+ await IOUtils.writeUTF8(pathname, contents);
+ ok(file.exists(), `Created ${pathname}`);
+ return file;
+}
+
+async function openContextMenu(itemElement, win = window) {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ itemElement.ownerDocument,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ itemElement,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ win
+ );
+ let { target } = await popupShownPromise;
+ return target;
+}
+
+function promiseFocus() {
+ return new Promise(resolve => {
+ waitForFocus(resolve);
+ });
+}
+
+function promisePanelOpened() {
+ if (DownloadsPanel.panel && DownloadsPanel.panel.state == "open") {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ // Hook to wait until the panel is shown.
+ let originalOnPopupShown = DownloadsPanel.onPopupShown;
+ DownloadsPanel.onPopupShown = function () {
+ DownloadsPanel.onPopupShown = originalOnPopupShown;
+ originalOnPopupShown.apply(this, arguments);
+
+ // Defer to the next tick of the event loop so that we don't continue
+ // processing during the DOM event handler itself.
+ setTimeout(resolve, 0);
+ };
+ });
+}
+
+async function task_resetState() {
+ // Remove all downloads.
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ for (let download of downloads) {
+ await publicList.remove(download);
+ if (await IOUtils.exists(download.target.path)) {
+ await download.finalize(true);
+ info("removing " + download.target.path);
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ }
+ }
+
+ DownloadsPanel.hidePanel();
+
+ await promiseFocus();
+}
+
+async function task_addDownloads(aItems) {
+ let startTimeMs = Date.now();
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ for (let item of aItems) {
+ let source = {
+ url: "http://www.example.com/test-download.txt",
+ ...item.source,
+ };
+ let target =
+ item.target instanceof Ci.nsIFile
+ ? item.target
+ : {
+ path: gTestTargetFile.path,
+ ...item.target,
+ };
+
+ let download = {
+ source,
+ target,
+ succeeded: item.state == DownloadsCommon.DOWNLOAD_FINISHED,
+ canceled:
+ item.state == DownloadsCommon.DOWNLOAD_CANCELED ||
+ item.state == DownloadsCommon.DOWNLOAD_PAUSED,
+ deleted: item.deleted ?? false,
+ error:
+ item.state == DownloadsCommon.DOWNLOAD_FAILED
+ ? new Error("Failed.")
+ : null,
+ hasPartialData: item.state == DownloadsCommon.DOWNLOAD_PAUSED,
+ hasBlockedData: item.hasBlockedData || false,
+ openDownloadsListOnStart: item.openDownloadsListOnStart ?? true,
+ contentType: item.contentType,
+ startTime: new Date(startTimeMs++),
+ };
+ // `"errorObj" in download` must be false when there's no error.
+ if (item.errorObj) {
+ download.errorObj = item.errorObj;
+ }
+ download = await Downloads.createDownload(download);
+ await publicList.add(download);
+ await download.refresh();
+ }
+}
+
+async function task_openPanel() {
+ await promiseFocus();
+
+ let promise = promisePanelOpened();
+ DownloadsPanel.showPanel();
+ await promise;
+}
+
+async function setDownloadDir() {
+ let tmpDir = PathUtils.join(
+ PathUtils.tempDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ registerCleanupFunction(async function () {
+ try {
+ await IOUtils.remove(tmpDir, { recursive: true });
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", tmpDir);
+ return tmpDir;
+}
+
+let gHttpServer = null;
+let gShouldServeInterruptibleFileAsDownload = false;
+function startServer() {
+ gHttpServer = new HttpServer();
+ gHttpServer.start(-1);
+ registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ // Ensure all the pending HTTP requests have a chance to finish.
+ continueResponses();
+ // Stop the HTTP server, calling resolve when it's done.
+ gHttpServer.stop(resolve);
+ });
+ });
+
+ gHttpServer.identity.setPrimary(
+ "http",
+ "www.example.com",
+ gHttpServer.identity.primaryPort
+ );
+
+ gHttpServer.registerPathHandler("/file1.txt", (request, response) => {
+ response.setStatusLine(null, 200, "OK");
+ response.write("file1");
+ response.processAsync();
+ response.finish();
+ });
+ gHttpServer.registerPathHandler("/file2.txt", (request, response) => {
+ response.setStatusLine(null, 200, "OK");
+ response.write("file2");
+ response.processAsync();
+ response.finish();
+ });
+ gHttpServer.registerPathHandler("/file3.txt", (request, response) => {
+ response.setStatusLine(null, 200, "OK");
+ response.write("file3");
+ response.processAsync();
+ response.finish();
+ });
+
+ gHttpServer.registerPathHandler(
+ "/interruptible.txt",
+ function (aRequest, aResponse) {
+ info("Interruptible request started.");
+
+ // Process the first part of the response.
+ aResponse.processAsync();
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ if (gShouldServeInterruptibleFileAsDownload) {
+ aResponse.setHeader("Content-Disposition", "attachment");
+ }
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT.length * 2,
+ false
+ );
+ aResponse.write(TEST_DATA_SHORT);
+
+ // Wait on the current deferred object, then finish the request.
+ _gDeferResponses.promise
+ .then(function RIH_onSuccess() {
+ aResponse.write(TEST_DATA_SHORT);
+ aResponse.finish();
+ info("Interruptible request finished.");
+ })
+ .catch(console.error);
+ }
+ );
+}
+
+function serveInterruptibleAsDownload() {
+ gShouldServeInterruptibleFileAsDownload = true;
+ registerCleanupFunction(
+ () => (gShouldServeInterruptibleFileAsDownload = false)
+ );
+}
+
+function httpUrl(aFileName) {
+ return (
+ "http://localhost:" + gHttpServer.identity.primaryPort + "/" + aFileName
+ );
+}
+
+function openLibrary(aLeftPaneRoot) {
+ let library = window.openDialog(
+ "chrome://browser/content/places/places.xhtml",
+ "",
+ "chrome,toolbar=yes,dialog=no,resizable",
+ aLeftPaneRoot
+ );
+
+ return new Promise(resolve => {
+ waitForFocus(resolve, library);
+ });
+}
+
+/**
+ * Waits for a download to reach its progress, in case it has not
+ * reached the expected progress already.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has reached its progress.
+ * @rejects Never.
+ */
+function promiseDownloadHasProgress(aDownload, progress) {
+ return new Promise(resolve => {
+ // Wait for the download to reach its progress.
+ let onchange = function () {
+ let downloadInProgress =
+ !aDownload.stopped && aDownload.progress == progress;
+ let downloadFinished =
+ progress == 100 &&
+ aDownload.progress == progress &&
+ aDownload.succeeded;
+ if (downloadInProgress || downloadFinished) {
+ info(`Download reached ${progress}%`);
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a given button to become visible.
+ */
+function promiseButtonShown(id) {
+ let dwu = window.windowUtils;
+ return BrowserTestUtils.waitForCondition(() => {
+ let target = document.getElementById(id);
+ let bounds = dwu.getBoundsWithoutFlushing(target);
+ return bounds.width > 0 && bounds.height > 0;
+ }, `Waiting for button ${id} to have non-0 size`);
+}
+
+async function simulateDropAndCheck(win, dropTarget, urls) {
+ let dragData = [[{ type: "text/plain", data: urls.join("\n") }]];
+ let list = await Downloads.getList(Downloads.ALL);
+
+ let added = new Set();
+ let succeeded = new Set();
+ await new Promise(resolve => {
+ let view = {
+ onDownloadAdded(download) {
+ added.add(download.source.url);
+ },
+ onDownloadChanged(download) {
+ if (!added.has(download.source.url)) {
+ return;
+ }
+ if (!download.succeeded) {
+ return;
+ }
+ succeeded.add(download.source.url);
+ if (succeeded.size == urls.length) {
+ list.removeView(view).then(resolve);
+ }
+ },
+ };
+ list.addView(view).then(function () {
+ EventUtils.synthesizeDrop(dropTarget, dropTarget, dragData, "link", win);
+ });
+ });
+
+ for (let url of urls) {
+ ok(added.has(url), url + " is added to download");
+ }
+}
diff --git a/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg b/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg
new file mode 100644
index 0000000000..04b7f003b4
--- /dev/null
+++ b/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg
Binary files differ
diff --git a/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg^headers^ b/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg^headers^
new file mode 100644
index 0000000000..c1a7794310
--- /dev/null
+++ b/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg^headers^
@@ -0,0 +1,2 @@
+Content-Type: image/webp
+
diff --git a/browser/components/downloads/test/browser/test_spammy_page.html b/browser/components/downloads/test/browser/test_spammy_page.html
new file mode 100644
index 0000000000..92332bb1c0
--- /dev/null
+++ b/browser/components/downloads/test/browser/test_spammy_page.html
@@ -0,0 +1,26 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>Spam Page Test</title>
+</head>
+<body>
+ <p> Hello, it's the spammy page! </p>
+<script type="text/javascript">
+ let count = 0;
+window.onload = window.onclick = function() {
+ if (count < 100) {
+ count++;
+ let l = document.createElement('a');
+ l.href = 'data:text/plain,some text';
+ l.download = 'sometext.txt';
+
+ document.body.appendChild(l);
+ l.click();
+ }
+}
+</script>
+</body>
+</html>
diff --git a/browser/components/downloads/test/unit/head.js b/browser/components/downloads/test/unit/head.js
new file mode 100644
index 0000000000..2f0326e779
--- /dev/null
+++ b/browser/components/downloads/test/unit/head.js
@@ -0,0 +1,67 @@
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
+ FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+async function createDownloadedFile(pathname, contents) {
+ info("createDownloadedFile: " + pathname);
+ let file = new FileUtils.File(pathname);
+ if (file.exists()) {
+ info(`File at ${pathname} already exists`);
+ if (!contents) {
+ ok(
+ false,
+ `A file already exists at ${pathname}, but createDownloadedFile was asked to create a non-existant file`
+ );
+ }
+ }
+ if (contents) {
+ await IOUtils.writeUTF8(pathname, contents);
+ ok(file.exists(), `Created ${pathname}`);
+ }
+ // No post-test cleanup necessary; tmp downloads directory is already removed after each test
+ return file;
+}
+
+let gDownloadDir;
+
+async function setDownloadDir() {
+ let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile).path;
+ tmpDir = PathUtils.join(
+ tmpDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ registerCleanupFunction(async function () {
+ try {
+ await IOUtils.remove(tmpDir, { recursive: true });
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", tmpDir);
+ return tmpDir;
+}
+
+/**
+ * All the tests are implemented with add_task, this starts them automatically.
+ */
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+add_setup(async function test_common_initialize() {
+ gDownloadDir = await setDownloadDir();
+ Services.prefs.setCharPref("browser.download.loglevel", "Debug");
+});
diff --git a/browser/components/downloads/test/unit/test_DownloadLastDir_basics.js b/browser/components/downloads/test/unit/test_DownloadLastDir_basics.js
new file mode 100644
index 0000000000..f1dfbe4733
--- /dev/null
+++ b/browser/components/downloads/test/unit/test_DownloadLastDir_basics.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Basic test for setting and retrieving a download last dir.
+// More complex tests can be found in browser/components/privatebrowsing/.
+
+const SAVE_PER_SITE_PREF_BRANCH = "browser.download.lastDir";
+const SAVE_PER_SITE_PREF = SAVE_PER_SITE_PREF_BRANCH + ".savePerSite";
+
+let { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+let { DownloadLastDir } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadLastDir.sys.mjs"
+);
+
+add_task(
+ {
+ pref_set: [[SAVE_PER_SITE_PREF, true]],
+ },
+ async function test() {
+ let downloadLastDir = new DownloadLastDir(null);
+
+ let unknownUri = Services.io.newURI("https://unknown.org/");
+ Assert.deepEqual(
+ await downloadLastDir.getFileAsync(unknownUri),
+ null,
+ "Untracked URI, no pref set"
+ );
+
+ let dir1 = FileUtils.getDir("TmpD", ["dir1"], true);
+ let uri1 = Services.io.newURI("https://test1.moz.org");
+ downloadLastDir.setFile(uri1, dir1);
+ let dir2 = FileUtils.getDir("TmpD", ["dir2"], true);
+ let uri2 = Services.io.newURI("https://test2.moz.org");
+ downloadLastDir.setFile(uri2, dir2);
+ let dir3 = FileUtils.getDir("TmpD", ["dir3"], true);
+ downloadLastDir.setFile(null, dir3);
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(uri1)).path,
+ dir1.path,
+ "Check common URI"
+ );
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(uri2)).path,
+ dir2.path,
+ "Check common URI"
+ );
+ Assert.equal(downloadLastDir.file.path, dir3.path, "No URI");
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(unknownUri)).path,
+ dir3.path,
+ "Untracked URI, pref set"
+ );
+
+ info("Check clearHistory removes all data");
+ let subject = {};
+ Services.obs.notifyObservers(subject, "browser:purge-session-history");
+ await subject.promise;
+ Assert.deepEqual(
+ await downloadLastDir.getFileAsync(uri1),
+ null,
+ "Check common URI after clear history returns null"
+ );
+ Assert.deepEqual(
+ await downloadLastDir.getFileAsync(uri2),
+ null,
+ "Check common URI after clear history returns null"
+ );
+ Assert.deepEqual(
+ await downloadLastDir.getFileAsync(unknownUri),
+ null,
+ "Check untracked URI after clear history returns null"
+ );
+
+ // file: URIs should all point to the same folder.
+ let fileUri1 = Services.io.newURI("file:///c:/test.txt");
+ downloadLastDir.setFile(uri1, dir3);
+ let dir4 = FileUtils.getDir("TmpD", ["dir4"], true);
+ let fileUri2 = Services.io.newURI("file:///d:/test.png");
+ downloadLastDir.setFile(uri1, dir4);
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(fileUri1)).path,
+ dir4.path,
+ "Check file URI"
+ );
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(fileUri2)).path,
+ dir4.path,
+ "Check file URI"
+ );
+ let unknownFileUri = Services.io.newURI("file:///e:/test.mkv");
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(unknownFileUri)).path,
+ dir4.path,
+ "Untracked File URI, pref set"
+ );
+
+ // data: URIs should point to a folder per mime-type.
+ // Unspecified mime-type is handled as text/plain.
+ let dataUri1 = Services.io.newURI("data:text/plain;charset=UTF-8,1234");
+ downloadLastDir.setFile(dataUri1, dir1);
+ let dataUri2 = Services.io.newURI("data:image/png;base64,1234");
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(dataUri2)).path,
+ dir1.path,
+ "Check data URI"
+ );
+ let dataUri3 = Services.io.newURI("data:image/png,5678");
+ downloadLastDir.setFile(dataUri3, dir2);
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(dataUri2)).path,
+ dir2.path,
+ "Data URI was changed, same mime-type"
+ );
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(dataUri1)).path,
+ dir1.path,
+ "Data URI was not changed, different mime-type"
+ );
+ let dataUri4 = Services.io.newURI("data:,");
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(dataUri4)).path,
+ dir1.path,
+ "Data URI defaults to text/plain"
+ );
+ downloadLastDir.setFile(null, dir4);
+ let unknownDataUri = Services.io.newURI("data:application/zip,");
+ Assert.deepEqual(
+ (await downloadLastDir.getFileAsync(unknownDataUri)).path,
+ dir4.path,
+ "Untracked data URI"
+ );
+ Assert.equal(
+ (await downloadLastDir.getFileAsync(dataUri4)).path,
+ dir1.path,
+ "Data URI didn't change"
+ );
+ }
+);
diff --git a/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js b/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js
new file mode 100644
index 0000000000..3e87fa9ec9
--- /dev/null
+++ b/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js
@@ -0,0 +1,168 @@
+const DATA_PDF = atob(
+ "JVBERi0xLjANCjEgMCBvYmo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PmVuZG9iaiAyIDAgb2JqPDwvVHlwZS9QYWdlcy9LaWRzWzMgMCBSXS9Db3VudCAxPj5lbmRvYmogMyAwIG9iajw8L1R5cGUvUGFnZS9NZWRpYUJveFswIDAgMyAzXT4+ZW5kb2JqDQp4cmVmDQowIDQNCjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxMCAwMDAwMCBuDQowMDAwMDAwMDUzIDAwMDAwIG4NCjAwMDAwMDAxMDIgMDAwMDAgbg0KdHJhaWxlcjw8L1NpemUgNC9Sb290IDEgMCBSPj4NCnN0YXJ0eHJlZg0KMTQ5DQolRU9G"
+);
+
+const DOWNLOAD_TEMPLATE = {
+ source: {
+ url: "https://example.com/download",
+ },
+ target: {
+ path: "",
+ },
+ contentType: "text/plain",
+ succeeded: DownloadsCommon.DOWNLOAD_FINISHED,
+ canceled: false,
+ error: null,
+ hasPartialData: false,
+ hasBlockedData: false,
+ startTime: new Date(Date.now() - 1000),
+};
+
+const TESTFILES = {
+ "download-test.txt": "Text file contents\n",
+ "download-test.pdf": DATA_PDF,
+ "download-test.PDF": DATA_PDF,
+ "download-test.xxunknown": "Unknown file contents\n",
+ "download-test": "No extension file contents\n",
+};
+let gPublicList;
+
+add_task(async function test_setup() {
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ Assert.ok(profileDir, "profileDir: " + profileDir);
+ for (let [filename, contents] of Object.entries(TESTFILES)) {
+ TESTFILES[filename] = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, filename),
+ contents
+ );
+ }
+ gPublicList = await Downloads.getList(Downloads.PUBLIC);
+});
+
+const TESTCASES = [
+ {
+ name: "Check returned value is null when the download did not succeed",
+ testFile: "download-test.txt",
+ contentType: "text/plain",
+ succeeded: false,
+ expected: null,
+ },
+ {
+ name: "Check correct mime-info is returned when download contentType is unambiguous",
+ testFile: "download-test.txt",
+ contentType: "text/plain",
+ expected: {
+ type: "text/plain",
+ },
+ },
+ {
+ name: "Returns correct mime-info from file extension when download contentType is missing",
+ testFile: "download-test.pdf",
+ contentType: undefined,
+ expected: {
+ type: "application/pdf",
+ },
+ },
+ {
+ name: "Returns correct mime-info from file extension case-insensitively",
+ testFile: "download-test.PDF",
+ contentType: undefined,
+ expected: {
+ type: "application/pdf",
+ },
+ },
+ {
+ name: "Returns null when contentType is missing and file extension is unknown",
+ testFile: "download-test.xxunknown",
+ contentType: undefined,
+ expected: null,
+ },
+ {
+ name: "Returns contentType when contentType is ambiguous and file extension is unknown",
+ testFile: "download-test.xxunknown",
+ contentType: "application/octet-stream",
+ expected: {
+ type: "application/octet-stream",
+ },
+ },
+ {
+ name: "Returns contentType when contentType is ambiguous and there is no file extension",
+ testFile: "download-test",
+ contentType: "application/octet-stream",
+ expected: {
+ type: "application/octet-stream",
+ },
+ },
+ {
+ name: "Returns null when there's no contentType and no file extension",
+ testFile: "download-test",
+ contentType: undefined,
+ expected: null,
+ },
+];
+
+// add tests for each of the generic mime-types we recognize,
+// to ensure they prefer the associated mime-type of the target file extension
+for (let type of [
+ "application/octet-stream",
+ "binary/octet-stream",
+ "application/unknown",
+]) {
+ TESTCASES.push({
+ name: `Returns correct mime-info from file extension when contentType is generic (${type})`,
+ testFile: "download-test.pdf",
+ contentType: type,
+ expected: {
+ type: "application/pdf",
+ },
+ });
+}
+
+for (let testData of TESTCASES) {
+ let tmp = {
+ async [testData.name]() {
+ info("testing with: " + JSON.stringify(testData));
+ await test_getMimeInfo_basic_function(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+/**
+ * Sanity test the DownloadsCommon.getMimeInfo method with test parameters
+ */
+async function test_getMimeInfo_basic_function(testData) {
+ let downloadData = {
+ ...DOWNLOAD_TEMPLATE,
+ source: "source" in testData ? testData.source : DOWNLOAD_TEMPLATE.source,
+ succeeded:
+ "succeeded" in testData
+ ? testData.succeeded
+ : DOWNLOAD_TEMPLATE.succeeded,
+ target: TESTFILES[testData.testFile],
+ contentType: testData.contentType,
+ };
+ Assert.ok(downloadData.target instanceof Ci.nsIFile, "target is a nsIFile");
+ let download = await Downloads.createDownload(downloadData);
+ await gPublicList.add(download);
+ await download.refresh();
+
+ Assert.ok(
+ await IOUtils.exists(download.target.path),
+ "The file should actually exist."
+ );
+ let result = await DownloadsCommon.getMimeInfo(download);
+ if (testData.expected) {
+ Assert.equal(
+ result.type,
+ testData.expected.type,
+ "Got expected mimeInfo.type"
+ );
+ } else {
+ Assert.equal(
+ result,
+ null,
+ `Expected null, got object with type: ${result?.type}`
+ );
+ }
+}
diff --git a/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js b/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js
new file mode 100644
index 0000000000..d965ac264a
--- /dev/null
+++ b/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js
@@ -0,0 +1,147 @@
+const DATA_PDF = atob(
+ "JVBERi0xLjANCjEgMCBvYmo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PmVuZG9iaiAyIDAgb2JqPDwvVHlwZS9QYWdlcy9LaWRzWzMgMCBSXS9Db3VudCAxPj5lbmRvYmogMyAwIG9iajw8L1R5cGUvUGFnZS9NZWRpYUJveFswIDAgMyAzXT4+ZW5kb2JqDQp4cmVmDQowIDQNCjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxMCAwMDAwMCBuDQowMDAwMDAwMDUzIDAwMDAwIG4NCjAwMDAwMDAxMDIgMDAwMDAgbg0KdHJhaWxlcjw8L1NpemUgNC9Sb290IDEgMCBSPj4NCnN0YXJ0eHJlZg0KMTQ5DQolRU9G"
+);
+
+const DOWNLOAD_TEMPLATE = {
+ source: {
+ url: "https://download-test.com/download",
+ },
+ target: {
+ path: "",
+ },
+ contentType: "text/plain",
+ succeeded: DownloadsCommon.DOWNLOAD_FINISHED,
+ canceled: false,
+ error: null,
+ hasPartialData: false,
+ hasBlockedData: false,
+ startTime: new Date(Date.now() - 1000),
+};
+
+const TESTFILES = {
+ "download-test.pdf": DATA_PDF,
+ "download-test.xxunknown": DATA_PDF,
+ "download-test-missing.pdf": null,
+};
+let gPublicList;
+add_task(async function test_setup() {
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ Assert.ok(profileDir, "profileDir: " + profileDir);
+ for (let [filename, contents] of Object.entries(TESTFILES)) {
+ TESTFILES[filename] = await createDownloadedFile(
+ PathUtils.join(gDownloadDir, filename),
+ contents
+ );
+ }
+ gPublicList = await Downloads.getList(Downloads.PUBLIC);
+});
+
+const TESTCASES = [
+ {
+ name: "Null download arg",
+ typeArg: "application/pdf",
+ downloadProps: null,
+ expected: /TypeError/,
+ },
+ {
+ name: "Missing type arg",
+ typeArg: undefined,
+ downloadProps: {
+ target: "download-test.pdf",
+ },
+ expected: /TypeError/,
+ },
+ {
+ name: "Empty string type arg",
+ typeArg: "",
+ downloadProps: {
+ target: "download-test.pdf",
+ },
+ expected: false,
+ },
+ {
+ name: "download succeeded, file exists, unknown extension but contentType matches",
+ typeArg: "application/pdf",
+ downloadProps: {
+ target: "download-test.xxunknown",
+ contentType: "application/pdf",
+ },
+ expected: true,
+ },
+ {
+ name: "download succeeded, file exists, contentType is generic and file extension maps to matching mime-type",
+ typeArg: "application/pdf",
+ downloadProps: {
+ target: "download-test.pdf",
+ contentType: "application/unknown",
+ },
+ expected: true,
+ },
+ {
+ name: "download did not succeed",
+ typeArg: "application/pdf",
+ downloadProps: {
+ target: "download-test.pdf",
+ contentType: "application/pdf",
+ succeeded: false,
+ },
+ expected: false,
+ },
+ {
+ name: "file does not exist",
+ typeArg: "application/pdf",
+ downloadProps: {
+ target: "download-test-missing.pdf",
+ contentType: "application/pdf",
+ },
+ expected: false,
+ },
+ {
+ name: "contentType is missing and file extension doesnt map to a known mime-type",
+ typeArg: "application/pdf",
+ downloadProps: {
+ contentType: undefined,
+ target: "download-test.xxunknown",
+ },
+ expected: false,
+ },
+];
+
+for (let testData of TESTCASES) {
+ let tmp = {
+ async [testData.name]() {
+ info("testing with: " + JSON.stringify(testData));
+ await test_isFileOfType(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+/**
+ * Sanity test the DownloadsCommon.isFileOfType method with test parameters
+ */
+async function test_isFileOfType({ name, typeArg, downloadProps, expected }) {
+ let download, result;
+ if (downloadProps) {
+ let downloadData = {
+ ...DOWNLOAD_TEMPLATE,
+ ...downloadProps,
+ };
+ downloadData.target = TESTFILES[downloadData.target];
+ Assert.ok(downloadData.target instanceof Ci.nsIFile, "target is a nsIFile");
+ download = await Downloads.createDownload(downloadData);
+ await gPublicList.add(download);
+ await download.refresh();
+ }
+
+ if (typeof expected == "boolean") {
+ result = await DownloadsCommon.isFileOfType(download, typeArg);
+ Assert.equal(result, expected, "Expected result from call to isFileOfType");
+ } else {
+ Assert.throws(
+ () => DownloadsCommon.isFileOfType(download, typeArg),
+ expected,
+ "isFileOfType should throw an exception if either the download object or mime-type arguments are falsey"
+ );
+ }
+}
diff --git a/browser/components/downloads/test/unit/test_DownloadsViewableInternally.js b/browser/components/downloads/test/unit/test_DownloadsViewableInternally.js
new file mode 100644
index 0000000000..07925bc7d5
--- /dev/null
+++ b/browser/components/downloads/test/unit/test_DownloadsViewableInternally.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const PREF_SVG_DISABLED = "svg.disabled";
+const PREF_WEBP_ENABLED = "image.webp.enabled";
+const PREF_AVIF_ENABLED = "image.avif.enabled";
+const PDF_MIME = "application/pdf";
+const OCTET_MIME = "application/octet-stream";
+const XML_MIME = "text/xml";
+const SVG_MIME = "image/svg+xml";
+const AVIF_MIME = "image/avif";
+const WEBP_MIME = "image/webp";
+
+const { Integration } = ChromeUtils.importESModule(
+ "resource://gre/modules/Integration.sys.mjs"
+);
+const {
+ DownloadsViewableInternally,
+ PREF_ENABLED_TYPES,
+ PREF_BRANCH_WAS_REGISTERED,
+ PREF_BRANCH_PREVIOUS_ACTION,
+ PREF_BRANCH_PREVIOUS_ASK,
+} = ChromeUtils.importESModule(
+ "resource:///modules/DownloadsViewableInternally.sys.mjs"
+);
+
+/* global DownloadIntegration */
+Integration.downloads.defineESModuleGetter(
+ this,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const HandlerService = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+].getService(Ci.nsIHandlerService);
+const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+function checkPreferInternal(mime, ext, expectedPreferInternal) {
+ const handler = MIMEService.getFromTypeAndExtension(mime, ext);
+ if (expectedPreferInternal) {
+ Assert.equal(
+ handler?.preferredAction,
+ Ci.nsIHandlerInfo.handleInternally,
+ `checking ${mime} preferredAction == handleInternally`
+ );
+ } else {
+ Assert.notEqual(
+ handler?.preferredAction,
+ Ci.nsIHandlerInfo.handleInternally,
+ `checking ${mime} preferredAction != handleInternally`
+ );
+ }
+}
+
+function shouldView(mime, ext) {
+ return DownloadIntegration.shouldViewDownloadInternally(mime, ext);
+}
+
+function checkShouldView(mime, ext, expectedShouldView) {
+ Assert.equal(
+ shouldView(mime, ext),
+ expectedShouldView,
+ `checking ${mime} shouldViewDownloadInternally`
+ );
+}
+
+function checkWasRegistered(ext, expectedWasRegistered) {
+ Assert.equal(
+ Services.prefs.getBoolPref(PREF_BRANCH_WAS_REGISTERED + ext, false),
+ expectedWasRegistered,
+ `checking ${ext} was registered pref`
+ );
+}
+
+function checkAll(mime, ext, expected) {
+ checkPreferInternal(mime, ext, expected && ext != "xml" && ext != "svg");
+ checkShouldView(mime, ext, expected);
+ if (ext != "xml" && ext != "svg") {
+ checkWasRegistered(ext, expected);
+ }
+}
+
+add_task(async function test_viewable_internally() {
+ Services.prefs.setCharPref(PREF_ENABLED_TYPES, "xml , svg,avif,webp");
+ Services.prefs.setBoolPref(PREF_SVG_DISABLED, false);
+ Services.prefs.setBoolPref(PREF_WEBP_ENABLED, true);
+ Services.prefs.setBoolPref(PREF_AVIF_ENABLED, true);
+
+ checkAll(XML_MIME, "xml", false);
+ checkAll(SVG_MIME, "svg", false);
+ checkAll(WEBP_MIME, "webp", false);
+ checkAll(AVIF_MIME, "avif", false);
+
+ DownloadsViewableInternally.register();
+
+ checkAll(XML_MIME, "xml", true);
+ checkAll(SVG_MIME, "svg", true);
+ checkAll(WEBP_MIME, "webp", true);
+ checkAll(AVIF_MIME, "avif", true);
+
+ // Remove webp so it won't be cleared
+ Services.prefs.clearUserPref(PREF_BRANCH_WAS_REGISTERED + "webp");
+
+ // Disable xml, avif and webp, check that avif becomes disabled
+ Services.prefs.setCharPref(PREF_ENABLED_TYPES, "svg");
+
+ // (XML is externally managed, and we just cleared the webp pref)
+ checkAll(XML_MIME, "xml", true);
+ checkPreferInternal(WEBP_MIME, "webp", true);
+
+ // Avif should be disabled
+ checkAll(AVIF_MIME, "avif", false);
+
+ // SVG shouldn't be cleared as it's still enabled
+ checkAll(SVG_MIME, "svg", true);
+
+ Assert.ok(
+ shouldView(PDF_MIME),
+ "application/pdf should be unaffected by pref"
+ );
+ Assert.ok(
+ shouldView(OCTET_MIME, "pdf"),
+ ".pdf should be accepted by extension"
+ );
+ Assert.ok(
+ shouldView(OCTET_MIME, "PDF"),
+ ".pdf should be detected case-insensitively"
+ );
+ Assert.ok(!shouldView(OCTET_MIME, "exe"), ".exe shouldn't be accepted");
+
+ Assert.ok(!shouldView(WEBP_MIME), "imave/webp should be disabled by pref");
+ Assert.ok(!shouldView(AVIF_MIME), "image/avif should be disabled by pref");
+
+ // Enable, check that everything is enabled again
+ Services.prefs.setCharPref(PREF_ENABLED_TYPES, "xml,svg,webp,avif");
+
+ checkAll(XML_MIME, "xml", true);
+ checkAll(SVG_MIME, "svg", true);
+ checkPreferInternal(WEBP_MIME, "webp", true);
+ checkPreferInternal(AVIF_MIME, "avif", true);
+
+ Assert.ok(
+ shouldView(PDF_MIME),
+ "application/pdf should be unaffected by pref"
+ );
+ Assert.ok(shouldView(XML_MIME), "text/xml should be enabled by pref");
+ Assert.ok(
+ shouldView("application/xml"),
+ "alternate MIME type application/xml should be accepted"
+ );
+ Assert.ok(
+ shouldView(OCTET_MIME, "xml"),
+ ".xml should be accepted by extension"
+ );
+
+ // Disable viewable internally, pre-set handlers.
+ Services.prefs.setCharPref(PREF_ENABLED_TYPES, "");
+
+ for (const [mime, ext, action, ask] of [
+ [XML_MIME, "xml", Ci.nsIHandlerInfo.useSystemDefault, true],
+ [SVG_MIME, "svg", Ci.nsIHandlerInfo.saveToDisk, true],
+ [WEBP_MIME, "webp", Ci.nsIHandlerInfo.saveToDisk, false],
+ ]) {
+ let handler = MIMEService.getFromTypeAndExtension(mime, ext);
+ handler.preferredAction = action;
+ handler.alwaysAskBeforeHandling = ask;
+
+ HandlerService.store(handler);
+ checkPreferInternal(mime, ext, false);
+
+ // Expect to read back the same values
+ handler = MIMEService.getFromTypeAndExtension(mime, ext);
+ Assert.equal(handler.preferredAction, action);
+ Assert.equal(handler.alwaysAskBeforeHandling, ask);
+ }
+
+ // Enable viewable internally, SVG and XML should not be replaced, WebP should be saved.
+ Services.prefs.setCharPref(PREF_ENABLED_TYPES, "svg,webp,xml");
+
+ Assert.equal(
+ Services.prefs.prefHasUserValue(PREF_BRANCH_PREVIOUS_ACTION + "svg"),
+ false,
+ "svg action should not be stored"
+ );
+ Assert.equal(
+ Services.prefs.prefHasUserValue(PREF_BRANCH_PREVIOUS_ASK + "svg"),
+ false,
+ "svg ask should not be stored"
+ );
+ Assert.equal(
+ Services.prefs.getIntPref(PREF_BRANCH_PREVIOUS_ACTION + "webp"),
+ Ci.nsIHandlerInfo.saveToDisk,
+ "webp action should be saved"
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref(PREF_BRANCH_PREVIOUS_ASK + "webp"),
+ false,
+ "webp ask should be saved"
+ );
+
+ {
+ let handler = MIMEService.getFromTypeAndExtension(SVG_MIME, "svg");
+ Assert.equal(
+ handler.preferredAction,
+ Ci.nsIHandlerInfo.saveToDisk,
+ "svg action should be preserved"
+ );
+ Assert.equal(
+ !!handler.alwaysAskBeforeHandling,
+ true,
+ "svg ask should be preserved"
+ );
+ // Clean up
+ HandlerService.remove(handler);
+ handler = MIMEService.getFromTypeAndExtension(XML_MIME, "xml");
+ Assert.equal(
+ handler.preferredAction,
+ Ci.nsIHandlerInfo.useSystemDefault,
+ "xml action should be preserved"
+ );
+ Assert.equal(
+ !!handler.alwaysAskBeforeHandling,
+ true,
+ "xml ask should be preserved"
+ );
+ // Clean up
+ HandlerService.remove(handler);
+ }
+ // It should still be possible to view XML internally
+ checkShouldView(XML_MIME, "xml", true);
+
+ checkAll(SVG_MIME, "svg", true);
+ checkAll(WEBP_MIME, "webp", true);
+
+ // Disable SVG to test SVG enabled check (depends on the pref)
+ Services.prefs.setBoolPref(PREF_SVG_DISABLED, true);
+ checkAll(SVG_MIME, "svg", false);
+ Services.prefs.setBoolPref(PREF_SVG_DISABLED, false);
+ {
+ let handler = MIMEService.getFromTypeAndExtension(SVG_MIME, "svg");
+ handler.preferredAction = Ci.nsIHandlerInfo.saveToDisk;
+ handler.alwaysAskBeforeHandling = false;
+ HandlerService.store(handler);
+ }
+
+ checkAll(SVG_MIME, "svg", true);
+
+ // Test WebP enabled check (depends on the pref)
+ Services.prefs.setBoolPref(PREF_WEBP_ENABLED, false);
+ // Should have restored the settings from above
+ {
+ let handler = MIMEService.getFromTypeAndExtension(WEBP_MIME, "webp");
+ Assert.equal(handler.preferredAction, Ci.nsIHandlerInfo.saveToDisk);
+ Assert.equal(!!handler.alwaysAskBeforeHandling, false);
+ // Clean up
+ HandlerService.remove(handler);
+ }
+ checkAll(WEBP_MIME, "webp", false);
+
+ Services.prefs.setBoolPref(PREF_WEBP_ENABLED, true);
+ checkAll(WEBP_MIME, "webp", true);
+
+ Assert.ok(!shouldView(null, "pdf"), "missing MIME shouldn't be accepted");
+ Assert.ok(!shouldView(null, "xml"), "missing MIME shouldn't be accepted");
+ Assert.ok(!shouldView(OCTET_MIME), "unsupported MIME shouldn't be accepted");
+ Assert.ok(!shouldView(OCTET_MIME, "exe"), ".exe shouldn't be accepted");
+});
+
+registerCleanupFunction(() => {
+ // Clear all types to remove any saved values
+ Services.prefs.setCharPref(PREF_ENABLED_TYPES, "");
+ // Reset to the defaults
+ Services.prefs.clearUserPref(PREF_ENABLED_TYPES);
+ Services.prefs.clearUserPref(PREF_SVG_DISABLED);
+ Services.prefs.clearUserPref(PREF_WEBP_ENABLED);
+});
diff --git a/browser/components/downloads/test/unit/xpcshell.ini b/browser/components/downloads/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..9e67834c3e
--- /dev/null
+++ b/browser/components/downloads/test/unit/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+head = head.js
+firefox-appdir = browser
+skip-if = toolkit == 'android' # bug 1730213
+
+[test_DownloadLastDir_basics.js]
+[test_DownloadsCommon_getMimeInfo.js]
+[test_DownloadsCommon_isFileOfType.js]
+[test_DownloadsViewableInternally.js]