summaryrefslogtreecommitdiffstats
path: root/toolkit/components/search/SearchEngineSelector.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/search/SearchEngineSelector.sys.mjs')
-rw-r--r--toolkit/components/search/SearchEngineSelector.sys.mjs743
1 files changed, 743 insertions, 0 deletions
diff --git a/toolkit/components/search/SearchEngineSelector.sys.mjs b/toolkit/components/search/SearchEngineSelector.sys.mjs
new file mode 100644
index 0000000000..0c9fb7cb70
--- /dev/null
+++ b/toolkit/components/search/SearchEngineSelector.sys.mjs
@@ -0,0 +1,743 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchEngineSelector",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+/**
+ * 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.NEW_SETTINGS_KEY);
+ this._remoteConfigOverrides = lazy.RemoteSettings(
+ lazy.SearchUtils.NEW_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.
+ */
+ 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;
+ }
+
+ /**
+ * Used by tests to get the configuration overrides.
+ */
+ 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 {object}
+ * An object with "engines" field, a sorted list of engines and
+ * optionally "privateDefault" which is an object containing the engine
+ * details for the engine which should be the default in Private Browsing mode.
+ */
+ 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 variants =
+ config.variants?.filter(variant =>
+ this.#matchesUserEnvironment(variant, userEnv)
+ ) ?? [];
+
+ if (!variants.length) {
+ continue;
+ }
+
+ let engine = structuredClone(config.base);
+ engine.identifier = config.identifier;
+
+ for (let variant of variants) {
+ engine = this.#deepCopyObject(engine, variant);
+ }
+
+ 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);
+ }
+ }
+
+ engines.sort(this._sort.bind(this, defaultEngine, privateDefault));
+
+ let result = { engines };
+
+ if (privateDefault) {
+ result.privateDefault = privateDefault;
+ }
+
+ 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 (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)
+ );
+ }
+
+ /**
+ * @param {string} userDistro
+ * The distribution from the user's environment.
+ * @param {Array} 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 {Array} 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 {Array} 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);
+ }
+
+ /**
+ * 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);
+ }
+}