diff options
Diffstat (limited to 'toolkit/components/jsdownloads/src/DownloadIntegration.jsm')
-rw-r--r-- | toolkit/components/jsdownloads/src/DownloadIntegration.jsm | 1273 |
1 files changed, 1273 insertions, 0 deletions
diff --git a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm new file mode 100644 index 000000000..5fed9212a --- /dev/null +++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm @@ -0,0 +1,1273 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * 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; + } +#ifdef MOZ_B2G + // On B2G we keep a few days of history. + let maxTime = Date.now() - + Services.prefs.getIntPref("dom.downloads.max_retention_days") * 24 * 60 * 60 * 1000; + return aDownload.startTime > maxTime; +#elif 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 = 1; + + try { + prefValue = Services.prefs.getIntPref("browser.download.folderList"); + } catch(e) {} + + 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(); +#elifdef MOZ_WIDGET_GONK + 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) { + 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; + }, + +#ifdef XP_WIN + /** + * 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; + } + }, +#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); + +#ifndef XP_WIN + // Ask for confirmation if the file is executable, except 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() && + !(yield this.confirmLaunchExecutable(file.path))) { + return; + } +#endif + + // 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]; + } + + 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; + } + + // 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(); + } + }, +}; |