/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; var EXPORTED_SYMBOLS = [ "DownloadsCommon", ]; /** * Handles the Downloads panel shared methods and data access. * * This file includes the following constructors and global objects: * * DownloadsCommon * This object is exposed directly to the consumers of this JavaScript module, * and provides shared methods for all the instances of the user interface. * * DownloadsData * Retrieves the list of past and completed downloads from the underlying * Downloads API data, and provides asynchronous notifications allowing * to build a consistent view of the available data. */ // Globals const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); const { AppConstants } = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); XPCOMUtils.defineLazyModuleGetters(this, { NetUtil: "resource://gre/modules/NetUtil.jsm", PluralForm: "resource://gre/modules/PluralForm.jsm", DownloadHistory: "resource://gre/modules/DownloadHistory.jsm", Downloads: "resource://gre/modules/Downloads.jsm", DownloadUIHelper: "resource://gre/modules/DownloadUIHelper.jsm", DownloadUtils: "resource://gre/modules/DownloadUtils.jsm", OS: "resource://gre/modules/osfile.jsm", }); XPCOMUtils.defineLazyGetter(this, "DownloadsLogger", () => { let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm", {}); let consoleOptions = { maxLogLevelPref: "browser.download.loglevel", prefix: "Downloads" }; return new ConsoleAPI(consoleOptions); }); const kDownloadsStringBundleUrl = "chrome://communicator/locale/downloads/downloadmanager.properties"; // Currently not used. Keep for future updates. const kDownloadsStringsRequiringFormatting = { fileExecutableSecurityWarning: true }; // Currently not used. Keep for future updates. const kDownloadsStringsRequiringPluralForm = { otherDownloads3: true }; const kPartialDownloadSuffix = ".part"; const kPrefBranch = Services.prefs.getBranch("browser.download."); const PREF_DM_BEHAVIOR = "browser.download.manager.behavior"; const PROGRESS_DIALOG_URL = "chrome://communicator/content/downloads/progressDialog.xul"; var PrefObserver = { QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), getPref(name) { try { switch (typeof this.prefs[name]) { case "boolean": return kPrefBranch.getBoolPref(name); } } catch (ex) { } return this.prefs[name]; }, observe(aSubject, aTopic, aData) { if (this.prefs.hasOwnProperty(aData)) { delete this[aData]; this[aData] = this.getPref(aData); } }, register(prefs) { this.prefs = prefs; kPrefBranch.addObserver("", this, true); for (let key in prefs) { let name = key; XPCOMUtils.defineLazyGetter(this, name, function() { return PrefObserver.getPref(name); }); } }, }; // PrefObserver.register({ // prefName: defaultValue // }); // DownloadsCommon /** * This object is exposed directly to the consumers of this JavaScript module, * and provides shared methods for all the instances of the user interface. */ var DownloadsCommon = { // The following legacy constants are still returned by stateOfDownload, but // individual properties of the Download object should normally be used. DOWNLOAD_NOTSTARTED: -1, DOWNLOAD_DOWNLOADING: 0, DOWNLOAD_FINISHED: 1, DOWNLOAD_FAILED: 2, DOWNLOAD_CANCELED: 3, DOWNLOAD_PAUSED: 4, DOWNLOAD_BLOCKED_PARENTAL: 6, DOWNLOAD_DIRTY: 8, DOWNLOAD_BLOCKED_POLICY: 9, // The following are the possible values of the "attention" property. ATTENTION_NONE: "", ATTENTION_SUCCESS: "success", ATTENTION_WARNING: "warning", ATTENTION_SEVERE: "severe", /** * Returns an object whose keys are the string names from the downloads string * bundle, and whose values are either the translated strings or functions * returning formatted strings. */ get strings() { let strings = {}; let sb = Services.strings.createBundle(kDownloadsStringBundleUrl); let enumerator = sb.getSimpleEnumeration(); while (enumerator.hasMoreElements()) { let string = enumerator.getNext().QueryInterface(Ci.nsIPropertyElement); let stringName = string.key; if (stringName in kDownloadsStringsRequiringFormatting) { strings[stringName] = function() { // Convert "arguments" to a real array before calling into XPCOM. return sb.formatStringFromName(stringName, Array.slice(arguments, 0), arguments.length); }; } else if (stringName in kDownloadsStringsRequiringPluralForm) { strings[stringName] = function(aCount) { // Convert "arguments" to a real array before calling into XPCOM. let formattedString = sb.formatStringFromName(stringName, Array.slice(arguments, 0), arguments.length); return PluralForm.get(aCount, formattedString); }; } else { strings[stringName] = string.value; } } delete this.strings; return this.strings = strings; }, /** * Get access to one of the DownloadsData or HistoryDownloadsData objects * depending on whether history downloads should be included. * * @param window * The browser window which owns the download button. * @param [optional] history * True to include history downloads when the window is public. */ // does not apply in SM getData(window, history = false) { if (history) { return HistoryDownloadsData; } return DownloadsData; }, /** * Initializes the Downloads Manager common code. */ init() { const { DownloadsData } = ChromeUtils.import("resource://gre/modules/Downloads.jsm"); const { DownloadIntegration } = ChromeUtils.import("resource://gre/modules/DownloadIntegration.jsm"); DownloadIntegration.shouldPersistDownload = function() { return true; }; DownloadsData.initializeDataLink(); }, /** * Returns the legacy state integer value for the provided Download object. */ stateOfDownload(download) { // Collapse state using the correct priority. if (!download.stopped) { return DownloadsCommon.DOWNLOAD_DOWNLOADING; } if (download.succeeded) { return DownloadsCommon.DOWNLOAD_FINISHED; } if (download.error) { if (download.error.becauseBlockedByParentalControls) { return DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL; } if (download.error.becauseBlockedByReputationCheck) { return DownloadsCommon.DOWNLOAD_DIRTY; } return DownloadsCommon.DOWNLOAD_FAILED; } if (download.canceled) { if (download.hasPartialData) { return DownloadsCommon.DOWNLOAD_PAUSED; } return DownloadsCommon.DOWNLOAD_CANCELED; } return DownloadsCommon.DOWNLOAD_NOTSTARTED; }, /** * Returns the state as a string for the provided Download object. */ stateOfDownloadText(download) { // Don't duplicate the logic so just call stateOfDownload. let state = this.stateOfDownload(download); let s = DownloadsCommon.strings; let title = s.unblockHeaderUnblock; let verboseState; switch (state) { case DownloadsCommon.DOWNLOAD_PAUSED: verboseState = s.statePaused; break; case DownloadsCommon.DOWNLOAD_DOWNLOADING: verboseState = s.stateDownloading; break; case DownloadsCommon.DOWNLOAD_FINISHED: verboseState = s.stateCompleted; break; case DownloadsCommon.DOWNLOAD_FAILED: verboseState = s.stateFailed; break; case DownloadsCommon.DOWNLOAD_CANCELED: verboseState = s.stateCanceled; break; // Security Zone Policy case DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL: // Security Zone Policy verboseState = s.stateBlockedParentalControls; break; // Security Zone Policy case DownloadsCommon.DOWNLOAD_BLOCKED_POLICY: verboseState = s.stateBlockedPolicy; break; // possible virus/spyware case DownloadsCommon.DOWNLOAD_DIRTY: verboseState = s.stateDirty; break; // Currently not returned. case DownloadsCommon.DOWNLOAD_UPLOADING: verboseState = s.stateNotStarted; break; case DownloadsCommon.DOWNLOAD_NOTSTARTED: verboseState = s.stateNotStarted; break; // Whoops! default: verboseState = s.stateUnknown; break; } return verboseState; }, /** * Returns the transfer progress text for the provided Download object. */ getTransferredBytes(download) { let currentBytes; let totalBytes; // Download in progress. // Download paused / canceled and has partial data. if (!download.stopped || (download.canceled && download.hasPartialData)) { currentBytes = download.currentBytes, totalBytes = download.hasProgress ? download.totalBytes : -1; // Download done but file missing. } else if (download.succeeded && !download.exists) { currentBytes = download.totalBytes ? download.totalBytes : -1; totalBytes = -1; // For completed downloads, show the file size } else if (download.succeeded && download.target.size !== undefined) { currentBytes = download.target.size; totalBytes = -1; // Some local files saves e.g. from attachments also have no size. // They only have a target in downloads.json but no target.path. // FIX ME later. } else { currentBytes = -1; totalBytes = -1; } // We do not want to show 0 of xxx bytes. if (currentBytes == 0) { currentBytes = -1; } if (totalBytes == 0) { totalBytes = -1; } // We tried everything. if (currentBytes == -1 && totalBytes == -1) { return ""; } return DownloadUtils.getTransferTotal(currentBytes, totalBytes); }, /** * Returns the time remaining text for the provided Download object. * For calculation a variable is stored in it. */ getTimeRemaining(download) { // If you do changes here please check progressDialog.js. if (!download.stopped) { let lastSec = (download.lastSec == null) ? Infinity : download.lastSec; // Calculate the time remaining if we have valid values let seconds = (download.speed > 0) && (download.totalBytes > 0) ? (download.totalBytes - download.currentBytes) / download.speed : -1; let [timeLeft, newLast] = DownloadUtils.getTimeLeft(seconds, lastSec); // Store it back for next calculation. download.lastSec = newLast; return timeLeft; } return ""; }, /** * Opens a downloaded file. * * @param aFile * the downloaded file to be opened. * @param aMimeInfo * the mime type info object. May be null. * @param aOwnerWindow * the window with which this action is associated. */ openDownloadedFile(aFile, aMimeInfo, aOwnerWindow) { if (!(aFile instanceof Ci.nsIFile)) { throw new Error("aFile must be a nsIFile object"); } if (aMimeInfo && !(aMimeInfo instanceof Ci.nsIMIMEInfo)) { throw new Error("Invalid value passed for aMimeInfo"); } if (!(aOwnerWindow instanceof Ci.nsIDOMWindow)) { throw new Error("aOwnerWindow must be a dom-window object"); } let isWindowsExe = AppConstants.platform == "win" && aFile.leafName.toLowerCase().endsWith(".exe"); let promiseShouldLaunch; // Don't prompt on Windows for .exe since there will be a native prompt. if (aFile.isExecutable() && !isWindowsExe) { // We get a prompter for the provided window here, even though anchoring // to the most recently active window should work as well. promiseShouldLaunch = DownloadUIHelper.getPrompter(aOwnerWindow) .confirmLaunchExecutable(aFile.path); } else { promiseShouldLaunch = Promise.resolve(true); } promiseShouldLaunch.then(shouldLaunch => { if (!shouldLaunch) { return; } // Actually open the file. try { if (aMimeInfo && aMimeInfo.preferredAction == aMimeInfo.useHelperApp) { aMimeInfo.launchWithFile(aFile); return; } } catch (ex) { } // If either we don't have the mime info, or the preferred action failed, // attempt to launch the file directly. try { aFile.launch(); } catch (ex) { // If launch fails, try sending it through the system's external "file:" // URL handler. Cc["@mozilla.org/uriloader/external-protocol-service;1"] .getService(Ci.nsIExternalProtocolService) .loadUrl(NetUtil.newURI(aFile)); } }).catch(Cu.reportError); }, /** * Show a downloaded file in the system file manager. * * @param aFile * a downloaded file. */ showDownloadedFile(aFile) { if (!(aFile instanceof Ci.nsIFile)) { throw new Error("aFile must be a nsIFile object"); } try { // Show the directory containing the file and select the file. aFile.reveal(); } catch (ex) { // If reveal fails for some reason (e.g., it's not implemented on unix // or the file doesn't exist), try using the parent if we have it. let parent = aFile.parent; if (parent) { this.showDirectory(parent); } } }, /** * Show the specified folder in the system file manager. * * @param aDirectory * a directory to be opened with system file manager. */ showDirectory(aDirectory) { if (!(aDirectory instanceof Ci.nsIFile)) { throw new Error("aDirectory must be a nsIFile object"); } try { aDirectory.launch(); } catch (ex) { // If launch fails (probably because it's not implemented), let // the OS handler try to open the directory. Cc["@mozilla.org/uriloader/external-protocol-service;1"] .getService(Ci.nsIExternalProtocolService) .loadUrl(NetUtil.newURI(aDirectory)); } }, /** * Displays an alert message box which asks the user if they want to * unblock the downloaded file or not. * * @param options * An object with the following properties: * { * verdict: * The detailed reason why the download was blocked, according to * the "Downloads.Error.BLOCK_VERDICT_" constants. If an unknown * reason is specified, "Downloads.Error.BLOCK_VERDICT_MALWARE" is * assumed. * window: * The window with which this action is associated. * dialogType: * String that determines which actions are available: * - "unblock" to offer just "unblock". * - "chooseUnblock" to offer "unblock" and "confirmBlock". * - "chooseOpen" to offer "open" and "confirmBlock". * } * * @return {Promise} * @resolves String representing the action that should be executed: * - "open" to allow the download and open the file. * - "unblock" to allow the download without opening the file. * - "confirmBlock" to delete the blocked data permanently. * - "cancel" to do nothing and cancel the operation. */ async confirmUnblockDownload({ verdict, window, dialogType }) { let s = DownloadsCommon.strings; // All the dialogs have an action button and a cancel button, while only // some of them have an additonal button to remove the file. The cancel // button must always be the one at BUTTON_POS_1 because this is the value // returned by confirmEx when using ESC or closing the dialog (bug 345067). let title = s.unblockHeaderUnblock; let firstButtonText = s.unblockButtonUnblock; let firstButtonAction = "unblock"; let buttonFlags = (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0) + (Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1); switch (dialogType) { case "unblock": // Use only the unblock action. The default is to cancel. buttonFlags += Ci.nsIPrompt.BUTTON_POS_1_DEFAULT; break; case "chooseUnblock": // Use the unblock and remove file actions. The default is remove file. buttonFlags += (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2) + Ci.nsIPrompt.BUTTON_POS_2_DEFAULT; break; case "chooseOpen": // Use the unblock and open file actions. The default is open file. title = s.unblockHeaderOpen; firstButtonText = s.unblockButtonOpen; firstButtonAction = "open"; buttonFlags += (Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_2) + Ci.nsIPrompt.BUTTON_POS_0_DEFAULT; break; default: Cu.reportError("Unexpected dialog type: " + dialogType); return "cancel"; } let message; switch (verdict) { case Downloads.Error.BLOCK_VERDICT_UNCOMMON: message = s.unblockTypeUncommon2; break; case Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: message = s.unblockTypePotentiallyUnwanted2; break; default: // Assume Downloads.Error.BLOCK_VERDICT_MALWARE message = s.unblockTypeMalware; break; } message += "\n\n" + s.unblockTip2; Services.ww.registerNotification(function onOpen(subj, topic) { if (topic == "domwindowopened" && subj instanceof Ci.nsIDOMWindow) { // Make sure to listen for "DOMContentLoaded" because it is fired // before the "load" event. subj.addEventListener("DOMContentLoaded", function() { if (subj.document.documentURI == "chrome://global/content/commonDialog.xul") { Services.ww.unregisterNotification(onOpen); let dialog = subj.document.getElementById("commonDialog"); if (dialog) { // Change the dialog to use a warning icon. dialog.classList.add("alert-dialog"); } } }, {once: true}); } }); let rv = Services.prompt.confirmEx(window, title, message, buttonFlags, firstButtonText, null, s.unblockButtonConfirmBlock, null, {}); return [firstButtonAction, "cancel", "confirmBlock"][rv]; }, }; XPCOMUtils.defineLazyGetter(this.DownloadsCommon, "log", () => { return DownloadsLogger.log.bind(DownloadsLogger); }); XPCOMUtils.defineLazyGetter(this.DownloadsCommon, "error", () => { return DownloadsLogger.error.bind(DownloadsLogger); }); // DownloadsData /** * Retrieves the list of past and completed downloads from the underlying * Downloads API data, and provides asynchronous notifications allowing to * build a consistent view of the available data. * * Note that using this object does not automatically initialize the list of * downloads. This is useful to display a neutral progress indicator in * the main browser window until the autostart timeout elapses. * * This powers the DownloadsData and HistoryDownloadsData singleton objects. */ function DownloadsDataCtor({ isHistory } = {}) { // Contains all the available Download objects and their integer state. this.oldDownloadStates = new Map(); // For the history downloads list we don't need to register this as a view, // but we have to ensure that the DownloadsData object is initialized before // we register more views. This ensures that the view methods of DownloadsData // are invoked before those of views registered on HistoryDownloadsData, // allowing the endTime property to be set correctly. if (isHistory) { DownloadsData.initializeDataLink(); this._promiseList = DownloadsData._promiseList .then(() => DownloadHistory.getList()); return; } // This defines "initializeDataLink" and "_promiseList" synchronously, then // continues execution only when "initializeDataLink" is called, allowing the // underlying data to be loaded only when actually needed. this._promiseList = (async () => { await new Promise(resolve => this.initializeDataLink = resolve); let list = await Downloads.getList(Downloads.ALL); await list.addView(this); this._downloadsLoaded = true; return list; })(); } DownloadsDataCtor.prototype = { /** * Starts receiving events for current downloads. */ initializeDataLink() {}, /** * Used by sound logic when download ends. */ _sound: null, /** * Promise resolved with the underlying DownloadList object once we started * receiving events for current downloads. */ _promiseList: null, _downloadsLoaded: null, /** * Iterator for all the available Download objects. This is empty until the * data has been loaded using the JavaScript API for downloads. */ get downloads() { return this.oldDownloadStates.keys(); }, /** * True if there are finished downloads that can be removed from the list. */ get canRemoveFinished() { for (let download of this.downloads) { // Stopped, paused, and failed downloads with partial data are removed. if (download.stopped && !(download.canceled && download.hasPartialData)) { return true; } } return false; }, /** * Asks the back-end to remove finished downloads from the list. This method * is only called after the data link has been initialized. */ removeFinished() { Downloads.getList(Downloads.ALL) .then(list => list.removeFinished()) .catch(Cu.reportError); }, // Integration with the asynchronous Downloads back-end // Download view onDownloadAdded: function(download) { // Download objects do not store the end time of downloads, as the Downloads // API does not need to persist this information for all platforms. Once a // download terminates on a Desktop browser, it becomes a history download, // for which the end time is stored differently, as a Places annotation. download.endTime = Date.now(); this.oldDownloadStates.set(download, DownloadsCommon.stateOfDownload(download)); download.displayName = download.target.path ? OS.Path.basename(download.target.path) : download.source.url; this.onDownloadChanged(download); if (!this._downloadsLoaded) return; var behavior = download.source.isPrivate ? 1 : Services.prefs.getIntPref(PREF_DM_BEHAVIOR); switch (behavior) { case 0: Cc["@mozilla.org/suite/suiteglue;1"] .getService(Ci.nsISuiteGlue) .showDownloadManager(true); break; case 1: Services.ww.openWindow(null, PROGRESS_DIALOG_URL, null, "chrome,titlebar,centerscreen,minimizable=yes,dialog=no", { wrappedJSObject: download }); break; } return; // No UI for behavior >= 2 }, onDownloadChanged(download) { let oldState = this.oldDownloadStates.get(download); let newState = DownloadsCommon.stateOfDownload(download); this.oldDownloadStates.set(download, newState); if (oldState != newState && (download.succeeded || (download.canceled && !download.hasPartialData) || download.error)) { // Store the end time that may be displayed by the views. download.endTime = Date.now(); // This state transition code should actually be located in a Downloads // API module (bug 941009). // This might end with an exception if it is an unsupported uri scheme. DownloadHistory.updateMetaData(download); if (download.succeeded) { this.playDownloadSound(); } } }, onDownloadRemoved(download) { this.oldDownloadStates.delete(download); }, // Download summary onSummaryChanged: function() { if (!gTaskbarProgress) return; const nsITaskbarProgress = Ci.nsITaskbarProgress; var currentBytes = gDownloadsSummary.progressCurrentBytes; var totalBytes = gDownloadsSummary.progressTotalBytes; var state = gDownloadsSummary.allHaveStopped ? currentBytes ? nsITaskbarProgress.STATE_PAUSED : nsITaskbarProgress.STATE_NO_PROGRESS : currentBytes < totalBytes ? nsITaskbarProgress.STATE_NORMAL : nsITaskbarProgress.STATE_INDETERMINATE; switch (state) { case nsITaskbarProgress.STATE_NO_PROGRESS: case nsITaskbarProgress.STATE_INDETERMINATE: gTaskbarProgress.setProgressState(state, 0, 0); break; default: gTaskbarProgress.setProgressState(state, currentBytes, totalBytes); break; } }, // Play a download sound. playDownloadSound: function() { if (Services.prefs.getBoolPref("browser.download.finished_download_sound")) { if (!this._sound) this._sound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound); try { let url = Services.prefs.getStringPref("browser.download.finished_sound_url"); this._sound.play(Services.io.newURI(url)); } catch (e) { this._sound.beep(); } } }, // Registration of views /** * Adds an object to be notified when the available download data changes. * The specified object is initialized with the currently available downloads. * * @param aView * DownloadsView object to be added. This reference must be passed to * removeView before termination. */ addView(aView) { this._promiseList.then(list => list.addView(aView)) .catch(Cu.reportError); }, /** * Removes an object previously added using addView. * * @param aView * DownloadsView object to be removed. */ removeView(aView) { this._promiseList.then(list => list.removeView(aView)) .catch(Cu.reportError); }, }; XPCOMUtils.defineLazyGetter(this, "HistoryDownloadsData", function() { return new DownloadsDataCtor({ isHistory: true }); }); XPCOMUtils.defineLazyGetter(this, "DownloadsData", function() { return new DownloadsDataCtor(); });