diff options
Diffstat (limited to 'browser/components/downloads/DownloadsViewUI.sys.mjs')
-rw-r--r-- | browser/components/downloads/DownloadsViewUI.sys.mjs | 1201 |
1 files changed, 1201 insertions, 0 deletions
diff --git a/browser/components/downloads/DownloadsViewUI.sys.mjs b/browser/components/downloads/DownloadsViewUI.sys.mjs new file mode 100644 index 0000000000..0e0f5a7af3 --- /dev/null +++ b/browser/components/downloads/DownloadsViewUI.sys.mjs @@ -0,0 +1,1201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This module is imported by code that uses the "download.xml" binding, and + * provides prototypes for objects that handle input and display information. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "handlerSvc", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gReputationService", + "@mozilla.org/reputationservice/application-reputation-service;1", + Ci.nsIApplicationReputationService +); + +import { Integration } from "resource://gre/modules/Integration.sys.mjs"; + +Integration.downloads.defineESModuleGetter( + lazy, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +var gDownloadElementButtons = { + cancel: { + commandName: "downloadsCmd_cancel", + l10nId: "downloads-cmd-cancel", + descriptionL10nId: "downloads-cancel-download", + panelL10nId: "downloads-cmd-cancel-panel", + iconClass: "downloadIconCancel", + }, + retry: { + commandName: "downloadsCmd_retry", + l10nId: "downloads-cmd-retry", + descriptionL10nId: "downloads-retry-download", + panelL10nId: "downloads-cmd-retry-panel", + iconClass: "downloadIconRetry", + }, + show: { + commandName: "downloadsCmd_show", + l10nId: "downloads-cmd-show-button-2", + descriptionL10nId: "downloads-cmd-show-description-2", + panelL10nId: "downloads-cmd-show-panel-2", + iconClass: "downloadIconShow", + }, + subviewOpenOrRemoveFile: { + commandName: "downloadsCmd_showBlockedInfo", + l10nId: "downloads-cmd-choose-open", + descriptionL10nId: "downloads-show-more-information", + panelL10nId: "downloads-cmd-choose-open-panel", + iconClass: "downloadIconSubviewArrow", + }, + askOpenOrRemoveFile: { + commandName: "downloadsCmd_chooseOpen", + l10nId: "downloads-cmd-choose-open", + panelL10nId: "downloads-cmd-choose-open-panel", + iconClass: "downloadIconShow", + }, + askRemoveFileOrAllow: { + commandName: "downloadsCmd_chooseUnblock", + l10nId: "downloads-cmd-choose-unblock", + panelL10nId: "downloads-cmd-choose-unblock-panel", + iconClass: "downloadIconShow", + }, + removeFile: { + commandName: "downloadsCmd_confirmBlock", + l10nId: "downloads-cmd-remove-file", + panelL10nId: "downloads-cmd-remove-file-panel", + iconClass: "downloadIconCancel", + }, +}; + +/** + * Associates each document with a pre-built DOM fragment representing the + * download list item. This is then cloned to create each individual list item. + * This is stored on the document to prevent leaks that would occur if a single + * instance created by one document's DOMParser was stored globally. + */ +var gDownloadListItemFragments = new WeakMap(); + +export var DownloadsViewUI = { + /** + * Returns true if the given string is the name of a command that can be + * handled by the Downloads user interface, including standard commands. + */ + isCommandName(name) { + return name.startsWith("cmd_") || name.startsWith("downloadsCmd_"); + }, + + /** + * Get source url of the download without'http' or'https' prefix. + */ + getStrippedUrl(download) { + return lazy.UrlbarUtils.stripPrefixAndTrim(download?.source?.url, { + stripHttp: true, + stripHttps: true, + })[0]; + }, + + /** + * Returns the user-facing label for the given Download object. This is + * normally the leaf name of the download target file. In case this is a very + * old history download for which the target file is unknown, the download + * source URI is displayed. + */ + getDisplayName(download) { + if ( + download.error?.reputationCheckVerdict == + lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM + ) { + let l10n = { + id: "downloads-blocked-from-url", + args: { url: DownloadsViewUI.getStrippedUrl(download) }, + }; + return { l10n }; + } + return download.target.path + ? PathUtils.filename(download.target.path) + : download.source.url; + }, + + /** + * Given a Download object, returns a string representing its file size with + * an appropriate measurement unit, for example "1.5 MB", or an empty string + * if the size is unknown. + */ + getSizeWithUnits(download) { + if (download.target.size === undefined) { + return ""; + } + + let [size, unit] = lazy.DownloadUtils.convertByteUnits( + download.target.size + ); + return lazy.DownloadsCommon.strings.sizeWithUnits(size, unit); + }, + + /** + * Given a context menu and a download element on which it is invoked, + * update items in the context menu to reflect available options for + * that download element. + */ + updateContextMenuForElement(contextMenu, element) { + // Get the state and ensure only the appropriate items are displayed. + let state = parseInt(element.getAttribute("state"), 10); + + const document = contextMenu.ownerDocument; + + const { + DOWNLOAD_NOTSTARTED, + DOWNLOAD_DOWNLOADING, + DOWNLOAD_FINISHED, + DOWNLOAD_FAILED, + DOWNLOAD_CANCELED, + DOWNLOAD_PAUSED, + DOWNLOAD_BLOCKED_PARENTAL, + DOWNLOAD_DIRTY, + DOWNLOAD_BLOCKED_POLICY, + } = lazy.DownloadsCommon; + + contextMenu.querySelector(".downloadPauseMenuItem").hidden = + state != DOWNLOAD_DOWNLOADING; + + contextMenu.querySelector(".downloadResumeMenuItem").hidden = + state != DOWNLOAD_PAUSED; + + // Only show "unblock" for blocked (dirty) items that have not been + // confirmed and have temporary data: + contextMenu.querySelector(".downloadUnblockMenuItem").hidden = + state != DOWNLOAD_DIRTY || !element.classList.contains("temporary-block"); + + // Can only remove finished/failed/canceled/blocked downloads. + contextMenu.querySelector(".downloadRemoveFromHistoryMenuItem").hidden = ![ + DOWNLOAD_FINISHED, + DOWNLOAD_FAILED, + DOWNLOAD_CANCELED, + DOWNLOAD_BLOCKED_PARENTAL, + DOWNLOAD_DIRTY, + DOWNLOAD_BLOCKED_POLICY, + ].includes(state); + + // Can reveal downloads with data on the file system using the relevant OS + // tool (Explorer, Finder, appropriate Linux file system viewer): + contextMenu.querySelector(".downloadShowMenuItem").hidden = + ![ + DOWNLOAD_NOTSTARTED, + DOWNLOAD_DOWNLOADING, + DOWNLOAD_FINISHED, + DOWNLOAD_PAUSED, + ].includes(state) || + (state == DOWNLOAD_FINISHED && !element.hasAttribute("exists")); + + // Show the separator if we're showing either unblock or reveal menu items. + contextMenu.querySelector(".downloadCommandsSeparator").hidden = + contextMenu.querySelector(".downloadUnblockMenuItem").hidden && + contextMenu.querySelector(".downloadShowMenuItem").hidden; + + let download = element._shell.download; + let mimeInfo = lazy.DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault, defaultDescription } = mimeInfo + ? mimeInfo + : {}; + + // Hide the "Delete" item if there's no file data to delete. + contextMenu.querySelector(".downloadDeleteFileMenuItem").hidden = + download.deleted || + !(download.target?.exists || download.target?.partFileExists); + + // Hide the "Go To Download Page" item if there's no referrer. Ideally the + // Downloads API will require a referrer (see bug 1723712) to create a + // download, but this fallback will ensure any failures aren't user facing. + contextMenu.querySelector(".downloadOpenReferrerMenuItem").hidden = + !download.source.referrerInfo?.originalReferrer; + + // Hide the "use system viewer" and "always use system viewer" items + // if the feature is disabled or this download doesn't support it: + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + let canViewInternally = element.hasAttribute("viewable-internally"); + useSystemViewerItem.hidden = + !lazy.DownloadsCommon.openInSystemViewerItemEnabled || + !canViewInternally || + !download.target?.exists; + + alwaysUseSystemViewerItem.hidden = + !lazy.DownloadsCommon.alwaysOpenInSystemViewerItemEnabled || + !canViewInternally; + + // Set menuitem labels to display the system viewer's name. Stop the l10n + // mutation observer temporarily since we're going to synchronously + // translate the elements to avoid translation delay. See bug 1737951 & bug + // 1746748. This can be simplified when they're resolved. + try { + document.l10n.pauseObserving(); + // Handler descriptions longer than 40 characters will be skipped to avoid + // unreasonably stretching the context menu. + if (defaultDescription && defaultDescription.length < 40) { + document.l10n.setAttributes( + useSystemViewerItem, + "downloads-cmd-use-system-default-named", + { handler: defaultDescription } + ); + document.l10n.setAttributes( + alwaysUseSystemViewerItem, + "downloads-cmd-always-use-system-default-named", + { handler: defaultDescription } + ); + } else { + // In the unlikely event that defaultDescription is somehow missing/invalid, + // fall back to the static "Open In System Viewer" label. + document.l10n.setAttributes( + useSystemViewerItem, + "downloads-cmd-use-system-default" + ); + document.l10n.setAttributes( + alwaysUseSystemViewerItem, + "downloads-cmd-always-use-system-default" + ); + } + } finally { + document.l10n.resumeObserving(); + } + document.l10n.translateElements([ + useSystemViewerItem, + alwaysUseSystemViewerItem, + ]); + + // If non default mime-type or cannot be opened internally, display + // "always open similar files" item instead so that users can add a new + // mimetype to about:preferences table and set to open with system default. + let alwaysOpenSimilarFilesItem = contextMenu.querySelector( + ".downloadAlwaysOpenSimilarFilesMenuItem" + ); + + /** + * In HelperAppDlg.sys.mjs, we determine whether or not an "always open..." checkbox + * should appear in the unknownContentType window. Here, we use similar checks to + * determine if we should show the "always open similar files" context menu item. + * + * Note that we also read the content type using mimeInfo to detect better and available + * mime types, given a file extension. Some sites default to "application/octet-stream", + * further limiting what file types can be added to about:preferences, even for file types + * that are in fact capable of being handled with a default application. + * + * There are also cases where download.contentType is undefined (ex. when opening + * the context menu on a previously downloaded item via download history). + * Using mimeInfo ensures that content type exists and prevents intermittence. + */ + // + let filename = PathUtils.filename(download.target.path); + + let isExemptExecutableExtension = + Services.policies.isExemptExecutableExtension( + download.source.originalUrl || download.source.url, + filename?.split(".").at(-1) + ); + + let shouldNotRememberChoice = + !mimeInfo?.type || + mimeInfo.type === "application/octet-stream" || + mimeInfo.type === "application/x-msdownload" || + mimeInfo.type === "application/x-msdos-program" || + (lazy.gReputationService.isExecutable(filename) && + !isExemptExecutableExtension) || + (mimeInfo.type === "text/plain" && + lazy.gReputationService.isBinary(download.target.path)); + + alwaysOpenSimilarFilesItem.hidden = + canViewInternally || + state !== DOWNLOAD_FINISHED || + shouldNotRememberChoice; + + // Update checkbox for "always open..." options. + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + alwaysOpenSimilarFilesItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + alwaysOpenSimilarFilesItem.removeAttribute("checked"); + } + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + DownloadsViewUI, + "clearHistoryOnDelete", + "browser.download.clearHistoryOnDelete", + 0 +); + +DownloadsViewUI.BaseView = class { + canClearDownloads(nodeContainer) { + // Downloads can be cleared if there's at least one removable download in + // the list (either a history download or a completed session download). + // Because history downloads are always removable and are listed after the + // session downloads, check from bottom to top. + for (let elt = nodeContainer.lastChild; elt; elt = elt.previousSibling) { + // Stopped, paused, and failed downloads with partial data are removed. + let download = elt._shell.download; + if (download.stopped && !(download.canceled && download.hasPartialData)) { + return true; + } + } + return false; + } +}; + +/** + * A download element shell is responsible for handling the commands and the + * displayed data for a single element that uses the "download.xml" binding. + * + * The information to display is obtained through the associated Download object + * from the JavaScript API for downloads, and commands are executed using a + * combination of Download methods and DownloadsCommon.sys.mjs helper functions. + * + * Specialized versions of this shell must be defined, and they are required to + * implement the "download" property or getter. Currently these objects are the + * HistoryDownloadElementShell and the DownloadsViewItem for the panel. The + * history view may use a HistoryDownload object in place of a Download object. + */ +DownloadsViewUI.DownloadElementShell = function () {}; + +DownloadsViewUI.DownloadElementShell.prototype = { + /** + * The richlistitem for the download, initialized by the derived object. + */ + element: null, + + /** + * Manages the "active" state of the shell. By default all the shells are + * inactive, thus their UI is not updated. They must be activated when + * entering the visible area. + */ + ensureActive() { + if (!this._active) { + this._active = true; + this.connect(); + this.onChanged(); + } + }, + get active() { + return !!this._active; + }, + + connect() { + let document = this.element.ownerDocument; + let downloadListItemFragment = gDownloadListItemFragments.get(document); + // When changing the markup within the fragment, please ensure that + // the functions within DownloadsView still operate correctly. + if (!downloadListItemFragment) { + let MozXULElement = document.defaultView.MozXULElement; + downloadListItemFragment = MozXULElement.parseXULToFragment(` + <hbox class="downloadMainArea" flex="1" align="center"> + <image class="downloadTypeIcon"/> + <vbox class="downloadContainer" flex="1" pack="center"> + <description class="downloadTarget" crop="center"/> + <description class="downloadDetails downloadDetailsNormal" + crop="end"/> + <description class="downloadDetails downloadDetailsHover" + crop="end"/> + <description class="downloadDetails downloadDetailsButtonHover" + crop="end"/> + </vbox> + <image class="downloadBlockedBadge" /> + </hbox> + <button class="downloadButton"/> + `); + gDownloadListItemFragments.set(document, downloadListItemFragment); + } + this.element.setAttribute("active", true); + this.element.setAttribute("orient", "horizontal"); + this.element.addEventListener("click", ev => { + ev.target.ownerGlobal.DownloadsView.onDownloadClick(ev); + }); + this.element.appendChild( + document.importNode(downloadListItemFragment, true) + ); + let downloadButton = this.element.querySelector(".downloadButton"); + downloadButton.addEventListener("command", function (event) { + event.target.ownerGlobal.DownloadsView.onDownloadButton(event); + }); + for (let [propertyName, selector] of [ + ["_downloadTypeIcon", ".downloadTypeIcon"], + ["_downloadTarget", ".downloadTarget"], + ["_downloadDetailsNormal", ".downloadDetailsNormal"], + ["_downloadDetailsHover", ".downloadDetailsHover"], + ["_downloadDetailsButtonHover", ".downloadDetailsButtonHover"], + ["_downloadButton", ".downloadButton"], + ]) { + this[propertyName] = this.element.querySelector(selector); + } + + // HTML elements can be created directly without using parseXULToFragment. + let progress = (this._downloadProgress = document.createElementNS( + HTML_NS, + "progress" + )); + progress.className = "downloadProgress"; + progress.setAttribute("max", "100"); + this._downloadTarget.insertAdjacentElement("afterend", progress); + }, + + /** + * URI string for the file type icon displayed in the download element. + */ + get image() { + if (!this.download.target.path) { + // Old history downloads may not have a target path. + return "moz-icon://.unknown?size=32"; + } + + // When a download that was previously in progress finishes successfully, it + // means that the target file now exists and we can extract its specific + // icon, for example from a Windows executable. To ensure that the icon is + // reloaded, however, we must change the URI used by the XUL image element, + // for example by adding a query parameter. This only works if we add one of + // the parameters explicitly supported by the nsIMozIconURI interface. + return ( + "moz-icon://" + + this.download.target.path + + "?size=32" + + (this.download.succeeded ? "&state=normal" : "") + ); + }, + + get browserWindow() { + return lazy.BrowserWindowTracker.getTopWindow(); + }, + + /** + * Updates the display name and icon. + * + * @param displayName + * This is usually the full file name of the download without the path. + * @param icon + * URL of the icon to load, generally from the "image" property. + */ + showDisplayNameAndIcon(displayName, icon) { + if (displayName.l10n) { + let document = this.element.ownerDocument; + document.l10n.setAttributes( + this._downloadTarget, + displayName.l10n.id, + displayName.l10n.args + ); + } else { + this._downloadTarget.setAttribute("value", displayName); + this._downloadTarget.setAttribute("tooltiptext", displayName); + } + this._downloadTypeIcon.setAttribute("src", icon); + }, + + /** + * Updates the displayed progress bar. + * + * @param mode + * Either "normal" or "undetermined". + * @param value + * Percentage of the progress bar to display, from 0 to 100. + * @param paused + * True to display the progress bar style for paused downloads. + */ + showProgress(mode, value, paused) { + if (mode == "undetermined") { + this._downloadProgress.removeAttribute("value"); + } else { + this._downloadProgress.setAttribute("value", value); + } + this._downloadProgress.toggleAttribute("paused", !!paused); + }, + + /** + * Updates the full status line. + * + * @param status + * Status line of the Downloads Panel or the Downloads View. + * @param hoverStatus + * Label to show in the Downloads Panel when the mouse pointer is over + * the main area of the item. If not specified, this will be the same + * as the status line. This is ignored in the Downloads View. Type is + * either l10n object or string literal. + */ + showStatus(status, hoverStatus = status) { + let document = this.element.ownerDocument; + if (status?.l10n) { + document.l10n.setAttributes( + this._downloadDetailsNormal, + status.l10n.id, + status.l10n.args + ); + } else { + this._downloadDetailsNormal.removeAttribute("data-l10n-id"); + this._downloadDetailsNormal.setAttribute("value", status); + this._downloadDetailsNormal.setAttribute("tooltiptext", status); + } + if (hoverStatus?.l10n) { + document.l10n.setAttributes( + this._downloadDetailsHover, + hoverStatus.l10n.id, + hoverStatus.l10n.args + ); + } else { + this._downloadDetailsHover.removeAttribute("data-l10n-id"); + this._downloadDetailsHover.setAttribute("value", hoverStatus); + } + }, + + /** + * Updates the status line combining the given state label with other labels. + * + * @param stateLabel + * Label representing the state of the download, for example "Failed". + * In the Downloads Panel, this is the only text displayed when the + * the mouse pointer is not over the main area of the item. In the + * Downloads View, this label is combined with the host and date, for + * example "Failed - example.com - 1:45 PM". + * @param hoverStatus + * Label to show in the Downloads Panel when the mouse pointer is over + * the main area of the item. If not specified, this will be the + * state label combined with the host and date. This is ignored in the + * Downloads View. Type is either l10n object or string literal. + */ + showStatusWithDetails(stateLabel, hoverStatus) { + if (stateLabel.l10n) { + this.showStatus(stateLabel, hoverStatus); + return; + } + let [displayHost] = lazy.DownloadUtils.getURIHost(this.download.source.url); + let [displayDate] = lazy.DownloadUtils.getReadableDates( + new Date(this.download.endTime) + ); + + let firstPart = lazy.DownloadsCommon.strings.statusSeparator( + stateLabel, + displayHost + ); + let fullStatus = lazy.DownloadsCommon.strings.statusSeparator( + firstPart, + displayDate + ); + + if (!this.isPanel) { + this.showStatus(fullStatus); + } else { + this.showStatus(stateLabel, hoverStatus || fullStatus); + } + }, + + /** + * Updates the main action button and makes it visible. + * + * @param type + * One of the presets defined in gDownloadElementButtons. + */ + showButton(type) { + let { commandName, l10nId, descriptionL10nId, panelL10nId, iconClass } = + gDownloadElementButtons[type]; + + this.buttonCommandName = commandName; + let stringId = this.isPanel ? panelL10nId : l10nId; + let document = this.element.ownerDocument; + document.l10n.setAttributes(this._downloadButton, stringId); + if (this.isPanel && descriptionL10nId) { + document.l10n.setAttributes( + this._downloadDetailsButtonHover, + descriptionL10nId + ); + } + this._downloadButton.setAttribute("class", "downloadButton " + iconClass); + this._downloadButton.removeAttribute("hidden"); + }, + + hideButton() { + this._downloadButton.hidden = true; + }, + + lastEstimatedSecondsLeft: Infinity, + + /** + * This is called when a major state change occurs in the download, but is not + * called for every progress update in order to improve performance. + */ + _updateState() { + this.showDisplayNameAndIcon( + DownloadsViewUI.getDisplayName(this.download), + this.image + ); + this.element.setAttribute( + "state", + lazy.DownloadsCommon.stateOfDownload(this.download) + ); + + if (!this.download.stopped) { + // When the download becomes in progress, we make all the major changes to + // the user interface here. The _updateStateInner function takes care of + // displaying the right button type for all other state changes. + this.showButton("cancel"); + + // If there was a verdict set but the download is running we can assume + // that the verdict has been overruled and can be removed. + this.element.removeAttribute("verdict"); + } + + // Since state changed, reset the time left estimation. + this.lastEstimatedSecondsLeft = Infinity; + + this._updateStateInner(); + }, + + /** + * This is called for all changes in the download, including progress updates. + * For major state changes, _updateState is called first, but several elements + * are still updated here. When the download is in progress, this function + * takes a faster path with less element updates to improve performance. + */ + _updateStateInner() { + let progressPaused = false; + + this.element.classList.toggle("openWhenFinished", !this.download.stopped); + + if (!this.download.stopped) { + // The download is in progress, so we don't change the button state + // because the _updateState function already did it. We still need to + // update all elements that may change during the download. + let totalBytes = this.download.hasProgress + ? this.download.totalBytes + : -1; + let [status, newEstimatedSecondsLeft] = + lazy.DownloadUtils.getDownloadStatus( + this.download.currentBytes, + totalBytes, + this.download.speed, + this.lastEstimatedSecondsLeft + ); + this.lastEstimatedSecondsLeft = newEstimatedSecondsLeft; + + if (this.download.launchWhenSucceeded) { + status = lazy.DownloadUtils.getFormattedTimeStatus( + newEstimatedSecondsLeft + ); + } + let hoverStatus = { + l10n: { id: "downloading-file-click-to-open" }, + }; + this.showStatus(status, hoverStatus); + } else { + let verdict = ""; + + // The download is not in progress, so we update the user interface based + // on other properties. The order in which we check the properties of the + // Download object is the same used by stateOfDownload. + if (this.download.deleted) { + this.showDeletedOrMissing(); + } else if (this.download.succeeded) { + lazy.DownloadsCommon.log( + "_updateStateInner, target exists? ", + this.download.target.path, + this.download.target.exists + ); + if (this.download.target.exists) { + // This is a completed download, and the target file still exists. + this.element.setAttribute("exists", "true"); + + this.element.toggleAttribute( + "viewable-internally", + lazy.DownloadIntegration.shouldViewDownloadInternally( + lazy.DownloadsCommon.getMimeInfo(this.download)?.type + ) + ); + + let sizeWithUnits = DownloadsViewUI.getSizeWithUnits(this.download); + if (this.isPanel) { + // In the Downloads Panel, we show the file size after the state + // label, for example "Completed - 1.5 MB". When the pointer is over + // the main area of the item, this label is replaced with a + // description of the default action, which opens the file. + let status = lazy.DownloadsCommon.strings.stateCompleted; + if (sizeWithUnits) { + status = lazy.DownloadsCommon.strings.statusSeparator( + status, + sizeWithUnits + ); + } + this.showStatus(status, { l10n: { id: "downloads-open-file" } }); + } else { + // In the Downloads View, we show the file size in place of the + // state label, for example "1.5 MB - example.com - 1:45 PM". + this.showStatusWithDetails( + sizeWithUnits || lazy.DownloadsCommon.strings.sizeUnknown + ); + } + this.showButton("show"); + } else { + // This is a completed download, but the target file does not exist + // anymore, so the main action of opening the file is unavailable. + this.showDeletedOrMissing(); + } + } else if (this.download.error) { + if (this.download.error.becauseBlockedByParentalControls) { + // This download was blocked permanently by parental controls. + this.showStatusWithDetails( + lazy.DownloadsCommon.strings.stateBlockedParentalControls + ); + this.hideButton(); + } else if (this.download.error.becauseBlockedByReputationCheck) { + verdict = this.download.error.reputationCheckVerdict; + let hover = ""; + if (!this.download.hasBlockedData) { + // This download was blocked permanently by reputation check. + this.hideButton(); + } else if (this.isPanel) { + // This download was blocked temporarily by reputation check. In the + // Downloads Panel, a subview can be used to remove the file or open + // the download anyways. + this.showButton("subviewOpenOrRemoveFile"); + hover = { l10n: { id: "downloads-show-more-information" } }; + } else { + // This download was blocked temporarily by reputation check. In the + // Downloads View, the interface depends on the threat severity. + switch (verdict) { + case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: + case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: + case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + // Keep the option the user chose on the save dialogue + if (this.download.launchWhenSucceeded) { + this.showButton("askOpenOrRemoveFile"); + } else { + this.showButton("askRemoveFileOrAllow"); + } + break; + case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM: + this.showButton("askRemoveFileOrAllow"); + break; + default: + // Assume Downloads.Error.BLOCK_VERDICT_MALWARE + this.showButton("removeFile"); + break; + } + } + this.showStatusWithDetails(this.rawBlockedTitleAndDetails[0], hover); + } else { + // This download failed without being blocked, and can be restarted. + this.showStatusWithDetails(lazy.DownloadsCommon.strings.stateFailed); + this.showButton("retry"); + } + } else if (this.download.canceled) { + if (this.download.hasPartialData) { + // This download was paused. The main action button will cancel the + // download, and in both the Downloads Panel and the Downlods View the + // status includes the size, for example "Paused - 1.1 MB". + let totalBytes = this.download.hasProgress + ? this.download.totalBytes + : -1; + let transfer = lazy.DownloadUtils.getTransferTotal( + this.download.currentBytes, + totalBytes + ); + this.showStatus( + lazy.DownloadsCommon.strings.statusSeparatorBeforeNumber( + lazy.DownloadsCommon.strings.statePaused, + transfer + ) + ); + this.showButton("cancel"); + progressPaused = true; + } else { + // This download was canceled. + this.showStatusWithDetails( + lazy.DownloadsCommon.strings.stateCanceled + ); + this.showButton("retry"); + } + } else { + // This download was added to the global list before it started. While + // we still support this case, at the moment it can only be triggered by + // internally developed add-ons and regression tests, and should not + // happen unless there is a bug. This means the stateStarting string can + // probably be removed when converting the localization to Fluent. + this.showStatus(lazy.DownloadsCommon.strings.stateStarting); + this.showButton("cancel"); + } + + // These attributes are only set in this slower code path, because they + // are irrelevant for downloads that are in progress. + if (verdict) { + this.element.setAttribute("verdict", verdict); + } else { + this.element.removeAttribute("verdict"); + } + + this.element.classList.toggle( + "temporary-block", + !!this.download.hasBlockedData + ); + } + + // These attributes are set in all code paths, because they are relevant for + // downloads that are in progress and for other states. + if (this.download.hasProgress) { + this.showProgress("normal", this.download.progress, progressPaused); + } else { + this.showProgress("undetermined", 100, progressPaused); + } + }, + + /** + * Returns [title, [details1, details2]] for blocked downloads. + * The title or details could be raw strings or l10n objects. + */ + get rawBlockedTitleAndDetails() { + let s = lazy.DownloadsCommon.strings; + if ( + !this.download.error || + !this.download.error.becauseBlockedByReputationCheck + ) { + return [null, null]; + } + switch (this.download.error.reputationCheckVerdict) { + case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: + return [s.blockedUncommon2, [s.unblockTypeUncommon2, s.unblockTip2]]; + case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: + return [ + s.blockedPotentiallyInsecure, + [s.unblockInsecure2, s.unblockTip2], + ]; + case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + return [ + s.blockedPotentiallyUnwanted, + [s.unblockTypePotentiallyUnwanted2, s.unblockTip2], + ]; + case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE: + return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]]; + + case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM: + let title = { + id: "downloads-files-not-downloaded", + args: { + num: this.download.blockedDownloadsCount, + }, + }; + let details = { + id: "downloads-blocked-download-detailed-info", + args: { url: DownloadsViewUI.getStrippedUrl(this.download) }, + }; + return [{ l10n: title }, [{ l10n: details }, null]]; + } + throw new Error( + "Unexpected reputationCheckVerdict: " + + this.download.error.reputationCheckVerdict + ); + }, + + showDeletedOrMissing() { + this.element.removeAttribute("exists"); + let label = + lazy.DownloadsCommon.strings[ + this.download.deleted ? "fileDeleted" : "fileMovedOrMissing" + ]; + this.showStatusWithDetails(label, label); + this.hideButton(); + }, + + /** + * Shows the appropriate unblock dialog based on the verdict, and executes the + * action selected by the user in the dialog, which may involve unblocking, + * opening or removing the file. + * + * @param window + * The window to which the dialog should be anchored. + * @param dialogType + * Can be "unblock", "chooseUnblock", or "chooseOpen". + */ + confirmUnblock(window, dialogType) { + lazy.DownloadsCommon.confirmUnblockDownload({ + verdict: this.download.error.reputationCheckVerdict, + window, + dialogType, + }) + .then(action => { + if (action == "open") { + return this.unblockAndOpenDownload(); + } else if (action == "unblock") { + return this.download.unblock(); + } else if (action == "confirmBlock") { + return this.download.confirmBlock(); + } + return Promise.resolve(); + }) + .catch(console.error); + }, + + /** + * Unblocks the downloaded file and opens it. + * + * @return A promise that's resolved after the file has been opened. + */ + unblockAndOpenDownload() { + return this.download.unblock().then(() => this.downloadsCmd_open()); + }, + + unblockAndSave() { + return this.download.unblock(); + }, + /** + * Returns the name of the default command to use for the current state of the + * download, when there is a double click or another default interaction. If + * there is no default command for the current state, returns an empty string. + * The commands are implemented as functions on this object or derived ones. + */ + get currentDefaultCommandName() { + switch (lazy.DownloadsCommon.stateOfDownload(this.download)) { + case lazy.DownloadsCommon.DOWNLOAD_NOTSTARTED: + return "downloadsCmd_cancel"; + case lazy.DownloadsCommon.DOWNLOAD_FAILED: + case lazy.DownloadsCommon.DOWNLOAD_CANCELED: + return "downloadsCmd_retry"; + case lazy.DownloadsCommon.DOWNLOAD_PAUSED: + return "downloadsCmd_pauseResume"; + case lazy.DownloadsCommon.DOWNLOAD_FINISHED: + return "downloadsCmd_open"; + case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL: + return "downloadsCmd_openReferrer"; + case lazy.DownloadsCommon.DOWNLOAD_DIRTY: + return "downloadsCmd_showBlockedInfo"; + } + return ""; + }, + + /** + * Returns true if the specified command can be invoked on the current item. + * The commands are implemented as functions on this object or derived ones. + * + * @param aCommand + * Name of the command to check, for example "downloadsCmd_retry". + */ + isCommandEnabled(aCommand) { + switch (aCommand) { + case "downloadsCmd_retry": + return this.download.canceled || !!this.download.error; + case "downloadsCmd_pauseResume": + return this.download.hasPartialData && !this.download.error; + case "downloadsCmd_openReferrer": + return ( + !!this.download.source.referrerInfo && + !!this.download.source.referrerInfo.originalReferrer + ); + case "downloadsCmd_confirmBlock": + case "downloadsCmd_chooseUnblock": + case "downloadsCmd_chooseOpen": + case "downloadsCmd_unblock": + case "downloadsCmd_unblockAndSave": + case "downloadsCmd_unblockAndOpen": + return this.download.hasBlockedData; + case "downloadsCmd_cancel": + return this.download.hasPartialData || !this.download.stopped; + case "downloadsCmd_open": + case "downloadsCmd_open:current": + case "downloadsCmd_open:tab": + case "downloadsCmd_open:tabshifted": + case "downloadsCmd_open:window": + case "downloadsCmd_alwaysOpenSimilarFiles": + // This property is false if the download did not succeed. + return this.download.target.exists; + + case "downloadsCmd_show": + case "downloadsCmd_deleteFile": + let { target } = this.download; + return ( + !this.download.deleted && (target.exists || target.partFileExists) + ); + + case "downloadsCmd_delete": + case "cmd_delete": + // We don't want in-progress downloads to be removed accidentally. + return this.download.stopped; + case "downloadsCmd_openInSystemViewer": + case "downloadsCmd_alwaysOpenInSystemViewer": + return lazy.DownloadIntegration.shouldViewDownloadInternally( + lazy.DownloadsCommon.getMimeInfo(this.download)?.type + ); + } + return DownloadsViewUI.isCommandName(aCommand) && !!this[aCommand]; + }, + + doCommand(aCommand) { + // split off an optional command "modifier" into an argument, + // e.g. "downloadsCmd_open:window" + let [command, modifier] = aCommand.split(":"); + if (DownloadsViewUI.isCommandName(command)) { + this[command](modifier); + } + }, + + onButton() { + this.doCommand(this.buttonCommandName); + }, + + downloadsCmd_cancel() { + // This is the correct way to avoid race conditions when cancelling. + this.download.cancel().catch(() => {}); + this.download + .removePartialData() + .catch(console.error) + .finally(() => this.download.target.refresh()); + }, + + downloadsCmd_confirmBlock() { + this.download.confirmBlock().catch(console.error); + }, + + downloadsCmd_open(openWhere = "tab") { + lazy.DownloadsCommon.openDownload(this.download, { + openWhere, + }); + }, + + downloadsCmd_openReferrer() { + this.element.ownerGlobal.openURL( + this.download.source.referrerInfo.originalReferrer + ); + }, + + downloadsCmd_pauseResume() { + if (this.download.stopped) { + this.download.start(); + } else { + this.download.cancel(); + } + }, + + downloadsCmd_show() { + let file = new lazy.FileUtils.File(this.download.target.path); + lazy.DownloadsCommon.showDownloadedFile(file); + }, + + downloadsCmd_retry() { + if (this.download.start) { + // Errors when retrying are already reported as download failures. + this.download.start().catch(() => {}); + return; + } + + let window = this.browserWindow || this.element.ownerGlobal; + let document = window.document; + + // Do not suggest a file name if we don't know the original target. + let targetPath = this.download.target.path + ? PathUtils.filename(this.download.target.path) + : null; + window.DownloadURL(this.download.source.url, targetPath, document); + }, + + downloadsCmd_delete() { + // Alias for the 'cmd_delete' command, because it may clash with another + // controller which causes unexpected behavior as different codepaths claim + // ownership. + this.cmd_delete(); + }, + + cmd_delete() { + lazy.DownloadsCommon.deleteDownload(this.download).catch(console.error); + }, + + async downloadsCmd_deleteFile() { + // Remove the download from the session and history downloads, delete part files. + await lazy.DownloadsCommon.deleteDownloadFiles( + this.download, + DownloadsViewUI.clearHistoryOnDelete + ); + }, + + downloadsCmd_openInSystemViewer() { + // For this interaction only, pass a flag to override the preferredAction for this + // mime-type and open using the system viewer + lazy.DownloadsCommon.openDownload(this.download, { + useSystemDefault: true, + }).catch(console.error); + }, + + downloadsCmd_alwaysOpenInSystemViewer() { + // this command toggles between setting preferredAction for this mime-type to open + // using the system viewer, or to open the file in browser. + const mimeInfo = lazy.DownloadsCommon.getMimeInfo(this.download); + if (!mimeInfo) { + throw new Error( + "Can't open download with unknown mime-type in system viewer" + ); + } + if (mimeInfo.preferredAction !== mimeInfo.useSystemDefault) { + // User has selected to open this mime-type with the system viewer from now on + lazy.DownloadsCommon.log( + "downloadsCmd_alwaysOpenInSystemViewer command for download: ", + this.download, + "switching to use system default for " + mimeInfo.type + ); + mimeInfo.preferredAction = mimeInfo.useSystemDefault; + mimeInfo.alwaysAskBeforeHandling = false; + } else { + lazy.DownloadsCommon.log( + "downloadsCmd_alwaysOpenInSystemViewer command for download: ", + this.download, + "currently uses system default, switching to handleInternally" + ); + // User has selected to not open this mime-type with the system viewer + mimeInfo.preferredAction = mimeInfo.handleInternally; + } + lazy.handlerSvc.store(mimeInfo); + lazy.DownloadsCommon.openDownload(this.download).catch(console.error); + }, + + downloadsCmd_alwaysOpenSimilarFiles() { + const mimeInfo = lazy.DownloadsCommon.getMimeInfo(this.download); + if (!mimeInfo) { + throw new Error("Can't open download with unknown mime-type"); + } + + // User has selected to always open this mime-type from now on and will add this + // mime-type to our preferences table with the system default option. Open the + // file immediately after selecting the menu item like alwaysOpenInSystemViewer. + if (mimeInfo.preferredAction !== mimeInfo.useSystemDefault) { + mimeInfo.preferredAction = mimeInfo.useSystemDefault; + lazy.handlerSvc.store(mimeInfo); + lazy.DownloadsCommon.openDownload(this.download).catch(console.error); + } else { + // Otherwise, if user unchecks this option after already enabling it from the + // context menu, resort to saveToDisk. + mimeInfo.preferredAction = mimeInfo.saveToDisk; + lazy.handlerSvc.store(mimeInfo); + } + }, +}; |