summaryrefslogtreecommitdiffstats
path: root/toolkit/content/contentAreaUtils.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/contentAreaUtils.js')
-rw-r--r--toolkit/content/contentAreaUtils.js1246
1 files changed, 1246 insertions, 0 deletions
diff --git a/toolkit/content/contentAreaUtils.js b/toolkit/content/contentAreaUtils.js
new file mode 100644
index 0000000000..5371b1a45b
--- /dev/null
+++ b/toolkit/content/contentAreaUtils.js
@@ -0,0 +1,1246 @@
+/* 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/. */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ Deprecated: "resource://gre/modules/Deprecated.sys.mjs",
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ DownloadLastDir: "resource://gre/modules/DownloadLastDir.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+var ContentAreaUtils = {
+ get stringBundle() {
+ delete this.stringBundle;
+ return (this.stringBundle = Services.strings.createBundle(
+ "chrome://global/locale/contentAreaCommands.properties"
+ ));
+ },
+};
+
+function urlSecurityCheck(
+ aURL,
+ aPrincipal,
+ aFlags = Services.scriptSecurityManager
+) {
+ if (aURL instanceof Ci.nsIURI) {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ aPrincipal,
+ aURL,
+ aFlags
+ );
+ } else {
+ Services.scriptSecurityManager.checkLoadURIStrWithPrincipal(
+ aPrincipal,
+ aURL,
+ aFlags
+ );
+ }
+}
+
+// Clientele: (Make sure you don't break any of these)
+// - File -> Save Page/Frame As...
+// - Context -> Save Page/Frame As...
+// - Context -> Save Link As...
+// - Alt-Click links in web pages
+// - Alt-Click links in the UI
+//
+// Try saving each of these types:
+// - A complete webpage using File->Save Page As, and Context->Save Page As
+// - A webpage as HTML only using the above methods
+// - A webpage as Text only using the above methods
+// - An image with an extension (e.g. .jpg) in its file name, using
+// Context->Save Image As...
+// - An image without an extension (e.g. a banner ad on cnn.com) using
+// the above method.
+// - A linked document using Save Link As...
+// - A linked document using Alt-click Save Link As...
+//
+function saveURL(
+ aURL,
+ aOriginalURL,
+ aFileName,
+ aFilePickerTitleKey,
+ aShouldBypassCache,
+ aSkipPrompt,
+ aReferrerInfo,
+ aCookieJarSettings,
+ aSourceDocument,
+ aIsContentWindowPrivate,
+ aPrincipal
+) {
+ internalSave(
+ aURL,
+ aOriginalURL,
+ null,
+ aFileName,
+ null,
+ null,
+ aShouldBypassCache,
+ aFilePickerTitleKey,
+ null,
+ aReferrerInfo,
+ aCookieJarSettings,
+ aSourceDocument,
+ aSkipPrompt,
+ null,
+ aIsContentWindowPrivate,
+ aPrincipal
+ );
+}
+
+// Save the current document inside any browser/frame-like element,
+// whether in-process or out-of-process.
+function saveBrowser(aBrowser, aSkipPrompt, aBrowsingContext = null) {
+ if (!aBrowser) {
+ throw new Error("Must have a browser when calling saveBrowser");
+ }
+ let persistable = aBrowser.frameLoader;
+ // PDF.js has its own way to handle saving PDFs since it may need to
+ // generate a new PDF to save modified form data.
+ if (aBrowser.contentPrincipal.spec == "resource://pdf.js/web/viewer.html") {
+ aBrowser.sendMessageToActor("PDFJS:Save", {}, "Pdfjs");
+ return;
+ }
+ let stack = Components.stack.caller;
+ persistable.startPersistence(aBrowsingContext, {
+ onDocumentReady(document) {
+ if (!document || !(document instanceof Ci.nsIWebBrowserPersistDocument)) {
+ throw new Error("Must have an nsIWebBrowserPersistDocument!");
+ }
+
+ internalSave(
+ document.documentURI,
+ null, // originalURL
+ document,
+ null, // file name
+ document.contentDisposition,
+ document.contentType,
+ false, // bypass cache
+ null, // file picker title key
+ null, // chosen file data
+ document.referrerInfo,
+ document.cookieJarSettings,
+ document,
+ aSkipPrompt,
+ document.cacheKey
+ );
+ },
+ onError(status) {
+ throw new Components.Exception(
+ "saveBrowser failed asynchronously in startPersistence",
+ status,
+ stack
+ );
+ },
+ });
+}
+
+function DownloadListener(win, transfer) {
+ function makeClosure(name) {
+ return function() {
+ transfer[name].apply(transfer, arguments);
+ };
+ }
+
+ this.window = win;
+
+ // Now... we need to forward all calls to our transfer
+ for (var i in transfer) {
+ if (i != "QueryInterface") {
+ this[i] = makeClosure(i);
+ }
+ }
+}
+
+DownloadListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ ]),
+
+ getInterface: function dl_gi(aIID) {
+ if (aIID.equals(Ci.nsIAuthPrompt) || aIID.equals(Ci.nsIAuthPrompt2)) {
+ var ww = Cc["@mozilla.org/embedcomp/window-watcher;1"].getService(
+ Ci.nsIPromptFactory
+ );
+ return ww.getPrompt(this.window, aIID);
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+};
+
+const kSaveAsType_Complete = 0; // Save document with attached objects.
+XPCOMUtils.defineConstant(this, "kSaveAsType_Complete", 0);
+// const kSaveAsType_URL = 1; // Save document or URL by itself.
+const kSaveAsType_Text = 2; // Save document, converting to plain text.
+XPCOMUtils.defineConstant(this, "kSaveAsType_Text", kSaveAsType_Text);
+
+/**
+ * internalSave: Used when saving a document or URL.
+ *
+ * If aChosenData is null, this method:
+ * - Determines a local target filename to use
+ * - Prompts the user to confirm the destination filename and save mode
+ * (aContentType affects this)
+ * - [Note] This process involves the parameters aURL, aReferrerInfo,
+ * aDocument, aDefaultFileName, aFilePickerTitleKey, and aSkipPrompt.
+ *
+ * If aChosenData is non-null, this method:
+ * - Uses the provided source URI and save file name
+ * - Saves the document as complete DOM if possible (aDocument present and
+ * right aContentType)
+ * - [Note] The parameters aURL, aDefaultFileName, aFilePickerTitleKey, and
+ * aSkipPrompt are ignored.
+ *
+ * In any case, this method:
+ * - Creates a 'Persist' object (which will perform the saving in the
+ * background) and then starts it.
+ * - [Note] This part of the process only involves the parameters aDocument,
+ * aShouldBypassCache and aReferrerInfo. The source, the save name and the
+ * save mode are the ones determined previously.
+ *
+ * @param aURL
+ * The String representation of the URL of the document being saved
+ * @param aOriginalURL
+ * The String representation of the original URL of the document being
+ * saved. It can useful in case aURL is a blob.
+ * @param aDocument
+ * The document to be saved
+ * @param aDefaultFileName
+ * The caller-provided suggested filename if we don't
+ * find a better one
+ * @param aContentDisposition
+ * The caller-provided content-disposition header to use.
+ * @param aContentType
+ * The caller-provided content-type to use
+ * @param aShouldBypassCache
+ * If true, the document will always be refetched from the server
+ * @param aFilePickerTitleKey
+ * Alternate title for the file picker
+ * @param aChosenData
+ * If non-null this contains an instance of object AutoChosen (see below)
+ * which holds pre-determined data so that the user does not need to be
+ * prompted for a target filename.
+ * @param aReferrerInfo
+ * the referrerInfo object to use, or null if no referrer should be sent.
+ * @param aCookieJarSettings
+ * the cookieJarSettings object to use. This will be used for the channel
+ * used to save.
+ * @param aInitiatingDocument [optional]
+ * The document from which the save was initiated.
+ * If this is omitted then aIsContentWindowPrivate has to be provided.
+ * @param aSkipPrompt [optional]
+ * If set to true, we will attempt to save the file to the
+ * default downloads folder without prompting.
+ * @param aCacheKey [optional]
+ * If set will be passed to saveURI. See nsIWebBrowserPersist for
+ * allowed values.
+ * @param aIsContentWindowPrivate [optional]
+ * This parameter is provided when the aInitiatingDocument is not a
+ * real document object. Stores whether aInitiatingDocument.defaultView
+ * was private or not.
+ * @param aPrincipal [optional]
+ * This parameter is provided when neither aDocument nor
+ * aInitiatingDocument is provided. Used to determine what level of
+ * privilege to load the URI with.
+ */
+function internalSave(
+ aURL,
+ aOriginalURL,
+ aDocument,
+ aDefaultFileName,
+ aContentDisposition,
+ aContentType,
+ aShouldBypassCache,
+ aFilePickerTitleKey,
+ aChosenData,
+ aReferrerInfo,
+ aCookieJarSettings,
+ aInitiatingDocument,
+ aSkipPrompt,
+ aCacheKey,
+ aIsContentWindowPrivate,
+ aPrincipal
+) {
+ if (aSkipPrompt == undefined) {
+ aSkipPrompt = false;
+ }
+
+ if (aCacheKey == undefined) {
+ aCacheKey = 0;
+ }
+
+ // Note: aDocument == null when this code is used by save-link-as...
+ var saveMode = GetSaveModeForContentType(aContentType, aDocument);
+
+ var file, sourceURI, saveAsType;
+ let contentPolicyType = Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD;
+ // Find the URI object for aURL and the FileName/Extension to use when saving.
+ // FileName/Extension will be ignored if aChosenData supplied.
+ if (aChosenData) {
+ file = aChosenData.file;
+ sourceURI = aChosenData.uri;
+ saveAsType = kSaveAsType_Complete;
+
+ continueSave();
+ } else {
+ var charset = null;
+ if (aDocument) {
+ charset = aDocument.characterSet;
+ }
+ var fileInfo = new FileInfo(aDefaultFileName);
+ initFileInfo(
+ fileInfo,
+ aURL,
+ charset,
+ aDocument,
+ aContentType,
+ aContentDisposition
+ );
+ sourceURI = fileInfo.uri;
+
+ if (aContentType && aContentType.startsWith("image/")) {
+ contentPolicyType = Ci.nsIContentPolicy.TYPE_IMAGE;
+ }
+ var fpParams = {
+ fpTitleKey: aFilePickerTitleKey,
+ fileInfo,
+ contentType: aContentType,
+ saveMode,
+ saveAsType: kSaveAsType_Complete,
+ file,
+ };
+
+ // Find a URI to use for determining last-downloaded-to directory
+ let relatedURI = aReferrerInfo?.originalReferrer || sourceURI;
+
+ promiseTargetFile(fpParams, aSkipPrompt, relatedURI)
+ .then(aDialogAccepted => {
+ if (!aDialogAccepted) {
+ return;
+ }
+
+ saveAsType = fpParams.saveAsType;
+ file = fpParams.file;
+
+ continueSave();
+ })
+ .catch(Cu.reportError);
+ }
+
+ function continueSave() {
+ // XXX We depend on the following holding true in appendFiltersForContentType():
+ // If we should save as a complete page, the saveAsType is kSaveAsType_Complete.
+ // If we should save as text, the saveAsType is kSaveAsType_Text.
+ var useSaveDocument =
+ aDocument &&
+ ((saveMode & SAVEMODE_COMPLETE_DOM &&
+ saveAsType == kSaveAsType_Complete) ||
+ (saveMode & SAVEMODE_COMPLETE_TEXT && saveAsType == kSaveAsType_Text));
+ // If we're saving a document, and are saving either in complete mode or
+ // as converted text, pass the document to the web browser persist component.
+ // If we're just saving the HTML (second option in the list), send only the URI.
+
+ let isPrivate = aIsContentWindowPrivate;
+ if (isPrivate === undefined) {
+ isPrivate =
+ aInitiatingDocument.nodeType == 9 /* DOCUMENT_NODE */
+ ? PrivateBrowsingUtils.isContentWindowPrivate(
+ aInitiatingDocument.defaultView
+ )
+ : aInitiatingDocument.isPrivate;
+ }
+
+ // We have to cover the cases here where we were either passed an explicit
+ // principal, or a 'real' document (with a nodePrincipal property), or an
+ // nsIWebBrowserPersistDocument which has a principal property.
+ let sourcePrincipal =
+ aPrincipal ||
+ (aDocument && (aDocument.nodePrincipal || aDocument.principal)) ||
+ (aInitiatingDocument && aInitiatingDocument.nodePrincipal);
+
+ let sourceOriginalURI = aOriginalURL ? makeURI(aOriginalURL) : null;
+
+ var persistArgs = {
+ sourceURI,
+ sourceOriginalURI,
+ sourcePrincipal,
+ sourceReferrerInfo: aReferrerInfo,
+ sourceDocument: useSaveDocument ? aDocument : null,
+ targetContentType: saveAsType == kSaveAsType_Text ? "text/plain" : null,
+ targetFile: file,
+ sourceCacheKey: aCacheKey,
+ sourcePostData: aDocument ? getPostData(aDocument) : null,
+ bypassCache: aShouldBypassCache,
+ contentPolicyType,
+ cookieJarSettings: aCookieJarSettings,
+ isPrivate,
+ };
+
+ // Start the actual save process
+ internalPersist(persistArgs);
+ }
+}
+
+/**
+ * internalPersist: Creates a 'Persist' object (which will perform the saving
+ * in the background) and then starts it.
+ *
+ * @param persistArgs.sourceURI
+ * The nsIURI of the document being saved
+ * @param persistArgs.sourceCacheKey [optional]
+ * If set will be passed to savePrivacyAwareURI
+ * @param persistArgs.sourceDocument [optional]
+ * The document to be saved, or null if not saving a complete document
+ * @param persistArgs.sourceReferrerInfo
+ * Required and used only when persistArgs.sourceDocument is NOT present,
+ * the nsIReferrerInfo of the referrer info to use, or null if no
+ * referrer should be sent.
+ * @param persistArgs.sourcePostData
+ * Required and used only when persistArgs.sourceDocument is NOT present,
+ * represents the POST data to be sent along with the HTTP request, and
+ * must be null if no POST data should be sent.
+ * @param persistArgs.targetFile
+ * The nsIFile of the file to create
+ * @param persistArgs.contentPolicyType
+ * The type of content we're saving. Will be used to determine what
+ * content is accepted, enforce sniffing restrictions, etc.
+ * @param persistArgs.cookieJarSettings [optional]
+ * The nsICookieJarSettings that will be used for the saving channel, or
+ * null that savePrivacyAwareURI will create one based on the current
+ * state of the prefs/permissions
+ * @param persistArgs.targetContentType
+ * Required and used only when persistArgs.sourceDocument is present,
+ * determines the final content type of the saved file, or null to use
+ * the same content type as the source document. Currently only
+ * "text/plain" is meaningful.
+ * @param persistArgs.bypassCache
+ * If true, the document will always be refetched from the server
+ * @param persistArgs.isPrivate
+ * Indicates whether this is taking place in a private browsing context.
+ */
+function internalPersist(persistArgs) {
+ var persist = makeWebBrowserPersist();
+
+ // Calculate persist flags.
+ const nsIWBP = Ci.nsIWebBrowserPersist;
+ const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
+ if (persistArgs.bypassCache) {
+ persist.persistFlags = flags | nsIWBP.PERSIST_FLAGS_BYPASS_CACHE;
+ } else {
+ persist.persistFlags = flags | nsIWBP.PERSIST_FLAGS_FROM_CACHE;
+ }
+
+ // Leave it to WebBrowserPersist to discover the encoding type (or lack thereof):
+ persist.persistFlags |= nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
+
+ // Find the URI associated with the target file
+ var targetFileURL = makeFileURI(persistArgs.targetFile);
+
+ // Create download and initiate it (below)
+ var tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
+ tr.init(
+ persistArgs.sourceURI,
+ persistArgs.sourceOriginalURI,
+ targetFileURL,
+ "",
+ null,
+ null,
+ null,
+ persist,
+ persistArgs.isPrivate,
+ Ci.nsITransfer.DOWNLOAD_ACCEPTABLE,
+ persistArgs.sourceReferrerInfo
+ );
+ persist.progressListener = new DownloadListener(window, tr);
+
+ if (persistArgs.sourceDocument) {
+ // Saving a Document, not a URI:
+ var filesFolder = null;
+ if (persistArgs.targetContentType != "text/plain") {
+ // Create the local directory into which to save associated files.
+ filesFolder = persistArgs.targetFile.clone();
+
+ var nameWithoutExtension = getFileBaseName(filesFolder.leafName);
+ var filesFolderLeafName = ContentAreaUtils.stringBundle.formatStringFromName(
+ "filesFolder",
+ [nameWithoutExtension]
+ );
+
+ filesFolder.leafName = filesFolderLeafName;
+ }
+
+ var encodingFlags = 0;
+ if (persistArgs.targetContentType == "text/plain") {
+ encodingFlags |= nsIWBP.ENCODE_FLAGS_FORMATTED;
+ encodingFlags |= nsIWBP.ENCODE_FLAGS_ABSOLUTE_LINKS;
+ encodingFlags |= nsIWBP.ENCODE_FLAGS_NOFRAMES_CONTENT;
+ } else {
+ encodingFlags |= nsIWBP.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES;
+ }
+
+ const kWrapColumn = 80;
+ persist.saveDocument(
+ persistArgs.sourceDocument,
+ targetFileURL,
+ filesFolder,
+ persistArgs.targetContentType,
+ encodingFlags,
+ kWrapColumn
+ );
+ } else {
+ persist.savePrivacyAwareURI(
+ persistArgs.sourceURI,
+ persistArgs.sourcePrincipal,
+ persistArgs.sourceCacheKey,
+ persistArgs.sourceReferrerInfo,
+ persistArgs.cookieJarSettings,
+ persistArgs.sourcePostData,
+ null,
+ targetFileURL,
+ persistArgs.contentPolicyType || Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ persistArgs.isPrivate
+ );
+ }
+}
+
+/**
+ * Structure for holding info about automatically supplied parameters for
+ * internalSave(...). This allows parameters to be supplied so the user does not
+ * need to be prompted for file info.
+ * @param aFileAutoChosen This is an nsIFile object that has been
+ * pre-determined as the filename for the target to save to
+ * @param aUriAutoChosen This is the nsIURI object for the target
+ */
+function AutoChosen(aFileAutoChosen, aUriAutoChosen) {
+ this.file = aFileAutoChosen;
+ this.uri = aUriAutoChosen;
+}
+
+/**
+ * Structure for holding info about a URL and the target filename it should be
+ * saved to. This structure is populated by initFileInfo(...).
+ * @param aSuggestedFileName This is used by initFileInfo(...) when it
+ * cannot 'discover' the filename from the url
+ * @param aFileName The target filename
+ * @param aFileBaseName The filename without the file extension
+ * @param aFileExt The extension of the filename
+ * @param aUri An nsIURI object for the url that is being saved
+ */
+function FileInfo(
+ aSuggestedFileName,
+ aFileName,
+ aFileBaseName,
+ aFileExt,
+ aUri
+) {
+ this.suggestedFileName = aSuggestedFileName;
+ this.fileName = aFileName;
+ this.fileBaseName = aFileBaseName;
+ this.fileExt = aFileExt;
+ this.uri = aUri;
+}
+
+/**
+ * Determine what the 'default' filename string is, its file extension and the
+ * filename without the extension. This filename is used when prompting the user
+ * for confirmation in the file picker dialog.
+ * @param aFI A FileInfo structure into which we'll put the results of this method.
+ * @param aURL The String representation of the URL of the document being saved
+ * @param aURLCharset The charset of aURL.
+ * @param aDocument The document to be saved
+ * @param aContentType The content type we're saving, if it could be
+ * determined by the caller.
+ * @param aContentDisposition The content-disposition header for the object
+ * we're saving, if it could be determined by the caller.
+ */
+function initFileInfo(
+ aFI,
+ aURL,
+ aURLCharset,
+ aDocument,
+ aContentType,
+ aContentDisposition
+) {
+ try {
+ let uriExt = null;
+ // Get an nsIURI object from aURL if possible:
+ try {
+ aFI.uri = makeURI(aURL, aURLCharset);
+ // Assuming nsiUri is valid, calling QueryInterface(...) on it will
+ // populate extra object fields (eg filename and file extension).
+ uriExt = aFI.uri.QueryInterface(Ci.nsIURL).fileExtension;
+ } catch (e) {}
+
+ // Get the default filename:
+ let fileName = getDefaultFileName(
+ aFI.suggestedFileName || aFI.fileName,
+ aFI.uri,
+ aDocument,
+ aContentDisposition
+ );
+
+ let mimeService = this.getMIMEService();
+ aFI.fileName = mimeService.validateFileNameForSaving(
+ fileName,
+ aContentType,
+ mimeService.VALIDATE_FORCE_APPEND_EXTENSION
+ );
+
+ // If uriExt is blank, consider: aFI.suggestedFileName is supplied if
+ // saveURL(...) was the original caller (hence both aContentType and
+ // aDocument are blank). If they were saving a link to a website then make
+ // the extension .htm .
+ if (
+ !uriExt &&
+ !aDocument &&
+ !aContentType &&
+ /^http(s?):\/\//i.test(aURL)
+ ) {
+ aFI.fileExt = "htm";
+ aFI.fileBaseName = aFI.fileName;
+ } else {
+ let idx = aFI.fileName.lastIndexOf(".");
+ aFI.fileBaseName =
+ idx >= 0 ? aFI.fileName.substring(0, idx) : aFI.fileName;
+ aFI.fileExt = idx >= 0 ? aFI.fileName.substring(idx + 1) : null;
+ }
+ } catch (e) {}
+}
+
+/**
+ * Given the Filepicker Parameters (aFpP), show the file picker dialog,
+ * prompting the user to confirm (or change) the fileName.
+ * @param aFpP
+ * A structure (see definition in internalSave(...) method)
+ * containing all the data used within this method.
+ * @param aSkipPrompt
+ * If true, attempt to save the file automatically to the user's default
+ * download directory, thus skipping the explicit prompt for a file name,
+ * but only if the associated preference is set.
+ * If false, don't save the file automatically to the user's
+ * default download directory, even if the associated preference
+ * is set, but ask for the target explicitly.
+ * @param aRelatedURI
+ * An nsIURI associated with the download. The last used
+ * directory of the picker is retrieved from/stored in the
+ * Content Pref Service using this URI.
+ * @return Promise
+ * @resolve a boolean. When true, it indicates that the file picker dialog
+ * is accepted.
+ */
+function promiseTargetFile(
+ aFpP,
+ /* optional */ aSkipPrompt,
+ /* optional */ aRelatedURI
+) {
+ return (async function() {
+ let downloadLastDir = new DownloadLastDir(window);
+ let prefBranch = Services.prefs.getBranch("browser.download.");
+ let useDownloadDir = prefBranch.getBoolPref("useDownloadDir");
+
+ if (!aSkipPrompt) {
+ useDownloadDir = false;
+ }
+
+ // Default to the user's default downloads directory configured
+ // through download prefs.
+ let dirPath = await Downloads.getPreferredDownloadsDirectory();
+ let dirExists = await IOUtils.exists(dirPath);
+ let dir = new FileUtils.File(dirPath);
+
+ if (useDownloadDir && dirExists) {
+ dir.append(aFpP.fileInfo.fileName);
+ aFpP.file = uniqueFile(dir);
+ return true;
+ }
+
+ // We must prompt for the file name explicitly.
+ // If we must prompt because we were asked to...
+ let file = await new Promise(resolve => {
+ if (useDownloadDir) {
+ // Keep async behavior in both branches
+ Services.tm.dispatchToMainThread(function() {
+ resolve(null);
+ });
+ } else {
+ downloadLastDir.getFileAsync(aRelatedURI, function getFileAsyncCB(
+ aFile
+ ) {
+ resolve(aFile);
+ });
+ }
+ });
+ if (file && (await IOUtils.exists(file.path))) {
+ dir = file;
+ dirExists = true;
+ }
+
+ if (!dirExists) {
+ // Default to desktop.
+ dir = Services.dirsvc.get("Desk", Ci.nsIFile);
+ }
+
+ let fp = makeFilePicker();
+ let titleKey = aFpP.fpTitleKey || "SaveLinkTitle";
+ fp.init(
+ window,
+ ContentAreaUtils.stringBundle.GetStringFromName(titleKey),
+ Ci.nsIFilePicker.modeSave
+ );
+
+ fp.displayDirectory = dir;
+ fp.defaultExtension = aFpP.fileInfo.fileExt;
+ fp.defaultString = aFpP.fileInfo.fileName;
+ appendFiltersForContentType(
+ fp,
+ aFpP.contentType,
+ aFpP.fileInfo.fileExt,
+ aFpP.saveMode
+ );
+
+ // The index of the selected filter is only preserved and restored if there's
+ // more than one filter in addition to "All Files".
+ if (aFpP.saveMode != SAVEMODE_FILEONLY) {
+ // eslint-disable-next-line mozilla/use-default-preference-values
+ try {
+ fp.filterIndex = prefBranch.getIntPref("save_converter_index");
+ } catch (e) {}
+ }
+
+ let result = await new Promise(resolve => {
+ fp.open(function(aResult) {
+ resolve(aResult);
+ });
+ });
+ if (result == Ci.nsIFilePicker.returnCancel || !fp.file) {
+ return false;
+ }
+
+ if (aFpP.saveMode != SAVEMODE_FILEONLY) {
+ prefBranch.setIntPref("save_converter_index", fp.filterIndex);
+ }
+
+ // Do not store the last save directory as a pref inside the private browsing mode
+ downloadLastDir.setFile(aRelatedURI, fp.file.parent);
+
+ fp.file.leafName = validateFileName(fp.file.leafName);
+
+ aFpP.saveAsType = fp.filterIndex;
+ aFpP.file = fp.file;
+ aFpP.fileURL = fp.fileURL;
+
+ return true;
+ })();
+}
+
+// Since we're automatically downloading, we don't get the file picker's
+// logic to check for existing files, so we need to do that here.
+//
+// Note - this code is identical to that in
+// mozilla/toolkit/mozapps/downloads/src/nsHelperAppDlg.js.in
+// If you are updating this code, update that code too! We can't share code
+// here since that code is called in a js component.
+function uniqueFile(aLocalFile) {
+ var collisionCount = 0;
+ while (aLocalFile.exists()) {
+ collisionCount++;
+ if (collisionCount == 1) {
+ // Append "(2)" before the last dot in (or at the end of) the filename
+ // special case .ext.gz etc files so we don't wind up with .tar(2).gz
+ if (aLocalFile.leafName.match(/\.[^\.]{1,3}\.(gz|bz2|Z)$/i)) {
+ aLocalFile.leafName = aLocalFile.leafName.replace(
+ /\.[^\.]{1,3}\.(gz|bz2|Z)$/i,
+ "(2)$&"
+ );
+ } else {
+ aLocalFile.leafName = aLocalFile.leafName.replace(
+ /(\.[^\.]*)?$/,
+ "(2)$&"
+ );
+ }
+ } else {
+ // replace the last (n) in the filename with (n+1)
+ aLocalFile.leafName = aLocalFile.leafName.replace(
+ /^(.*\()\d+\)/,
+ "$1" + (collisionCount + 1) + ")"
+ );
+ }
+ }
+ return aLocalFile;
+}
+
+/**
+ * Download a URL using the Downloads API.
+ *
+ * @param aURL
+ * the url to download
+ * @param [optional] aFileName
+ * the destination file name, if omitted will be obtained from the url.
+ * @param aInitiatingDocument
+ * The document from which the download was initiated.
+ */
+function DownloadURL(aURL, aFileName, aInitiatingDocument) {
+ // For private browsing, try to get document out of the most recent browser
+ // window, or provide our own if there's no browser window.
+ let isPrivate = aInitiatingDocument.defaultView.docShell.QueryInterface(
+ Ci.nsILoadContext
+ ).usePrivateBrowsing;
+
+ let fileInfo = new FileInfo(aFileName);
+ initFileInfo(fileInfo, aURL, null, null, null, null);
+
+ let filepickerParams = {
+ fileInfo,
+ saveMode: SAVEMODE_FILEONLY,
+ };
+
+ (async function() {
+ let accepted = await promiseTargetFile(
+ filepickerParams,
+ true,
+ fileInfo.uri
+ );
+ if (!accepted) {
+ return;
+ }
+
+ let file = filepickerParams.file;
+ let download = await Downloads.createDownload({
+ source: { url: aURL, isPrivate },
+ target: { path: file.path, partFilePath: file.path + ".part" },
+ });
+ download.tryToKeepPartialData = true;
+
+ // Ignore errors because failures are reported through the download list.
+ download.start().catch(() => {});
+
+ // Add the download to the list, allowing it to be managed.
+ let list = await Downloads.getList(Downloads.ALL);
+ list.add(download);
+ })().catch(Cu.reportError);
+}
+
+// We have no DOM, and can only save the URL as is.
+const SAVEMODE_FILEONLY = 0x00;
+XPCOMUtils.defineConstant(this, "SAVEMODE_FILEONLY", SAVEMODE_FILEONLY);
+// We have a DOM and can save as complete.
+const SAVEMODE_COMPLETE_DOM = 0x01;
+XPCOMUtils.defineConstant(this, "SAVEMODE_COMPLETE_DOM", SAVEMODE_COMPLETE_DOM);
+// We have a DOM which we can serialize as text.
+const SAVEMODE_COMPLETE_TEXT = 0x02;
+XPCOMUtils.defineConstant(
+ this,
+ "SAVEMODE_COMPLETE_TEXT",
+ SAVEMODE_COMPLETE_TEXT
+);
+
+// If we are able to save a complete DOM, the 'save as complete' filter
+// must be the first filter appended. The 'save page only' counterpart
+// must be the second filter appended. And the 'save as complete text'
+// filter must be the third filter appended.
+function appendFiltersForContentType(
+ aFilePicker,
+ aContentType,
+ aFileExtension,
+ aSaveMode
+) {
+ // The bundle name for saving only a specific content type.
+ var bundleName;
+ // The corresponding filter string for a specific content type.
+ var filterString;
+
+ // Every case where GetSaveModeForContentType can return non-FILEONLY
+ // modes must be handled here.
+ if (aSaveMode != SAVEMODE_FILEONLY) {
+ switch (aContentType) {
+ case "text/html":
+ bundleName = "WebPageHTMLOnlyFilter";
+ filterString = "*.htm; *.html";
+ break;
+
+ case "application/xhtml+xml":
+ bundleName = "WebPageXHTMLOnlyFilter";
+ filterString = "*.xht; *.xhtml";
+ break;
+
+ case "image/svg+xml":
+ bundleName = "WebPageSVGOnlyFilter";
+ filterString = "*.svg; *.svgz";
+ break;
+
+ case "text/xml":
+ case "application/xml":
+ bundleName = "WebPageXMLOnlyFilter";
+ filterString = "*.xml";
+ break;
+ }
+ }
+
+ if (!bundleName) {
+ if (aSaveMode != SAVEMODE_FILEONLY) {
+ throw new Error(`Invalid save mode for type '${aContentType}'`);
+ }
+
+ var mimeInfo = getMIMEInfoForType(aContentType, aFileExtension);
+ if (mimeInfo) {
+ var extString = "";
+ for (var extension of mimeInfo.getFileExtensions()) {
+ if (extString) {
+ extString += "; ";
+ } // If adding more than one extension,
+ // separate by semi-colon
+ extString += "*." + extension;
+ }
+
+ if (extString) {
+ aFilePicker.appendFilter(mimeInfo.description, extString);
+ }
+ }
+ }
+
+ if (aSaveMode & SAVEMODE_COMPLETE_DOM) {
+ aFilePicker.appendFilter(
+ ContentAreaUtils.stringBundle.GetStringFromName("WebPageCompleteFilter"),
+ filterString
+ );
+ // We should always offer a choice to save document only if
+ // we allow saving as complete.
+ aFilePicker.appendFilter(
+ ContentAreaUtils.stringBundle.GetStringFromName(bundleName),
+ filterString
+ );
+ }
+
+ if (aSaveMode & SAVEMODE_COMPLETE_TEXT) {
+ aFilePicker.appendFilters(Ci.nsIFilePicker.filterText);
+ }
+
+ // Always append the all files (*) filter
+ aFilePicker.appendFilters(Ci.nsIFilePicker.filterAll);
+}
+
+function getPostData(aDocument) {
+ if (aDocument instanceof Ci.nsIWebBrowserPersistDocument) {
+ return aDocument.postData;
+ }
+ try {
+ // Find the session history entry corresponding to the given document. In
+ // the current implementation, nsIWebPageDescriptor.currentDescriptor always
+ // returns a session history entry.
+ let sessionHistoryEntry = aDocument.defaultView.docShell
+ .QueryInterface(Ci.nsIWebPageDescriptor)
+ .currentDescriptor.QueryInterface(Ci.nsISHEntry);
+ return sessionHistoryEntry.postData;
+ } catch (e) {}
+ return null;
+}
+
+function makeWebBrowserPersist() {
+ const persistContractID =
+ "@mozilla.org/embedding/browser/nsWebBrowserPersist;1";
+ const persistIID = Ci.nsIWebBrowserPersist;
+ return Cc[persistContractID].createInstance(persistIID);
+}
+
+function makeURI(aURL, aOriginCharset, aBaseURI) {
+ return Services.io.newURI(aURL, aOriginCharset, aBaseURI);
+}
+
+function makeFileURI(aFile) {
+ return Services.io.newFileURI(aFile);
+}
+
+function makeFilePicker() {
+ const fpContractID = "@mozilla.org/filepicker;1";
+ const fpIID = Ci.nsIFilePicker;
+ return Cc[fpContractID].createInstance(fpIID);
+}
+
+function getMIMEService() {
+ const mimeSvcContractID = "@mozilla.org/mime;1";
+ const mimeSvcIID = Ci.nsIMIMEService;
+ const mimeSvc = Cc[mimeSvcContractID].getService(mimeSvcIID);
+ return mimeSvc;
+}
+
+// Given aFileName, find the fileName without the extension on the end.
+function getFileBaseName(aFileName) {
+ // Remove the file extension from aFileName:
+ return aFileName.replace(/\.[^.]*$/, "");
+}
+
+function getMIMETypeForURI(aURI) {
+ try {
+ return getMIMEService().getTypeFromURI(aURI);
+ } catch (e) {}
+ return null;
+}
+
+function getMIMEInfoForType(aMIMEType, aExtension) {
+ if (aMIMEType || aExtension) {
+ try {
+ return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension);
+ } catch (e) {}
+ }
+ return null;
+}
+
+function getDefaultFileName(
+ aDefaultFileName,
+ aURI,
+ aDocument,
+ aContentDisposition
+) {
+ // 1) look for a filename in the content-disposition header, if any
+ if (aContentDisposition) {
+ const mhpContractID = "@mozilla.org/network/mime-hdrparam;1";
+ const mhpIID = Ci.nsIMIMEHeaderParam;
+ const mhp = Cc[mhpContractID].getService(mhpIID);
+ var dummy = { value: null }; // Need an out param...
+ var charset = getCharsetforSave(aDocument);
+
+ var fileName = null;
+ try {
+ fileName = mhp.getParameter(
+ aContentDisposition,
+ "filename",
+ charset,
+ true,
+ dummy
+ );
+ } catch (e) {
+ try {
+ fileName = mhp.getParameter(
+ aContentDisposition,
+ "name",
+ charset,
+ true,
+ dummy
+ );
+ } catch (e) {}
+ }
+ if (fileName) {
+ return Services.textToSubURI.unEscapeURIForUI(
+ fileName,
+ /* dontEscape = */ true
+ );
+ }
+ }
+
+ let docTitle;
+ if (aDocument && aDocument.title && aDocument.title.trim()) {
+ // If the document looks like HTML or XML, try to use its original title.
+ let contentType = aDocument.contentType;
+ if (
+ contentType == "application/xhtml+xml" ||
+ contentType == "application/xml" ||
+ contentType == "image/svg+xml" ||
+ contentType == "text/html" ||
+ contentType == "text/xml"
+ ) {
+ // 2) Use the document title
+ return aDocument.title;
+ }
+ }
+
+ try {
+ var url = aURI.QueryInterface(Ci.nsIURL);
+ if (url.fileName != "") {
+ // 3) Use the actual file name, if present
+ return Services.textToSubURI.unEscapeURIForUI(
+ url.fileName,
+ /* dontEscape = */ true
+ );
+ }
+ } catch (e) {
+ // This is something like a data: and so forth URI... no filename here.
+ }
+
+ // Don't use the title if it's from a data URI
+ if (docTitle && aURI?.scheme != "data") {
+ // 4) Use the document title
+ return docTitle;
+ }
+
+ if (aDefaultFileName) {
+ // 5) Use the caller-provided name, if any
+ return aDefaultFileName;
+ }
+
+ try {
+ if (aURI.host) {
+ // 6) Use the host.
+ return aURI.host;
+ }
+ } catch (e) {
+ // Some files have no information at all, like Javascript generated pages
+ }
+
+ return "";
+}
+
+// This is only used after the user has entered a filename.
+function validateFileName(aFileName) {
+ let processed = DownloadPaths.sanitize(aFileName) || "_";
+ if (AppConstants.platform == "android") {
+ // If a large part of the filename has been sanitized, then we
+ // will use a default filename instead
+ if (processed.replace(/_/g, "").length <= processed.length / 2) {
+ // We purposefully do not use a localized default filename,
+ // which we could have done using
+ // ContentAreaUtils.stringBundle.GetStringFromName("DefaultSaveFileName")
+ // since it may contain invalid characters.
+ var original = processed;
+ processed = "download";
+
+ // Preserve a suffix, if there is one
+ if (original.includes(".")) {
+ var suffix = original.split(".").slice(-1)[0];
+ if (suffix && !suffix.includes("_")) {
+ processed += "." + suffix;
+ }
+ }
+ }
+ }
+ return processed;
+}
+
+function GetSaveModeForContentType(aContentType, aDocument) {
+ // We can only save a complete page if we have a loaded document,
+ if (!aDocument) {
+ return SAVEMODE_FILEONLY;
+ }
+
+ // Find the possible save modes using the provided content type
+ var saveMode = SAVEMODE_FILEONLY;
+ switch (aContentType) {
+ case "text/html":
+ case "application/xhtml+xml":
+ case "image/svg+xml":
+ saveMode |= SAVEMODE_COMPLETE_TEXT;
+ // Fall through
+ case "text/xml":
+ case "application/xml":
+ saveMode |= SAVEMODE_COMPLETE_DOM;
+ break;
+ }
+
+ return saveMode;
+}
+
+function getCharsetforSave(aDocument) {
+ if (aDocument) {
+ return aDocument.characterSet;
+ }
+
+ if (document.commandDispatcher.focusedWindow) {
+ return document.commandDispatcher.focusedWindow.document.characterSet;
+ }
+
+ return window.content.document.characterSet;
+}
+
+/**
+ * Open a URL from chrome, determining if we can handle it internally or need to
+ * launch an external application to handle it.
+ * @param aURL The URL to be opened
+ *
+ * WARNING: Please note that openURL() does not perform any content security checks!!!
+ */
+function openURL(aURL) {
+ var uri = aURL instanceof Ci.nsIURI ? aURL : makeURI(aURL);
+
+ var protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+
+ let recentWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ if (!protocolSvc.isExposedProtocol(uri.scheme)) {
+ // If we're not a browser, use the external protocol service to load the URI.
+ protocolSvc.loadURI(uri, recentWindow?.document.contentPrincipal);
+ } else {
+ if (recentWindow) {
+ recentWindow.openWebLinkIn(uri.spec, "tab", {
+ triggeringPrincipal: recentWindow.document.contentPrincipal,
+ });
+ return;
+ }
+
+ var loadgroup = Cc["@mozilla.org/network/load-group;1"].createInstance(
+ Ci.nsILoadGroup
+ );
+ var appstartup = Services.startup;
+
+ var loadListener = {
+ onStartRequest: function ll_start(aRequest) {
+ appstartup.enterLastWindowClosingSurvivalArea();
+ },
+ onStopRequest: function ll_stop(aRequest, aStatusCode) {
+ appstartup.exitLastWindowClosingSurvivalArea();
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ loadgroup.groupObserver = loadListener;
+
+ var uriListener = {
+ doContent(ctype, preferred, request, handler) {
+ return false;
+ },
+ isPreferred(ctype, desired) {
+ return false;
+ },
+ canHandleContent(ctype, preferred, desired) {
+ return false;
+ },
+ loadCookie: null,
+ parentContentListener: null,
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIURIContentListener)) {
+ return this;
+ }
+ if (iid.equals(Ci.nsILoadGroup)) {
+ return loadgroup;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ };
+
+ var channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ });
+
+ if (channel) {
+ channel.channelIsForDownload = true;
+ }
+
+ var uriLoader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader);
+ uriLoader.openURI(
+ channel,
+ Ci.nsIURILoader.IS_CONTENT_PREFERRED,
+ uriListener
+ );
+ }
+}