Source code
Revision control
Copy as Markdown
Other Tools
/* 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
/**
* Provides functions to integrate with the host application, handling for
* example the global prompts on shutdown.
*/
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { Downloads } from "resource://gre/modules/Downloads.sys.mjs";
import { Integration } from "resource://gre/modules/Integration.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
DownloadSpamProtection: "resource:///modules/DownloadSpamProtection.sys.mjs",
DownloadStore: "resource://gre/modules/DownloadStore.sys.mjs",
DownloadUIHelper: "resource://gre/modules/DownloadUIHelper.sys.mjs",
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gDownloadPlatform",
"@mozilla.org/toolkit/download-platform;1",
"mozIDownloadPlatform"
);
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gMIMEService",
"@mozilla.org/mime;1",
"nsIMIMEService"
);
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gExternalProtocolService",
"@mozilla.org/uriloader/external-protocol-service;1",
"nsIExternalProtocolService"
);
ChromeUtils.defineLazyGetter(lazy, "gParentalControlsService", function () {
if ("@mozilla.org/parental-controls-service;1" in Cc) {
return Cc["@mozilla.org/parental-controls-service;1"].createInstance(
Ci.nsIParentalControlsService
);
}
return null;
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gApplicationReputationService",
"@mozilla.org/reputationservice/application-reputation-service;1",
Ci.nsIApplicationReputationService
);
Integration.downloads.defineESModuleGetter(
lazy,
"DownloadIntegration",
"resource://gre/modules/DownloadIntegration.sys.mjs"
);
ChromeUtils.defineLazyGetter(lazy, "gCombinedDownloadIntegration", () => {
return lazy.DownloadIntegration;
});
ChromeUtils.defineLazyGetter(lazy, "stringBundle", () =>
Services.strings.createBundle(
"chrome://mozapps/locale/downloads/downloads.properties"
)
);
const Timer = Components.Constructor(
"@mozilla.org/timer;1",
"nsITimer",
"initWithCallback"
);
/**
* Indicates the delay between a change to the downloads data and the related
* save operation.
*
* For best efficiency, this value should be high enough that the input/output
* for opening or closing the target file does not overlap with the one for
* saving the list of downloads.
*/
const kSaveDelayMs = 1500;
/**
* List of observers to listen against
*/
const kObserverTopics = [
"quit-application-requested",
"offline-requested",
"last-pb-context-exiting",
"last-pb-context-exited",
"sleep_notification",
"suspend_process_notification",
"wake_notification",
"resume_process_notification",
"network:offline-about-to-go-offline",
"network:offline-status-changed",
"xpcom-will-shutdown",
"blocked-automatic-download",
];
/**
* Maps nsIApplicationReputationService verdicts with the DownloadError ones.
*/
const kVerdictMap = {
[Ci.nsIApplicationReputationService.VERDICT_DANGEROUS]:
Downloads.Error.BLOCK_VERDICT_MALWARE,
[Ci.nsIApplicationReputationService.VERDICT_UNCOMMON]:
Downloads.Error.BLOCK_VERDICT_UNCOMMON,
[Ci.nsIApplicationReputationService.VERDICT_POTENTIALLY_UNWANTED]:
Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED,
[Ci.nsIApplicationReputationService.VERDICT_DANGEROUS_HOST]:
Downloads.Error.BLOCK_VERDICT_MALWARE,
};
/**
* Provides functions to integrate with the host application, handling for
* example the global prompts on shutdown.
*/
export var DownloadIntegration = {
/**
* Main DownloadStore object for loading and saving the list of persistent
* downloads, or null if the download list was never requested and thus it
* doesn't need to be persisted.
*/
_store: null,
/**
* Returns whether data for blocked downloads should be kept on disk.
* Implementations which support unblocking downloads may return true to
* keep the blocked download on disk until its fate is decided.
*
* If a download is blocked and the partial data is kept the Download's
* 'hasBlockedData' property will be true. In this state Download.unblock()
* or Download.confirmBlock() may be used to either unblock the download or
* remove the downloaded data respectively.
*
* Even if shouldKeepBlockedData returns true, if the download did not use a
* partFile the blocked data will be removed - preventing the complete
* download from existing on disk with its final filename.
*
* @return boolean True if data should be kept.
*/
shouldKeepBlockedData() {
const FIREFOX_ID = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
return Services.appinfo.ID == FIREFOX_ID;
},
/**
* Performs initialization of the list of persistent downloads, before its
* first use by the host application. This function may be called only once
* during the entire lifetime of the application.
*
* @param list
* DownloadList object to be initialized.
*
* @return {Promise}
* @resolves When the list has been initialized.
* @rejects JavaScript exception.
*/
async initializePublicDownloadList(list) {
try {
await this.loadPublicDownloadListFromStore(list);
} catch (ex) {
console.error(ex);
}
if (AppConstants.MOZ_PLACES) {
// After the list of persistent downloads has been loaded, we can add the
// history observers, even if the load operation failed. This object is kept
// alive by the history service.
new DownloadHistoryObserver(list);
}
},
/**
* Called by initializePublicDownloadList to load the list of persistent
* downloads, before its first use by the host application. This function may
* be called only once during the entire lifetime of the application.
*
* @param list
* DownloadList object to be populated with the download objects
* serialized from the previous session. This list will be persisted
* to disk during the session lifetime.
*
* @return {Promise}
* @resolves When the list has been populated.
* @rejects JavaScript exception.
*/
async loadPublicDownloadListFromStore(list) {
if (this._store) {
throw new Error("Initialization may be performed only once.");
}
this._store = new lazy.DownloadStore(
list,
PathUtils.join(PathUtils.profileDir, "downloads.json")
);
this._store.onsaveitem = this.shouldPersistDownload.bind(this);
try {
await this._store.load();
} catch (ex) {
console.error(ex);
}
// Add the view used for detecting changes to downloads to be persisted.
// We must do this after the list of persistent downloads has been loaded,
// even if the load operation failed. We wait for a complete initialization
// so other callers cannot modify the list without being detected. The
// DownloadAutoSaveView is kept alive by the underlying DownloadList.
await new DownloadAutoSaveView(list, this._store).initialize();
},
/**
* Determines if a Download object from the list of persistent downloads
* should be saved into a file, so that it can be restored across sessions.
*
* This function allows filtering out downloads that the host application is
* not interested in persisting across sessions, for example downloads that
* finished successfully.
*
* @param aDownload
* The Download object to be inspected. This is originally taken from
* the global DownloadList object for downloads that were not started
* from a private browsing window. The item may have been removed
* from the list since the save operation started, though in this case
* the save operation will be repeated later.
*
* @return True to save the download, false otherwise.
*/
shouldPersistDownload(aDownload) {
// On all platforms, we save all the downloads currently in progress, as
// well as stopped downloads for which we retained partially downloaded
// data or we have blocked data.
// On Android we store all history; on Desktop, stopped downloads for which
// we don't need to track the presence of a ".part" file are only retained
// in the browser history.
return (
!aDownload.stopped ||
aDownload.hasPartialData ||
aDownload.hasBlockedData ||
AppConstants.platform == "android"
);
},
/**
* Returns the system downloads directory asynchronously.
*
* @return {Promise}
* @resolves The downloads directory string path.
*/
async getSystemDownloadsDirectory() {
if (this._downloadsDirectory) {
return this._downloadsDirectory;
}
if (AppConstants.platform == "android") {
// Android doesn't have a $HOME directory, and by default we only have
// write access to /data/data/org.mozilla.{$APP} and /sdcard
this._downloadsDirectory = Services.env.get("DOWNLOADS_DIRECTORY");
if (!this._downloadsDirectory) {
throw new Components.Exception(
"DOWNLOADS_DIRECTORY is not set.",
Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH
);
}
} else {
try {
this._downloadsDirectory = this._getDirectory("DfltDwnld");
} catch (e) {
this._downloadsDirectory = await this._createDownloadsDirectory("Home");
}
}
return this._downloadsDirectory;
},
_downloadsDirectory: null,
/**
* Returns the user downloads directory asynchronously.
*
* On platforms where external helper apps use the downloads directory, the
* behavior should match that of the synchronous function of the same name
* exposed by nsIExternalHelperAppService.
*
* @return {Promise}
* @resolves The downloads directory string path.
*/
async getPreferredDownloadsDirectory() {
let directoryPath = null;
let prefValue = Services.prefs.getIntPref("browser.download.folderList", 1);
switch (prefValue) {
case 0: // Desktop
directoryPath = this._getDirectory("Desk");
break;
case 1: // Downloads
directoryPath = await this.getSystemDownloadsDirectory();
break;
case 2: // Custom
try {
let directory = Services.prefs.getComplexValue(
"browser.download.dir",
Ci.nsIFile
);
directoryPath = directory.path;
await IOUtils.makeDirectory(directoryPath, {
createAncestors: false,
});
} catch (ex) {
console.error(ex);
// Either the preference isn't set or the directory cannot be created.
directoryPath = await this.getSystemDownloadsDirectory();
}
break;
default:
directoryPath = await this.getSystemDownloadsDirectory();
}
return directoryPath;
},
/**
* Returns the temporary downloads directory asynchronously.
*
* @return {Promise}
* @resolves The downloads directory string path.
*/
async getTemporaryDownloadsDirectory() {
let directoryPath = null;
if (AppConstants.platform == "macosx") {
directoryPath = await this.getPreferredDownloadsDirectory();
} else if (AppConstants.platform == "android") {
directoryPath = await this.getSystemDownloadsDirectory();
} else {
directoryPath = this._getDirectory("TmpD");
}
return directoryPath;
},
/**
* Checks to determine whether to block downloads for parental controls.
*
* aParam aDownload
* The download object.
*
* @return {Promise}
* @resolves The boolean indicates to block downloads or not.
*/
shouldBlockForParentalControls(aDownload) {
let isEnabled =
lazy.gParentalControlsService &&
lazy.gParentalControlsService.parentalControlsEnabled;
let shouldBlock =
isEnabled && lazy.gParentalControlsService.blockFileDownloadsEnabled;
// Log the event if required by parental controls settings.
if (isEnabled && lazy.gParentalControlsService.loggingEnabled) {
lazy.gParentalControlsService.log(
lazy.gParentalControlsService.ePCLog_FileDownload,
shouldBlock,
lazy.NetUtil.newURI(aDownload.source.url),
null
);
}
return Promise.resolve(shouldBlock);
},
/**
* Checks to determine whether to block downloads because they might be
* malware, based on application reputation checks.
*
* aParam aDownload
* The download object.
*
* @return {Promise}
* @resolves Object with the following properties:
* {
* shouldBlock: Whether the download should be blocked.
* verdict: Detailed reason for the block, according to the
* "Downloads.Error.BLOCK_VERDICT_" constants, or empty
* string if the reason is unknown.
* }
*/
shouldBlockForReputationCheck(aDownload) {
let hash;
let sigInfo;
let channelRedirects;
try {
hash = aDownload.saver.getSha256Hash();
sigInfo = aDownload.saver.getSignatureInfo();
channelRedirects = aDownload.saver.getRedirects();
} catch (ex) {
// Bail if DownloadSaver doesn't have a hash or signature info.
return Promise.resolve({
shouldBlock: false,
verdict: "",
});
}
if (!hash || !sigInfo) {
return Promise.resolve({
shouldBlock: false,
verdict: "",
});
}
return new Promise(resolve => {
lazy.gApplicationReputationService.queryReputation(
{
sourceURI: lazy.NetUtil.newURI(aDownload.source.url),
referrerInfo: aDownload.source.referrerInfo,
fileSize: aDownload.currentBytes,
sha256Hash: hash,
suggestedFileName: PathUtils.filename(aDownload.target.path),
signatureInfo: sigInfo,
redirects: channelRedirects,
},
function onComplete(aShouldBlock, aRv, aVerdict) {
resolve({
shouldBlock: aShouldBlock,
verdict: (aShouldBlock && kVerdictMap[aVerdict]) || "",
});
}
);
});
},
/**
* Checks whether downloaded files should be marked as coming from
* Internet Zone.
*
* @return true if files should be marked
*/
_shouldSaveZoneInformation() {
let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
Ci.nsIWindowsRegKey
);
try {
key.open(
Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
"Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Attachments",
Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE
);
try {
return key.readIntValue("SaveZoneInformation") != 1;
} finally {
key.close();
}
} catch (ex) {
// If the key is not present, files should be marked by default.
return true;
}
},
/**
* Builds a key and URL value pair for the "Zone.Identifier" Alternate Data
* Stream.
*
* @param aKey
* String to write before the "=" sign. This is not validated.
* @param aUrl
* URL string to write after the "=" sign. Only the "http(s)" and
* "ftp" schemes are allowed, and usernames and passwords are
* stripped.
* @param [optional] aFallback
* Value to place after the "=" sign in case the URL scheme is not
* allowed. If unspecified, an empty string is returned when the
* scheme is not allowed.
*
* @return Line to add to the stream, including the final CRLF, or an empty
* string if the validation failed.
*/
_zoneIdKey(aKey, aUrl, aFallback) {
try {
let url;
const uri = lazy.NetUtil.newURI(aUrl);
if (["http", "https", "ftp"].includes(uri.scheme)) {
url = uri.mutate().setUserPass("").finalize().spec;
} else if (aFallback) {
url = aFallback;
} else {
return "";
}
return aKey + "=" + url + "\r\n";
} catch (e) {
return "";
}
},
/**
* Performs platform-specific operations when a download is done.
*
* aParam aDownload
* The Download object.
*
* @return {Promise}
* @resolves When all the operations completed successfully.
* @rejects JavaScript exception if any of the operations failed.
*/
async downloadDone(aDownload) {
// On Windows, we mark any file saved to the NTFS file system as coming
// from the Internet security zone unless Group Policy disables the
// feature. We do this by writing to the "Zone.Identifier" Alternate
// Data Stream directly, because the Save method of the
// IAttachmentExecute interface would trigger operations that may cause
// the application to hang, or other performance issues.
// The stream created in this way is forward-compatible with all the
// current and future versions of Windows.
if (AppConstants.platform == "win" && this._shouldSaveZoneInformation()) {
let zone;
try {
zone = lazy.gDownloadPlatform.mapUrlToZone(aDownload.source.url);
} catch (e) {
// Default to Internet Zone if mapUrlToZone failed for
// whatever reason.
zone = Ci.mozIDownloadPlatform.ZONE_INTERNET;
}
// Don't write zone IDs for Local, Intranet, or Trusted sites
// to match Windows behavior.
if (zone >= Ci.mozIDownloadPlatform.ZONE_INTERNET) {
let path = aDownload.target.path + ":Zone.Identifier";
try {
let zoneId = "[ZoneTransfer]\r\nZoneId=" + zone + "\r\n";
let { url, isPrivate, referrerInfo } = aDownload.source;
if (!isPrivate) {
let referrer = referrerInfo
? referrerInfo.computedReferrerSpec
: "";
zoneId +=
this._zoneIdKey("ReferrerUrl", referrer) +
this._zoneIdKey("HostUrl", url, "about:internet");
}
await IOUtils.writeUTF8(
PathUtils.toExtendedWindowsPath(path),
zoneId
);
} catch (ex) {
// If writing to the file fails, we ignore the error and continue.
if (!DOMException.isInstance(ex)) {
console.error(ex);
}
}
}
}
// The file with the partially downloaded data has restrictive permissions
// that don't allow other users on the system to access it. Now that the
// download is completed, we need to adjust permissions based on whether
// this is a permanently downloaded file or a temporary download to be
// opened read-only with an external application.
try {
let isTemporaryDownload =
aDownload.launchWhenSucceeded && aDownload.source.isPrivate;
// Permanently downloaded files are made accessible by other users on
// this system, while temporary downloads are marked as read-only.
let unixMode;
if (isTemporaryDownload) {
unixMode = 0o400;
} else {
unixMode = 0o666;
}
// On Unix, the umask of the process is respected.
await IOUtils.setPermissions(aDownload.target.path, unixMode);
} catch (ex) {
// We should report errors with making the permissions less restrictive
// or marking the file as read-only on Unix and Mac, but this should not
// prevent the download from completing.
if (!DOMException.isInstance(ex)) {
console.error(ex);
}
}
let aReferrer = null;
if (aDownload.source.referrerInfo) {
aReferrer = aDownload.source.referrerInfo.originalReferrer;
}
await lazy.gDownloadPlatform.downloadDone(
lazy.NetUtil.newURI(aDownload.source.url),
aReferrer,
new lazy.FileUtils.File(aDownload.target.path),
aDownload.contentType,
aDownload.source.isPrivate
);
},
/**
* Decide whether a download of this type, opened from the downloads
* list, should open internally.
*
* @param aMimeType
* The MIME type of the file, as a string
* @param [optional] aExtension
* The file extension, which can match instead of the MIME type.
*/
shouldViewDownloadInternally() {
// Refuse all files by default, this is meant to be replaced with a check
// for specific types via Integration.downloads.register().
return false;
},
/**
* Launches a file represented by the target of a download. This can
* open the file with the default application for the target MIME type
* or file extension, or with a custom application if
* aDownload.launcherPath or aDownload.launcherId is set.
*
* @param aDownload
* A Download object that contains the necessary information
* to launch the file. The relevant properties are: the target
* file, the contentType and the custom application chosen
* to launch it.
* @param options.openWhere Optional string indicating how to open when handling
* download by opening the target file URI.
* One of "window", "tab", "tabshifted"
* @param options.useSystemDefault
* Optional value indicating how to handle launching this download,
* this time only. Will override the associated mimeInfo.preferredAction
*
* @return {Promise}
* @resolves When the instruction to launch the file has been
* successfully given to the operating system. Note that
* the OS might still take a while until the file is actually
* launched.
* @rejects JavaScript exception if there was an error trying to launch
* the file.
*/
async launchDownload(aDownload, { openWhere, useSystemDefault = null }) {
let file = new lazy.FileUtils.File(aDownload.target.path);
// In case of a double extension, like ".tar.gz", we only
// consider the last one, because the MIME service cannot
// handle multiple extensions.
let fileExtension = null,
mimeInfo = null;
let match = file.leafName.match(/\.([^.]+)$/);
if (match) {
fileExtension = match[1];
}
let isWindowsExe =
AppConstants.platform == "win" &&
fileExtension &&
fileExtension.toLowerCase() == "exe";
let isExemptExecutableExtension =
Services.policies.isExemptExecutableExtension(
aDownload.source.url,
fileExtension
);
// Ask for confirmation if the file is executable, except for .exe on
// Windows where the operating system will show the prompt based on the
// security zone. We do this here, instead of letting the caller handle
// the prompt separately in the user interface layer, for two reasons. The
// first is because of its security nature, so that add-ons cannot forget
// to do this check. The second is that the system-level security prompt
// would be displayed at launch time in any case.
// We allow policy to override this behavior for file extensions on specific domains.
if (
file.isExecutable() &&
!isWindowsExe &&
!isExemptExecutableExtension &&
!(await this.confirmLaunchExecutable(file.path))
) {
return;
}
try {
// The MIME service might throw if contentType == "" and it can't find
// a MIME type for the given extension, so we'll treat this case as
// an unknown mimetype.
mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
aDownload.contentType,
fileExtension
);
} catch (e) {}
if (aDownload.launcherPath || aDownload.launcherId) {
if (!mimeInfo) {
// This should not happen on normal circumstances because launcherPath
// is only set when we had an instance of nsIMIMEInfo to retrieve
// the custom application chosen by the user.
throw new Error(
"Unable to create nsIMIMEInfo to launch a custom application"
);
}
let localHandlerApp = null;
if (aDownload.launcherId) {
if (!Cc["@mozilla.org/gio-service;1"]) {
throw new Error(
"The launcherId is set but missing gio-service to create nsGIOHandlerApp"
);
}
localHandlerApp = Cc["@mozilla.org/gio-service;1"]
.getService(Ci.nsIGIOService)
.createHandlerAppFromAppId(aDownload.launcherId);
} else {
// Custom application chosen
localHandlerApp = Cc[
"@mozilla.org/uriloader/local-handler-app;1"
].createInstance(Ci.nsILocalHandlerApp);
localHandlerApp.executable = new lazy.FileUtils.File(
aDownload.launcherPath
);
}
mimeInfo.preferredApplicationHandler = localHandlerApp;
mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
this.launchFile(file, mimeInfo);
// After an attempt has been made to launch the download, clear the
// launchWhenSucceeded bit so future attempts to open the download can go
// through Firefox when possible.
aDownload.launchWhenSucceeded = false;
return;
}
if (!useSystemDefault && mimeInfo) {
useSystemDefault = mimeInfo.preferredAction == mimeInfo.useSystemDefault;
}
if (!useSystemDefault) {
// No explicit instruction was passed to launch this download using the default system viewer.
if (
aDownload.handleInternally ||
(mimeInfo &&
this.shouldViewDownloadInternally(mimeInfo.type, fileExtension) &&
!mimeInfo.alwaysAskBeforeHandling &&
(mimeInfo.preferredAction === Ci.nsIHandlerInfo.handleInternally ||
(["image/svg+xml", "text/xml", "application/xml"].includes(
mimeInfo.type
) &&
mimeInfo.preferredAction === Ci.nsIHandlerInfo.saveToDisk)) &&
!aDownload.launchWhenSucceeded)
) {
lazy.DownloadUIHelper.loadFileIn(file, {
browsingContextId: aDownload.source.browsingContextId,
isPrivate: aDownload.source.isPrivate,
openWhere,
userContextId: aDownload.source.userContextId,
});
return;
}
}
// An attempt will now be made to launch the download, clear the
// launchWhenSucceeded bit so future attempts to open the download can go
// through Firefox when possible.
aDownload.launchWhenSucceeded = false;
// When a file has no extension, and there's an executable file with the
// same name in the same folder, Windows shell can get confused.
// For this reason we show the file in the containing folder instead of
// trying to open it.
// We also don't trust mimeinfo, it could be a type we can forward to a
// system handler, but it could also be an executable type, and we
// don't have an exhaustive list with all of them.
if (!fileExtension && AppConstants.platform == "win") {
// We can't check for the existance of a same-name file with every
// possible executable extension, so this is a catch-all.
this.showContainingDirectory(aDownload.target.path);
return;
}
// No custom application chosen, let's launch the file with the default
// handler. First, let's try to launch it through the MIME service.
if (mimeInfo) {
mimeInfo.preferredAction = Ci.nsIMIMEInfo.useSystemDefault;
try {
this.launchFile(file, mimeInfo);
return;
} catch (ex) {}
}
// If it didn't work or if there was no MIME info available,
// let's try to directly launch the file.
try {
this.launchFile(file);
return;
} catch (ex) {}
// If our previous attempts failed, try sending it through
// the system's external "file:" URL handler.
lazy.gExternalProtocolService.loadURI(
lazy.NetUtil.newURI(file),
Services.scriptSecurityManager.getSystemPrincipal()
);
},
/**
* Asks for confirmation for launching the specified executable file. This
* can be overridden by regression tests to avoid the interactive prompt.
*/
async confirmLaunchExecutable(path) {
// We don't anchor the prompt to a specific window intentionally, not
// only because this is the same behavior as the system-level prompt,
// but also because the most recently active window is the right choice
// in basically all cases.
return lazy.DownloadUIHelper.getPrompter().confirmLaunchExecutable(path);
},
/**
* Launches the specified file, unless overridden by regression tests.
* @note Always use launchDownload() from the outside of this module, it is
* both more powerful and safer.
*/
launchFile(file, mimeInfo) {
if (mimeInfo) {
mimeInfo.launchWithFile(file);
} else {
file.launch();
}
},
/**
* Shows the containing folder of a file.
*
* @param aFilePath
* The path to the file.
*
* @return {Promise}
* @resolves When the instruction to open the containing folder has been
* successfully given to the operating system. Note that
* the OS might still take a while until the folder is actually
* opened.
* @rejects JavaScript exception if there was an error trying to open
* the containing folder.
*/
async showContainingDirectory(aFilePath) {
let file = new lazy.FileUtils.File(aFilePath);
try {
// Show the directory containing the file and select the file.
file.reveal();
return;
} catch (ex) {}
// If reveal fails for some reason (e.g., it's not implemented on unix
// or the file doesn't exist), try using the parent if we have it.
let parent = file.parent;
if (!parent) {
throw new Error(
"Unexpected reference to a top-level directory instead of a file"
);
}
try {
// Open the parent directory to show where the file should be.
parent.launch();
return;
} catch (ex) {}
// If launch also fails (probably because it's not implemented), let
// the OS handler try to open the parent.
lazy.gExternalProtocolService.loadURI(
lazy.NetUtil.newURI(parent),
Services.scriptSecurityManager.getSystemPrincipal()
);
},
/**
* Calls the directory service, create a downloads directory and returns an
* nsIFile for the downloads directory.
*
* @return {Promise}
* @resolves The directory string path.
*/
_createDownloadsDirectory(aName) {
// We read the name of the directory from the list of translated strings
// that is kept by the UI helper module, even if this string is not strictly
// displayed in the user interface.
let directoryPath = PathUtils.join(
this._getDirectory(aName),
lazy.stringBundle.GetStringFromName("downloadsFolder")
);
// Create the Downloads folder and ignore if it already exists.
return IOUtils.makeDirectory(directoryPath, {
createAncestors: false,
}).then(() => directoryPath);
},
/**
* Returns the string path for the given directory service location name. This
* can be overridden by regression tests to return the path of the system
* temporary directory in all cases.
*/
_getDirectory(name) {
return Services.dirsvc.get(name, Ci.nsIFile).path;
},
/**
* Initializes the DownloadSpamProtection instance.
* This is used to observe and group multiple automatic downloads.
*/
_initializeDownloadSpamProtection() {
if (!this.downloadSpamProtection) {
this.downloadSpamProtection = new lazy.DownloadSpamProtection();
}
},
/**
* Register the downloads interruption observers.
*
* @param aList
* The public or private downloads list.
* @param aIsPrivate
* True if the list is private, false otherwise.
*
* @return {Promise}
* @resolves When the views and observers are added.
*/
addListObservers(aList, aIsPrivate) {
DownloadObserver.registerView(aList, aIsPrivate);
if (!DownloadObserver.observersAdded) {
DownloadObserver.observersAdded = true;
for (let topic of kObserverTopics) {
Services.obs.addObserver(DownloadObserver, topic);
}
}
return Promise.resolve();
},
/**
* Force a save on _store if it exists. Used to ensure downloads do not
* persist after being sanitized on Android.
*
* @return {Promise}
* @resolves When _store.save() completes.
*/
forceSave() {
if (this._store) {
return this._store.save();
}
return Promise.resolve();
},
};
var DownloadObserver = {
/**
* Flag to determine if the observers have been added previously.
*/
observersAdded: false,
/**
* Timer used to delay restarting canceled downloads upon waking and returning
* online.
*/
_wakeTimer: null,
/**
* Set that contains the in progress publics downloads.
* It's kept updated when a public download is added, removed or changes its
* properties.
*/
_publicInProgressDownloads: new Set(),
/**
* Set that contains the in progress private downloads.
* It's kept updated when a private download is added, removed or changes its
* properties.
*/
_privateInProgressDownloads: new Set(),
/**
* Set that contains the downloads that have been canceled when going offline
* or to sleep. These are started again when returning online or waking. This
* list is not persisted so when exiting and restarting, the downloads will not
* be started again.
*/
_canceledOfflineDownloads: new Set(),
/**
* Registers a view that updates the corresponding downloads state set, based
* on the aIsPrivate argument. The set is updated when a download is added,
* removed or changes its properties.
*
* @param aList
* The public or private downloads list.
* @param aIsPrivate
* True if the list is private, false otherwise.
*/
registerView: function DO_registerView(aList, aIsPrivate) {
let downloadsSet = aIsPrivate
? this._privateInProgressDownloads
: this._publicInProgressDownloads;
let downloadsView = {
onDownloadAdded: aDownload => {
if (!aDownload.stopped) {
downloadsSet.add(aDownload);
}
},
onDownloadChanged: aDownload => {
if (aDownload.stopped) {
downloadsSet.delete(aDownload);
} else {
downloadsSet.add(aDownload);
}
},
onDownloadRemoved: aDownload => {
downloadsSet.delete(aDownload);
// The download must also be removed from the canceled when offline set.
this._canceledOfflineDownloads.delete(aDownload);
},
};
// We register the view asynchronously.
aList.addView(downloadsView).catch(console.error);
},
/**
* Wrapper that handles the test mode before calling the prompt that display
* a warning message box that informs that there are active downloads,
* and asks whether the user wants to cancel them or not.
*
* @param aCancel
* The observer notification subject.
* @param aDownloadsCount
* The current downloads count.
* @param aPrompter
* The prompter object that shows the confirm dialog.
* @param aPromptType
* The type of prompt notification depending on the observer.
*/
_confirmCancelDownloads: function DO_confirmCancelDownload(
aCancel,
aDownloadsCount,
aPromptType
) {
// Handle test mode
if (lazy.gCombinedDownloadIntegration._testPromptDownloads) {
lazy.gCombinedDownloadIntegration._testPromptDownloads = aDownloadsCount;
return;
}
if (!aDownloadsCount) {
return;
}
// If user has already dismissed the request, then do nothing.
if (aCancel instanceof Ci.nsISupportsPRBool && aCancel.data) {
return;
}
let prompter = lazy.DownloadUIHelper.getPrompter();
aCancel.data = prompter.confirmCancelDownloads(
aDownloadsCount,
prompter[aPromptType]
);
},
/**
* Resume all downloads that were paused when going offline, used when waking
* from sleep or returning from being offline.
*/
_resumeOfflineDownloads: function DO_resumeOfflineDownloads() {
this._wakeTimer = null;
for (let download of this._canceledOfflineDownloads) {
download.start().catch(() => {});
}
this._canceledOfflineDownloads.clear();
},
// nsIObserver
observe: function DO_observe(aSubject, aTopic, aData) {
let downloadsCount;
switch (aTopic) {
case "quit-application-requested":
downloadsCount =
this._publicInProgressDownloads.size +
this._privateInProgressDownloads.size;
this._confirmCancelDownloads(aSubject, downloadsCount, "ON_QUIT");
break;
case "offline-requested":
downloadsCount =
this._publicInProgressDownloads.size +
this._privateInProgressDownloads.size;
this._confirmCancelDownloads(aSubject, downloadsCount, "ON_OFFLINE");
break;
case "last-pb-context-exiting":
downloadsCount = this._privateInProgressDownloads.size;
this._confirmCancelDownloads(
aSubject,
downloadsCount,
"ON_LEAVE_PRIVATE_BROWSING"
);
break;
case "last-pb-context-exited":
let promise = (async function () {
let list = await Downloads.getList(Downloads.PRIVATE);
let downloads = await list.getAll();
// We can remove the downloads and finalize them in parallel.
for (let download of downloads) {
list.remove(download).catch(console.error);
download.finalize(true).catch(console.error);
}
})();
// Handle test mode
if (lazy.gCombinedDownloadIntegration._testResolveClearPrivateList) {
lazy.gCombinedDownloadIntegration._testResolveClearPrivateList(
promise
);
} else {
promise.catch(ex => console.error(ex));
}
break;
case "sleep_notification":
case "suspend_process_notification":
case "network:offline-about-to-go-offline":
// Ignore shutdown notification so aborted downloads will be restarted
// on the next session.
if (
Services.startup.isInOrBeyondShutdownPhase(
Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
)
) {
break;
}
for (let download of this._publicInProgressDownloads) {
download.cancel();
this._canceledOfflineDownloads.add(download);
}
for (let download of this._privateInProgressDownloads) {
download.cancel();
this._canceledOfflineDownloads.add(download);
}
break;
case "wake_notification":
case "resume_process_notification":
let wakeDelay = Services.prefs.getIntPref(
"browser.download.manager.resumeOnWakeDelay",
10000
);
if (wakeDelay >= 0) {
this._wakeTimer = new Timer(
this._resumeOfflineDownloads.bind(this),
wakeDelay,
Ci.nsITimer.TYPE_ONE_SHOT
);
}
break;
case "network:offline-status-changed":
if (aData == "online") {
this._resumeOfflineDownloads();
}
break;
// We need to unregister observers explicitly before we reach the
// "xpcom-shutdown" phase, otherwise observers may be notified when some
// required services are not available anymore. We can't unregister
// observers on "quit-application", because this module is also loaded
// during "make package" automation, and the quit notification is not sent
case "xpcom-will-shutdown":
for (let topic of kObserverTopics) {
Services.obs.removeObserver(this, topic);
}
break;
case "blocked-automatic-download":
if (AppConstants.MOZ_BUILD_APP == "browser") {
DownloadIntegration._initializeDownloadSpamProtection();
DownloadIntegration.downloadSpamProtection.update(
aData,
aSubject.topChromeWindow
);
}
break;
}
},
QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
};
/**
* Registers a Places observer so that operations on download history are
* reflected on the provided list of downloads.
*
* You do not need to keep a reference to this object in order to keep it alive,
* because the history service already keeps a strong reference to it.
*
* @param aList
* DownloadList object linked to this observer.
*/
var DownloadHistoryObserver = function (aList) {
this._list = aList;
const placesObserver = new PlacesWeakCallbackWrapper(
this.handlePlacesEvents.bind(this)
);
PlacesObservers.addListener(
["history-cleared", "page-removed"],
placesObserver
);
};
DownloadHistoryObserver.prototype = {
/**
* DownloadList object linked to this observer.
*/
_list: null,
handlePlacesEvents(events) {
for (const event of events) {
switch (event.type) {
case "history-cleared": {
this._list.removeFinished();
break;
}
case "page-removed": {
if (event.isRemovedFromStore) {
this._list.removeFinished(
download => event.url === download.source.url
);
}
break;
}
}
}
},
};
/**
* This view can be added to a DownloadList object to trigger a save operation
* in the given DownloadStore object when a relevant change occurs. You should
* call the "initialize" method in order to register the view and load the
* current state from disk.
*
* You do not need to keep a reference to this object in order to keep it alive,
* because the DownloadList object already keeps a strong reference to it.
*
* @param aList
* The DownloadList object on which the view should be registered.
* @param aStore
* The DownloadStore object used for saving.
*/
var DownloadAutoSaveView = function (aList, aStore) {
this._list = aList;
this._store = aStore;
this._downloadsMap = new Map();
this._writer = new lazy.DeferredTask(() => this._store.save(), kSaveDelayMs);
lazy.AsyncShutdown.profileBeforeChange.addBlocker(
"DownloadAutoSaveView: writing data",
() => this._writer.finalize()
);
};
DownloadAutoSaveView.prototype = {
/**
* DownloadList object linked to this view.
*/
_list: null,
/**
* The DownloadStore object used for saving.
*/
_store: null,
/**
* True when the initial state of the downloads has been loaded.
*/
_initialized: false,
/**
* Registers the view and loads the current state from disk.
*
* @return {Promise}
* @resolves When the view has been registered.
* @rejects JavaScript exception.
*/
initialize() {
// We set _initialized to true after adding the view, so that
// onDownloadAdded doesn't cause a save to occur.
return this._list.addView(this).then(() => (this._initialized = true));
},
/**
* This map contains only Download objects that should be saved to disk, and
* associates them with the result of their getSerializationHash function, for
* the purpose of detecting changes to the relevant properties.
*/
_downloadsMap: null,
/**
* DeferredTask for the save operation.
*/
_writer: null,
/**
* Called when the list of downloads changed, this triggers the asynchronous
* serialization of the list of downloads.
*/
saveSoon() {
this._writer.arm();
},
// DownloadList callback
onDownloadAdded(aDownload) {
if (lazy.gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) {
this._downloadsMap.set(aDownload, aDownload.getSerializationHash());
if (this._initialized) {
this.saveSoon();
}
}
},
// DownloadList callback
onDownloadChanged(aDownload) {
if (!lazy.gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) {
if (this._downloadsMap.has(aDownload)) {
this._downloadsMap.delete(aDownload);
this.saveSoon();
}
return;
}
let hash = aDownload.getSerializationHash();
if (this._downloadsMap.get(aDownload) != hash) {
this._downloadsMap.set(aDownload, hash);
this.saveSoon();
}
},
// DownloadList callback
onDownloadRemoved(aDownload) {
if (this._downloadsMap.has(aDownload)) {
this._downloadsMap.delete(aDownload);
this.saveSoon();
}
},
};