fune/browser/components/downloads/content/downloads.js
Shane Hughes ad03151040 Bug 1742999 - Make DownloadSpamProtection per-window. r=Gijs,mak
This patch modifies DownloadSpamProtection and DownloadIntegration so
that each window will track blocked spam downloads separately. (Which
shouldn't affect permissions.) When a download is blocked, the helper
app service dispatches a notification, passing the relevant browsing
context and URL to DownloadIntegration. Then it passes the window and
URL to the singleton DownloadSpamProtection. That maps all the windows
to objects that carry the spam download objects. This allows us to only
show blocked spam downloads in the downloads panel of the window from
which they were triggered.

Differential Revision: https://phabricator.services.mozilla.com/D148092
2022-10-04 22:38:18 +00:00

1720 lines
50 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-env mozilla/browser-window */
/**
* Handles the Downloads panel user interface for each browser window.
*
* This file includes the following constructors and global objects:
*
* DownloadsPanel
* Main entry point for the downloads panel interface.
*
* DownloadsView
* Builds and updates the downloads list widget, responding to changes in the
* download state and real-time data. In addition, handles part of the user
* interaction events raised by the downloads list widget.
*
* DownloadsViewItem
* Builds and updates a single item in the downloads list widget, responding to
* changes in the download state and real-time data, and handles the user
* interaction events related to a single item in the downloads list widgets.
*
* DownloadsViewController
* Handles part of the user interaction events raised by the downloads list
* widget, in particular the "commands" that apply to multiple items, and
* dispatches the commands that apply to individual items.
*/
"use strict";
var { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
ChromeUtils.defineModuleGetter(
this,
"DownloadsViewUI",
"resource:///modules/DownloadsViewUI.jsm"
);
ChromeUtils.defineESModuleGetters(this, {
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});
ChromeUtils.defineModuleGetter(
this,
"NetUtil",
"resource://gre/modules/NetUtil.jsm"
);
const { Integration } = ChromeUtils.importESModule(
"resource://gre/modules/Integration.sys.mjs"
);
/* global DownloadIntegration */
Integration.downloads.defineModuleGetter(
this,
"DownloadIntegration",
"resource://gre/modules/DownloadIntegration.jsm"
);
// DownloadsPanel
/**
* Main entry point for the downloads panel interface.
*/
var DownloadsPanel = {
// Initialization and termination
/**
* Timeout that re-enables previously disabled download items in the downloads panel
* after some time has passed.
*/
_delayTimeout: null,
/**
* Internal state of the downloads panel, based on one of the kState
* constants. This is not the same state as the XUL panel element.
*/
_state: 0,
/** The panel is not linked to downloads data yet. */
get kStateUninitialized() {
return 0;
},
/** This object is linked to data, but the panel is invisible. */
get kStateHidden() {
return 1;
},
/** The panel will be shown as soon as possible. */
get kStateWaitingData() {
return 2;
},
/** The panel is open. */
get kStateShown() {
return 3;
},
/**
* Starts loading the download data in background, without opening the panel.
* Use showPanel instead to load the data and open the panel at the same time.
*/
initialize() {
DownloadsCommon.log(
"Attempting to initialize DownloadsPanel for a window."
);
if (DownloadIntegration.downloadSpamProtection) {
DownloadIntegration.downloadSpamProtection.register(
DownloadsView,
window
);
}
if (this._state != this.kStateUninitialized) {
DownloadsCommon.log("DownloadsPanel is already initialized.");
return;
}
this._state = this.kStateHidden;
window.addEventListener("unload", this.onWindowUnload);
// Load and resume active downloads if required. If there are downloads to
// be shown in the panel, they will be loaded asynchronously.
DownloadsCommon.initializeAllDataLinks();
// Now that data loading has eventually started, load the required XUL
// elements and initialize our views.
this.panel.hidden = false;
DownloadsViewController.initialize();
DownloadsCommon.log("Attaching DownloadsView...");
DownloadsCommon.getData(window).addView(DownloadsView);
DownloadsCommon.getSummary(window, DownloadsView.kItemCountLimit).addView(
DownloadsSummary
);
DownloadsCommon.log(
"DownloadsView attached - the panel for this window",
"should now see download items come in."
);
DownloadsPanel._attachEventListeners();
DownloadsCommon.log("DownloadsPanel initialized.");
},
/**
* Closes the downloads panel and frees the internal resources related to the
* downloads. The downloads panel can be reopened later, even after this
* function has been called.
*/
terminate() {
DownloadsCommon.log("Attempting to terminate DownloadsPanel for a window.");
if (this._state == this.kStateUninitialized) {
DownloadsCommon.log(
"DownloadsPanel was never initialized. Nothing to do."
);
return;
}
window.removeEventListener("unload", this.onWindowUnload);
// Ensure that the panel is closed before shutting down.
this.hidePanel();
DownloadsViewController.terminate();
DownloadsCommon.getData(window).removeView(DownloadsView);
DownloadsCommon.getSummary(
window,
DownloadsView.kItemCountLimit
).removeView(DownloadsSummary);
this._unattachEventListeners();
if (DownloadIntegration.downloadSpamProtection) {
DownloadIntegration.downloadSpamProtection.unregister(window);
}
this._state = this.kStateUninitialized;
DownloadsSummary.active = false;
DownloadsCommon.log("DownloadsPanel terminated.");
},
// Panel interface
/**
* Main panel element in the browser window.
*/
get panel() {
delete this.panel;
return (this.panel = document.getElementById("downloadsPanel"));
},
/**
* Starts opening the downloads panel interface, anchored to the downloads
* button of the browser window. The list of downloads to display is
* initialized the first time this method is called, and the panel is shown
* only when data is ready.
*/
showPanel(openedManually = false, isKeyPress = false) {
Services.telemetry.scalarAdd("downloads.panel_shown", 1);
DownloadsCommon.log("Opening the downloads panel.");
this._openedManually = openedManually;
this._preventFocusRing = !openedManually || !isKeyPress;
if (this.isPanelShowing) {
DownloadsCommon.log("Panel is already showing - focusing instead.");
this._focusPanel();
return;
}
// As a belt-and-suspenders check, ensure the button is not hidden.
DownloadsButton.unhide();
this.initialize();
// Delay displaying the panel because this function will sometimes be
// called while another window is closing (like the window for selecting
// whether to save or open the file), and that would cause the panel to
// close immediately.
setTimeout(() => this._openPopupIfDataReady(), 0);
DownloadsCommon.log("Waiting for the downloads panel to appear.");
this._state = this.kStateWaitingData;
},
/**
* Hides the downloads panel, if visible, but keeps the internal state so that
* the panel can be reopened quickly if required.
*/
hidePanel() {
DownloadsCommon.log("Closing the downloads panel.");
if (!this.isPanelShowing) {
DownloadsCommon.log("Downloads panel is not showing - nothing to do.");
return;
}
PanelMultiView.hidePopup(this.panel);
// Ensure that we allow the panel to be reopened. Note that, if the popup
// was open, then the onPopupHidden event handler has already updated the
// current state, otherwise we must update the state ourselves.
this._state = this.kStateHidden;
DownloadsCommon.log("Downloads panel is now closed.");
},
/**
* Indicates whether the panel is shown or will be shown.
*/
get isPanelShowing() {
return (
this._state == this.kStateWaitingData || this._state == this.kStateShown
);
},
handleEvent(aEvent) {
switch (aEvent.type) {
case "mousemove":
if (
!DownloadsView.contextMenuOpen &&
!DownloadsView.subViewOpen &&
this.panel.contains(document.activeElement)
) {
// Let mouse movement remove focus rings and reset focus in the panel.
// This behavior is copied from PanelMultiView.
document.activeElement.blur();
DownloadsView.richListBox.removeAttribute("force-focus-visible");
this._preventFocusRing = true;
this._focusPanel();
}
break;
case "keydown":
this._onKeyDown(aEvent);
break;
case "keypress":
this._onKeyPress(aEvent);
break;
case "focus":
case "select":
this._onSelect(aEvent);
break;
}
},
// Callback functions from DownloadsView
/**
* Called after data loading finished.
*/
onViewLoadCompleted() {
this._openPopupIfDataReady();
},
// User interface event functions
onWindowUnload() {
// This function is registered as an event listener, we can't use "this".
DownloadsPanel.terminate();
},
onPopupShown(aEvent) {
// Ignore events raised by nested popups.
if (aEvent.target != aEvent.currentTarget) {
return;
}
DownloadsCommon.log("Downloads panel has shown.");
this._state = this.kStateShown;
// Since at most one popup is open at any given time, we can set globally.
DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
DownloadsCommon.SUPPRESS_PANEL_OPEN;
// Ensure that the first item is selected when the panel is focused.
if (DownloadsView.richListBox.itemCount > 0) {
DownloadsView.richListBox.selectedIndex = 0;
}
this._focusPanel();
},
onPopupHidden(aEvent) {
// Ignore events raised by nested popups.
if (aEvent.target != aEvent.currentTarget) {
return;
}
DownloadsCommon.log("Downloads panel has hidden.");
if (this._delayTimeout) {
DownloadsView.richListBox.removeAttribute("disabled");
clearTimeout(this._delayTimeout);
this._stopWatchingForSpammyDownloadActivation();
this._delayTimeout = null;
}
DownloadsView.richListBox.removeAttribute("force-focus-visible");
// Since at most one popup is open at any given time, we can set globally.
DownloadsCommon.getIndicatorData(
window
).attentionSuppressed &= ~DownloadsCommon.SUPPRESS_PANEL_OPEN;
// Allow the anchor to be hidden.
DownloadsButton.releaseAnchor();
// Allow the panel to be reopened.
this._state = this.kStateHidden;
},
// Related operations
/**
* Shows or focuses the user interface dedicated to downloads history.
*/
showDownloadsHistory() {
DownloadsCommon.log("Showing download history.");
// Hide the panel before showing another window, otherwise focus will return
// to the browser window when the panel closes automatically.
this.hidePanel();
BrowserDownloadsUI();
},
// Internal functions
/**
* Attach event listeners to a panel element. These listeners should be
* removed in _unattachEventListeners. This is called automatically after the
* panel has successfully loaded.
*/
_attachEventListeners() {
// Handle keydown to support accel-V.
this.panel.addEventListener("keydown", this);
// Handle keypress to be able to preventDefault() events before they reach
// the richlistbox, for keyboard navigation.
this.panel.addEventListener("keypress", this);
this.panel.addEventListener("mousemove", this);
DownloadsView.richListBox.addEventListener("focus", this);
DownloadsView.richListBox.addEventListener("select", this);
},
/**
* Unattach event listeners that were added in _attachEventListeners. This
* is called automatically on panel termination.
*/
_unattachEventListeners() {
this.panel.removeEventListener("keydown", this);
this.panel.removeEventListener("keypress", this);
this.panel.removeEventListener("mousemove", this);
DownloadsView.richListBox.removeEventListener("focus", this);
DownloadsView.richListBox.removeEventListener("select", this);
},
_onKeyPress(aEvent) {
// Handle unmodified keys only.
if (aEvent.altKey || aEvent.ctrlKey || aEvent.shiftKey || aEvent.metaKey) {
return;
}
// Pass keypress events to the richlistbox view when it's focused.
if (document.activeElement === DownloadsView.richListBox) {
DownloadsView.onDownloadKeyPress(aEvent);
}
},
/**
* Keydown listener that listens for the keys to start key focusing, as well
* as the the accel-V "paste" event, which initiates a file download if the
* pasted item can be resolved to a URI.
*/
_onKeyDown(aEvent) {
if (DownloadsView.richListBox.hasAttribute("disabled")) {
this._handlePotentiallySpammyDownloadActivation(aEvent);
return;
}
let richListBox = DownloadsView.richListBox;
// If the user has pressed the up or down cursor key, force-enable focus
// indicators for the richlistbox. :focus-visible doesn't work in this case
// because the the focused element may not change here if the richlistbox
// already had focus. The force-focus-visible attribute will be removed
// again if the user moves the mouse on the panel or if the panel is closed.
if (
aEvent.keyCode == aEvent.DOM_VK_UP ||
aEvent.keyCode == aEvent.DOM_VK_DOWN
) {
richListBox.setAttribute("force-focus-visible", "true");
}
// If the footer is focused and the downloads list has at least 1 element
// in it, focus the last element in the list when going up.
if (aEvent.keyCode == aEvent.DOM_VK_UP && richListBox.firstElementChild) {
if (
document
.getElementById("downloadsFooter")
.contains(document.activeElement)
) {
richListBox.selectedItem = richListBox.lastElementChild;
richListBox.focus();
aEvent.preventDefault();
return;
}
}
if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
// If the last element in the list is selected, or the footer is already
// focused, focus the footer.
if (
richListBox.selectedItem === richListBox.lastElementChild ||
document
.getElementById("downloadsFooter")
.contains(document.activeElement)
) {
richListBox.selectedIndex = -1;
DownloadsFooter.focus();
aEvent.preventDefault();
return;
}
}
let pasting =
aEvent.keyCode == aEvent.DOM_VK_V && aEvent.getModifierState("Accel");
if (!pasting) {
return;
}
DownloadsCommon.log("Received a paste event.");
let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
Ci.nsITransferable
);
trans.init(null);
let flavors = ["text/x-moz-url", "text/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) {}
},
_onSelect() {
let richlistbox = DownloadsView.richListBox;
richlistbox.itemChildren.forEach(item => {
let button = item.querySelector("button");
if (item.selected) {
button.removeAttribute("tabindex");
} else {
button.setAttribute("tabindex", -1);
}
});
},
/**
* Move focus to the main element in the downloads panel, unless another
* element in the panel is already focused.
*/
_focusPanel() {
// We may be invoked while the panel is still waiting to be shown.
if (this._state != this.kStateShown) {
return;
}
if (document.activeElement && this.panel.contains(document.activeElement)) {
return;
}
let focusOptions = {};
if (this._preventFocusRing) {
focusOptions.focusVisible = false;
}
if (DownloadsView.richListBox.itemCount > 0) {
DownloadsView.richListBox.selectedIndex = 0;
DownloadsView.richListBox.focus(focusOptions);
} else {
DownloadsFooter.focus(focusOptions);
}
},
_delayPopupItems() {
DownloadsView.richListBox.setAttribute("disabled", true);
this._startWatchingForSpammyDownloadActivation();
this._refreshDelayTimer();
},
_refreshDelayTimer() {
// If timeout already exists, overwrite it to avoid multiple timeouts.
if (this._delayTimeout) {
clearTimeout(this._delayTimeout);
}
let delay = Services.prefs.getIntPref("security.dialog_enable_delay");
this._delayTimeout = setTimeout(() => {
DownloadsView.richListBox.removeAttribute("disabled");
this._stopWatchingForSpammyDownloadActivation();
this._focusPanel();
this._delayTimeout = null;
}, delay);
},
_startWatchingForSpammyDownloadActivation() {
Services.els.addSystemEventListener(window, "keydown", this, true);
},
_lastBeepTime: 0,
_handlePotentiallySpammyDownloadActivation(aEvent) {
if (aEvent.key == "Enter" || aEvent.key == " ") {
// Throttle our beeping to a maximum of once per second, otherwise it
// appears on Win10 that beeps never make it through at all.
if (Date.now() - this._lastBeepTime > 1000) {
Cc["@mozilla.org/sound;1"].getService(Ci.nsISound).beep();
this._lastBeepTime = Date.now();
}
this._refreshDelayTimer();
}
},
_stopWatchingForSpammyDownloadActivation() {
Services.els.removeSystemEventListener(window, "keydown", this, true);
},
/**
* Opens the downloads panel when data is ready to be displayed.
*/
_openPopupIfDataReady() {
// We don't want to open the popup if we already displayed it, or if we are
// still loading data.
if (this._state != this.kStateWaitingData || DownloadsView.loading) {
return;
}
// At this point, if the window is minimized, opening the panel could fail
// without any notification, and there would be no way to either open or
// close the panel any more. To prevent this, check if the window is
// minimized and in that case force the panel to the closed state.
if (window.windowState == window.STATE_MINIMIZED) {
this._state = this.kStateHidden;
return;
}
// Ensure the anchor is visible. If that is not possible, show the panel
// anchored to the top area of the window, near the default anchor position.
let anchor = DownloadsButton.getAnchor();
if (!anchor) {
DownloadsCommon.error("Downloads button cannot be found.");
this._state = this.kStateHidden;
return;
}
let onBookmarksToolbar = !!anchor.closest("#PersonalToolbar");
this.panel.classList.toggle("bookmarks-toolbar", onBookmarksToolbar);
// When the panel is opened, we check if the target files of visible items
// still exist, and update the allowed items interactions accordingly. We
// do these checks on a background thread, and don't prevent the panel to
// be displayed while these checks are being performed.
for (let viewItem of DownloadsView._visibleViewItems.values()) {
viewItem.download.refresh().catch(Cu.reportError);
}
DownloadsCommon.log("Opening downloads panel popup.");
// Delay displaying the panel because this function will sometimes be
// called while another window is closing (like the window for selecting
// whether to save or open the file), and that would cause the panel to
// close immediately.
setTimeout(() => {
PanelMultiView.openPopup(
this.panel,
anchor,
"bottomright topright",
0,
0,
false,
null
).catch(e => {
Cu.reportError(e);
this._state = this.kStateHidden;
});
if (!this._openedManually) {
this._delayPopupItems();
}
}, 0);
},
};
XPCOMUtils.defineConstant(this, "DownloadsPanel", DownloadsPanel);
// DownloadsView
/**
* Builds and updates the downloads list widget, responding to changes in the
* download state and real-time data. In addition, handles part of the user
* interaction events raised by the downloads list widget.
*/
var DownloadsView = {
// Functions handling download items in the list
/**
* Maximum number of items shown by the list at any given time.
*/
kItemCountLimit: 5,
/**
* Indicates whether there is a DownloadsBlockedSubview open.
*/
subViewOpen: false,
/**
* Indicates whether we are still loading downloads data asynchronously.
*/
loading: false,
/**
* Ordered array of all Download objects. We need to keep this array because
* only a limited number of items are shown at once, and if an item that is
* currently visible is removed from the list, we might need to take another
* item from the array and make it appear at the bottom.
*/
_downloads: [],
/**
* Associates the visible Download objects with their corresponding
* DownloadsViewItem object. There is a limited number of view items in the
* panel at any given time.
*/
_visibleViewItems: new Map(),
/**
* Called when the number of items in the list changes.
*/
_itemCountChanged() {
DownloadsCommon.log(
"The downloads item count has changed - we are tracking",
this._downloads.length,
"downloads in total."
);
let count = this._downloads.length;
let hiddenCount = count - this.kItemCountLimit;
if (count > 0) {
DownloadsCommon.log(
"Setting the panel's hasdownloads attribute to true."
);
DownloadsPanel.panel.setAttribute("hasdownloads", "true");
} else {
DownloadsCommon.log("Removing the panel's hasdownloads attribute.");
DownloadsPanel.panel.removeAttribute("hasdownloads");
}
// If we've got some hidden downloads, we should activate the
// DownloadsSummary. The DownloadsSummary will determine whether or not
// it's appropriate to actually display the summary.
DownloadsSummary.active = hiddenCount > 0;
},
/**
* Element corresponding to the list of downloads.
*/
get richListBox() {
delete this.richListBox;
return (this.richListBox = document.getElementById("downloadsListBox"));
},
/**
* Element corresponding to the button for showing more downloads.
*/
get downloadsHistory() {
delete this.downloadsHistory;
return (this.downloadsHistory = document.getElementById(
"downloadsHistory"
));
},
// Callback functions from DownloadsData
/**
* Called before multiple downloads are about to be loaded.
*/
onDownloadBatchStarting() {
DownloadsCommon.log("onDownloadBatchStarting called for DownloadsView.");
this.loading = true;
},
/**
* Called after data loading finished.
*/
onDownloadBatchEnded() {
DownloadsCommon.log("onDownloadBatchEnded called for DownloadsView.");
this.loading = false;
// We suppressed item count change notifications during the batch load, at
// this point we should just call the function once.
this._itemCountChanged();
// Notify the panel that all the initially available downloads have been
// loaded. This ensures that the interface is visible, if still required.
DownloadsPanel.onViewLoadCompleted();
},
/**
* Called when a new download data item is available, either during the
* asynchronous data load or when a new download is started.
*
* @param aDownload
* Download object that was just added.
*/
onDownloadAdded(download) {
DownloadsCommon.log("A new download data item was added");
this._downloads.unshift(download);
// The newly added item is visible in the panel and we must add the
// corresponding element. If the list overflows, remove the last item from
// the panel to make room for the new one that we just added at the top.
this._addViewItem(download, true);
if (this._downloads.length > this.kItemCountLimit) {
this._removeViewItem(this._downloads[this.kItemCountLimit]);
}
// For better performance during batch loads, don't update the count for
// every item, because the interface won't be visible until load finishes.
if (!this.loading) {
this._itemCountChanged();
}
},
onDownloadChanged(download) {
let viewItem = this._visibleViewItems.get(download);
if (viewItem) {
viewItem.onChanged();
}
},
/**
* Called when a data item is removed. Ensures that the widget associated
* with the view item is removed from the user interface.
*
* @param download
* Download object that is being removed.
*/
onDownloadRemoved(download) {
DownloadsCommon.log("A download data item was removed.");
let itemIndex = this._downloads.indexOf(download);
this._downloads.splice(itemIndex, 1);
if (itemIndex < this.kItemCountLimit) {
// The item to remove is visible in the panel.
this._removeViewItem(download);
if (this._downloads.length >= this.kItemCountLimit) {
// Reinsert the next item into the panel.
this._addViewItem(this._downloads[this.kItemCountLimit - 1], false);
}
}
this._itemCountChanged();
},
/**
* Associates each richlistitem for a download with its corresponding
* DownloadsViewItem object.
*/
_itemsForElements: new Map(),
itemForElement(element) {
return this._itemsForElements.get(element);
},
/**
* Creates a new view item associated with the specified data item, and adds
* it to the top or the bottom of the list.
*/
_addViewItem(download, aNewest) {
DownloadsCommon.log(
"Adding a new DownloadsViewItem to the downloads list.",
"aNewest =",
aNewest
);
let element = document.createXULElement("richlistitem");
element.setAttribute("align", "center");
let viewItem = new DownloadsViewItem(download, element);
this._visibleViewItems.set(download, viewItem);
this._itemsForElements.set(element, viewItem);
if (aNewest) {
this.richListBox.insertBefore(
element,
this.richListBox.firstElementChild
);
} else {
this.richListBox.appendChild(element);
}
viewItem.ensureActive();
},
/**
* Removes the view item associated with the specified data item.
*/
_removeViewItem(download) {
DownloadsCommon.log(
"Removing a DownloadsViewItem from the downloads list."
);
let element = this._visibleViewItems.get(download).element;
let previousSelectedIndex = this.richListBox.selectedIndex;
this.richListBox.removeChild(element);
if (previousSelectedIndex != -1) {
this.richListBox.selectedIndex = Math.min(
previousSelectedIndex,
this.richListBox.itemCount - 1
);
}
this._visibleViewItems.delete(download);
this._itemsForElements.delete(element);
},
// User interface event functions
onDownloadClick(aEvent) {
// Handle primary clicks in the main area only:
if (aEvent.button == 0 && aEvent.target.closest(".downloadMainArea")) {
let target = aEvent.target;
while (target.nodeName != "richlistitem") {
target = target.parentNode;
}
let download = DownloadsView.itemForElement(target).download;
if (download.succeeded) {
download._launchedFromPanel = true;
}
let command = "downloadsCmd_open";
if (download.hasBlockedData) {
command = "downloadsCmd_showBlockedInfo";
} else if (aEvent.shiftKey || aEvent.ctrlKey || aEvent.metaKey) {
// We adjust the command for supported modifiers to suggest where the download
// may be opened
let openWhere = target.ownerGlobal.whereToOpenLink(aEvent, false, true);
if (["tab", "window", "tabshifted"].includes(openWhere)) {
command += ":" + openWhere;
}
}
// Toggle opening the file after the download has completed
if (
!download.stopped &&
command.startsWith("downloadsCmd_open") &&
Services.prefs.getBoolPref(
"browser.download.improvements_to_download_panel"
)
) {
download.launchWhenSucceeded = !download.launchWhenSucceeded;
download._launchedFromPanel = download.launchWhenSucceeded;
}
DownloadsCommon.log("onDownloadClick, resolved command: ", command);
goDoCommand(command);
}
},
onDownloadButton(event) {
let target = event.target.closest("richlistitem");
DownloadsView.itemForElement(target).onButton();
},
/**
* Handles keypress events on a download item.
*/
onDownloadKeyPress(aEvent) {
// Pressing the key on buttons should not invoke the action because the
// event has already been handled by the button itself.
if (
aEvent.originalTarget.hasAttribute("command") ||
aEvent.originalTarget.hasAttribute("oncommand")
) {
return;
}
if (aEvent.charCode == " ".charCodeAt(0)) {
aEvent.preventDefault();
goDoCommand("downloadsCmd_pauseResume");
return;
}
if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
let readyToDownload = !DownloadsView.richListBox.disabled;
if (readyToDownload) {
goDoCommand("downloadsCmd_doDefault");
}
}
},
get contextMenu() {
let menu = document.getElementById("downloadsContextMenu");
if (menu) {
delete this.contextMenu;
this.contextMenu = menu;
}
return menu;
},
/**
* Indicates whether there is an open contextMenu for a download item.
*/
get contextMenuOpen() {
return this.contextMenu.state != "closed";
},
/**
* Mouse listeners to handle selection on hover.
*/
onDownloadMouseOver(aEvent) {
let item = aEvent.target.closest("richlistitem,richlistbox");
if (item.localName != "richlistitem") {
return;
}
if (aEvent.target.classList.contains("downloadButton")) {
item.classList.add("downloadHoveringButton");
}
item.classList.toggle(
"hoveringMainArea",
aEvent.target.closest(".downloadMainArea")
);
if (!this.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();
DownloadsViewUI.updateContextMenuForElement(this.contextMenu, element);
// Hide the copy location item if there is somehow no URL. We have to do
// this here instead of in DownloadsViewUI because DownloadsPlacesView
// allows selecting multiple downloads, so in that view the menuitem will be
// shown according to whether at least one of the selected items has a URL.
this.contextMenu.querySelector(
".downloadCopyLocationMenuItem"
).hidden = !element._shell.download.source?.url;
},
onDownloadDragStart(aEvent) {
let element = aEvent.target.closest("richlistitem");
if (!element) {
return;
}
// We must check for existence synchronously because this is a DOM event.
let file = new FileUtils.File(
DownloadsView.itemForElement(element).download.target.path
);
if (!file.exists()) {
return;
}
let dataTransfer = aEvent.dataTransfer;
dataTransfer.mozSetDataAt("application/x-moz-file", file, 0);
dataTransfer.effectAllowed = "copyMove";
let spec = NetUtil.newURI(file).spec;
dataTransfer.setData("text/uri-list", spec);
dataTransfer.setData("text/plain", spec);
dataTransfer.addElement(element);
aEvent.stopPropagation();
},
};
XPCOMUtils.defineConstant(this, "DownloadsView", DownloadsView);
// DownloadsViewItem
/**
* Builds and updates a single item in the downloads list widget, responding to
* changes in the download state and real-time data, and handles the user
* interaction events related to a single item in the downloads list widgets.
*
* @param download
* Download object to be associated with the view item.
* @param aElement
* XUL element corresponding to the single download item in the view.
*/
class DownloadsViewItem extends DownloadsViewUI.DownloadElementShell {
constructor(download, aElement) {
super();
this.download = download;
this.element = aElement;
this.element._shell = this;
this.element.setAttribute("type", "download");
this.element.classList.add("download-state");
this.isPanel = true;
}
onChanged() {
let newState = DownloadsCommon.stateOfDownload(this.download);
if (this.downloadState !== newState) {
this.downloadState = newState;
this._updateState();
} else {
this._updateStateInner();
}
}
isCommandEnabled(aCommand) {
switch (aCommand) {
case "downloadsCmd_open":
case "downloadsCmd_open:current":
case "downloadsCmd_open:tab":
case "downloadsCmd_open:tabshifted":
case "downloadsCmd_open:window":
case "downloadsCmd_alwaysOpenSimilarFiles": {
if (!this.download.succeeded) {
return false;
}
let file = new FileUtils.File(this.download.target.path);
return file.exists();
}
case "downloadsCmd_show": {
let file = new FileUtils.File(this.download.target.path);
if (file.exists()) {
return true;
}
if (!this.download.target.partFilePath) {
return false;
}
let partFile = new FileUtils.File(this.download.target.partFilePath);
return partFile.exists();
}
case "downloadsCmd_copyLocation":
return !!this.download.source?.url;
case "cmd_delete":
case "downloadsCmd_doDefault":
return true;
case "downloadsCmd_showBlockedInfo":
return this.download.hasBlockedData;
}
return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call(
this,
aCommand
);
}
doCommand(aCommand) {
if (this.isCommandEnabled(aCommand)) {
let [command, modifier] = aCommand.split(":");
// split off an optional command "modifier" into an argument,
// e.g. "downloadsCmd_open:window"
this[command](modifier);
}
}
// Item commands
downloadsCmd_unblock() {
DownloadsPanel.hidePanel();
this.confirmUnblock(window, "unblock");
}
downloadsCmd_chooseUnblock() {
DownloadsPanel.hidePanel();
this.confirmUnblock(window, "chooseUnblock");
}
downloadsCmd_unblockAndOpen() {
DownloadsPanel.hidePanel();
this.unblockAndOpenDownload().catch(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_alwaysOpenSimilarFiles() {
super.downloadsCmd_alwaysOpenSimilarFiles();
// We explicitly close the panel here to give the user the feedback that
// their click has been received, and we're handling the action.
DownloadsPanel.hidePanel();
}
downloadsCmd_show() {
let file = new FileUtils.File(this.download.target.path);
DownloadsCommon.showDownloadedFile(file);
// We explicitly close the panel here to give the user the feedback that
// their click has been received, and we're handling the action.
// Otherwise, we'd have to wait for the operating system file manager
// window to open before the panel closed. This also helps to prevent the
// user from opening the containing folder several times.
DownloadsPanel.hidePanel();
}
async downloadsCmd_deleteFile() {
await super.downloadsCmd_deleteFile();
// Protects against an unusual edge case where the user:
// 1) downloads a file with Firefox; 2) deletes the file from outside of Firefox, e.g., a file manager;
// 3) downloads the same file from the same source; 4) opens the downloads panel and uses the menuitem to delete one of those 2 files;
// Under those conditions, Firefox will make 2 view items even though there's only 1 file.
// Using this method will only delete the view item it was called on, because this instance is not aware of other view items with identical targets.
// So the remaining view item needs to be refreshed to hide the "Delete" option.
// That example only concerns 2 duplicate view items but you can have an arbitrary number, so iterate over all items...
for (let viewItem of DownloadsView._visibleViewItems.values()) {
viewItem.download.refresh().catch(Cu.reportError);
}
// Don't use DownloadsPanel.hidePanel for this method because it will remove
// the view item from the list, which is already sufficient feedback.
}
downloadsCmd_showBlockedInfo() {
DownloadsBlockedSubview.toggle(
this.element,
...this.rawBlockedTitleAndDetails
);
}
downloadsCmd_openReferrer() {
openURL(this.download.source.referrerInfo.originalReferrer);
}
downloadsCmd_copyLocation() {
DownloadsCommon.copyDownloadLink(this.download);
}
downloadsCmd_doDefault() {
let defaultCommand = this.currentDefaultCommandName;
if (defaultCommand && this.isCommandEnabled(defaultCommand)) {
this.doCommand(defaultCommand);
}
}
}
// DownloadsViewController
/**
* Handles part of the user interaction events raised by the downloads list
* widget, in particular the "commands" that apply to multiple items, and
* dispatches the commands that apply to individual items.
*/
var DownloadsViewController = {
// Initialization and termination
initialize() {
window.controllers.insertControllerAt(0, this);
},
terminate() {
window.controllers.removeController(this);
},
// nsIController
supportsCommand(aCommand) {
if (aCommand === "downloadsCmd_clearList") {
return true;
}
// Firstly, determine if this is a command that we can handle.
if (!DownloadsViewUI.isCommandName(aCommand)) {
return false;
}
// Strip off any :modifier suffix before checking if the command name is
// a method on our view
let [command] = aCommand.split(":");
if (!(command in this) && !(command in DownloadsViewItem.prototype)) {
return false;
}
// The currently supported commands depend on whether the blocked subview is
// showing. If it is, then take the following path.
if (DownloadsView.subViewOpen) {
let blockedSubviewCmds = [
"downloadsCmd_unblockAndOpen",
"cmd_delete",
"downloadsCmd_unblockAndSave",
];
return blockedSubviewCmds.includes(aCommand);
}
// If the blocked subview is not showing, then determine if focus is on a
// control in the downloads list.
let element = document.commandDispatcher.focusedElement;
while (element && element != DownloadsView.richListBox) {
element = element.parentNode;
}
// We should handle the command only if the downloads list is among the
// ancestors of the focused element.
return !!element;
},
isCommandEnabled(aCommand) {
// Handle commands that are not selection-specific.
if (aCommand == "downloadsCmd_clearList") {
return DownloadsCommon.getData(window).canRemoveFinished;
}
// Other commands are selection-specific.
let element = DownloadsView.richListBox.selectedItem;
return (
element &&
DownloadsView.itemForElement(element).isCommandEnabled(aCommand)
);
},
doCommand(aCommand) {
// If this command is not selection-specific, execute it.
if (aCommand in this) {
this[aCommand]();
return;
}
// Other commands are selection-specific.
let element = DownloadsView.richListBox.selectedItem;
if (element) {
// The doCommand function also checks if the command is enabled.
DownloadsView.itemForElement(element).doCommand(aCommand);
}
},
onEvent() {},
// Other functions
updateCommands() {
function updateCommandsForObject(object) {
for (let name in object) {
if (DownloadsViewUI.isCommandName(name)) {
goUpdateCommand(name);
}
}
}
updateCommandsForObject(this);
updateCommandsForObject(DownloadsViewItem.prototype);
},
// Selection-independent commands
downloadsCmd_clearList() {
DownloadsCommon.getData(window).removeFinished();
},
};
XPCOMUtils.defineConstant(
this,
"DownloadsViewController",
DownloadsViewController
);
// DownloadsSummary
/**
* Manages the summary at the bottom of the downloads panel list if the number
* of items in the list exceeds the panels limit.
*/
var DownloadsSummary = {
/**
* Sets the active state of the summary. When active, the summary subscribes
* to the DownloadsCommon DownloadsSummaryData singleton.
*
* @param aActive
* Set to true to activate the summary.
*/
set active(aActive) {
if (aActive == this._active || !this._summaryNode) {
return;
}
if (aActive) {
DownloadsCommon.getSummary(
window,
DownloadsView.kItemCountLimit
).refreshView(this);
} else {
DownloadsFooter.showingSummary = false;
}
this._active = aActive;
},
/**
* Returns the active state of the downloads summary.
*/
get active() {
return this._active;
},
_active: false,
/**
* Sets whether or not we show the progress bar.
*
* @param aShowingProgress
* True if we should show the progress bar.
*/
set showingProgress(aShowingProgress) {
if (aShowingProgress) {
this._summaryNode.setAttribute("inprogress", "true");
} else {
this._summaryNode.removeAttribute("inprogress");
}
// If progress isn't being shown, then we simply do not show the summary.
DownloadsFooter.showingSummary = aShowingProgress;
},
/**
* Sets the amount of progress that is visible in the progress bar.
*
* @param aValue
* A value between 0 and 100 to represent the progress of the
* summarized downloads.
*/
set percentComplete(aValue) {
if (this._progressNode) {
this._progressNode.setAttribute("value", aValue);
}
},
/**
* Sets the description for the download summary.
*
* @param aValue
* A string representing the description of the summarized
* downloads.
*/
set description(aValue) {
if (this._descriptionNode) {
this._descriptionNode.setAttribute("value", aValue);
this._descriptionNode.setAttribute("tooltiptext", aValue);
}
},
/**
* Sets the details for the download summary, such as the time remaining,
* the amount of bytes transferred, etc.
*
* @param aValue
* A string representing the details of the summarized
* downloads.
*/
set details(aValue) {
if (this._detailsNode) {
this._detailsNode.setAttribute("value", aValue);
this._detailsNode.setAttribute("tooltiptext", aValue);
}
},
/**
* Focuses the root element of the summary.
*/
focus(focusOptions) {
if (this._summaryNode) {
this._summaryNode.focus(focusOptions);
}
},
/**
* Respond to keydown events on the Downloads Summary node.
*
* @param aEvent
* The keydown event being handled.
*/
onKeyDown(aEvent) {
if (
aEvent.charCode == " ".charCodeAt(0) ||
aEvent.keyCode == KeyEvent.DOM_VK_RETURN
) {
DownloadsPanel.showDownloadsHistory();
}
},
/**
* Respond to click events on the Downloads Summary node.
*
* @param aEvent
* The click event being handled.
*/
onClick(aEvent) {
DownloadsPanel.showDownloadsHistory();
},
/**
* Element corresponding to the root of the downloads summary.
*/
get _summaryNode() {
let node = document.getElementById("downloadsSummary");
if (!node) {
return null;
}
delete this._summaryNode;
return (this._summaryNode = node);
},
/**
* Element corresponding to the progress bar in the downloads summary.
*/
get _progressNode() {
let node = document.getElementById("downloadsSummaryProgress");
if (!node) {
return null;
}
delete this._progressNode;
return (this._progressNode = node);
},
/**
* Element corresponding to the main description of the downloads
* summary.
*/
get _descriptionNode() {
let node = document.getElementById("downloadsSummaryDescription");
if (!node) {
return null;
}
delete this._descriptionNode;
return (this._descriptionNode = node);
},
/**
* Element corresponding to the secondary description of the downloads
* summary.
*/
get _detailsNode() {
let node = document.getElementById("downloadsSummaryDetails");
if (!node) {
return null;
}
delete this._detailsNode;
return (this._detailsNode = node);
},
};
XPCOMUtils.defineConstant(this, "DownloadsSummary", DownloadsSummary);
// DownloadsFooter
/**
* Manages events sent to to the footer vbox, which contains both the
* DownloadsSummary as well as the "Show all downloads" button.
*/
var DownloadsFooter = {
/**
* Focuses the appropriate element within the footer. If the summary
* is visible, focus it. If not, focus the "Show all downloads"
* button.
*/
focus(focusOptions) {
if (this._showingSummary) {
DownloadsSummary.focus(focusOptions);
} else {
DownloadsView.downloadsHistory.focus(focusOptions);
}
},
_showingSummary: false,
/**
* Sets whether or not the Downloads Summary should be displayed in the
* footer. If not, the "Show all downloads" button is shown instead.
*/
set showingSummary(aValue) {
if (this._footerNode) {
if (aValue) {
this._footerNode.setAttribute("showingsummary", "true");
} else {
this._footerNode.removeAttribute("showingsummary");
}
this._showingSummary = aValue;
}
},
/**
* Element corresponding to the footer of the downloads panel.
*/
get _footerNode() {
let node = document.getElementById("downloadsFooter");
if (!node) {
return null;
}
delete this._footerNode;
return (this._footerNode = node);
},
};
XPCOMUtils.defineConstant(this, "DownloadsFooter", DownloadsFooter);
// DownloadsBlockedSubview
/**
* Manages the blocked subview that slides in when you click a blocked download.
*/
var DownloadsBlockedSubview = {
/**
* Elements in the subview.
*/
get elements() {
let idSuffixes = [
"title",
"details1",
"details2",
"unblockButton",
"deleteButton",
];
let elements = idSuffixes.reduce((memo, s) => {
memo[s] = document.getElementById("downloadsPanel-blockedSubview-" + s);
return memo;
}, {});
delete this.elements;
return (this.elements = elements);
},
/**
* The blocked-download richlistitem element that was clicked to show the
* subview. If the subview is not showing, this is undefined.
*/
element: undefined,
/**
* Slides in the blocked subview.
*
* @param element
* The blocked-download richlistitem element that was clicked.
* @param title
* The title to show in the subview.
* @param details
* An array of strings with information about the block.
*/
toggle(element, title, details) {
DownloadsView.subViewOpen = true;
DownloadsViewController.updateCommands();
const { download } = DownloadsView.itemForElement(element);
let e = this.elements;
let s = DownloadsCommon.strings;
title.l10n
? document.l10n.setAttributes(e.title, title.l10n.id, title.l10n.args)
: (e.title.textContent = title);
details[0].l10n
? document.l10n.setAttributes(
e.details1,
details[0].l10n.id,
details[0].l10n.args
)
: (e.details1.textContent = details[0]);
e.details2.textContent = details[1];
if (download.launchWhenSucceeded) {
e.unblockButton.label = s.unblockButtonOpen;
e.unblockButton.command = "downloadsCmd_unblockAndOpen";
} else {
e.unblockButton.label = s.unblockButtonUnblock;
e.unblockButton.command = "downloadsCmd_unblockAndSave";
}
e.deleteButton.label = s.unblockButtonConfirmBlock;
let verdict = element.getAttribute("verdict");
this.subview.setAttribute("verdict", verdict);
this.mainView.addEventListener("ViewShown", this);
DownloadsPanel.panel.addEventListener("popuphidden", this);
this.panelMultiView.showSubView(this.subview);
// Without this, the mainView is more narrow than the panel once all
// downloads are removed from the panel.
this.mainView.style.minWidth = window.getComputedStyle(this.subview).width;
},
handleEvent(event) {
// This is called when the main view is shown or the panel is hidden.
DownloadsView.subViewOpen = false;
this.mainView.removeEventListener("ViewShown", this);
DownloadsPanel.panel.removeEventListener("popuphidden", this);
// Focus the proper element if we're going back to the main panel.
if (event.type == "ViewShown") {
DownloadsPanel.showPanel();
}
},
/**
* Deletes the download and hides the entire panel.
*/
confirmBlock() {
goDoCommand("cmd_delete");
DownloadsPanel.hidePanel();
},
};
XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "panelMultiView", () =>
document.getElementById("downloadsPanel-multiView")
);
XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "mainView", () =>
document.getElementById("downloadsPanel-mainView")
);
XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "subview", () =>
document.getElementById("downloadsPanel-blockedSubview")
);
XPCOMUtils.defineConstant(
this,
"DownloadsBlockedSubview",
DownloadsBlockedSubview
);