diff options
Diffstat (limited to 'toolkit/modules/BrowserUtils.jsm')
-rw-r--r-- | toolkit/modules/BrowserUtils.jsm | 1086 |
1 files changed, 1086 insertions, 0 deletions
diff --git a/toolkit/modules/BrowserUtils.jsm b/toolkit/modules/BrowserUtils.jsm new file mode 100644 index 0000000000..1020f9ca16 --- /dev/null +++ b/toolkit/modules/BrowserUtils.jsm @@ -0,0 +1,1086 @@ +/* -*- mode: js; 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["BrowserUtils"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); + +// The maximum number of concurrently-loaded origins allowed in order to +// qualify for the Fission rollout experiment. +XPCOMUtils.defineLazyPreferenceGetter( + this, + "fissionExperimentMaxOrigins", + "fission.experiment.max-origins.origin-cap", + 30 +); +// The length of the sliding window during which a user must stay below +// the max origin cap. If the last time a user passed the max origin cap +// fell outside of this window, they will requalify for the experiment. +XPCOMUtils.defineLazyPreferenceGetter( + this, + "fissionExperimentSlidingWindowMS", + "fission.experiment.max-origins.sliding-window-ms", + 7 * 24 * 60 * 60 * 1000 +); +// The pref holding the current qaualification state of the user. If +// true, the user is currently qualified from the experiment. +const FISSION_EXPERIMENT_PREF_QUALIFIED = + "fission.experiment.max-origins.qualified"; +XPCOMUtils.defineLazyPreferenceGetter( + this, + "fissionExperimentQualified", + FISSION_EXPERIMENT_PREF_QUALIFIED, + false +); +// The pref holding the timestamp of the last time we saw an origin +// count below the cap while the user was not currently marked as +// qualified. +const FISSION_EXPERIMENT_PREF_LAST_QUALIFIED = + "fission.experiment.max-origins.last-qualified"; +XPCOMUtils.defineLazyPreferenceGetter( + this, + "fissionExperimentLastQualified", + FISSION_EXPERIMENT_PREF_LAST_QUALIFIED, + 0 +); +// The pref holding the timestamp of the last time we saw an origin +// count exceeding the cap. +const FISSION_EXPERIMENT_PREF_LAST_DISQUALIFIED = + "fission.experiment.max-origins.last-disqualified"; +XPCOMUtils.defineLazyPreferenceGetter( + this, + "fissionExperimentLastDisqualified", + FISSION_EXPERIMENT_PREF_LAST_DISQUALIFIED, + 0 +); + +var BrowserUtils = { + /** + * Prints arguments separated by a space and appends a new line. + */ + dumpLn(...args) { + for (let a of args) { + dump(a + " "); + } + dump("\n"); + }, + + /** + * restartApplication: Restarts the application, keeping it in + * safe mode if it is already in safe mode. + */ + restartApplication() { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + if (cancelQuit.data) { + // The quit request has been canceled. + return false; + } + // if already in safe mode restart in safe mode + if (Services.appinfo.inSafeMode) { + Services.startup.restartInSafeMode( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + return undefined; + } + Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + return undefined; + }, + + /** + * Check whether a page can be considered as 'empty', that its URI + * reflects its origin, and that if it's loaded in a tab, that tab + * could be considered 'empty' (e.g. like the result of opening + * a 'blank' new tab). + * + * We have to do more than just check the URI, because especially + * for things like about:blank, it is possible that the opener or + * some other page has control over the contents of the page. + * + * @param {Browser} browser + * The browser whose page we're checking. + * @param {nsIURI} [uri] + * The URI against which we're checking (the browser's currentURI + * if omitted). + * + * @return {boolean} false if the page was opened by or is controlled by + * arbitrary web content, unless that content corresponds with the URI. + * true if the page is blank and controlled by a principal matching + * that URI (or the system principal if the principal has no URI) + */ + checkEmptyPageOrigin(browser, uri = browser.currentURI) { + // If another page opened this page with e.g. window.open, this page might + // be controlled by its opener. + if (browser.hasContentOpener) { + return false; + } + let contentPrincipal = browser.contentPrincipal; + // Not all principals have URIs... + // There are two special-cases involving about:blank. One is where + // the user has manually loaded it and it got created with a null + // principal. The other involves the case where we load + // some other empty page in a browser and the current page is the + // initial about:blank page (which has that as its principal, not + // just URI in which case it could be web-based). Especially in + // e10s, we need to tackle that case specifically to avoid race + // conditions when updating the URL bar. + // + // Note that we check the documentURI here, since the currentURI on + // the browser might have been set by SessionStore in order to + // support switch-to-tab without having actually loaded the content + // yet. + let uriToCheck = browser.documentURI || uri; + if ( + (uriToCheck.spec == "about:blank" && contentPrincipal.isNullPrincipal) || + contentPrincipal.spec == "about:blank" + ) { + return true; + } + if (contentPrincipal.isContentPrincipal) { + return contentPrincipal.equalsURI(uri); + } + // ... so for those that don't have them, enforce that the page has the + // system principal (this matches e.g. on about:newtab). + return contentPrincipal.isSystemPrincipal; + }, + + /** + * urlSecurityCheck: JavaScript wrapper for checkLoadURIWithPrincipal + * and checkLoadURIStrWithPrincipal. + * If |aPrincipal| is not allowed to link to |aURL|, this function throws with + * an error message. + * + * @param aURL + * The URL a page has linked to. This could be passed either as a string + * or as a nsIURI object. + * @param aPrincipal + * The principal of the document from which aURL came. + * @param aFlags + * Flags to be passed to checkLoadURIStr. If undefined, + * nsIScriptSecurityManager.STANDARD will be passed. + */ + urlSecurityCheck(aURL, aPrincipal, aFlags) { + var secMan = Services.scriptSecurityManager; + if (aFlags === undefined) { + aFlags = secMan.STANDARD; + } + + try { + if (aURL instanceof Ci.nsIURI) { + secMan.checkLoadURIWithPrincipal(aPrincipal, aURL, aFlags); + } else { + secMan.checkLoadURIStrWithPrincipal(aPrincipal, aURL, aFlags); + } + } catch (e) { + let principalStr = ""; + try { + principalStr = " from " + aPrincipal.spec; + } catch (e2) {} + + throw new Error(`Load of ${aURL + principalStr} denied.`); + } + }, + + /** + * Return or create a principal with the content of one, and the originAttributes + * of an existing principal (e.g. on a docshell, where the originAttributes ought + * not to change, that is, we should keep the userContextId, privateBrowsingId, + * etc. the same when changing the principal). + * + * @param principal + * The principal whose content/null/system-ness we want. + * @param existingPrincipal + * The principal whose originAttributes we want, usually the current + * principal of a docshell. + * @return an nsIPrincipal that matches the content/null/system-ness of the first + * param, and the originAttributes of the second. + */ + principalWithMatchingOA(principal, existingPrincipal) { + // Don't care about system principals: + if (principal.isSystemPrincipal) { + return principal; + } + + // If the originAttributes already match, just return the principal as-is. + if (existingPrincipal.originSuffix == principal.originSuffix) { + return principal; + } + + let secMan = Services.scriptSecurityManager; + if (principal.isContentPrincipal) { + return secMan.principalWithOA( + principal, + existingPrincipal.originAttributes + ); + } + + if (principal.isNullPrincipal) { + return secMan.createNullPrincipal(existingPrincipal.originAttributes); + } + throw new Error( + "Can't change the originAttributes of an expanded principal!" + ); + }, + + /** + * Constructs a new URI, using nsIIOService. + * @param aURL The URI spec. + * @param aOriginCharset The charset of the URI. + * @param aBaseURI Base URI to resolve aURL, or null. + * @return an nsIURI object based on aURL. + * + * @deprecated Use Services.io.newURI directly instead. + */ + makeURI(aURL, aOriginCharset, aBaseURI) { + return Services.io.newURI(aURL, aOriginCharset, aBaseURI); + }, + + /** + * @deprecated Use Services.io.newFileURI directly instead. + */ + makeFileURI(aFile) { + return Services.io.newFileURI(aFile); + }, + + /** + * For a given DOM element, returns its position in "screen" + * coordinates. In a content process, the coordinates returned will + * be relative to the left/top of the tab. In the chrome process, + * the coordinates are relative to the user's screen. + */ + getElementBoundingScreenRect(aElement) { + return this.getElementBoundingRect(aElement, true); + }, + + /** + * For a given DOM element, returns its position as an offset from the topmost + * window. In a content process, the coordinates returned will be relative to + * the left/top of the topmost content area. If aInScreenCoords is true, + * screen coordinates will be returned instead. + */ + getElementBoundingRect(aElement, aInScreenCoords) { + let rect = aElement.getBoundingClientRect(); + let win = aElement.ownerGlobal; + + let x = rect.left; + let y = rect.top; + + // We need to compensate for any iframes that might shift things + // over. We also need to compensate for zooming. + let parentFrame = win.frameElement; + while (parentFrame) { + win = parentFrame.ownerGlobal; + let cstyle = win.getComputedStyle(parentFrame); + + let framerect = parentFrame.getBoundingClientRect(); + x += + framerect.left + + parseFloat(cstyle.borderLeftWidth) + + parseFloat(cstyle.paddingLeft); + y += + framerect.top + + parseFloat(cstyle.borderTopWidth) + + parseFloat(cstyle.paddingTop); + + parentFrame = win.frameElement; + } + + rect = { + left: x, + top: y, + width: rect.width, + height: rect.height, + }; + rect = win.windowUtils.transformRectLayoutToVisual( + rect.left, + rect.top, + rect.width, + rect.height + ); + + if (aInScreenCoords) { + rect = { + left: rect.left + win.mozInnerScreenX, + top: rect.top + win.mozInnerScreenY, + width: rect.width, + height: rect.height, + }; + } + + let fullZoom = win.windowUtils.fullZoom; + rect = { + left: rect.left * fullZoom, + top: rect.top * fullZoom, + width: rect.width * fullZoom, + height: rect.height * fullZoom, + }; + + return rect; + }, + + onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) { + // Don't modify non-default targets or targets that aren't in top-level app + // tab docshells (isAppTab will be false for app tab subframes). + if (originalTarget != "" || !isAppTab) { + return originalTarget; + } + + // External links from within app tabs should always open in new tabs + // instead of replacing the app tab's page (Bug 575561) + let linkHost; + let docHost; + try { + linkHost = linkURI.host; + docHost = linkNode.ownerDocument.documentURIObject.host; + } catch (e) { + // nsIURI.host can throw for non-nsStandardURL nsIURIs. + // If we fail to get either host, just return originalTarget. + return originalTarget; + } + + if (docHost == linkHost) { + return originalTarget; + } + + // Special case: ignore "www" prefix if it is part of host string + let [longHost, shortHost] = + linkHost.length > docHost.length + ? [linkHost, docHost] + : [docHost, linkHost]; + if (longHost == "www." + shortHost) { + return originalTarget; + } + + return "_blank"; + }, + + /** + * Map the plugin's name to a filtered version more suitable for UI. + * + * @param aName The full-length name string of the plugin. + * @return the simplified name string. + */ + makeNicePluginName(aName) { + if (aName == "Shockwave Flash") { + return "Adobe Flash"; + } + // Regex checks if aName begins with "Java" + non-letter char + if (/^Java\W/.exec(aName)) { + return "Java"; + } + + // Clean up the plugin name by stripping off parenthetical clauses, + // trailing version numbers or "plugin". + // EG, "Foo Bar (Linux) Plugin 1.23_02" --> "Foo Bar" + // Do this by first stripping the numbers, etc. off the end, and then + // removing "Plugin" (and then trimming to get rid of any whitespace). + // (Otherwise, something like "Java(TM) Plug-in 1.7.0_07" gets mangled) + let newName = aName + .replace(/\(.*?\)/g, "") + .replace(/[\s\d\.\-\_\(\)]+$/, "") + .replace(/\bplug-?in\b/i, "") + .trim(); + return newName; + }, + + /** + * Returns true if |mimeType| is text-based, or false otherwise. + * + * @param mimeType + * The MIME type to check. + */ + mimeTypeIsTextBased(mimeType) { + return ( + mimeType.startsWith("text/") || + mimeType.endsWith("+xml") || + mimeType == "application/x-javascript" || + mimeType == "application/javascript" || + mimeType == "application/json" || + mimeType == "application/xml" + ); + }, + + /** + * Returns true if we can show a find bar, including FAYT, for the specified + * document location. The location must not be in a blacklist of specific + * "about:" pages for which find is disabled. + * + * This can be called from the parent process or from content processes. + */ + canFindInPage(location) { + return ( + !location.startsWith("about:addons") && + !location.startsWith( + "chrome://mozapps/content/extensions/aboutaddons.html" + ) && + !location.startsWith("about:preferences") + ); + }, + + _visibleToolbarsMap: new WeakMap(), + + /** + * Return true if any or a specific toolbar that interacts with the content + * document is visible. + * + * @param {nsIDocShell} docShell The docShell instance that a toolbar should + * be interacting with + * @param {String} which Identifier of a specific toolbar + * @return {Boolean} + */ + isToolbarVisible(docShell, which) { + let window = this.getRootWindow(docShell); + if (!this._visibleToolbarsMap.has(window)) { + return false; + } + let toolbars = this._visibleToolbarsMap.get(window); + return !!toolbars && toolbars.has(which); + }, + + /** + * Sets the --toolbarbutton-button-height CSS property on the closest + * toolbar to the provided element. Useful if you need to vertically + * center a position:absolute element within a toolbar that uses + * -moz-pack-align:stretch, and thus a height which is dependant on + * the font-size. + * + * @param element An element within the toolbar whose height is desired. + */ + async setToolbarButtonHeightProperty(element) { + let window = element.ownerGlobal; + let dwu = window.windowUtils; + let toolbarItem = element; + let urlBarContainer = element.closest("#urlbar-container"); + if (urlBarContainer) { + // The stop-reload-button, which is contained in #urlbar-container, + // needs to use #urlbar-container to calculate the bounds. + toolbarItem = urlBarContainer; + } + if (!toolbarItem) { + return; + } + let bounds = dwu.getBoundsWithoutFlushing(toolbarItem); + if (!bounds.height) { + await window.promiseDocumentFlushed(() => { + bounds = dwu.getBoundsWithoutFlushing(toolbarItem); + }); + } + if (bounds.height) { + toolbarItem.style.setProperty( + "--toolbarbutton-height", + bounds.height + "px" + ); + } + }, + + /** + * Track whether a toolbar is visible for a given a docShell. + * + * @param {nsIDocShell} docShell The docShell instance that a toolbar should + * be interacting with + * @param {String} which Identifier of a specific toolbar + * @param {Boolean} [visible] Whether the toolbar is visible. Optional, + * defaults to `true`. + */ + trackToolbarVisibility(docShell, which, visible = true) { + // We have to get the root window object, because XPConnect WrappedNatives + // can't be used as WeakMap keys. + let window = this.getRootWindow(docShell); + let toolbars = this._visibleToolbarsMap.get(window); + if (!toolbars) { + toolbars = new Set(); + this._visibleToolbarsMap.set(window, toolbars); + } + if (!visible) { + toolbars.delete(which); + } else { + toolbars.add(which); + } + }, + + /** + * Retrieve the root window object (i.e. the top-most content global) for a + * specific docShell object. + * + * @param {nsIDocShell} docShell + * @return {nsIDOMWindow} + */ + getRootWindow(docShell) { + return docShell.browsingContext.top.window; + }, + + /** + * Trim the selection text to a reasonable size and sanitize it to make it + * safe for search query input. + * + * @param aSelection + * The selection text to trim. + * @param aMaxLen + * The maximum string length, defaults to a reasonable size if undefined. + * @return The trimmed selection text. + */ + trimSelection(aSelection, aMaxLen) { + // Selections of more than 150 characters aren't useful. + const maxLen = Math.min(aMaxLen || 150, aSelection.length); + + if (aSelection.length > maxLen) { + // only use the first maxLen important chars. see bug 221361 + let pattern = new RegExp("^(?:\\s*.){0," + maxLen + "}"); + pattern.test(aSelection); + aSelection = RegExp.lastMatch; + } + + aSelection = aSelection.trim().replace(/\s+/g, " "); + + if (aSelection.length > maxLen) { + aSelection = aSelection.substr(0, maxLen); + } + + return aSelection; + }, + + /** + * Retrieve the text selection details for the given window. + * + * @param aTopWindow + * The top window of the element containing the selection. + * @param aCharLen + * The maximum string length for the selection text. + * @return The selection details containing the full and trimmed selection text + * and link details for link selections. + */ + getSelectionDetails(aTopWindow, aCharLen) { + let focusedWindow = {}; + let focusedElement = Services.focus.getFocusedElementForWindow( + aTopWindow, + true, + focusedWindow + ); + focusedWindow = focusedWindow.value; + + let selection = focusedWindow.getSelection(); + let selectionStr = selection.toString(); + let fullText; + + let url; + let linkText; + + let isDocumentLevelSelection = true; + // try getting a selected text in text input. + if (!selectionStr && focusedElement) { + // Don't get the selection for password fields. See bug 565717. + if ( + ChromeUtils.getClassName(focusedElement) === "HTMLTextAreaElement" || + (ChromeUtils.getClassName(focusedElement) === "HTMLInputElement" && + focusedElement.mozIsTextField(true)) + ) { + selection = focusedElement.editor.selection; + selectionStr = selection.toString(); + isDocumentLevelSelection = false; + } + } + + let collapsed = selection.isCollapsed; + + if (selectionStr) { + // Have some text, let's figure out if it looks like a URL that isn't + // actually a link. + linkText = selectionStr.trim(); + if (/^(?:https?|ftp):/i.test(linkText)) { + try { + url = this.makeURI(linkText); + } catch (ex) {} + } else if (/^(?:[a-z\d-]+\.)+[a-z]+$/i.test(linkText)) { + // Check if this could be a valid url, just missing the protocol. + // Now let's see if this is an intentional link selection. Our guess is + // based on whether the selection begins/ends with whitespace or is + // preceded/followed by a non-word character. + + // selection.toString() trims trailing whitespace, so we look for + // that explicitly in the first and last ranges. + let beginRange = selection.getRangeAt(0); + let delimitedAtStart = /^\s/.test(beginRange); + if (!delimitedAtStart) { + let container = beginRange.startContainer; + let offset = beginRange.startOffset; + if (container.nodeType == container.TEXT_NODE && offset > 0) { + delimitedAtStart = /\W/.test(container.textContent[offset - 1]); + } else { + delimitedAtStart = true; + } + } + + let delimitedAtEnd = false; + if (delimitedAtStart) { + let endRange = selection.getRangeAt(selection.rangeCount - 1); + delimitedAtEnd = /\s$/.test(endRange); + if (!delimitedAtEnd) { + let container = endRange.endContainer; + let offset = endRange.endOffset; + if ( + container.nodeType == container.TEXT_NODE && + offset < container.textContent.length + ) { + delimitedAtEnd = /\W/.test(container.textContent[offset]); + } else { + delimitedAtEnd = true; + } + } + } + + if (delimitedAtStart && delimitedAtEnd) { + try { + url = Services.uriFixup.getFixupURIInfo(linkText).preferredURI; + } catch (ex) {} + } + } + } + + if (selectionStr) { + // Pass up to 16K through unmolested. If an add-on needs more, they will + // have to use a content script. + fullText = selectionStr.substr(0, 16384); + selectionStr = this.trimSelection(selectionStr, aCharLen); + } + + if (url && !url.host) { + url = null; + } + + return { + text: selectionStr, + docSelectionIsCollapsed: collapsed, + isDocumentLevelSelection, + fullText, + linkURL: url ? url.spec : null, + linkText: url ? linkText : "", + }; + }, + + /** + * Replaces %s or %S in the provided url or postData with the given parameter, + * acccording to the best charset for the given url. + * + * @return [url, postData] + * @throws if nor url nor postData accept a param, but a param was provided. + */ + async parseUrlAndPostData(url, postData, param) { + let hasGETParam = /%s/i.test(url); + let decodedPostData = postData ? unescape(postData) : ""; + let hasPOSTParam = /%s/i.test(decodedPostData); + + if (!hasGETParam && !hasPOSTParam) { + if (param) { + // If nor the url, nor postData contain parameters, but a parameter was + // provided, return the original input. + throw new Error( + "A param was provided but there's nothing to bind it to" + ); + } + return [url, postData]; + } + + let charset = ""; + const re = /^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/; + let matches = url.match(re); + if (matches) { + [, url, charset] = matches; + } else { + // Try to fetch a charset from History. + try { + // Will return an empty string if character-set is not found. + let pageInfo = await PlacesUtils.history.fetch(url, { + includeAnnotations: true, + }); + if (pageInfo && pageInfo.annotations.has(PlacesUtils.CHARSET_ANNO)) { + charset = pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO); + } + } catch (ex) { + // makeURI() throws if url is invalid. + Cu.reportError(ex); + } + } + + // encodeURIComponent produces UTF-8, and cannot be used for other charsets. + // escape() works in those cases, but it doesn't uri-encode +, @, and /. + // Therefore we need to manually replace these ASCII characters by their + // encodeURIComponent result, to match the behavior of nsEscape() with + // url_XPAlphas. + let encodedParam = ""; + if (charset && charset != "UTF-8") { + try { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = charset; + encodedParam = converter.ConvertFromUnicode(param) + converter.Finish(); + } catch (ex) { + encodedParam = param; + } + encodedParam = escape(encodedParam).replace( + /[+@\/]+/g, + encodeURIComponent + ); + } else { + // Default charset is UTF-8 + encodedParam = encodeURIComponent(param); + } + + url = url.replace(/%s/g, encodedParam).replace(/%S/g, param); + if (hasPOSTParam) { + postData = decodedPostData + .replace(/%s/g, encodedParam) + .replace(/%S/g, param); + } + return [url, postData]; + }, + + /** + * Generate a document fragment for a localized string that has DOM + * node replacements. This avoids using getFormattedString followed + * by assigning to innerHTML. Fluent can probably replace this when + * it is in use everywhere. + * + * @param {Document} doc + * @param {String} msg + * The string to put replacements in. Fetch from + * a stringbundle using getString or GetStringFromName, + * or even an inserted dtd string. + * @param {Node|String} nodesOrStrings + * The replacement items. Can be a mix of Nodes + * and Strings. However, for correct behaviour, the + * number of items provided needs to exactly match + * the number of replacement strings in the l10n string. + * @returns {DocumentFragment} + * A document fragment. In the trivial case (no + * replacements), this will simply be a fragment with 1 + * child, a text node containing the localized string. + */ + getLocalizedFragment(doc, msg, ...nodesOrStrings) { + // Ensure replacement points are indexed: + for (let i = 1; i <= nodesOrStrings.length; i++) { + if (!msg.includes("%" + i + "$S")) { + msg = msg.replace(/%S/, "%" + i + "$S"); + } + } + let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length; + if (numberOfInsertionPoints != nodesOrStrings.length) { + Cu.reportError( + `Message has ${numberOfInsertionPoints} insertion points, ` + + `but got ${nodesOrStrings.length} replacement parameters!` + ); + } + + let fragment = doc.createDocumentFragment(); + let parts = [msg]; + let insertionPoint = 1; + for (let replacement of nodesOrStrings) { + let insertionString = "%" + insertionPoint++ + "$S"; + let partIndex = parts.findIndex( + part => typeof part == "string" && part.includes(insertionString) + ); + if (partIndex == -1) { + fragment.appendChild(doc.createTextNode(msg)); + return fragment; + } + + if (typeof replacement == "string") { + parts[partIndex] = parts[partIndex].replace( + insertionString, + replacement + ); + } else { + let [firstBit, lastBit] = parts[partIndex].split(insertionString); + parts.splice(partIndex, 1, firstBit, replacement, lastBit); + } + } + + // Put everything in a document fragment: + for (let part of parts) { + if (typeof part == "string") { + if (part) { + fragment.appendChild(doc.createTextNode(part)); + } + } else { + fragment.appendChild(part); + } + } + return fragment; + }, + + /** + * Returns a Promise which resolves when the given observer topic has been + * observed. + * + * @param {string} topic + * The topic to observe. + * @param {function(nsISupports, string)} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected notification, false otherwise. + * @returns {Promise<object>} + */ + promiseObserved(topic, test = () => true) { + return new Promise(resolve => { + let observer = (subject, topic, data) => { + if (test(subject, data)) { + Services.obs.removeObserver(observer, topic); + resolve({ subject, data }); + } + }; + Services.obs.addObserver(observer, topic); + }); + }, + + removeSingleTrailingSlashFromURL(aURL) { + // remove single trailing slash for http/https/ftp URLs + return aURL.replace(/^((?:http|https|ftp):\/\/[^/]+)\/$/, "$1"); + }, + + /** + * Returns a URL which has been trimmed by removing 'http://' and any + * trailing slash (in http/https/ftp urls). + * Note that a trimmed url may not load the same page as the original url, so + * before loading it, it must be passed through URIFixup, to check trimming + * doesn't change its destination. We don't run the URIFixup check here, + * because trimURL is in the page load path (see onLocationChange), so it + * must be fast and simple. + * + * @param {string} aURL The URL to trim. + * @returns {string} The trimmed string. + */ + get trimURLProtocol() { + return "http://"; + }, + trimURL(aURL) { + let url = this.removeSingleTrailingSlashFromURL(aURL); + // Remove "http://" prefix. + return url.startsWith(this.trimURLProtocol) + ? url.substring(this.trimURLProtocol.length) + : url; + }, + + recordSiteOriginTelemetry(aWindows, aIsGeckoView) { + Services.tm.idleDispatchToMainThread(() => { + this._recordSiteOriginTelemetry(aWindows, aIsGeckoView); + }); + }, + + computeSiteOriginCount(aWindows, aIsGeckoView) { + // Geckoview and Desktop work differently. On desktop, aBrowser objects + // holds an array of tabs which we can use to get the <browser> objects. + // In Geckoview, it is apps' responsibility to keep track of the tabs, so + // there isn't an easy way for us to get the tabs. + let tabs = []; + if (aIsGeckoView) { + // To get all active windows; Each tab has its own window + tabs = aWindows; + } else { + for (const win of aWindows) { + tabs = tabs.concat(win.gBrowser.tabs); + } + } + + let topLevelBCs = []; + + for (const tab of tabs) { + let browser; + if (aIsGeckoView) { + browser = tab.browser; + } else { + browser = tab.linkedBrowser; + } + + if (browser.browsingContext) { + // This is the top level browsingContext + topLevelBCs.push(browser.browsingContext); + } + } + + return CanonicalBrowsingContext.countSiteOrigins(topLevelBCs); + }, + + _recordSiteOriginTelemetry(aWindows, aIsGeckoView) { + let currentTime = Date.now(); + + // default is 5 minutes + if (!this.min_interval) { + this.min_interval = Services.prefs.getIntPref( + "telemetry.number_of_site_origin.min_interval", + 300000 + ); + } + + let originCount = this.computeSiteOriginCount(aWindows, aIsGeckoView); + let histogram = Services.telemetry.getHistogramById( + "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS" + ); + + // Discard the first load because most of the time the first load only has 1 + // tab and 1 window open, so it is useless to report it. + if (!this._lastRecordSiteOrigin) { + this._lastRecordSiteOrigin = currentTime; + } else if (currentTime >= this._lastRecordSiteOrigin + this.min_interval) { + this._lastRecordSiteOrigin = currentTime; + + histogram.add(originCount); + } + + // Update the Fission experiment qualification state based on the + // current origin count: + + // If we don't already have a last disqualification timestamp, look + // through the existing histogram values, and use the existing + // maximum value as the initial count. This will prevent us from + // enrolling users in the experiment if they have a history of + // exceeding our origin cap. + if (!this._checkedInitialExperimentQualification) { + this._checkedInitialExperimentQualification = true; + if ( + !Services.prefs.prefHasUserValue( + FISSION_EXPERIMENT_PREF_LAST_DISQUALIFIED + ) + ) { + for (let [bucketStr, entryCount] of Object.entries( + histogram.snapshot().values + )) { + let bucket = Number(bucketStr); + if (bucket > originCount && entryCount > 0) { + originCount = bucket; + } + } + Services.prefs.setIntPref(FISSION_EXPERIMENT_PREF_LAST_DISQUALIFIED, 0); + } + } + + let currentTimeSec = currentTime / 1000; + if (originCount < fissionExperimentMaxOrigins) { + let lastDisqualified = fissionExperimentLastDisqualified; + let lastQualified = fissionExperimentLastQualified; + + // If the last time we saw a qualifying origin count was earlier + // than the last time we say a disqualifying count, update any + // existing last disqualified timestamp to just before now, on the + // basis that our origin count has probably just fallen below the + // cap. + if (lastDisqualified > 0 && lastQualified <= lastDisqualified) { + Services.prefs.setIntPref( + FISSION_EXPERIMENT_PREF_LAST_DISQUALIFIED, + currentTimeSec - 1 + ); + } + + if (!fissionExperimentQualified) { + Services.prefs.setIntPref( + FISSION_EXPERIMENT_PREF_LAST_QUALIFIED, + currentTimeSec + ); + + // We have a qualifying origin count now. If the last time we were + // disqualified was prior to the start of our current sliding + // window, re-qualify the user. + if ( + currentTimeSec - lastDisqualified >= + fissionExperimentSlidingWindowMS / 1000 + ) { + Services.prefs.setBoolPref(FISSION_EXPERIMENT_PREF_QUALIFIED, true); + } + } + } else { + Services.prefs.setIntPref( + FISSION_EXPERIMENT_PREF_LAST_DISQUALIFIED, + currentTimeSec + ); + Services.prefs.setBoolPref(FISSION_EXPERIMENT_PREF_QUALIFIED, false); + } + }, + + /** + * Converts a property bag to object. + * @param {nsIPropertyBag} bag - The property bag to convert + * @returns {Object} - The object representation of the nsIPropertyBag + */ + propBagToObject(bag) { + function toValue(property) { + if (typeof property != "object") { + return property; + } + if (Array.isArray(property)) { + return property.map(this.toValue, this); + } + if (property && property instanceof Ci.nsIPropertyBag) { + return this.propBagToObject(property); + } + return property; + } + if (!(bag instanceof Ci.nsIPropertyBag)) { + throw new TypeError("Not a property bag"); + } + let result = {}; + for (let { name, value: property } of bag.enumerator) { + let value = toValue(property); + result[name] = value; + } + return result; + }, + + /** + * Converts an object to a property bag. + * @param {Object} obj - The object to convert. + * @returns {nsIPropertyBag} - The property bag representation of the object. + */ + objectToPropBag(obj) { + function fromValue(value) { + if (typeof value == "function") { + return null; // Emulating the behavior of JSON.stringify with functions + } + if (Array.isArray(value)) { + return value.map(this.fromValue, this); + } + if (value == null || typeof value != "object") { + // Auto-converted to nsIVariant + return value; + } + return this.objectToPropBag(value); + } + + if (obj == null || typeof obj != "object") { + throw new TypeError("Invalid object: " + obj); + } + let bag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + for (let k of Object.keys(obj)) { + let value = fromValue(obj[k]); + bag.setProperty(k, value); + } + return bag; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + BrowserUtils, + "navigationRequireUserInteraction", + "browser.navigation.requireUserInteraction", + false +); |