gecko-dev/toolkit/components/search/SearchEngineSelector.jsm

430 строки
13 KiB
JavaScript

/* 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/. */
"use strict";
var EXPORTED_SYMBOLS = ["SearchEngineSelector"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
RemoteSettings: "resource://services-settings/remote-settings.js",
SearchUtils: "resource://gre/modules/SearchUtils.jsm",
Services: "resource://gre/modules/Services.jsm",
});
const USER_LOCALE = "$USER_LOCALE";
XPCOMUtils.defineLazyGetter(this, "logConsole", () => {
return console.createInstance({
prefix: "SearchEngineSelector",
maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn",
});
});
function getAppInfo(key) {
let value = null;
try {
// Services.appinfo is often null in tests.
value = Services.appinfo[key].toLowerCase();
} catch (e) {}
return value;
}
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.
*/
class SearchEngineSelector {
/**
* @param {function} listener
* A listener for configuration update changes.
*/
constructor(listener) {
this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
this._remoteConfig = RemoteSettings(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) {
logConsole.error(ex);
failed = true;
}
if (!result.length) {
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.
*/
_onConfigurationUpdated({ data: { current } }) {
this._configuration = current;
logConsole.debug("Search configuration updated remotely");
if (this._changeListener) {
this._changeListener();
}
}
/**
* @param {object} options
* @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.
* @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,
}) {
if (!this._configuration) {
await this.getEngineConfiguration();
}
let name = getAppInfo("name");
let version = getAppInfo("version");
logConsole.debug(
`fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${name}:${version}`
);
let engines = [];
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;
}
}
const distroExcluded =
(distroID &&
sectionIncludes(section, "excludedDistributions", distroID)) ||
isDistroExcluded(section, "distributions", distroID);
if (distroID && !distroExcluded && section.override) {
return true;
}
if (
sectionExcludes(section, "channel", channel) ||
sectionExcludes(section, "name", name) ||
distroExcluded ||
belowMinVersion(section, version) ||
aboveMaxVersion(section, version)
) {
return false;
}
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;
});
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 == USER_LOCALE ? locale : webExtensionLocale;
engines.push(engine);
}
} else {
const engine = { ...baseConfig };
(engine.webExtension = engine.webExtension || {}).locale =
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) {
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 (SearchUtils.loggingEnabled) {
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;
}
}