зеркало из https://github.com/mozilla/gecko-dev.git
430 строки
13 KiB
JavaScript
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;
|
|
}
|
|
}
|