Source code
Revision control
Copy as Markdown
Other Tools
/* 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
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
return console.createInstance({
prefix: "SearchEngineSelector",
maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
});
});
/**
* @typedef {object} RefinedConfig
* @property {object[]} engines
* An array of objects defining the engines that should be presented to the user.
* @property {string} appDefaultEngineId
* The identifier of the engine that should be used for the application
* default engine.
* @property {string} [appPrivateDefaultEngineId]
* If specified, the identifier of the engine that should be used for the
* application default engine in private browsing mode.
*/
/**
* SearchEngineSelector parses the JSON configuration for
* search engines and returns the applicable engines depending
* on their region + locale.
*/
export class SearchEngineSelector {
/**
* @param {Function} listener
* A listener for configuration update changes.
*/
constructor(listener) {
this._remoteConfig = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
this._remoteConfigOverrides = lazy.RemoteSettings(
lazy.SearchUtils.SETTINGS_OVERRIDES_KEY
);
this._listenerAdded = false;
this._onConfigurationUpdated = this._onConfigurationUpdated.bind(this);
this._onConfigurationOverridesUpdated =
this._onConfigurationOverridesUpdated.bind(this);
this._changeListener = listener;
}
/**
* Resets the remote settings listeners.
*/
reset() {
if (this._listenerAdded) {
this._remoteConfig.off("sync", this._onConfigurationUpdated);
this._remoteConfigOverrides.off(
"sync",
this._onConfigurationOverridesUpdated
);
this._listenerAdded = false;
}
}
/**
* Handles getting the configuration from remote settings.
*
* @returns {object}
* The configuration data.
*/
async getEngineConfiguration() {
if (this._getConfigurationPromise) {
return this._getConfigurationPromise;
}
this._getConfigurationPromise = Promise.all([
this._getConfiguration(),
this._getConfigurationOverrides(),
]);
let remoteSettingsData = await this._getConfigurationPromise;
this._configuration = remoteSettingsData[0];
this._configurationOverrides = remoteSettingsData[1];
delete this._getConfigurationPromise;
if (!this._configuration?.length) {
throw Components.Exception(
"Failed to get engine data from Remote Settings",
Cr.NS_ERROR_UNEXPECTED
);
}
if (!this._listenerAdded) {
this._remoteConfig.on("sync", this._onConfigurationUpdated);
this._remoteConfigOverrides.on(
"sync",
this._onConfigurationOverridesUpdated
);
this._listenerAdded = true;
}
return this._configuration;
}
async findContextualSearchEngineByHost(host) {
for (let config of this._configuration) {
if (config.recordType !== "engine") {
continue;
}
let searchHost = new URL(config.base.urls.search.base).hostname;
if (searchHost.startsWith("www.")) {
searchHost = searchHost.slice(4);
}
if (searchHost.startsWith(host)) {
let engine = structuredClone(config.base);
engine.identifier = config.identifier;
return engine;
}
}
return null;
}
/**
* Used by tests to get the configuration overrides.
*
* @returns {object}
* The engine overrides data.
*/
async getEngineConfigurationOverrides() {
await this.getEngineConfiguration();
return this._configurationOverrides;
}
/**
* Obtains the configuration from remote settings. This includes
* verifying the signature of the record within the database.
*
* If the signature in the database is invalid, the database will be wiped
* and the stored dump will be used, until the settings next update.
*
* Note that this may cause a network check of the certificate, but that
* should generally be quick.
*
* @param {boolean} [firstTime]
* Internal boolean to indicate if this is the first time check or not.
* @returns {Array}
* An array of objects in the database, or an empty array if none
* could be obtained.
*/
async _getConfiguration(firstTime = true) {
let result = [];
let failed = false;
try {
result = await this._remoteConfig.get({
order: "id",
});
} catch (ex) {
lazy.logConsole.error(ex);
failed = true;
}
if (!result.length) {
lazy.logConsole.error("Received empty search configuration!");
failed = true;
}
// If we failed, or the result is empty, try loading from the local dump.
if (firstTime && failed) {
await this._remoteConfig.db.clear();
// Now call this again.
return this._getConfiguration(false);
}
return result;
}
/**
* Handles updating of the configuration. Note that the search service is
* only updated after a period where the user is observed to be idle.
*
* @param {object} options
* The options object
* @param {object} options.data
* The data to update
* @param {Array} options.data.current
* The new configuration object
*/
_onConfigurationUpdated({ data: { current } }) {
this._configuration = current;
lazy.logConsole.debug("Search configuration updated remotely");
if (this._changeListener) {
this._changeListener();
}
}
/**
* Handles updating of the configuration. Note that the search service is
* only updated after a period where the user is observed to be idle.
*
* @param {object} options
* The options object
* @param {object} options.data
* The data to update
* @param {Array} options.data.current
* The new configuration object
*/
_onConfigurationOverridesUpdated({ data: { current } }) {
this._configurationOverrides = current;
lazy.logConsole.debug("Search configuration overrides updated remotely");
if (this._changeListener) {
this._changeListener();
}
}
/**
* Obtains the configuration overrides from remote settings.
*
* @returns {Array}
* An array of objects in the database, or an empty array if none
* could be obtained.
*/
async _getConfigurationOverrides() {
let result = [];
try {
result = await this._remoteConfigOverrides.get();
} catch (ex) {
// This data is remote only, so we just return an empty array if it fails.
}
return result;
}
/**
* @param {object} options
* The options object
* @param {string} options.locale
* Users locale.
* @param {string} options.region
* Users region.
* @param {string} [options.channel]
* The update channel the application is running on.
* @param {string} [options.distroID]
* The distribution ID of the application.
* @param {string} [options.experiment]
* Any associated experiment id.
* @param {string} [options.appName]
* The name of the application.
* @param {string} [options.version]
* The version of the application.
* @returns {RefinedConfig}
* An object which contains the refined configuration with a filtered list
* of search engines, and the identifiers for the application default engines.
*/
async fetchEngineConfiguration({
locale,
region,
channel = "default",
distroID,
experiment,
appName = Services.appinfo.name ?? "",
version = Services.appinfo.version ?? "",
}) {
if (!this._configuration) {
await this.getEngineConfiguration();
}
lazy.logConsole.debug(
`fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${appName}:${version}`
);
appName = appName.toLowerCase();
version = version.toLowerCase();
locale = locale.toLowerCase();
region = region.toLowerCase();
let engines = [];
let defaultsConfig;
let engineOrders;
let userEnv = {
appName,
version,
locale,
region,
channel,
distroID,
experiment,
};
for (let config of this._configuration) {
if (config.recordType == "defaultEngines") {
defaultsConfig = config;
}
if (config.recordType == "engineOrders") {
engineOrders = config;
}
if (config.recordType !== "engine") {
continue;
}
let variant = config.variants?.findLast(v =>
this.#matchesUserEnvironment(v, userEnv)
);
if (!variant) {
continue;
}
let subVariant = variant.subVariants?.findLast(sv =>
this.#matchesUserEnvironment(sv, userEnv)
);
let engine = structuredClone(config.base);
engine.identifier = config.identifier;
engine = this.#deepCopyObject(engine, variant);
if (subVariant) {
engine = this.#deepCopyObject(engine, subVariant);
}
for (let override of this._configurationOverrides) {
if (override.identifier == engine.identifier) {
engine = this.#deepCopyObject(engine, override);
}
}
engines.push(engine);
}
let { defaultEngine, privateDefault } = this.#defaultEngines(
engines,
defaultsConfig,
userEnv
);
for (const orderData of engineOrders.orders) {
let environment = orderData.environment;
if (this.#matchesUserEnvironment({ environment }, userEnv)) {
this.#setEngineOrders(engines, orderData.order);
}
}
if (!defaultEngine) {
if (engines.length) {
lazy.logConsole.error(
"Could not find a matching default engine, using the first one in the list"
);
defaultEngine = engines[0];
} else {
throw new Error(
"Could not find any engines in the filtered configuration"
);
}
}
engines.sort(this._sort.bind(this, defaultEngine, privateDefault));
let result = { engines, appDefaultEngineId: defaultEngine.identifier };
if (privateDefault) {
result.appPrivateDefaultEngineId = privateDefault.identifier;
}
if (lazy.SearchUtils.loggingEnabled) {
lazy.logConsole.debug(
"fetchEngineConfiguration: " + result.engines.map(e => e.identifier)
);
}
return result;
}
_sort(defaultEngine, defaultPrivateEngine, a, b) {
return (
this._sortIndex(b, defaultEngine, defaultPrivateEngine) -
this._sortIndex(a, defaultEngine, defaultPrivateEngine)
);
}
/**
* Create an index order to ensure default (and backup default)
* engines are ordered correctly.
*
* @param {object} obj
* Object representing the engine configuration.
* @param {object} defaultEngine
* The default engine, for comparison to obj.
* @param {object} defaultPrivateEngine
* The default private engine, for comparison to obj.
* @returns {integer}
* Number indicating how this engine should be sorted.
*/
_sortIndex(obj, defaultEngine, defaultPrivateEngine) {
if (obj == defaultEngine) {
return Number.MAX_SAFE_INTEGER;
}
if (obj == defaultPrivateEngine) {
return Number.MAX_SAFE_INTEGER - 1;
}
return obj.orderHint || 0;
}
/**
* Deep copies an object to the target object and ignores some keys.
*
* @param {object} target - Object to copy to.
* @param {object} source - Object to copy from.
* @returns {object} - The source object.
*/
#deepCopyObject(target, source) {
for (let key in source) {
if (["environment"].includes(key)) {
continue;
}
if (["subVariants"].includes(key)) {
continue;
}
if (typeof source[key] == "object" && !Array.isArray(source[key])) {
if (key in target) {
this.#deepCopyObject(target[key], source[key]);
} else {
target[key] = structuredClone(source[key]);
}
} else {
target[key] = structuredClone(source[key]);
}
}
return target;
}
/**
* Matches the user's environment against the engine config's environment.
*
* @param {object} config
* The config for the given base or variant engine.
* @param {object} user
* The user's environment we use to match with the engine's environment.
* @param {string} user.appName
* The name of the application.
* @param {string} user.version
* The version of the application.
* @param {string} user.locale
* The locale of the user.
* @param {string} user.region
* The region of the user.
* @param {string} user.channel
* The channel the application is running on.
* @param {string} user.distroID
* The distribution ID of the application.
* @param {string} user.experiment
* Any associated experiment id.
* @returns {boolean}
* True if the engine config's environment matches the user's environment.
*/
#matchesUserEnvironment(config, user = {}) {
if ("experiment" in config.environment) {
if (user.experiment != config.environment.experiment) {
return false;
}
}
if ("excludedDistributions" in config.environment) {
if (config.environment.excludedDistributions.includes(user.distroID)) {
return false;
}
}
// Skip the optional flag for Desktop, it's a feature only on Android.
if (config.optional) {
return false;
}
return (
this.#matchesRegionAndLocale(
user.region,
user.locale,
config.environment
) &&
this.#matchesDistribution(
user.distroID,
config.environment.distributions
) &&
this.#matchesVersions(
config.environment.minVersion,
config.environment.maxVersion,
user.version
) &&
this.#matchesChannel(config.environment.channels, user.channel) &&
this.#matchesApplication(config.environment.applications, user.appName) &&
!this.#hasDeviceType(config.environment)
);
}
/**
* @param {string} userDistro
* The distribution from the user's environment.
* @param {string[]} configDistro
* An array of distributions for the particular environment in the config.
* @returns {boolean}
* True if the user's distribution is included in the config distribution
* list.
*/
#matchesDistribution(userDistro, configDistro) {
// If there's no distribution for this engineConfig, ignore the check.
if (!configDistro) {
return true;
}
return configDistro?.includes(userDistro);
}
/**
* @param {string} min
* The minimum version supported.
* @param {string} max
* The maximum version supported.
* @param {string} userVersion
* The user's version.
* @returns {boolean}
* True if the user's version is within the range of the min and max versions
* supported.
*/
#matchesVersions(min, max, userVersion) {
// If there's no versions for this engineConfig, ignore the check.
if (!min && !max) {
return true;
}
if (!userVersion) {
return false;
}
if (min && !max) {
return this.#isAboveOrEqualMin(userVersion, min);
}
if (!min && max) {
return this.#isBelowOrEqualMax(userVersion, max);
}
return (
this.#isAboveOrEqualMin(userVersion, min) &&
this.#isBelowOrEqualMax(userVersion, max)
);
}
#isAboveOrEqualMin(userVersion, min) {
return Services.vc.compare(userVersion, min) >= 0;
}
#isBelowOrEqualMax(userVersion, max) {
return Services.vc.compare(userVersion, max) <= 0;
}
/**
* @param {string[]} configChannels
* Release channels such as nightly, beta, release, esr.
* @param {string} userChannel
* The user's channel.
* @returns {boolean}
* True if the user's channel is included in the config channels.
*/
#matchesChannel(configChannels, userChannel) {
// If there's no channels for this engineConfig, ignore the check.
if (!configChannels) {
return true;
}
return configChannels.includes(userChannel);
}
/**
* @param {string[]} configApps
* The applications such as firefox, firefox-android, firefox-ios,
* focus-android, and focus-ios.
* @param {string} userApp
* The user's application.
* @returns {boolean}
* True if the user's application is included in the config applications.
*/
#matchesApplication(configApps, userApp) {
// If there's no config Applications for this engineConfig, ignore the check.
if (!configApps) {
return true;
}
return configApps.includes(userApp);
}
/**
* Generally the device type option should only be used when the application
* is selected to be on an android or iOS based product. However, we support
* rejecting if this is non-empty in case of future requirements that we haven't
* predicted.
*
* @param {object} environment
* An environment section from the engine configuration.
* @returns {boolean}
* Returns true if there is a device type section and it is not empty.
*/
#hasDeviceType(environment) {
return !!environment.deviceType?.length;
}
/**
* Determines whether the region and locale constraints in the config
* environment applies to a user given what region and locale they are using.
*
* @param {string} region
* The region the user is in.
* @param {string} locale
* The language the user has configured.
* @param {object} configEnv
* The environment of the engine configuration.
* @returns {boolean}
* True if the user's region and locale matches the config's region and
* locale contraints. Otherwise false.
*/
#matchesRegionAndLocale(region, locale, configEnv) {
if (
this.#doesConfigInclude(configEnv.excludedLocales, locale) ||
this.#doesConfigInclude(configEnv.excludedRegions, region)
) {
return false;
}
if (configEnv.allRegionsAndLocales) {
return true;
}
// When none of the regions and locales are set. This implies its available
// everywhere.
if (
!Object.hasOwn(configEnv, "allRegionsAndLocales") &&
!Object.hasOwn(configEnv, "regions") &&
!Object.hasOwn(configEnv, "locales")
) {
return true;
}
if (
this.#doesConfigInclude(configEnv?.locales, locale) &&
this.#doesConfigInclude(configEnv?.regions, region)
) {
return true;
}
if (
this.#doesConfigInclude(configEnv?.locales, locale) &&
!Object.hasOwn(configEnv, "regions")
) {
return true;
}
if (
this.#doesConfigInclude(configEnv?.regions, region) &&
!Object.hasOwn(configEnv, "locales")
) {
return true;
}
return false;
}
/**
* This function converts the characters in the config to lowercase and
* checks if the user's locale or region is included in config the
* environment.
*
* @param {Array} configArray
* An Array of locales or regions from the config environment.
* @param {string} compareItem
* The user's locale or region.
* @returns {boolean}
* True if user's region or locale is found in the config environment.
* Otherwise false.
*/
#doesConfigInclude(configArray, compareItem) {
if (!configArray) {
return false;
}
return configArray.find(
configItem => configItem.toLowerCase() === compareItem
);
}
/**
* Gets the default engine and default private engine based on the user's
* environment.
*
* @param {Array} engines
* An array that contains the engines for the user environment.
* @param {object} defaultsConfig
* The defaultEngines record type from the search config.
* @param {object} userEnv
* The user's environment.
* @returns {object}
* An object with default engine and default private engine.
*/
#defaultEngines(engines, defaultsConfig, userEnv) {
let defaultEngine, privateDefault;
for (let data of defaultsConfig.specificDefaults) {
let environment = data.environment;
if (this.#matchesUserEnvironment({ environment }, userEnv)) {
defaultEngine = this.#findDefault(engines, data) ?? defaultEngine;
privateDefault =
this.#findDefault(engines, data, "private") ?? privateDefault;
}
}
defaultEngine ??= this.#findGlobalDefault(engines, defaultsConfig);
privateDefault ??= this.#findGlobalDefault(
engines,
defaultsConfig,
"private"
);
return { defaultEngine, privateDefault };
}
/**
* Finds the global default engine or global default private engine.
*
* @param {Array} engines
* The engines for the user environment.
* @param {string} config
* The defaultEngines record from the config.
* @param {string} [engineType]
* A string to identify default or default private.
* @returns {object}
* The global default engine or global default private engine.
*/
#findGlobalDefault(engines, config, engineType = "default") {
let engine;
if (config.globalDefault && engineType == "default") {
engine = engines.find(e => e.identifier == config.globalDefault);
}
if (config.globalDefaultPrivate && engineType == "private") {
engine = engines.find(e => e.identifier == config.globalDefaultPrivate);
}
return engine;
}
/**
* Finds the default engine or default private engine from the list of
* engines that match the user's environment.
*
* @param {Array} engines
* The engines for the user environment.
* @param {string} config
* The specific defaults record that contains the default engine or default
* private engine identifer for the environment.
* @param {string} [engineType]
* A string to identify default engine or default private engine.
* @returns {object|undefined}
* The default engine or default private engine. Undefined if none can be
* found.
*/
#findDefault(engines, config, engineType = "default") {
let defaultMatch =
engineType == "default" ? config.default : config.defaultPrivate;
if (!defaultMatch) {
return undefined;
}
return this.#findEngineWithMatch(engines, defaultMatch);
}
/**
* Sets the orderHint number for the engines.
*
* @param {Array} engines
* The engines for the user environment.
* @param {Array} orderedEngines
* The ordering of engines. Engines in the beginning of the list get a higher
* orderHint number.
*/
#setEngineOrders(engines, orderedEngines) {
let orderNumber = orderedEngines.length;
for (const engine of orderedEngines) {
let foundEngine = this.#findEngineWithMatch(engines, engine);
if (foundEngine) {
foundEngine.orderHint = orderNumber;
orderNumber -= 1;
}
}
}
/**
* Finds an engine with the given match.
*
* @param {object[]} engines
* An array of search engine configurations.
* @param {string} match
* A string to match against the engine identifier. This will be an exact
* match, unless the string ends with `*`, in which case it will use a
* startsWith match.
* @returns {object|undefined}
*/
#findEngineWithMatch(engines, match) {
if (match.endsWith("*")) {
let matchNoStar = match.slice(0, -1);
return engines.find(e => e.identifier.startsWith(matchNoStar));
}
return engines.find(e => e.identifier == match);
}
}