Source code
Revision control
Copy as Markdown
Other Tools
/* -*- 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
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",
});
ChromeUtils.defineLazyGetter(lazy, "CatManListenerManager", () => {
const CatManListenerManager = {
cachedModules: {},
cachedListeners: {},
// All 3 category manager notifications will have the category name
// as the `data` part of the observer notification.
observe(_subject, _topic, categoryName) {
delete this.cachedListeners[categoryName];
},
/**
* Fetch and parse category manager consumers for a given category name.
* Will use cachedListeners for the given category name if they exist.
*/
getListeners(categoryName) {
if (Object.hasOwn(this.cachedListeners, categoryName)) {
return this.cachedListeners[categoryName];
}
let rv = Array.from(
Services.catMan.enumerateCategory(categoryName),
({ data: module, value }) => {
try {
let [objName, method] = value.split(".");
if (!Object.hasOwn(this.cachedModules, module)) {
this.cachedModules[module] = ChromeUtils.importESModule(module);
}
let fn = async (...args) => {
try {
// This await doesn't do much as the caller won't await us,
// but means we can catch and report any exceptions.
await this.cachedModules[module][objName][method](...args);
} catch (ex) {
console.error(
`Error in processing ${categoryName} for ${objName}`
);
console.error(ex);
}
};
return fn;
} catch (ex) {
console.error(
`Error processing category manifest for ${module}: ${value}`,
ex
);
return null;
}
}
);
// Remove any null entries.
rv = rv.filter(l => !!l);
this.cachedListeners[categoryName] = rv;
return rv;
},
};
Services.obs.addObserver(
CatManListenerManager,
"xpcom-category-entry-removed"
);
Services.obs.addObserver(CatManListenerManager, "xpcom-category-entry-added");
Services.obs.addObserver(CatManListenerManager, "xpcom-category-cleared");
return CatManListenerManager;
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"INVALID_SHAREABLE_SCHEMES",
"services.sync.engine.tabs.filteredSchemes",
"",
null,
val => {
return new Set(val.split("|"));
}
);
ChromeUtils.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:settings") &&
!location.startsWith("about:logins") &&
!location.startsWith("about:firefoxview")
);
},
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" ||
) {
href =
node.getAttribute("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.
* - 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.
// See also nsWindowWatcher::GetWindowOpenLocation in
// toolkit/components/windowwatcher/nsWindowWatcher.cpp
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) {
// 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;
},
/**
* Invoke all the category manager consumers of a given JS consumer.
* Similar to the (C++-only) NS_CreateServicesFromCategory in that it'll
* abstract away the actual work of invoking the modules/services.
* Different in that it's JS-only and will invoke methods in modules
* instead of using XPCOM services.
*/
callModulesFromCategory(categoryName, ...args) {
for (let listener of lazy.CatManListenerManager.getListeners(
categoryName
)) {
// Note that we deliberately do not await anything here.
listener(...args);
}
},
/**
* 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_regions",
default:
"ca,my,nz,sg,gb,gg,im,io,je,uk,vg,as,mp,pr,um,us,vi,de,fr,at,be,ch,es,it,ie,nl,se,fi,bg,cy,cz,dk,ee,hr,hu,lt,lu,lv,mt,pl,pt,ro,si,sk",
},
disallowedRegions: {
name: "browser.vpn_promo.disallowed_regions",
default: "ae,by,cn,cu,iq,ir,kp,om,ru,sd,sy,tm,tr",
},
},
illegalRegions: [
"ae",
"by",
"cn",
"cu",
"iq",
"ir",
"kp",
"om",
"ru",
"sd",
"sy",
"tm",
"tr",
],
},
[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
);