summaryrefslogtreecommitdiffstats
path: root/toolkit/components/search/SearchService.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/search/SearchService.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/search/SearchService.sys.mjs')
-rw-r--r--toolkit/components/search/SearchService.sys.mjs4068
1 files changed, 4068 insertions, 0 deletions
diff --git a/toolkit/components/search/SearchService.sys.mjs b/toolkit/components/search/SearchService.sys.mjs
new file mode 100644
index 0000000000..4e1a99671f
--- /dev/null
+++ b/toolkit/components/search/SearchService.sys.mjs
@@ -0,0 +1,4068 @@
+/* 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/. */
+
+/* eslint no-shadow: error, mozilla/no-aArgs: error */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AppProvidedSearchEngine:
+ "resource://gre/modules/AppProvidedSearchEngine.sys.mjs",
+ AddonSearchEngine: "resource://gre/modules/AddonSearchEngine.sys.mjs",
+ IgnoreLists: "resource://gre/modules/IgnoreLists.sys.mjs",
+ loadAndParseOpenSearchEngine:
+ "resource://gre/modules/OpenSearchLoader.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.sys.mjs",
+ PolicySearchEngine: "resource://gre/modules/PolicySearchEngine.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchEngine: "resource://gre/modules/SearchEngine.sys.mjs",
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+ SearchEngineSelectorOld:
+ "resource://gre/modules/SearchEngineSelectorOld.sys.mjs",
+ SearchSettings: "resource://gre/modules/SearchSettings.sys.mjs",
+ SearchStaticData: "resource://gre/modules/SearchStaticData.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ UserSearchEngine: "resource://gre/modules/UserSearchEngine.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchService",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+/**
+ * A reference to the handler for the default override allowlist.
+ *
+ * @type {SearchDefaultOverrideAllowlistHandler}
+ */
+ChromeUtils.defineLazyGetter(lazy, "defaultOverrideAllowlist", () => {
+ return new SearchDefaultOverrideAllowlistHandler();
+});
+
+// Exported to tests for not splitting ids when building webextension ids.
+export const NON_SPLIT_ENGINE_IDS = [
+ "allegro-pl",
+ "bok-NO",
+ "daum-kr",
+ "faclair-beag",
+ "gulesider-NO",
+ "mapy-cz",
+ "naver-kr",
+ "prisjakt-sv-SE",
+ "seznam-cz",
+ "tyda-sv-SE",
+ "wolnelektury-pl",
+ "yahoo-jp",
+ "yahoo-jp-auctions",
+];
+
+const TOPIC_LOCALES_CHANGE = "intl:app-locales-changed";
+const QUIT_APPLICATION_TOPIC = "quit-application";
+
+// The update timer for OpenSearch engines checks in once a day.
+const OPENSEARCH_UPDATE_TIMER_TOPIC = "search-engine-update-timer";
+const OPENSEARCH_UPDATE_TIMER_INTERVAL = 60 * 60 * 24;
+
+// This is the amount of time we'll be idle for before applying any configuration
+// changes.
+const RECONFIG_IDLE_TIME_SEC = 5 * 60;
+
+/**
+ * A reason that is used in the change of default search engine event telemetry.
+ * These are mutally exclusive.
+ */
+const REASON_CHANGE_MAP = new Map([
+ // The cause of the change is unknown.
+ [Ci.nsISearchService.CHANGE_REASON_UNKNOWN, "unknown"],
+ // The user changed the default search engine via the options in the
+ // preferences UI.
+ [Ci.nsISearchService.CHANGE_REASON_USER, "user"],
+ // The change resulted from the user toggling the "Use this search engine in
+ // Private Windows" option in the preferences UI.
+ [Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT, "user_private_split"],
+ // The user changed the default via keys (cmd/ctrl-up/down) in the separate
+ // search bar.
+ [Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR, "user_searchbar"],
+ // The user changed the default via context menu on the one-off buttons in the
+ // separate search bar.
+ [
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT,
+ "user_searchbar_context",
+ ],
+ // An add-on requested the change of default on install, which was either
+ // accepted automatically or by the user.
+ [Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL, "addon-install"],
+ // An add-on was uninstalled, which caused the engine to be uninstalled.
+ [Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL, "addon-uninstall"],
+ // A configuration update caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_CONFIG, "config"],
+ // A locale update caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_LOCALE, "locale"],
+ // A region update caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_REGION, "region"],
+ // Turning on/off an experiment caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_EXPERIMENT, "experiment"],
+ // An enterprise policy caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_ENTERPRISE, "enterprise"],
+ // The UI Tour caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_UITOUR, "uitour"],
+ // The engine updated.
+ [Ci.nsISearchService.CHANGE_REASON_ENGINE_UPDATE, "engine-update"],
+]);
+
+/**
+ * The ParseSubmissionResult contains getter methods that return attributes
+ * about the parsed submission url.
+ *
+ * @implements {nsIParseSubmissionResult}
+ */
+class ParseSubmissionResult {
+ constructor(engine, terms, termsParameterName) {
+ this.#engine = engine;
+ this.#terms = terms;
+ this.#termsParameterName = termsParameterName;
+ }
+
+ get engine() {
+ return this.#engine;
+ }
+
+ get terms() {
+ return this.#terms;
+ }
+
+ get termsParameterName() {
+ return this.#termsParameterName;
+ }
+
+ /**
+ * The search engine associated with the URL passed in to
+ * nsISearchEngine::parseSubmissionURL, or null if the URL does not represent
+ * a search submission.
+ *
+ * @type {nsISearchEngine|null}
+ */
+ #engine;
+
+ /**
+ * String containing the sought terms. This can be an empty string in case no
+ * terms were specified or the URL does not represent a search submission.*
+ *
+ * @type {string}
+ */
+ #terms;
+
+ /**
+ * The name of the query parameter used by `engine` for queries. E.g. "q".
+ *
+ * @type {string}
+ */
+ #termsParameterName;
+
+ QueryInterface = ChromeUtils.generateQI(["nsISearchParseSubmissionResult"]);
+}
+
+const gEmptyParseSubmissionResult = Object.freeze(
+ new ParseSubmissionResult(null, "", "")
+);
+
+/**
+ * The search service handles loading and maintaining of search engines. It will
+ * also work out the default lists for each locale/region.
+ *
+ * @implements {nsISearchService}
+ */
+export class SearchService {
+ constructor() {
+ // this._engines is prefixed with _ rather than # because it is called from
+ // a test.
+ this._engines = new Map();
+ this._settings = new lazy.SearchSettings(this);
+
+ this.#defineLazyPreferenceGetters();
+ }
+
+ classID = Components.ID("{7319788a-fe93-4db3-9f39-818cf08f4256}");
+
+ get defaultEngine() {
+ this.#ensureInitialized();
+ return this._getEngineDefault(false);
+ }
+
+ set defaultEngine(newEngine) {
+ this.#ensureInitialized();
+ this.#setEngineDefault(false, newEngine);
+ }
+
+ get defaultPrivateEngine() {
+ this.#ensureInitialized();
+ return this._getEngineDefault(this.#separatePrivateDefault);
+ }
+
+ set defaultPrivateEngine(newEngine) {
+ this.#ensureInitialized();
+ if (!this._separatePrivateDefaultPrefValue) {
+ Services.prefs.setBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ }
+ this.#setEngineDefault(this.#separatePrivateDefault, newEngine);
+ }
+
+ async getDefault() {
+ await this.init();
+ return this.defaultEngine;
+ }
+
+ async setDefault(engine, changeSource) {
+ await this.init();
+ this.#setEngineDefault(false, engine, changeSource);
+ }
+
+ async getDefaultPrivate() {
+ await this.init();
+ return this.defaultPrivateEngine;
+ }
+
+ async setDefaultPrivate(engine, changeSource) {
+ await this.init();
+ if (!this._separatePrivateDefaultPrefValue) {
+ Services.prefs.setBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ }
+ this.#setEngineDefault(this.#separatePrivateDefault, engine, changeSource);
+ }
+
+ /**
+ * @returns {SearchEngine}
+ * The engine that is the default for this locale/region, ignoring any
+ * user changes to the default engine.
+ */
+ get appDefaultEngine() {
+ return this.#appDefaultEngine();
+ }
+
+ /**
+ * @returns {SearchEngine}
+ * The engine that is the default for this locale/region in private browsing
+ * mode, ignoring any user changes to the default engine.
+ * Note: if there is no default for this locale/region, then the non-private
+ * browsing engine will be returned.
+ */
+ get appPrivateDefaultEngine() {
+ return this.#appDefaultEngine(this.#separatePrivateDefault);
+ }
+
+ /**
+ * Determine whether initialization has been completed.
+ *
+ * Clients of the service can use this attribute to quickly determine whether
+ * initialization is complete, and decide to trigger some immediate treatment,
+ * to launch asynchronous initialization or to bailout.
+ *
+ * Note that this attribute does not indicate that initialization has
+ * succeeded, use hasSuccessfullyInitialized() for that.
+ *
+ * @returns {boolean}
+ * |true | if the search service has finished its attempt to initialize and
+ * we have an outcome. It could have failed or succeeded during this
+ * process.
+ * |false| if initialization has not been triggered yet or initialization is
+ * still ongoing.
+ */
+ get isInitialized() {
+ return (
+ this.#initializationStatus == "success" ||
+ this.#initializationStatus == "failed"
+ );
+ }
+
+ /**
+ * Determine whether initialization has been successfully completed.
+ *
+ * @returns {boolean}
+ * |true | if the search service has succesfully initialized.
+ * |false| if initialization has not been started yet, initialization is
+ * still ongoing or initializaiton has failed.
+ */
+ get hasSuccessfullyInitialized() {
+ return this.#initializationStatus == "success";
+ }
+
+ /**
+ * A promise that is resolved when initialization has finished. This does not
+ * trigger initialization to begin.
+ *
+ * @returns {Promise}
+ * Resolved when initalization has successfully finished, and rejected if it
+ * has failed.
+ */
+ get promiseInitialized() {
+ return this.#initDeferredPromise.promise;
+ }
+
+ getDefaultEngineInfo() {
+ let [telemetryId, defaultSearchEngineData] = this.#getEngineInfo(
+ this.defaultEngine
+ );
+ const result = {
+ defaultSearchEngine: telemetryId,
+ defaultSearchEngineData,
+ };
+
+ if (this.#separatePrivateDefault) {
+ let [privateTelemetryId, defaultPrivateSearchEngineData] =
+ this.#getEngineInfo(this.defaultPrivateEngine);
+ result.defaultPrivateSearchEngine = privateTelemetryId;
+ result.defaultPrivateSearchEngineData = defaultPrivateSearchEngineData;
+ }
+
+ return result;
+ }
+
+ /**
+ * If possible, please call getEngineById() rather than getEngineByName()
+ * because engines are stored as { id: object } in this._engine Map.
+ *
+ * Returns the engine associated with the name.
+ *
+ * @param {string} engineName
+ * The name of the engine.
+ * @returns {SearchEngine}
+ * The associated engine if found, null otherwise.
+ */
+ getEngineByName(engineName) {
+ this.#ensureInitialized();
+ return this.#getEngineByName(engineName);
+ }
+
+ /**
+ * Returns the engine associated with the name without initialization checks.
+ *
+ * @param {string} engineName
+ * The name of the engine.
+ * @returns {SearchEngine}
+ * The associated engine if found, null otherwise.
+ */
+ #getEngineByName(engineName) {
+ for (let engine of this._engines.values()) {
+ if (engine.name == engineName) {
+ return engine;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the engine associated with the id.
+ *
+ * @param {string} engineId
+ * The id of the engine.
+ * @returns {SearchEngine}
+ * The associated engine if found, null otherwise.
+ */
+ getEngineById(engineId) {
+ this.#ensureInitialized();
+ return this._engines.get(engineId) || null;
+ }
+
+ async getEngineByAlias(alias) {
+ await this.init();
+ for (var engine of this._engines.values()) {
+ if (engine && engine.aliases.includes(alias)) {
+ return engine;
+ }
+ }
+ return null;
+ }
+
+ async getEngines() {
+ await this.init();
+ lazy.logConsole.debug("getEngines: getting all engines");
+ return this.#sortedEngines;
+ }
+
+ async getVisibleEngines() {
+ await this.init(true);
+ lazy.logConsole.debug("getVisibleEngines: getting all visible engines");
+ return this.#sortedVisibleEngines;
+ }
+
+ async getAppProvidedEngines() {
+ await this.init();
+
+ return this._sortEnginesByDefaults(
+ this.#sortedEngines.filter(e => e.isAppProvided)
+ );
+ }
+
+ async getEnginesByExtensionID(extensionID) {
+ await this.init();
+ return this.#getEnginesByExtensionID(extensionID);
+ }
+
+ /**
+ * This function calls #init to start initialization when it has not been
+ * started yet. Otherwise, it returns the pending promise.
+ *
+ * @returns {Promise}
+ * Returns the pending Promise when #init has started but not yet finished.
+ * | Resolved | when initialization has successfully finished.
+ * | Rejected | when initialization has failed.
+ *
+ */
+ async init() {
+ if (["started", "success", "failed"].includes(this.#initializationStatus)) {
+ return this.promiseInitialized;
+ }
+ this.#initializationStatus = "started";
+ return this.#init();
+ }
+
+ /**
+ * Runs background checks for the search service. This is called from
+ * BrowserGlue and may be run once per session if the user is idle for
+ * long enough.
+ */
+ async runBackgroundChecks() {
+ await this.init();
+ await this.#migrateLegacyEngines();
+ await this.#checkWebExtensionEngines();
+ await this.#addOpenSearchTelemetry();
+ }
+
+ /**
+ * Test only - reset SearchService data. Ideally this should be replaced
+ */
+ reset() {
+ this.#initializationStatus = "not initialized";
+ this.#initDeferredPromise = Promise.withResolvers();
+ this.#startupExtensions = new Set();
+ this._engines.clear();
+ this._cachedSortedEngines = null;
+ this.#currentEngine = null;
+ this.#currentPrivateEngine = null;
+ this._searchDefault = null;
+ this.#searchPrivateDefault = null;
+ this.#maybeReloadDebounce = false;
+ this._settings._batchTask?.disarm();
+ if (this.#engineSelector) {
+ this.#engineSelector.reset();
+ this.#engineSelector = null;
+ }
+ }
+
+ // Test-only function to set SearchService initialization status
+ forceInitializationStatusForTests(status) {
+ this.#initializationStatus = status;
+ }
+
+ /**
+ * Test only variable to indicate an error should occur during
+ * search service initialization.
+ *
+ * @type {string}
+ */
+ errorToThrowInTest = null;
+
+ // Test-only function to reset just the engine selector so that it can
+ // load a different configuration.
+ resetEngineSelector() {
+ if (lazy.SearchUtils.newSearchConfigEnabled) {
+ this.#engineSelector = new lazy.SearchEngineSelector(
+ this.#handleConfigurationUpdated.bind(this)
+ );
+ } else {
+ this.#engineSelector = new lazy.SearchEngineSelectorOld(
+ this.#handleConfigurationUpdated.bind(this)
+ );
+ }
+ }
+
+ resetToAppDefaultEngine() {
+ let appDefaultEngine = this.appDefaultEngine;
+ appDefaultEngine.hidden = false;
+ this.defaultEngine = appDefaultEngine;
+ }
+
+ async maybeSetAndOverrideDefault(extension) {
+ let searchProvider =
+ extension.manifest.chrome_settings_overrides.search_provider;
+ let engine = this.getEngineByName(searchProvider.name);
+ if (!engine || !engine.isAppProvided || engine.hidden) {
+ // If the engine is not application provided, then we shouldn't simply
+ // set default to it.
+ // If the engine is application provided, but hidden, then we don't
+ // switch to it, nor do we try to install it.
+ return {
+ canChangeToAppProvided: false,
+ canInstallEngine: !engine?.hidden,
+ };
+ }
+
+ if (
+ extension.startupReason === "ADDON_INSTALL" ||
+ extension.startupReason === "ADDON_ENABLE"
+ ) {
+ // Don't allow an extension to set the default if it is already the default.
+ if (this.defaultEngine.name == searchProvider.name) {
+ return {
+ canChangeToAppProvided: false,
+ canInstallEngine: false,
+ };
+ }
+ if (
+ !(await lazy.defaultOverrideAllowlist.canOverride(
+ extension,
+ engine._extensionID
+ ))
+ ) {
+ lazy.logConsole.debug(
+ "Allowing default engine to be set to app-provided.",
+ extension.id
+ );
+ // We don't allow overriding the engine in this case, but we can allow
+ // the extension to change the default engine.
+ return {
+ canChangeToAppProvided: true,
+ canInstallEngine: false,
+ };
+ }
+ // We're ok to override.
+ engine.overrideWithEngine({ extension });
+ lazy.logConsole.debug(
+ "Allowing default engine to be set to app-provided and overridden.",
+ extension.id
+ );
+ return {
+ canChangeToAppProvided: true,
+ canInstallEngine: false,
+ };
+ }
+
+ if (
+ engine.getAttr("overriddenBy") == extension.id &&
+ (await lazy.defaultOverrideAllowlist.canOverride(
+ extension,
+ engine._extensionID
+ ))
+ ) {
+ engine.overrideWithEngine({ extension });
+ lazy.logConsole.debug(
+ "Re-enabling overriding of core extension by",
+ extension.id
+ );
+ return {
+ canChangeToAppProvided: true,
+ canInstallEngine: false,
+ };
+ }
+
+ return {
+ canChangeToAppProvided: false,
+ canInstallEngine: false,
+ };
+ }
+
+ /**
+ * Adds a search engine that is specified from enterprise policies.
+ *
+ * @param {object} details
+ * An object that matches the `SearchEngines` policy schema.
+ * @param {object} [settings]
+ * The saved settings for the user.
+ * @see browser/components/enterprisepolicies/schemas/policies-schema.json
+ */
+ async #addPolicyEngine(details, settings) {
+ let newEngine = new lazy.PolicySearchEngine({ details, settings });
+ lazy.logConsole.debug("Adding Policy Engine:", newEngine.name);
+ this.#addEngineToStore(newEngine);
+ }
+
+ /**
+ * Adds a search engine that is specified by the user.
+ *
+ * @param {string} name
+ * The name of the search engine
+ * @param {string} url
+ * The url that the search engine uses for searches
+ * @param {string} alias
+ * An alias for the search engine
+ */
+ async addUserEngine(name, url, alias) {
+ await this.init();
+
+ let newEngine = new lazy.UserSearchEngine({
+ details: { name, url, alias },
+ });
+ lazy.logConsole.debug(`Adding ${newEngine.name}`);
+ this.#addEngineToStore(newEngine);
+ }
+
+ /**
+ * Called from the AddonManager when it either installs a new
+ * extension containing a search engine definition or an upgrade
+ * to an existing one.
+ *
+ * @param {object} extension
+ * An Extension object containing data about the extension.
+ */
+ async addEnginesFromExtension(extension) {
+ lazy.logConsole.debug("addEnginesFromExtension: " + extension.id);
+ // Treat add-on upgrade and downgrades the same - either way, the search
+ // engine gets updated, not added. Generally, we don't expect a downgrade,
+ // but just in case...
+ if (
+ extension.startupReason == "ADDON_UPGRADE" ||
+ extension.startupReason == "ADDON_DOWNGRADE"
+ ) {
+ // Bug 1679861 An a upgrade or downgrade could be adding a search engine
+ // that was not in a prior version, or the addon may have been blocklisted.
+ // In either case, there will not be an existing engine.
+ let existing = await this.#upgradeExtensionEngine(extension);
+ if (existing?.length) {
+ return existing;
+ }
+ }
+
+ if (extension.isAppProvided) {
+ // If we are in the middle of initialization or reloading engines,
+ // don't add the engine here. This has been called as the result
+ // of _makeEngineFromConfig installing the extension, and that is already
+ // handling the addition of the engine.
+ if (this.isInitialized && !this._reloadingEngines) {
+ let { engines } = await this._fetchEngineSelectorEngines();
+ let inConfig = engines.filter(el => el.webExtension.id == extension.id);
+ if (inConfig.length) {
+ return this.#installExtensionEngine(
+ extension,
+ inConfig.map(el => el.webExtension.locale)
+ );
+ }
+ }
+ lazy.logConsole.debug(
+ "addEnginesFromExtension: Ignoring builtIn engine."
+ );
+ return [];
+ }
+
+ // If we havent started SearchService yet, store this extension
+ // to install in SearchService.init().
+ if (!this.isInitialized) {
+ this.#startupExtensions.add(extension);
+ return [];
+ }
+
+ return this.#installExtensionEngine(extension, [
+ lazy.SearchUtils.DEFAULT_TAG,
+ ]);
+ }
+
+ async addOpenSearchEngine(engineURL, iconURL) {
+ lazy.logConsole.debug("addOpenSearchEngine: Adding", engineURL);
+ await this.init();
+ let engine;
+ try {
+ let engineData = await lazy.loadAndParseOpenSearchEngine(
+ Services.io.newURI(engineURL)
+ );
+ engine = new lazy.OpenSearchEngine({ engineData });
+ engine._setIcon(iconURL, false);
+ } catch (ex) {
+ throw Components.Exception(
+ "addEngine: Error adding engine:\n" + ex,
+ ex.result || Cr.NS_ERROR_FAILURE
+ );
+ }
+ this.#addEngineToStore(engine);
+ this.#maybeStartOpenSearchUpdateTimer();
+ return engine;
+ }
+
+ async removeWebExtensionEngine(id) {
+ if (!this.isInitialized) {
+ lazy.logConsole.debug(
+ "Delaying removing extension engine on startup:",
+ id
+ );
+ this.#startupRemovedExtensions.add(id);
+ return;
+ }
+
+ lazy.logConsole.debug("removeWebExtensionEngine:", id);
+ for (let engine of this.#getEnginesByExtensionID(id)) {
+ await this.removeEngine(engine);
+ }
+ }
+
+ async removeEngine(engine) {
+ await this.init();
+ if (!engine) {
+ throw Components.Exception(
+ "no engine passed to removeEngine!",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ var engineToRemove = null;
+ for (var e of this._engines.values()) {
+ if (engine.wrappedJSObject == e) {
+ engineToRemove = e;
+ }
+ }
+
+ if (!engineToRemove) {
+ throw Components.Exception(
+ "removeEngine: Can't find engine to remove!",
+ Cr.NS_ERROR_FILE_NOT_FOUND
+ );
+ }
+
+ engineToRemove.pendingRemoval = true;
+
+ if (engineToRemove == this.defaultEngine) {
+ this.#findAndSetNewDefaultEngine({
+ privateMode: false,
+ });
+ }
+
+ // Bug 1575649 - We can't just check the default private engine here when
+ // we're not using separate, as that re-checks the normal default, and
+ // triggers update of the default search engine, which messes up various
+ // tests. Really, removeEngine should always commit to updating any
+ // changed defaults.
+ if (
+ this.#separatePrivateDefault &&
+ engineToRemove == this.defaultPrivateEngine
+ ) {
+ this.#findAndSetNewDefaultEngine({
+ privateMode: true,
+ });
+ }
+
+ if (engineToRemove.inMemory) {
+ // Just hide it (the "hidden" setter will notify) and remove its alias to
+ // avoid future conflicts with other engines.
+ engineToRemove.hidden = true;
+ engineToRemove.alias = null;
+ engineToRemove.pendingRemoval = false;
+ } else {
+ // Remove the engine file from disk if we had a legacy file in the profile.
+ if (engineToRemove._filePath) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.persistentDescriptor = engineToRemove._filePath;
+ if (file.exists()) {
+ file.remove(false);
+ }
+ engineToRemove._filePath = null;
+ }
+ this.#internalRemoveEngine(engineToRemove);
+
+ // Since we removed an engine, we may need to update the preferences.
+ if (!this.#dontSetUseSavedOrder) {
+ this.#saveSortedEngineList();
+ }
+ }
+ lazy.SearchUtils.notifyAction(
+ engineToRemove,
+ lazy.SearchUtils.MODIFIED_TYPE.REMOVED
+ );
+ }
+
+ async moveEngine(engine, newIndex) {
+ await this.init();
+ if (newIndex > this.#sortedEngines.length || newIndex < 0) {
+ throw Components.Exception(
+ "moveEngine: Index out of bounds!",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+ if (
+ !(engine instanceof Ci.nsISearchEngine) &&
+ !(engine instanceof lazy.SearchEngine)
+ ) {
+ throw Components.Exception(
+ "moveEngine: Invalid engine passed to moveEngine!",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+ if (engine.hidden) {
+ throw Components.Exception(
+ "moveEngine: Can't move a hidden engine!",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ engine = engine.wrappedJSObject;
+
+ var currentIndex = this.#sortedEngines.indexOf(engine);
+ if (currentIndex == -1) {
+ throw Components.Exception(
+ "moveEngine: Can't find engine to move!",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ // Our callers only take into account non-hidden engines when calculating
+ // newIndex, but we need to move it in the array of all engines, so we
+ // need to adjust newIndex accordingly. To do this, we count the number
+ // of hidden engines in the list before the engine that we're taking the
+ // place of. We do this by first finding newIndexEngine (the engine that
+ // we were supposed to replace) and then iterating through the complete
+ // engine list until we reach it, increasing newIndex for each hidden
+ // engine we find on our way there.
+ //
+ // This could be further simplified by having our caller pass in
+ // newIndexEngine directly instead of newIndex.
+ var newIndexEngine = this.#sortedVisibleEngines[newIndex];
+ if (!newIndexEngine) {
+ throw Components.Exception(
+ "moveEngine: Can't find engine to replace!",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ for (var i = 0; i < this.#sortedEngines.length; ++i) {
+ if (newIndexEngine == this.#sortedEngines[i]) {
+ break;
+ }
+ if (this.#sortedEngines[i].hidden) {
+ newIndex++;
+ }
+ }
+
+ if (currentIndex == newIndex) {
+ return;
+ } // nothing to do!
+
+ // Move the engine
+ var movedEngine = this._cachedSortedEngines.splice(currentIndex, 1)[0];
+ this._cachedSortedEngines.splice(newIndex, 0, movedEngine);
+
+ lazy.SearchUtils.notifyAction(
+ engine,
+ lazy.SearchUtils.MODIFIED_TYPE.CHANGED
+ );
+
+ // Since we moved an engine, we need to update the preferences.
+ this.#saveSortedEngineList();
+ }
+
+ restoreDefaultEngines() {
+ this.#ensureInitialized();
+ for (let e of this._engines.values()) {
+ // Unhide all default engines
+ if (e.hidden && e.isAppProvided) {
+ e.hidden = false;
+ }
+ }
+ }
+
+ parseSubmissionURL(url) {
+ if (!this.hasSuccessfullyInitialized) {
+ // If search is not initialized or failed initializing, do nothing.
+ // This allows us to use this function early in telemetry.
+ // The only other consumer of this (places) uses it much later.
+ return gEmptyParseSubmissionResult;
+ }
+
+ if (!this.#parseSubmissionMap) {
+ this.#buildParseSubmissionMap();
+ }
+
+ // Extract the elements of the provided URL first.
+ let soughtKey, soughtQuery;
+ try {
+ let soughtUrl = Services.io.newURI(url);
+
+ // Exclude any URL that is not HTTP or HTTPS from the beginning.
+ if (soughtUrl.schemeIs("http") && soughtUrl.schemeIs("https")) {
+ return gEmptyParseSubmissionResult;
+ }
+
+ // Reading these URL properties may fail and raise an exception.
+ soughtKey = soughtUrl.host + soughtUrl.filePath.toLowerCase();
+ soughtQuery = soughtUrl.query;
+ } catch (ex) {
+ // Errors while parsing the URL or accessing the properties are not fatal.
+ return gEmptyParseSubmissionResult;
+ }
+
+ // Look up the domain and path in the map to identify the search engine.
+ let mapEntry = this.#parseSubmissionMap.get(soughtKey);
+ if (!mapEntry) {
+ return gEmptyParseSubmissionResult;
+ }
+
+ // Extract the search terms from the parameter, for example "caff%C3%A8"
+ // from the URL "https://www.google.com/search?q=caff%C3%A8&client=firefox".
+ // We cannot use `URLSearchParams` here as the terms might not be
+ // encoded in UTF-8.
+ let encodedTerms = null;
+ for (let param of soughtQuery.split("&")) {
+ let equalPos = param.indexOf("=");
+ if (
+ equalPos != -1 &&
+ param.substr(0, equalPos) == mapEntry.termsParameterName
+ ) {
+ // This is the parameter we are looking for.
+ encodedTerms = param.substr(equalPos + 1);
+ break;
+ }
+ }
+ if (encodedTerms === null) {
+ return gEmptyParseSubmissionResult;
+ }
+
+ // Decode the terms using the charset defined in the search engine.
+ let terms;
+ try {
+ terms = Services.textToSubURI.UnEscapeAndConvert(
+ mapEntry.engine.queryCharset,
+ encodedTerms.replace(/\+/g, " ")
+ );
+ } catch (ex) {
+ // Decoding errors will cause this match to be ignored.
+ return gEmptyParseSubmissionResult;
+ }
+
+ return new ParseSubmissionResult(
+ mapEntry.engine,
+ terms,
+ mapEntry.termsParameterName
+ );
+ }
+
+ /**
+ * This is a nsITimerCallback for the timerManager notification that is
+ * registered for handling updates to search engines. Only OpenSearch engines
+ * have these updates and hence, only those are handled here.
+ */
+ async notify() {
+ lazy.logConsole.debug("notify: checking for updates");
+
+ // Walk the engine list, looking for engines whose update time has expired.
+ for (let engine of this._engines.values()) {
+ if (!(engine instanceof lazy.OpenSearchEngine)) {
+ continue;
+ }
+ await engine.maybeUpdate();
+ }
+ }
+
+ #currentEngine;
+ #currentPrivateEngine;
+ #queuedIdle;
+
+ /**
+ * A deferred promise that is resolved when initialization has finished.
+ *
+ * @type {Promise}
+ * Resolved when initalization has successfully finished, and rejected if it
+ * has failed.
+ */
+ #initDeferredPromise = Promise.withResolvers();
+
+ /**
+ * Indicates if initialization has started, failed, succeeded or has not
+ * started yet.
+ *
+ * These are the statuses:
+ * "not initialized" - The SearchService has not started initialization.
+ * "started" - The SearchService has started initializaiton.
+ * "success" - The SearchService successfully completed initialization.
+ * "failed" - The SearchService failed during initialization.
+ *
+ * @type {string}
+ */
+ #initializationStatus = "not initialized";
+
+ /**
+ * Indicates if we're already waiting for maybeReloadEngines to be called.
+ *
+ * @type {boolean}
+ */
+ #maybeReloadDebounce = false;
+
+ /**
+ * Indicates if we're currently in maybeReloadEngines.
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in a test.
+ *
+ * @type {boolean}
+ */
+ _reloadingEngines = false;
+
+ /**
+ * The engine selector singleton that is managing the engine configuration.
+ *
+ * @type {SearchEngineSelector|null}
+ */
+ #engineSelector = null;
+
+ /**
+ * Various search engines may be ignored if their submission urls contain a
+ * string that is in the list. The list is controlled via remote settings.
+ *
+ * @type {Array}
+ */
+ #submissionURLIgnoreList = [];
+
+ /**
+ * Various search engines may be ignored if their load path is contained
+ * in this list. The list is controlled via remote settings.
+ *
+ * @type {Array}
+ */
+ #loadPathIgnoreList = [];
+
+ /**
+ * A map of engine display names to `SearchEngine`.
+ *
+ * @type {Map<string, object>|null}
+ */
+ _engines = null;
+
+ /**
+ * An array of engine short names sorted into display order.
+ *
+ * @type {Array}
+ */
+ _cachedSortedEngines = null;
+
+ /**
+ * A flag to prevent setting of useSavedOrder when there's non-user
+ * activity happening.
+ *
+ * @type {boolean}
+ */
+ #dontSetUseSavedOrder = false;
+
+ /**
+ * An object containing the {id, locale} of the WebExtension for the default
+ * engine, as suggested by the configuration.
+ * For the legacy configuration, this is the user visible name.
+ *
+ * @type {object}
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in a test.
+ */
+ _searchDefault = null;
+
+ /**
+ * An object containing the {id, locale} of the WebExtension for the default
+ * engine for private browsing mode, as suggested by the configuration.
+ * For the legacy configuration, this is the user visible name.
+ *
+ * @type {object}
+ */
+ #searchPrivateDefault = null;
+
+ /**
+ * A Set of installed search extensions reported by AddonManager
+ * startup before SearchSevice has started. Will be installed
+ * during init().
+ *
+ * @type {Set<object>}
+ */
+ #startupExtensions = new Set();
+
+ /**
+ * A Set of removed search extensions reported by AddonManager
+ * startup before SearchSevice has started. Will be removed
+ * during init().
+ *
+ * @type {Set<object>}
+ */
+ #startupRemovedExtensions = new Set();
+
+ /**
+ * This map is built lazily after the available search engines change. It
+ * allows quick parsing of an URL representing a search submission into the
+ * search engine name and original terms.
+ *
+ * The keys are strings containing the domain name and lowercase path of the
+ * engine submission, for example "www.google.com/search".
+ *
+ * The values are objects with these properties:
+ * {
+ * engine: The associated nsISearchEngine.
+ * termsParameterName: Name of the URL parameter containing the search
+ * terms, for example "q".
+ * }
+ */
+ #parseSubmissionMap = null;
+
+ /**
+ * Keep track of observers have been added.
+ *
+ * @type {boolean}
+ */
+ #observersAdded = false;
+
+ /**
+ * Keeps track to see if the OpenSearch update timer has been started or not.
+ *
+ * @type {boolean}
+ */
+ #openSearchUpdateTimerStarted = false;
+
+ get #sortedEngines() {
+ if (!this._cachedSortedEngines) {
+ return this.#buildSortedEngineList();
+ }
+ return this._cachedSortedEngines;
+ }
+ /**
+ * This reflects the combined values of the prefs for enabling the separate
+ * private default UI, and for the user choosing a separate private engine.
+ * If either one is disabled, then we don't enable the separate private default.
+ *
+ * @returns {boolean}
+ */
+ get #separatePrivateDefault() {
+ return (
+ this._separatePrivateDefaultPrefValue &&
+ this._separatePrivateDefaultEnabledPrefValue
+ );
+ }
+
+ #getEnginesByExtensionID(extensionID) {
+ lazy.logConsole.debug("getEngines: getting all engines for", extensionID);
+ var engines = this.#sortedEngines.filter(function (engine) {
+ return engine._extensionID == extensionID;
+ });
+ return engines;
+ }
+
+ /**
+ * Returns the engine associated with the WebExtension details.
+ *
+ * @param {object} details
+ * Details of the WebExtension.
+ * @param {string} details.id
+ * The WebExtension ID
+ * @param {string} details.locale
+ * The WebExtension locale
+ * @returns {nsISearchEngine|null}
+ * The found engine, or null if no engine matched.
+ */
+ #getEngineByWebExtensionDetails(details) {
+ for (const engine of this._engines.values()) {
+ if (
+ engine._extensionID == details.id &&
+ engine._locale == details.locale
+ ) {
+ return engine;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Helper function to get the current default engine.
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in test_remove_engine_notification_box.js
+ *
+ * @param {boolean} privateMode
+ * If true, returns the default engine for private browsing mode, otherwise
+ * the default engine for the normal mode. Note, this function does not
+ * check the "separatePrivateDefault" preference - that is up to the caller.
+ * @returns {nsISearchEngine|null}
+ * The appropriate search engine, or null if one could not be determined.
+ */
+ _getEngineDefault(privateMode) {
+ let currentEngine = privateMode
+ ? this.#currentPrivateEngine
+ : this.#currentEngine;
+
+ if (currentEngine && !currentEngine.hidden) {
+ return currentEngine;
+ }
+
+ // No default loaded, so find it from settings.
+ const attributeName = privateMode
+ ? "privateDefaultEngineId"
+ : "defaultEngineId";
+
+ let engineId = this._settings.getMetaDataAttribute(attributeName);
+ let engine = this._engines.get(engineId) || null;
+ if (
+ engine &&
+ this._settings.getVerifiedMetaDataAttribute(
+ attributeName,
+ engine.isAppProvided
+ )
+ ) {
+ if (privateMode) {
+ this.#currentPrivateEngine = engine;
+ } else {
+ this.#currentEngine = engine;
+ }
+ }
+ if (!engineId) {
+ if (privateMode) {
+ this.#currentPrivateEngine = this.appPrivateDefaultEngine;
+ } else {
+ this.#currentEngine = this.appDefaultEngine;
+ }
+ }
+
+ currentEngine = privateMode
+ ? this.#currentPrivateEngine
+ : this.#currentEngine;
+ if (currentEngine && !currentEngine.hidden) {
+ return currentEngine;
+ }
+ // No default in settings or it is hidden, so find the new default.
+ return this.#findAndSetNewDefaultEngine({ privateMode });
+ }
+
+ /**
+ * If initialization has not been completed yet, perform synchronous
+ * initialization.
+ * Throws in case of initialization error.
+ */
+ #ensureInitialized() {
+ if (this.#initializationStatus === "success") {
+ return;
+ }
+
+ if (this.#initializationStatus === "failed") {
+ throw new Error("SearchService failed while it was initializing.");
+ }
+
+ let err = new Error(
+ "Something tried to use the search service before it finished " +
+ "initializing. Please examine the stack trace to figure out what and " +
+ "where to fix it:\n"
+ );
+ err.message += err.stack;
+ throw err;
+ }
+
+ /**
+ * Define lazy preference getters for separate private default engine in
+ * private browsing mode.
+ */
+ #defineLazyPreferenceGetters() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_separatePrivateDefaultPrefValue",
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false,
+ this.#onSeparateDefaultPrefChanged.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_separatePrivateDefaultEnabledPrefValue",
+ lazy.SearchUtils.BROWSER_SEARCH_PREF +
+ "separatePrivateDefault.ui.enabled",
+ false,
+ this.#onSeparateDefaultPrefChanged.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "separatePrivateDefaultUrlbarResultEnabled",
+ lazy.SearchUtils.BROWSER_SEARCH_PREF +
+ "separatePrivateDefault.urlbarResult.enabled",
+ false
+ );
+ }
+
+ /**
+ * This function adds observers, retrieves the search engine ignore list, and
+ * initializes the Search Engine Selector prior to doing the core tasks of
+ * search service initialization.
+ *
+ */
+ #doPreInitWork() {
+ // We need to catch the region being updated during initialization so we
+ // start listening straight away.
+ Services.obs.addObserver(this, lazy.Region.REGION_TOPIC);
+
+ this.#getIgnoreListAndSubscribe().catch(ex =>
+ console.error(ex, "Search Service could not get the ignore list.")
+ );
+
+ if (lazy.SearchUtils.newSearchConfigEnabled) {
+ this.#engineSelector = new lazy.SearchEngineSelector(
+ this.#handleConfigurationUpdated.bind(this)
+ );
+ } else {
+ this.#engineSelector = new lazy.SearchEngineSelectorOld(
+ this.#handleConfigurationUpdated.bind(this)
+ );
+ }
+ }
+
+ /**
+ * This function fetches information to load search engines and ensures the
+ * search service is in the correct state for external callers to interact
+ * with it.
+ *
+ * This function sets #initDeferredPromise to resolve or reject.
+ * | Resolved | when initalization has successfully finished.
+ * | Rejected | when initialization has failed.
+ */
+ async #init() {
+ lazy.logConsole.debug("init");
+
+ const timerId = Glean.searchService.startupTime.start();
+
+ this.#doPreInitWork();
+
+ let initSection;
+ try {
+ initSection = "Settings";
+ this.#maybeThrowErrorInTest(initSection);
+ const settings = await this._settings.get();
+
+ initSection = "FetchEngines";
+ this.#maybeThrowErrorInTest(initSection);
+ const { engines, privateDefault } =
+ await this._fetchEngineSelectorEngines();
+
+ initSection = "LoadEngines";
+ this.#maybeThrowErrorInTest(initSection);
+ await this.#loadEngines(settings, engines, privateDefault);
+ } catch (ex) {
+ Glean.searchService.initializationStatus[`failed${initSection}`].add();
+ Glean.searchService.startupTime.cancel(timerId);
+
+ lazy.logConsole.error("#init: failure initializing search:", ex);
+ this.#initializationStatus = "failed";
+ this.#initDeferredPromise.reject(ex);
+
+ throw ex;
+ }
+
+ // If we've got this far, but the application is now shutting down,
+ // then we need to abandon any further work, especially not writing
+ // the settings. We do this, because the add-on manager has also
+ // started shutting down and as a result, we might have an incomplete
+ // picture of the installed search engines. Writing the settings at
+ // this stage would potentially mean the user would loose their engine
+ // data.
+ // We will however, rebuild the settings on next start up if we detect
+ // it is necessary.
+ if (Services.startup.shuttingDown) {
+ Glean.searchService.startupTime.cancel(timerId);
+
+ let ex = Components.Exception(
+ "#init: abandoning init due to shutting down",
+ Cr.NS_ERROR_ABORT
+ );
+
+ this.#initializationStatus = "failed";
+ this.#initDeferredPromise.reject(ex);
+ throw ex;
+ }
+
+ this.#initializationStatus = "success";
+ Glean.searchService.initializationStatus.success.add();
+ this.#initDeferredPromise.resolve();
+ this.#addObservers();
+
+ Glean.searchService.startupTime.stopAndAccumulate(timerId);
+
+ this.#recordTelemetryData();
+
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "init-complete"
+ );
+
+ lazy.logConsole.debug("Completed #init");
+ this.#doPostInitWork();
+ }
+
+ /**
+ * This function records telemetry, checks experiment updates, sets up a timer
+ * for opensearch, removes any necessary Add-on engines immediately after the
+ * search service has successfully initialized.
+ *
+ */
+ #doPostInitWork() {
+ // It is possible that Nimbus could have called onUpdate before
+ // we started listening, so do a check on startup.
+ Services.tm.dispatchToMainThread(async () => {
+ await lazy.NimbusFeatures.searchConfiguration.ready();
+ this.#checkNimbusPrefs(true);
+ });
+
+ this.#maybeStartOpenSearchUpdateTimer();
+
+ if (this.#startupRemovedExtensions.size) {
+ Services.tm.dispatchToMainThread(async () => {
+ // Now that init() has successfully finished, we remove any engines
+ // that have had their add-ons removed by the add-on manager.
+ // We do this after init() has complete, as that allows us to use
+ // removeEngine to look after any default engine changes as well.
+ // This could cause a slight flicker on startup, but it should be
+ // a rare action.
+ lazy.logConsole.debug("Removing delayed extension engines");
+ for (let id of this.#startupRemovedExtensions) {
+ for (let engine of this.#getEnginesByExtensionID(id)) {
+ // Only do this for non-application provided engines. We shouldn't
+ // ever get application provided engines removed here, but just in case.
+ if (!engine.isAppProvided) {
+ await this.removeEngine(engine);
+ }
+ }
+ }
+ this.#startupRemovedExtensions.clear();
+ });
+ }
+ }
+
+ /**
+ * Obtains the ignore list from remote settings. This should only be
+ * called from init(). Any subsequent updates to the remote settings are
+ * handled via a sync listener.
+ *
+ */
+ async #getIgnoreListAndSubscribe() {
+ let listener = this.#handleIgnoreListUpdated.bind(this);
+ const current = await lazy.IgnoreLists.getAndSubscribe(listener);
+
+ // Only save the listener after the subscribe, otherwise for tests it might
+ // not be fully set up by the time we remove it again.
+ this.ignoreListListener = listener;
+
+ await this.#handleIgnoreListUpdated({ data: { current } });
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "settings-update-complete"
+ );
+ }
+
+ /**
+ * This handles updating of the ignore list settings, and removing any ignored
+ * engines.
+ *
+ * @param {object} eventData
+ * The event in the format received from RemoteSettings.
+ */
+ async #handleIgnoreListUpdated(eventData) {
+ lazy.logConsole.debug("#handleIgnoreListUpdated");
+ const {
+ data: { current },
+ } = eventData;
+
+ for (const entry of current) {
+ if (entry.id == "load-paths") {
+ this.#loadPathIgnoreList = [...entry.matches];
+ } else if (entry.id == "submission-urls") {
+ this.#submissionURLIgnoreList = [...entry.matches];
+ }
+ }
+
+ try {
+ await this.promiseInitialized;
+ } catch (ex) {
+ // If there's a problem with initialization return early to allow
+ // search service to continue in a limited mode without engines.
+ return;
+ }
+
+ // We try to remove engines manually, as this should be more efficient and
+ // we don't really want to cause a re-init as this upsets unit tests.
+ let engineRemoved = false;
+ for (let engine of this._engines.values()) {
+ if (this.#engineMatchesIgnoreLists(engine)) {
+ await this.removeEngine(engine);
+ engineRemoved = true;
+ }
+ }
+ // If we've removed an engine, and we don't have any left, we need to
+ // reload the engines - it is possible the settings just had one engine in it,
+ // and that is now empty, so we need to load from our main list.
+ if (engineRemoved && !this._engines.size) {
+ this._maybeReloadEngines().catch(console.error);
+ }
+ }
+
+ /**
+ * Determines if a given engine matches the ignorelists or not.
+ *
+ * @param {Engine} engine
+ * The engine to check against the ignorelists.
+ * @returns {boolean}
+ * Returns true if the engine matches a ignorelists entry.
+ */
+ #engineMatchesIgnoreLists(engine) {
+ if (this.#loadPathIgnoreList.includes(engine._loadPath)) {
+ return true;
+ }
+ let url = engine.searchURLWithNoTerms.spec.toLowerCase();
+ if (
+ this.#submissionURLIgnoreList.some(code =>
+ url.includes(code.toLowerCase())
+ )
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handles the search configuration being - adds a wait on the user
+ * being idle, before the search engine update gets handled.
+ */
+ #handleConfigurationUpdated() {
+ if (this.#queuedIdle) {
+ return;
+ }
+
+ this.#queuedIdle = true;
+
+ this.idleService.addIdleObserver(this, RECONFIG_IDLE_TIME_SEC);
+ }
+
+ /**
+ * Returns the engine that is the default for this locale/region, ignoring any
+ * user changes to the default engine.
+ *
+ * @param {boolean} privateMode
+ * Set to true to return the default engine in private mode,
+ * false for normal mode.
+ * @returns {SearchEngine}
+ * The engine that is default.
+ */
+ #appDefaultEngine(privateMode = false) {
+ let defaultEngine = this.#getEngineByWebExtensionDetails(
+ privateMode && this.#searchPrivateDefault
+ ? this.#searchPrivateDefault
+ : this._searchDefault
+ );
+
+ if (Services.policies?.status == Ci.nsIEnterprisePolicies.ACTIVE) {
+ let activePolicies = Services.policies.getActivePolicies();
+ if (activePolicies.SearchEngines) {
+ if (activePolicies.SearchEngines.Default) {
+ return this.#getEngineByName(activePolicies.SearchEngines.Default);
+ }
+ if (activePolicies.SearchEngines.Remove?.includes(defaultEngine.name)) {
+ defaultEngine = null;
+ }
+ }
+ }
+
+ if (defaultEngine) {
+ return defaultEngine;
+ }
+
+ if (privateMode) {
+ // If for some reason we can't find the private mode engine, fall back
+ // to the non-private one.
+ return this.#appDefaultEngine(false);
+ }
+
+ // Something unexpected has happened. In order to recover the app default
+ // engine, use the first visible engine that is also a general purpose engine.
+ // Worst case, we just use the first visible engine.
+ defaultEngine = this.#sortedVisibleEngines.find(
+ e => e.isGeneralPurposeEngine
+ );
+ return defaultEngine ? defaultEngine : this.#sortedVisibleEngines[0];
+ }
+
+ /**
+ * Loads engines asynchronously.
+ *
+ * @param {object} settings
+ * An object representing the search engine settings.
+ * @param {Array} engines
+ * An array containing the engines objects from remote settings.
+ * @param {object} privateDefault
+ * An object representing the private default search engine.
+ */
+ async #loadEngines(settings, engines, privateDefault) {
+ // Get user's current settings and search engine before we load engines from
+ // config. These values will be compared after engines are loaded.
+ let prevMetaData = { ...settings?.metaData };
+ let prevCurrentEngineId = prevMetaData.defaultEngineId;
+ let prevAppDefaultEngineId = prevMetaData?.appDefaultEngineId;
+
+ lazy.logConsole.debug("#loadEngines: start");
+ this.#setDefaultAndOrdersFromSelector(engines, privateDefault);
+
+ // We've done what we can without the add-on manager, now ensure that
+ // it has finished starting before we continue.
+ if (!lazy.SearchUtils.newSearchConfigEnabled) {
+ await lazy.AddonManager.readyPromise;
+ }
+
+ await this.#loadEnginesFromConfig(engines, settings);
+
+ await this.#loadStartupEngines(settings);
+
+ this.#loadEnginesFromPolicies(settings);
+
+ // `loadEnginesFromSettings` loads the engines and their settings together.
+ // If loading the settings caused the default engine to change because of an
+ // override, then we don't want to show the notification box.
+ let skipDefaultChangedNotification = await this.#loadEnginesFromSettings(
+ settings
+ );
+
+ // If #loadEnginesFromSettings changed the default engine, then we don't
+ // need to call #checkOpenSearchOverrides as we know that the overrides have
+ // only just been applied.
+ skipDefaultChangedNotification ||= await this.#checkOpenSearchOverrides(
+ settings
+ );
+
+ // Settings file version 6 and below will need a migration to store the
+ // engine ids rather than engine names.
+ this._settings.migrateEngineIds(settings);
+
+ lazy.logConsole.debug("#loadEngines: done");
+
+ let newCurrentEngine = this._getEngineDefault(false);
+ let newCurrentEngineId = newCurrentEngine?.id;
+
+ this._settings.setMetaDataAttribute(
+ "appDefaultEngineId",
+ this.appDefaultEngine?.id
+ );
+
+ if (
+ !skipDefaultChangedNotification &&
+ this.#shouldDisplayRemovalOfEngineNotificationBox(
+ settings,
+ prevMetaData,
+ newCurrentEngineId,
+ prevCurrentEngineId,
+ prevAppDefaultEngineId
+ )
+ ) {
+ let newCurrentEngineName = newCurrentEngine?.name;
+
+ let [prevCurrentEngineName, prevAppDefaultEngineName] = [
+ settings.engines.find(e => e.id == prevCurrentEngineId)?._name,
+ settings.engines.find(e => e.id == prevAppDefaultEngineId)?._name,
+ ];
+
+ this._showRemovalOfSearchEngineNotificationBox(
+ prevCurrentEngineName || prevAppDefaultEngineName,
+ newCurrentEngineName
+ );
+ }
+ }
+
+ /**
+ * Helper function to determine if the removal of search engine notification
+ * box should be displayed.
+ *
+ * @param { object } settings
+ * The user's search engine settings.
+ * @param { object } prevMetaData
+ * The user's previous search settings metadata.
+ * @param { object } newCurrentEngineId
+ * The user's new current default engine.
+ * @param { object } prevCurrentEngineId
+ * The user's previous default engine.
+ * @param { object } prevAppDefaultEngineId
+ * The user's previous app default engine.
+ * @returns { boolean }
+ * Return true if the previous default engine has been removed and
+ * notification box should be displayed.
+ */
+ #shouldDisplayRemovalOfEngineNotificationBox(
+ settings,
+ prevMetaData,
+ newCurrentEngineId,
+ prevCurrentEngineId,
+ prevAppDefaultEngineId
+ ) {
+ if (
+ !Services.prefs.getBoolPref("browser.search.removeEngineInfobar.enabled")
+ ) {
+ return false;
+ }
+
+ // If for some reason we were unable to install any engines and hence no
+ // default engine, do not display the notification box
+ if (!newCurrentEngineId) {
+ return false;
+ }
+
+ // If the previous engine is still available, don't show the notification
+ // box.
+ if (prevCurrentEngineId && this._engines.has(prevCurrentEngineId)) {
+ return false;
+ }
+ if (!prevCurrentEngineId && this._engines.has(prevAppDefaultEngineId)) {
+ return false;
+ }
+
+ // Don't show the notification if the previous engine was an enterprise engine -
+ // the text doesn't quite make sense.
+ // let checkPolicyEngineId = prevCurrentEngineId ? prevCurrentEngineId : prevAppDefaultEngineId;
+ let checkPolicyEngineId = prevCurrentEngineId || prevAppDefaultEngineId;
+ if (checkPolicyEngineId) {
+ let engineSettings = settings.engines.find(
+ e => e.id == checkPolicyEngineId
+ );
+ if (engineSettings?._loadPath?.startsWith("[policy]")) {
+ return false;
+ }
+ }
+
+ // If the user's previous engine id is different than the new current
+ // engine id, or if the user was using the app default engine and the
+ // app default engine id is different than the new current engine id,
+ // we check if the user's settings metadata has been upddated.
+ if (
+ (prevCurrentEngineId && prevCurrentEngineId !== newCurrentEngineId) ||
+ (!prevCurrentEngineId &&
+ prevAppDefaultEngineId &&
+ prevAppDefaultEngineId !== newCurrentEngineId)
+ ) {
+ // Check settings metadata to detect an update to locale. Sometimes when
+ // the user changes their locale it causes a change in engines.
+ // If there is no update to settings metadata then the engine change was
+ // caused by an update to config rather than a user changing their locale.
+ if (!this.#didSettingsMetaDataUpdate(prevMetaData)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Loads engines as specified by the configuration. We only expect
+ * configured engines here, user engines should not be listed.
+ *
+ * @param {Array} engineConfigs
+ * An array of engines configurations based on the schema.
+ * @param {object} [settings]
+ * The saved settings for the user.
+ */
+ async #loadEnginesFromConfig(engineConfigs, settings) {
+ lazy.logConsole.debug("#loadEnginesFromConfig");
+ for (let config of engineConfigs) {
+ try {
+ let engine = await this._makeEngineFromConfig(config, settings);
+ this.#addEngineToStore(engine);
+ } catch (ex) {
+ console.error(
+ `Could not load engine ${
+ "webExtension" in config ? config.webExtension.id : "unknown"
+ }: ${ex}`
+ );
+ }
+ }
+ }
+
+ /**
+ * Loads any engines that have been received from the AddonManager during
+ * startup and before we have finished initialising.
+ *
+ * @param {object} [settings]
+ * The saved settings for the user.
+ */
+ async #loadStartupEngines(settings) {
+ if (
+ this.#startupExtensions.size &&
+ lazy.SearchUtils.newSearchConfigEnabled
+ ) {
+ await lazy.AddonManager.readyPromise;
+ }
+
+ lazy.logConsole.debug(
+ "#loadEngines: loading",
+ this.#startupExtensions.size,
+ "engines reported by AddonManager startup"
+ );
+ for (let extension of this.#startupExtensions) {
+ try {
+ await this.#installExtensionEngine(
+ extension,
+ [lazy.SearchUtils.DEFAULT_TAG],
+ settings,
+ true
+ );
+ } catch (ex) {
+ lazy.logConsole.error(
+ `#installExtensionEngine failed for ${extension.id}`,
+ ex
+ );
+ }
+ }
+ this.#startupExtensions.clear();
+ }
+
+ /**
+ * When starting up, check if any of the saved application provided engines
+ * are no longer required, previously were default and were overridden by
+ * an OpenSearch engine.
+ *
+ * Also check if any OpenSearch overrides need to be re-applied.
+ *
+ * Add-on search engines are handled separately.
+ *
+ * @param {object} settings
+ * The loaded settings for the user.
+ * @returns {boolean}
+ * Returns true if the default engine was changed.
+ */
+ async #checkOpenSearchOverrides(settings) {
+ let defaultEngineChanged = false;
+ let savedDefaultEngineId =
+ settings.metaData.defaultEngineId || settings.metaData.appDefaultEngineId;
+ if (!savedDefaultEngineId) {
+ return false;
+ }
+ // First handle the case where the application provided engine was removed,
+ // and we need to restore the OpenSearch engine.
+ for (let engineSettings of settings.engines) {
+ if (
+ !this._engines.get(engineSettings.id) &&
+ engineSettings._isAppProvided &&
+ engineSettings.id == savedDefaultEngineId &&
+ engineSettings._metaData.overriddenByOpenSearch
+ ) {
+ let restoringEngine = new lazy.OpenSearchEngine({
+ json: engineSettings._metaData.overriddenByOpenSearch,
+ });
+ restoringEngine.copyUserSettingsFrom(engineSettings);
+ this.#addEngineToStore(restoringEngine, true);
+
+ // We assume that the app provided engine was removed due to a
+ // configuration change, and therefore we have re-added the OpenSearch
+ // search engine. It is possible that it was actually due to a
+ // locale/region change, but that is harder to detect here.
+ this.#setEngineDefault(
+ false,
+ restoringEngine,
+ Ci.nsISearchService.CHANGE_REASON_CONFIG
+ );
+ delete engineSettings._metaData.overriddenByOpenSearch;
+ }
+ }
+ // Now handle the case where the an application provided engine has been
+ // overridden by an OpenSearch engine, and we need to re-apply the override.
+ for (let engine of this._engines.values()) {
+ if (
+ engine.isAppProvided &&
+ engine.getAttr("overriddenByOpenSearch") &&
+ engine.id == savedDefaultEngineId
+ ) {
+ let restoringEngine = new lazy.OpenSearchEngine({
+ json: engine.getAttr("overriddenByOpenSearch"),
+ });
+ if (
+ await lazy.defaultOverrideAllowlist.canEngineOverride(
+ restoringEngine,
+ engine._extensionID
+ )
+ ) {
+ engine.overrideWithEngine({ engine: restoringEngine });
+ }
+ }
+ }
+
+ return defaultEngineChanged;
+ }
+
+ /**
+ * Reloads engines asynchronously, but only when
+ * the service has already been initialized.
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in test_reload_engines.js
+ *
+ * @param {integer} changeReason
+ * The reason reload engines is being called, one of
+ * Ci.nsISearchService.CHANGE_REASON*
+ */
+ async _maybeReloadEngines(changeReason) {
+ if (this.#maybeReloadDebounce) {
+ lazy.logConsole.debug("We're already waiting to reload engines.");
+ return;
+ }
+
+ if (!this.isInitialized || this._reloadingEngines) {
+ this.#maybeReloadDebounce = true;
+ // Schedule a reload to happen at most 10 seconds after the current run.
+ Services.tm.idleDispatchToMainThread(() => {
+ if (!this.#maybeReloadDebounce) {
+ return;
+ }
+ this.#maybeReloadDebounce = false;
+ this._maybeReloadEngines(changeReason).catch(console.error);
+ }, 10000);
+ lazy.logConsole.debug(
+ "Post-poning maybeReloadEngines() as we're currently initializing."
+ );
+ return;
+ }
+
+ // Before entering `_reloadingEngines` get the settings which we'll need.
+ // This also ensures that any pending settings have finished being written,
+ // which could otherwise cause data loss.
+ let settings = await this._settings.get();
+
+ lazy.logConsole.debug("Running maybeReloadEngines");
+ this._reloadingEngines = true;
+
+ try {
+ await this._reloadEngines(settings, changeReason);
+ } catch (ex) {
+ lazy.logConsole.error("maybeReloadEngines failed", ex);
+ }
+ this._reloadingEngines = false;
+ lazy.logConsole.debug("maybeReloadEngines complete");
+ }
+
+ /**
+ * Manages reloading of the search engines when something in the user's
+ * environment or the configuration has changed.
+ *
+ * The order of work here is designed to avoid potential issues when updating
+ * the default engines, so that we're not removing active defaults or trying
+ * to set a default to something that hasn't been added yet. The order is:
+ *
+ * 1) Update exising engines that are in both the old and new configuration.
+ * 2) Add any new engines from the new configuration.
+ * 3) Check for changes needed to the default engines due to environment changes
+ * and potentially overriding engines as per the override allowlist.
+ * 4) Update the default engines.
+ * 5) Remove any old engines.
+ *
+ * This is prefixed with _ rather than # because it is called in
+ * test_remove_engine_notification_box.js
+ *
+ * @param {object} settings
+ * The user's current saved settings.
+ * @param {integer} changeReason
+ * The reason reload engines is being called, one of
+ * Ci.nsISearchService.CHANGE_REASON*
+ */
+ async _reloadEngines(settings, changeReason) {
+ // Capture the current engine state, in case we need to notify below.
+ let prevCurrentEngine = this.#currentEngine;
+ let prevPrivateEngine = this.#currentPrivateEngine;
+ let prevMetaData = { ...settings?.metaData };
+
+ // Ensure that we don't set the useSavedOrder flag whilst we're doing this.
+ // This isn't a user action, so we shouldn't be switching it.
+ this.#dontSetUseSavedOrder = true;
+
+ let { engines: appDefaultConfigEngines, privateDefault } =
+ await this._fetchEngineSelectorEngines();
+
+ let configEngines = [...appDefaultConfigEngines];
+ let oldEngineList = [...this._engines.values()];
+
+ for (let engine of oldEngineList) {
+ if (!engine.isAppProvided) {
+ if (engine instanceof lazy.AddonSearchEngine) {
+ // If this is an add-on search engine, check to see if it needs
+ // an update.
+ await engine.update();
+ }
+ continue;
+ }
+
+ let index = configEngines.findIndex(
+ e =>
+ e.webExtension.id == engine._extensionID &&
+ e.webExtension.locale == engine._locale
+ );
+
+ if (index == -1) {
+ // No engines directly match on id and locale, however, check to see
+ // if we have a new entry that matches on id and name - we might just
+ // be swapping the in-use locale.
+ let replacementEngines = configEngines.filter(
+ e => e.webExtension.id == engine._extensionID
+ );
+ // If there's no possible, or more than one, we treat these as distinct
+ // engines so we'll remove the existing engine and add new later if
+ // necessary.
+ if (replacementEngines.length != 1) {
+ engine.pendingRemoval = true;
+ continue;
+ }
+
+ // Update the index so we can handle the updating below.
+ index = configEngines.findIndex(
+ e =>
+ e.webExtension.id == replacementEngines[0].webExtension.id &&
+ e.webExtension.locale == replacementEngines[0].webExtension.locale
+ );
+ let locale =
+ replacementEngines[0].webExtension.locale ||
+ lazy.SearchUtils.DEFAULT_TAG;
+
+ // If the name is different, then we must treat the engine as different,
+ // and go through the remove and add cycle, rather than modifying the
+ // existing one.
+ let hasUpdated = await engine.updateIfNoNameChange({
+ configuration: configEngines[index],
+ locale,
+ });
+ if (!hasUpdated) {
+ // No matching name, so just remove it.
+ engine.pendingRemoval = true;
+ continue;
+ }
+ } else {
+ // This is an existing engine that we should update (we don't know if
+ // the configuration for this engine has changed or not).
+ await engine.update({
+ configuration: configEngines[index],
+ locale: engine._locale,
+ });
+ }
+
+ configEngines.splice(index, 1);
+ }
+
+ let existingDuplicateEngines = [];
+
+ // Any remaining configuration engines are ones that we need to add.
+ for (let engine of configEngines) {
+ try {
+ let newAppEngine = await this._makeEngineFromConfig(engine, settings);
+
+ // If this is a duplicate name, keep track of the old engine as we need
+ // to handle it later.
+ let duplicateEngine = this.#getEngineByName(newAppEngine.name);
+ if (duplicateEngine) {
+ existingDuplicateEngines.push({
+ duplicateEngine,
+ newAppEngine,
+ });
+ }
+ // We add our new engine to the store anyway, as we know it is an
+ // application provided engine which will take priority over the
+ // duplicate.
+ this.#addEngineToStore(newAppEngine, true);
+ } catch (ex) {
+ lazy.logConsole.warn(
+ `Could not load engine ${
+ "webExtension" in engine ? engine.webExtension.id : "unknown"
+ }: ${ex}`
+ );
+ }
+ }
+
+ // Now set the sort out the default engines and notify as appropriate.
+
+ // Clear the current values, so that we'll completely reset.
+ this.#currentEngine = null;
+ this.#currentPrivateEngine = null;
+
+ // If the user's default is one of the private engines that is being removed,
+ // reset the stored setting, so that we correctly detect the change in
+ // in default.
+ if (prevCurrentEngine?.pendingRemoval) {
+ this._settings.setMetaDataAttribute("defaultEngineId", "");
+ }
+ if (prevPrivateEngine?.pendingRemoval) {
+ this._settings.setMetaDataAttribute("privateDefaultEngineId", "");
+ }
+
+ this.#setDefaultAndOrdersFromSelector(
+ appDefaultConfigEngines,
+ privateDefault
+ );
+
+ let skipDefaultChangedNotification = false;
+
+ for (let { duplicateEngine, newAppEngine } of existingDuplicateEngines) {
+ if (prevCurrentEngine && prevCurrentEngine == duplicateEngine) {
+ if (
+ await lazy.defaultOverrideAllowlist.canEngineOverride(
+ duplicateEngine,
+ newAppEngine?._extensionID
+ )
+ ) {
+ lazy.logConsole.log(
+ "Applying override from",
+ duplicateEngine.id,
+ "to application engine",
+ newAppEngine._extensionID,
+ "and setting app engine default"
+ );
+ // This engine was default, and is allowed to override our application
+ // provided engines, so update the application engine and set it as
+ // default.
+ newAppEngine.overrideWithEngine({
+ engine: duplicateEngine,
+ });
+
+ this.defaultEngine = newAppEngine;
+ // We're removing the old engine and we've changed the default, but this
+ // is intentional and effectively everything is the same for the user, so
+ // don't notify.
+ skipDefaultChangedNotification = true;
+ }
+ }
+ duplicateEngine.pendingRemoval = true;
+ }
+
+ if (prevCurrentEngine && prevCurrentEngine.pendingRemoval) {
+ skipDefaultChangedNotification ||=
+ await this.#maybeRestoreEngineFromOverride(prevCurrentEngine);
+ }
+
+ // If the defaultEngine has changed between the previous load and this one,
+ // dispatch the appropriate notifications.
+ if (prevCurrentEngine && this.defaultEngine !== prevCurrentEngine) {
+ this.#recordDefaultChangedEvent(
+ false,
+ prevCurrentEngine,
+ this.defaultEngine,
+ changeReason
+ );
+ lazy.SearchUtils.notifyAction(
+ this.#currentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT
+ );
+ // If we've not got a separate private active, notify update of the
+ // private so that the UI updates correctly.
+ if (!this.#separatePrivateDefault) {
+ lazy.SearchUtils.notifyAction(
+ this.#currentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+
+ if (
+ !skipDefaultChangedNotification &&
+ prevMetaData &&
+ settings.metaData &&
+ !this.#didSettingsMetaDataUpdate(prevMetaData) &&
+ prevCurrentEngine?.pendingRemoval &&
+ Services.prefs.getBoolPref("browser.search.removeEngineInfobar.enabled")
+ ) {
+ this._showRemovalOfSearchEngineNotificationBox(
+ prevCurrentEngine.name,
+ this.defaultEngine.name
+ );
+ }
+ }
+
+ if (
+ this.#separatePrivateDefault &&
+ prevPrivateEngine &&
+ this.defaultPrivateEngine !== prevPrivateEngine
+ ) {
+ this.#recordDefaultChangedEvent(
+ true,
+ prevPrivateEngine,
+ this.defaultPrivateEngine,
+ changeReason
+ );
+ lazy.SearchUtils.notifyAction(
+ this.#currentPrivateEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+
+ // Finally, remove any engines that need removing. We do this after sorting
+ // out the new default, as otherwise this could cause multiple notifications
+ // and the wrong engine to be selected as default.
+
+ for (let engine of this._engines.values()) {
+ if (!engine.pendingRemoval) {
+ continue;
+ }
+
+ // If we have other engines that use the same extension ID, then
+ // we do not want to remove the add-on - only remove the engine itself.
+ let inUseEngines = [...this._engines.values()].filter(
+ e => e._extensionID == engine._extensionID
+ );
+
+ if (inUseEngines.length <= 1) {
+ if (inUseEngines.length == 1 && inUseEngines[0] == engine) {
+ // No other engines are using this extension ID.
+
+ // The internal remove is done first to avoid a call to removeEngine
+ // which could adjust the sort order when we don't want it to.
+ this.#internalRemoveEngine(engine);
+
+ // Only uninstall application provided engines. We don't want to
+ // remove third-party add-ons. Their search engine names might conflict,
+ // but we still allow the add-on to be installed.
+ if (engine.isAppProvided) {
+ let addon = await lazy.AddonManager.getAddonByID(
+ engine._extensionID
+ );
+ if (addon) {
+ // AddonManager won't call removeEngine if an engine with the
+ // WebExtension id doesn't exist in the search service.
+ await addon.uninstall();
+ }
+ }
+ }
+ // For the case where `inUseEngines[0] != engine`:
+ // This is a situation where there was an engine added earlier in this
+ // function with the same name.
+ // For example, eBay has the same name for both US and GB, but has
+ // a different domain and uses a different locale of the same
+ // WebExtension.
+ // The result of this is the earlier addition has already replaced
+ // the engine in `this._engines` (which is indexed by name), so all that
+ // needs to be done here is to pretend the old engine was removed
+ // which is notified below.
+ } else {
+ // More than one engine is using this extension ID, so we don't want to
+ // remove the add-on.
+ this.#internalRemoveEngine(engine);
+ }
+ lazy.SearchUtils.notifyAction(
+ engine,
+ lazy.SearchUtils.MODIFIED_TYPE.REMOVED
+ );
+ }
+
+ // Save app default engine to the user's settings metaData incase it has
+ // been updated
+ this._settings.setMetaDataAttribute(
+ "appDefaultEngineId",
+ this.appDefaultEngine?.id
+ );
+
+ // If we are leaving an experiment, and the default is the same as the
+ // application default, we reset the user's setting to blank, so that
+ // future changes of the application default engine may take effect.
+ if (
+ prevMetaData.experiment &&
+ !this._settings.getMetaDataAttribute("experiment")
+ ) {
+ if (this.defaultEngine == this.appDefaultEngine) {
+ this._settings.setVerifiedMetaDataAttribute("defaultEngineId", "");
+ }
+ if (
+ this.#separatePrivateDefault &&
+ this.defaultPrivateEngine == this.appPrivateDefaultEngine
+ ) {
+ this._settings.setVerifiedMetaDataAttribute(
+ "privateDefaultEngineId",
+ ""
+ );
+ }
+ }
+
+ this.#dontSetUseSavedOrder = false;
+ // Clear out the sorted engines settings, so that we re-sort it if necessary.
+ this._cachedSortedEngines = null;
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "engines-reloaded"
+ );
+ }
+
+ /**
+ * Potentially restores an engine if it was previously overriding the app
+ * provided engine.
+ *
+ * @param {SearchEngine} prevCurrentEngine
+ * The previous current engine to check for override.
+ * @returns {boolean}
+ * True if an engine was restored.
+ */
+ async #maybeRestoreEngineFromOverride(prevCurrentEngine) {
+ let overriddenBy = prevCurrentEngine.getAttr("overriddenBy");
+ if (!overriddenBy) {
+ return false;
+ }
+ let overriddenByOpenSearch = prevCurrentEngine.getAttr(
+ "overriddenByOpenSearch"
+ );
+ let engine;
+ if (overriddenByOpenSearch) {
+ engine = new lazy.OpenSearchEngine({
+ json: overriddenByOpenSearch,
+ });
+ } else {
+ // The previous application default engine is being removed, and it was
+ // overridden by another engine. We want to put the previous engine back,
+ // so that the user retains that engine as default.
+ engine = new lazy.AddonSearchEngine({
+ isAppProvided: false,
+ details: {
+ extensionID: overriddenBy,
+ locale: lazy.SearchUtils.DEFAULT_TAG,
+ },
+ });
+ try {
+ await engine.init({ locale: lazy.SearchUtils.DEFAULT_TAG });
+ } catch (ex) {
+ // If there is an error, the add-on may no longer be available, or
+ // there was some other issue with the settings.
+ lazy.logConsole.error(
+ "Error restoring overridden engine",
+ overriddenBy,
+ ex
+ );
+ return false;
+ }
+ }
+ engine.copyUserSettingsFrom(prevCurrentEngine);
+ this.#addEngineToStore(engine, true);
+
+ // Now set it back to default.
+ this.defaultEngine = engine;
+ return true;
+ }
+
+ #addEngineToStore(engine, skipDuplicateCheck = false) {
+ if (this.#engineMatchesIgnoreLists(engine)) {
+ lazy.logConsole.debug("#addEngineToStore: Ignoring engine");
+ return;
+ }
+
+ lazy.logConsole.debug("#addEngineToStore: Adding engine:", engine.name);
+
+ // See if there is an existing engine with the same name.
+ if (!skipDuplicateCheck && this.#getEngineByName(engine.name)) {
+ throw Components.Exception(
+ `#addEngineToStore: An engine called ${engine.name} already exists!`,
+ Cr.NS_ERROR_FILE_ALREADY_EXISTS
+ );
+ }
+
+ // Not an update, just add the new engine.
+ this._engines.set(engine.id, engine);
+ // Only add the engine to the list of sorted engines if the initial list
+ // has already been built (i.e. if this._cachedSortedEngines is non-null). If
+ // it hasn't, we're loading engines from disk and the sorted engine list
+ // will be built once we need it.
+ if (this._cachedSortedEngines && !this.#dontSetUseSavedOrder) {
+ this._cachedSortedEngines.push(engine);
+ this.#saveSortedEngineList();
+ }
+ lazy.SearchUtils.notifyAction(engine, lazy.SearchUtils.MODIFIED_TYPE.ADDED);
+
+ // Let the engine know it can start notifying new updates.
+ engine._engineAddedToStore = true;
+ }
+
+ /**
+ * Loads any search engines specified by enterprise policies.
+ *
+ * @param {object} [settings]
+ * The saved settings for the user.
+ */
+ #loadEnginesFromPolicies(settings) {
+ if (Services.policies?.status != Ci.nsIEnterprisePolicies.ACTIVE) {
+ return;
+ }
+
+ let activePolicies = Services.policies.getActivePolicies();
+ if (!activePolicies.SearchEngines) {
+ return;
+ }
+ for (let engineDetails of activePolicies.SearchEngines.Add ?? []) {
+ this.#addPolicyEngine(engineDetails, settings);
+ }
+ }
+
+ /**
+ * Loads remaining user search engines from settings.
+ *
+ * @param {object} [settings]
+ * The saved settings for the user.
+ * @returns {boolean}
+ * Returns true if the default engine was changed.
+ */
+ async #loadEnginesFromSettings(settings) {
+ if (!settings.engines) {
+ return false;
+ }
+
+ lazy.logConsole.debug(
+ "#loadEnginesFromSettings: Loading",
+ settings.engines.length,
+ "engines from settings"
+ );
+
+ let defaultEngineChanged = false;
+ let skippedEngines = 0;
+ for (let engineJSON of settings.engines) {
+ // We renamed isBuiltin to isAppProvided in bug 1631898,
+ // keep checking isBuiltin for older settings.
+ if (engineJSON._isAppProvided || engineJSON._isBuiltin) {
+ ++skippedEngines;
+ continue;
+ }
+
+ // Some OpenSearch type engines are now obsolete and no longer supported.
+ // These were application provided engines that used to use the OpenSearch
+ // format before gecko transitioned to WebExtensions.
+ // These will sometimes have been missed in migration due to various
+ // reasons, and due to how the settings saves everything. We therefore
+ // explicitly ignore them here to drop them, and let the rest of the code
+ // fallback to the application/distribution default if necessary.
+ let loadPath = engineJSON._loadPath?.toLowerCase();
+ if (
+ loadPath &&
+ // Replaced by application provided in Firefox 79.
+ (loadPath.startsWith("[distribution]") ||
+ // Langpack engines moved in-app in Firefox 62.
+ // Note: these may be prefixed by jar:,
+ loadPath.includes("[app]/extensions/langpack") ||
+ loadPath.includes("[other]/langpack") ||
+ loadPath.includes("[profile]/extensions/langpack") ||
+ // Old omni.ja engines also moved to in-app in Firefox 62.
+ loadPath.startsWith("jar:[app]/omni.ja"))
+ ) {
+ continue;
+ }
+
+ try {
+ let engine;
+ if (loadPath?.startsWith("[policy]")) {
+ skippedEngines++;
+ continue;
+ } else if (loadPath?.startsWith("[user]")) {
+ engine = new lazy.UserSearchEngine({ json: engineJSON });
+ } else if (engineJSON.extensionID ?? engineJSON._extensionID) {
+ engine = new lazy.AddonSearchEngine({
+ isAppProvided: false,
+ json: engineJSON,
+ });
+ } else {
+ engine = new lazy.OpenSearchEngine({
+ json: engineJSON,
+ });
+ }
+ // Only check the override for Add-on or OpenSearch engines, and only
+ // if they are the default engine.
+ if (
+ (engine instanceof lazy.OpenSearchEngine ||
+ engine instanceof lazy.AddonSearchEngine) &&
+ settings.metaData?.defaultEngineId == engine.id
+ ) {
+ defaultEngineChanged = await this.#maybeApplyOverride(engine);
+ if (defaultEngineChanged) {
+ continue;
+ }
+ }
+ this.#addEngineToStore(engine);
+ } catch (ex) {
+ lazy.logConsole.error(
+ "Failed to load",
+ engineJSON._name,
+ "from settings:",
+ ex,
+ engineJSON
+ );
+ }
+ }
+
+ if (skippedEngines) {
+ lazy.logConsole.debug(
+ "#loadEnginesFromSettings: skipped",
+ skippedEngines,
+ "built-in/policy engines."
+ );
+ }
+ return defaultEngineChanged;
+ }
+
+ /**
+ * Looks to see if an override may be applied to an application engine
+ * if the supplied engine is a duplicate of it. This should only be called
+ * in the case where the engine would become the default engine.
+ *
+ * @param {SearchEngine} engine
+ * The search engine to check to see if it should override an existing engine.
+ * @returns {boolean}
+ * True if the default engine was changed.
+ */
+ async #maybeApplyOverride(engine) {
+ // If an engine with the same name already exists, we're not going to
+ // be allowed to add it - however, if it is default, and it
+ // matches an existing engine, then we might be allowed to
+ // override the application provided engine.
+ let existingEngine = this.#getEngineByName(engine.name);
+ if (
+ existingEngine?.isAppProvided &&
+ (await lazy.defaultOverrideAllowlist.canEngineOverride(
+ engine,
+ existingEngine?._extensionID
+ ))
+ ) {
+ existingEngine.overrideWithEngine({
+ engine,
+ });
+ this.#setEngineDefault(
+ false,
+ existingEngine,
+ // We assume that the application provided engine was added due
+ // to a configuration change. It is possible that it was actually
+ // due to a locale/region change, but that is harder to detect
+ // here.
+ Ci.nsISearchService.CHANGE_REASON_CONFIG
+ );
+ return true;
+ }
+ return false;
+ }
+
+ // This is prefixed with _ rather than # because it is
+ // called in test_remove_engine_notification_box.js
+ async _fetchEngineSelectorEngines() {
+ let searchEngineSelectorProperties = {
+ locale: Services.locale.appLocaleAsBCP47,
+ region: lazy.Region.home || "default",
+ channel: lazy.SearchUtils.MODIFIED_APP_CHANNEL,
+ experiment:
+ lazy.NimbusFeatures.searchConfiguration.getVariable("experiment") ?? "",
+ distroID: lazy.SearchUtils.distroID ?? "",
+ };
+
+ for (let [key, value] of Object.entries(searchEngineSelectorProperties)) {
+ this._settings.setMetaDataAttribute(key, value);
+ }
+
+ let { engines, privateDefault } =
+ await this.#engineSelector.fetchEngineConfiguration(
+ searchEngineSelectorProperties
+ );
+
+ for (let e of engines) {
+ if (!e.webExtension) {
+ e.webExtension = {};
+ }
+ e.webExtension.locale =
+ e.webExtension?.locale ?? lazy.SearchUtils.DEFAULT_TAG;
+
+ // TODO Bug 1875912 - Remove the webextension.id and webextension.locale when
+ // we're ready to remove old search-config and use search-config-v2 for all
+ // clients. The id in appProvidedSearchEngine should be changed to
+ // engine.identifier.
+ if (lazy.SearchUtils.newSearchConfigEnabled) {
+ let identifierComponents = NON_SPLIT_ENGINE_IDS.includes(e.identifier)
+ ? [e.identifier]
+ : e.identifier.split("-");
+
+ if (e.identifier == "amazon-se") {
+ identifierComponents[1] = "sweden";
+ }
+
+ if (e.identifier == "amazon-es") {
+ identifierComponents[1] = "spain";
+ }
+
+ let locale = identifierComponents.slice(1).join("-") || "default";
+
+ e.webExtension.id = identifierComponents[0] + "@search.mozilla.org";
+ e.webExtension.locale = locale;
+ }
+ }
+
+ return { engines, privateDefault };
+ }
+
+ #setDefaultAndOrdersFromSelector(engines, privateDefault) {
+ const defaultEngine = engines[0];
+ this._searchDefault = {
+ id: defaultEngine.webExtension.id,
+ locale: defaultEngine.webExtension.locale,
+ };
+ if (privateDefault) {
+ this.#searchPrivateDefault = {
+ id: privateDefault.webExtension.id,
+ locale: privateDefault.webExtension.locale,
+ };
+ }
+ }
+
+ #saveSortedEngineList() {
+ lazy.logConsole.debug("#saveSortedEngineList");
+
+ // Set the useSavedOrder attribute to indicate that from now on we should
+ // use the user's order information stored in settings.
+ this._settings.setMetaDataAttribute("useSavedOrder", true);
+
+ var engines = this.#sortedEngines;
+
+ for (var i = 0; i < engines.length; ++i) {
+ engines[i].setAttr("order", i + 1);
+ }
+ }
+
+ #buildSortedEngineList() {
+ // We must initialise _cachedSortedEngines here to avoid infinite recursion
+ // in the case of tests which don't define a default search engine.
+ // If there's no default defined, then we revert to the first item in the
+ // sorted list, but we can't do that if we don't have a list.
+ this._cachedSortedEngines = [];
+
+ // If the user has specified a custom engine order, read the order
+ // information from the metadata instead of the default prefs.
+ if (this._settings.getMetaDataAttribute("useSavedOrder")) {
+ lazy.logConsole.debug("#buildSortedEngineList: using saved order");
+ let addedEngines = {};
+
+ // Flag to keep track of whether or not we need to call #saveSortedEngineList.
+ let needToSaveEngineList = false;
+
+ for (let engine of this._engines.values()) {
+ var orderNumber = engine.getAttr("order");
+
+ // Since the DB isn't regularly cleared, and engine files may disappear
+ // without us knowing, we may already have an engine in this slot. If
+ // that happens, we just skip it - it will be added later on as an
+ // unsorted engine.
+ if (orderNumber && !this._cachedSortedEngines[orderNumber - 1]) {
+ this._cachedSortedEngines[orderNumber - 1] = engine;
+ addedEngines[engine.name] = engine;
+ } else {
+ // We need to call #saveSortedEngineList so this gets sorted out.
+ needToSaveEngineList = true;
+ }
+ }
+
+ // Filter out any nulls for engines that may have been removed
+ var filteredEngines = this._cachedSortedEngines.filter(function (a) {
+ return !!a;
+ });
+ if (this._cachedSortedEngines.length != filteredEngines.length) {
+ needToSaveEngineList = true;
+ }
+ this._cachedSortedEngines = filteredEngines;
+
+ if (needToSaveEngineList) {
+ this.#saveSortedEngineList();
+ }
+
+ // Array for the remaining engines, alphabetically sorted.
+ let alphaEngines = [];
+
+ for (let engine of this._engines.values()) {
+ if (!(engine.name in addedEngines)) {
+ alphaEngines.push(engine);
+ }
+ }
+
+ const collator = new Intl.Collator();
+ alphaEngines.sort((a, b) => {
+ return collator.compare(a.name, b.name);
+ });
+ return (this._cachedSortedEngines =
+ this._cachedSortedEngines.concat(alphaEngines));
+ }
+ lazy.logConsole.debug("#buildSortedEngineList: using default orders");
+
+ return (this._cachedSortedEngines = this._sortEnginesByDefaults(
+ Array.from(this._engines.values())
+ ));
+ }
+
+ /**
+ * Sorts engines by the default settings (prefs, configuration values).
+ *
+ * @param {Array} engines
+ * An array of engine objects to sort.
+ * @returns {Array}
+ * The sorted array of engine objects.
+ *
+ * This is a private method with _ rather than # because it is
+ * called in a test.
+ */
+ _sortEnginesByDefaults(engines) {
+ const sortedEngines = [];
+ const addedEngines = new Set();
+
+ function maybeAddEngineToSort(engine) {
+ if (!engine || addedEngines.has(engine.name)) {
+ return;
+ }
+
+ sortedEngines.push(engine);
+ addedEngines.add(engine.name);
+ }
+
+ // The app default engine should always be first in the list (except
+ // for distros, that we should respect).
+ const appDefault = this.appDefaultEngine;
+ maybeAddEngineToSort(appDefault);
+
+ // If there's a private default, and it is different to the normal
+ // default, then it should be second in the list.
+ const appPrivateDefault = this.appPrivateDefaultEngine;
+ if (appPrivateDefault && appPrivateDefault != appDefault) {
+ maybeAddEngineToSort(appPrivateDefault);
+ }
+
+ let remainingEngines;
+ const collator = new Intl.Collator();
+
+ remainingEngines = engines.filter(e => !addedEngines.has(e.name));
+
+ // We sort by highest orderHint first, then alphabetically by name.
+ remainingEngines.sort((a, b) => {
+ if (a._orderHint && b._orderHint) {
+ if (a._orderHint == b._orderHint) {
+ return collator.compare(a.name, b.name);
+ }
+ return b._orderHint - a._orderHint;
+ }
+ if (a._orderHint) {
+ return -1;
+ }
+ if (b._orderHint) {
+ return 1;
+ }
+ return collator.compare(a.name, b.name);
+ });
+
+ return [...sortedEngines, ...remainingEngines];
+ }
+
+ /**
+ * Get a sorted array of the visible engines.
+ *
+ * @returns {Array<SearchEngine>}
+ */
+
+ get #sortedVisibleEngines() {
+ return this.#sortedEngines.filter(engine => !engine.hidden);
+ }
+
+ /**
+ * Migrates legacy add-ons which used the OpenSearch definitions to
+ * WebExtensions, if an equivalent WebExtension is installed.
+ *
+ * Run during the background checks.
+ */
+ async #migrateLegacyEngines() {
+ lazy.logConsole.debug("Running migrate legacy engines");
+
+ const matchRegExp = /extensions\/(.*?)\.xpi!/i;
+ for (let engine of this._engines.values()) {
+ if (
+ !engine.isAppProvided &&
+ !engine._extensionID &&
+ engine._loadPath.includes("[profile]/extensions/")
+ ) {
+ let match = engine._loadPath.match(matchRegExp);
+ if (match?.[1]) {
+ // There's a chance here that the WebExtension might not be
+ // installed any longer, even though the engine is. We'll deal
+ // with that in `checkWebExtensionEngines`.
+ let engines = await this.getEnginesByExtensionID(match[1]);
+ if (engines.length) {
+ lazy.logConsole.debug(
+ `Migrating ${engine.name} to WebExtension install`
+ );
+
+ if (this.defaultEngine == engine) {
+ this.defaultEngine = engines[0];
+ }
+ await this.removeEngine(engine);
+ }
+ }
+ }
+ }
+
+ lazy.logConsole.debug("Migrate legacy engines complete");
+ }
+
+ /**
+ * Checks if Search Engines associated with WebExtensions are valid and
+ * up-to-date, and reports them via telemetry if not.
+ *
+ * Run during the background checks.
+ */
+ async #checkWebExtensionEngines() {
+ lazy.logConsole.debug("Running check on WebExtension engines");
+
+ for (let engine of this._engines.values()) {
+ if (engine instanceof lazy.AddonSearchEngine && !engine.isAppProvided) {
+ await engine.checkAndReportIfSettingsValid();
+ }
+ }
+ lazy.logConsole.debug("WebExtension engine check complete");
+ }
+
+ /**
+ * Counts the number of secure, insecure, securely updated and insecurely
+ * updated OpenSearch engines the user has installed and reports those
+ * counts via telemetry.
+ *
+ * Run during the background checks.
+ */
+ async #addOpenSearchTelemetry() {
+ let totalSecure = 0;
+ let totalInsecure = 0;
+ let totalWithSecureUpdates = 0;
+ let totalWithInsecureUpdates = 0;
+
+ let engine;
+ let searchURI;
+ let updateURI;
+ for (let elem of this._engines) {
+ engine = elem[1];
+ if (engine instanceof lazy.OpenSearchEngine) {
+ searchURI = engine.searchURLWithNoTerms;
+ updateURI = engine.updateURI;
+
+ if (lazy.SearchUtils.isSecureURIForOpenSearch(searchURI)) {
+ totalSecure++;
+ } else {
+ totalInsecure++;
+ }
+
+ if (updateURI && lazy.SearchUtils.isSecureURIForOpenSearch(updateURI)) {
+ totalWithSecureUpdates++;
+ } else if (updateURI) {
+ totalWithInsecureUpdates++;
+ }
+ }
+ }
+
+ Services.telemetry.scalarSet(
+ "browser.searchinit.secure_opensearch_engine_count",
+ totalSecure
+ );
+ Services.telemetry.scalarSet(
+ "browser.searchinit.insecure_opensearch_engine_count",
+ totalInsecure
+ );
+ Services.telemetry.scalarSet(
+ "browser.searchinit.secure_opensearch_update_count",
+ totalWithSecureUpdates
+ );
+ Services.telemetry.scalarSet(
+ "browser.searchinit.insecure_opensearch_update_count",
+ totalWithInsecureUpdates
+ );
+ }
+
+ /**
+ * Creates and adds a WebExtension based engine.
+ *
+ * @param {object} options
+ * Options for the engine.
+ * @param {Extension} options.extension
+ * An Extension object containing data about the extension.
+ * @param {string} [options.locale]
+ * The locale to use within the WebExtension. Defaults to the WebExtension's
+ * default locale.
+ * @param {object} [options.settings]
+ * The saved settings for the user.
+ * @param {initEngine} [options.initEngine]
+ * Set to true if this engine is being loaded during initialization.
+ */
+ async _createAndAddEngine({
+ extension,
+ locale = lazy.SearchUtils.DEFAULT_TAG,
+ settings,
+ initEngine = false,
+ }) {
+ // If we're in the startup cycle, and we've already loaded this engine,
+ // then we use the existing one rather than trying to start from scratch.
+ // This also avoids console errors.
+ if (extension.startupReason == "APP_STARTUP") {
+ let engine = this.#getEngineByWebExtensionDetails({
+ id: extension.id,
+ locale,
+ });
+ if (engine) {
+ lazy.logConsole.debug(
+ "Engine already loaded via settings, skipping due to APP_STARTUP:",
+ extension.id
+ );
+ return engine;
+ }
+ }
+
+ // We install search extensions during the init phase, both built in
+ // web extensions freshly installed (via addEnginesFromExtension) or
+ // user installed extensions being reenabled calling this directly.
+ if (!this.isInitialized && !extension.isAppProvided && !initEngine) {
+ await this.init();
+ }
+
+ let shouldSetAsDefault = false;
+ let changeReason = Ci.nsISearchService.CHANGE_REASON_UNKNOWN;
+
+ for (let engine of this._engines.values()) {
+ if (
+ !engine.extensionID &&
+ engine._loadPath.startsWith(`jar:[profile]/extensions/${extension.id}`)
+ ) {
+ // This is a legacy extension engine that needs to be migrated to WebExtensions.
+ lazy.logConsole.debug("Migrating existing engine");
+ shouldSetAsDefault = shouldSetAsDefault || this.defaultEngine == engine;
+ await this.removeEngine(engine);
+ }
+ }
+
+ let newEngine = new lazy.AddonSearchEngine({
+ isAppProvided: extension.isAppProvided,
+ details: {
+ extensionID: extension.id,
+ locale,
+ },
+ });
+ await newEngine.init({
+ settings,
+ extension,
+ locale,
+ });
+
+ // If this extension is starting up, check to see if it previously overrode
+ // an application provided engine that has now been removed from the user's
+ // set-up. If the application provided engine has been removed and was
+ // default, then we should set this engine back to default and copy
+ // the settings across.
+ if (extension.startupReason == "APP_STARTUP") {
+ if (!settings) {
+ settings = await this._settings.get();
+ }
+ // We check the saved settings for the overridden flag, because if the engine
+ // has been removed, we won't have that in _engines.
+ let previouslyOverridden = settings.engines?.find(
+ e => !!e._metaData.overriddenBy
+ );
+ if (previouslyOverridden) {
+ let previousWebExtensionId = previouslyOverridden.id.endsWith("default")
+ ? previouslyOverridden.id.slice(0, -7)
+ : previouslyOverridden.id;
+
+ // Only allow override if we were previously overriding and the
+ // engine is no longer installed, and the new engine still matches the
+ // override allow list.
+ if (
+ previouslyOverridden._metaData.overriddenBy == extension.id &&
+ !this._engines.get(previouslyOverridden.id) &&
+ (await lazy.defaultOverrideAllowlist.canEngineOverride(
+ newEngine,
+ previousWebExtensionId
+ ))
+ ) {
+ shouldSetAsDefault = true;
+ // We assume that the app provided engine was removed due to a
+ // configuration change, and therefore we have re-added the add-on
+ // search engine. It is possible that it was actually due to a
+ // locale/region change, but that is harder to detect here.
+ changeReason = Ci.nsISearchService.CHANGE_REASON_CONFIG;
+ newEngine.copyUserSettingsFrom(previouslyOverridden);
+ }
+ }
+ }
+
+ this.#addEngineToStore(newEngine);
+ if (shouldSetAsDefault) {
+ this.#setEngineDefault(false, newEngine, changeReason);
+ }
+ return newEngine;
+ }
+
+ /**
+ * Called when we see an upgrade to an existing search extension.
+ *
+ * @param {object} extension
+ * An Extension object containing data about the extension.
+ */
+ async #upgradeExtensionEngine(extension) {
+ let { engines } = await this._fetchEngineSelectorEngines();
+ let extensionEngines = await this.getEnginesByExtensionID(extension.id);
+
+ for (let engine of extensionEngines) {
+ let isDefault = engine == this.defaultEngine;
+ let isDefaultPrivate = engine == this.defaultPrivateEngine;
+
+ let originalName = engine.name;
+ let locale = engine._locale || lazy.SearchUtils.DEFAULT_TAG;
+ let configuration =
+ engines.find(
+ e =>
+ e.webExtension.id == extension.id && e.webExtension.locale == locale
+ ) ?? {};
+
+ await engine.update({
+ configuration,
+ extension,
+ locale,
+ });
+
+ if (engine.name != originalName) {
+ if (isDefault) {
+ this._settings.setVerifiedMetaDataAttribute(
+ "defaultEngineId",
+ engine.id
+ );
+ }
+ if (isDefaultPrivate) {
+ this._settings.setVerifiedMetaDataAttribute(
+ "privateDefaultEngineId",
+ engine.id
+ );
+ }
+ this._cachedSortedEngines = null;
+ }
+ }
+ return extensionEngines;
+ }
+
+ async #installExtensionEngine(
+ extension,
+ locales,
+ settings,
+ initEngine = false
+ ) {
+ lazy.logConsole.debug("installExtensionEngine:", extension.id);
+
+ let installLocale = async locale => {
+ return this._createAndAddEngine({
+ extension,
+ locale,
+ settings,
+ initEngine,
+ });
+ };
+
+ let engines = [];
+ for (let locale of locales) {
+ lazy.logConsole.debug(
+ "addEnginesFromExtension: installing:",
+ extension.id,
+ ":",
+ locale
+ );
+ engines.push(await installLocale(locale));
+ }
+ return engines;
+ }
+
+ #internalRemoveEngine(engine) {
+ // Remove the engine from _sortedEngines
+ if (this._cachedSortedEngines) {
+ var index = this._cachedSortedEngines.indexOf(engine);
+ if (index == -1) {
+ throw Components.Exception(
+ "Can't find engine to remove in _sortedEngines!",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ this._cachedSortedEngines.splice(index, 1);
+ }
+
+ // Remove the engine from the internal store
+ this._engines.delete(engine.id);
+ }
+
+ /**
+ * Helper function to find a new default engine and set it. This could
+ * be used if there is not default set yet, or if the current default is
+ * being removed.
+ *
+ * This function will not consider engines that have a `pendingRemoval`
+ * property set to true.
+ *
+ * The new default will be chosen from (in order):
+ *
+ * - Existing default from configuration, if it is not hidden.
+ * - The first non-hidden engine that is a general search engine.
+ * - If all other engines are hidden, unhide the default from the configuration.
+ * - If the default from the configuration is the one being removed, unhide
+ * the first general search engine, or first visible engine.
+ *
+ * @param {boolean} privateMode
+ * If true, returns the default engine for private browsing mode, otherwise
+ * the default engine for the normal mode. Note, this function does not
+ * check the "separatePrivateDefault" preference - that is up to the caller.
+ * @returns {nsISearchEngine|null}
+ * The appropriate search engine, or null if one could not be determined.
+ */
+ #findAndSetNewDefaultEngine({ privateMode }) {
+ // First to the app default engine...
+ let newDefault = privateMode
+ ? this.appPrivateDefaultEngine
+ : this.appDefaultEngine;
+
+ if (!newDefault || newDefault.hidden || newDefault.pendingRemoval) {
+ let sortedEngines = this.#sortedVisibleEngines;
+ let generalSearchEngines = sortedEngines.filter(
+ e => e.isGeneralPurposeEngine
+ );
+
+ // then to the first visible general search engine that isn't excluded...
+ let firstVisible = generalSearchEngines.find(e => !e.pendingRemoval);
+ if (firstVisible) {
+ newDefault = firstVisible;
+ } else if (newDefault) {
+ // then to the app default if it is not the one that is excluded...
+ if (!newDefault.pendingRemoval) {
+ newDefault.hidden = false;
+ } else {
+ newDefault = null;
+ }
+ }
+
+ // and finally as a last resort we unhide the first engine
+ // even if the name is the same as the excluded one (should never happen).
+ if (!newDefault) {
+ if (!firstVisible) {
+ sortedEngines = this.#sortedEngines;
+ firstVisible = sortedEngines.find(e => e.isGeneralPurposeEngine);
+ if (!firstVisible) {
+ firstVisible = sortedEngines[0];
+ }
+ }
+ if (firstVisible) {
+ firstVisible.hidden = false;
+ newDefault = firstVisible;
+ }
+ }
+ }
+ // We tried out best but something went very wrong.
+ if (!newDefault) {
+ lazy.logConsole.error("Could not find a replacement default engine.");
+ return null;
+ }
+
+ // If the current engine wasn't set or was hidden, we used a fallback
+ // to pick a new current engine. As soon as we return it, this new
+ // current engine will become user-visible, so we should persist it.
+ // by calling the setter.
+ this.#setEngineDefault(privateMode, newDefault);
+
+ return privateMode ? this.#currentPrivateEngine : this.#currentEngine;
+ }
+
+ /**
+ * Helper function to set the current default engine.
+ *
+ * @param {boolean} privateMode
+ * If true, sets the default engine for private browsing mode, otherwise
+ * sets the default engine for the normal mode. Note, this function does not
+ * check the "separatePrivateDefault" preference - that is up to the caller.
+ * @param {nsISearchEngine} newEngine
+ * The search engine to select
+ * @param {SearchUtils.REASON_CHANGE_MAP} changeSource
+ * The source of the change of engine.
+ */
+ #setEngineDefault(privateMode, newEngine, changeSource) {
+ // Sometimes we get wrapped nsISearchEngine objects (external XPCOM callers),
+ // and sometimes we get raw Engine JS objects (callers in this file), so
+ // handle both.
+ if (
+ !(newEngine instanceof Ci.nsISearchEngine) &&
+ !(newEngine instanceof lazy.SearchEngine)
+ ) {
+ throw Components.Exception(
+ "Invalid argument passed to defaultEngine setter",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ const newCurrentEngine = this._engines.get(newEngine.id);
+ if (!newCurrentEngine) {
+ throw Components.Exception(
+ "Can't find engine in store!",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ if (!newCurrentEngine.isAppProvided) {
+ // If a non default engine is being set as the current engine, ensure
+ // its loadPath has a verification hash.
+ if (!newCurrentEngine._loadPath) {
+ newCurrentEngine._loadPath = "[other]unknown";
+ }
+ let loadPathHash = lazy.SearchUtils.getVerificationHash(
+ newCurrentEngine._loadPath
+ );
+ let currentHash = newCurrentEngine.getAttr("loadPathHash");
+ if (!currentHash || currentHash != loadPathHash) {
+ newCurrentEngine.setAttr("loadPathHash", loadPathHash);
+ lazy.SearchUtils.notifyAction(
+ newCurrentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.CHANGED
+ );
+ }
+ }
+
+ let currentEngine = privateMode
+ ? this.#currentPrivateEngine
+ : this.#currentEngine;
+
+ if (newCurrentEngine == currentEngine) {
+ return;
+ }
+
+ // Ensure that we reset an engine override if it was previously overridden.
+ currentEngine?.removeExtensionOverride();
+
+ if (privateMode) {
+ this.#currentPrivateEngine = newCurrentEngine;
+ } else {
+ this.#currentEngine = newCurrentEngine;
+ }
+
+ // If we change the default engine in the future, that change should impact
+ // users who have switched away from and then back to the build's
+ // "app default" engine. So clear the user pref when the currentEngine is
+ // set to the build's app default engine, so that the currentEngine getter
+ // falls back to whatever the default is.
+ // However, we do not do this whilst we are running an experiment - an
+ // experiment must preseve the user's choice of default engine during it's
+ // runtime and when it ends. Once the experiment ends, we will reset the
+ // attribute elsewhere.
+ let newId = newCurrentEngine.id;
+ const appDefaultEngine = privateMode
+ ? this.appPrivateDefaultEngine
+ : this.appDefaultEngine;
+ if (
+ newCurrentEngine == appDefaultEngine &&
+ !lazy.NimbusFeatures.searchConfiguration.getVariable("experiment")
+ ) {
+ newId = "";
+ }
+
+ this._settings.setVerifiedMetaDataAttribute(
+ privateMode ? "privateDefaultEngineId" : "defaultEngineId",
+ newId
+ );
+
+ // Only do this if we're initialized though - this function can get called
+ // during initalization.
+ if (this.isInitialized) {
+ this.#recordDefaultChangedEvent(
+ privateMode,
+ currentEngine,
+ newCurrentEngine,
+ changeSource
+ );
+ this.#recordTelemetryData();
+ }
+
+ lazy.SearchUtils.notifyAction(
+ newCurrentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE[
+ privateMode ? "DEFAULT_PRIVATE" : "DEFAULT"
+ ]
+ );
+ // If we've not got a separate private active, notify update of the
+ // private so that the UI updates correctly.
+ if (!privateMode && !this.#separatePrivateDefault) {
+ lazy.SearchUtils.notifyAction(
+ newCurrentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+ }
+
+ #onSeparateDefaultPrefChanged(prefName, previousValue, currentValue) {
+ // Clear out the sorted engines settings, so that we re-sort it if necessary.
+ this._cachedSortedEngines = null;
+ // We should notify if the normal default, and the currently saved private
+ // default are different. Otherwise, save the energy.
+ if (this.defaultEngine != this._getEngineDefault(true)) {
+ lazy.SearchUtils.notifyAction(
+ // Always notify with the new private engine, the function checks
+ // the preference value for us.
+ this.defaultPrivateEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+ // Always notify about the change of status of private default if the user
+ // toggled the UI.
+ if (
+ prefName ==
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault"
+ ) {
+ if (!previousValue && currentValue) {
+ this.#recordDefaultChangedEvent(
+ true,
+ null,
+ this._getEngineDefault(true),
+ Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT
+ );
+ } else {
+ this.#recordDefaultChangedEvent(
+ true,
+ this._getEngineDefault(true),
+ null,
+ Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT
+ );
+ }
+ }
+ // Update the telemetry data.
+ this.#recordTelemetryData();
+ }
+
+ #getEngineInfo(engine) {
+ if (!engine) {
+ // The defaultEngine getter will throw if there's no engine at all,
+ // which shouldn't happen unless an add-on or a test deleted all of them.
+ // Our preferences UI doesn't let users do that.
+ console.error("getDefaultEngineInfo: No default engine");
+ return ["NONE", { name: "NONE" }];
+ }
+
+ const engineData = {
+ loadPath: engine._loadPath,
+ name: engine.name ? engine.name : "",
+ };
+
+ if (engine.isAppProvided) {
+ engineData.origin = "default";
+ } else {
+ let currentHash = engine.getAttr("loadPathHash");
+ if (!currentHash) {
+ engineData.origin = "unverified";
+ } else {
+ let loadPathHash = lazy.SearchUtils.getVerificationHash(
+ engine._loadPath
+ );
+ engineData.origin =
+ currentHash == loadPathHash ? "verified" : "invalid";
+ }
+ }
+
+ // For privacy, we only collect the submission URL for default engines...
+ let sendSubmissionURL = engine.isAppProvided;
+
+ if (!sendSubmissionURL) {
+ // ... or engines that are the same domain as a default engine.
+ let engineHost = engine.searchUrlDomain;
+ for (let innerEngine of this._engines.values()) {
+ if (!innerEngine.isAppProvided) {
+ continue;
+ }
+
+ if (innerEngine.searchUrlDomain == engineHost) {
+ sendSubmissionURL = true;
+ break;
+ }
+ }
+
+ if (!sendSubmissionURL) {
+ // ... or well known search domains.
+ //
+ // Starts with: www.google., search.aol., yandex.
+ // or
+ // Ends with: search.yahoo.com, .ask.com, .bing.com, .startpage.com, baidu.com, duckduckgo.com
+ const urlTest =
+ /^(?:www\.google\.|search\.aol\.|yandex\.)|(?:search\.yahoo|\.ask|\.bing|\.startpage|\.baidu|duckduckgo)\.com$/;
+ sendSubmissionURL = urlTest.test(engineHost);
+ }
+ }
+
+ if (sendSubmissionURL) {
+ let uri = engine.searchURLWithNoTerms;
+ uri = uri
+ .mutate()
+ .setUserPass("") // Avoid reporting a username or password.
+ .finalize();
+ engineData.submissionURL = uri.spec;
+ }
+
+ return [engine.telemetryId, engineData];
+ }
+
+ /**
+ * Records an event for where the default engine is changed. This is
+ * recorded to both Glean and Telemetry.
+ *
+ * The Glean GIFFT functionality is not used here because we use longer
+ * names in the extra arguments to the event.
+ *
+ * @param {boolean} isPrivate
+ * True if this is a event about a private engine.
+ * @param {SearchEngine} [previousEngine]
+ * The previously default search engine.
+ * @param {SearchEngine} [newEngine]
+ * The new default search engine.
+ * @param {string} changeSource
+ * The source of the change of default.
+ */
+ #recordDefaultChangedEvent(
+ isPrivate,
+ previousEngine,
+ newEngine,
+ changeSource = Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ ) {
+ changeSource = REASON_CHANGE_MAP.get(changeSource) ?? "unknown";
+ Services.telemetry.setEventRecordingEnabled("search", true);
+ let telemetryId;
+ let engineInfo;
+ // If we are toggling the separate private browsing settings, we might not
+ // have an engine to record.
+ if (newEngine) {
+ [telemetryId, engineInfo] = this.#getEngineInfo(newEngine);
+ } else {
+ telemetryId = "";
+ engineInfo = {
+ name: "",
+ loadPath: "",
+ submissionURL: "",
+ };
+ }
+
+ let submissionURL = engineInfo.submissionURL ?? "";
+ Services.telemetry.recordEvent(
+ "search",
+ "engine",
+ isPrivate ? "change_private" : "change_default",
+ changeSource,
+ {
+ // In docshell tests, the previous engine does not exist, so we allow
+ // for the previousEngine to be undefined.
+ prev_id: previousEngine?.telemetryId ?? "",
+ new_id: telemetryId,
+ new_name: engineInfo.name,
+ new_load_path: engineInfo.loadPath,
+ // Telemetry has a limit of 80 characters.
+ new_sub_url: submissionURL.slice(0, 80),
+ }
+ );
+
+ let extraArgs = {
+ // In docshell tests, the previous engine does not exist, so we allow
+ // for the previousEngine to be undefined.
+ previous_engine_id: previousEngine?.telemetryId ?? "",
+ new_engine_id: telemetryId,
+ new_display_name: engineInfo.name,
+ new_load_path: engineInfo.loadPath,
+ // Glean has a limit of 100 characters.
+ new_submission_url: submissionURL.slice(0, 100),
+ change_source: changeSource,
+ };
+ if (isPrivate) {
+ Glean.searchEnginePrivate.changed.record(extraArgs);
+ } else {
+ Glean.searchEngineDefault.changed.record(extraArgs);
+ }
+ }
+
+ /**
+ * Records the user's current default engine (normal and private) data to
+ * telemetry.
+ */
+ #recordTelemetryData() {
+ let info = this.getDefaultEngineInfo();
+
+ Glean.searchEngineDefault.engineId.set(info.defaultSearchEngine);
+ Glean.searchEngineDefault.displayName.set(
+ info.defaultSearchEngineData.name
+ );
+ Glean.searchEngineDefault.loadPath.set(
+ info.defaultSearchEngineData.loadPath
+ );
+ Glean.searchEngineDefault.submissionUrl.set(
+ info.defaultSearchEngineData.submissionURL ?? "blank:"
+ );
+ Glean.searchEngineDefault.verified.set(info.defaultSearchEngineData.origin);
+
+ Glean.searchEnginePrivate.engineId.set(
+ info.defaultPrivateSearchEngine ?? ""
+ );
+
+ if (info.defaultPrivateSearchEngineData) {
+ Glean.searchEnginePrivate.displayName.set(
+ info.defaultPrivateSearchEngineData.name
+ );
+ Glean.searchEnginePrivate.loadPath.set(
+ info.defaultPrivateSearchEngineData.loadPath
+ );
+ Glean.searchEnginePrivate.submissionUrl.set(
+ info.defaultPrivateSearchEngineData.submissionURL ?? "blank:"
+ );
+ Glean.searchEnginePrivate.verified.set(
+ info.defaultPrivateSearchEngineData.origin
+ );
+ } else {
+ Glean.searchEnginePrivate.displayName.set("");
+ Glean.searchEnginePrivate.loadPath.set("");
+ Glean.searchEnginePrivate.submissionUrl.set("blank:");
+ Glean.searchEnginePrivate.verified.set("");
+ }
+ }
+
+ /**
+ * This function is called at the beginning of search service init.
+ * If the error type set in a test environment matches errorType
+ * passed to this function, we throw an error.
+ *
+ * @param {string} errorType
+ * The error that can occur during search service init.
+ *
+ */
+ #maybeThrowErrorInTest(errorType) {
+ if (
+ Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") &&
+ this.errorToThrowInTest === errorType
+ ) {
+ throw new Error(
+ `Fake ${errorType} error during search service initialization.`
+ );
+ }
+ }
+
+ #buildParseSubmissionMap() {
+ this.#parseSubmissionMap = new Map();
+
+ // Used only while building the map, indicates which entries do not refer to
+ // the main domain of the engine but to an alternate domain, for example
+ // "www.google.fr" for the "www.google.com" search engine.
+ let keysOfAlternates = new Set();
+
+ for (let engine of this.#sortedEngines) {
+ if (engine.hidden) {
+ continue;
+ }
+
+ let urlParsingInfo = engine.getURLParsingInfo();
+ if (!urlParsingInfo) {
+ continue;
+ }
+
+ // Store the same object on each matching map key, as an optimization.
+ let mapValueForEngine = {
+ engine,
+ termsParameterName: urlParsingInfo.termsParameterName,
+ };
+
+ let processDomain = (domain, isAlternate) => {
+ let key = domain + urlParsingInfo.path;
+
+ // Apply the logic for which main domains take priority over alternate
+ // domains, even if they are found later in the ordered engine list.
+ let existingEntry = this.#parseSubmissionMap.get(key);
+ if (!existingEntry) {
+ if (isAlternate) {
+ keysOfAlternates.add(key);
+ }
+ } else if (!isAlternate && keysOfAlternates.has(key)) {
+ keysOfAlternates.delete(key);
+ } else {
+ return;
+ }
+
+ this.#parseSubmissionMap.set(key, mapValueForEngine);
+ };
+
+ processDomain(urlParsingInfo.mainDomain, false);
+ lazy.SearchStaticData.getAlternateDomains(
+ urlParsingInfo.mainDomain
+ ).forEach(d => processDomain(d, true));
+ }
+ }
+
+ #nimbusSearchUpdatedFun = null;
+
+ async #nimbusSearchUpdated() {
+ this.#checkNimbusPrefs();
+ Services.search.wrappedJSObject._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_EXPERIMENT
+ );
+ }
+
+ /**
+ * Check the prefs are correctly updated for users enrolled in a Nimbus experiment.
+ *
+ * @param {boolean} isStartup
+ * Whether this function was called as part of the startup flow.
+ */
+ #checkNimbusPrefs(isStartup = false) {
+ // If we are in an experiment we may need to check the status on startup, otherwise
+ // ignore the call to check on startup so we do not reset users prefs when they are
+ // not an experiment.
+ if (
+ isStartup &&
+ !lazy.NimbusFeatures.searchConfiguration.getVariable("experiment")
+ ) {
+ return;
+ }
+ let nimbusPrivateDefaultUIEnabled =
+ lazy.NimbusFeatures.searchConfiguration.getVariable(
+ "seperatePrivateDefaultUIEnabled"
+ );
+ let nimbusPrivateDefaultUrlbarResultEnabled =
+ lazy.NimbusFeatures.searchConfiguration.getVariable(
+ "seperatePrivateDefaultUrlbarResultEnabled"
+ );
+
+ let previousPrivateDefault = this.defaultPrivateEngine;
+ let uiWasEnabled = this._separatePrivateDefaultEnabledPrefValue;
+ if (
+ this._separatePrivateDefaultEnabledPrefValue !=
+ nimbusPrivateDefaultUIEnabled
+ ) {
+ Services.prefs.setBoolPref(
+ `${lazy.SearchUtils.BROWSER_SEARCH_PREF}separatePrivateDefault.ui.enabled`,
+ nimbusPrivateDefaultUIEnabled
+ );
+ let newPrivateDefault = this.defaultPrivateEngine;
+ if (previousPrivateDefault != newPrivateDefault) {
+ if (!uiWasEnabled) {
+ this.#recordDefaultChangedEvent(
+ true,
+ null,
+ newPrivateDefault,
+ Ci.nsISearchService.CHANGE_REASON_EXPERIMENT
+ );
+ } else {
+ this.#recordDefaultChangedEvent(
+ true,
+ previousPrivateDefault,
+ null,
+ Ci.nsISearchService.CHANGE_REASON_EXPERIMENT
+ );
+ }
+ }
+ }
+ if (
+ this.separatePrivateDefaultUrlbarResultEnabled !=
+ nimbusPrivateDefaultUrlbarResultEnabled
+ ) {
+ Services.prefs.setBoolPref(
+ `${lazy.SearchUtils.BROWSER_SEARCH_PREF}separatePrivateDefault.urlbarResult.enabled`,
+ nimbusPrivateDefaultUrlbarResultEnabled
+ );
+ }
+ }
+
+ #addObservers() {
+ if (this.#observersAdded) {
+ // There might be a race between synchronous and asynchronous
+ // initialization for which we try to register the observers twice.
+ return;
+ }
+ this.#observersAdded = true;
+
+ this.#nimbusSearchUpdatedFun = this.#nimbusSearchUpdated.bind(this);
+ lazy.NimbusFeatures.searchConfiguration.onUpdate(
+ this.#nimbusSearchUpdatedFun
+ );
+
+ Services.obs.addObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
+ Services.obs.addObserver(this, QUIT_APPLICATION_TOPIC);
+ Services.obs.addObserver(this, TOPIC_LOCALES_CHANGE);
+
+ this._settings.addObservers();
+
+ // The current stage of shutdown. Used to help analyze crash
+ // signatures in case of shutdown timeout.
+ let shutdownState = {
+ step: "Not started",
+ latestError: {
+ message: undefined,
+ stack: undefined,
+ },
+ };
+ IOUtils.profileBeforeChange.addBlocker(
+ "Search service: shutting down",
+ () =>
+ (async () => {
+ // If we are in initialization, then don't attempt to save the settings.
+ // It is likely that shutdown will have caused the add-on manager to
+ // stop, which can cause initialization to fail.
+ // Hence at that stage, we could have broken settings which we don't
+ // want to write.
+ // The good news is, that if we don't write the settings here, we'll
+ // detect the out-of-date settings on next state, and automatically
+ // rebuild it.
+ if (!this.isInitialized) {
+ lazy.logConsole.warn(
+ "not saving settings on shutdown due to initializing."
+ );
+ return;
+ }
+
+ try {
+ await this._settings.shutdown(shutdownState);
+ } catch (ex) {
+ // Ensure that error is reported and that it causes tests
+ // to fail, otherwise ignore it.
+ Promise.reject(ex);
+ }
+ })(),
+
+ () => shutdownState
+ );
+ }
+
+ // This is prefixed with _ rather than # because it is
+ // called in a test.
+ _removeObservers() {
+ if (this.ignoreListListener) {
+ lazy.IgnoreLists.unsubscribe(this.ignoreListListener);
+ delete this.ignoreListListener;
+ }
+ if (this.#queuedIdle) {
+ this.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC);
+ this.#queuedIdle = false;
+ }
+
+ this._settings.removeObservers();
+
+ lazy.NimbusFeatures.searchConfiguration.offUpdate(
+ this.#nimbusSearchUpdatedFun
+ );
+
+ Services.obs.removeObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
+ Services.obs.removeObserver(this, QUIT_APPLICATION_TOPIC);
+ Services.obs.removeObserver(this, TOPIC_LOCALES_CHANGE);
+ Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC);
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsISearchService",
+ "nsIObserver",
+ "nsITimerCallback",
+ ]);
+
+ // nsIObserver
+ observe(engine, topic, verb) {
+ switch (topic) {
+ case lazy.SearchUtils.TOPIC_ENGINE_MODIFIED:
+ switch (verb) {
+ case lazy.SearchUtils.MODIFIED_TYPE.ADDED:
+ this.#parseSubmissionMap = null;
+ break;
+ case lazy.SearchUtils.MODIFIED_TYPE.CHANGED:
+ engine = engine.wrappedJSObject;
+ if (
+ engine == this.defaultEngine ||
+ engine == this.defaultPrivateEngine
+ ) {
+ this.#recordDefaultChangedEvent(
+ engine != this.defaultEngine,
+ engine,
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_ENGINE_UPDATE
+ );
+ }
+ this.#parseSubmissionMap = null;
+ break;
+ case lazy.SearchUtils.MODIFIED_TYPE.REMOVED:
+ // Invalidate the map used to parse URLs to search engines.
+ this.#parseSubmissionMap = null;
+ break;
+ }
+ break;
+
+ case "idle": {
+ this.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC);
+ this.#queuedIdle = false;
+ lazy.logConsole.debug(
+ "Reloading engines after idle due to configuration change"
+ );
+ this._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_CONFIG
+ ).catch(console.error);
+ break;
+ }
+
+ case QUIT_APPLICATION_TOPIC:
+ this._removeObservers();
+ break;
+
+ case TOPIC_LOCALES_CHANGE:
+ // Locale changed. Re-init. We rely on observers, because we can't
+ // return this promise to anyone.
+
+ // At the time of writing, when the user does a "Apply and Restart" for
+ // a new language the preferences code triggers the locales change and
+ // restart straight after, so we delay the check, which means we should
+ // be able to avoid the reload on shutdown, and we'll sort it out
+ // on next startup.
+ // This also helps to avoid issues with the add-on manager shutting
+ // down at the same time (see _reInit for more info).
+ Services.tm.dispatchToMainThread(() => {
+ if (!Services.startup.shuttingDown) {
+ this._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_LOCALE
+ ).catch(console.error);
+ }
+ });
+ break;
+ case lazy.Region.REGION_TOPIC:
+ lazy.logConsole.debug("Region updated:", lazy.Region.home);
+ this._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_REGION
+ ).catch(console.error);
+ break;
+ }
+ }
+
+ /**
+ * Create an engine object from the search configuration details.
+ *
+ * This method is prefixed with _ rather than # because it is
+ * called in a test.
+ *
+ * @param {object} config
+ * The configuration object that defines the details of the engine
+ * webExtensionId etc.
+ * @param {object} [settings]
+ * The saved settings for the user.
+ * @returns {nsISearchEngine}
+ * Returns the search engine object.
+ */
+ async _makeEngineFromConfig(config, settings) {
+ lazy.logConsole.debug("_makeEngineFromConfig:", config);
+
+ if (!lazy.SearchUtils.newSearchConfigEnabled) {
+ let locale =
+ "locale" in config.webExtension
+ ? config.webExtension.locale
+ : lazy.SearchUtils.DEFAULT_TAG;
+
+ let engine = new lazy.AddonSearchEngine({
+ isAppProvided: true,
+ details: {
+ extensionID: config.webExtension.id,
+ locale,
+ },
+ });
+ await engine.init({
+ settings,
+ locale,
+ config,
+ });
+ return engine;
+ }
+
+ return new lazy.AppProvidedSearchEngine({ config, settings });
+ }
+
+ /**
+ * @param {object} metaData
+ * The metadata object that defines the details of the engine.
+ * @returns {boolean}
+ * Returns true if metaData has different property values than
+ * the cached _metaData.
+ */
+ #didSettingsMetaDataUpdate(metaData) {
+ let metaDataProperties = [
+ "locale",
+ "region",
+ "channel",
+ "experiment",
+ "distroID",
+ ];
+
+ return metaDataProperties.some(p => {
+ return metaData?.[p] !== this._settings.getMetaDataAttribute(p);
+ });
+ }
+
+ /**
+ * Shows an infobar to notify the user their default search engine has been
+ * removed and replaced by a new default search engine.
+ *
+ * This method is prefixed with _ rather than # because it is
+ * called in a test.
+ *
+ * @param {string} prevCurrentEngineName
+ * The name of the previous default engine that will be replaced.
+ * @param {string} newCurrentEngineName
+ * The name of the engine that will be the new default engine.
+ *
+ */
+ _showRemovalOfSearchEngineNotificationBox(
+ prevCurrentEngineName,
+ newCurrentEngineName
+ ) {
+ let win = Services.wm.getMostRecentBrowserWindow();
+ win.BrowserSearch.removalOfSearchEngineNotificationBox(
+ prevCurrentEngineName,
+ newCurrentEngineName
+ );
+ }
+
+ /**
+ * Maybe starts the timer for OpenSearch engine updates. This will be set
+ * only if updates are enabled and there are OpenSearch engines installed
+ * which have updates.
+ */
+ #maybeStartOpenSearchUpdateTimer() {
+ if (
+ this.#openSearchUpdateTimerStarted ||
+ !Services.prefs.getBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "update",
+ true
+ )
+ ) {
+ return;
+ }
+
+ let engineWithUpdates = [...this._engines.values()].some(
+ engine => engine instanceof lazy.OpenSearchEngine && engine.hasUpdates
+ );
+
+ if (engineWithUpdates) {
+ lazy.logConsole.debug("Engine with updates found, setting update timer");
+ lazy.timerManager.registerTimer(
+ OPENSEARCH_UPDATE_TIMER_TOPIC,
+ this,
+ OPENSEARCH_UPDATE_TIMER_INTERVAL,
+ true
+ );
+ this.#openSearchUpdateTimerStarted = true;
+ }
+ }
+} // end SearchService class
+
+XPCOMUtils.defineLazyServiceGetter(
+ SearchService.prototype,
+ "idleService",
+ "@mozilla.org/widget/useridleservice;1",
+ "nsIUserIdleService"
+);
+
+/**
+ * Handles getting and checking extensions against the allow list.
+ */
+class SearchDefaultOverrideAllowlistHandler {
+ /**
+ * @param {Function} listener
+ * A listener for configuration update changes.
+ */
+ constructor(listener) {
+ this._remoteConfig = lazy.RemoteSettings(
+ lazy.SearchUtils.SETTINGS_ALLOWLIST_KEY
+ );
+ }
+
+ /**
+ * Determines if a search engine extension can override a default one
+ * according to the allow list.
+ *
+ * @param {object} extension
+ * The extension object (from add-on manager) that will override the
+ * app provided search engine.
+ * @param {string} appProvidedExtensionId
+ * The id of the search engine that will be overriden.
+ * @returns {boolean}
+ * Returns true if the search engine extension may override the app provided
+ * instance.
+ */
+ async canOverride(extension, appProvidedExtensionId) {
+ const overrideTable = await this._getAllowlist();
+
+ let entry = overrideTable.find(e => e.thirdPartyId == extension.id);
+ if (!entry) {
+ return false;
+ }
+
+ if (appProvidedExtensionId != entry.overridesId) {
+ return false;
+ }
+
+ let searchProvider =
+ extension.manifest.chrome_settings_overrides.search_provider;
+
+ return entry.urls.some(
+ e =>
+ searchProvider.search_url == e.search_url &&
+ searchProvider.search_form == e.search_form &&
+ searchProvider.search_url_get_params == e.search_url_get_params &&
+ searchProvider.search_url_post_params == e.search_url_post_params
+ );
+ }
+
+ /**
+ * Determines if an existing search engine is allowed to override a default one
+ * according to the allow list.
+ *
+ * @param {SearchEngine} engine
+ * The existing search engine.
+ * @param {string} appProvidedEngineExtensionId
+ * The id of the search engine that will be overriden.
+ * @returns {boolean}
+ * Returns true if the existing search engine is allowed to override the
+ * app provided instance.
+ */
+ async canEngineOverride(engine, appProvidedEngineExtensionId) {
+ const overrideEntries = await this._getAllowlist();
+
+ let entry;
+
+ if (engine instanceof lazy.AddonSearchEngine) {
+ entry = overrideEntries.find(e => e.thirdPartyId == engine._extensionID);
+ } else if (engine instanceof lazy.OpenSearchEngine) {
+ entry = overrideEntries.find(
+ e =>
+ e.thirdPartyId == "opensearch@search.mozilla.org" &&
+ e.engineName == engine.name
+ );
+ }
+ if (!entry) {
+ return false;
+ }
+
+ if (appProvidedEngineExtensionId != entry.overridesId) {
+ return false;
+ }
+
+ return entry.urls.some(urlSet =>
+ engine.checkSearchUrlMatchesManifest(urlSet)
+ );
+ }
+
+ /**
+ * 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.
+ *
+ * @returns {Array}
+ * An array of objects in the database, or an empty array if none
+ * could be obtained.
+ */
+ async _getAllowlist() {
+ let result = [];
+ try {
+ result = await this._remoteConfig.get();
+ } catch (ex) {
+ // Don't throw an error just log it, just continue with no data, and hopefully
+ // a sync will fix things later on.
+ console.error(ex);
+ }
+ lazy.logConsole.debug("Allow list is:", result);
+ return result;
+ }
+}