diff options
Diffstat (limited to '')
51 files changed, 11556 insertions, 0 deletions
diff --git a/browser/components/downloads/DownloadsCommon.jsm b/browser/components/downloads/DownloadsCommon.jsm new file mode 100644 index 0000000000..45ac56a2ec --- /dev/null +++ b/browser/components/downloads/DownloadsCommon.jsm @@ -0,0 +1,1691 @@ +/* -*- 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DownloadsCommon"]; + +/** + * 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 + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + NetUtil: "resource://gre/modules/NetUtil.jsm", + PluralForm: "resource://gre/modules/PluralForm.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + DownloadHistory: "resource://gre/modules/DownloadHistory.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadUtils: "resource://gre/modules/DownloadUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(this, { + gClipboardHelper: [ + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper", + ], + gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"], +}); + +XPCOMUtils.defineLazyGetter(this, "DownloadsLogger", () => { + let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm"); + let consoleOptions = { + maxLogLevelPref: "browser.download.loglevel", + prefix: "Downloads", + }; + return new ConsoleAPI(consoleOptions); +}); + +const kDownloadsStringBundleUrl = + "chrome://browser/locale/downloads/downloads.properties"; + +const kDownloadsStringsRequiringFormatting = { + sizeWithUnits: true, + statusSeparator: true, + statusSeparatorBeforeNumber: true, +}; + +const kDownloadsStringsRequiringPluralForm = { + otherDownloads3: true, +}; + +const kMaxHistoryResultsForLimitedView = 42; + +const kPrefBranch = Services.prefs.getBranch("browser.download."); + +const kFileExtensions = [ + "aac", + "adt", + "adts", + "accdb", + "accde", + "accdr", + "accdt", + "aif", + "aifc", + "aiff", + "apng", + "aspx", + "avi", + "bat", + "bin", + "bmp", + "cab", + "cda", + "csv", + "dif", + "dll", + "doc", + "docm", + "docx", + "dot", + "dotx", + "eml", + "eps", + "exe", + "flac", + "flv", + "gif", + "htm", + "html", + "ico", + "ini", + "iso", + "jar", + "jfif", + "jpg", + "jpeg", + "json", + "m4a", + "mdb", + "mid", + "midi", + "mov", + "mp3", + "mp4", + "mpeg", + "mpg", + "msi", + "mui", + "oga", + "ogg", + "ogv", + "opus", + "pdf", + "pjpeg", + "pjp", + "png", + "pot", + "potm", + "potx", + "ppam", + "pps", + "ppsm", + "ppsx", + "ppt", + "pptm", + "pptx", + "psd", + "pst", + "pub", + "rar", + "rdf", + "rtf", + "shtml", + "sldm", + "sldx", + "svg", + "swf", + "sys", + "tif", + "tiff", + "tmp", + "txt", + "vob", + "vsd", + "vsdm", + "vsdx", + "vss", + "vssm", + "vst", + "vstm", + "vstx", + "wav", + "wbk", + "webm", + "webp", + "wks", + "wma", + "wmd", + "wmv", + "wmz", + "wms", + "wpd", + "wp5", + "xht", + "xhtml", + "xla", + "xlam", + "xll", + "xlm", + "xls", + "xlsm", + "xlsx", + "xlt", + "xltm", + "xltx", + "xml", + "zip", +]; + +const kGenericContentTypes = [ + "application/octet-stream", + "binary/octet-stream", + "application/unknown", +]; + +const TELEMETRY_EVENT_CATEGORY = "downloads"; + +var PrefObserver = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + getPref(name) { + try { + switch (typeof this.prefs[name]) { + case "boolean": + return kPrefBranch.getBoolPref(name); + } + } catch (ex) {} + return this.prefs[name]; + }, + observe(aSubject, aTopic, aData) { + if (this.prefs.hasOwnProperty(aData)) { + delete this[aData]; + this[aData] = this.getPref(aData); + } + }, + register(prefs) { + this.prefs = prefs; + kPrefBranch.addObserver("", this, true); + for (let key in prefs) { + let name = key; + XPCOMUtils.defineLazyGetter(this, name, function() { + return PrefObserver.getPref(name); + }); + } + }, +}; + +PrefObserver.register({ + // prefName: defaultValue + animateNotifications: true, + openInSystemViewerContextMenuItem: true, + alwaysOpenInSystemViewerContextMenuItem: true, +}); + +// DownloadsCommon + +/** + * This object is exposed directly to the consumers of this JavaScript module, + * and provides shared methods for all the instances of the user interface. + */ +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_WARNING: "warning", + ATTENTION_SEVERE: "severe", + + /** + * 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 if (stringName in kDownloadsStringsRequiringPluralForm) { + strings[stringName] = function(aCount) { + // Convert "arguments" to a real array before calling into XPCOM. + let formattedString = sb.formatStringFromName( + stringName, + Array.from(arguments) + ); + return PluralForm.get(aCount, formattedString); + }; + } else { + strings[stringName] = string.value; + } + } + delete this.strings; + return (this.strings = strings); + }, + + /** + * Indicates whether we should show visual notification on the indicator + * when a download event is triggered. + */ + get animateNotifications() { + return PrefObserver.animateNotifications; + }, + + /** + * Indicates whether or not to show the 'Open in system viewer' context menu item when appropriate + */ + get openInSystemViewerItemEnabled() { + return PrefObserver.openInSystemViewerContextMenuItem; + }, + + /** + * Indicates whether or not to show the 'Always open...' context menu item when appropriate + */ + get alwaysOpenInSystemViewerItemEnabled() { + return PrefObserver.alwaysOpenInSystemViewerContextMenuItem; + }, + + /** + * Get access to one of the DownloadsData, PrivateDownloadsData, or + * HistoryDownloadsData objects, depending on the privacy status of the + * specified window and on whether history downloads should be included. + * + * @param [optional] window + * The browser window which owns the download button. + * If not given, the privacy status will be assumed as non-private. + * @param [optional] history + * True to include history downloads when the window is public. + * @param [optional] privateAll + * Whether to force the public downloads data to be returned together + * with the private downloads data for a private window. + * @param [optional] limited + * True to limit the amount of downloads returned to + * `kMaxHistoryResultsForLimitedView`. + */ + getData(window, history = false, privateAll = false, limited = false) { + let isPrivate = + window && PrivateBrowsingUtils.isContentWindowPrivate(window); + if (isPrivate && !privateAll) { + return PrivateDownloadsData; + } + if (history) { + if (isPrivate && privateAll) { + return LimitedPrivateHistoryDownloadData; + } + return limited ? LimitedHistoryDownloadsData : HistoryDownloadsData; + } + return DownloadsData; + }, + + /** + * Initializes the Downloads back-end and starts receiving events for both the + * private and non-private downloads data objects. + */ + initializeAllDataLinks() { + DownloadsData.initializeDataLink(); + 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 (PrivateBrowsingUtils.isContentWindowPrivate(aWindow)) { + return PrivateDownloadsIndicatorData; + } + return 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 (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) { + // 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 PlacesUtils.history.remove(download.source.url); + } catch (ex) { + Cu.reportError(ex); + } + let list = await Downloads.getList(Downloads.ALL); + await list.remove(download); + await download.finalize(true); + }, + + /** + * 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 = gMIMEService.getTypeFromExtension(fileExtension); + } catch (ex) { + DownloadsCommon.log( + "Cant get mimeType from file extension: ", + fileExtension + ); + } + } + if (!(contentType || fileExtension)) { + return null; + } + let mimeInfo = null; + try { + mimeInfo = 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) { + gClipboardHelper.copyString(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 Downloads.createDownload(download); + } + return download.launch(options).catch(ex => Cu.reportError(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( + 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: + Cu.reportError("Unexpected dialog type: " + dialogType); + return "cancel"; + } + + let message; + switch (verdict) { + case Downloads.Error.BLOCK_VERDICT_UNCOMMON: + message = s.unblockTypeUncommon2; + break; + case Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + message = s.unblockTypePotentiallyUnwanted2; + 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 DownloadsLogger.log.bind(DownloadsLogger); +}); +XPCOMUtils.defineLazyGetter(DownloadsCommon, "error", () => { + return DownloadsLogger.error.bind(DownloadsLogger); +}); + +/** + * Returns true if we are executing on Windows Vista or a later version. + */ +XPCOMUtils.defineLazyGetter(DownloadsCommon, "isWinVistaOrHigher", function() { + let os = Services.appinfo.OS; + if (os != "WINNT") { + return false; + } + return parseFloat(Services.sysinfo.getProperty("version")) >= 6; +}); + +// DownloadsData + +/** + * Retrieves the list of past and completed downloads from the underlying + * Downloads API data, and provides asynchronous notifications allowing to + * build a consistent view of the available data. + * + * Note that using this object does not automatically initialize the list of + * downloads. This is useful to display a neutral progress indicator in + * the main browser window until the autostart timeout elapses. + * + * This powers the DownloadsData, PrivateDownloadsData, and HistoryDownloadsData + * singleton objects. + */ +function DownloadsDataCtor({ isPrivate, isHistory, maxHistoryResults } = {}) { + this._isPrivate = !!isPrivate; + + // Contains all the available Download objects and their integer state. + this.oldDownloadStates = new Map(); + + // 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) { + PrivateDownloadsData.initializeDataLink(); + } + DownloadsData.initializeDataLink(); + this._promiseList = DownloadsData._promiseList.then(() => { + // For history downloads in Private Browsing mode, we'll fetch the combined + // list of public and private downloads. + return DownloadHistory.getList({ + type: isPrivate ? Downloads.ALL : 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 Downloads.getList( + isPrivate ? Downloads.PRIVATE : 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 this.oldDownloadStates.keys(); + }, + + /** + * 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() { + Downloads.getList(this._isPrivate ? Downloads.PRIVATE : Downloads.PUBLIC) + .then(list => list.removeFinished()) + .catch(Cu.reportError); + let indicatorData = this._isPrivate + ? PrivateDownloadsIndicatorData + : DownloadsIndicatorData; + indicatorData.attention = DownloadsCommon.ATTENTION_NONE; + }, + + // Integration with the asynchronous Downloads back-end + + onDownloadAdded(download) { + let extension = download.target.path.split(".").pop(); + + if (!kFileExtensions.includes(extension)) { + extension = "other"; + } + + try { + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + "added", + "fileExtension", + extension, + {} + ); + } catch (ex) { + Cu.reportError( + "DownloadsCommon: error recording telemetry event. " + ex.message + ); + } + + // 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). + DownloadHistory.updateMetaData(download).catch(Cu.reportError); + } + + if ( + download.succeeded || + (download.error && download.error.becauseBlocked) + ) { + this._notifyDownloadEvent("finish"); + } + } + + if (!download.newDownloadNotified) { + download.newDownloadNotified = true; + this._notifyDownloadEvent("start"); + } + }, + + onDownloadRemoved(download) { + this.oldDownloadStates.delete(download); + }, + + // Registration of views + + /** + * Adds an object to be notified when the available download data changes. + * The specified object is initialized with the currently available downloads. + * + * @param aView + * DownloadsView object to be added. This reference must be passed to + * removeView before termination. + */ + addView(aView) { + this._promiseList.then(list => list.addView(aView)).catch(Cu.reportError); + }, + + /** + * Removes an object previously added using addView. + * + * @param aView + * DownloadsView object to be removed. + */ + removeView(aView) { + this._promiseList + .then(list => list.removeView(aView)) + .catch(Cu.reportError); + }, + + // Notifications sent to the most recent browser window only + + /** + * Set to true after the first download causes the downloads panel to be + * displayed. + */ + get panelHasShownBefore() { + try { + return Services.prefs.getBoolPref("browser.download.panel.shown"); + } catch (ex) {} + return false; + }, + + set panelHasShownBefore(aValue) { + Services.prefs.setBoolPref("browser.download.panel.shown", aValue); + return 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 aType + * Set to "start" for new downloads, "finish" for completed downloads, + * "error" for downloads that failed and need attention + */ + _notifyDownloadEvent(aType) { + 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 = BrowserWindowTracker.getTopWindow({ + private: this._isPrivate, + }); + if (!browserWin) { + return; + } + + if (this.panelHasShownBefore && aType != "error") { + // 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. + DownloadsCommon.log("Showing new download notification."); + browserWin.DownloadsIndicatorView.showEventNotification(aType); + return; + } + this.panelHasShownBefore = true; + browserWin.DownloadsPanel.showPanel(); + }, +}; + +XPCOMUtils.defineLazyGetter(this, "HistoryDownloadsData", function() { + return new DownloadsDataCtor({ isHistory: true }); +}); + +XPCOMUtils.defineLazyGetter(this, "LimitedHistoryDownloadsData", function() { + return new DownloadsDataCtor({ + isHistory: true, + maxHistoryResults: kMaxHistoryResultsForLimitedView, + }); +}); + +XPCOMUtils.defineLazyGetter( + this, + "LimitedPrivateHistoryDownloadData", + function() { + return new DownloadsDataCtor({ + isPrivate: true, + isHistory: true, + maxHistoryResults: kMaxHistoryResultsForLimitedView, + }); + } +); + +XPCOMUtils.defineLazyGetter(this, "PrivateDownloadsData", function() { + return new DownloadsDataCtor({ isPrivate: true }); +}); + +XPCOMUtils.defineLazyGetter(this, "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) { + PrivateDownloadsData.addView(this); + } else { + 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) { + PrivateDownloadsData.removeView(this); + } else { + 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.jsm + * 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) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * 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 = { + __proto__: DownloadsViewPrototype, + + /** + * 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 ( + !download.succeeded && + download.error && + download.error.reputationCheckVerdict + ) { + switch (download.error.reputationCheckVerdict) { + case Downloads.Error.BLOCK_VERDICT_UNCOMMON: // fall-through + case Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + case Downloads.Error.BLOCK_VERDICT_INSECURE: + // Existing higher level attention indication trumps ATTENTION_WARNING. + if (this._attention != DownloadsCommon.ATTENTION_SEVERE) { + this.attention = DownloadsCommon.ATTENTION_WARNING; + } + break; + case Downloads.Error.BLOCK_VERDICT_MALWARE: + this.attention = DownloadsCommon.ATTENTION_SEVERE; + break; + default: + this.attention = DownloadsCommon.ATTENTION_SEVERE; + Cu.reportError( + "Unknown reputation verdict: " + + download.error.reputationCheckVerdict + ); + } + } else if (download.succeeded) { + // Existing higher level attention indication trumps ATTENTION_SUCCESS. + if ( + this._attention != DownloadsCommon.ATTENTION_SEVERE && + this._attention != DownloadsCommon.ATTENTION_WARNING + ) { + this.attention = DownloadsCommon.ATTENTION_SUCCESS; + } + } else if (download.error) { + // Existing higher level attention indication trumps ATTENTION_WARNING. + if (this._attention != DownloadsCommon.ATTENTION_SEVERE) { + this.attention = DownloadsCommon.ATTENTION_WARNING; + } + } + }, + + onDownloadChanged(download) { + DownloadsViewPrototype.onDownloadChanged.call(this, download); + this._updateViews(); + }, + + onDownloadRemoved(download) { + this._itemCount--; + 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(); + return aValue; + }, + _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(aValue) { + this._attentionSuppressed = aValue; + this._attention = DownloadsCommon.ATTENTION_NONE; + this._updateViews(); + return aValue; + }, + _attentionSuppressed: false, + + /** + * 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.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 + ? PrivateDownloadsData.downloads + : 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; + } + }, +}; + +XPCOMUtils.defineLazyGetter(this, "PrivateDownloadsIndicatorData", function() { + return new DownloadsIndicatorDataCtor(true); +}); + +XPCOMUtils.defineLazyGetter(this, "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 = { + __proto__: DownloadsViewPrototype, + + /** + * 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) { + 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() + ); + + this._description = DownloadsCommon.strings.otherDownloads3( + 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] = DownloadUtils.getDownloadStatusNoRate( + summary.totalTransferred, + summary.totalSize, + summary.slowestSpeed, + this._lastTimeLeft + ); + } + }, +}; diff --git a/browser/components/downloads/DownloadsMacFinderProgress.jsm b/browser/components/downloads/DownloadsMacFinderProgress.jsm new file mode 100644 index 0000000000..589c027706 --- /dev/null +++ b/browser/components/downloads/DownloadsMacFinderProgress.jsm @@ -0,0 +1,88 @@ +/* -*- 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. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DownloadsMacFinderProgress"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Downloads: "resource://gre/modules/Downloads.jsm", +}); + +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(); + Downloads.getList(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(Cu.reportError); + download.removePartialData().catch(Cu.reportError); + }); + + 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/DownloadsSubview.jsm b/browser/components/downloads/DownloadsSubview.jsm new file mode 100644 index 0000000000..e58e3cce07 --- /dev/null +++ b/browser/components/downloads/DownloadsSubview.jsm @@ -0,0 +1,631 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["DownloadsSubview"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadsCommon: "resource:///modules/DownloadsCommon.jsm", + DownloadsViewUI: "resource:///modules/DownloadsViewUI.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); + +let gPanelViewInstances = new WeakMap(); +const kRefreshBatchSize = 10; +const kMaxWaitForIdleMs = 200; +XPCOMUtils.defineLazyGetter(this, "kButtonLabels", () => { + return { + show: + DownloadsCommon.strings[ + AppConstants.platform == "macosx" ? "showMacLabel" : "showLabel" + ], + open: DownloadsCommon.strings.openFileLabel, + retry: DownloadsCommon.strings.retryLabel, + }; +}); + +class DownloadsSubview extends DownloadsViewUI.BaseView { + constructor(panelview) { + super(); + this.document = panelview.ownerDocument; + this.window = panelview.ownerGlobal; + + this.context = "panelDownloadsContextMenu"; + + this.panelview = panelview; + this.container = this.document.getElementById("panelMenu_downloadsMenu"); + while (this.container.lastChild) { + this.container.lastChild.remove(); + } + this.panelview.addEventListener("click", DownloadsSubview.onClick); + this.panelview.addEventListener( + "ViewHiding", + DownloadsSubview.onViewHiding + ); + + this._viewItemsForDownloads = new WeakMap(); + + let contextMenu = this.document.getElementById(this.context); + if (!contextMenu) { + contextMenu = this.document + .getElementById("downloadsContextMenu") + .cloneNode(true); + contextMenu.setAttribute("closemenu", "none"); + contextMenu.setAttribute("id", this.context); + contextMenu.removeAttribute("onpopupshown"); + contextMenu.setAttribute( + "onpopupshowing", + "DownloadsSubview.updateContextMenu(document.popupNode, this);" + ); + contextMenu.setAttribute( + "onpopuphidden", + "DownloadsSubview.onContextMenuHidden(this);" + ); + let clearButton = contextMenu.querySelector( + "menuitem[command='downloadsCmd_clearDownloads']" + ); + clearButton.hidden = false; + clearButton.previousElementSibling.hidden = true; + contextMenu + .querySelector("menuitem[command='cmd_delete']") + .setAttribute("command", "downloadsCmd_delete"); + } + this.panelview.appendChild(contextMenu); + this.container.setAttribute("context", this.context); + + this._downloadsData = DownloadsCommon.getData( + this.window, + true, + true, + true + ); + this._downloadsData.addView(this); + } + + destructor(event) { + this.panelview.removeEventListener("click", DownloadsSubview.onClick); + this.panelview.removeEventListener( + "ViewHiding", + DownloadsSubview.onViewHiding + ); + this._downloadsData.removeView(this); + gPanelViewInstances.delete(this); + this.destroyed = true; + } + + /** + * DataView handler; invoked when a batch of downloads is being passed in - + * usually when this instance is added as a view in the constructor. + */ + onDownloadBatchStarting() { + this.window.clearTimeout(this._batchTimeout); + } + + /** + * DataView handler; invoked when the view stopped feeding its current list of + * downloads. + */ + onDownloadBatchEnded() { + let { window } = this; + window.clearTimeout(this._batchTimeout); + // If there are no downloads to display, wait a bit to dispatch the load + // completion event, because another batch may start right away. + this._batchTimeout = window.setTimeout( + () => { + this._updateStatsFromDisk(); + this.panelview.dispatchEvent(new window.CustomEvent("DownloadsLoaded")); + }, + this.container.childElementCount ? 0 : 200 + ); + } + + /** + * DataView handler; invoked when a new download is added to the list. + * + * @param {Download} download + * @param {DOMNode} [options.insertBefore] + */ + onDownloadAdded(download, { insertBefore } = {}) { + let element = this.document.createXULElement("hbox"); + let shell = new DownloadsSubview.Button(download, element); + 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", element); + } else { + this.container.prepend(element); + } + + // After connecting to the document, trigger the code that updates all + // attributes to match the current state of the downloads. + shell.ensureActive(); + } + + /** + * DataView Handler; invoked when the state of a download changed. + * + * @param {Download} download + */ + onDownloadChanged(download) { + this._viewItemsForDownloads.get(download).onChanged(); + } + + /** + * DataView handler; invoked when a download is removed. + * + * @param {Download} download + */ + onDownloadRemoved(download) { + this._viewItemsForDownloads.get(download).element.remove(); + } + + /** + * Schedule a refresh of the downloads that were added, which is mainly about + * checking whether the target file still exists. + * We're doing this during idle time and in chunks. + */ + async _updateStatsFromDisk() { + if (this._updatingStats) { + return; + } + + this._updatingStats = true; + + try { + let idleOptions = { timeout: kMaxWaitForIdleMs }; + // Start with getting an idle moment to (maybe) refresh the list of downloads. + await new Promise( + resolve => this.window.requestIdleCallback(resolve), + idleOptions + ); + // In the meantime, this instance could have been destroyed, so take note. + if (this.destroyed) { + return; + } + + let count = 0; + for (let button of this.container.children) { + if (this.destroyed) { + return; + } + if (!button._shell) { + continue; + } + + await button._shell.refresh(); + + // Make sure to request a new idle moment every `kRefreshBatchSize` buttons. + if (++count % kRefreshBatchSize === 0) { + await new Promise(resolve => + this.window.requestIdleCallback(resolve, idleOptions) + ); + } + } + } catch (ex) { + Cu.reportError(ex); + } finally { + this._updatingStats = false; + } + } + + // ----- Static methods. ----- + + /** + * Show the Downloads subview panel and listen for events that will trigger + * building the dynamic part of the view. + * + * @param {DOMNode} anchor The button that was commanded to trigger this function. + */ + static show(anchor) { + let document = anchor.ownerDocument; + let window = anchor.ownerGlobal; + + let panelview = document.getElementById("PanelUI-downloads"); + anchor.setAttribute("closemenu", "none"); + gPanelViewInstances.set(panelview, new DownloadsSubview(panelview)); + + // Since the DownloadsLists are propagated asynchronously, we need to wait a + // little to get the view propagated. + panelview.addEventListener( + "ViewShowing", + event => { + event.detail.addBlocker( + new Promise(resolve => { + panelview.addEventListener("DownloadsLoaded", resolve, { + once: true, + }); + }) + ); + }, + { once: true } + ); + + window.PanelUI.showSubView("PanelUI-downloads", anchor); + } + + /** + * Handler method; reveal the users' download directory using the OS specific + * method. + */ + static async onShowDownloads() { + // Retrieve the user's default download directory. + let preferredDir = await Downloads.getPreferredDownloadsDirectory(); + DownloadsCommon.showDirectory(new FileUtils.File(preferredDir)); + } + + /** + * Handler method; clear the list downloads finished and old(er) downloads, + * just like in the Library. + * + * @param {DOMNode} button Button that was clicked to call this method. + */ + static onClearDownloads(button) { + let instance = gPanelViewInstances.get(button.closest("panelview")); + if (!instance) { + return; + } + instance._downloadsData.removeFinished(); + PlacesUtils.history + .removeVisitsByFilter({ + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + }) + .catch(Cu.reportError); + } + + /** + * Just before showing the context menu, anchored to a download item, we need + * to set the right properties to make sure the right menu-items are visible. + * + * @param {DOMNode} button The Button the context menu will be anchored to. + * @param {DOMNode} menu The context menu. + */ + static updateContextMenu(button, menu) { + while (!button._shell) { + button = button.parentNode; + } + let download = button._shell.download; + let mimeInfo = DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault } = mimeInfo ? mimeInfo : {}; + + menu.setAttribute("state", button.getAttribute("state")); + if (button.hasAttribute("exists")) { + menu.setAttribute("exists", button.getAttribute("exists")); + } else { + menu.removeAttribute("exists"); + } + menu.classList.toggle( + "temporary-block", + button.classList.contains("temporary-block") + ); + // menu items are conditionally displayed via CSS based on a viewable-internally attribute + DownloadsCommon.log( + "DownloadsSubview, updateContextMenu, download is viewable internally? ", + download.target.path, + button.hasAttribute("viewable-internally") + ); + if (button.hasAttribute("viewable-internally")) { + menu.setAttribute("viewable-internally", "true"); + let alwaysUseSystemViewerItem = menu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + } + alwaysUseSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.alwaysOpenInSystemViewerItemEnabled + ); + let useSystemViewerItem = menu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + useSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.openInSystemViewerItemEnabled + ); + } else { + menu.removeAttribute("viewable-internally"); + } + + for (let menuitem of menu.getElementsByTagName("menuitem")) { + let command = menuitem.getAttribute("command"); + if (!command) { + continue; + } + if (command == "downloadsCmd_clearDownloads") { + menuitem.disabled = !DownloadsSubview.canClearDownloads(button); + } else { + menuitem.disabled = !button._shell.isCommandEnabled(command); + } + } + + // The menu anchorNode property is not available long enough to be used elsewhere, + // so tack it another property name. + menu._anchorNode = button; + } + + /** + * Right after the context menu was hidden, perform a bit of cleanup. + * + * @param {DOMNode} menu The context menu. + */ + static onContextMenuHidden(menu) { + delete menu._anchorNode; + } + + /** + * Static version of DownloadsSubview#canClearDownloads(). + * + * @param {DOMNode} button Button that we'll use to find the right + * DownloadsSubview instance. + */ + static canClearDownloads(button) { + let instance = gPanelViewInstances.get(button.closest("panelview")); + if (!instance) { + return false; + } + return instance.canClearDownloads(instance.container); + } + + /** + * Handler method; invoked when the Downloads panel is hidden and should be + * torn down & cleaned up. + * + * @param {DOMEvent} event + */ + static onViewHiding(event) { + let instance = gPanelViewInstances.get(event.target); + if (!instance) { + return; + } + instance.destructor(event); + } + + /** + * Handler method; invoked when anything is clicked inside the Downloads panel. + * Depending on the context, it will find the appropriate command to invoke. + * + * We don't have a command dispatcher registered for this view, so we don't go + * through the goDoCommand path like we do for the other views. + * + * @param {DOMMouseEvent} event + */ + static onClick(event) { + // Handle left & middle clicks with any key modifiers + if (event.button > 1) { + return; + } + + let button = event.target.closest( + ".subviewbutton,toolbarbutton,menuitem,panelview" + ); + if (!button || button.localName == "panelview") { + return; + } + + let item = button.closest(".subviewbutton.download"); + + let command = "downloadsCmd_open"; + let openWhere; + if (button.classList.contains("action-button")) { + command = item.hasAttribute("canShow") + ? "downloadsCmd_show" + : "downloadsCmd_retry"; + } else if (button.localName == "menuitem") { + command = button.getAttribute("command"); + if (command == "downloadsCmd_clearDownloads") { + DownloadsSubview.onClearDownloads(button); + return; + } + item = button.parentNode._anchorNode; + } + if ( + command == "downloadsCmd_open" && + (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 topWindow = BrowserWindowTracker.getTopWindow(); + openWhere = topWindow.whereToOpenLink(event, false, true); + } + if (item && item._shell.isCommandEnabled(command)) { + item._shell[command](openWhere); + } + } +} + +/** + * 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 gDownloadsSubviewItemFragments = new WeakMap(); + +DownloadsSubview.Button = class extends DownloadsViewUI.DownloadElementShell { + constructor(download, element) { + super(); + this.download = download; + this.element = element; + this.element._shell = this; + + this.element.classList.add( + "subviewbutton", + "subviewbutton-iconic", + "download", + "download-state", + "navigable" + ); + + let hover = event => { + if (event.originalTarget.classList.contains("action-button")) { + this.element.classList.toggle( + "downloadHoveringButton", + event.type == "mouseover" + ); + } + }; + this.element.addEventListener("mouseover", hover); + this.element.addEventListener("mouseout", hover); + } + + get browserWindow() { + return this.element.ownerGlobal; + } + + async refresh() { + if (this._targetFileChecked) { + return; + } + + try { + await this.download.refresh(); + } catch (ex) { + Cu.reportError(ex); + } finally { + this._targetFileChecked = true; + } + } + + /** + * Handle state changes of a download. + */ + onStateChanged() { + // Since the state changed, we may need to check the target file again. + this._targetFileChecked = false; + + this._updateState(); + } + + /** + * Handler method; invoked when any state attribute of a download changed. + */ + onChanged() { + let newState = DownloadsCommon.stateOfDownload(this.download); + if (this._downloadState !== newState) { + this._downloadState = newState; + this.onStateChanged(); + } else { + this._updateState(); + } + } + + // DownloadElementShell + connect() { + let document = this.element.ownerDocument; + let downloadsSubviewItemFragment = gDownloadsSubviewItemFragments.get( + document + ); + if (!downloadsSubviewItemFragment) { + let MozXULElement = document.defaultView.MozXULElement; + downloadsSubviewItemFragment = MozXULElement.parseXULToFragment(` + <image class="toolbarbutton-icon" validate="always"/> + <vbox class="toolbarbutton-text" flex="1"> + <label crop="end"/> + <label class="status-text status-full" crop="end"/> + <label class="status-text status-open" crop="end"/> + <label class="status-text status-retry" crop="end"/> + <label class="status-text status-show" crop="end"/> + </vbox> + <toolbarbutton class="action-button"/> + `); + gDownloadsSubviewItemFragments.set( + document, + downloadsSubviewItemFragment + ); + } + this.element.appendChild(downloadsSubviewItemFragment.cloneNode(true)); + for (let [propertyName, selector] of [ + ["_downloadTypeIcon", ".toolbarbutton-icon"], + ["_downloadTarget", "label"], + ["_downloadStatus", ".status-full"], + ["_downloadButton", ".action-button"], + ]) { + this[propertyName] = this.element.querySelector(selector); + } + + for (let [label, selector] of [ + [kButtonLabels.open, ".status-open"], + [kButtonLabels.retry, ".status-retry"], + [kButtonLabels.show, ".status-show"], + ]) { + this.element.querySelector(selector).value = label; + } + } + + // DownloadElementShell + showDisplayNameAndIcon(displayName, icon) { + this._downloadTarget.value = displayName; + this._downloadTypeIcon.src = icon; + } + + // DownloadElementShell + showProgress() {} + + // DownloadElementShell + showStatus(status) { + this._downloadStatus.value = status; + this.element.tooltipText = status; + } + + // DownloadElementShell + showButton() {} + + // DownloadElementShell + hideButton() {} + + // DownloadElementShell + _updateState() { + // This view only show completed and failed downloads. + let state = DownloadsCommon.stateOfDownload(this.download); + let shouldDisplay = + state == DownloadsCommon.DOWNLOAD_FINISHED || + state == DownloadsCommon.DOWNLOAD_FAILED; + this.element.hidden = !shouldDisplay; + if (!shouldDisplay) { + return; + } + + super._updateState(); + + if (this.isCommandEnabled("downloadsCmd_show")) { + this.element.setAttribute("canShow", "true"); + this.element.removeAttribute("canRetry"); + } else if (this.isCommandEnabled("downloadsCmd_retry")) { + this.element.setAttribute("canRetry", "true"); + this.element.removeAttribute("canShow"); + } else { + this.element.removeAttribute("canRetry"); + this.element.removeAttribute("canShow"); + } + } + + // DownloadElementShell + _updateStateInner() { + if (!this.element.hidden) { + super._updateStateInner(); + } + } + + /** + * Command handler; copy the download URL to the OS general clipboard. + */ + downloadsCmd_copyLocation() { + DownloadsCommon.copyDownloadLink(this.download); + } +}; diff --git a/browser/components/downloads/DownloadsTaskbar.jsm b/browser/components/downloads/DownloadsTaskbar.jsm new file mode 100644 index 0000000000..0096ebcafb --- /dev/null +++ b/browser/components/downloads/DownloadsTaskbar.jsm @@ -0,0 +1,221 @@ +/* -*- 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. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DownloadsTaskbar"]; + +// Globals + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "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(this, "gMacTaskbarProgress", function() { + return ( + "@mozilla.org/widget/macdocksupport;1" in Cc && + Cc["@mozilla.org/widget/macdocksupport;1"].getService(Ci.nsITaskbarProgress) + ); +}); + +XPCOMUtils.defineLazyGetter(this, "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. + */ +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 (gMacTaskbarProgress) { + // On Mac OS X, we have to register the global indicator only once. + this._taskbarProgress = gMacTaskbarProgress; + // Free the XPCOM reference on shutdown, to prevent detecting a leak. + Services.obs.addObserver(() => { + this._taskbarProgress = null; + gMacTaskbarProgress = null; + }, "quit-application-granted"); + } else if (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 (gGtkTaskbarProgress) { + this._taskbarProgress = 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) { + Downloads.getSummary(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(Cu.reportError); + } + }, + + /** + * 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 = 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 = 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 = 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.jsm b/browser/components/downloads/DownloadsViewUI.jsm new file mode 100644 index 0000000000..9b3f7afb10 --- /dev/null +++ b/browser/components/downloads/DownloadsViewUI.jsm @@ -0,0 +1,883 @@ +/* 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. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DownloadsViewUI"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadUtils: "resource://gre/modules/DownloadUtils.jsm", + DownloadsCommon: "resource:///modules/DownloadsCommon.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + OS: "resource://gre/modules/osfile.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "handlerSvc", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +const { Integration } = ChromeUtils.import( + "resource://gre/modules/Integration.jsm" +); + +/* global DownloadIntegration */ +Integration.downloads.defineModuleGetter( + this, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.jsm" +); + +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", + descriptionL10nId: "downloads-cmd-show-description", + panelL10nId: "downloads-cmd-show-panel", + 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(); + +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_"); + }, + + /** + * 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) { + return download.target.path + ? OS.Path.basename(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] = DownloadUtils.convertByteUnits(download.target.size); + return DownloadsCommon.strings.sizeWithUnits(size, unit); + }, +}; + +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.jsm 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. + // E.g. onDownloadClick() relies on brittle logic and performs/prevents + // actions based on the check if originaltarget was not a button. + if (!downloadListItemFragment) { + let MozXULElement = document.defaultView.MozXULElement; + downloadListItemFragment = MozXULElement.parseXULToFragment(` + <hbox class="downloadMainArea" flex="1" align="center"> + <stack> + <image class="downloadTypeIcon" validate="always"/> + <image class="downloadBlockedBadge" /> + </stack> + <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> + </hbox> + <toolbarseparator /> + <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 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) { + 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) { + this._downloadDetailsNormal.setAttribute("value", status); + this._downloadDetailsNormal.setAttribute("tooltiptext", status); + if (hoverStatus && hoverStatus.l10n) { + let document = this.element.ownerDocument; + document.l10n.setAttributes(this._downloadDetailsHover, hoverStatus.l10n); + } 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) { + let [displayHost] = DownloadUtils.getURIHost(this.download.source.url); + let [displayDate] = DownloadUtils.getReadableDates( + new Date(this.download.endTime) + ); + + let firstPart = DownloadsCommon.strings.statusSeparator( + stateLabel, + displayHost + ); + let fullStatus = 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.setAttribute("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", + 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"); + } + + // 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; + + 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] = DownloadUtils.getDownloadStatus( + this.download.currentBytes, + totalBytes, + this.download.speed, + this.lastEstimatedSecondsLeft + ); + this.lastEstimatedSecondsLeft = newEstimatedSecondsLeft; + this.showStatus(status); + } 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.succeeded) { + 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", + DownloadIntegration.shouldViewDownloadInternally( + 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 = DownloadsCommon.strings.stateCompleted; + if (sizeWithUnits) { + status = DownloadsCommon.strings.statusSeparator( + status, + sizeWithUnits + ); + } + this.showStatus(status, { l10n: "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 || 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.element.removeAttribute("exists"); + let label = DownloadsCommon.strings.fileMovedOrMissing; + this.showStatusWithDetails(label, label); + this.hideButton(); + } + } else if (this.download.error) { + if (this.download.error.becauseBlockedByParentalControls) { + // This download was blocked permanently by parental controls. + this.showStatusWithDetails( + 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: "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 Downloads.Error.BLOCK_VERDICT_UNCOMMON: + case Downloads.Error.BLOCK_VERDICT_INSECURE: + case 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; + 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(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 = DownloadUtils.getTransferTotal( + this.download.currentBytes, + totalBytes + ); + this.showStatus( + DownloadsCommon.strings.statusSeparatorBeforeNumber( + DownloadsCommon.strings.statePaused, + transfer + ) + ); + this.showButton("cancel"); + progressPaused = true; + } else { + // This download was canceled. + this.showStatusWithDetails(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(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. + */ + get rawBlockedTitleAndDetails() { + let s = DownloadsCommon.strings; + if ( + !this.download.error || + !this.download.error.becauseBlockedByReputationCheck + ) { + return [null, null]; + } + switch (this.download.error.reputationCheckVerdict) { + case Downloads.Error.BLOCK_VERDICT_UNCOMMON: + return [s.blockedUncommon2, [s.unblockTypeUncommon2, s.unblockTip2]]; + case Downloads.Error.BLOCK_VERDICT_INSECURE: + return [ + s.blockedPotentiallyInsecure, + [s.unblockInsecure, s.unblockTip2], + ]; + case Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + return [ + s.blockedPotentiallyUnwanted, + [s.unblockTypePotentiallyUnwanted2, s.unblockTip2], + ]; + case Downloads.Error.BLOCK_VERDICT_MALWARE: + return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]]; + } + throw new Error( + "Unexpected reputationCheckVerdict: " + + this.download.error.reputationCheckVerdict + ); + }, + + /** + * 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) { + 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(Cu.reportError); + }, + + /** + * 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 (DownloadsCommon.stateOfDownload(this.download)) { + case DownloadsCommon.DOWNLOAD_NOTSTARTED: + return "downloadsCmd_cancel"; + case DownloadsCommon.DOWNLOAD_FAILED: + case DownloadsCommon.DOWNLOAD_CANCELED: + return "downloadsCmd_retry"; + case DownloadsCommon.DOWNLOAD_PAUSED: + return "downloadsCmd_pauseResume"; + case DownloadsCommon.DOWNLOAD_FINISHED: + return "downloadsCmd_open"; + case DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL: + return "downloadsCmd_openReferrer"; + case 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": + // This property is false if the download did not succeed. + return this.download.target.exists; + case "downloadsCmd_show": + let { target } = this.download; + return 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 DownloadIntegration.shouldViewDownloadInternally( + 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(Cu.reportError); + }, + + downloadsCmd_confirmBlock() { + this.download.confirmBlock().catch(Cu.reportError); + }, + + downloadsCmd_open(openWhere = "tab") { + 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 FileUtils.File(this.download.target.path); + 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 + ? OS.Path.basename(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() { + DownloadsCommon.deleteDownload(this.download).catch(Cu.reportError); + }, + + downloadsCmd_openInSystemViewer() { + // For this interaction only, pass a flag to override the preferredAction for this + // mime-type and open using the system viewer + DownloadsCommon.openDownload(this.download, { + useSystemDefault: true, + }).catch(Cu.reportError); + }, + + 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 = 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 + 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 { + 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; + } + handlerSvc.store(mimeInfo); + DownloadsCommon.openDownload(this.download).catch(Cu.reportError); + }, +}; diff --git a/browser/components/downloads/DownloadsViewableInternally.jsm b/browser/components/downloads/DownloadsViewableInternally.jsm new file mode 100644 index 0000000000..fca951f05c --- /dev/null +++ b/browser/components/downloads/DownloadsViewableInternally.jsm @@ -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. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "DownloadsViewableInternally", + "PREF_ENABLED_TYPES", + "PREF_BRANCH_WAS_REGISTERED", + "PREF_BRANCH_PREVIOUS_ACTION", + "PREF_BRANCH_PREVIOUS_ASK", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyServiceGetter( + this, + "HandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "MIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +ChromeUtils.defineModuleGetter( + this, + "Integration", + "resource://gre/modules/Integration.jsm" +); + +const PREF_BRANCH = "browser.download.viewableInternally."; +const PREF_ENABLED_TYPES = PREF_BRANCH + "enabledTypes"; +const PREF_BRANCH_WAS_REGISTERED = PREF_BRANCH + "typeWasRegistered."; +const PREF_BRANCH_PREVIOUS_ACTION = + PREF_BRANCH + "previousHandler.preferredAction."; +const PREF_BRANCH_PREVIOUS_ASK = + PREF_BRANCH + "previousHandler.alwaysAskBeforeHandling."; + +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 + 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, + }, + { + 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() + }, + { + 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() { + // NOTE: This does not handle MOZ_AV1, which determines whether + // the browser is built with AV1, and therefore AVIF, support. + // When image.avif.enabled is set true by default in + // StaticPrefList.yaml, it should be wrapped in #ifdef MOZ_AV1 + XPCOMUtils.defineLazyPreferenceGetter( + this, + "available", + "image.avif.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); + HandlerService.store(handlerInfo); + } else { + // Nothing to restore, just remove the handler. + 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 (!HandlerService.exists(fakeHandlerInfo)) { + HandlerService.store(fakeHandlerInfo); + } else { + const handlerInfo = 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; + + 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 = 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..f2f6e7fa96 --- /dev/null +++ b/browser/components/downloads/content/allDownloadsView.js @@ -0,0 +1,952 @@ +/* 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.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadsCommon: "resource:///modules/DownloadsCommon.jsm", + DownloadsViewUI: "resource:///modules/DownloadsViewUI.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", + OS: "resource://gre/modules/osfile.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.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 = { + __proto__: DownloadsViewUI.DownloadElementShell.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.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(Cu.reportError) + .then(() => { + // Do not try to check for existence again even if this failed. + this._targetFileChecked = true; + }); + } + }, +}; + +/** + * 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) { + 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); + + // Get the Download button out of the attention state since we're about to + // view all downloads. + DownloadsCommon.getIndicatorData(window).attention = + DownloadsCommon.ATTENTION_NONE; + + // Make sure to unregister the view if the window is closed. + window.addEventListener( + "unload", + () => { + window.controllers.removeController(this); + this._downloadsData.removeView(this); + this.result = null; + }, + true + ); + // Resizing the window may change items visibility. + window.addEventListener( + "resize", + () => { + this._ensureVisibleElementsAreActive(true); + }, + true + ); +} + +DownloadsPlacesView.prototype = { + __proto__: DownloadsViewUI.BaseView.prototype, + + get associatedElement() { + return this._richlistbox; + }, + + get active() { + return this._active; + }, + set active(val) { + this._active = val; + if (this._active) { + this._ensureVisibleElementsAreActive(true); + } + return this._active; + }, + + /** + * 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(); + } + return (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": + 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 => element._shell.download.source.url + ); + + 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/unicode"]; + 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(Cu.reportError); + } + // 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; + } + + // Set the state attribute so that only the appropriate items are displayed. + let contextMenu = document.getElementById("downloadsContextMenu"); + let download = element._shell.download; + let mimeInfo = DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault } = mimeInfo ? mimeInfo : {}; + + contextMenu.setAttribute( + "state", + DownloadsCommon.stateOfDownload(download) + ); + contextMenu.setAttribute("exists", "true"); + contextMenu.classList.toggle("temporary-block", !!download.hasBlockedData); + + if (element.hasAttribute("viewable-internally")) { + contextMenu.setAttribute("viewable-internally", "true"); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + } + alwaysUseSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.alwaysOpenInSystemViewerItemEnabled + ); + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + useSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.openInSystemViewerItemEnabled + ); + } else { + contextMenu.removeAttribute("viewable-internally"); + } + + 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); + } + }, +}; + +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("downloadsRichListBox"); + 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..f42e577503 --- /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/. */ + +#downloadsRichListBox:not(:empty) + #downloadsListEmptyDescription, +#downloadsRichListBox: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..9637e7988f --- /dev/null +++ b/browser/components/downloads/content/contentAreaDownloadsView.js @@ -0,0 +1,32 @@ +/* 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.import( + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); + +var ContentAreaDownloadsView = { + init() { + let box = document.getElementById("downloadsRichListBox"); + box.addEventListener( + "InitialDownloadsLoaded", + () => { + // Set focus to Downloads list once it is created + document.getElementById("downloadsRichListBox").focus(); + }, + { once: true } + ); + let view = new DownloadsPlacesView(box); + // 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..f6e88122a7 --- /dev/null +++ b/browser/components/downloads/content/contentAreaDownloadsView.xhtml @@ -0,0 +1,54 @@ +<?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 [ +<!ENTITY % editMenuDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuDTD; +]> + +<!-- @CSP: We have to whitelist the 'oncommand' handler for all the cmd_* fields within + - editMenuOverlay.js until Bug 371900 is fixed using + - sha512-4o5Uf4E4EG+90Mb820FH2YFDf4IuX4bfUwQC7reK1ZhgcXWJBKMK2330XIELaFJJ8HiPffS9mP60MPjuXMIrHA== + --> +<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:; script-src chrome: 'sha512-4o5Uf4E4EG+90Mb820FH2YFDf4IuX4bfUwQC7reK1ZhgcXWJBKMK2330XIELaFJJ8HiPffS9mP60MPjuXMIrHA=='; 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="downloadsRichListBox" + 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..1819ffbda4 --- /dev/null +++ b/browser/components/downloads/content/downloads.css @@ -0,0 +1,172 @@ +/* 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 ***/ + +#downloadsRichListBox > 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] > toolbarseparator, +.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, + +.download-state:not([state="0"] /* Downloading */) + .downloadPauseMenuItem, + +.download-state:not([state="4"] /* Paused */) + .downloadResumeMenuItem, + +/* Blocked (dirty) downloads that have not been confirmed and + have temporary data. */ +.download-state:not([state="8"] /* Blocked (dirty) */) + .downloadUnblockMenuItem, + +.download-state[state="8"]:not(.temporary-block) .downloadUnblockMenuItem, + +.download-state:not([state="1"], /* Finished */ + [state="2"], /* Failed */ + [state="3"], /* Canceled */ + [state="6"], /* Blocked (parental) */ + [state="8"], /* Blocked (dirty) */ + [state="9"] /* Blocked (policy) */) + .downloadRemoveFromHistoryMenuItem, + +.download-state:not([state="-1"],/* Starting (initial) */ + [state="0"], /* Downloading */ + [state="1"], /* Finished */ + [state="4"], /* Paused */ + [state="5"] /* Starting (queued) */) + .downloadShowMenuItem, + +.download-state[state="1"]:not([exists]) .downloadShowMenuItem, + +.download-state:not([state="-1"],/* Starting (initial) */ + [state="0"], /* Downloading */ + [state="1"], /* Finished */ + [state="4"], /* Paused */ + [state="5"], /* Starting (queued) */ + [state="8"] /* Blocked (dirty) */) + .downloadCommandsSeparator, + +.download-state[state="1"]:not([exists]) .downloadCommandsSeparator, + +.download-state[state="8"]:not(.temporary-block) .downloadCommandsSeparator, + +/* the system-viewer context menu items are only shown for certain mime-types + and can be individually enabled via prefs */ +.download-state:not([viewable-internally]) .downloadUseSystemDefaultMenuItem, +.download-state .downloadUseSystemDefaultMenuItem:not([enabled]), +.download-state .downloadAlwaysUseSystemDefaultMenuItem:not([enabled]), +.download-state:not([viewable-internally]) .downloadAlwaysUseSystemDefaultMenuItem { + 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; +} + +/* DownloadsSubview styles: */ + +/* Hide all status labels by default and selectively display one at a time, + depending on the state of the Download. */ +.subviewbutton.download > .toolbarbutton-text > .status-text, +/* When a Download is not hovered at all, hide the secondary action button. */ +.subviewbutton.download:not(:hover) > .action-button, +/* Always hide the label of the secondary action button. */ +.subviewbutton.download > .action-button > .toolbarbutton-text { + display: none; +} + +/* When a Download is _not_ hovered, display the full status message. */ +.subviewbutton.download:not(:hover) > .toolbarbutton-text > .status-full, +/* When a Download is hovered when the file doesn't exist and cannot be retried, + keep showing the full status message. */ +.subviewbutton.download:hover:is(:not([canShow]),:not([exists])):not([canRetry]) > .toolbarbutton-text > .status-full, +/* When a Download is hovered and the it can be retried, but the action button + is _not_ hovered, keep showing the full status message. */ +.subviewbutton.download:hover[canRetry]:not(.downloadHoveringButton) > .toolbarbutton-text > .status-full, +/* When a Download is hovered and the file can be opened, but the action button + is _not_ hovered, show the 'Open File' status label. */ +.subviewbutton.download:hover[canShow][exists]:not(.downloadHoveringButton) > .toolbarbutton-text > .status-open, +/* When a Download is hovered - its action button explicitly - and it can be + retried, show the 'Retry Download' label. */ +.subviewbutton.download:hover[canRetry].downloadHoveringButton > .toolbarbutton-text > .status-retry, +/* When a Download is hovered - its action button explicitly - and the file can + be shown in the OS's shell, show the 'Open Containing Folder' label. */ +.subviewbutton.download:hover[canShow][exists].downloadHoveringButton > .toolbarbutton-text > .status-show { + display: inline; +} diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js new file mode 100644 index 0000000000..c8b354bda1 --- /dev/null +++ b/browser/components/downloads/content/downloads.js @@ -0,0 +1,1641 @@ +/* -*- 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. + */ + +/** + * A few words on focus and focusrings + * + * We do quite a few hacks in the Downloads Panel for focusrings. In fact, we + * basically suppress most if not all XUL-level focusrings, and style/draw + * them ourselves (using :focus instead of -moz-focusring). There are a few + * reasons for this: + * + * 1) Richlists on OSX don't have focusrings; instead, they are shown as + * selected. This makes for some ambiguity when we have a focused/selected + * item in the list, and the mouse is hovering a completed download (which + * highlights). + * 2) Windows doesn't show focusrings until after the first time that tab is + * pressed (and by then you're focusing the second item in the panel). + * 3) Richlistbox sets -moz-focusring even when we select it with a mouse. + * + * In general, the desired behaviour is to focus the first item after pressing + * tab/down, and show that focus with a ring. Then, if the mouse moves over + * the panel, to hide that focus ring; essentially resetting us to the state + * before pressing the key. + * + * We end up capturing the tab/down key events, and preventing their default + * behaviour. We then set a "keyfocus" attribute on the panel, which allows + * us to draw a ring around the currently focused element. If the panel is + * closed or the mouse moves over the panel, we remove the attribute. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "DownloadsViewUI", + "resource:///modules/DownloadsViewUI.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DownloadsSubview", + "resource:///modules/DownloadsSubview.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); + +// DownloadsPanel + +/** + * Main entry point for the downloads panel interface. + */ +var DownloadsPanel = { + // Initialization and termination + + /** + * 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 (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(); + + 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() { + Services.telemetry.scalarAdd("downloads.panel_shown", 1); + DownloadsCommon.log("Opening the downloads panel."); + + 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 + ); + }, + + /** + * Returns whether the user has started keyboard navigation. + */ + get keyFocusing() { + return this.panel.hasAttribute("keyfocus"); + }, + + /** + * Set to true if the user has started keyboard navigation, and we should be + * showing focusrings in the panel. Also adds a mousemove event handler to + * the panel which disables keyFocusing. + */ + set keyFocusing(aValue) { + if (aValue) { + this.panel.setAttribute("keyfocus", "true"); + this.panel.addEventListener("mousemove", this); + } else { + this.panel.removeAttribute("keyfocus"); + this.panel.removeEventListener("mousemove", this); + } + return aValue; + }, + + /** + * Handles the mousemove event for the panel, which disables focusring + * visualization. + */ + handleEvent(aEvent) { + switch (aEvent.type) { + case "mousemove": + this.keyFocusing = false; + break; + case "keydown": + this._onKeyDown(aEvent); + break; + case "keypress": + this._onKeyPress(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 = true; + + // Ensure that the first item is selected when the panel is focused. + if ( + DownloadsView.richListBox.itemCount > 0 && + DownloadsView.richListBox.selectedIndex == -1 + ) { + 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."); + + // Removes the keyfocus attribute so that we stop handling keyboard + // navigation. + this.keyFocusing = false; + + // Since at most one popup is open at any given time, we can set globally. + DownloadsCommon.getIndicatorData(window).attentionSuppressed = false; + + // 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); + }, + + /** + * 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); + }, + + _onKeyPress(aEvent) { + // Handle unmodified keys only. + if (aEvent.altKey || aEvent.ctrlKey || aEvent.shiftKey || aEvent.metaKey) { + return; + } + + let richListBox = DownloadsView.richListBox; + + // If the user has pressed the tab, up, or down cursor key, start keyboard + // navigation, thus enabling focusrings in the panel. Keyboard navigation + // is automatically disabled if the user moves the mouse on the panel, or + // if the panel is closed. + if ( + (aEvent.keyCode == aEvent.DOM_VK_TAB || + aEvent.keyCode == aEvent.DOM_VK_UP || + aEvent.keyCode == aEvent.DOM_VK_DOWN) && + !this.keyFocusing + ) { + this.keyFocusing = true; + // Ensure there's a selection, we will show the focus ring around it and + // prevent the richlistbox from changing the selection. + if (DownloadsView.richListBox.selectedIndex == -1) { + DownloadsView.richListBox.selectedIndex = 0; + } + 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 ( + richListBox.selectedItem === richListBox.lastElementChild || + document.activeElement.parentNode.id === "downloadsFooter" + ) { + DownloadsFooter.focus(); + aEvent.preventDefault(); + return; + } + } + + // Pass keypress events to the richlistbox view when it's focused. + if (document.activeElement === 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 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 && + document.activeElement.parentNode.id === "downloadsFooter" && + DownloadsView.richListBox.firstElementChild + ) { + DownloadsView.richListBox.focus(); + DownloadsView.richListBox.selectedItem = + DownloadsView.richListBox.lastElementChild; + 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/unicode"]; + 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) {} + }, + + /** + * 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; + } + + let element = document.commandDispatcher.focusedElement; + while (element && element != this.panel) { + element = element.parentNode; + } + if (!element) { + if (DownloadsView.richListBox.itemCount > 0) { + DownloadsView.richListBox.focus(); + } else { + DownloadsFooter.focus(); + } + } + }, + + /** + * 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."); + 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(Cu.reportError); + } + + DownloadsCommon.log("Opening downloads panel popup."); + PanelMultiView.openPopup( + this.panel, + anchor, + "bottomcenter topright", + 0, + 0, + false, + null + ).catch(Cu.reportError); + }, +}; + +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 an open contextMenu for a download item. + */ + contextMenuOpen: false, + + /** + * 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"); + 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 only, and exclude the action button. + if (aEvent.button == 0 && aEvent.originalTarget.localName != "button") { + let target = aEvent.target; + while (target.nodeName != "richlistitem") { + target = target.parentNode; + } + Services.telemetry.scalarAdd("downloads.file_opened", 1); + let download = DownloadsView.itemForElement(target).download; + 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; + } + } + 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) { + goDoCommand("downloadsCmd_doDefault"); + } + }, + + /** + * Event handlers to keep track of context menu state (open/closed) for + * download items. + */ + onContextPopupShown(aEvent) { + // Ignore events raised by nested popups. + if (aEvent.target != aEvent.currentTarget) { + return; + } + + DownloadsCommon.log("Context menu has shown."); + this.contextMenuOpen = true; + }, + + onContextPopupHidden(aEvent) { + // Ignore events raised by nested popups. + if (aEvent.target != aEvent.currentTarget) { + return; + } + + DownloadsCommon.log("Context menu has hidden."); + this.contextMenuOpen = false; + }, + + /** + * 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"); + } + + if (!this.contextMenuOpen && !this.subViewOpen) { + 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.contextMenuOpen && + !this.subViewOpen && + !item.contains(aEvent.relatedTarget) + ) { + this.richListBox.selectedIndex = -1; + } + }, + + onDownloadContextMenu(aEvent) { + let element = this.richListBox.selectedItem; + if (!element) { + return; + } + + DownloadsViewController.updateCommands(); + + let download = element._shell.download; + let mimeInfo = DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault } = mimeInfo ? mimeInfo : {}; + + // Set the state attribute so that only the appropriate items are displayed. + let contextMenu = document.getElementById("downloadsContextMenu"); + contextMenu.setAttribute("state", element.getAttribute("state")); + if (element.hasAttribute("exists")) { + contextMenu.setAttribute("exists", "true"); + } else { + contextMenu.removeAttribute("exists"); + } + contextMenu.classList.toggle( + "temporary-block", + element.classList.contains("temporary-block") + ); + if (element.hasAttribute("viewable-internally")) { + contextMenu.setAttribute("viewable-internally", "true"); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + } + alwaysUseSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.alwaysOpenInSystemViewerItemEnabled + ); + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + useSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.openInSystemViewerItemEnabled + ); + } else { + contextMenu.removeAttribute("viewable-internally"); + } + }, + + onDownloadDragStart(aEvent) { + let element = this.richListBox.selectedItem; + 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": { + 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 "cmd_delete": + case "downloadsCmd_copyLocation": + 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(Cu.reportError); + } + 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_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(); + } + + 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 this._active; + } + if (aActive) { + DownloadsCommon.getSummary( + window, + DownloadsView.kItemCountLimit + ).refreshView(this); + } else { + DownloadsFooter.showingSummary = false; + } + + return (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. + return (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); + } + return 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); + } + return 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); + } + return aValue; + }, + + /** + * Focuses the root element of the summary. + */ + focus() { + if (this._summaryNode) { + this._summaryNode.focus(); + } + }, + + /** + * 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() { + if (this._showingSummary) { + DownloadsSummary.focus(); + } else { + DownloadsView.downloadsHistory.focus(); + } + }, + + _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; + } + return 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; + e.title.textContent = title; + 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..ebcae25a05 --- /dev/null +++ b/browser/components/downloads/content/downloadsCommands.inc.xhtml @@ -0,0 +1,27 @@ +# 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"/> +</commandset> diff --git a/browser/components/downloads/content/downloadsCommands.js b/browser/components/downloads/content/downloadsCommands.js new file mode 100644 index 0000000000..53f5fac0f9 --- /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..fc34dadf7f --- /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"> + + <linkset> + <html:link rel="localization" href="browser/downloads.ftl" /> + </linkset> + + <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_show" + class="downloadShowMenuItem" +#ifdef XP_MACOSX + data-l10n-id="downloads-cmd-show-menuitem-mac" +#else + data-l10n-id="downloads-cmd-show-menuitem" +#endif + /> + + <menuseparator class="downloadCommandsSeparator"/> + + <menuitem command="downloadsCmd_openReferrer" + data-l10n-id="downloads-cmd-go-to-download-page"/> + <menuitem command="cmd_copy" + data-l10n-id="downloads-cmd-copy-download-link"/> + + <menuseparator/> + + <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..688bf75415 --- /dev/null +++ b/browser/components/downloads/content/downloadsPanel.inc.xhtml @@ -0,0 +1,212 @@ +<!-- 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> + <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')"/> +</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" + onpopupshown="DownloadsView.onContextPopupShown(event);" + onpopuphidden="DownloadsView.onContextPopupHidden(event);" + 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_show" + class="downloadShowMenuItem" + #ifdef XP_MACOSX + data-l10n-id="downloads-cmd-show-menuitem-mac" + #else + data-l10n-id="downloads-cmd-show-menuitem" + #endif + /> + + <menuseparator class="downloadCommandsSeparator"/> + + <menuitem command="downloadsCmd_openReferrer" + data-l10n-id="downloads-cmd-go-to-download-page"/> + <menuitem command="downloadsCmd_copyLocation" + data-l10n-id="downloads-cmd-copy-download-link"/> + + <menuseparator/> + + <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"> + + <panelview id="downloadsPanel-mainView"> + <vbox class="panel-view-body-unscrollable"> + <richlistbox id="downloadsListBox" + data-l10n-id="downloads-panel-list" + 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> + <hbox id="downloadsFooterButtons" + class="panel-footer"> + <button id="downloadsHistory" + data-l10n-id="downloads-history" + class="downloadsPanelFooterButton" + flex="1" + oncommand="DownloadsPanel.showDownloadsHistory();" + pack="start"/> + </hbox> + </stack> + </vbox> + </panelview> + + <panelview id="downloadsPanel-blockedSubview" + data-l10n-id="downloads-details" + descriptionheightworkaround="true" + class="PanelUI-subView"> + <vbox class="panel-view-body-unscrollable"> + <description id="downloadsPanel-blockedSubview-title"/> + <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> + + <panelview id="PanelUI-downloads" class="PanelUI-subView"> + <vbox class="panel-subview-body"> + <toolbarbutton id="appMenu-library-downloads-show-button" + data-l10n-id="downloads-cmd-show-downloads" + class="subviewbutton subviewbutton-iconic" + closemenu="none" + oncommand="DownloadsSubview.onShowDownloads(this);"/> + <toolbarseparator/> + <toolbaritem id="panelMenu_downloadsMenu" + orient="vertical" + smoothscroll="false" + flatList="true" + tooltip="bhTooltip"> + <!-- downloads menu items will go here --> + </toolbaritem> + </vbox> + <toolbarbutton id="PanelUI-downloadsMore" + data-l10n-id="downloads-history" + class="panel-subview-footer subviewbutton" + oncommand="BrowserDownloadsUI(); CustomizableUI.hidePanelForNode(this);"/> + </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..da28a485d2 --- /dev/null +++ b/browser/components/downloads/content/indicator.js @@ -0,0 +1,686 @@ +/* -*- 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, + + /** + * Indicates whether the button has been torn down. + * TODO: This is used for a temporary workaround for bug 1543537 and should be + * removed when fixed. + */ + _uninitialized: 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; + 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"); + } + } + }, + + hide() { + let button = this._placeholder; + if (this.autoHideDownloadsButton && button && button.closest("toolbar")) { + DownloadsPanel.hidePanel(); + button.setAttribute("hidden", "true"); + this._navBar.removeAttribute("downloadsbuttonshown"); + } + }, + + startAutoHide() { + if (DownloadsIndicatorView.hasDownloads) { + this.unhide(); + } else { + this.hide(); + } + }, + + checkForAutoHide() { + if (this._uninitialized) { + return; + } + 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() { + this._uninitialized = true; + 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.onWindowUnload); + 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.onWindowUnload); + 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; + } + + if (!DownloadsCommon.animateNotifications) { + 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) { + // No need to show visual notification if the panel is visible. + if (DownloadsPanel.isPanelShowing) { + return; + } + + let anchor = DownloadsButton._placeholder; + let widgetGroup = CustomizableUI.getWidget("downloads-button"); + let widget = widgetGroup.forWindow(window); + if (widgetGroup.areaType == CustomizableUI.TYPE_MENU_PANEL) { + if (anchor && this._isAncestorPanelOpen(anchor)) { + // If the containing panel is open, don't do anything, because the + // notification would appear under the open panel. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=984023 + return; + } + + // Otherwise, try to use the anchor of the panel: + anchor = widget.anchor; + } + if (!anchor || !isElementVisible(anchor.parentNode)) { + // Our container isn't visible, so can't show the animation: + return; + } + + // The notification element is positioned to show in the same location as + // the downloads button. It's not in the downloads button itself in order to + // be able to anchor the notification elsewhere if required, and to ensure + // the notification isn't clipped by overflow properties of the anchor's + // container. + // Note: no notifier animation for download finished in Photon + let notifier = this.notifier; + + if (aType == "start") { + // Show the notifier before measuring for size/placement. Being hidden by default + // avoids the interference with scrolling/APZ when the notifier element is + // tall enough to overlap the tabbrowser element + notifier.removeAttribute("hidden"); + + // the anchor height may vary if font-size is changed or + // compact/tablet mode is selected so recalculate this each time + let anchorRect = anchor.getBoundingClientRect(); + let notifierRect = notifier.getBoundingClientRect(); + let topDiff = anchorRect.top - notifierRect.top; + let leftDiff = anchorRect.left - notifierRect.left; + let heightDiff = anchorRect.height - notifierRect.height; + let widthDiff = anchorRect.width - notifierRect.width; + let translateX = leftDiff + 0.5 * widthDiff + "px"; + let translateY = topDiff + 0.5 * heightDiff + "px"; + notifier.style.transform = + "translate(" + translateX + ", " + translateY + ")"; + notifier.setAttribute("notification", aType); + } + anchor.setAttribute("notification", aType); + + let animationDuration; + // This value is determined by the overall duration of animation in CSS. + animationDuration = aType == "start" ? 760 : 850; + + this._currentNotificationType = aType; + + setTimeout(() => { + requestAnimationFrame(() => { + notifier.setAttribute("hidden", "true"); + notifier.removeAttribute("notification"); + notifier.style.transform = ""; + anchor.removeAttribute("notification"); + + requestAnimationFrame(() => { + let nextType = this._nextNotificationType; + this._currentNotificationType = null; + this._nextNotificationType = null; + if (nextType) { + this._showNotification(nextType); + } + }); + }); + }, animationDuration); + }, + + // 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) { + DownloadsButton.unhide(); + this._ensureOperational(); + } else { + DownloadsButton.checkForAutoHide(); + } + } + return aValue; + }, + 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 this._percentComplete; + } + + if (this._percentComplete !== aValue) { + this._percentComplete = aValue; + this._refreshAttention(); + + if (this._percentComplete >= 0) { + this.indicator.setAttribute("progress", "true"); + // For arrow type only: + // We set animationDelay to a minus value (0s ~ -100s) to show the + // corresponding frame needed for progress. + this._progressIcon.style.animationDelay = -this._percentComplete + "s"; + } else { + this.indicator.removeAttribute("progress"); + this._progressIcon.style.animationDelay = "1s"; + } + } + return aValue; + }, + _percentComplete: null, + + /** + * Set when the indicator should draw user attention to itself. + */ + set attention(aValue) { + if (!this._operational) { + return this._attention; + } + if (this._attention != aValue) { + this._attention = aValue; + this._refreshAttention(); + } + return this._attention; + }, + + _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_MENU_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 + + onWindowUnload() { + // This function is registered as an event listener, we can't use "this". + DownloadsIndicatorView.ensureTerminated(); + }, + + 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(); + 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, 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) { + return this._indicator; + } + + let indicator = document.getElementById("downloads-button"); + if (!indicator || indicator.getAttribute("indicator") != "true") { + return null; + } + + return (this._indicator = indicator); + }, + + get indicatorAnchor() { + let widgetGroup = CustomizableUI.getWidget("downloads-button"); + if (widgetGroup.areaType == CustomizableUI.TYPE_MENU_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" + )) + ); + }, + + get notifier() { + return ( + this._notifier || + (this._notifier = document.getElementById( + "downloads-notification-anchor" + )) + ); + }, + + _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..b98c027fd8 --- /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.jsm", + "DownloadsSubview.jsm", + "DownloadsTaskbar.jsm", + "DownloadsViewableInternally.jsm", + "DownloadsViewUI.jsm", +] + +toolkit = CONFIG["MOZ_WIDGET_TOOLKIT"] + +if toolkit == "cocoa": + EXTRA_JS_MODULES += ["DownloadsMacFinderProgress.jsm"] + +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..c8232aef5f --- /dev/null +++ b/browser/components/downloads/test/browser/browser.ini @@ -0,0 +1,35 @@ +[DEFAULT] +support-files = head.js + +[browser_about_downloads.js] +[browser_basic_functionality.js] +[browser_download_overwrite.js] +support-files = + foo.txt + foo.txt^headers^ + !/toolkit/content/tests/browser/common/mockTransfer.js +[browser_first_download_panel.js] +skip-if = os == "linux" # Bug 949434 +[browser_image_mimetype_issues.js] +support-files = + not-really-a-jpeg.jpeg + not-really-a-jpeg.jpeg^headers^ + blank.JPG +[browser_library_select_all.js] +[browser_overflow_anchor.js] +skip-if = os == "linux" # Bug 952422 +[browser_confirm_unblock_download.js] +[browser_iframe_gone_mid_download.js] +[browser_indicatorDrop.js] +[browser_libraryDrop.js] +skip-if = (os == 'win' && os_version == '10.0' && ccov) # Bug 1306510 +[browser_library_clearall.js] +[browser_downloads_panel_block.js] +skip-if = true # Bug 1352792 +[browser_downloads_panel_context_menu.js] +[browser_downloads_panel_ctrl_click.js] +[browser_downloads_panel_height.js] +[browser_downloads_autohide.js] +[browser_go_to_download_page.js] +[browser_pdfjs_preview.js] +[browser_downloads_pauseResume.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..ab6568ef24 --- /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.loadURI(browser, "about:downloads"); + await downloadsLoaded; + await SpecialPowers.spawn(browser, [], async function() { + let box = content.document.getElementById("downloadsRichListBox"); + 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..7e58f95fca --- /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..f47ea4d9b2 --- /dev/null +++ b/browser/components/downloads/test/browser/browser_confirm_unblock_download.js @@ -0,0 +1,94 @@ +/* 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 }) { + BrowserTestUtils.promiseAlertDialogOpen(buttonToClick); + is(await DownloadsCommon.confirmUnblockDownload(args), expectedResult); +} + +/** + * 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_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() { + let args = { + verdict: Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED, + 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() { + let args = { + verdict: Downloads.Error.BLOCK_VERDICT_UNCOMMON, + 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_overwrite.js b/browser/components/downloads/test/browser/browser_download_overwrite.js new file mode 100644 index 0000000000..71eb715315 --- /dev/null +++ b/browser/components/downloads/test/browser/browser_download_overwrite.js @@ -0,0 +1,129 @@ +/* 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); + +/* import-globals-from ../../../../../toolkit/content/tests/browser/common/mockTransfer.js */ +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js", + this +); + +add_task(async function setup() { + // 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.useDownloadDir", false]], + }); + // Set up the file picker. + let destDir = gTestTargetFile.parent; + + MockFilePicker.displayDirectory = destDir; + MockFilePicker.showCallback = function(fp) { + MockFilePicker.setFiles([gTestTargetFile]); + }; + 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(); + } + }); + + let dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + // Now try and download a thing to the file: + await BrowserTestUtils.withNewTab(TEST_ROOT + "foo.txt", async function() { + let dialog = await dialogPromise; + info("Got dialog."); + let saveEl = dialog.document.getElementById("save"); + dialog.document.getElementById("mode").selectedItem = saveEl; + // Allow accepting the dialog (to avoid the delay helper): + dialog.document + .getElementById("unknownContentType") + .getButton("accept").disabled = false; + // Then accept it: + dialog.document.querySelector("dialog").acceptDialog(); + 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 dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + // Now try and download a thing to the file: + await BrowserTestUtils.withNewTab(TEST_ROOT + "foo.txt", async function() { + let dialog = await dialogPromise; + info("Got dialog."); + // 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); + } + }, + }); + }); + let saveEl = dialog.document.getElementById("save"); + dialog.document.getElementById("mode").selectedItem = saveEl; + // Allow accepting the dialog (to avoid the delay helper): + dialog.document + .getElementById("unknownContentType") + .getButton("accept").disabled = false; + // Then accept it: + dialog.document.querySelector("dialog").acceptDialog(); + 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_downloads_autohide.js b/browser/components/downloads/test/browser/browser_downloads_autohide.js new file mode 100644 index 0000000000..f56957d7aa --- /dev/null +++ b/browser/components/downloads/test/browser/browser_downloads_autohide.js @@ -0,0 +1,510 @@ +/* -*- 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_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("home-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_panel_block.js b/browser/components/downloads/test/browser/browser_downloads_panel_block.js new file mode 100644 index 0000000000..7e0678205c --- /dev/null +++ b/browser/components/downloads/test/browser/browser_downloads_panel_block.js @@ -0,0 +1,181 @@ +/* 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(); + + // The current item is always the first one in the listbox since each + // iteration of this loop removes the item at the end. + let item = DownloadsView.richListBox.firstElementChild; + + // Open the panel and click the item to show the subview. + let viewPromise = promiseViewShown(DownloadsBlockedSubview.subview); + await EventUtils.sendMouseEvent({ type: "click" }, 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] + ); + + // Go back to the main view. + viewPromise = promiseViewShown(DownloadsBlockedSubview.mainView); + DownloadsBlockedSubview.panelMultiView.goBack(); + await viewPromise; + + // Show the subview again. + viewPromise = promiseViewShown(DownloadsBlockedSubview.subview); + await EventUtils.sendMouseEvent({ type: "click" }, item); + await viewPromise; + + // 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 unblockOpenPromise = promiseUnblockAndOpenDownloadCalled(item); + let hidePromise = promisePanelHidden(); + EventUtils.synthesizeMouse( + DownloadsBlockedSubview.elements.unblockButton, + 10, + 10, + {}, + window + ); + await unblockOpenPromise; + await hidePromise; + + window.focus(); + await SimpleTest.promiseFocus(window); + + // Reopen the panel and show the subview again. + await openPanel(); + + viewPromise = promiseViewShown(DownloadsBlockedSubview.subview); + await EventUtils.sendMouseEvent({ type: "click" }, item); + await viewPromise; + + // Click the Remove button. The panel should close and the item should be + // removed from it. + hidePromise = promisePanelHidden(); + EventUtils.synthesizeMouse( + DownloadsBlockedSubview.elements.deleteButton, + 10, + 10, + {}, + window + ); + await hidePromise; + + 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 promiseUnblockAndOpenDownloadCalled(item) { + return new Promise(resolve => { + let realFn = item._shell.unblockAndOpenDownload; + item._shell.unblockAndOpenDownload = () => { + item._shell.unblockAndOpenDownload = realFn; + resolve(); + // unblockAndOpenDownload returns a promise (that's resolved when the file + // is opened). + return Promise.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..31841fde5e --- /dev/null +++ b/browser/components/downloads/test/browser/browser_downloads_panel_context_menu.js @@ -0,0 +1,204 @@ +/* + Coverage for context menu state for downloads in the Downloads Panel +*/ + +let gDownloadDir; +const TestFiles = {}; + +const MENU_ITEMS = { + pause: ".downloadPauseMenuItem", + resume: ".downloadResumeMenuItem", + unblock: '[command="downloadsCmd_unblock"]', + openInSystemViewer: '[command="downloadsCmd_openInSystemViewer"]', + alwaysOpenInSystemViewer: '[command="downloadsCmd_alwaysOpenInSystemViewer"]', + show: '[command="downloadsCmd_show"]', + commandsSeparator: "menuseparator,.downloadCommandsSeparator", + openReferrer: '[command="downloadsCmd_openReferrer"]', + copyLocation: '[command="downloadsCmd_copyLocation"]', + separator: "menuseparator", + delete: '[command="cmd_delete"]', + clearList: '[command="downloadsCmd_clearList"]', + clearDownloads: '[command="downloadsCmd_clearDownloads"]', +}; + +const TestCases = [ + { + name: "Completed PDF download", + downloads: [ + { + state: DownloadsCommon.DOWNLOAD_FINISHED, + contentType: "application/pdf", + target: {}, + }, + ], + expected: { + menu: [ + MENU_ITEMS.openInSystemViewer, + MENU_ITEMS.alwaysOpenInSystemViewer, + MENU_ITEMS.show, + MENU_ITEMS.commandsSeparator, + MENU_ITEMS.openReferrer, + MENU_ITEMS.copyLocation, + MENU_ITEMS.separator, + MENU_ITEMS.delete, + MENU_ITEMS.clearList, + ], + }, + }, + { + name: "Canceled PDF download", + downloads: [ + { + state: DownloadsCommon.DOWNLOAD_CANCELED, + contentType: "application/pdf", + target: {}, + }, + ], + expected: { + menu: [ + MENU_ITEMS.openReferrer, + MENU_ITEMS.copyLocation, + MENU_ITEMS.separator, + MENU_ITEMS.delete, + MENU_ITEMS.clearList, + ], + }, + }, +]; + +add_task(async function test_setUp() { + // 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( + OS.Path.join(gDownloadDir, "downloaded.pdf"), + DATA_PDF + ); + info("Created downloaded PDF file at:" + TestFiles.pdf.path); + TestFiles.txt = await createDownloadedFile( + OS.Path.join(gDownloadDir, "downloaded.txt"), + "Test file" + ); + info("Created downloaded text file at:" + TestFiles.txt.path); +}); + +// 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 testDownloadContextMenu(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function testDownloadContextMenu({ downloads = [], expected }) { + // prepare downloads + await prepareDownloads(downloads); + let downloadList = await Downloads.getList(Downloads.PUBLIC); + let [firstDownload] = await downloadList.getAll(); + info("Download succeeded? " + firstDownload.succeeded); + info("Download target exists? " + firstDownload.target.exists); + + // open panel + await task_openPanel(); + await TestUtils.waitForCondition( + () => + document.getElementById("downloadsListBox").childElementCount == + downloads.length + ); + + info("trigger the context menu"); + let itemTarget = document.querySelector( + "#downloadsListBox richlistitem .downloadMainArea" + ); + + let contextMenu = await openContextMenu(itemTarget); + + 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) { + for (let props of downloads) { + info(JSON.stringify(props)); + if (props.state !== DownloadsCommon.DOWNLOAD_FINISHED) { + continue; + } + switch (props.contentType) { + case "application/pdf": + props.target = TestFiles.pdf; + break; + default: + props.target = TestFiles.txt; + break; + } + ok(props.target instanceof Ci.nsIFile, "download target is a nsIFile"); + } + await task_addDownloads(downloads); +} 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_height.js b/browser/components/downloads/test/browser/browser_downloads_panel_height.js new file mode 100644 index 0000000000..f65be18cb4 --- /dev/null +++ b/browser/components/downloads/test/browser/browser_downloads_panel_height.js @@ -0,0 +1,31 @@ +/* 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"); + 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(); + + 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_pauseResume.js b/browser/components/downloads/test/browser/browser_downloads_pauseResume.js new file mode 100644 index 0000000000..21c786b1bb --- /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("downloadsRichListBox"); + 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..cbffcc35bd --- /dev/null +++ b/browser/components/downloads/test/browser/browser_first_download_panel.js @@ -0,0 +1,65 @@ +/* -*- 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." + ); + + // 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..6a42a885a0 --- /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("downloadsRichListBox"); + 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" + ); + goToDownloadButton.click(); + + 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..582b49d985 --- /dev/null +++ b/browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js @@ -0,0 +1,76 @@ +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.import( + "resource://gre/modules/DownloadLastDir.jsm" + ); + + 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 new Promise((resolve, reject) => { + gDownloadLastDir.getFileAsync("http://www.mozilla.org/", function(dir) { + resolve(dir); + }); + }); + } catch (ex) { + ok( + false, + "Got an exception trying to get the directory where things should be saved." + ); + Cu.reportError(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." + ); + Cu.reportError(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..c0bb0b8d9e --- /dev/null +++ b/browser/components/downloads/test/browser/browser_image_mimetype_issues.js @@ -0,0 +1,133 @@ +/* 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; + }; + EventUtils.synthesizeMouseAtCenter( + menu.querySelector("#context-saveimage"), + {} + ); + }); + } + ); +}); + +/** + * Test with the "save link as" context menu. + */ +add_task(async function test_save_link_webp_with_jpeg_extension() { + 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; + }; + EventUtils.synthesizeMouseAtCenter( + menu.querySelector("#context-savelink"), + {} + ); + }); + } + ); +}); + +/** + * 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..1cbdd89761 --- /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..d00ff2b9e6 --- /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("downloadsRichListBox"); + 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..41080f23b0 --- /dev/null +++ b/browser/components/downloads/test/browser/browser_library_clearall.js @@ -0,0 +1,121 @@ +/* -*- 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.defineModuleGetter( + this, + "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm" +); + +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("downloadsRichListBox"); + ok(listbox, "download list box present"); + + let promiseLength = waitForChildrenLength(listbox, DOWNLOAD_DATA.length); + await simulateDropAndCheck(win, listbox, DOWNLOAD_DATA); + await promiseLength; + + let receivedNotifications = []; + let promiseNotification = PlacesTestUtils.waitForNotification( + "onDeleteURI", + uri => { + if (DOWNLOAD_DATA.includes(uri.spec)) { + receivedNotifications.push(uri.spec); + } + return receivedNotifications.length == DOWNLOAD_DATA.length; + }, + "history" + ); + + 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_task(async function setup() { + // 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" + ); + clearDownloadsButton.click(); + }); +}); 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..b3a0a9f263 --- /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_task(async function setup() { + 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("downloadsRichListBox"); + 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..e07140c7a2 --- /dev/null +++ b/browser/components/downloads/test/browser/browser_overflow_anchor.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This is the same value used by CustomizableUI tests. +const kForceOverflowWidthPx = 450; + +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(); + await 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(); + await 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..d183d0bcec --- /dev/null +++ b/browser/components/downloads/test/browser/browser_pdfjs_preview.js @@ -0,0 +1,749 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +let gDownloadDir; + +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: "#downloadsRichListBox 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: "#downloadsRichListBox 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: "#downloadsRichListBox 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: "#downloadsRichListBox 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: "#downloadsRichListBox 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: "#downloadsRichListBox 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"); + EventUtils.synthesizeMouseAtCenter( + itemTarget, + Object.assign({ clickCount: 1 }, modifiers), + content + ); + EventUtils.synthesizeMouseAtCenter( + itemTarget, + Object.assign({ clickCount: 2 }, modifiers), + 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 = OS.Path.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 + ); +} + +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("downloadsRichListBox"); + 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.loadURI(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("downloadsRichListBox") + .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/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..a184bd1582 --- /dev/null +++ b/browser/components/downloads/test/browser/head.js @@ -0,0 +1,284 @@ +/* -*- 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.defineModuleGetter( + this, + "Downloads", + "resource://gre/modules/Downloads.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DownloadsCommon", + "resource:///modules/DownloadsCommon.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "HttpServer", + "resource://testing-common/httpd.js" +); + +var gTestTargetFile = FileUtils.getFile("TmpD", ["dm-ui-test.file"]); +gTestTargetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + +// The file may have been already deleted when removing a paused download. +registerCleanupFunction(() => + OS.File.remove(gTestTargetFile.path, { ignoreAbsent: true }) +); + +const DATA_PDF = atob( + "JVBERi0xLjANCjEgMCBvYmo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PmVuZG9iaiAyIDAgb2JqPDwvVHlwZS9QYWdlcy9LaWRzWzMgMCBSXS9Db3VudCAxPj5lbmRvYmogMyAwIG9iajw8L1R5cGUvUGFnZS9NZWRpYUJveFswIDAgMyAzXT4+ZW5kb2JqDQp4cmVmDQowIDQNCjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxMCAwMDAwMCBuDQowMDAwMDAwMDUzIDAwMDAwIG4NCjAwMDAwMDAxMDIgMDAwMDAgbg0KdHJhaWxlcjw8L1NpemUgNC9Sb290IDEgMCBSPj4NCnN0YXJ0eHJlZg0KMTQ5DQolRU9G" +); + +// Asynchronous support subroutines + +async function createDownloadedFile(pathname, contents) { + let encoder = new TextEncoder(); + 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 OS.File.writeAtomic(pathname, encoder.encode(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) { + publicList.remove(download); + await download.finalize(true); + } + + 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, + error: + item.state == DownloadsCommon.DOWNLOAD_FAILED + ? new Error("Failed.") + : null, + hasPartialData: item.state == DownloadsCommon.DOWNLOAD_PAUSED, + hasBlockedData: item.hasBlockedData || false, + 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 = await PathUtils.getTempDir(); + 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) { + Cu.reportError(e); + } + }); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setCharPref("browser.download.dir", tmpDir); + return tmpDir; +} + +let gHttpServer = null; +function startServer() { + gHttpServer = new HttpServer(); + gHttpServer.start(-1); + registerCleanupFunction(async function() { + await new Promise(function(resolve) { + gHttpServer.stop(resolve); + }); + }); + + 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(); + }); +} + +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 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/unit/head.js b/browser/components/downloads/test/unit/head.js new file mode 100644 index 0000000000..6769bfe79a --- /dev/null +++ b/browser/components/downloads/test/unit/head.js @@ -0,0 +1,89 @@ +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "Downloads", + "resource://gre/modules/Downloads.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DownloadsCommon", + "resource:///modules/DownloadsCommon.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter( + this, + "FileTestUtils", + "resource://testing-common/FileTestUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "TestUtils", + "resource://testing-common/TestUtils.jsm" +); + +async function createDownloadedFile(pathname, contents) { + info("createDownloadedFile: " + pathname); + let encoder = new TextEncoder(); + 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 OS.File.writeAtomic(pathname, encoder.encode(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) { + Cu.reportError(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_task(async function test_common_initialize() { + gDownloadDir = await setDownloadDir(); + Services.prefs.setCharPref("browser.download.loglevel", "Debug"); +}); 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..89d1bf708a --- /dev/null +++ b/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js @@ -0,0 +1,175 @@ +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() { + Assert.ok( + OS.Constants.Path.profileDir, + "profileDir: " + OS.Constants.Path.profileDir + ); + for (let [filename, contents] of Object.entries(TESTFILES)) { + TESTFILES[filename] = await createDownloadedFile( + OS.Path.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 OS.File.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..e921d7b540 --- /dev/null +++ b/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js @@ -0,0 +1,153 @@ +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() { + Assert.ok( + OS.Constants.Path.profileDir, + "profileDir: " + OS.Constants.Path.profileDir + ); + for (let [filename, contents] of Object.entries(TESTFILES)) { + TESTFILES[filename] = await createDownloadedFile( + OS.Path.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..16aee6e209 --- /dev/null +++ b/browser/components/downloads/test/unit/test_DownloadsViewableInternally.js @@ -0,0 +1,252 @@ +/* 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 PDF_MIME = "application/pdf"; +const OCTET_MIME = "application/octet-stream"; +const XML_MIME = "text/xml"; +const SVG_MIME = "image/svg+xml"; +const WEBP_MIME = "image/webp"; + +const { Integration } = ChromeUtils.import( + "resource://gre/modules/Integration.jsm" +); +const { + DownloadsViewableInternally, + PREF_ENABLED_TYPES, + PREF_BRANCH_WAS_REGISTERED, + PREF_BRANCH_PREVIOUS_ACTION, + PREF_BRANCH_PREVIOUS_ASK, +} = ChromeUtils.import("resource:///modules/DownloadsViewableInternally.jsm"); + +/* global DownloadIntegration */ +Integration.downloads.defineModuleGetter( + this, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.jsm" +); + +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); + checkShouldView(mime, ext, expected); + checkWasRegistered(ext, expected); +} + +add_task(async function test_viewable_internally() { + Services.prefs.setCharPref(PREF_ENABLED_TYPES, "xml , svg,webp"); + Services.prefs.setBoolPref(PREF_SVG_DISABLED, false); + Services.prefs.setBoolPref(PREF_WEBP_ENABLED, true); + + checkAll(XML_MIME, "xml", false); + checkAll(SVG_MIME, "svg", false); + checkAll(WEBP_MIME, "webp", false); + + DownloadsViewableInternally.register(); + + checkAll(XML_MIME, "xml", true); + checkAll(SVG_MIME, "svg", true); + checkAll(WEBP_MIME, "webp", true); + + // Remove SVG so it won't be cleared + Services.prefs.clearUserPref(PREF_BRANCH_WAS_REGISTERED + "svg"); + + // Disable xml and svg, check that xml becomes disabled + Services.prefs.setCharPref(PREF_ENABLED_TYPES, "webp"); + + checkAll(XML_MIME, "xml", false); + checkAll(WEBP_MIME, "webp", true); + + // SVG shouldn't be cleared + checkPreferInternal(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(XML_MIME), "text/xml should be disabled by pref"); + Assert.ok(!shouldView(SVG_MIME), "image/xml+svg should be disabled by pref"); + + // Enable, check that everything is enabled again + Services.prefs.setCharPref(PREF_ENABLED_TYPES, "xml,svg,webp"); + + checkPreferInternal(XML_MIME, "xml", true); + checkPreferInternal(SVG_MIME, "svg", 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, XML should not be replaced, SVG and WebP should be saved. + Services.prefs.setCharPref(PREF_ENABLED_TYPES, "svg,webp,xml"); + + Assert.equal( + Services.prefs.getIntPref(PREF_BRANCH_PREVIOUS_ACTION + "svg"), + Ci.nsIHandlerInfo.saveToDisk, + "svg action should be saved" + ); + Assert.equal( + Services.prefs.getBoolPref(PREF_BRANCH_PREVIOUS_ASK + "svg"), + true, + "svg ask should be saved" + ); + 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(XML_MIME, "xml"); + Assert.equal( + handler.preferredAction, + Ci.nsIHandlerInfo.useSystemDefault, + "svg action should be preserved" + ); + Assert.equal( + !!handler.alwaysAskBeforeHandling, + true, + "svg ask should be preserved" + ); + // Clean up + HandlerService.remove(handler); + } + // It should still be possible to view XML internally + checkShouldView(XML_MIME, "xml", true); + checkWasRegistered("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); + // Should have restored the settings from above + { + let handler = MIMEService.getFromTypeAndExtension(SVG_MIME, "svg"); + Assert.equal(handler.preferredAction, Ci.nsIHandlerInfo.saveToDisk); + Assert.equal(!!handler.alwaysAskBeforeHandling, true); + // Clean up + HandlerService.remove(handler); + } + checkAll(SVG_MIME, "svg", false); + + Services.prefs.setBoolPref(PREF_SVG_DISABLED, false); + 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..c272510def --- /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' + + +[test_DownloadsCommon_getMimeInfo.js] +[test_DownloadsCommon_isFileOfType.js] +[test_DownloadsViewableInternally.js] |