summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/BrowserUtils.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/modules/BrowserUtils.sys.mjs589
1 files changed, 589 insertions, 0 deletions
diff --git a/toolkit/modules/BrowserUtils.sys.mjs b/toolkit/modules/BrowserUtils.sys.mjs
new file mode 100644
index 0000000000..666a40c4cc
--- /dev/null
+++ b/toolkit/modules/BrowserUtils.sys.mjs
@@ -0,0 +1,589 @@
+/* -*- 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "INVALID_SHAREABLE_SCHEMES",
+ "services.sync.engine.tabs.filteredSchemes",
+ "",
+ null,
+ val => {
+ return new Set(val.split("|"));
+ }
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "gLocalization", () => {
+ return new Localization(["toolkit/global/browser-utils.ftl"], true);
+});
+
+function stringPrefToSet(prefVal) {
+ return new Set(
+ prefVal
+ .toLowerCase()
+ .split(/\s*,\s*/g) // split on commas, ignoring whitespace
+ .filter(v => !!v) // discard any falsey values
+ );
+}
+
+export var BrowserUtils = {
+ /**
+ * 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!"
+ );
+ },
+
+ /**
+ * 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.endsWith("+json") ||
+ 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 blocklist 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:preferences") &&
+ !location.startsWith("about:logins")
+ );
+ },
+
+ isFindbarVisible(docShell) {
+ const FINDER_SYS_MJS = "resource://gre/modules/Finder.sys.mjs";
+ return (
+ Cu.isESModuleLoaded(FINDER_SYS_MJS) &&
+ ChromeUtils.importESModule(FINDER_SYS_MJS).Finder.isFindbarVisible(
+ docShell
+ )
+ );
+ },
+
+ /**
+ * 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);
+ });
+ },
+
+ formatURIStringForDisplay(uriString, options = {}) {
+ try {
+ return this.formatURIForDisplay(Services.io.newURI(uriString), options);
+ } catch (ex) {
+ return uriString;
+ }
+ },
+
+ formatURIForDisplay(uri, options = {}) {
+ let { showInsecureHTTP = false } = options;
+ switch (uri.scheme) {
+ case "view-source":
+ let innerURI = uri.spec.substring("view-source:".length);
+ return this.formatURIStringForDisplay(innerURI, options);
+ case "http":
+ // Fall through.
+ case "https":
+ let host = uri.displayHostPort;
+ if (!showInsecureHTTP && host.startsWith("www.")) {
+ host = Services.eTLD.getSchemelessSite(uri);
+ }
+ if (showInsecureHTTP && uri.scheme == "http") {
+ return "http://" + host;
+ }
+ return host;
+ case "about":
+ return "about:" + uri.filePath;
+ case "blob":
+ try {
+ let url = new URL(uri.specIgnoringRef);
+ // _If_ we find a non-null origin, report that.
+ if (url.origin && url.origin != "null") {
+ return this.formatURIStringForDisplay(url.origin, options);
+ }
+ // otherwise, fall through...
+ } catch (ex) {
+ console.error("Invalid blob URI passed to formatURIForDisplay: ", ex);
+ }
+ /* For blob URIs without an origin, fall through and use the data URI
+ * logic (shows just "(data)", localized). */
+ case "data":
+ return lazy.gLocalization.formatValueSync("browser-utils-url-data");
+ case "moz-extension":
+ let policy = WebExtensionPolicy.getByURI(uri);
+ return lazy.gLocalization.formatValueSync(
+ "browser-utils-url-extension",
+ { extension: policy?.name.trim() || uri.spec }
+ );
+ case "chrome":
+ case "resource":
+ case "jar":
+ case "file":
+ default:
+ try {
+ let url = uri.QueryInterface(Ci.nsIURL);
+ // Just the filename if we have one:
+ if (url.fileName) {
+ return url.fileName;
+ }
+ // We won't get a filename for a path that looks like:
+ // /foo/bar/baz/
+ // So try the directory name:
+ if (url.directory) {
+ let parts = url.directory.split("/");
+ // Pop off any empty bits at the end:
+ let last;
+ while (!last && parts.length) {
+ last = parts.pop();
+ }
+ if (last) {
+ return last;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ return uri.asciiHost || uri.spec;
+ },
+
+ // Given a URL returns a (possibly transformed) URL suitable for sharing, or null if
+ // no such URL can be obtained.
+ getShareableURL(url) {
+ if (!url) {
+ return null;
+ }
+
+ // Carve out an exception for about:reader.
+ if (url.spec.startsWith("about:reader?")) {
+ url = Services.io.newURI(lazy.ReaderMode.getOriginalUrl(url.spec));
+ }
+ // Disallow sharing URLs with more than 65535 characters.
+ if (url.spec.length > 65535) {
+ return null;
+ }
+ // Use the same preference as synced tabs to disable what kind
+ // of tabs we can send to another device
+ return lazy.INVALID_SHAREABLE_SCHEMES.has(url.scheme) ? null : url;
+ },
+
+ /**
+ * Extracts linkNode and href for a click event.
+ *
+ * @param event
+ * The click event.
+ * @return [href, linkNode, linkPrincipal].
+ *
+ * @note linkNode will be null if the click wasn't on an anchor
+ * element. This includes SVG links, because callers expect |node|
+ * to behave like an <a> element, which SVG links (XLink) don't.
+ */
+ hrefAndLinkNodeForClickEvent(event) {
+ // We should get a window off the event, and bail if not:
+ let content = event.view || event.composedTarget?.ownerGlobal;
+ if (!content?.HTMLAnchorElement) {
+ return null;
+ }
+ function isHTMLLink(aNode) {
+ // Be consistent with what nsContextMenu.js does.
+ return (
+ (content.HTMLAnchorElement.isInstance(aNode) && aNode.href) ||
+ (content.HTMLAreaElement.isInstance(aNode) && aNode.href) ||
+ content.HTMLLinkElement.isInstance(aNode)
+ );
+ }
+
+ let node = event.composedTarget;
+ while (node && !isHTMLLink(node)) {
+ node = node.flattenedTreeParentNode;
+ }
+
+ if (node) {
+ return [node.href, node, node.ownerDocument.nodePrincipal];
+ }
+
+ // If there is no linkNode, try simple XLink.
+ let href, baseURI;
+ node = event.composedTarget;
+ while (node && !href) {
+ if (
+ node.nodeType == content.Node.ELEMENT_NODE &&
+ (node.localName == "a" ||
+ node.namespaceURI == "http://www.w3.org/1998/Math/MathML")
+ ) {
+ href =
+ node.getAttribute("href") ||
+ node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
+ if (href) {
+ baseURI = node.ownerDocument.baseURIObject;
+ break;
+ }
+ }
+ node = node.flattenedTreeParentNode;
+ }
+
+ // In case of XLink, we don't return the node we got href from since
+ // callers expect <a>-like elements.
+ // Note: makeURI() will throw if aUri is not a valid URI.
+ return [
+ href ? Services.io.newURI(href, null, baseURI).spec : null,
+ null,
+ node && node.ownerDocument.nodePrincipal,
+ ];
+ },
+
+ /**
+ * whereToOpenLink() looks at an event to decide where to open a link.
+ *
+ * The event may be a mouse event (click, double-click, middle-click) or keypress event (enter).
+ *
+ * On Windows, the modifiers are:
+ * Ctrl new tab, selected
+ * Shift new window
+ * Ctrl+Shift new tab, in background
+ * Alt save
+ *
+ * Middle-clicking is the same as Ctrl+clicking (it opens a new tab).
+ *
+ * Exceptions:
+ * - Alt is ignored for menu items selected using the keyboard so you don't accidentally save stuff.
+ * (Currently, the Alt isn't sent here at all for menu items, but that will change in bug 126189.)
+ * - Alt is hard to use in context menus, because pressing Alt closes the menu.
+ * - Alt can't be used on the bookmarks toolbar because Alt is used for "treat this as something draggable".
+ * - The button is ignored for the middle-click-paste-URL feature, since it's always a middle-click.
+ *
+ * @param e {Event|Object} Event or JSON Object
+ * @param ignoreButton {Boolean}
+ * @param ignoreAlt {Boolean}
+ * @returns {"current" | "tabshifted" | "tab" | "save" | "window"}
+ */
+ whereToOpenLink(e, ignoreButton, ignoreAlt) {
+ // This method must treat a null event like a left click without modifier keys (i.e.
+ // e = { shiftKey:false, ctrlKey:false, metaKey:false, altKey:false, button:0 })
+ // for compatibility purposes.
+ if (!e) {
+ return "current";
+ }
+
+ e = this.getRootEvent(e);
+
+ var shift = e.shiftKey;
+ var ctrl = e.ctrlKey;
+ var meta = e.metaKey;
+ var alt = e.altKey && !ignoreAlt;
+
+ // ignoreButton allows "middle-click paste" to use function without always opening in a new window.
+ let middle = !ignoreButton && e.button == 1;
+ let middleUsesTabs = Services.prefs.getBoolPref(
+ "browser.tabs.opentabfor.middleclick",
+ true
+ );
+ let middleUsesNewWindow = Services.prefs.getBoolPref(
+ "middlemouse.openNewWindow",
+ false
+ );
+
+ // Don't do anything special with right-mouse clicks. They're probably clicks on context menu items.
+
+ var metaKey = AppConstants.platform == "macosx" ? meta : ctrl;
+ if (metaKey || (middle && middleUsesTabs)) {
+ return shift ? "tabshifted" : "tab";
+ }
+
+ if (alt && Services.prefs.getBoolPref("browser.altClickSave", false)) {
+ return "save";
+ }
+
+ if (shift || (middle && !middleUsesTabs && middleUsesNewWindow)) {
+ return "window";
+ }
+
+ return "current";
+ },
+
+ // Utility function to check command events for potential middle-click events
+ // from checkForMiddleClick and unwrap them.
+ getRootEvent(aEvent) {
+ // Part of the fix for Bug 1523813.
+ // Middle-click events arrive here wrapped in different numbers (1-2) of
+ // command events, depending on the button originally clicked.
+ if (!aEvent) {
+ return aEvent;
+ }
+ let tempEvent = aEvent;
+ while (tempEvent.sourceEvent) {
+ if (tempEvent.sourceEvent.button == 1) {
+ aEvent = tempEvent.sourceEvent;
+ break;
+ }
+ tempEvent = tempEvent.sourceEvent;
+ }
+ return aEvent;
+ },
+
+ /**
+ * An enumeration of the promotion types that can be passed to shouldShowPromo
+ */
+ PromoType: {
+ DEFAULT: 0, // invalid
+ VPN: 1,
+ RELAY: 2,
+ FOCUS: 3,
+ PIN: 4,
+ COOKIE_BANNERS: 5,
+ },
+
+ /**
+ * Should a given promo be shown to the user now, based on things including:
+ *
+ * current region
+ * home region
+ * where ads for a particular thing are allowed
+ * where they are illegal
+ * in what regions is the thing being promoted supported?
+ * whether there is an active enterprise policy
+ * settings of specific preferences related to this promo
+ *
+ * @param {BrowserUtils.PromoType} promoType - What promo are we checking on?
+ *
+ * @return {boolean} - should we display this promo now or not?
+ */
+ shouldShowPromo(promoType) {
+ switch (promoType) {
+ case this.PromoType.VPN:
+ case this.PromoType.FOCUS:
+ case this.PromoType.PIN:
+ case this.PromoType.RELAY:
+ case this.PromoType.COOKIE_BANNERS:
+ break;
+ default:
+ throw new Error("Unknown promo type: ", promoType);
+ }
+
+ const info = PromoInfo[promoType];
+ const promoEnabled =
+ !info.enabledPref || Services.prefs.getBoolPref(info.enabledPref, true);
+
+ const homeRegion = lazy.Region.home || "";
+ const currentRegion = lazy.Region.current || "";
+
+ let inSupportedRegion = true;
+ if ("supportedRegions" in info.lazyStringSetPrefs) {
+ const supportedRegions =
+ info.lazyStringSetPrefs.supportedRegions.lazyValue;
+ inSupportedRegion =
+ supportedRegions.has(currentRegion.toLowerCase()) ||
+ supportedRegions.has(homeRegion.toLowerCase());
+ }
+
+ const avoidAdsRegions =
+ info.lazyStringSetPrefs.disallowedRegions?.lazyValue;
+
+ // Don't show promo if there's an active enterprise policy
+ const noActivePolicy =
+ info.showForEnterprise ||
+ !Services.policies ||
+ Services.policies.status !== Services.policies.ACTIVE;
+
+ // Promos may add custom checks that must pass.
+ const passedExtraCheck = !info.extraCheck || info.extraCheck();
+
+ return (
+ promoEnabled &&
+ !avoidAdsRegions?.has(homeRegion.toLowerCase()) &&
+ !avoidAdsRegions?.has(currentRegion.toLowerCase()) &&
+ !info.illegalRegions.includes(homeRegion.toLowerCase()) &&
+ !info.illegalRegions.includes(currentRegion.toLowerCase()) &&
+ inSupportedRegion &&
+ noActivePolicy &&
+ passedExtraCheck
+ );
+ },
+
+ /**
+ * @deprecated in favor of shouldShowPromo
+ */
+ shouldShowVPNPromo() {
+ return this.shouldShowPromo(this.PromoType.VPN);
+ },
+
+ // Return true if Send to Device emails are supported for user's locale
+ sendToDeviceEmailsSupported() {
+ const userLocale = Services.locale.appLocaleAsBCP47.toLowerCase();
+ return this.emailSupportedLocales.has(userLocale);
+ },
+};
+
+/**
+ * A table of promos used by shouldShowPromo to decide whether or not to show.
+ * Each entry defines the criteria for a given promo, and also houses lazy
+ * getters for specified string set preferences.
+ */
+let PromoInfo = {
+ [BrowserUtils.PromoType.VPN]: {
+ enabledPref: "browser.vpn_promo.enabled",
+ lazyStringSetPrefs: {
+ supportedRegions: {
+ name: "browser.contentblocking.report.vpn_region",
+ default: "us,ca,nz,sg,my,gb,de,fr",
+ },
+ disallowedRegions: {
+ name: "browser.vpn_promo.disallowed_regions",
+ default: "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr,ua",
+ },
+ },
+ illegalRegions: ["cn", "kp", "tm"],
+ },
+ [BrowserUtils.PromoType.FOCUS]: {
+ enabledPref: "browser.promo.focus.enabled",
+ lazyStringSetPrefs: {
+ // there are no particular limitions to where it is "supported",
+ // so we leave out the supported pref
+ disallowedRegions: {
+ name: "browser.promo.focus.disallowed_regions",
+ default: "cn",
+ },
+ },
+ illegalRegions: ["cn"],
+ },
+ [BrowserUtils.PromoType.PIN]: {
+ enabledPref: "browser.promo.pin.enabled",
+ lazyStringSetPrefs: {},
+ illegalRegions: [],
+ },
+ [BrowserUtils.PromoType.RELAY]: {
+ lazyStringSetPrefs: {},
+ illegalRegions: [],
+ // Returns true if user is using the FxA "production" instance, or returns
+ // false for custom FxA instance (such as accounts.firefox.com.cn for the
+ // China repack) which doesn't support authentication for addons like Relay.
+ extraCheck: () =>
+ !Services.prefs.getCharPref("identity.fxaccounts.autoconfig.uri", "") &&
+ [
+ "identity.fxaccounts.remote.root",
+ "identity.fxaccounts.auth.uri",
+ "identity.fxaccounts.remote.oauth.uri",
+ "identity.fxaccounts.remote.profile.uri",
+ "identity.fxaccounts.remote.pairing.uri",
+ "identity.sync.tokenserver.uri",
+ ].every(pref => !Services.prefs.prefHasUserValue(pref)),
+ },
+ [BrowserUtils.PromoType.COOKIE_BANNERS]: {
+ enabledPref: "browser.promo.cookiebanners.enabled",
+ lazyStringSetPrefs: {},
+ illegalRegions: [],
+ showForEnterprise: true,
+ },
+};
+
+/*
+ * Finish setting up the PromoInfo data structure by attaching lazy prefs getters
+ * as specified in the structure. (the object for each pref in the lazyStringSetPrefs
+ * gets a `lazyValue` property attached to it).
+ */
+for (let promo of Object.values(PromoInfo)) {
+ for (let prefObj of Object.values(promo.lazyStringSetPrefs)) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ prefObj,
+ "lazyValue",
+ prefObj.name,
+ prefObj.default,
+ null,
+ stringPrefToSet
+ );
+ }
+}
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BrowserUtils,
+ "navigationRequireUserInteraction",
+ "browser.navigation.requireUserInteraction",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BrowserUtils,
+ "emailSupportedLocales",
+ "browser.send_to_device_locales",
+ "de,en-GB,en-US,es-AR,es-CL,es-ES,es-MX,fr,id,pl,pt-BR,ru,zh-TW",
+ null,
+ stringPrefToSet
+);