Backed out changeset 496886cb30a5 (bug 1867152) for bc failures on browser_user_input...
[gecko.git] / browser / components / downloads / DownloadsViewUI.sys.mjs
blob9c6bd17d63a40dcd4c0a258a9e72daa07d7a2b81
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /*
6  * This module is imported by code that uses the "download.xml" binding, and
7  * provides prototypes for objects that handle input and display information.
8  */
10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
12 const lazy = {};
14 ChromeUtils.defineESModuleGetters(lazy, {
15   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
16   DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
17   Downloads: "resource://gre/modules/Downloads.sys.mjs",
18   DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
19   FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
20   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
21 });
23 XPCOMUtils.defineLazyServiceGetter(
24   lazy,
25   "handlerSvc",
26   "@mozilla.org/uriloader/handler-service;1",
27   "nsIHandlerService"
30 XPCOMUtils.defineLazyServiceGetter(
31   lazy,
32   "gReputationService",
33   "@mozilla.org/reputationservice/application-reputation-service;1",
34   Ci.nsIApplicationReputationService
37 import { Integration } from "resource://gre/modules/Integration.sys.mjs";
39 Integration.downloads.defineESModuleGetter(
40   lazy,
41   "DownloadIntegration",
42   "resource://gre/modules/DownloadIntegration.sys.mjs"
45 const HTML_NS = "http://www.w3.org/1999/xhtml";
47 var gDownloadElementButtons = {
48   cancel: {
49     commandName: "downloadsCmd_cancel",
50     l10nId: "downloads-cmd-cancel",
51     descriptionL10nId: "downloads-cancel-download",
52     panelL10nId: "downloads-cmd-cancel-panel",
53     iconClass: "downloadIconCancel",
54   },
55   retry: {
56     commandName: "downloadsCmd_retry",
57     l10nId: "downloads-cmd-retry",
58     descriptionL10nId: "downloads-retry-download",
59     panelL10nId: "downloads-cmd-retry-panel",
60     iconClass: "downloadIconRetry",
61   },
62   show: {
63     commandName: "downloadsCmd_show",
64     l10nId: "downloads-cmd-show-button-2",
65     descriptionL10nId: "downloads-cmd-show-description-2",
66     panelL10nId: "downloads-cmd-show-panel-2",
67     iconClass: "downloadIconShow",
68   },
69   subviewOpenOrRemoveFile: {
70     commandName: "downloadsCmd_showBlockedInfo",
71     l10nId: "downloads-cmd-choose-open",
72     descriptionL10nId: "downloads-show-more-information",
73     panelL10nId: "downloads-cmd-choose-open-panel",
74     iconClass: "downloadIconSubviewArrow",
75   },
76   askOpenOrRemoveFile: {
77     commandName: "downloadsCmd_chooseOpen",
78     l10nId: "downloads-cmd-choose-open",
79     panelL10nId: "downloads-cmd-choose-open-panel",
80     iconClass: "downloadIconShow",
81   },
82   askRemoveFileOrAllow: {
83     commandName: "downloadsCmd_chooseUnblock",
84     l10nId: "downloads-cmd-choose-unblock",
85     panelL10nId: "downloads-cmd-choose-unblock-panel",
86     iconClass: "downloadIconShow",
87   },
88   removeFile: {
89     commandName: "downloadsCmd_confirmBlock",
90     l10nId: "downloads-cmd-remove-file",
91     panelL10nId: "downloads-cmd-remove-file-panel",
92     iconClass: "downloadIconCancel",
93   },
96 /**
97  * Associates each document with a pre-built DOM fragment representing the
98  * download list item. This is then cloned to create each individual list item.
99  * This is stored on the document to prevent leaks that would occur if a single
100  * instance created by one document's DOMParser was stored globally.
101  */
102 var gDownloadListItemFragments = new WeakMap();
104 export var DownloadsViewUI = {
105   /**
106    * Returns true if the given string is the name of a command that can be
107    * handled by the Downloads user interface, including standard commands.
108    */
109   isCommandName(name) {
110     return name.startsWith("cmd_") || name.startsWith("downloadsCmd_");
111   },
113   /**
114    * Get source url of the download without'http' or'https' prefix.
115    */
116   getStrippedUrl(download) {
117     return lazy.UrlbarUtils.stripPrefixAndTrim(download?.source?.url, {
118       stripHttp: true,
119       stripHttps: true,
120     })[0];
121   },
123   /**
124    * Returns the user-facing label for the given Download object. This is
125    * normally the leaf name of the download target file. In case this is a very
126    * old history download for which the target file is unknown, the download
127    * source URI is displayed.
128    */
129   getDisplayName(download) {
130     if (
131       download.error?.reputationCheckVerdict ==
132       lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM
133     ) {
134       let l10n = {
135         id: "downloads-blocked-from-url",
136         args: { url: DownloadsViewUI.getStrippedUrl(download) },
137       };
138       return { l10n };
139     }
140     return download.target.path
141       ? PathUtils.filename(download.target.path)
142       : download.source.url;
143   },
145   /**
146    * Given a Download object, returns a string representing its file size with
147    * an appropriate measurement unit, for example "1.5 MB", or an empty string
148    * if the size is unknown.
149    */
150   getSizeWithUnits(download) {
151     if (download.target.size === undefined) {
152       return "";
153     }
155     let [size, unit] = lazy.DownloadUtils.convertByteUnits(
156       download.target.size
157     );
158     return lazy.DownloadsCommon.strings.sizeWithUnits(size, unit);
159   },
161   /**
162    * Given a context menu and a download element on which it is invoked,
163    * update items in the context menu to reflect available options for
164    * that download element.
165    */
166   updateContextMenuForElement(contextMenu, element) {
167     // Get the state and ensure only the appropriate items are displayed.
168     let state = parseInt(element.getAttribute("state"), 10);
170     const document = contextMenu.ownerDocument;
172     const {
173       DOWNLOAD_NOTSTARTED,
174       DOWNLOAD_DOWNLOADING,
175       DOWNLOAD_FINISHED,
176       DOWNLOAD_FAILED,
177       DOWNLOAD_CANCELED,
178       DOWNLOAD_PAUSED,
179       DOWNLOAD_BLOCKED_PARENTAL,
180       DOWNLOAD_DIRTY,
181       DOWNLOAD_BLOCKED_POLICY,
182     } = lazy.DownloadsCommon;
184     contextMenu.querySelector(".downloadPauseMenuItem").hidden =
185       state != DOWNLOAD_DOWNLOADING;
187     contextMenu.querySelector(".downloadResumeMenuItem").hidden =
188       state != DOWNLOAD_PAUSED;
190     // Only show "unblock" for blocked (dirty) items that have not been
191     // confirmed and have temporary data:
192     contextMenu.querySelector(".downloadUnblockMenuItem").hidden =
193       state != DOWNLOAD_DIRTY || !element.classList.contains("temporary-block");
195     // Can only remove finished/failed/canceled/blocked downloads.
196     contextMenu.querySelector(".downloadRemoveFromHistoryMenuItem").hidden = ![
197       DOWNLOAD_FINISHED,
198       DOWNLOAD_FAILED,
199       DOWNLOAD_CANCELED,
200       DOWNLOAD_BLOCKED_PARENTAL,
201       DOWNLOAD_DIRTY,
202       DOWNLOAD_BLOCKED_POLICY,
203     ].includes(state);
205     // Can reveal downloads with data on the file system using the relevant OS
206     // tool (Explorer, Finder, appropriate Linux file system viewer):
207     contextMenu.querySelector(".downloadShowMenuItem").hidden =
208       ![
209         DOWNLOAD_NOTSTARTED,
210         DOWNLOAD_DOWNLOADING,
211         DOWNLOAD_FINISHED,
212         DOWNLOAD_PAUSED,
213       ].includes(state) ||
214       (state == DOWNLOAD_FINISHED && !element.hasAttribute("exists"));
216     // Show the separator if we're showing either unblock or reveal menu items.
217     contextMenu.querySelector(".downloadCommandsSeparator").hidden =
218       contextMenu.querySelector(".downloadUnblockMenuItem").hidden &&
219       contextMenu.querySelector(".downloadShowMenuItem").hidden;
221     let download = element._shell.download;
222     let mimeInfo = lazy.DownloadsCommon.getMimeInfo(download);
223     let { preferredAction, useSystemDefault, defaultDescription } = mimeInfo
224       ? mimeInfo
225       : {};
227     // Hide the "Delete" item if there's no file data to delete.
228     contextMenu.querySelector(".downloadDeleteFileMenuItem").hidden =
229       download.deleted ||
230       !(download.target?.exists || download.target?.partFileExists);
232     // Hide the "Go To Download Page" item if there's no referrer. Ideally the
233     // Downloads API will require a referrer (see bug 1723712) to create a
234     // download, but this fallback will ensure any failures aren't user facing.
235     contextMenu.querySelector(".downloadOpenReferrerMenuItem").hidden =
236       !download.source.referrerInfo?.originalReferrer;
238     // Hide the "use system viewer" and "always use system viewer" items
239     // if the feature is disabled or this download doesn't support it:
240     let useSystemViewerItem = contextMenu.querySelector(
241       ".downloadUseSystemDefaultMenuItem"
242     );
243     let alwaysUseSystemViewerItem = contextMenu.querySelector(
244       ".downloadAlwaysUseSystemDefaultMenuItem"
245     );
246     let canViewInternally = element.hasAttribute("viewable-internally");
247     useSystemViewerItem.hidden =
248       !lazy.DownloadsCommon.openInSystemViewerItemEnabled ||
249       !canViewInternally ||
250       !download.target?.exists;
252     alwaysUseSystemViewerItem.hidden =
253       !lazy.DownloadsCommon.alwaysOpenInSystemViewerItemEnabled ||
254       !canViewInternally;
256     // Set menuitem labels to display the system viewer's name. Stop the l10n
257     // mutation observer temporarily since we're going to synchronously
258     // translate the elements to avoid translation delay. See bug 1737951 & bug
259     // 1746748. This can be simplified when they're resolved.
260     try {
261       document.l10n.pauseObserving();
262       // Handler descriptions longer than 40 characters will be skipped to avoid
263       // unreasonably stretching the context menu.
264       if (defaultDescription && defaultDescription.length < 40) {
265         document.l10n.setAttributes(
266           useSystemViewerItem,
267           "downloads-cmd-use-system-default-named",
268           { handler: defaultDescription }
269         );
270         document.l10n.setAttributes(
271           alwaysUseSystemViewerItem,
272           "downloads-cmd-always-use-system-default-named",
273           { handler: defaultDescription }
274         );
275       } else {
276         // In the unlikely event that defaultDescription is somehow missing/invalid,
277         // fall back to the static "Open In System Viewer" label.
278         document.l10n.setAttributes(
279           useSystemViewerItem,
280           "downloads-cmd-use-system-default"
281         );
282         document.l10n.setAttributes(
283           alwaysUseSystemViewerItem,
284           "downloads-cmd-always-use-system-default"
285         );
286       }
287     } finally {
288       document.l10n.resumeObserving();
289     }
290     document.l10n.translateElements([
291       useSystemViewerItem,
292       alwaysUseSystemViewerItem,
293     ]);
295     // If non default mime-type or cannot be opened internally, display
296     // "always open similar files" item instead so that users can add a new
297     // mimetype to about:preferences table and set to open with system default.
298     let alwaysOpenSimilarFilesItem = contextMenu.querySelector(
299       ".downloadAlwaysOpenSimilarFilesMenuItem"
300     );
302     /**
303      * In HelperAppDlg.sys.mjs, we determine whether or not an "always open..." checkbox
304      * should appear in the unknownContentType window. Here, we use similar checks to
305      * determine if we should show the "always open similar files" context menu item.
306      *
307      * Note that we also read the content type using mimeInfo to detect better and available
308      * mime types, given a file extension. Some sites default to "application/octet-stream",
309      * further limiting what file types can be added to about:preferences, even for file types
310      * that are in fact capable of being handled with a default application.
311      *
312      * There are also cases where download.contentType is undefined (ex. when opening
313      * the context menu on a previously downloaded item via download history).
314      * Using mimeInfo ensures that content type exists and prevents intermittence.
315      */
316     //
317     let filename = PathUtils.filename(download.target.path);
319     let isExemptExecutableExtension =
320       Services.policies.isExemptExecutableExtension(
321         download.source.originalUrl || download.source.url,
322         filename?.split(".").at(-1)
323       );
325     let shouldNotRememberChoice =
326       !mimeInfo?.type ||
327       mimeInfo.type === "application/octet-stream" ||
328       mimeInfo.type === "application/x-msdownload" ||
329       mimeInfo.type === "application/x-msdos-program" ||
330       (lazy.gReputationService.isExecutable(filename) &&
331         !isExemptExecutableExtension) ||
332       (mimeInfo.type === "text/plain" &&
333         lazy.gReputationService.isBinary(download.target.path));
335     alwaysOpenSimilarFilesItem.hidden =
336       canViewInternally ||
337       state !== DOWNLOAD_FINISHED ||
338       shouldNotRememberChoice;
340     // Update checkbox for "always open..." options.
341     if (preferredAction === useSystemDefault) {
342       alwaysUseSystemViewerItem.setAttribute("checked", "true");
343       alwaysOpenSimilarFilesItem.setAttribute("checked", "true");
344     } else {
345       alwaysUseSystemViewerItem.removeAttribute("checked");
346       alwaysOpenSimilarFilesItem.removeAttribute("checked");
347     }
348   },
351 XPCOMUtils.defineLazyPreferenceGetter(
352   DownloadsViewUI,
353   "clearHistoryOnDelete",
354   "browser.download.clearHistoryOnDelete",
355   0
358 DownloadsViewUI.BaseView = class {
359   canClearDownloads(nodeContainer) {
360     // Downloads can be cleared if there's at least one removable download in
361     // the list (either a history download or a completed session download).
362     // Because history downloads are always removable and are listed after the
363     // session downloads, check from bottom to top.
364     for (let elt = nodeContainer.lastChild; elt; elt = elt.previousSibling) {
365       // Stopped, paused, and failed downloads with partial data are removed.
366       let download = elt._shell.download;
367       if (download.stopped && !(download.canceled && download.hasPartialData)) {
368         return true;
369       }
370     }
371     return false;
372   }
376  * A download element shell is responsible for handling the commands and the
377  * displayed data for a single element that uses the "download.xml" binding.
379  * The information to display is obtained through the associated Download object
380  * from the JavaScript API for downloads, and commands are executed using a
381  * combination of Download methods and DownloadsCommon.sys.mjs helper functions.
383  * Specialized versions of this shell must be defined, and they are required to
384  * implement the "download" property or getter. Currently these objects are the
385  * HistoryDownloadElementShell and the DownloadsViewItem for the panel. The
386  * history view may use a HistoryDownload object in place of a Download object.
387  */
388 DownloadsViewUI.DownloadElementShell = function () {};
390 DownloadsViewUI.DownloadElementShell.prototype = {
391   /**
392    * The richlistitem for the download, initialized by the derived object.
393    */
394   element: null,
396   /**
397    * Manages the "active" state of the shell. By default all the shells are
398    * inactive, thus their UI is not updated. They must be activated when
399    * entering the visible area.
400    */
401   ensureActive() {
402     if (!this._active) {
403       this._active = true;
404       this.connect();
405       this.onChanged();
406     }
407   },
408   get active() {
409     return !!this._active;
410   },
412   connect() {
413     let document = this.element.ownerDocument;
414     let downloadListItemFragment = gDownloadListItemFragments.get(document);
415     // When changing the markup within the fragment, please ensure that
416     // the functions within DownloadsView still operate correctly.
417     if (!downloadListItemFragment) {
418       let MozXULElement = document.defaultView.MozXULElement;
419       downloadListItemFragment = MozXULElement.parseXULToFragment(`
420         <hbox class="downloadMainArea" flex="1" align="center">
421           <image class="downloadTypeIcon"/>
422           <vbox class="downloadContainer" flex="1" pack="center">
423             <description class="downloadTarget" crop="center"/>
424             <description class="downloadDetails downloadDetailsNormal"
425                          crop="end"/>
426             <description class="downloadDetails downloadDetailsHover"
427                          crop="end"/>
428             <description class="downloadDetails downloadDetailsButtonHover"
429                          crop="end"/>
430           </vbox>
431           <image class="downloadBlockedBadge" />
432         </hbox>
433         <button class="downloadButton"/>
434       `);
435       gDownloadListItemFragments.set(document, downloadListItemFragment);
436     }
437     this.element.setAttribute("active", true);
438     this.element.setAttribute("orient", "horizontal");
439     this.element.addEventListener("click", ev => {
440       ev.target.ownerGlobal.DownloadsView.onDownloadClick(ev);
441     });
442     this.element.appendChild(
443       document.importNode(downloadListItemFragment, true)
444     );
445     let downloadButton = this.element.querySelector(".downloadButton");
446     downloadButton.addEventListener("command", function (event) {
447       event.target.ownerGlobal.DownloadsView.onDownloadButton(event);
448     });
449     for (let [propertyName, selector] of [
450       ["_downloadTypeIcon", ".downloadTypeIcon"],
451       ["_downloadTarget", ".downloadTarget"],
452       ["_downloadDetailsNormal", ".downloadDetailsNormal"],
453       ["_downloadDetailsHover", ".downloadDetailsHover"],
454       ["_downloadDetailsButtonHover", ".downloadDetailsButtonHover"],
455       ["_downloadButton", ".downloadButton"],
456     ]) {
457       this[propertyName] = this.element.querySelector(selector);
458     }
460     // HTML elements can be created directly without using parseXULToFragment.
461     let progress = (this._downloadProgress = document.createElementNS(
462       HTML_NS,
463       "progress"
464     ));
465     progress.className = "downloadProgress";
466     progress.setAttribute("max", "100");
467     this._downloadTarget.insertAdjacentElement("afterend", progress);
468   },
470   /**
471    * URI string for the file type icon displayed in the download element.
472    */
473   get image() {
474     if (!this.download.target.path) {
475       // Old history downloads may not have a target path.
476       return "moz-icon://.unknown?size=32";
477     }
479     // When a download that was previously in progress finishes successfully, it
480     // means that the target file now exists and we can extract its specific
481     // icon, for example from a Windows executable. To ensure that the icon is
482     // reloaded, however, we must change the URI used by the XUL image element,
483     // for example by adding a query parameter. This only works if we add one of
484     // the parameters explicitly supported by the nsIMozIconURI interface.
485     return (
486       "moz-icon://" +
487       this.download.target.path +
488       "?size=32" +
489       (this.download.succeeded ? "&state=normal" : "")
490     );
491   },
493   get browserWindow() {
494     return lazy.BrowserWindowTracker.getTopWindow();
495   },
497   /**
498    * Updates the display name and icon.
499    *
500    * @param displayName
501    *        This is usually the full file name of the download without the path.
502    * @param icon
503    *        URL of the icon to load, generally from the "image" property.
504    */
505   showDisplayNameAndIcon(displayName, icon) {
506     if (displayName.l10n) {
507       let document = this.element.ownerDocument;
508       document.l10n.setAttributes(
509         this._downloadTarget,
510         displayName.l10n.id,
511         displayName.l10n.args
512       );
513     } else {
514       this._downloadTarget.setAttribute("value", displayName);
515       this._downloadTarget.setAttribute("tooltiptext", displayName);
516     }
517     this._downloadTypeIcon.setAttribute("src", icon);
518   },
520   /**
521    * Updates the displayed progress bar.
522    *
523    * @param mode
524    *        Either "normal" or "undetermined".
525    * @param value
526    *        Percentage of the progress bar to display, from 0 to 100.
527    * @param paused
528    *        True to display the progress bar style for paused downloads.
529    */
530   showProgress(mode, value, paused) {
531     if (mode == "undetermined") {
532       this._downloadProgress.removeAttribute("value");
533     } else {
534       this._downloadProgress.setAttribute("value", value);
535     }
536     this._downloadProgress.toggleAttribute("paused", !!paused);
537   },
539   /**
540    * Updates the full status line.
541    *
542    * @param status
543    *        Status line of the Downloads Panel or the Downloads View.
544    * @param hoverStatus
545    *        Label to show in the Downloads Panel when the mouse pointer is over
546    *        the main area of the item. If not specified, this will be the same
547    *        as the status line. This is ignored in the Downloads View. Type is
548    *        either l10n object or string literal.
549    */
550   showStatus(status, hoverStatus = status) {
551     let document = this.element.ownerDocument;
552     if (status?.l10n) {
553       document.l10n.setAttributes(
554         this._downloadDetailsNormal,
555         status.l10n.id,
556         status.l10n.args
557       );
558     } else {
559       this._downloadDetailsNormal.removeAttribute("data-l10n-id");
560       this._downloadDetailsNormal.setAttribute("value", status);
561       this._downloadDetailsNormal.setAttribute("tooltiptext", status);
562     }
563     if (hoverStatus?.l10n) {
564       document.l10n.setAttributes(
565         this._downloadDetailsHover,
566         hoverStatus.l10n.id,
567         hoverStatus.l10n.args
568       );
569     } else {
570       this._downloadDetailsHover.removeAttribute("data-l10n-id");
571       this._downloadDetailsHover.setAttribute("value", hoverStatus);
572     }
573   },
575   /**
576    * Updates the status line combining the given state label with other labels.
577    *
578    * @param stateLabel
579    *        Label representing the state of the download, for example "Failed".
580    *        In the Downloads Panel, this is the only text displayed when the
581    *        the mouse pointer is not over the main area of the item. In the
582    *        Downloads View, this label is combined with the host and date, for
583    *        example "Failed - example.com - 1:45 PM".
584    * @param hoverStatus
585    *        Label to show in the Downloads Panel when the mouse pointer is over
586    *        the main area of the item. If not specified, this will be the
587    *        state label combined with the host and date. This is ignored in the
588    *        Downloads View. Type is either l10n object or string literal.
589    */
590   showStatusWithDetails(stateLabel, hoverStatus) {
591     if (stateLabel.l10n) {
592       this.showStatus(stateLabel, hoverStatus);
593       return;
594     }
595     let [displayHost] = lazy.DownloadUtils.getURIHost(this.download.source.url);
596     let [displayDate] = lazy.DownloadUtils.getReadableDates(
597       new Date(this.download.endTime)
598     );
600     let firstPart = lazy.DownloadsCommon.strings.statusSeparator(
601       stateLabel,
602       displayHost
603     );
604     let fullStatus = lazy.DownloadsCommon.strings.statusSeparator(
605       firstPart,
606       displayDate
607     );
609     if (!this.isPanel) {
610       this.showStatus(fullStatus);
611     } else {
612       this.showStatus(stateLabel, hoverStatus || fullStatus);
613     }
614   },
616   /**
617    * Updates the main action button and makes it visible.
618    *
619    * @param type
620    *        One of the presets defined in gDownloadElementButtons.
621    */
622   showButton(type) {
623     let { commandName, l10nId, descriptionL10nId, panelL10nId, iconClass } =
624       gDownloadElementButtons[type];
626     this.buttonCommandName = commandName;
627     let stringId = this.isPanel ? panelL10nId : l10nId;
628     let document = this.element.ownerDocument;
629     document.l10n.setAttributes(this._downloadButton, stringId);
630     if (this.isPanel && descriptionL10nId) {
631       document.l10n.setAttributes(
632         this._downloadDetailsButtonHover,
633         descriptionL10nId
634       );
635     }
636     this._downloadButton.setAttribute("class", "downloadButton " + iconClass);
637     this._downloadButton.removeAttribute("hidden");
638   },
640   hideButton() {
641     this._downloadButton.hidden = true;
642   },
644   lastEstimatedSecondsLeft: Infinity,
646   /**
647    * This is called when a major state change occurs in the download, but is not
648    * called for every progress update in order to improve performance.
649    */
650   _updateState() {
651     this.showDisplayNameAndIcon(
652       DownloadsViewUI.getDisplayName(this.download),
653       this.image
654     );
655     this.element.setAttribute(
656       "state",
657       lazy.DownloadsCommon.stateOfDownload(this.download)
658     );
660     if (!this.download.stopped) {
661       // When the download becomes in progress, we make all the major changes to
662       // the user interface here. The _updateStateInner function takes care of
663       // displaying the right button type for all other state changes.
664       this.showButton("cancel");
666       // If there was a verdict set but the download is running we can assume
667       // that the verdict has been overruled and can be removed.
668       this.element.removeAttribute("verdict");
669     }
671     // Since state changed, reset the time left estimation.
672     this.lastEstimatedSecondsLeft = Infinity;
674     this._updateStateInner();
675   },
677   /**
678    * This is called for all changes in the download, including progress updates.
679    * For major state changes, _updateState is called first, but several elements
680    * are still updated here. When the download is in progress, this function
681    * takes a faster path with less element updates to improve performance.
682    */
683   _updateStateInner() {
684     let progressPaused = false;
686     this.element.classList.toggle("openWhenFinished", !this.download.stopped);
688     if (!this.download.stopped) {
689       // The download is in progress, so we don't change the button state
690       // because the _updateState function already did it. We still need to
691       // update all elements that may change during the download.
692       let totalBytes = this.download.hasProgress
693         ? this.download.totalBytes
694         : -1;
695       let [status, newEstimatedSecondsLeft] =
696         lazy.DownloadUtils.getDownloadStatus(
697           this.download.currentBytes,
698           totalBytes,
699           this.download.speed,
700           this.lastEstimatedSecondsLeft
701         );
702       this.lastEstimatedSecondsLeft = newEstimatedSecondsLeft;
704       if (this.download.launchWhenSucceeded) {
705         status = lazy.DownloadUtils.getFormattedTimeStatus(
706           newEstimatedSecondsLeft
707         );
708       }
709       let hoverStatus = {
710         l10n: { id: "downloading-file-click-to-open" },
711       };
712       this.showStatus(status, hoverStatus);
713     } else {
714       let verdict = "";
716       // The download is not in progress, so we update the user interface based
717       // on other properties. The order in which we check the properties of the
718       // Download object is the same used by stateOfDownload.
719       if (this.download.deleted) {
720         this.showDeletedOrMissing();
721       } else if (this.download.succeeded) {
722         lazy.DownloadsCommon.log(
723           "_updateStateInner, target exists? ",
724           this.download.target.path,
725           this.download.target.exists
726         );
727         if (this.download.target.exists) {
728           // This is a completed download, and the target file still exists.
729           this.element.setAttribute("exists", "true");
731           this.element.toggleAttribute(
732             "viewable-internally",
733             lazy.DownloadIntegration.shouldViewDownloadInternally(
734               lazy.DownloadsCommon.getMimeInfo(this.download)?.type
735             )
736           );
738           let sizeWithUnits = DownloadsViewUI.getSizeWithUnits(this.download);
739           if (this.isPanel) {
740             // In the Downloads Panel, we show the file size after the state
741             // label, for example "Completed - 1.5 MB". When the pointer is over
742             // the main area of the item, this label is replaced with a
743             // description of the default action, which opens the file.
744             let status = lazy.DownloadsCommon.strings.stateCompleted;
745             if (sizeWithUnits) {
746               status = lazy.DownloadsCommon.strings.statusSeparator(
747                 status,
748                 sizeWithUnits
749               );
750             }
751             this.showStatus(status, { l10n: { id: "downloads-open-file" } });
752           } else {
753             // In the Downloads View, we show the file size in place of the
754             // state label, for example "1.5 MB - example.com - 1:45 PM".
755             this.showStatusWithDetails(
756               sizeWithUnits || lazy.DownloadsCommon.strings.sizeUnknown
757             );
758           }
759           this.showButton("show");
760         } else {
761           // This is a completed download, but the target file does not exist
762           // anymore, so the main action of opening the file is unavailable.
763           this.showDeletedOrMissing();
764         }
765       } else if (this.download.error) {
766         if (this.download.error.becauseBlockedByParentalControls) {
767           // This download was blocked permanently by parental controls.
768           this.showStatusWithDetails(
769             lazy.DownloadsCommon.strings.stateBlockedParentalControls
770           );
771           this.hideButton();
772         } else if (this.download.error.becauseBlockedByReputationCheck) {
773           verdict = this.download.error.reputationCheckVerdict;
774           let hover = "";
775           if (!this.download.hasBlockedData) {
776             // This download was blocked permanently by reputation check.
777             this.hideButton();
778           } else if (this.isPanel) {
779             // This download was blocked temporarily by reputation check. In the
780             // Downloads Panel, a subview can be used to remove the file or open
781             // the download anyways.
782             this.showButton("subviewOpenOrRemoveFile");
783             hover = { l10n: { id: "downloads-show-more-information" } };
784           } else {
785             // This download was blocked temporarily by reputation check. In the
786             // Downloads View, the interface depends on the threat severity.
787             switch (verdict) {
788               case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON:
789               case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE:
790               case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED:
791                 // Keep the option the user chose on the save dialogue
792                 if (this.download.launchWhenSucceeded) {
793                   this.showButton("askOpenOrRemoveFile");
794                 } else {
795                   this.showButton("askRemoveFileOrAllow");
796                 }
797                 break;
798               case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM:
799                 this.showButton("askRemoveFileOrAllow");
800                 break;
801               default:
802                 // Assume Downloads.Error.BLOCK_VERDICT_MALWARE
803                 this.showButton("removeFile");
804                 break;
805             }
806           }
807           this.showStatusWithDetails(this.rawBlockedTitleAndDetails[0], hover);
808         } else {
809           // This download failed without being blocked, and can be restarted.
810           this.showStatusWithDetails(lazy.DownloadsCommon.strings.stateFailed);
811           this.showButton("retry");
812         }
813       } else if (this.download.canceled) {
814         if (this.download.hasPartialData) {
815           // This download was paused. The main action button will cancel the
816           // download, and in both the Downloads Panel and the Downlods View the
817           // status includes the size, for example "Paused - 1.1 MB".
818           let totalBytes = this.download.hasProgress
819             ? this.download.totalBytes
820             : -1;
821           let transfer = lazy.DownloadUtils.getTransferTotal(
822             this.download.currentBytes,
823             totalBytes
824           );
825           this.showStatus(
826             lazy.DownloadsCommon.strings.statusSeparatorBeforeNumber(
827               lazy.DownloadsCommon.strings.statePaused,
828               transfer
829             )
830           );
831           this.showButton("cancel");
832           progressPaused = true;
833         } else {
834           // This download was canceled.
835           this.showStatusWithDetails(
836             lazy.DownloadsCommon.strings.stateCanceled
837           );
838           this.showButton("retry");
839         }
840       } else {
841         // This download was added to the global list before it started. While
842         // we still support this case, at the moment it can only be triggered by
843         // internally developed add-ons and regression tests, and should not
844         // happen unless there is a bug. This means the stateStarting string can
845         // probably be removed when converting the localization to Fluent.
846         this.showStatus(lazy.DownloadsCommon.strings.stateStarting);
847         this.showButton("cancel");
848       }
850       // These attributes are only set in this slower code path, because they
851       // are irrelevant for downloads that are in progress.
852       if (verdict) {
853         this.element.setAttribute("verdict", verdict);
854       } else {
855         this.element.removeAttribute("verdict");
856       }
858       this.element.classList.toggle(
859         "temporary-block",
860         !!this.download.hasBlockedData
861       );
862     }
864     // These attributes are set in all code paths, because they are relevant for
865     // downloads that are in progress and for other states.
866     if (this.download.hasProgress) {
867       this.showProgress("normal", this.download.progress, progressPaused);
868     } else {
869       this.showProgress("undetermined", 100, progressPaused);
870     }
871   },
873   /**
874    * Returns [title, [details1, details2]] for blocked downloads.
875    * The title or details could be raw strings or l10n objects.
876    */
877   get rawBlockedTitleAndDetails() {
878     let s = lazy.DownloadsCommon.strings;
879     if (
880       !this.download.error ||
881       !this.download.error.becauseBlockedByReputationCheck
882     ) {
883       return [null, null];
884     }
885     switch (this.download.error.reputationCheckVerdict) {
886       case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON:
887         return [s.blockedUncommon2, [s.unblockTypeUncommon2, s.unblockTip2]];
888       case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE:
889         return [
890           s.blockedPotentiallyInsecure,
891           [s.unblockInsecure2, s.unblockTip2],
892         ];
893       case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED:
894         return [
895           s.blockedPotentiallyUnwanted,
896           [s.unblockTypePotentiallyUnwanted2, s.unblockTip2],
897         ];
898       case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE:
899         return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]];
901       case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM:
902         let title = {
903           id: "downloads-files-not-downloaded",
904           args: {
905             num: this.download.blockedDownloadsCount,
906           },
907         };
908         let details = {
909           id: "downloads-blocked-download-detailed-info",
910           args: { url: DownloadsViewUI.getStrippedUrl(this.download) },
911         };
912         return [{ l10n: title }, [{ l10n: details }, null]];
913     }
914     throw new Error(
915       "Unexpected reputationCheckVerdict: " +
916         this.download.error.reputationCheckVerdict
917     );
918   },
920   showDeletedOrMissing() {
921     this.element.removeAttribute("exists");
922     let label =
923       lazy.DownloadsCommon.strings[
924         this.download.deleted ? "fileDeleted" : "fileMovedOrMissing"
925       ];
926     this.showStatusWithDetails(label, label);
927     this.hideButton();
928   },
930   /**
931    * Shows the appropriate unblock dialog based on the verdict, and executes the
932    * action selected by the user in the dialog, which may involve unblocking,
933    * opening or removing the file.
934    *
935    * @param window
936    *        The window to which the dialog should be anchored.
937    * @param dialogType
938    *        Can be "unblock", "chooseUnblock", or "chooseOpen".
939    */
940   confirmUnblock(window, dialogType) {
941     lazy.DownloadsCommon.confirmUnblockDownload({
942       verdict: this.download.error.reputationCheckVerdict,
943       window,
944       dialogType,
945     })
946       .then(action => {
947         if (action == "open") {
948           return this.unblockAndOpenDownload();
949         } else if (action == "unblock") {
950           return this.download.unblock();
951         } else if (action == "confirmBlock") {
952           return this.download.confirmBlock();
953         }
954         return Promise.resolve();
955       })
956       .catch(console.error);
957   },
959   /**
960    * Unblocks the downloaded file and opens it.
961    *
962    * @return A promise that's resolved after the file has been opened.
963    */
964   unblockAndOpenDownload() {
965     return this.download.unblock().then(() => this.downloadsCmd_open());
966   },
968   unblockAndSave() {
969     return this.download.unblock();
970   },
971   /**
972    * Returns the name of the default command to use for the current state of the
973    * download, when there is a double click or another default interaction. If
974    * there is no default command for the current state, returns an empty string.
975    * The commands are implemented as functions on this object or derived ones.
976    */
977   get currentDefaultCommandName() {
978     switch (lazy.DownloadsCommon.stateOfDownload(this.download)) {
979       case lazy.DownloadsCommon.DOWNLOAD_NOTSTARTED:
980         return "downloadsCmd_cancel";
981       case lazy.DownloadsCommon.DOWNLOAD_FAILED:
982       case lazy.DownloadsCommon.DOWNLOAD_CANCELED:
983         return "downloadsCmd_retry";
984       case lazy.DownloadsCommon.DOWNLOAD_PAUSED:
985         return "downloadsCmd_pauseResume";
986       case lazy.DownloadsCommon.DOWNLOAD_FINISHED:
987         return "downloadsCmd_open";
988       case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL:
989         return "downloadsCmd_openReferrer";
990       case lazy.DownloadsCommon.DOWNLOAD_DIRTY:
991         return "downloadsCmd_showBlockedInfo";
992     }
993     return "";
994   },
996   /**
997    * Returns true if the specified command can be invoked on the current item.
998    * The commands are implemented as functions on this object or derived ones.
999    *
1000    * @param aCommand
1001    *        Name of the command to check, for example "downloadsCmd_retry".
1002    */
1003   isCommandEnabled(aCommand) {
1004     switch (aCommand) {
1005       case "downloadsCmd_retry":
1006         return this.download.canceled || !!this.download.error;
1007       case "downloadsCmd_pauseResume":
1008         return this.download.hasPartialData && !this.download.error;
1009       case "downloadsCmd_openReferrer":
1010         return (
1011           !!this.download.source.referrerInfo &&
1012           !!this.download.source.referrerInfo.originalReferrer
1013         );
1014       case "downloadsCmd_confirmBlock":
1015       case "downloadsCmd_chooseUnblock":
1016       case "downloadsCmd_chooseOpen":
1017       case "downloadsCmd_unblock":
1018       case "downloadsCmd_unblockAndSave":
1019       case "downloadsCmd_unblockAndOpen":
1020         return this.download.hasBlockedData;
1021       case "downloadsCmd_cancel":
1022         return this.download.hasPartialData || !this.download.stopped;
1023       case "downloadsCmd_open":
1024       case "downloadsCmd_open:current":
1025       case "downloadsCmd_open:tab":
1026       case "downloadsCmd_open:tabshifted":
1027       case "downloadsCmd_open:window":
1028       case "downloadsCmd_alwaysOpenSimilarFiles":
1029         // This property is false if the download did not succeed.
1030         return this.download.target.exists;
1032       case "downloadsCmd_show":
1033       case "downloadsCmd_deleteFile":
1034         let { target } = this.download;
1035         return (
1036           !this.download.deleted && (target.exists || target.partFileExists)
1037         );
1039       case "downloadsCmd_delete":
1040       case "cmd_delete":
1041         // We don't want in-progress downloads to be removed accidentally.
1042         return this.download.stopped;
1043       case "downloadsCmd_openInSystemViewer":
1044       case "downloadsCmd_alwaysOpenInSystemViewer":
1045         return lazy.DownloadIntegration.shouldViewDownloadInternally(
1046           lazy.DownloadsCommon.getMimeInfo(this.download)?.type
1047         );
1048     }
1049     return DownloadsViewUI.isCommandName(aCommand) && !!this[aCommand];
1050   },
1052   doCommand(aCommand) {
1053     // split off an optional command "modifier" into an argument,
1054     // e.g. "downloadsCmd_open:window"
1055     let [command, modifier] = aCommand.split(":");
1056     if (DownloadsViewUI.isCommandName(command)) {
1057       this[command](modifier);
1058     }
1059   },
1061   onButton() {
1062     this.doCommand(this.buttonCommandName);
1063   },
1065   downloadsCmd_cancel() {
1066     // This is the correct way to avoid race conditions when cancelling.
1067     this.download.cancel().catch(() => {});
1068     this.download
1069       .removePartialData()
1070       .catch(console.error)
1071       .finally(() => this.download.target.refresh());
1072   },
1074   downloadsCmd_confirmBlock() {
1075     this.download.confirmBlock().catch(console.error);
1076   },
1078   downloadsCmd_open(openWhere = "tab") {
1079     lazy.DownloadsCommon.openDownload(this.download, {
1080       openWhere,
1081     });
1082   },
1084   downloadsCmd_openReferrer() {
1085     this.element.ownerGlobal.openURL(
1086       this.download.source.referrerInfo.originalReferrer
1087     );
1088   },
1090   downloadsCmd_pauseResume() {
1091     if (this.download.stopped) {
1092       this.download.start();
1093     } else {
1094       this.download.cancel();
1095     }
1096   },
1098   downloadsCmd_show() {
1099     let file = new lazy.FileUtils.File(this.download.target.path);
1100     lazy.DownloadsCommon.showDownloadedFile(file);
1101   },
1103   downloadsCmd_retry() {
1104     if (this.download.start) {
1105       // Errors when retrying are already reported as download failures.
1106       this.download.start().catch(() => {});
1107       return;
1108     }
1110     let window = this.browserWindow || this.element.ownerGlobal;
1111     let document = window.document;
1113     // Do not suggest a file name if we don't know the original target.
1114     let targetPath = this.download.target.path
1115       ? PathUtils.filename(this.download.target.path)
1116       : null;
1117     window.DownloadURL(this.download.source.url, targetPath, document);
1118   },
1120   downloadsCmd_delete() {
1121     // Alias for the 'cmd_delete' command, because it may clash with another
1122     // controller which causes unexpected behavior as different codepaths claim
1123     // ownership.
1124     this.cmd_delete();
1125   },
1127   cmd_delete() {
1128     lazy.DownloadsCommon.deleteDownload(this.download).catch(console.error);
1129   },
1131   async downloadsCmd_deleteFile() {
1132     // Remove the download from the session and history downloads, delete part files.
1133     await lazy.DownloadsCommon.deleteDownloadFiles(
1134       this.download,
1135       DownloadsViewUI.clearHistoryOnDelete
1136     );
1137   },
1139   downloadsCmd_openInSystemViewer() {
1140     // For this interaction only, pass a flag to override the preferredAction for this
1141     // mime-type and open using the system viewer
1142     lazy.DownloadsCommon.openDownload(this.download, {
1143       useSystemDefault: true,
1144     }).catch(console.error);
1145   },
1147   downloadsCmd_alwaysOpenInSystemViewer() {
1148     // this command toggles between setting preferredAction for this mime-type to open
1149     // using the system viewer, or to open the file in browser.
1150     const mimeInfo = lazy.DownloadsCommon.getMimeInfo(this.download);
1151     if (!mimeInfo) {
1152       throw new Error(
1153         "Can't open download with unknown mime-type in system viewer"
1154       );
1155     }
1156     if (mimeInfo.preferredAction !== mimeInfo.useSystemDefault) {
1157       // User has selected to open this mime-type with the system viewer from now on
1158       lazy.DownloadsCommon.log(
1159         "downloadsCmd_alwaysOpenInSystemViewer command for download: ",
1160         this.download,
1161         "switching to use system default for " + mimeInfo.type
1162       );
1163       mimeInfo.preferredAction = mimeInfo.useSystemDefault;
1164       mimeInfo.alwaysAskBeforeHandling = false;
1165     } else {
1166       lazy.DownloadsCommon.log(
1167         "downloadsCmd_alwaysOpenInSystemViewer command for download: ",
1168         this.download,
1169         "currently uses system default, switching to handleInternally"
1170       );
1171       // User has selected to not open this mime-type with the system viewer
1172       mimeInfo.preferredAction = mimeInfo.handleInternally;
1173     }
1174     lazy.handlerSvc.store(mimeInfo);
1175     lazy.DownloadsCommon.openDownload(this.download).catch(console.error);
1176   },
1178   downloadsCmd_alwaysOpenSimilarFiles() {
1179     const mimeInfo = lazy.DownloadsCommon.getMimeInfo(this.download);
1180     if (!mimeInfo) {
1181       throw new Error("Can't open download with unknown mime-type");
1182     }
1184     // User has selected to always open this mime-type from now on and will add this
1185     // mime-type to our preferences table with the system default option. Open the
1186     // file immediately after selecting the menu item like alwaysOpenInSystemViewer.
1187     if (mimeInfo.preferredAction !== mimeInfo.useSystemDefault) {
1188       mimeInfo.preferredAction = mimeInfo.useSystemDefault;
1189       lazy.handlerSvc.store(mimeInfo);
1190       lazy.DownloadsCommon.openDownload(this.download).catch(console.error);
1191     } else {
1192       // Otherwise, if user unchecks this option after already enabling it from the
1193       // context menu, resort to saveToDisk.
1194       mimeInfo.preferredAction = mimeInfo.saveToDisk;
1195       lazy.handlerSvc.store(mimeInfo);
1196     }
1197   },