summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/downloads
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/downloads')
-rw-r--r--toolkit/mozapps/downloads/DownloadLastDir.sys.mjs254
-rw-r--r--toolkit/mozapps/downloads/DownloadUtils.sys.mjs616
-rw-r--r--toolkit/mozapps/downloads/HelperAppDlg.sys.mjs1349
-rw-r--r--toolkit/mozapps/downloads/components.conf14
-rw-r--r--toolkit/mozapps/downloads/content/unknownContentType.xhtml104
-rw-r--r--toolkit/mozapps/downloads/jar.mn7
-rw-r--r--toolkit/mozapps/downloads/moz.build22
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser.toml33
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js98
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js118
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js96
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js103
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js58
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js68
-rw-r--r--toolkit/mozapps/downloads/tests/browser/example.jnlp0
-rw-r--r--toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/head.js17
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE0
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^2
-rw-r--r--toolkit/mozapps/downloads/tests/moz.build8
-rw-r--r--toolkit/mozapps/downloads/tests/unit/head_downloads.js5
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js398
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js56
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js28
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js33
-rw-r--r--toolkit/mozapps/downloads/tests/unit/xpcshell.toml10
30 files changed, 3502 insertions, 0 deletions
diff --git a/toolkit/mozapps/downloads/DownloadLastDir.sys.mjs b/toolkit/mozapps/downloads/DownloadLastDir.sys.mjs
new file mode 100644
index 0000000000..da0439dcc5
--- /dev/null
+++ b/toolkit/mozapps/downloads/DownloadLastDir.sys.mjs
@@ -0,0 +1,254 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/*
+ * The behavior implemented by gDownloadLastDir is documented here.
+ *
+ * In normal browsing sessions, gDownloadLastDir uses the browser.download.lastDir
+ * preference to store the last used download directory. The first time the user
+ * switches into the private browsing mode, the last download directory is
+ * preserved to the pref value, but if the user switches to another directory
+ * during the private browsing mode, that directory is not stored in the pref,
+ * and will be merely kept in memory. When leaving the private browsing mode,
+ * this in-memory value will be discarded, and the last download directory
+ * will be reverted to the pref value.
+ *
+ * Both the pref and the in-memory value will be cleared when clearing the
+ * browsing history. This effectively changes the last download directory
+ * to the default download directory on each platform.
+ *
+ * If passed a URI, the last used directory is also stored with that URI in the
+ * content preferences database. This can be disabled by setting the pref
+ * browser.download.lastDir.savePerSite to false.
+ */
+
+const LAST_DIR_PREF = "browser.download.lastDir";
+const SAVE_PER_SITE_PREF = LAST_DIR_PREF + ".savePerSite";
+const nsIFile = Ci.nsIFile;
+
+import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "cps2",
+ "@mozilla.org/content-pref/service;1",
+ "nsIContentPrefService2"
+);
+
+let nonPrivateLoadContext = Cu.createLoadContext();
+let privateLoadContext = Cu.createPrivateLoadContext();
+
+var observer = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "last-pb-context-exited":
+ gDownloadLastDirFile = null;
+ break;
+ case "browser:purge-session-history":
+ gDownloadLastDirFile = null;
+ if (Services.prefs.prefHasUserValue(LAST_DIR_PREF)) {
+ Services.prefs.clearUserPref(LAST_DIR_PREF);
+ }
+ // Ensure that purging session history causes both the session-only PB cache
+ // and persistent prefs to be cleared.
+ let promises = [
+ new Promise(resolve =>
+ lazy.cps2.removeByName(LAST_DIR_PREF, nonPrivateLoadContext, {
+ handleCompletion: resolve,
+ })
+ ),
+ new Promise(resolve =>
+ lazy.cps2.removeByName(LAST_DIR_PREF, privateLoadContext, {
+ handleCompletion: resolve,
+ })
+ ),
+ ];
+ // This is for testing purposes.
+ if (aSubject && typeof subject == "object") {
+ aSubject.promise = Promise.all(promises);
+ }
+ break;
+ }
+ },
+};
+
+Services.obs.addObserver(observer, "last-pb-context-exited", true);
+Services.obs.addObserver(observer, "browser:purge-session-history", true);
+
+function readLastDirPref() {
+ try {
+ return Services.prefs.getComplexValue(LAST_DIR_PREF, nsIFile);
+ } catch (e) {
+ return null;
+ }
+}
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isContentPrefEnabled",
+ SAVE_PER_SITE_PREF,
+ true
+);
+
+var gDownloadLastDirFile = readLastDirPref();
+
+export class DownloadLastDir {
+ // aForcePrivate is only used when aWindow is null.
+ constructor(aWindow, aForcePrivate) {
+ let isPrivate = false;
+ if (aWindow === null) {
+ isPrivate =
+ aForcePrivate || PrivateBrowsingUtils.permanentPrivateBrowsing;
+ } else {
+ let loadContext = aWindow.docShell.QueryInterface(Ci.nsILoadContext);
+ isPrivate = loadContext.usePrivateBrowsing;
+ }
+
+ // We always use a fake load context because we may not have one (i.e.,
+ // in the aWindow == null case) and because the load context associated
+ // with aWindow may disappear by the time we need it. This approach is
+ // safe because we only care about the private browsing state. All the
+ // rest of the load context isn't of interest to the content pref service.
+ this.fakeContext = isPrivate ? privateLoadContext : nonPrivateLoadContext;
+ }
+
+ isPrivate() {
+ return this.fakeContext.usePrivateBrowsing;
+ }
+
+ // compat shims
+ get file() {
+ return this.#getLastFile();
+ }
+ set file(val) {
+ this.setFile(null, val);
+ }
+
+ cleanupPrivateFile() {
+ gDownloadLastDirFile = null;
+ }
+
+ #getLastFile() {
+ if (gDownloadLastDirFile && !gDownloadLastDirFile.exists()) {
+ gDownloadLastDirFile = null;
+ }
+
+ if (this.isPrivate()) {
+ if (!gDownloadLastDirFile) {
+ gDownloadLastDirFile = readLastDirPref();
+ }
+ return gDownloadLastDirFile;
+ }
+ return readLastDirPref();
+ }
+
+ async getFileAsync(aURI) {
+ let plainPrefFile = this.#getLastFile();
+ if (!aURI || !lazy.isContentPrefEnabled) {
+ return plainPrefFile;
+ }
+
+ return new Promise(resolve => {
+ lazy.cps2.getByDomainAndName(
+ this.#cpsGroupFromURL(aURI),
+ LAST_DIR_PREF,
+ this.fakeContext,
+ {
+ _result: null,
+ handleResult(aResult) {
+ this._result = aResult;
+ },
+ handleCompletion(aReason) {
+ let file = plainPrefFile;
+ if (
+ aReason == Ci.nsIContentPrefCallback2.COMPLETE_OK &&
+ this._result instanceof Ci.nsIContentPref
+ ) {
+ try {
+ file = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ file.initWithPath(this._result.value);
+ } catch (e) {
+ file = plainPrefFile;
+ }
+ }
+ resolve(file);
+ },
+ }
+ );
+ });
+ }
+
+ setFile(aURI, aFile) {
+ if (aURI && lazy.isContentPrefEnabled) {
+ if (aFile instanceof Ci.nsIFile) {
+ lazy.cps2.set(
+ this.#cpsGroupFromURL(aURI),
+ LAST_DIR_PREF,
+ aFile.path,
+ this.fakeContext
+ );
+ } else {
+ lazy.cps2.removeByDomainAndName(
+ this.#cpsGroupFromURL(aURI),
+ LAST_DIR_PREF,
+ this.fakeContext
+ );
+ }
+ }
+ if (this.isPrivate()) {
+ if (aFile instanceof Ci.nsIFile) {
+ gDownloadLastDirFile = aFile.clone();
+ } else {
+ gDownloadLastDirFile = null;
+ }
+ } else if (aFile instanceof Ci.nsIFile) {
+ Services.prefs.setComplexValue(LAST_DIR_PREF, nsIFile, aFile);
+ } else if (Services.prefs.prefHasUserValue(LAST_DIR_PREF)) {
+ Services.prefs.clearUserPref(LAST_DIR_PREF);
+ }
+ }
+
+ /**
+ * Pre-processor to extract a domain name to be used with the content-prefs
+ * service. This specially handles data and file URIs so that the download
+ * dirs are recalled in a more consistent way:
+ * - all file:/// URIs share the same folder
+ * - data: URIs share a folder per mime-type. If a mime-type is not
+ * specified text/plain is assumed.
+ * - blob: URIs share the same folder as their origin. This is done by
+ * ContentPrefs already, so we just let the url fall-through.
+ * In any other case the original URL is returned as a string and ContentPrefs
+ * will do its usual parsing.
+ *
+ * @param {string|nsIURI|URL} url The URL to parse
+ * @returns {string} the domain name to use, or the original url.
+ */
+ #cpsGroupFromURL(url) {
+ if (typeof url == "string") {
+ url = new URL(url);
+ } else if (url instanceof Ci.nsIURI) {
+ url = URL.fromURI(url);
+ }
+ if (!URL.isInstance(url)) {
+ return url;
+ }
+ if (url.protocol == "data:") {
+ return url.href.match(/^data:[^;,]*/i)[0].replace(/:$/, ":text/plain");
+ }
+ if (url.protocol == "file:") {
+ return "file:///";
+ }
+ return url.href;
+ }
+}
diff --git a/toolkit/mozapps/downloads/DownloadUtils.sys.mjs b/toolkit/mozapps/downloads/DownloadUtils.sys.mjs
new file mode 100644
index 0000000000..3bf97f3c9e
--- /dev/null
+++ b/toolkit/mozapps/downloads/DownloadUtils.sys.mjs
@@ -0,0 +1,616 @@
+/* vim: sw=2 ts=2 sts=2 expandtab 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/. */
+
+/**
+ * This module provides the DownloadUtils object which contains useful methods
+ * for downloads such as displaying file sizes, transfer times, and download
+ * locations.
+ *
+ * List of methods:
+ *
+ * [string status, double newLast]
+ * getDownloadStatus(int aCurrBytes, [optional] int aMaxBytes,
+ * [optional] double aSpeed, [optional] double aLastSec)
+ *
+ * string progress
+ * getTransferTotal(int aCurrBytes, [optional] int aMaxBytes)
+ *
+ * [string timeLeft, double newLast]
+ * getTimeLeft(double aSeconds, [optional] double aLastSec)
+ *
+ * [string dateCompact, string dateComplete]
+ * getReadableDates(Date aDate, [optional] Date aNow)
+ *
+ * [string displayHost, string fullHost]
+ * getURIHost(string aURIString)
+ *
+ * [string convertedBytes, string units]
+ * convertByteUnits(int aBytes)
+ *
+ * [int time, string units, int subTime, string subUnits]
+ * convertTimeUnits(double aSecs)
+ */
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+const BYTE_UNITS = [
+ "download-utils-bytes",
+ "download-utils-kilobyte",
+ "download-utils-megabyte",
+ "download-utils-gigabyte",
+];
+
+const TIME_UNITS = [
+ "download-utils-short-seconds",
+ "download-utils-short-minutes",
+ "download-utils-short-hours",
+ "download-utils-short-days",
+];
+
+// These are the maximum values for seconds, minutes, hours corresponding
+// with TIME_UNITS without the last item
+const TIME_SIZES = [60, 60, 24];
+
+var localeNumberFormatCache = new Map();
+function getLocaleNumberFormat(fractionDigits) {
+ if (!localeNumberFormatCache.has(fractionDigits)) {
+ localeNumberFormatCache.set(
+ fractionDigits,
+ new Services.intl.NumberFormat(undefined, {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })
+ );
+ }
+ return localeNumberFormatCache.get(fractionDigits);
+}
+
+const l10n = new Localization(["toolkit/downloads/downloadUtils.ftl"], true);
+
+// Keep track of at most this many second/lastSec pairs so that multiple calls
+// to getTimeLeft produce the same time left
+const kCachedLastMaxSize = 10;
+var gCachedLast = [];
+
+export var DownloadUtils = {
+ /**
+ * Generate a full status string for a download given its current progress,
+ * total size, speed, last time remaining
+ *
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @param [optional] aSpeed
+ * Current transfer rate in bytes/sec or -1 for unknown
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A pair: [download status text, new value of "last seconds"]
+ */
+ getDownloadStatus: function DU_getDownloadStatus(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ ) {
+ let [transfer, timeLeft, newLast, normalizedSpeed] =
+ this._deriveTransferRate(aCurrBytes, aMaxBytes, aSpeed, aLastSec);
+
+ let [rate, unit] = DownloadUtils.convertByteUnits(normalizedSpeed);
+
+ let status;
+ if (rate === "Infinity") {
+ // Infinity download speed doesn't make sense. Show a localized phrase instead.
+ status = l10n.formatValueSync("download-utils-status-infinite-rate", {
+ transfer,
+ timeLeft,
+ });
+ } else {
+ status = l10n.formatValueSync("download-utils-status", {
+ transfer,
+ rate,
+ unit,
+ timeLeft,
+ });
+ }
+ return [status, newLast];
+ },
+
+ /**
+ * Generate a status string for a download given its current progress,
+ * total size, speed, last time remaining. The status string contains the
+ * time remaining, as well as the total bytes downloaded. Unlike
+ * getDownloadStatus, it does not include the rate of download.
+ *
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @param [optional] aSpeed
+ * Current transfer rate in bytes/sec or -1 for unknown
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A pair: [download status text, new value of "last seconds"]
+ */
+ getDownloadStatusNoRate: function DU_getDownloadStatusNoRate(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ ) {
+ let [transfer, timeLeft, newLast] = this._deriveTransferRate(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ );
+
+ let status = l10n.formatValueSync("download-utils-status-no-rate", {
+ transfer,
+ timeLeft,
+ });
+ return [status, newLast];
+ },
+
+ /**
+ * Helper function that returns a transfer string, a time remaining string,
+ * and a new value of "last seconds".
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @param [optional] aSpeed
+ * Current transfer rate in bytes/sec or -1 for unknown
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A triple: [amount transferred string, time remaining string,
+ * new value of "last seconds"]
+ */
+ _deriveTransferRate: function DU__deriveTransferRate(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ ) {
+ if (aMaxBytes == null) {
+ aMaxBytes = -1;
+ }
+ if (aSpeed == null) {
+ aSpeed = -1;
+ }
+ if (aLastSec == null) {
+ aLastSec = Infinity;
+ }
+
+ // Calculate the time remaining if we have valid values
+ let seconds =
+ aSpeed > 0 && aMaxBytes > 0 ? (aMaxBytes - aCurrBytes) / aSpeed : -1;
+
+ let transfer = DownloadUtils.getTransferTotal(aCurrBytes, aMaxBytes);
+ let [timeLeft, newLast] = DownloadUtils.getTimeLeft(seconds, aLastSec);
+ return [transfer, timeLeft, newLast, aSpeed];
+ },
+
+ /**
+ * Generate the transfer progress string to show the current and total byte
+ * size. Byte units will be as large as possible and the same units for
+ * current and max will be suppressed for the former.
+ *
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @return The transfer progress text
+ */
+ getTransferTotal: function DU_getTransferTotal(aCurrBytes, aMaxBytes) {
+ if (aMaxBytes == null) {
+ aMaxBytes = -1;
+ }
+
+ let [progress, progressUnits] = DownloadUtils.convertByteUnits(aCurrBytes);
+ let [total, totalUnits] = DownloadUtils.convertByteUnits(aMaxBytes);
+
+ // Figure out which byte progress string to display
+ let name;
+ if (aMaxBytes < 0) {
+ name = "download-utils-transfer-no-total";
+ } else if (progressUnits == totalUnits) {
+ name = "download-utils-transfer-same-units";
+ } else {
+ name = "download-utils-transfer-diff-units";
+ }
+
+ return l10n.formatValueSync(name, {
+ progress,
+ progressUnits,
+ total,
+ totalUnits,
+ });
+ },
+
+ /**
+ * Generate a "time left" string given an estimate on the time left and the
+ * last time. The extra time is used to give a better estimate on the time to
+ * show. Both the time values are doubles instead of integers to help get
+ * sub-second accuracy for current and future estimates.
+ *
+ * @param aSeconds
+ * Current estimate on number of seconds left for the download
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A pair: [time left text, new value of "last seconds"]
+ */
+ getTimeLeft: function DU_getTimeLeft(aSeconds, aLastSec) {
+ let nf = new Services.intl.NumberFormat();
+ if (aLastSec == null) {
+ aLastSec = Infinity;
+ }
+
+ if (aSeconds < 0) {
+ return [l10n.formatValueSync("download-utils-time-unknown"), aLastSec];
+ }
+
+ // Try to find a cached lastSec for the given second
+ aLastSec = gCachedLast.reduce(
+ (aResult, aItem) => (aItem[0] == aSeconds ? aItem[1] : aResult),
+ aLastSec
+ );
+
+ // Add the current second/lastSec pair unless we have too many
+ gCachedLast.push([aSeconds, aLastSec]);
+ if (gCachedLast.length > kCachedLastMaxSize) {
+ gCachedLast.shift();
+ }
+
+ // Apply smoothing only if the new time isn't a huge change -- e.g., if the
+ // new time is more than half the previous time; this is useful for
+ // downloads that start/resume slowly
+ if (aSeconds > aLastSec / 2) {
+ // Apply hysteresis to favor downward over upward swings
+ // 30% of down and 10% of up (exponential smoothing)
+ let diff = aSeconds - aLastSec;
+ aSeconds = aLastSec + (diff < 0 ? 0.3 : 0.1) * diff;
+
+ // If the new time is similar, reuse something close to the last seconds,
+ // but subtract a little to provide forward progress
+ let diffPct = (diff / aLastSec) * 100;
+ if (Math.abs(diff) < 5 || Math.abs(diffPct) < 5) {
+ aSeconds = aLastSec - (diff < 0 ? 0.4 : 0.2);
+ }
+ }
+
+ // Decide what text to show for the time
+ let timeLeft;
+ if (aSeconds < 4) {
+ // Be friendly in the last few seconds
+ timeLeft = l10n.formatValueSync("download-utils-time-few-seconds");
+ } else {
+ // Convert the seconds into its two largest units to display
+ let [time1, unit1, time2, unit2] =
+ DownloadUtils.convertTimeUnits(aSeconds);
+
+ const pair1 = l10n.formatValueSync("download-utils-time-pair", {
+ time: nf.format(time1),
+ unit: unit1,
+ });
+
+ // Only show minutes for under 1 hour unless there's a few minutes left;
+ // or the second pair is 0.
+ if ((aSeconds < 3600 && time1 >= 4) || time2 == 0) {
+ timeLeft = l10n.formatValueSync("download-utils-time-left-single", {
+ time: pair1,
+ });
+ } else {
+ // We've got 2 pairs of times to display
+ const pair2 = l10n.formatValueSync("download-utils-time-pair", {
+ time: nf.format(time2),
+ unit: unit2,
+ });
+ timeLeft = l10n.formatValueSync("download-utils-time-left-double", {
+ time1: pair1,
+ time2: pair2,
+ });
+ }
+ }
+
+ return [timeLeft, aSeconds];
+ },
+
+ /**
+ * Converts a Date object to two readable formats, one compact, one complete.
+ * The compact format is relative to the current date, and is not an accurate
+ * representation. For example, only the time is displayed for today. The
+ * complete format always includes both the date and the time, excluding the
+ * seconds, and is often shown when hovering the cursor over the compact
+ * representation.
+ *
+ * @param aDate
+ * Date object representing the date and time to format. It is assumed
+ * that this value represents a past date.
+ * @param [optional] aNow
+ * Date object representing the current date and time. The real date
+ * and time of invocation is used if this parameter is omitted.
+ * @return A pair: [compact text, complete text]
+ */
+ getReadableDates: function DU_getReadableDates(aDate, aNow) {
+ if (!aNow) {
+ aNow = new Date();
+ }
+
+ // Figure out when today begins
+ let today = new Date(aNow.getFullYear(), aNow.getMonth(), aNow.getDate());
+
+ let dateTimeCompact;
+ let dateTimeFull;
+
+ // Figure out if the time is from today, yesterday, this week, etc.
+ if (aDate >= today) {
+ let dts = new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "short",
+ });
+ dateTimeCompact = dts.format(aDate);
+ } else if (today - aDate < MS_PER_DAY) {
+ // After yesterday started, show yesterday
+ dateTimeCompact = l10n.formatValueSync("download-utils-yesterday");
+ } else if (today - aDate < 6 * MS_PER_DAY) {
+ // After last week started, show day of week
+ dateTimeCompact = aDate.toLocaleDateString(undefined, {
+ weekday: "long",
+ });
+ } else {
+ // Show month/day
+ dateTimeCompact = aDate.toLocaleString(undefined, {
+ month: "long",
+ day: "numeric",
+ });
+ }
+
+ const dtOptions = { dateStyle: "long", timeStyle: "short" };
+ dateTimeFull = new Services.intl.DateTimeFormat(
+ undefined,
+ dtOptions
+ ).format(aDate);
+
+ return [dateTimeCompact, dateTimeFull];
+ },
+
+ /**
+ * Get the appropriate display host string for a URI string depending on if
+ * the URI has an eTLD + 1, is an IP address, a local file, or other protocol
+ *
+ * @param aURIString
+ * The URI string to try getting an eTLD + 1, etc.
+ * @return A pair: [display host for the URI string, full host name]
+ */
+ getURIHost: function DU_getURIHost(aURIString) {
+ let idnService = Cc["@mozilla.org/network/idn-service;1"].getService(
+ Ci.nsIIDNService
+ );
+
+ // Get a URI that knows about its components
+ let uri;
+ try {
+ uri = Services.io.newURI(aURIString);
+ } catch (ex) {
+ return ["", ""];
+ }
+
+ // Get the inner-most uri for schemes like jar:
+ if (uri instanceof Ci.nsINestedURI) {
+ uri = uri.innermostURI;
+ }
+
+ if (uri.scheme == "blob") {
+ let origin = new URL(uri.spec).origin;
+ // Origin can be "null" for blob URIs from a sandbox.
+ if (origin != "null") {
+ // `newURI` can throw (like for null) and throwing here breaks...
+ // a lot of stuff. So let's avoid doing that in case there are other
+ // edgecases we're missing here.
+ try {
+ uri = Services.io.newURI(origin);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ let fullHost;
+ try {
+ // Get the full host name; some special URIs fail (data: jar:)
+ fullHost = uri.host;
+ } catch (e) {
+ fullHost = "";
+ }
+
+ let displayHost;
+ try {
+ // This might fail if it's an IP address or doesn't have more than 1 part
+ let baseDomain = Services.eTLD.getBaseDomain(uri);
+
+ // Convert base domain for display; ignore the isAscii out param
+ displayHost = idnService.convertToDisplayIDN(baseDomain, {});
+ } catch (e) {
+ // Default to the host name
+ displayHost = fullHost;
+ }
+
+ // Check if we need to show something else for the host
+ if (uri.scheme == "file") {
+ // Display special text for file protocol
+ displayHost = l10n.formatValueSync("download-utils-done-file-scheme");
+ fullHost = displayHost;
+ } else if (!displayHost.length) {
+ // Got nothing; show the scheme (data: about: moz-icon:)
+ displayHost = l10n.formatValueSync("download-utils-done-scheme", {
+ scheme: uri.scheme,
+ });
+ fullHost = displayHost;
+ } else if (uri.port != -1) {
+ // Tack on the port if it's not the default port
+ let port = ":" + uri.port;
+ displayHost += port;
+ fullHost += port;
+ }
+
+ return [displayHost, fullHost];
+ },
+
+ /**
+ * Converts a number of bytes to the appropriate unit that results in an
+ * internationalized number that needs fewer than 4 digits.
+ *
+ * @param aBytes
+ * Number of bytes to convert
+ * @return A pair: [new value with 3 sig. figs., its unit]
+ */
+ convertByteUnits: function DU_convertByteUnits(aBytes) {
+ let unitIndex = 0;
+
+ // Convert to next unit if it needs 4 digits (after rounding), but only if
+ // we know the name of the next unit
+ while (aBytes >= 999.5 && unitIndex < BYTE_UNITS.length - 1) {
+ aBytes /= 1024;
+ unitIndex++;
+ }
+
+ // Get rid of insignificant bits by truncating to 1 or 0 decimal points
+ // 0 -> 0; 1.2 -> 1.2; 12.3 -> 12.3; 123.4 -> 123; 234.5 -> 235
+ // added in bug 462064: (unitIndex != 0) makes sure that no decimal digit for bytes appears when aBytes < 100
+ let fractionDigits = aBytes > 0 && aBytes < 100 && unitIndex != 0 ? 1 : 0;
+
+ // Don't try to format Infinity values using NumberFormat.
+ if (aBytes === Infinity) {
+ aBytes = "Infinity";
+ } else {
+ aBytes = getLocaleNumberFormat(fractionDigits).format(aBytes);
+ }
+
+ return [aBytes, l10n.formatValueSync(BYTE_UNITS[unitIndex])];
+ },
+
+ /**
+ * Converts a number of seconds to the two largest units. Time values are
+ * whole numbers, and units have the correct plural/singular form.
+ *
+ * @param aSecs
+ * Seconds to convert into the appropriate 2 units
+ * @return 4-item array [first value, its unit, second value, its unit]
+ */
+ convertTimeUnits: function DU_convertTimeUnits(aSecs) {
+ let time = aSecs;
+ let scale = 1;
+ let unitIndex = 0;
+
+ // Keep converting to the next unit while we have units left and the
+ // current one isn't the largest unit possible
+ while (unitIndex < TIME_SIZES.length && time >= TIME_SIZES[unitIndex]) {
+ time /= TIME_SIZES[unitIndex];
+ scale *= TIME_SIZES[unitIndex];
+ unitIndex++;
+ }
+
+ let value = convertTimeUnitsValue(time);
+ let units = convertTimeUnitsUnits(value, unitIndex);
+
+ let extra = aSecs - value * scale;
+ let nextIndex = unitIndex - 1;
+
+ // Convert the extra time to the next largest unit
+ for (let index = 0; index < nextIndex; index++) {
+ extra /= TIME_SIZES[index];
+ }
+
+ let value2 = convertTimeUnitsValue(extra);
+ let units2 = convertTimeUnitsUnits(value2, nextIndex);
+
+ return [value, units, value2, units2];
+ },
+
+ /**
+ * Converts a number of seconds to "downloading file opens in X" status.
+ * @param aSeconds
+ * Seconds to convert into the time format.
+ * @return status object, example:
+ * status = {
+ * l10n: {
+ * id: "downloading-file-opens-in-minutes-and-seconds",
+ * args: { minutes: 2, seconds: 30 },
+ * },
+ * };
+ */
+ getFormattedTimeStatus: function DU_getFormattedTimeStatus(aSeconds) {
+ aSeconds = Math.floor(aSeconds);
+ let l10n;
+ if (!isFinite(aSeconds) || aSeconds < 0) {
+ l10n = {
+ id: "downloading-file-opens-in-some-time-2",
+ };
+ } else if (aSeconds < 60) {
+ l10n = {
+ id: "downloading-file-opens-in-seconds-2",
+ args: { seconds: aSeconds },
+ };
+ } else if (aSeconds < 3600) {
+ let minutes = Math.floor(aSeconds / 60);
+ let seconds = aSeconds % 60;
+ l10n = seconds
+ ? {
+ args: { seconds, minutes },
+ id: "downloading-file-opens-in-minutes-and-seconds-2",
+ }
+ : { args: { minutes }, id: "downloading-file-opens-in-minutes-2" };
+ } else {
+ let hours = Math.floor(aSeconds / 3600);
+ let minutes = Math.floor((aSeconds % 3600) / 60);
+ l10n = {
+ args: { hours, minutes },
+ id: "downloading-file-opens-in-hours-and-minutes-2",
+ };
+ }
+ return { l10n };
+ },
+};
+
+/**
+ * Private helper for convertTimeUnits that gets the display value of a time
+ *
+ * @param aTime
+ * Time value for display
+ * @return An integer value for the time rounded down
+ */
+function convertTimeUnitsValue(aTime) {
+ return Math.floor(aTime);
+}
+
+/**
+ * Private helper for convertTimeUnits that gets the display units of a time
+ *
+ * @param timeValue
+ * Time value for display
+ * @param aIndex
+ * Index into gStr.timeUnits for the appropriate unit
+ * @return The appropriate plural form of the unit for the time
+ */
+function convertTimeUnitsUnits(timeValue, aIndex) {
+ // Negative index would be an invalid unit, so just give empty
+ if (aIndex < 0) {
+ return "";
+ }
+
+ return l10n.formatValueSync(TIME_UNITS[aIndex], { timeValue });
+}
+
+/**
+ * Private helper function to log errors to the error console and command line
+ *
+ * @param aMsg
+ * Error message to log or an array of strings to concat
+ */
+// function log(aMsg) {
+// let msg = "DownloadUtils.jsm: " + (aMsg.join ? aMsg.join("") : aMsg);
+// Services.console.logStringMessage(msg);
+// dump(msg + "\n");
+// }
diff --git a/toolkit/mozapps/downloads/HelperAppDlg.sys.mjs b/toolkit/mozapps/downloads/HelperAppDlg.sys.mjs
new file mode 100644
index 0000000000..66f77d38e4
--- /dev/null
+++ b/toolkit/mozapps/downloads/HelperAppDlg.sys.mjs
@@ -0,0 +1,1349 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { BrowserUtils } from "resource://gre/modules/BrowserUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ EnableDelayHelper: "resource://gre/modules/PromptUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gReputationService",
+ "@mozilla.org/reputationservice/application-reputation-service;1",
+ Ci.nsIApplicationReputationService
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ Ci.nsIMIMEService
+);
+
+import { Integration } from "resource://gre/modules/Integration.sys.mjs";
+
+Integration.downloads.defineESModuleGetter(
+ lazy,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+// /////////////////////////////////////////////////////////////////////////////
+// // Helper Functions
+
+/**
+ * Determines if a given directory is able to be used to download to.
+ *
+ * @param aDirectory
+ * The directory to check.
+ * @return true if we can use the directory, false otherwise.
+ */
+function isUsableDirectory(aDirectory) {
+ return (
+ aDirectory.exists() && aDirectory.isDirectory() && aDirectory.isWritable()
+ );
+}
+
+// Web progress listener so we can detect errors while mLauncher is
+// streaming the data to a temporary file.
+function nsUnknownContentTypeDialogProgressListener(aHelperAppDialog) {
+ this.helperAppDlg = aHelperAppDialog;
+}
+
+nsUnknownContentTypeDialogProgressListener.prototype = {
+ // nsIWebProgressListener methods.
+ // Look for error notifications and display alert to user.
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (aStatus != Cr.NS_OK) {
+ // Display error alert (using text supplied by back-end).
+ // FIXME this.dialog is undefined?
+ Services.prompt.alert(this.dialog, this.helperAppDlg.mTitle, aMessage);
+ // Close the dialog.
+ this.helperAppDlg.onCancel();
+ if (this.helperAppDlg.mDialog) {
+ this.helperAppDlg.mDialog.close();
+ }
+ }
+ },
+
+ // Ignore onProgressChange, onProgressChange64, onStateChange, onLocationChange, onSecurityChange, onContentBlockingEvent and onRefreshAttempted notifications.
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {},
+
+ onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {},
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {},
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {},
+
+ onSecurityChange(aWebProgress, aRequest, aState) {},
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {},
+
+ onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
+ return true;
+ },
+};
+
+// /////////////////////////////////////////////////////////////////////////////
+// // nsUnknownContentTypeDialog
+
+/* This file implements the nsIHelperAppLauncherDialog interface.
+ *
+ * The implementation consists of a JavaScript "class" named nsUnknownContentTypeDialog,
+ * comprised of:
+ * - a JS constructor function
+ * - a prototype providing all the interface methods and implementation stuff
+ */
+
+const PREF_BD_USEDOWNLOADDIR = "browser.download.useDownloadDir";
+const nsITimer = Ci.nsITimer;
+
+import * as downloadModule from "resource://gre/modules/DownloadLastDir.sys.mjs";
+import { DownloadPaths } from "resource://gre/modules/DownloadPaths.sys.mjs";
+
+import { DownloadUtils } from "resource://gre/modules/DownloadUtils.sys.mjs";
+import { Downloads } from "resource://gre/modules/Downloads.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+
+/* ctor
+ */
+export function nsUnknownContentTypeDialog() {
+ // Initialize data properties.
+ this.mLauncher = null;
+ this.mContext = null;
+ this.mReason = null;
+ this.chosenApp = null;
+ this.givenDefaultApp = false;
+ this.updateSelf = true;
+ this.mTitle = "";
+}
+
+nsUnknownContentTypeDialog.prototype = {
+ classID: Components.ID("{F68578EB-6EC2-4169-AE19-8C6243F0ABE1}"),
+
+ nsIMIMEInfo: Ci.nsIMIMEInfo,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIHelperAppLauncherDialog",
+ "nsITimerCallback",
+ ]),
+
+ // ---------- nsIHelperAppLauncherDialog methods ----------
+
+ // show: Open XUL dialog using window watcher. Since the dialog is not
+ // modal, it needs to be a top level window and the way to open
+ // one of those is via that route).
+ show(aLauncher, aContext, aReason) {
+ this.mLauncher = aLauncher;
+ this.mContext = aContext;
+ this.mReason = aReason;
+
+ // Cache some information in case this context goes away:
+ try {
+ let parent = aContext.getInterface(Ci.nsIDOMWindow);
+ this._mDownloadDir = new downloadModule.DownloadLastDir(parent);
+ } catch (ex) {
+ console.error(
+ "Missing window information when showing nsIHelperAppLauncherDialog:",
+ ex
+ );
+ }
+
+ const nsITimer = Ci.nsITimer;
+ this._showTimer = Cc["@mozilla.org/timer;1"].createInstance(nsITimer);
+ this._showTimer.initWithCallback(this, 0, nsITimer.TYPE_ONE_SHOT);
+ },
+
+ // When opening from new tab, if tab closes while dialog is opening,
+ // (which is a race condition on the XUL file being cached and the timer
+ // in nsExternalHelperAppService), the dialog gets a blur and doesn't
+ // activate the OK button. So we wait a bit before doing opening it.
+ reallyShow() {
+ try {
+ let docShell = this.mContext.getInterface(Ci.nsIDocShell);
+ let rootWin = docShell.browsingContext.topChromeWindow;
+ this.mDialog = Services.ww.openWindow(
+ rootWin,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ null,
+ "chrome,centerscreen,titlebar,dialog=yes,dependent",
+ null
+ );
+ } catch (ex) {
+ // The containing window may have gone away. Break reference
+ // cycles and stop doing the download.
+ this.mLauncher.cancel(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+
+ // Hook this object to the dialog.
+ this.mDialog.dialog = this;
+
+ // Hook up utility functions.
+ this.getSpecialFolderKey = this.mDialog.getSpecialFolderKey;
+
+ // Watch for error notifications.
+ var progressListener = new nsUnknownContentTypeDialogProgressListener(this);
+ this.mLauncher.setWebProgressListener(progressListener);
+ },
+
+ //
+ // displayBadPermissionAlert()
+ //
+ // Diplay an alert panel about the bad permission of folder/directory.
+ //
+ displayBadPermissionAlert() {
+ let bundle = Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/unknownContentType.properties"
+ );
+
+ Services.prompt.alert(
+ this.dialog,
+ bundle.GetStringFromName("badPermissions.title"),
+ bundle.GetStringFromName("badPermissions")
+ );
+ },
+
+ promptForSaveToFileAsync(
+ aLauncher,
+ aContext,
+ aDefaultFileName,
+ aSuggestedFileExtension,
+ aForcePrompt
+ ) {
+ var result = null;
+
+ this.mLauncher = aLauncher;
+
+ let bundle = Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/unknownContentType.properties"
+ );
+
+ let parent;
+ let gDownloadLastDir;
+ try {
+ parent = aContext.getInterface(Ci.nsIDOMWindow);
+ } catch (ex) {}
+
+ if (parent) {
+ gDownloadLastDir = new downloadModule.DownloadLastDir(parent);
+ } else {
+ // Use the cached download info, but pick an arbitrary parent window
+ // because the original one is definitely gone (and nsIFilePicker doesn't like
+ // a null parent):
+ gDownloadLastDir = this._mDownloadDir;
+ for (let someWin of Services.wm.getEnumerator("")) {
+ // We need to make sure we don't end up with this dialog, because otherwise
+ // that's going to go away when the user clicks "Save", and that breaks the
+ // windows file picker that's supposed to show up if we let the user choose
+ // where to save files...
+ if (someWin != this.mDialog) {
+ parent = someWin;
+ }
+ }
+ if (!parent) {
+ console.error(
+ "No candidate parent windows were found for the save filepicker." +
+ "This should never happen."
+ );
+ }
+ }
+
+ (async () => {
+ if (!aForcePrompt) {
+ // Check to see if the user wishes to auto save to the default download
+ // folder without prompting. Note that preference might not be set.
+ let autodownload = Services.prefs.getBoolPref(
+ PREF_BD_USEDOWNLOADDIR,
+ false
+ );
+
+ if (autodownload) {
+ // Retrieve the user's default download directory
+ let preferredDir = await Downloads.getPreferredDownloadsDirectory();
+ let defaultFolder = new FileUtils.File(preferredDir);
+
+ try {
+ if (aDefaultFileName) {
+ result = this.validateLeafName(
+ defaultFolder,
+ aDefaultFileName,
+ aSuggestedFileExtension
+ );
+ }
+ } catch (ex) {
+ // When the default download directory is write-protected,
+ // prompt the user for a different target file.
+ }
+
+ // Check to make sure we have a valid directory, otherwise, prompt
+ if (result) {
+ // This path is taken when we have a writable default download directory.
+ aLauncher.saveDestinationAvailable(result);
+ return;
+ }
+ }
+ }
+
+ // Use file picker to show dialog.
+ var nsIFilePicker = Ci.nsIFilePicker;
+ var picker =
+ Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ var windowTitle = bundle.GetStringFromName("saveDialogTitle");
+ picker.init(parent, windowTitle, nsIFilePicker.modeSave);
+ if (aDefaultFileName) {
+ picker.defaultString = this.getFinalLeafName(aDefaultFileName);
+ }
+
+ if (aSuggestedFileExtension) {
+ // aSuggestedFileExtension includes the period, so strip it
+ picker.defaultExtension = aSuggestedFileExtension.substring(1);
+ } else {
+ try {
+ picker.defaultExtension = this.mLauncher.MIMEInfo.primaryExtension;
+ } catch (ex) {}
+ }
+
+ var wildCardExtension = "*";
+ if (aSuggestedFileExtension) {
+ wildCardExtension += aSuggestedFileExtension;
+ picker.appendFilter(
+ this.mLauncher.MIMEInfo.description,
+ wildCardExtension
+ );
+ }
+
+ picker.appendFilters(nsIFilePicker.filterAll);
+
+ // Default to lastDir if it is valid, otherwise use the user's default
+ // downloads directory. getPreferredDownloadsDirectory should always
+ // return a valid directory path, so we can safely default to it.
+ let preferredDir = await Downloads.getPreferredDownloadsDirectory();
+ picker.displayDirectory = new FileUtils.File(preferredDir);
+
+ gDownloadLastDir.getFileAsync(aLauncher.source).then(lastDir => {
+ if (lastDir && isUsableDirectory(lastDir)) {
+ picker.displayDirectory = lastDir;
+ }
+
+ picker.open(returnValue => {
+ if (returnValue == nsIFilePicker.returnCancel) {
+ // null result means user cancelled.
+ aLauncher.saveDestinationAvailable(null);
+ return;
+ }
+
+ // Be sure to save the directory the user chose through the Save As...
+ // dialog as the new browser.download.dir since the old one
+ // didn't exist.
+ result = picker.file;
+
+ if (result) {
+ let allowOverwrite = false;
+ try {
+ // If we're overwriting, avoid renaming our file, and assume
+ // overwriting it does the right thing.
+ if (
+ result.exists() &&
+ this.getFinalLeafName(result.leafName, "", true) ==
+ result.leafName
+ ) {
+ allowOverwrite = true;
+ }
+ } catch (ex) {
+ // As it turns out, the failure to remove the file, for example due to
+ // permission error, will be handled below eventually somehow.
+ }
+
+ var newDir = result.parent.QueryInterface(Ci.nsIFile);
+
+ // Do not store the last save directory as a pref inside the private browsing mode
+ gDownloadLastDir.setFile(aLauncher.source, newDir);
+
+ try {
+ result = this.validateLeafName(
+ newDir,
+ result.leafName,
+ null,
+ allowOverwrite,
+ true
+ );
+ } catch (ex) {
+ // When the chosen download directory is write-protected,
+ // display an informative error message.
+ // In all cases, download will be stopped.
+
+ if (ex.result == Cr.NS_ERROR_FILE_ACCESS_DENIED) {
+ this.displayBadPermissionAlert();
+ aLauncher.saveDestinationAvailable(null);
+ return;
+ }
+ }
+ }
+ // Don't pop up the downloads panel redundantly.
+ aLauncher.saveDestinationAvailable(result, true);
+ });
+ });
+ })().catch(console.error);
+ },
+
+ getFinalLeafName(aLeafName, aFileExt, aAfterFilePicker) {
+ return (
+ DownloadPaths.sanitize(aLeafName, {
+ compressWhitespaces: !aAfterFilePicker,
+ allowInvalidFilenames: aAfterFilePicker,
+ }) || "unnamed" + (aFileExt ? "." + aFileExt : "")
+ );
+ },
+
+ /**
+ * Ensures that a local folder/file combination does not already exist in
+ * the file system (or finds such a combination with a reasonably similar
+ * leaf name), creates the corresponding file, and returns it.
+ *
+ * @param aLocalFolder
+ * the folder where the file resides
+ * @param aLeafName
+ * the string name of the file (may be empty if no name is known,
+ * in which case a name will be chosen)
+ * @param aFileExt
+ * the extension of the file, if one is known; this will be ignored
+ * if aLeafName is non-empty
+ * @param aAllowExisting
+ * if set to true, avoid creating a unique file.
+ * @param aAfterFilePicker
+ * if set to true, this was a file entered by the user from a file picker.
+ * @return nsIFile
+ * the created file
+ * @throw an error such as permission doesn't allow creation of
+ * file, etc.
+ */
+ validateLeafName(
+ aLocalFolder,
+ aLeafName,
+ aFileExt,
+ aAllowExisting = false,
+ aAfterFilePicker = false
+ ) {
+ if (!(aLocalFolder && isUsableDirectory(aLocalFolder))) {
+ throw new Components.Exception(
+ "Destination directory non-existing or permission error",
+ Cr.NS_ERROR_FILE_ACCESS_DENIED
+ );
+ }
+
+ aLeafName = this.getFinalLeafName(aLeafName, aFileExt, aAfterFilePicker);
+ aLocalFolder.append(aLeafName);
+
+ if (!aAllowExisting) {
+ // The following assignment can throw an exception, but
+ // is now caught properly in the caller of validateLeafName.
+ var validatedFile = DownloadPaths.createNiceUniqueFile(aLocalFolder);
+ } else {
+ validatedFile = aLocalFolder;
+ }
+
+ return validatedFile;
+ },
+
+ // ---------- implementation methods ----------
+
+ // initDialog: Fill various dialog fields with initial content.
+ initDialog() {
+ // Put file name in window title.
+ var suggestedFileName = this.mLauncher.suggestedFileName;
+
+ this.mDialog.document.addEventListener("dialogaccept", this);
+ this.mDialog.document.addEventListener("dialogcancel", this);
+
+ let url = this.mLauncher.source;
+
+ if (url instanceof Ci.nsINestedURI) {
+ url = url.innermostURI;
+ }
+
+ let iconPath = "goat";
+ let fname = "";
+ if (suggestedFileName) {
+ fname = iconPath = suggestedFileName;
+ } else if (url instanceof Ci.nsIURL) {
+ // A url, use file name from it.
+ fname = iconPath = url.fileName;
+ } else if (["data", "blob"].includes(url.scheme)) {
+ // The path is useless for these, so use a reasonable default.
+ let { MIMEType } = this.mLauncher.MIMEInfo;
+ fname = lazy.gMIMEService.getValidFileName(null, MIMEType, url, 0);
+ } else {
+ fname = url.pathQueryRef;
+ }
+
+ this.mSourcePath = url.prePath;
+ // Some URIs do not implement nsIURL, so we can't just QI.
+ if (url instanceof Ci.nsIURL) {
+ this.mSourcePath += url.directory;
+ } else {
+ // Don't make the url excessively long (e.g. for data URIs)
+ // (this doesn't use a temp var to avoid copying a potentially
+ // several mb-long string)
+ this.mSourcePath +=
+ url.pathQueryRef.length > 500
+ ? url.pathQueryRef.substring(0, 500) + "\u2026"
+ : url.pathQueryRef;
+ }
+
+ var displayName = fname.replace(/ +/g, " ");
+
+ this.mTitle = this.dialogElement("strings").getFormattedString("title", [
+ displayName,
+ ]);
+ this.mDialog.document.title = this.mTitle;
+
+ // Put content type, filename and location into intro.
+ this.initIntro(url, displayName);
+
+ var iconString =
+ "moz-icon://" +
+ iconPath +
+ "?size=16&contentType=" +
+ this.mLauncher.MIMEInfo.MIMEType;
+ this.dialogElement("contentTypeImage").setAttribute("src", iconString);
+
+ let dialog = this.mDialog.document.getElementById("unknownContentType");
+
+ // if always-save and is-executable and no-handler
+ // then set up simple ui
+ var mimeType = this.mLauncher.MIMEInfo.MIMEType;
+ let isPlain = mimeType == "text/plain";
+
+ this.isExemptExecutableExtension =
+ Services.policies.isExemptExecutableExtension(
+ url.spec,
+ fname?.split(".").at(-1)
+ );
+
+ var shouldntRememberChoice =
+ mimeType == "application/octet-stream" ||
+ mimeType == "application/x-msdownload" ||
+ (this.mLauncher.targetFileIsExecutable &&
+ !this.isExemptExecutableExtension) ||
+ // Do not offer to remember text/plain mimetype choices if the file
+ // isn't actually a 'plain' text file.
+ (isPlain && lazy.gReputationService.isBinary(suggestedFileName));
+ if (
+ (shouldntRememberChoice && !this.openWithDefaultOK()) ||
+ Services.prefs.getBoolPref("browser.download.forbid_open_with")
+ ) {
+ // hide featured choice
+ this.dialogElement("normalBox").collapsed = true;
+ // show basic choice
+ this.dialogElement("basicBox").collapsed = false;
+ // change button labels and icons; use "save" icon for the accept
+ // button since it's the only action possible
+ let acceptButton = dialog.getButton("accept");
+ acceptButton.label = this.dialogElement("strings").getString(
+ "unknownAccept.label"
+ );
+ acceptButton.setAttribute("icon", "save");
+ dialog.getButton("cancel").label = this.dialogElement(
+ "strings"
+ ).getString("unknownCancel.label");
+ // hide other handler
+ this.dialogElement("openHandler").collapsed = true;
+ // set save as the selected option
+ this.dialogElement("mode").selectedItem = this.dialogElement("save");
+ } else {
+ this.initInteractiveControls();
+
+ // Initialize "always ask me" box. This should always be disabled
+ // and set to true for the ambiguous type application/octet-stream.
+ // We don't also check for application/x-msdownload here since we
+ // want users to be able to autodownload .exe files.
+ var rememberChoice = this.dialogElement("rememberChoice");
+
+ // Just because we have a content-type of application/octet-stream
+ // here doesn't actually mean that the content is of that type. Many
+ // servers default to sending text/plain for file types they don't know
+ // about. To account for this, the uriloader does some checking to see
+ // if a file sent as text/plain contains binary characters, and if so (*)
+ // it morphs the content-type into application/octet-stream so that
+ // the file can be properly handled. Since this is not generic binary
+ // data, rather, a data format that the system probably knows about,
+ // we don't want to use the content-type provided by this dialog's
+ // opener, as that's the generic application/octet-stream that the
+ // uriloader has passed, rather we want to ask the MIME Service.
+ // This is so we don't needlessly disable the "autohandle" checkbox.
+
+ if (shouldntRememberChoice) {
+ rememberChoice.checked = false;
+ rememberChoice.hidden = true;
+ } else {
+ rememberChoice.checked =
+ !this.mLauncher.MIMEInfo.alwaysAskBeforeHandling &&
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.handleInternally;
+ }
+ this.toggleRememberChoice(rememberChoice);
+ }
+
+ this.mDialog.setTimeout(function () {
+ this.dialog.postShowCallback();
+ }, 0);
+
+ this.delayHelper = new lazy.EnableDelayHelper({
+ disableDialog: () => {
+ dialog.getButton("accept").disabled = true;
+ },
+ enableDialog: () => {
+ dialog.getButton("accept").disabled = false;
+ },
+ focusTarget: this.mDialog,
+ });
+ },
+
+ notify(aTimer) {
+ if (aTimer == this._showTimer) {
+ if (!this.mDialog) {
+ this.reallyShow();
+ }
+ // The timer won't release us, so we have to release it.
+ this._showTimer = null;
+ } else if (aTimer == this._saveToDiskTimer) {
+ // Since saveToDisk may open a file picker and therefore block this routine,
+ // we should only call it once the dialog is closed.
+ this.mLauncher.promptForSaveDestination();
+ this._saveToDiskTimer = null;
+ }
+ },
+
+ postShowCallback() {
+ this.mDialog.sizeToContent();
+
+ // Set initial focus
+ this.dialogElement("mode").focus();
+ },
+
+ initIntro(url, displayName) {
+ this.dialogElement("location").value = displayName;
+ this.dialogElement("location").setAttribute("tooltiptext", displayName);
+
+ // if mSourcePath is a local file, then let's use the pretty path name
+ // instead of an ugly url...
+ let pathString;
+ if (url instanceof Ci.nsIFileURL) {
+ try {
+ // Getting .file might throw, or .parent could be null
+ pathString = url.file.parent.path;
+ } catch (ex) {}
+ }
+
+ if (!pathString) {
+ pathString = BrowserUtils.formatURIForDisplay(url, {
+ showInsecureHTTP: true,
+ });
+ }
+
+ // Set the location text, which is separate from the intro text so it can be cropped
+ var location = this.dialogElement("source");
+ location.value = pathString;
+ location.setAttribute("tooltiptext", this.mSourcePath);
+
+ // Show the type of file.
+ var type = this.dialogElement("type");
+ var mimeInfo = this.mLauncher.MIMEInfo;
+
+ // 1. Try to use the pretty description of the type, if one is available.
+ var typeString = mimeInfo.description;
+
+ if (typeString == "") {
+ // 2. If there is none, use the extension to identify the file, e.g. "ZIP file"
+ var primaryExtension = "";
+ try {
+ primaryExtension = mimeInfo.primaryExtension;
+ } catch (ex) {}
+ if (primaryExtension != "") {
+ typeString = this.dialogElement("strings").getFormattedString(
+ "fileType",
+ [primaryExtension.toUpperCase()]
+ );
+ }
+ // 3. If we can't even do that, just give up and show the MIME type.
+ else {
+ typeString = mimeInfo.MIMEType;
+ }
+ }
+ // When the length is unknown, contentLength would be -1
+ if (this.mLauncher.contentLength >= 0) {
+ let [size, unit] = DownloadUtils.convertByteUnits(
+ this.mLauncher.contentLength
+ );
+ type.value = this.dialogElement("strings").getFormattedString(
+ "orderedFileSizeWithType",
+ [typeString, size, unit]
+ );
+ } else {
+ type.value = typeString;
+ }
+ },
+
+ // Returns true if opening the default application makes sense.
+ openWithDefaultOK() {
+ // The checking is different on Windows...
+ if (AppConstants.platform == "win") {
+ // Windows presents some special cases.
+ // We need to prevent use of "system default" when the file is
+ // executable (so the user doesn't launch nasty programs downloaded
+ // from the web), and, enable use of "system default" if it isn't
+ // executable (because we will prompt the user for the default app
+ // in that case).
+
+ // Default is Ok if the file isn't executable (and vice-versa).
+ return (
+ !this.mLauncher.targetFileIsExecutable ||
+ this.isExemptExecutableExtension
+ );
+ }
+ // On other platforms, default is Ok if there is a default app.
+ // Note that nsIMIMEInfo providers need to ensure that this holds true
+ // on each platform.
+ return this.mLauncher.MIMEInfo.hasDefaultHandler;
+ },
+
+ // Set "default" application description field.
+ initDefaultApp() {
+ // Use description, if we can get one.
+ var desc = this.mLauncher.MIMEInfo.defaultDescription;
+ if (desc) {
+ var defaultApp = this.dialogElement("strings").getFormattedString(
+ "defaultApp",
+ [desc]
+ );
+ this.dialogElement("defaultHandler").label = defaultApp;
+ } else {
+ this.dialogElement("modeDeck").setAttribute("selectedIndex", "1");
+ // Hide the default handler item too, in case the user picks a
+ // custom handler at a later date which triggers the menulist to show.
+ this.dialogElement("defaultHandler").hidden = true;
+ }
+ },
+
+ getPath(aFile) {
+ if (AppConstants.platform == "macosx") {
+ return aFile.leafName || aFile.path;
+ }
+ return aFile.path;
+ },
+
+ initInteractiveControls() {
+ var modeGroup = this.dialogElement("mode");
+
+ // We don't let users open .exe files or random binary data directly
+ // from the browser at the moment because of security concerns.
+ var openWithDefaultOK = this.openWithDefaultOK();
+ var mimeType = this.mLauncher.MIMEInfo.MIMEType;
+ var openHandler = this.dialogElement("openHandler");
+ if (
+ (this.mLauncher.targetFileIsExecutable &&
+ !this.isExemptExecutableExtension) ||
+ ((mimeType == "application/octet-stream" ||
+ mimeType == "application/x-msdos-program" ||
+ mimeType == "application/x-msdownload") &&
+ !openWithDefaultOK)
+ ) {
+ this.dialogElement("open").disabled = true;
+ openHandler.disabled = true;
+ openHandler.selectedItem = null;
+ modeGroup.selectedItem = this.dialogElement("save");
+ return;
+ }
+
+ // Fill in helper app info, if there is any.
+ try {
+ this.chosenApp =
+ this.mLauncher.MIMEInfo.preferredApplicationHandler.QueryInterface(
+ Ci.nsILocalHandlerApp
+ );
+ } catch (e) {
+ this.chosenApp = null;
+ }
+ // Initialize "default application" field.
+ this.initDefaultApp();
+
+ var otherHandler = this.dialogElement("otherHandler");
+
+ // Fill application name textbox.
+ if (
+ this.chosenApp &&
+ this.chosenApp.executable &&
+ this.chosenApp.executable.path
+ ) {
+ otherHandler.setAttribute(
+ "path",
+ this.getPath(this.chosenApp.executable)
+ );
+
+ otherHandler.label = this.getFileDisplayName(this.chosenApp.executable);
+ otherHandler.hidden = false;
+ }
+
+ openHandler.selectedIndex = 0;
+ var defaultOpenHandler = this.dialogElement("defaultHandler");
+
+ if (this.shouldShowInternalHandlerOption()) {
+ this.dialogElement("handleInternally").hidden = false;
+ }
+
+ if (
+ this.mLauncher.MIMEInfo.preferredAction ==
+ this.nsIMIMEInfo.useSystemDefault
+ ) {
+ // Open (using system default).
+ modeGroup.selectedItem = this.dialogElement("open");
+ } else if (
+ this.mLauncher.MIMEInfo.preferredAction == this.nsIMIMEInfo.useHelperApp
+ ) {
+ // Open with given helper app.
+ modeGroup.selectedItem = this.dialogElement("open");
+ openHandler.selectedItem =
+ otherHandler && !otherHandler.hidden
+ ? otherHandler
+ : defaultOpenHandler;
+ } else if (
+ !this.dialogElement("handleInternally").hidden &&
+ this.mLauncher.MIMEInfo.preferredAction ==
+ this.nsIMIMEInfo.handleInternally
+ ) {
+ // Handle internally
+ modeGroup.selectedItem = this.dialogElement("handleInternally");
+ } else {
+ // Save to disk.
+ modeGroup.selectedItem = this.dialogElement("save");
+ }
+
+ // If we don't have a "default app" then disable that choice.
+ if (!openWithDefaultOK) {
+ var isSelected = defaultOpenHandler.selected;
+
+ // Disable that choice.
+ defaultOpenHandler.hidden = true;
+ // If that's the default, then switch to "save to disk."
+ if (isSelected) {
+ openHandler.selectedIndex = 1;
+ if (this.dialogElement("open").selected) {
+ modeGroup.selectedItem = this.dialogElement("save");
+ }
+ }
+ }
+
+ otherHandler.nextSibling.hidden =
+ otherHandler.nextSibling.nextSibling.hidden = false;
+ this.updateOKButton();
+ },
+
+ // Returns the user-selected application
+ helperAppChoice() {
+ return this.chosenApp;
+ },
+
+ get saveToDisk() {
+ return this.dialogElement("save").selected;
+ },
+
+ get useOtherHandler() {
+ return (
+ this.dialogElement("open").selected &&
+ this.dialogElement("openHandler").selectedIndex == 1
+ );
+ },
+
+ get useSystemDefault() {
+ return (
+ this.dialogElement("open").selected &&
+ this.dialogElement("openHandler").selectedIndex == 0
+ );
+ },
+
+ get handleInternally() {
+ return this.dialogElement("handleInternally").selected;
+ },
+
+ toggleRememberChoice(aCheckbox) {
+ this.dialogElement("settingsChange").hidden = !aCheckbox.checked;
+ this.mDialog.sizeToContent();
+ },
+
+ openHandlerCommand() {
+ var openHandler = this.dialogElement("openHandler");
+ if (openHandler.selectedItem.id == "choose") {
+ this.chooseApp();
+ } else {
+ openHandler.setAttribute(
+ "lastSelectedItemID",
+ openHandler.selectedItem.id
+ );
+ }
+ },
+
+ updateOKButton() {
+ var ok = false;
+ if (this.dialogElement("save").selected) {
+ // This is always OK.
+ ok = true;
+ } else if (this.dialogElement("open").selected) {
+ switch (this.dialogElement("openHandler").selectedIndex) {
+ case 0:
+ // No app need be specified in this case.
+ ok = true;
+ break;
+ case 1:
+ // only enable the OK button if we have a default app to use or if
+ // the user chose an app....
+ ok =
+ this.chosenApp ||
+ /\S/.test(this.dialogElement("otherHandler").getAttribute("path"));
+ break;
+ }
+ }
+
+ // Enable Ok button if ok to press.
+ let dialog = this.mDialog.document.getElementById("unknownContentType");
+ dialog.getButton("accept").disabled = !ok;
+ },
+
+ // Returns true iff the user-specified helper app has been modified.
+ appChanged() {
+ return (
+ this.helperAppChoice() !=
+ this.mLauncher.MIMEInfo.preferredApplicationHandler
+ );
+ },
+
+ updateMIMEInfo() {
+ let { MIMEInfo } = this.mLauncher;
+
+ // Don't erase the preferred choice being internal handler
+ // -- this dialog is often the result of the handler fallback
+ // (e.g. Content-Disposition was set as attachment) and we don't
+ // want to inadvertently cause that to always show the dialog if
+ // users don't want that behaviour.
+
+ // Note: this is the same condition as the one in initDialog
+ // which avoids ticking the checkbox. The user can still change
+ // the action by ticking the checkbox, or by using the prefs to
+ // manually select always ask (at which point `areAlwaysOpeningInternally`
+ // will be false, which means `discardUpdate` will be false, which means
+ // we'll store the last-selected option even if the filetype's pref is
+ // set to always ask).
+ let areAlwaysOpeningInternally =
+ MIMEInfo.preferredAction == Ci.nsIMIMEInfo.handleInternally &&
+ !MIMEInfo.alwaysAskBeforeHandling;
+ let discardUpdate =
+ areAlwaysOpeningInternally &&
+ !this.dialogElement("rememberChoice").checked;
+
+ var needUpdate = false;
+ // If current selection differs from what's in the mime info object,
+ // then we need to update.
+ if (this.saveToDisk) {
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction != this.nsIMIMEInfo.saveToDisk;
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction = this.nsIMIMEInfo.saveToDisk;
+ }
+ } else if (this.useSystemDefault) {
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.useSystemDefault;
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction =
+ this.nsIMIMEInfo.useSystemDefault;
+ }
+ } else if (this.useOtherHandler) {
+ // For "open with", we need to check both preferred action and whether the user chose
+ // a new app.
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.useHelperApp || this.appChanged();
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction = this.nsIMIMEInfo.useHelperApp;
+ // App may have changed - Update application
+ var app = this.helperAppChoice();
+ this.mLauncher.MIMEInfo.preferredApplicationHandler = app;
+ }
+ } else if (this.handleInternally) {
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.handleInternally;
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction =
+ this.nsIMIMEInfo.handleInternally;
+ }
+ }
+ // We will also need to update if the "always ask" flag has changed.
+ needUpdate =
+ needUpdate ||
+ this.mLauncher.MIMEInfo.alwaysAskBeforeHandling !=
+ !this.dialogElement("rememberChoice").checked;
+
+ // One last special case: If the input "always ask" flag was false, then we always
+ // update. In that case we are displaying the helper app dialog for the first
+ // time for this mime type and we need to store the user's action in the handler service
+ // (whether that action has changed or not; if it didn't change, then we need
+ // to store the "always ask" flag so the helper app dialog will or won't display
+ // next time, per the user's selection).
+ needUpdate = needUpdate || !this.mLauncher.MIMEInfo.alwaysAskBeforeHandling;
+
+ // Make sure mime info has updated setting for the "always ask" flag.
+ this.mLauncher.MIMEInfo.alwaysAskBeforeHandling =
+ !this.dialogElement("rememberChoice").checked;
+
+ return needUpdate && !discardUpdate;
+ },
+
+ // See if the user changed things, and if so, store this mime type in the
+ // handler service.
+ updateHelperAppPref() {
+ var handlerInfo = this.mLauncher.MIMEInfo;
+ var hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ hs.store(handlerInfo);
+ },
+
+ onOK(aEvent) {
+ // Verify typed app path, if necessary.
+ if (this.useOtherHandler) {
+ var helperApp = this.helperAppChoice();
+ if (
+ !helperApp ||
+ !helperApp.executable ||
+ !helperApp.executable.exists()
+ ) {
+ // Show alert and try again.
+ var bundle = this.dialogElement("strings");
+ var msg = bundle.getFormattedString("badApp", [
+ this.dialogElement("otherHandler").getAttribute("path"),
+ ]);
+ Services.prompt.alert(
+ this.mDialog,
+ bundle.getString("badApp.title"),
+ msg
+ );
+
+ // Disable the OK button.
+ let dialog = this.mDialog.document.getElementById("unknownContentType");
+ dialog.getButton("accept").disabled = true;
+ this.dialogElement("mode").focus();
+
+ // Clear chosen application.
+ this.chosenApp = null;
+
+ // Leave dialog up.
+ aEvent.preventDefault();
+ }
+ }
+
+ // Remove our web progress listener (a progress dialog will be
+ // taking over).
+ this.mLauncher.setWebProgressListener(null);
+
+ // saveToDisk and setDownloadToLaunch can return errors in
+ // certain circumstances (e.g. The user clicks cancel in the
+ // "Save to Disk" dialog. In those cases, we don't want to
+ // update the helper application preferences in the RDF file.
+ try {
+ var needUpdate = this.updateMIMEInfo();
+
+ if (this.dialogElement("save").selected) {
+ // see @notify
+ // we cannot use opener's setTimeout, see bug 420405
+ this._saveToDiskTimer =
+ Cc["@mozilla.org/timer;1"].createInstance(nsITimer);
+ this._saveToDiskTimer.initWithCallback(this, 0, nsITimer.TYPE_ONE_SHOT);
+ } else {
+ let uri = this.mLauncher.source;
+ // Launch local files immediately without downloading them:
+ if (uri instanceof Ci.nsIFileURL) {
+ this.mLauncher.launchLocalFile();
+ } else {
+ this.mLauncher.setDownloadToLaunch(this.handleInternally, null);
+ }
+ }
+
+ // Update user pref for this mime type (if necessary). We do not
+ // store anything in the mime type preferences for the ambiguous
+ // type application/octet-stream. We do NOT do this for
+ // application/x-msdownload since we want users to be able to
+ // autodownload these to disk.
+ if (
+ needUpdate &&
+ this.mLauncher.MIMEInfo.MIMEType != "application/octet-stream"
+ ) {
+ this.updateHelperAppPref();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ this.onUnload();
+ },
+
+ onCancel() {
+ // Remove our web progress listener.
+ this.mLauncher.setWebProgressListener(null);
+
+ // Cancel app launcher.
+ try {
+ this.mLauncher.cancel(Cr.NS_BINDING_ABORTED);
+ } catch (e) {
+ console.error(e);
+ }
+
+ this.onUnload();
+ },
+
+ onUnload() {
+ this.mDialog.document.removeEventListener("dialogaccept", this);
+ this.mDialog.document.removeEventListener("dialogcancel", this);
+
+ // Unhook dialog from this object.
+ this.mDialog.dialog = null;
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "dialogaccept":
+ this.onOK(aEvent);
+ break;
+ case "dialogcancel":
+ this.onCancel();
+ break;
+ }
+ },
+
+ dialogElement(id) {
+ return this.mDialog.document.getElementById(id);
+ },
+
+ // Retrieve the pretty description from the file
+ getFileDisplayName: function getFileDisplayName(file) {
+ if (AppConstants.platform == "win") {
+ if (file instanceof Ci.nsILocalFileWin) {
+ try {
+ return file.getVersionInfoField("FileDescription");
+ } catch (e) {}
+ }
+ } else if (AppConstants.platform == "macosx") {
+ if (file instanceof Ci.nsILocalFileMac) {
+ try {
+ return file.bundleDisplayName;
+ } catch (e) {}
+ }
+ }
+ return file.leafName;
+ },
+
+ finishChooseApp() {
+ if (this.chosenApp) {
+ // Show the "handler" menulist since we have a (user-specified)
+ // application now.
+ this.dialogElement("modeDeck").setAttribute("selectedIndex", "0");
+
+ // Update dialog.
+ var otherHandler = this.dialogElement("otherHandler");
+ otherHandler.removeAttribute("hidden");
+ otherHandler.setAttribute(
+ "path",
+ this.getPath(this.chosenApp.executable)
+ );
+ if (AppConstants.platform == "win") {
+ otherHandler.label = this.getFileDisplayName(this.chosenApp.executable);
+ } else {
+ otherHandler.label = this.chosenApp.name;
+ }
+ this.dialogElement("openHandler").selectedIndex = 1;
+ this.dialogElement("openHandler").setAttribute(
+ "lastSelectedItemID",
+ "otherHandler"
+ );
+
+ this.dialogElement("mode").selectedItem = this.dialogElement("open");
+ } else {
+ var openHandler = this.dialogElement("openHandler");
+ var lastSelectedID = openHandler.getAttribute("lastSelectedItemID");
+ if (!lastSelectedID) {
+ lastSelectedID = "defaultHandler";
+ }
+ openHandler.selectedItem = this.dialogElement(lastSelectedID);
+ }
+ },
+ // chooseApp: Open file picker and prompt user for application.
+ chooseApp() {
+ if (AppConstants.platform == "win") {
+ // Protect against the lack of an extension
+ var fileExtension = "";
+ try {
+ fileExtension = this.mLauncher.MIMEInfo.primaryExtension;
+ } catch (ex) {}
+
+ // Try to use the pretty description of the type, if one is available.
+ var typeString = this.mLauncher.MIMEInfo.description;
+
+ if (!typeString) {
+ // If there is none, use the extension to
+ // identify the file, e.g. "ZIP file"
+ if (fileExtension) {
+ typeString = this.dialogElement("strings").getFormattedString(
+ "fileType",
+ [fileExtension.toUpperCase()]
+ );
+ } else {
+ // If we can't even do that, just give up and show the MIME type.
+ typeString = this.mLauncher.MIMEInfo.MIMEType;
+ }
+ }
+
+ var params = {};
+ params.title = this.dialogElement("strings").getString(
+ "chooseAppFilePickerTitle"
+ );
+ params.description = typeString;
+ params.filename = this.mLauncher.suggestedFileName;
+ params.mimeInfo = this.mLauncher.MIMEInfo;
+ params.handlerApp = null;
+
+ this.mDialog.openDialog(
+ "chrome://global/content/appPicker.xhtml",
+ null,
+ "chrome,modal,centerscreen,titlebar,dialog=yes",
+ params
+ );
+
+ if (
+ params.handlerApp &&
+ params.handlerApp.executable &&
+ params.handlerApp.executable.isFile()
+ ) {
+ // Remember the file they chose to run.
+ this.chosenApp = params.handlerApp;
+ }
+ } else if ("@mozilla.org/applicationchooser;1" in Cc) {
+ var nsIApplicationChooser = Ci.nsIApplicationChooser;
+ var appChooser = Cc["@mozilla.org/applicationchooser;1"].createInstance(
+ nsIApplicationChooser
+ );
+ appChooser.init(
+ this.mDialog,
+ this.dialogElement("strings").getString("chooseAppFilePickerTitle")
+ );
+ var contentTypeDialogObj = this;
+ let appChooserCallback = function appChooserCallback_done(aResult) {
+ if (aResult) {
+ contentTypeDialogObj.chosenApp = aResult.QueryInterface(
+ Ci.nsILocalHandlerApp
+ );
+ }
+ contentTypeDialogObj.finishChooseApp();
+ };
+ appChooser.open(this.mLauncher.MIMEInfo.MIMEType, appChooserCallback);
+ // The finishChooseApp is called from appChooserCallback
+ return;
+ } else {
+ var nsIFilePicker = Ci.nsIFilePicker;
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ fp.init(
+ this.mDialog,
+ this.dialogElement("strings").getString("chooseAppFilePickerTitle"),
+ nsIFilePicker.modeOpen
+ );
+
+ fp.appendFilters(nsIFilePicker.filterApps);
+
+ fp.open(aResult => {
+ if (aResult == nsIFilePicker.returnOK && fp.file) {
+ // Remember the file they chose to run.
+ var localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.executable = fp.file;
+ this.chosenApp = localHandlerApp;
+ }
+ this.finishChooseApp();
+ });
+ // The finishChooseApp is called from fp.open() callback
+ return;
+ }
+
+ this.finishChooseApp();
+ },
+
+ shouldShowInternalHandlerOption() {
+ let browsingContext = this.mDialog.BrowsingContext.get(
+ this.mLauncher.browsingContextId
+ );
+ let primaryExtension = "";
+ try {
+ // The primaryExtension getter may throw if there are no
+ // known extensions for this mimetype.
+ primaryExtension = this.mLauncher.MIMEInfo.primaryExtension;
+ } catch (e) {}
+
+ // Only available for PDF files when pdf.js is enabled.
+ // Skip if the current window uses the resource scheme, to avoid
+ // showing the option when using the Download button in pdf.js.
+ if (primaryExtension == "pdf") {
+ return (
+ !(
+ this.mLauncher.source.schemeIs("blob") ||
+ this.mLauncher.source.equalsExceptRef(
+ browsingContext.currentWindowGlobal.documentURI
+ )
+ ) &&
+ !Services.prefs.getBoolPref("pdfjs.disabled", true) &&
+ Services.prefs.getBoolPref(
+ "browser.helperApps.showOpenOptionForPdfJS",
+ false
+ )
+ );
+ }
+
+ return (
+ Services.prefs.getBoolPref(
+ "browser.helperApps.showOpenOptionForViewableInternally",
+ false
+ ) &&
+ lazy.DownloadIntegration.shouldViewDownloadInternally(
+ this.mLauncher.MIMEInfo.MIMEType,
+ primaryExtension
+ )
+ );
+ },
+
+ // Turn this on to get debugging messages.
+ debug: false,
+
+ // Dump text (if debug is on).
+ dump(text) {
+ if (this.debug) {
+ dump(text);
+ }
+ },
+};
diff --git a/toolkit/mozapps/downloads/components.conf b/toolkit/mozapps/downloads/components.conf
new file mode 100644
index 0000000000..8446fb2150
--- /dev/null
+++ b/toolkit/mozapps/downloads/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{F68578EB-6EC2-4169-AE19-8C6243F0ABE1}',
+ 'contract_ids': ['@mozilla.org/helperapplauncherdialog;1'],
+ 'esModule': 'resource://gre/modules/HelperAppDlg.sys.mjs',
+ 'constructor': 'nsUnknownContentTypeDialog',
+ },
+]
diff --git a/toolkit/mozapps/downloads/content/unknownContentType.xhtml b/toolkit/mozapps/downloads/content/unknownContentType.xhtml
new file mode 100644
index 0000000000..47cf9d3117
--- /dev/null
+++ b/toolkit/mozapps/downloads/content/unknownContentType.xhtml
@@ -0,0 +1,104 @@
+<?xml version="1.0"?>
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+<window id="unknownContentTypeWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="dialog.initDialog();" onunload="if (dialog) dialog.onCancel();"
+#ifdef XP_WIN
+ style="min-width: 36em;"
+#else
+ style="min-width: 34em;"
+#endif
+ screenX="" screenY=""
+ persist="screenX screenY"
+ aria-describedby="intro location whichIs type from source unknownPrompt">
+<linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <html:link
+ rel="stylesheet"
+ href="chrome://mozapps/skin/downloads/unknownContentType.css"
+ />
+
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="toolkit/global/unknownContentType.ftl"/>
+</linkset>
+<dialog id="unknownContentType">
+
+ <stringbundle id="strings" src="chrome://mozapps/locale/downloads/unknownContentType.properties"/>
+
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <vbox flex="1" id="container">
+ <description id="intro" data-l10n-id="unknowncontenttype-intro"></description>
+ <separator class="thin"/>
+ <hbox align="start" class="small-indent">
+ <image id="contentTypeImage"/>
+ <vbox flex="1">
+ <description id="location" class="plain" crop="start" flex="1"/>
+ <separator class="thin"/>
+ <hbox align="center">
+ <label id="whichIs" data-l10n-id="unknowncontenttype-which-is"/>
+ <html:input id="type" class="plain" readonly="readonly" noinitialfocus="true"/>
+ </hbox>
+ <hbox align="center">
+ <label data-l10n-id="unknowncontenttype-from" id="from"/>
+ <description id="source" class="plain" crop="start" flex="1"/>
+ </hbox>
+ </vbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center" id="basicBox" collapsed="true">
+ <label id="unknownPrompt" data-l10n-id="unknowncontenttype-prompt" flex="1"/>
+ </hbox>
+
+ <vbox flex="1" id="normalBox">
+ <separator/>
+ <label control="mode" class="header" data-l10n-id="unknowncontenttype-action-question"/>
+ <radiogroup id="mode" class="small-indent">
+ <radio id="handleInternally" hidden="true" data-l10n-id="unknowncontenttype-handleinternally"/>
+
+ <hbox>
+ <radio id="open" data-l10n-id="unknowncontenttype-open-with"/>
+ <deck id="modeDeck" flex="1">
+ <hbox id="openHandlerBox" flex="1" align="center">
+ <menulist id="openHandler" flex="1" native="true">
+ <menupopup id="openHandlerPopup" oncommand="dialog.openHandlerCommand();">
+ <menuitem id="defaultHandler" default="true" crop="end"/>
+ <menuitem id="otherHandler" hidden="true" crop="start"/>
+ <menuseparator/>
+ <menuitem id="choose" data-l10n-id="unknowncontenttype-other"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <hbox flex="1" align="center">
+ <button id="chooseButton" oncommand="dialog.chooseApp();"
+ data-l10n-id="unknowncontenttype-choose-handler"/>
+ </hbox>
+ </deck>
+ </hbox>
+
+ <radio id="save" data-l10n-id="unknowncontenttype-save-file"/>
+ </radiogroup>
+ <separator class="thin"/>
+ <hbox class="small-indent">
+ <checkbox id="rememberChoice" data-l10n-id="unknowncontenttype-remember-choice"
+ oncommand="dialog.toggleRememberChoice(event.target);"
+ native="true"/>
+ </hbox>
+
+ <separator/>
+
+ <description id="settingsChange" hidden="true" data-l10n-id="unknowncontenttype-settingschange"/>
+
+ <separator class="thin"/>
+ </vbox>
+ </vbox>
+</dialog>
+</window>
diff --git a/toolkit/mozapps/downloads/jar.mn b/toolkit/mozapps/downloads/jar.mn
new file mode 100644
index 0000000000..7caa2f9668
--- /dev/null
+++ b/toolkit/mozapps/downloads/jar.mn
@@ -0,0 +1,7 @@
+# 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/.
+
+toolkit.jar:
+% content mozapps %content/mozapps/
+* content/mozapps/downloads/unknownContentType.xhtml (content/unknownContentType.xhtml)
diff --git a/toolkit/mozapps/downloads/moz.build b/toolkit/mozapps/downloads/moz.build
new file mode 100644
index 0000000000..2ac210ca68
--- /dev/null
+++ b/toolkit/mozapps/downloads/moz.build
@@ -0,0 +1,22 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Downloads API")
+
+TEST_DIRS += ["tests"]
+
+EXTRA_JS_MODULES += [
+ "DownloadLastDir.sys.mjs",
+ "DownloadUtils.sys.mjs",
+ "HelperAppDlg.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/mozapps/downloads/tests/browser/browser.toml b/toolkit/mozapps/downloads/tests/browser/browser.toml
new file mode 100644
index 0000000000..eddb8b84f1
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser.toml
@@ -0,0 +1,33 @@
+[DEFAULT]
+support-files = [
+ "unknownContentType_dialog_layout_data.pif",
+ "unknownContentType_dialog_layout_data.pif^headers^",
+ "unknownContentType_dialog_layout_data.txt",
+ "unknownContentType_dialog_layout_data.txt^headers^",
+ "head.js",
+]
+
+["browser_save_wrongextension.js"]
+
+["browser_unknownContentType_blob.js"]
+
+["browser_unknownContentType_delayedbutton.js"]
+skip-if = [
+ "os == 'linux' && bits == 64 && debug", # Bug 1747285
+ "os == 'linux' && fission && tsan", # Bug 1747285
+]
+
+["browser_unknownContentType_dialog_layout.js"]
+
+["browser_unknownContentType_extension.js"]
+support-files = [
+ "unknownContentType.EXE",
+ "unknownContentType.EXE^headers^",
+]
+
+["browser_unknownContentType_policy.js"]
+run-if = ["os == 'win'"] # jnlp file are not considered executable on macOS or Linux
+support-files = [
+ "example.jnlp",
+ "example.jnlp^headers^",
+]
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js b/toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js
new file mode 100644
index 0000000000..83e668076a
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js
@@ -0,0 +1,98 @@
+/* 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";
+
+let url =
+ "data:text/html,<a id='link' href='http://localhost:8000/thefile.js'>Link</a>";
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+let httpServer = null;
+
+add_task(async function test() {
+ const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+ );
+
+ httpServer = new HttpServer();
+ httpServer.start(8000);
+
+ function handleRequest(aRequest, aResponse) {
+ aResponse.setStatusLine(aRequest.httpVersion, 200);
+ aResponse.setHeader("Content-Type", "text/plain");
+ aResponse.write("Some Text");
+ }
+ httpServer.registerPathHandler("/thefile.js", handleRequest);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(document, "popupshown");
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShownPromise;
+
+ let tempDir = createTemporarySaveDirectory();
+ let destFile;
+
+ MockFilePicker.displayDirectory = tempDir;
+ MockFilePicker.showCallback = fp => {
+ let fileName = fp.defaultString;
+ destFile = tempDir.clone();
+ destFile.append(fileName);
+
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ };
+
+ let transferCompletePromise = new Promise(resolve => {
+ mockTransferCallback = resolve;
+ mockTransferRegisterer.register();
+ });
+
+ registerCleanupFunction(function () {
+ mockTransferRegisterer.unregister();
+ tempDir.remove(true);
+ });
+
+ document.getElementById("context-savelink").doCommand();
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ document,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+
+ await transferCompletePromise;
+
+ is(destFile.leafName, "thefile.js", "filename extension is not modified");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async () => {
+ MockFilePicker.cleanup();
+ await new Promise(resolve => httpServer.stop(resolve));
+});
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+function createTemporarySaveDirectory() {
+ let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ return saveDir;
+}
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js
new file mode 100644
index 0000000000..7b3900f46f
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+
+async function promiseDownloadFinished(list) {
+ return new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+}
+
+/**
+ * Check that both in the download "what do you want to do with this file"
+ * dialog and in the about:downloads download list, we represent blob URL
+ * download sources using the principal (origin) that generated the blob.
+ */
+add_task(async function test_check_blob_origin_representation() {
+ forcePromptForFiles("text/plain", "txt");
+
+ await check_blob_origin(
+ "https://example.org/1",
+ "example.org",
+ "example.org"
+ );
+ await check_blob_origin(
+ "data:text/html,<body>Some Text<br>",
+ "(data)",
+ "blob"
+ );
+});
+
+async function check_blob_origin(pageURL, expectedSource, expectedListOrigin) {
+ await BrowserTestUtils.withNewTab(pageURL, async browser => {
+ // Ensure we wait for the download to finish:
+ let downloadList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadPromise = promiseDownloadFinished(downloadList);
+
+ // Wait for the download prompting dialog
+ let dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ win => win.document.documentURI == UCT_URI
+ );
+
+ // create and click an <a download> link to a txt file.
+ await SpecialPowers.spawn(browser, [], () => {
+ // Use `eval` to get a blob URL scoped to content, so that content is
+ // actually allowed to open it and so we can check the origin is correct.
+ let url = content.eval(`
+ window.foo = new Blob(["Hello"], {type: "text/plain"});
+ URL.createObjectURL(window.foo)`);
+ let link = content.document.createElement("a");
+ link.href = url;
+ link.textContent = "Click me, click me, me me me";
+ link.download = "my-file.txt";
+ content.document.body.append(link);
+ link.click();
+ });
+
+ // Check what we display in the dialog
+ let dialogWin = await dialogPromise;
+ let source = dialogWin.document.getElementById("source");
+ is(
+ source.value,
+ expectedSource,
+ "Should list origin as source if available."
+ );
+
+ // Close the dialog
+ let closedPromise = BrowserTestUtils.windowClosed(dialogWin);
+ // Ensure we're definitely saving (otherwise this depends on mime service
+ // defaults):
+ dialogWin.document.getElementById("save").click();
+ let dialogNode = dialogWin.document.querySelector("dialog");
+ dialogNode.getButton("accept").disabled = false;
+ dialogNode.acceptDialog();
+ await closedPromise;
+
+ // Wait for the download to finish and ensure it is cleared up.
+ let download = await downloadPromise;
+ registerCleanupFunction(async () => {
+ let target = download.target.path;
+ await download.finalize();
+ await IOUtils.remove(target);
+ });
+
+ // Check that the same download is displayed correctly in about:downloads.
+ await BrowserTestUtils.withNewTab("about:downloads", async dlBrowser => {
+ let doc = dlBrowser.contentDocument;
+ let listNode = doc.getElementById("downloadsListBox");
+ await BrowserTestUtils.waitForMutationCondition(
+ listNode,
+ { childList: true, subtree: true, attributeFilter: ["value"] },
+ () =>
+ listNode.firstElementChild
+ ?.querySelector(".downloadDetailsNormal")
+ ?.getAttribute("value")
+ );
+ let download = listNode.firstElementChild;
+ let detailString = download.querySelector(".downloadDetailsNormal").value;
+ Assert.stringContains(
+ detailString,
+ expectedListOrigin,
+ "Should list origin in download list if available."
+ );
+ });
+ });
+}
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js
new file mode 100644
index 0000000000..06a5a9d238
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js
@@ -0,0 +1,96 @@
+/* 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/. */
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+const LOAD_URI =
+ "http://mochi.test:8888/browser/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt";
+
+const DIALOG_DELAY =
+ Services.prefs.getIntPref("security.dialog_enable_delay") + 200;
+
+let UCTObserver = {
+ opened: Promise.withResolvers(),
+ closed: Promise.withResolvers(),
+
+ observe(aSubject, aTopic, aData) {
+ let win = aSubject;
+
+ switch (aTopic) {
+ case "domwindowopened":
+ win.addEventListener(
+ "load",
+ function onLoad(event) {
+ // Let the dialog initialize
+ SimpleTest.executeSoon(function () {
+ UCTObserver.opened.resolve(win);
+ });
+ },
+ { once: true }
+ );
+ break;
+
+ case "domwindowclosed":
+ if (win.location == UCT_URI) {
+ this.closed.resolve();
+ }
+ break;
+ }
+ },
+};
+
+function waitDelay(delay) {
+ return new Promise((resolve, reject) => {
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ window.setTimeout(resolve, delay);
+ });
+}
+
+add_task(async function test_unknownContentType_delayedbutton() {
+ info("Starting browser_unknownContentType_delayedbutton.js...");
+ forcePromptForFiles("text/plain", "txt");
+
+ Services.ww.registerNotification(UCTObserver);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: LOAD_URI,
+ waitForLoad: false,
+ waitForStateStop: true,
+ },
+ async function () {
+ let uctWindow = await UCTObserver.opened.promise;
+ let dialog = uctWindow.document.getElementById("unknownContentType");
+ let ok = dialog.getButton("accept");
+
+ SimpleTest.is(ok.disabled, true, "button started disabled");
+
+ await waitDelay(DIALOG_DELAY);
+
+ SimpleTest.is(ok.disabled, false, "button was enabled");
+
+ let focusOutOfDialog = SimpleTest.promiseFocus(window);
+ window.focus();
+ await focusOutOfDialog;
+
+ SimpleTest.is(ok.disabled, true, "button was disabled");
+
+ let focusOnDialog = SimpleTest.promiseFocus(uctWindow);
+ uctWindow.focus();
+ await focusOnDialog;
+
+ SimpleTest.is(ok.disabled, true, "button remained disabled");
+
+ await waitDelay(DIALOG_DELAY);
+ SimpleTest.is(ok.disabled, false, "button re-enabled after delay");
+
+ dialog.cancelDialog();
+ await UCTObserver.closed.promise;
+
+ Services.ww.unregisterNotification(UCTObserver);
+ uctWindow = null;
+ UCTObserver = null;
+ }
+ );
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js
new file mode 100644
index 0000000000..6755c1aadb
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js
@@ -0,0 +1,103 @@
+/* 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/. */
+
+/*
+ * The unknownContentType popup can have two different layouts depending on
+ * whether a helper application can be selected or not.
+ * This tests that both layouts have correct collapsed elements.
+ */
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+
+let tests = [
+ {
+ // This URL will trigger the simple UI, where only the Save an Cancel buttons are available
+ url: "http://mochi.test:8888/browser/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif",
+ elements: {
+ basicBox: { collapsed: false },
+ normalBox: { collapsed: true },
+ },
+ },
+ {
+ // This URL will trigger the full UI
+ url: "http://mochi.test:8888/browser/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt",
+ elements: {
+ basicBox: { collapsed: true },
+ normalBox: { collapsed: false },
+ },
+ },
+];
+
+add_task(async function test_unknownContentType_dialog_layout() {
+ forcePromptForFiles("text/plain", "txt");
+ forcePromptForFiles("application/octet-stream", "pif");
+
+ for (let test of tests) {
+ let UCTObserver = {
+ opened: Promise.withResolvers(),
+ closed: Promise.withResolvers(),
+
+ observe(aSubject, aTopic, aData) {
+ let win = aSubject;
+
+ switch (aTopic) {
+ case "domwindowopened":
+ win.addEventListener(
+ "load",
+ function onLoad(event) {
+ // Let the dialog initialize
+ SimpleTest.executeSoon(function () {
+ UCTObserver.opened.resolve(win);
+ });
+ },
+ { once: true }
+ );
+ break;
+
+ case "domwindowclosed":
+ if (win.location == UCT_URI) {
+ this.closed.resolve();
+ }
+ break;
+ }
+ },
+ };
+
+ Services.ww.registerNotification(UCTObserver);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: test.url,
+ waitForLoad: false,
+ waitForStateStop: true,
+ },
+ async function () {
+ let uctWindow = await UCTObserver.opened.promise;
+
+ for (let [id, props] of Object.entries(test.elements)) {
+ let elem = uctWindow.dialog.dialogElement(id);
+ for (let [prop, value] of Object.entries(props)) {
+ SimpleTest.is(
+ elem[prop],
+ value,
+ "Element with id " +
+ id +
+ " has property " +
+ prop +
+ " set to " +
+ value
+ );
+ }
+ }
+ let focusOnDialog = SimpleTest.promiseFocus(uctWindow);
+ uctWindow.focus();
+ await focusOnDialog;
+
+ uctWindow.document.getElementById("unknownContentType").cancelDialog();
+ uctWindow = null;
+ Services.ww.unregisterNotification(UCTObserver);
+ }
+ );
+ }
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js
new file mode 100644
index 0000000000..1bb836c1d8
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+/**
+ * Check that case-sensitivity doesn't cause us to duplicate
+ * file name extensions.
+ */
+add_task(async function test_download_filename_extension() {
+ forcePromptForFiles("application/octet-stream", "exe");
+ let windowObserver = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "unknownContentType.EXE",
+ waitForLoad: false,
+ });
+ let win = await windowObserver;
+
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloadFinishedPromise = new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ if (download.stopped) {
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+
+ let dialog = win.document.querySelector("dialog");
+ dialog.getButton("accept").removeAttribute("disabled");
+ dialog.acceptDialog();
+ let download = await downloadFinishedPromise;
+ // We cannot assume that the filename didn't change.
+ let filename = PathUtils.filename(download.target.path);
+ Assert.ok(
+ filename.indexOf(".") == filename.lastIndexOf("."),
+ "Should not duplicate extension"
+ );
+ Assert.ok(filename.endsWith(".EXE"), "Should not change extension");
+ await list.remove(download);
+ BrowserTestUtils.removeTab(tab);
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ // Ignore errors in removing the file, the system may keep it locked and
+ // it's not a critical issue.
+ info("Failed to remove the file " + ex);
+ }
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js
new file mode 100644
index 0000000000..ccb0d957bb
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+/**
+ * Check that policy allows certain extensions to be launched.
+ */
+add_task(async function test_download_jnlp_policy() {
+ forcePromptForFiles("application/x-java-jnlp-file", "jnlp");
+ let windowObserver = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "example.jnlp",
+ waitForLoad: false,
+ });
+ let win = await windowObserver;
+
+ let dialog = win.document.querySelector("dialog");
+ let normalBox = win.document.getElementById("normalBox");
+ let basicBox = win.document.getElementById("basicBox");
+ is(normalBox.collapsed, !AppConstants.IS_ESR);
+ is(basicBox.collapsed, AppConstants.IS_ESR);
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(tab);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExemptDomainFileTypePairsFromFileTypeDownloadWarnings: [
+ {
+ file_extension: "jnlp",
+ domains: ["example.com"],
+ },
+ ],
+ },
+ });
+
+ windowObserver = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "example.jnlp",
+ waitForLoad: false,
+ });
+ win = await windowObserver;
+
+ dialog = win.document.querySelector("dialog");
+ normalBox = win.document.getElementById("normalBox");
+ basicBox = win.document.getElementById("basicBox");
+ is(normalBox.collapsed, false);
+ is(basicBox.collapsed, true);
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(tab);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {},
+ });
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/example.jnlp b/toolkit/mozapps/downloads/tests/browser/example.jnlp
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/example.jnlp
diff --git a/toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^ b/toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^
new file mode 100644
index 0000000000..fac0de2095
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^
@@ -0,0 +1 @@
+Content-Type: application/x-java-jnlp-file
diff --git a/toolkit/mozapps/downloads/tests/browser/head.js b/toolkit/mozapps/downloads/tests/browser/head.js
new file mode 100644
index 0000000000..a5536e95b2
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/head.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function forcePromptForFiles(mime, extension) {
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ let txtHandlerInfo = mimeSvc.getFromTypeAndExtension(mime, extension);
+ txtHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ txtHandlerInfo.alwaysAskBeforeHandling = true;
+ handlerSvc.store(txtHandlerInfo);
+ registerCleanupFunction(() => handlerSvc.remove(txtHandlerInfo));
+}
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^ b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^
new file mode 100644
index 0000000000..09b22facc0
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^
@@ -0,0 +1 @@
+Content-Type: application/octet-stream
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif
new file mode 100644
index 0000000000..9353d13126
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif
@@ -0,0 +1 @@
+Dummy content for unknownContentType_dialog_layout_data.pif
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^
new file mode 100644
index 0000000000..09b22facc0
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^
@@ -0,0 +1 @@
+Content-Type: application/octet-stream
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt
new file mode 100644
index 0000000000..77e7195596
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt
@@ -0,0 +1 @@
+Dummy content for unknownContentType_dialog_layout_data.txt
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^
new file mode 100644
index 0000000000..2a3c472e26
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^
@@ -0,0 +1,2 @@
+Content-Type: text/plain
+Content-Disposition: attachment
diff --git a/toolkit/mozapps/downloads/tests/moz.build b/toolkit/mozapps/downloads/tests/moz.build
new file mode 100644
index 0000000000..e274053906
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/moz.build
@@ -0,0 +1,8 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.toml"]
+BROWSER_CHROME_MANIFESTS += ["browser/browser.toml"]
diff --git a/toolkit/mozapps/downloads/tests/unit/head_downloads.js b/toolkit/mozapps/downloads/tests/unit/head_downloads.js
new file mode 100644
index 0000000000..f3178decef
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/head_downloads.js
@@ -0,0 +1,5 @@
+registerCleanupFunction(function () {
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js b/toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js
new file mode 100644
index 0000000000..956601c71e
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js
@@ -0,0 +1,398 @@
+/* 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/. */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+const gDecimalSymbol = Number(5.4).toLocaleString().match(/\D/);
+function _(str) {
+ return str.replace(/\./g, gDecimalSymbol);
+}
+
+function testConvertByteUnits(aBytes, aValue, aUnit) {
+ let [value, unit] = DownloadUtils.convertByteUnits(aBytes);
+ Assert.equal(value, aValue);
+ Assert.equal(unit, aUnit);
+}
+
+function testTransferTotal(aCurrBytes, aMaxBytes, aTransfer) {
+ let transfer = DownloadUtils.getTransferTotal(aCurrBytes, aMaxBytes);
+ Assert.equal(transfer, aTransfer);
+}
+
+// Get the em-dash character because typing it directly here doesn't work :(
+var gDash = DownloadUtils.getDownloadStatus(0)[0].match(/left (.) 0 bytes/)[1];
+
+var gVals = [
+ 0,
+ 100,
+ 2345,
+ 55555,
+ 982341,
+ 23194134,
+ 1482,
+ 58,
+ 9921949201,
+ 13498132,
+ Infinity,
+];
+
+function testStatus(aFunc, aCurr, aMore, aRate, aTest) {
+ dump("Status Test: " + [aCurr, aMore, aRate, aTest] + "\n");
+ let curr = gVals[aCurr];
+ let max = curr + gVals[aMore];
+ let speed = gVals[aRate];
+
+ let [status, last] = aFunc(curr, max, speed);
+
+ if (0) {
+ dump(
+ "testStatus(" +
+ aCurr +
+ ", " +
+ aMore +
+ ", " +
+ aRate +
+ ', ["' +
+ status.replace(gDash, "--") +
+ '", ' +
+ last.toFixed(3) +
+ "]);\n"
+ );
+ }
+
+ // Make sure the status text matches
+ Assert.equal(status, _(aTest[0].replace(/--/, gDash)));
+
+ // Make sure the lastSeconds matches
+ if (last == Infinity) {
+ Assert.equal(last, aTest[1]);
+ } else {
+ Assert.ok(Math.abs(last - aTest[1]) < 0.1);
+ }
+}
+
+function testFormattedTimeStatus(aSec, aExpected) {
+ dump("Formatted Time Status Test: [" + aSec + "]\n");
+
+ let status = DownloadUtils.getFormattedTimeStatus(aSec);
+ dump("Formatted Time Status Test Returns: (" + status.l10n.id + ")\n");
+
+ Assert.equal(status.l10n.id, aExpected);
+}
+
+function testURI(aURI, aDisp, aHost) {
+ dump("URI Test: " + [aURI, aDisp, aHost] + "\n");
+
+ let [disp, host] = DownloadUtils.getURIHost(aURI);
+
+ // Make sure we have the right display host and full host
+ Assert.equal(disp, aDisp);
+ Assert.equal(host, aHost);
+}
+
+function testGetReadableDates(aDate, aCompactValue) {
+ const now = new Date(2000, 11, 31, 11, 59, 59);
+
+ let [dateCompact] = DownloadUtils.getReadableDates(aDate, now);
+ Assert.equal(dateCompact, aCompactValue);
+}
+
+function testAllGetReadableDates() {
+ // This test cannot depend on the current date and time, or the date format.
+ // It depends on being run with the English localization, however.
+ const today_11_30 = new Date(2000, 11, 31, 11, 30, 15);
+ const today_12_30 = new Date(2000, 11, 31, 12, 30, 15);
+ const yesterday_11_30 = new Date(2000, 11, 30, 11, 30, 15);
+ const yesterday_12_30 = new Date(2000, 11, 30, 12, 30, 15);
+ const twodaysago = new Date(2000, 11, 29, 11, 30, 15);
+ const sixdaysago = new Date(2000, 11, 25, 11, 30, 15);
+ const sevendaysago = new Date(2000, 11, 24, 11, 30, 15);
+
+ let cDtf = Services.intl.DateTimeFormat;
+
+ testGetReadableDates(
+ today_11_30,
+ new cDtf(undefined, { timeStyle: "short" }).format(today_11_30)
+ );
+ testGetReadableDates(
+ today_12_30,
+ new cDtf(undefined, { timeStyle: "short" }).format(today_12_30)
+ );
+
+ testGetReadableDates(yesterday_11_30, "Yesterday");
+ testGetReadableDates(yesterday_12_30, "Yesterday");
+ testGetReadableDates(
+ twodaysago,
+ twodaysago.toLocaleDateString(undefined, { weekday: "long" })
+ );
+ testGetReadableDates(
+ sixdaysago,
+ sixdaysago.toLocaleDateString(undefined, { weekday: "long" })
+ );
+ testGetReadableDates(
+ sevendaysago,
+ sevendaysago.toLocaleDateString(undefined, { month: "long" }) +
+ " " +
+ sevendaysago.getDate().toString().padStart(2, "0")
+ );
+
+ let [, dateTimeFull] = DownloadUtils.getReadableDates(today_11_30);
+
+ const dtOptions = { dateStyle: "long", timeStyle: "short" };
+ Assert.equal(
+ dateTimeFull,
+ new cDtf(undefined, dtOptions).format(today_11_30)
+ );
+}
+
+function run_test() {
+ testConvertByteUnits(-1, "-1", "bytes");
+ testConvertByteUnits(1, _("1"), "bytes");
+ testConvertByteUnits(42, _("42"), "bytes");
+ testConvertByteUnits(123, _("123"), "bytes");
+ testConvertByteUnits(1024, _("1.0"), "KB");
+ testConvertByteUnits(8888, _("8.7"), "KB");
+ testConvertByteUnits(59283, _("57.9"), "KB");
+ testConvertByteUnits(640000, _("625"), "KB");
+ testConvertByteUnits(1048576, _("1.0"), "MB");
+ testConvertByteUnits(307232768, _("293"), "MB");
+ testConvertByteUnits(1073741824, _("1.0"), "GB");
+
+ testTransferTotal(1, 1, _("1 of 1 bytes"));
+ testTransferTotal(234, 4924, _("234 bytes of 4.8 KB"));
+ testTransferTotal(94923, 233923, _("92.7 of 228 KB"));
+ testTransferTotal(4924, 94923, _("4.8 of 92.7 KB"));
+ testTransferTotal(2342, 294960345, _("2.3 KB of 281 MB"));
+ testTransferTotal(234, undefined, _("234 bytes"));
+ testTransferTotal(4889023, undefined, _("4.7 MB"));
+
+ if (0) {
+ // Help find some interesting test cases
+ let r = () => Math.floor(Math.random() * 10);
+ for (let i = 0; i < 100; i++) {
+ testStatus(r(), r(), r());
+ }
+ }
+
+ // First, test with rates, via getDownloadStatus...
+ let statusFunc = DownloadUtils.getDownloadStatus.bind(DownloadUtils);
+
+ testStatus(statusFunc, 2, 1, 7, [
+ "A few seconds left -- 2.3 of 2.4 KB (58 bytes/sec)",
+ 1.724,
+ ]);
+ testStatus(statusFunc, 1, 2, 6, [
+ "A few seconds left -- 100 bytes of 2.4 KB (1.4 KB/sec)",
+ 1.582,
+ ]);
+ testStatus(statusFunc, 4, 3, 9, [
+ "A few seconds left -- 959 KB of 1.0 MB (12.9 MB/sec)",
+ 0.004,
+ ]);
+ testStatus(statusFunc, 2, 3, 8, [
+ "A few seconds left -- 2.3 of 56.5 KB (9.2 GB/sec)",
+ 0.0,
+ ]);
+
+ testStatus(statusFunc, 8, 4, 3, [
+ "17s left -- 9.2 of 9.2 GB (54.3 KB/sec)",
+ 17.682,
+ ]);
+ testStatus(statusFunc, 1, 3, 2, [
+ "23s left -- 100 bytes of 54.4 KB (2.3 KB/sec)",
+ 23.691,
+ ]);
+ testStatus(statusFunc, 9, 3, 2, [
+ "23s left -- 12.9 of 12.9 MB (2.3 KB/sec)",
+ 23.691,
+ ]);
+ testStatus(statusFunc, 5, 6, 7, [
+ "25s left -- 22.1 of 22.1 MB (58 bytes/sec)",
+ 25.552,
+ ]);
+
+ testStatus(statusFunc, 3, 9, 3, [
+ "4m left -- 54.3 KB of 12.9 MB (54.3 KB/sec)",
+ 242.969,
+ ]);
+ testStatus(statusFunc, 2, 3, 1, [
+ "9m left -- 2.3 of 56.5 KB (100 bytes/sec)",
+ 555.55,
+ ]);
+ testStatus(statusFunc, 4, 3, 7, [
+ "15m left -- 959 KB of 1.0 MB (58 bytes/sec)",
+ 957.845,
+ ]);
+ testStatus(statusFunc, 5, 3, 7, [
+ "15m left -- 22.1 of 22.2 MB (58 bytes/sec)",
+ 957.845,
+ ]);
+
+ testStatus(statusFunc, 1, 9, 2, [
+ "1h 35m left -- 100 bytes of 12.9 MB (2.3 KB/sec)",
+ 5756.133,
+ ]);
+ testStatus(statusFunc, 2, 9, 6, [
+ "2h 31m left -- 2.3 KB of 12.9 MB (1.4 KB/sec)",
+ 9108.051,
+ ]);
+ testStatus(statusFunc, 2, 4, 1, [
+ "2h 43m left -- 2.3 of 962 KB (100 bytes/sec)",
+ 9823.41,
+ ]);
+ testStatus(statusFunc, 6, 4, 7, [
+ "4h 42m left -- 1.4 of 961 KB (58 bytes/sec)",
+ 16936.914,
+ ]);
+
+ testStatus(statusFunc, 6, 9, 1, [
+ "1d 13h left -- 1.4 KB of 12.9 MB (100 bytes/sec)",
+ 134981.32,
+ ]);
+ testStatus(statusFunc, 3, 8, 3, [
+ "2d 1h left -- 54.3 KB of 9.2 GB (54.3 KB/sec)",
+ 178596.872,
+ ]);
+ testStatus(statusFunc, 1, 8, 6, [
+ "77d 11h left -- 100 bytes of 9.2 GB (1.4 KB/sec)",
+ 6694972.47,
+ ]);
+ testStatus(statusFunc, 6, 8, 7, [
+ "1,979d 22h left -- 1.4 KB of 9.2 GB (58 bytes/sec)",
+ 171068089.672,
+ ]);
+
+ testStatus(statusFunc, 0, 0, 5, [
+ "Unknown time left -- 0 of 0 bytes (22.1 MB/sec)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 0, 6, 0, [
+ "Unknown time left -- 0 bytes of 1.4 KB (0 bytes/sec)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 6, 6, 0, [
+ "Unknown time left -- 1.4 of 2.9 KB (0 bytes/sec)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 8, 5, 0, [
+ "Unknown time left -- 9.2 of 9.3 GB (0 bytes/sec)",
+ Infinity,
+ ]);
+
+ // With rate equal to Infinity
+ testStatus(statusFunc, 0, 0, 10, [
+ "Unknown time left -- 0 of 0 bytes (Really fast)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 1, 2, 10, [
+ "A few seconds left -- 100 bytes of 2.4 KB (Really fast)",
+ 0,
+ ]);
+
+ // Now test without rates, via getDownloadStatusNoRate.
+ statusFunc = DownloadUtils.getDownloadStatusNoRate.bind(DownloadUtils);
+
+ testStatus(statusFunc, 2, 1, 7, [
+ "A few seconds left -- 2.3 of 2.4 KB",
+ 1.724,
+ ]);
+ testStatus(statusFunc, 1, 2, 6, [
+ "A few seconds left -- 100 bytes of 2.4 KB",
+ 1.582,
+ ]);
+ testStatus(statusFunc, 4, 3, 9, [
+ "A few seconds left -- 959 KB of 1.0 MB",
+ 0.004,
+ ]);
+ testStatus(statusFunc, 2, 3, 8, [
+ "A few seconds left -- 2.3 of 56.5 KB",
+ 0.0,
+ ]);
+
+ testStatus(statusFunc, 8, 4, 3, ["17s left -- 9.2 of 9.2 GB", 17.682]);
+ testStatus(statusFunc, 1, 3, 2, ["23s left -- 100 bytes of 54.4 KB", 23.691]);
+ testStatus(statusFunc, 9, 3, 2, ["23s left -- 12.9 of 12.9 MB", 23.691]);
+ testStatus(statusFunc, 5, 6, 7, ["25s left -- 22.1 of 22.1 MB", 25.552]);
+
+ testStatus(statusFunc, 3, 9, 3, ["4m left -- 54.3 KB of 12.9 MB", 242.969]);
+ testStatus(statusFunc, 2, 3, 1, ["9m left -- 2.3 of 56.5 KB", 555.55]);
+ testStatus(statusFunc, 4, 3, 7, ["15m left -- 959 KB of 1.0 MB", 957.845]);
+ testStatus(statusFunc, 5, 3, 7, ["15m left -- 22.1 of 22.2 MB", 957.845]);
+
+ testStatus(statusFunc, 1, 9, 2, [
+ "1h 35m left -- 100 bytes of 12.9 MB",
+ 5756.133,
+ ]);
+ testStatus(statusFunc, 2, 9, 6, [
+ "2h 31m left -- 2.3 KB of 12.9 MB",
+ 9108.051,
+ ]);
+ testStatus(statusFunc, 2, 4, 1, ["2h 43m left -- 2.3 of 962 KB", 9823.41]);
+ testStatus(statusFunc, 6, 4, 7, ["4h 42m left -- 1.4 of 961 KB", 16936.914]);
+
+ testStatus(statusFunc, 6, 9, 1, [
+ "1d 13h left -- 1.4 KB of 12.9 MB",
+ 134981.32,
+ ]);
+ testStatus(statusFunc, 3, 8, 3, [
+ "2d 1h left -- 54.3 KB of 9.2 GB",
+ 178596.872,
+ ]);
+ testStatus(statusFunc, 1, 8, 6, [
+ "77d 11h left -- 100 bytes of 9.2 GB",
+ 6694972.47,
+ ]);
+ testStatus(statusFunc, 6, 8, 7, [
+ "1,979d 22h left -- 1.4 KB of 9.2 GB",
+ 171068089.672,
+ ]);
+
+ testStatus(statusFunc, 0, 0, 5, [
+ "Unknown time left -- 0 of 0 bytes",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 0, 6, 0, [
+ "Unknown time left -- 0 bytes of 1.4 KB",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 6, 6, 0, [
+ "Unknown time left -- 1.4 of 2.9 KB",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 8, 5, 0, [
+ "Unknown time left -- 9.2 of 9.3 GB",
+ Infinity,
+ ]);
+
+ testFormattedTimeStatus(-1, "downloading-file-opens-in-some-time-2");
+ // Passing in null will return a status of file-opens-in-seconds, as Math.floor(null) = 0
+ testFormattedTimeStatus(null, "downloading-file-opens-in-seconds-2");
+ testFormattedTimeStatus(0, "downloading-file-opens-in-seconds-2");
+ testFormattedTimeStatus(30, "downloading-file-opens-in-seconds-2");
+
+ testURI("http://www.mozilla.org/", "mozilla.org", "www.mozilla.org");
+ testURI(
+ "http://www.city.mikasa.hokkaido.jp/",
+ "city.mikasa.hokkaido.jp",
+ "www.city.mikasa.hokkaido.jp"
+ );
+ testURI("data:text/html,Hello World", "data resource", "data resource");
+ testURI(
+ "jar:http://www.mozilla.com/file!/magic",
+ "mozilla.com",
+ "www.mozilla.com"
+ );
+ testURI("file:///C:/Cool/Stuff/", "local file", "local file");
+ // Don't test for moz-icon if we don't have a protocol handler for it (e.g. b2g):
+ if ("@mozilla.org/network/protocol;1?name=moz-icon" in Cc) {
+ testURI("moz-icon:file:///test.extension", "local file", "local file");
+ testURI("moz-icon://.extension", "moz-icon resource", "moz-icon resource");
+ }
+ testURI("about:config", "about resource", "about resource");
+ testURI("invalid.uri", "", "");
+
+ testAllGetReadableDates();
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js b/toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js
new file mode 100644
index 0000000000..786da28b75
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js
@@ -0,0 +1,56 @@
+/* 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/. */
+
+/**
+ * Test bug 448344 to make sure when we're in low minutes, we show both minutes
+ * and seconds; but continue to show only minutes when we have plenty.
+ */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+/**
+ * Print some debug message to the console. All arguments will be printed,
+ * separated by spaces.
+ *
+ * @param [arg0, arg1, arg2, ...]
+ * Any number of arguments to print out
+ * @usage _("Hello World") -> prints "Hello World"
+ * @usage _(1, 2, 3) -> prints "1 2 3"
+ */
+var _ = function (some, debug, text, to) {
+ print(Array.from(arguments).join(" "));
+};
+
+_("Make an array of time lefts and expected string to be shown for that time");
+var expectedTimes = [
+ [1.1, "A few seconds left", "under 4sec -> few"],
+ [2.5, "A few seconds left", "under 4sec -> few"],
+ [3.9, "A few seconds left", "under 4sec -> few"],
+ [5.3, "5s left", "truncate seconds"],
+ [1.1 * 60, "1m 6s left", "under 4min -> show sec"],
+ [2.5 * 60, "2m 30s left", "under 4min -> show sec"],
+ [3.9 * 60, "3m 54s left", "under 4min -> show sec"],
+ [5.3 * 60, "5m left", "over 4min -> only show min"],
+ [1.1 * 3600, "1h 6m left", "over 1hr -> show min/sec"],
+ [2.5 * 3600, "2h 30m left", "over 1hr -> show min/sec"],
+ [3.9 * 3600, "3h 54m left", "over 1hr -> show min/sec"],
+ [5.3 * 3600, "5h 18m left", "over 1hr -> show min/sec"],
+];
+_(expectedTimes.join("\n"));
+
+function run_test() {
+ expectedTimes.forEach(function ([time, expectStatus, comment]) {
+ _("Running test with time", time);
+ _("Test comment:", comment);
+ let [status, last] = DownloadUtils.getTimeLeft(time);
+
+ _("Got status:", status, "last:", last);
+ _("Expecting..", expectStatus);
+ Assert.equal(status, expectStatus);
+
+ _();
+ });
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js b/toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js
new file mode 100644
index 0000000000..d60baee447
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js
@@ -0,0 +1,28 @@
+/* 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/. */
+
+/**
+ * Test bug 420482 by making sure multiple consumers of DownloadUtils gets the
+ * same time remaining time if they provide the same time left but a different
+ * "last time".
+ */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+function run_test() {
+ // Simulate having multiple downloads requesting time left
+ let downloadTimes = {};
+ for (let time of [1, 30, 60, 3456, 9999]) {
+ downloadTimes[time] = DownloadUtils.getTimeLeft(time)[0];
+ }
+
+ // Pretend we're a download status bar also asking for a time left, but we're
+ // using a different "last sec". We need to make sure we get the same time.
+ let lastSec = 314;
+ for (let [time, text] of Object.entries(downloadTimes)) {
+ Assert.equal(DownloadUtils.getTimeLeft(time, lastSec)[0], text);
+ }
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js b/toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js
new file mode 100644
index 0000000000..eb512d93ab
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js
@@ -0,0 +1,33 @@
+/* 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/. */
+
+/**
+ * Make sure passing null and nothing to various variable-arg DownloadUtils
+ * methods provide the same result.
+ */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+function run_test() {
+ Assert.equal(
+ DownloadUtils.getDownloadStatus(1000, null, null, null) + "",
+ DownloadUtils.getDownloadStatus(1000) + ""
+ );
+ Assert.equal(
+ DownloadUtils.getDownloadStatus(1000, null, null) + "",
+ DownloadUtils.getDownloadStatus(1000, null) + ""
+ );
+
+ Assert.equal(
+ DownloadUtils.getTransferTotal(1000, null) + "",
+ DownloadUtils.getTransferTotal(1000) + ""
+ );
+
+ Assert.equal(
+ DownloadUtils.getTimeLeft(1000, null) + "",
+ DownloadUtils.getTimeLeft(1000) + ""
+ );
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/xpcshell.toml b/toolkit/mozapps/downloads/tests/unit/xpcshell.toml
new file mode 100644
index 0000000000..7f1d488a3c
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/xpcshell.toml
@@ -0,0 +1,10 @@
+[DEFAULT]
+head = "head_downloads.js"
+
+["test_DownloadUtils.js"]
+
+["test_lowMinutes.js"]
+
+["test_syncedDownloadUtils.js"]
+
+["test_unspecified_arguments.js"]