Mypal/toolkit/components/jsdownloads/src/DownloadIntegration.jsm

1304 lines
46 KiB
JavaScript

/* -*- 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/. */
/**
* Provides functions to integrate with the host application, handling for
* example the global prompts on shutdown.
*/
"use strict";
this.EXPORTED_SYMBOLS = [
"DownloadIntegration",
];
////////////////////////////////////////////////////////////////////////////////
//// Globals
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/Integration.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
"resource://gre/modules/AsyncShutdown.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask",
"resource://gre/modules/DeferredTask.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
"resource://gre/modules/Downloads.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadStore",
"resource://gre/modules/DownloadStore.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadImport",
"resource://gre/modules/DownloadImport.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadUIHelper",
"resource://gre/modules/DownloadUIHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
#ifdef MOZ_PLACES
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
#endif
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Services",
"resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "gDownloadPlatform",
"@mozilla.org/toolkit/download-platform;1",
"mozIDownloadPlatform");
XPCOMUtils.defineLazyServiceGetter(this, "gEnvironment",
"@mozilla.org/process/environment;1",
"nsIEnvironment");
XPCOMUtils.defineLazyServiceGetter(this, "gMIMEService",
"@mozilla.org/mime;1",
"nsIMIMEService");
XPCOMUtils.defineLazyServiceGetter(this, "gExternalProtocolService",
"@mozilla.org/uriloader/external-protocol-service;1",
"nsIExternalProtocolService");
#ifdef MOZ_WIDGET_ANDROID
XPCOMUtils.defineLazyModuleGetter(this, "RuntimePermissions",
"resource://gre/modules/RuntimePermissions.jsm");
#endif
XPCOMUtils.defineLazyGetter(this, "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(this, "gApplicationReputationService",
"@mozilla.org/downloads/application-reputation-service;1",
Ci.nsIApplicationReputationService);
XPCOMUtils.defineLazyServiceGetter(this, "volumeService",
"@mozilla.org/telephony/volume-service;1",
"nsIVolumeService");
// We have to use the gCombinedDownloadIntegration identifier because, in this
// module only, the DownloadIntegration identifier refers to the base version.
Integration.downloads.defineModuleGetter(this, "gCombinedDownloadIntegration",
"resource://gre/modules/DownloadIntegration.jsm",
"DownloadIntegration");
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;
/**
* This pref indicates if we have already imported (or attempted to import)
* the downloads database from the previous SQLite storage.
*/
const kPrefImportedFromSqlite = "browser.download.importedFromSqlite";
/**
* 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",
];
/**
* 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,
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadIntegration
/**
* Provides functions to integrate with the host application, handling for
* example the global prompts on shutdown.
*/
this.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.
*/
initializePublicDownloadList: Task.async(function* (list) {
try {
yield this.loadPublicDownloadListFromStore(list);
} catch (ex) {
Cu.reportError(ex);
}
// 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.
*/
loadPublicDownloadListFromStore: Task.async(function* (list) {
if (this._store) {
throw new Error("Initialization may be performed only once.");
}
this._store = new DownloadStore(list, OS.Path.join(
OS.Constants.Path.profileDir,
"downloads.json"));
this._store.onsaveitem = this.shouldPersistDownload.bind(this);
try {
if (this._importedFromSqlite) {
yield this._store.load();
} else {
let sqliteDBpath = OS.Path.join(OS.Constants.Path.profileDir,
"downloads.sqlite");
if (yield OS.File.exists(sqliteDBpath)) {
let sqliteImport = new DownloadImport(list, sqliteDBpath);
yield sqliteImport.import();
let importCount = (yield list.getAll()).length;
if (importCount > 0) {
try {
yield this._store.save();
} catch (ex) { }
}
// No need to wait for the file removal.
OS.File.remove(sqliteDBpath).then(null, Cu.reportError);
}
Services.prefs.setBoolPref(kPrefImportedFromSqlite, true);
// Don't even report error here because this file is pre Firefox 3
// and most likely doesn't exist.
OS.File.remove(OS.Path.join(OS.Constants.Path.profileDir,
"downloads.rdf")).catch(() => {});
}
} catch (ex) {
Cu.reportError(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.
yield 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.
if (!aDownload.stopped || aDownload.hasPartialData ||
aDownload.hasBlockedData) {
return true;
}
#if defined(MOZ_WIDGET_ANDROID)
// On Android we store all history.
return true;
#else
// 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 false;
#endif
},
/**
* Returns the system downloads directory asynchronously.
*
* @return {Promise}
* @resolves The downloads directory string path.
*/
getSystemDownloadsDirectory: Task.async(function* () {
if (this._downloadsDirectory) {
return this._downloadsDirectory;
}
let directoryPath = null;
#ifdef XP_MACOSX
directoryPath = this._getDirectory("DfltDwnld");
#elifdef XP_WIN
// For XP/2K, use My Documents/Downloads. Other version uses
// the default Downloads directory.
let version = parseFloat(Services.sysinfo.getProperty("version"));
if (version < 6) {
directoryPath = yield this._createDownloadsDirectory("Pers");
} else {
directoryPath = this._getDirectory("DfltDwnld");
}
#elifdef XP_UNIX
#ifdef MOZ_WIDGET_ANDROID
// Android doesn't have a $HOME directory, and by default we only have
// write access to /data/data/org.mozilla.{$APP} and /sdcard
directoryPath = gEnvironment.get("DOWNLOADS_DIRECTORY");
if (!directoryPath) {
throw new Components.Exception("DOWNLOADS_DIRECTORY is not set.",
Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH);
}
#else
// For Linux, use XDG download dir, with a fallback to Home/Downloads
// if the XDG user dirs are disabled.
try {
directoryPath = this._getDirectory("DfltDwnld");
} catch(e) {
directoryPath = yield this._createDownloadsDirectory("Home");
}
#endif
#else
directoryPath = yield this._createDownloadsDirectory("Home");
#endif
this._downloadsDirectory = directoryPath;
return this._downloadsDirectory;
}),
_downloadsDirectory: null,
/**
* Returns the user downloads directory asynchronously.
*
* @return {Promise}
* @resolves The downloads directory string path.
*/
getPreferredDownloadsDirectory: Task.async(function* () {
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 = yield this.getSystemDownloadsDirectory();
break;
case 2: // Custom
try {
let directory = Services.prefs.getComplexValue("browser.download.dir",
Ci.nsIFile);
directoryPath = directory.path;
yield OS.File.makeDir(directoryPath, { ignoreExisting: true });
} catch(ex) {
// Either the preference isn't set or the directory cannot be created.
directoryPath = yield this.getSystemDownloadsDirectory();
}
break;
default:
directoryPath = yield this.getSystemDownloadsDirectory();
}
return directoryPath;
}),
/**
* Returns the temporary downloads directory asynchronously.
*
* @return {Promise}
* @resolves The downloads directory string path.
*/
getTemporaryDownloadsDirectory: Task.async(function* () {
let directoryPath = null;
#ifdef XP_MACOSX
directoryPath = yield this.getPreferredDownloadsDirectory();
#elifdef MOZ_WIDGET_ANDROID
directoryPath = yield this.getSystemDownloadsDirectory();
#else
directoryPath = this._getDirectory("TmpD");
#endif
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 = gParentalControlsService &&
gParentalControlsService.parentalControlsEnabled;
let shouldBlock = isEnabled &&
gParentalControlsService.blockFileDownloadsEnabled;
// Log the event if required by parental controls settings.
if (isEnabled && gParentalControlsService.loggingEnabled) {
gParentalControlsService.log(gParentalControlsService.ePCLog_FileDownload,
shouldBlock,
NetUtil.newURI(aDownload.source.url), null);
}
return Promise.resolve(shouldBlock);
},
/**
* Checks to determine whether to block downloads for not granted runtime permissions.
*
* @return {Promise}
* @resolves The boolean indicates to block downloads or not.
*/
shouldBlockForRuntimePermissions() {
#ifdef MOZ_WIDGET_ANDROID
return RuntimePermissions.waitForPermissions(RuntimePermissions.WRITE_EXTERNAL_STORAGE)
.then(permissionGranted => !permissionGranted);
#else
return Promise.resolve(false);
#endif
},
/**
* 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) {
#ifndef MOZ_URL_CLASSIFIER
return Promise.resolve({
shouldBlock: false,
verdict: "",
});
#else
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: "",
});
}
let deferred = Promise.defer();
let aReferrer = null;
if (aDownload.source.referrer) {
aReferrer = NetUtil.newURI(aDownload.source.referrer);
}
gApplicationReputationService.queryReputation({
sourceURI: NetUtil.newURI(aDownload.source.url),
referrerURI: aReferrer,
fileSize: aDownload.currentBytes,
sha256Hash: hash,
suggestedFileName: OS.Path.basename(aDownload.target.path),
signatureInfo: sigInfo,
redirects: channelRedirects },
function onComplete(aShouldBlock, aRv, aVerdict) {
deferred.resolve({
shouldBlock: aShouldBlock,
verdict: (aShouldBlock && kVerdictMap[aVerdict]) || "",
});
});
return deferred.promise;
#endif
},
#ifdef XP_WIN
/**
* Checks whether downloaded files should be marked as coming from
* Internet Zone.
*
* @return true if files should be marked
*/
_shouldSaveZoneInformation() {
let zonePref = 2;
try {
zonePref = Services.prefs.getIntPref("browser.download.saveZoneInformation");
} catch (ex) {}
switch (zonePref) {
case 0: // Never
return false;
case 1: // Always
return true;
case 2: // System-defined
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;
}
default: // Invalid pref value defaults marking files.
return true;
}
},
#endif
/**
* 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.
*/
downloadDone: Task.async(function* (aDownload) {
#ifdef XP_WIN
// 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 (this._shouldSaveZoneInformation()) {
let zone;
try {
zone = gDownloadPlatform.mapUrlToZone(aDownload.source.url);
} catch (e) {
// Default to Internet Zone if mapUrlToZone failed for
// whatever reason.
zone = Ci.mozIDownloadPlatform.ZONE_INTERNET;
}
try {
// Don't write zone IDs for Local, Intranet, or Trusted sites
// to match Windows behavior.
if (zone >= Ci.mozIDownloadPlatform.ZONE_INTERNET) {
let streamPath = aDownload.target.path + ":Zone.Identifier";
let stream = yield OS.File.open(
streamPath,
{ create: true },
{ winAllowLengthBeyondMaxPathWithCaveats: true }
);
try {
yield stream.write(new TextEncoder().encode("[ZoneTransfer]\r\nZoneId=" + zone + "\r\n"));
} finally {
yield stream.close();
}
}
} catch (ex) {
// If writing to the stream fails, we ignore the error and continue.
// The Windows API error 123 (ERROR_INVALID_NAME) is expected to
// occur when working on a file system that does not support
// Alternate Data Streams, like FAT32, thus we don't report this
// specific error.
if (!(ex instanceof OS.File.Error) || ex.winLastError != 123) {
Cu.reportError(ex);
}
}
}
#endif
// 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 {
// The following logic to determine whether this is a temporary download
// is due to the fact that "deleteTempFileOnExit" is false on Mac, where
// downloads to be opened with external applications are preserved in
// the "Downloads" folder like normal downloads.
let isTemporaryDownload =
aDownload.launchWhenSucceeded && (aDownload.source.isPrivate ||
Services.prefs.getBoolPref("browser.helperApps.deleteTempFileOnExit"));
// Permanently downloaded files are made accessible by other users on
// this system, while temporary downloads are marked as read-only.
let options = {};
if (isTemporaryDownload) {
options.unixMode = 0o400;
options.winAttributes = {readOnly: true};
} else {
options.unixMode = 0o666;
}
// On Unix, the umask of the process is respected.
yield OS.File.setPermissions(aDownload.target.path, options);
} 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.
// The setPermissions API error EPERM is expected to occur when working
// on a file system that does not support file permissions, like FAT32,
// thus we don't report this error.
if (!(ex instanceof OS.File.Error) || ex.unixErrno != OS.Constants.libc.EPERM) {
Cu.reportError(ex);
}
}
let aReferrer = null;
if (aDownload.source.referrer) {
aReferrer = NetUtil.newURI(aDownload.source.referrer);
}
gDownloadPlatform.downloadDone(NetUtil.newURI(aDownload.source.url),
aReferrer,
new FileUtils.File(aDownload.target.path),
aDownload.contentType,
aDownload.source.isPrivate);
}),
/**
* 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 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.
*
* @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.
*/
launchDownload: Task.async(function* (aDownload) {
let file = new 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];
}
#ifdef XP_WIN
let isWindowsExe = fileExtension.toLowerCase() == "exe";
#else
let isWindowsExe = false;
#endif
// 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.
if (file.isExecutable() && !isWindowsExe &&
!(yield 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 = gMIMEService.getFromTypeAndExtension(aDownload.contentType,
fileExtension);
} catch (e) { }
if (aDownload.launcherPath) {
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");
}
// Custom application chosen
let localHandlerApp = Cc["@mozilla.org/uriloader/local-handler-app;1"]
.createInstance(Ci.nsILocalHandlerApp);
localHandlerApp.executable = new FileUtils.File(aDownload.launcherPath);
mimeInfo.preferredApplicationHandler = localHandlerApp;
mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
this.launchFile(file, mimeInfo);
return;
}
#ifdef XP_WIN
// When a file has no extension, and there's an executable file with the
// same name in the same folder, the 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) {
// We can't check for the existence of a same-name file with every
// possible executable extension, so this is a catch-all.
this.showContainingDirectory(aDownload.target.path);
return;
}
#endif
// 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.
gExternalProtocolService.loadUrl(NetUtil.newURI(file));
}),
/**
* Asks for confirmation for launching the specified executable file. This
* can be overridden by regression tests to avoid the interactive prompt.
*/
confirmLaunchExecutable: Task.async(function* (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 yield DownloadUIHelper.getPrompter().confirmLaunchExecutable(path);
}),
/**
* Launches the specified file, unless overridden by regression tests.
*/
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.
*/
showContainingDirectory: Task.async(function* (aFilePath) {
let file = new 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.
gExternalProtocolService.loadUrl(NetUtil.newURI(parent));
}),
/**
* 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 = OS.Path.join(this._getDirectory(aName),
DownloadUIHelper.strings.downloadsFolder);
// Create the Downloads folder and ignore if it already exists.
return OS.File.makeDir(directoryPath, { ignoreExisting: true })
.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;
},
/**
* 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, false);
}
}
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();
},
/**
* Checks if we have already imported (or attempted to import)
* the downloads database from the previous SQLite storage.
*
* @return boolean True if we the previous DB was imported.
*/
get _importedFromSqlite() {
try {
return Services.prefs.getBoolPref(kPrefImportedFromSqlite);
} catch (ex) {
return false;
}
},
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadObserver
this.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).then(null, Cu.reportError);
},
/**
* 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, aPrompter, aPromptType) {
// If user has already dismissed the request, then do nothing.
if ((aCancel instanceof Ci.nsISupportsPRBool) && aCancel.data) {
return;
}
// Handle test mode
if (gCombinedDownloadIntegration._testPromptDownloads) {
gCombinedDownloadIntegration._testPromptDownloads = aDownloadsCount;
return;
}
aCancel.data = aPrompter.confirmCancelDownloads(aDownloadsCount, 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(() => {});
}
},
////////////////////////////////////////////////////////////////////////////
//// nsIObserver
observe: function DO_observe(aSubject, aTopic, aData) {
let downloadsCount;
let p = DownloadUIHelper.getPrompter();
switch (aTopic) {
case "quit-application-requested":
downloadsCount = this._publicInProgressDownloads.size +
this._privateInProgressDownloads.size;
this._confirmCancelDownloads(aSubject, downloadsCount, p, p.ON_QUIT);
break;
case "offline-requested":
downloadsCount = this._publicInProgressDownloads.size +
this._privateInProgressDownloads.size;
this._confirmCancelDownloads(aSubject, downloadsCount, p, p.ON_OFFLINE);
break;
case "last-pb-context-exiting":
downloadsCount = this._privateInProgressDownloads.size;
this._confirmCancelDownloads(aSubject, downloadsCount, p,
p.ON_LEAVE_PRIVATE_BROWSING);
break;
case "last-pb-context-exited":
let promise = Task.spawn(function() {
let list = yield Downloads.getList(Downloads.PRIVATE);
let downloads = yield list.getAll();
// We can remove the downloads and finalize them in parallel.
for (let download of downloads) {
list.remove(download).then(null, Cu.reportError);
download.finalize(true).then(null, Cu.reportError);
}
});
// Handle test mode
if (gCombinedDownloadIntegration._testResolveClearPrivateList) {
gCombinedDownloadIntegration._testResolveClearPrivateList(promise);
} else {
promise.catch(ex => Cu.reportError(ex));
}
break;
case "sleep_notification":
case "suspend_process_notification":
case "network:offline-about-to-go-offline":
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 = 10000;
try {
wakeDelay = Services.prefs.getIntPref("browser.download.manager.resumeOnWakeDelay");
} catch(e) {}
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
// in that execution environment (bug 973637).
case "xpcom-will-shutdown":
for (let topic of kObserverTopics) {
Services.obs.removeObserver(this, topic);
}
break;
}
},
////////////////////////////////////////////////////////////////////////////
//// nsISupports
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver])
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadHistoryObserver
#ifdef MOZ_PLACES
/**
* 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.
*/
this.DownloadHistoryObserver = function (aList)
{
this._list = aList;
PlacesUtils.history.addObserver(this, false);
}
this.DownloadHistoryObserver.prototype = {
/**
* DownloadList object linked to this observer.
*/
_list: null,
////////////////////////////////////////////////////////////////////////////
//// nsISupports
QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver]),
////////////////////////////////////////////////////////////////////////////
//// nsINavHistoryObserver
onDeleteURI: function DL_onDeleteURI(aURI, aGUID) {
this._list.removeFinished(download => aURI.equals(NetUtil.newURI(
download.source.url)));
},
onClearHistory: function DL_onClearHistory() {
this._list.removeFinished();
},
onTitleChanged: function () {},
onBeginUpdateBatch: function () {},
onEndUpdateBatch: function () {},
onVisit: function () {},
onPageChanged: function () {},
onDeleteVisits: function () {},
};
#else
/**
* Empty implementation when we have no Places support, for example on B2G.
*/
this.DownloadHistoryObserver = function (aList) {}
#endif
////////////////////////////////////////////////////////////////////////////////
//// DownloadAutoSaveView
/**
* 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.
*/
this.DownloadAutoSaveView = function (aList, aStore)
{
this._list = aList;
this._store = aStore;
this._downloadsMap = new Map();
this._writer = new DeferredTask(() => this._store.save(), kSaveDelayMs);
AsyncShutdown.profileBeforeChange.addBlocker("DownloadAutoSaveView: writing data",
() => this._writer.finalize());
}
this.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: function ()
{
// 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: function ()
{
this._writer.arm();
},
//////////////////////////////////////////////////////////////////////////////
//// DownloadList view
onDownloadAdded: function (aDownload)
{
if (gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) {
this._downloadsMap.set(aDownload, aDownload.getSerializationHash());
if (this._initialized) {
this.saveSoon();
}
}
},
onDownloadChanged: function (aDownload)
{
if (!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();
}
},
onDownloadRemoved: function (aDownload)
{
if (this._downloadsMap.has(aDownload)) {
this._downloadsMap.delete(aDownload);
this.saveSoon();
}
},
};