diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/downloads | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
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 Binary files differnew file mode 100644 index 0000000000..1cda9a53dc --- /dev/null +++ b/browser/components/downloads/test/browser/blank.JPG 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 Binary files differnew file mode 100644 index 0000000000..04b7f003b4 --- /dev/null +++ b/browser/components/downloads/test/browser/not-really-a-jpeg.jpeg 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] |