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.mjs447
1 files changed, 447 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..962b9cb3df
--- /dev/null
+++ b/toolkit/components/search/SearchEngineSelector.sys.mjs
@@ -0,0 +1,447 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const USER_LOCALE = "$USER_LOCALE";
+const USER_REGION = "$USER_REGION";
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchEngineSelector",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+function hasAppKey(config, key) {
+ return "application" in config && key in config.application;
+}
+
+function sectionExcludes(config, key, value) {
+ return hasAppKey(config, key) && !config.application[key].includes(value);
+}
+
+function sectionIncludes(config, key, value) {
+ return hasAppKey(config, key) && config.application[key].includes(value);
+}
+
+function isDistroExcluded(config, key, distroID) {
+ // Should be excluded when:
+ // - There's a distroID and that is not in the non-empty distroID list.
+ // - There's no distroID and the distroID list is not empty.
+ const appKey = hasAppKey(config, key);
+ if (!appKey) {
+ return false;
+ }
+ const distroList = config.application[key];
+ if (distroID) {
+ return distroList.length && !distroList.includes(distroID);
+ }
+ return !!distroList.length;
+}
+
+function belowMinVersion(config, version) {
+ return (
+ hasAppKey(config, "minVersion") &&
+ Services.vc.compare(version, config.application.minVersion) < 0
+ );
+}
+
+function aboveMaxVersion(config, version) {
+ return (
+ hasAppKey(config, "maxVersion") &&
+ Services.vc.compare(version, config.application.maxVersion) > 0
+ );
+}
+
+/**
+ * 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.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
+ this._remoteConfig = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
+ this._listenerAdded = false;
+ this._onConfigurationUpdated = this._onConfigurationUpdated.bind(this);
+ this._changeListener = listener;
+ }
+
+ /**
+ * Handles getting the configuration from remote settings.
+ */
+ async getEngineConfiguration() {
+ if (this._getConfigurationPromise) {
+ return this._getConfigurationPromise;
+ }
+
+ this._configuration = await (this._getConfigurationPromise =
+ this._getConfiguration());
+ 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._listenerAdded = true;
+ }
+
+ return this._configuration;
+ }
+
+ /**
+ * 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();
+ }
+ }
+
+ /**
+ * @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.name]
+ * 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,
+ name = Services.appinfo.name ?? "",
+ version = Services.appinfo.version ?? "",
+ }) {
+ if (!this._configuration) {
+ await this.getEngineConfiguration();
+ }
+ lazy.logConsole.debug(
+ `fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${name}:${version}`
+ );
+ let engines = [];
+ const lcName = name.toLowerCase();
+ const lcVersion = version.toLowerCase();
+ const lcLocale = locale.toLowerCase();
+ const lcRegion = region.toLowerCase();
+ for (let config of this._configuration) {
+ const appliesTo = config.appliesTo || [];
+ const applies = appliesTo.filter(section => {
+ if ("experiment" in section) {
+ if (experiment != section.experiment) {
+ return false;
+ }
+ if (section.override) {
+ return true;
+ }
+ }
+
+ let shouldInclude = () => {
+ let included =
+ "included" in section &&
+ this._isInSection(lcRegion, lcLocale, section.included);
+ let excluded =
+ "excluded" in section &&
+ this._isInSection(lcRegion, lcLocale, section.excluded);
+ return included && !excluded;
+ };
+
+ const distroExcluded =
+ (distroID &&
+ sectionIncludes(section, "excludedDistributions", distroID)) ||
+ isDistroExcluded(section, "distributions", distroID);
+
+ if (distroID && !distroExcluded && section.override) {
+ if ("included" in section || "excluded" in section) {
+ return shouldInclude();
+ }
+ return true;
+ }
+
+ if (
+ sectionExcludes(section, "channel", channel) ||
+ sectionExcludes(section, "name", lcName) ||
+ distroExcluded ||
+ belowMinVersion(section, lcVersion) ||
+ aboveMaxVersion(section, lcVersion)
+ ) {
+ return false;
+ }
+ return shouldInclude();
+ });
+
+ let baseConfig = this._copyObject({}, config);
+
+ // Don't include any engines if every section is an override
+ // entry, these are only supposed to override otherwise
+ // included engine configurations.
+ let allOverrides = applies.every(e => "override" in e && e.override);
+ // Loop through all the appliedTo sections that apply to
+ // this configuration.
+ if (applies.length && !allOverrides) {
+ for (let section of applies) {
+ this._copyObject(baseConfig, section);
+ }
+
+ if (
+ "webExtension" in baseConfig &&
+ "locales" in baseConfig.webExtension
+ ) {
+ for (const webExtensionLocale of baseConfig.webExtension.locales) {
+ const engine = { ...baseConfig };
+ engine.webExtension = { ...baseConfig.webExtension };
+ delete engine.webExtension.locales;
+ engine.webExtension.locale = webExtensionLocale
+ .replace(USER_LOCALE, locale)
+ .replace(USER_REGION, lcRegion);
+ engines.push(engine);
+ }
+ } else {
+ const engine = { ...baseConfig };
+ (engine.webExtension = engine.webExtension || {}).locale =
+ lazy.SearchUtils.DEFAULT_TAG;
+ engines.push(engine);
+ }
+ }
+ }
+
+ let defaultEngine;
+ let privateEngine;
+
+ function shouldPrefer(setting, hasCurrentDefault, currentDefaultSetting) {
+ if (
+ setting == "yes" &&
+ (!hasCurrentDefault || currentDefaultSetting == "yes-if-no-other")
+ ) {
+ return true;
+ }
+ return setting == "yes-if-no-other" && !hasCurrentDefault;
+ }
+
+ for (const engine of engines) {
+ engine.telemetryId = engine.telemetryId
+ ?.replace(USER_LOCALE, locale)
+ .replace(USER_REGION, lcRegion);
+ if (
+ "default" in engine &&
+ shouldPrefer(
+ engine.default,
+ !!defaultEngine,
+ defaultEngine && defaultEngine.default
+ )
+ ) {
+ defaultEngine = engine;
+ }
+ if (
+ "defaultPrivate" in engine &&
+ shouldPrefer(
+ engine.defaultPrivate,
+ !!privateEngine,
+ privateEngine && privateEngine.defaultPrivate
+ )
+ ) {
+ privateEngine = engine;
+ }
+ }
+
+ engines.sort(this._sort.bind(this, defaultEngine, privateEngine));
+
+ let result = { engines };
+
+ if (privateEngine) {
+ result.privateDefault = privateEngine;
+ }
+
+ if (lazy.SearchUtils.loggingEnabled) {
+ lazy.logConsole.debug(
+ "fetchEngineConfiguration: " +
+ result.engines.map(e => e.webExtension.id)
+ );
+ }
+ return result;
+ }
+
+ _sort(defaultEngine, privateEngine, a, b) {
+ return (
+ this._sortIndex(b, defaultEngine, privateEngine) -
+ this._sortIndex(a, defaultEngine, privateEngine)
+ );
+ }
+
+ /**
+ * Create an index order to ensure default (and backup default)
+ * engines are ordered correctly.
+ *
+ * @param {object} obj
+ * Object representing the engine configation.
+ * @param {object} defaultEngine
+ * The default engine, for comparison to obj.
+ * @param {object} privateEngine
+ * The private engine, for comparison to obj.
+ * @returns {integer}
+ * Number indicating how this engine should be sorted.
+ */
+ _sortIndex(obj, defaultEngine, privateEngine) {
+ if (obj == defaultEngine) {
+ return Number.MAX_SAFE_INTEGER;
+ }
+ if (obj == privateEngine) {
+ return Number.MAX_SAFE_INTEGER - 1;
+ }
+ return obj.orderHint || 0;
+ }
+
+ /**
+ * Is the engine marked to be the default search engine.
+ *
+ * @param {object} obj - Object representing the engine configation.
+ * @returns {boolean} - Whether the engine should be default.
+ */
+ _isDefault(obj) {
+ return "default" in obj && obj.default === "yes";
+ }
+
+ /**
+ * Object.assign but ignore some keys
+ *
+ * @param {object} target - Object to copy to.
+ * @param {object} source - Object top copy from.
+ * @returns {object} - The source object.
+ */
+ _copyObject(target, source) {
+ for (let key in source) {
+ if (["included", "excluded", "appliesTo"].includes(key)) {
+ continue;
+ }
+ if (key == "webExtension") {
+ if (key in target) {
+ this._copyObject(target[key], source[key]);
+ } else {
+ target[key] = { ...source[key] };
+ }
+ } else {
+ target[key] = source[key];
+ }
+ }
+ return target;
+ }
+
+ /**
+ * Determines wether the section of the config applies to a user
+ * given what region + locale they are using.
+ *
+ * @param {string} region - The region the user is in.
+ * @param {string} locale - The language the user has configured.
+ * @param {object} config - Section of configuration.
+ * @returns {boolean} - Does the section apply for the region + locale.
+ */
+ _isInSection(region, locale, config) {
+ if (!config) {
+ return false;
+ }
+ if (config.everywhere) {
+ return true;
+ }
+ let locales = config.locales || {};
+ let inLocales =
+ "matches" in locales &&
+ !!locales.matches.find(e => e.toLowerCase() == locale);
+ let inRegions =
+ "regions" in config &&
+ !!config.regions.find(e => e.toLowerCase() == region);
+ if (
+ locales.startsWith &&
+ locales.startsWith.some(key => locale.startsWith(key))
+ ) {
+ inLocales = true;
+ }
+ if (config.locales && config.regions) {
+ return inLocales && inRegions;
+ }
+ return inLocales || inRegions;
+ }
+}