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