diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/search/SearchService.jsm | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/search/SearchService.jsm')
-rw-r--r-- | toolkit/components/search/SearchService.jsm | 2838 |
1 files changed, 2838 insertions, 0 deletions
diff --git a/toolkit/components/search/SearchService.jsm b/toolkit/components/search/SearchService.jsm new file mode 100644 index 0000000000..f9083e0213 --- /dev/null +++ b/toolkit/components/search/SearchService.jsm @@ -0,0 +1,2838 @@ +/* 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 */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + AddonManager: "resource://gre/modules/AddonManager.jsm", + IgnoreLists: "resource://gre/modules/IgnoreLists.jsm", + OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.jsm", + OS: "resource://gre/modules/osfile.jsm", + Region: "resource://gre/modules/Region.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + SearchEngine: "resource://gre/modules/SearchEngine.jsm", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", + SearchSettings: "resource://gre/modules/SearchSettings.jsm", + SearchStaticData: "resource://gre/modules/SearchStaticData.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "gExperiment", + SearchUtils.BROWSER_SEARCH_PREF + "experiment", + false, + () => { + Services.search.wrappedJSObject._maybeReloadEngines(); + } +); + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "SearchService", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +const TOPIC_LOCALES_CHANGE = "intl:app-locales-changed"; +const QUIT_APPLICATION_TOPIC = "quit-application"; + +// The default engine update interval, in days. This is only used if an engine +// specifies an updateURL, but not an updateInterval. +const SEARCH_DEFAULT_UPDATE_INTERVAL = 7; + +// This is the amount of time we'll be idle for before applying any configuration +// changes. +const RECONFIG_IDLE_TIME_SEC = 5 * 60; + +// nsISearchParseSubmissionResult +function ParseSubmissionResult( + engine, + terms, + termsParameterName, + termsOffset, + termsLength +) { + this._engine = engine; + this._terms = terms; + this._termsParameterName = termsParameterName; + this._termsOffset = termsOffset; + this._termsLength = termsLength; +} +ParseSubmissionResult.prototype = { + get engine() { + return this._engine; + }, + get terms() { + return this._terms; + }, + get termsParameterName() { + return this._termsParameterName; + }, + get termsOffset() { + return this._termsOffset; + }, + get termsLength() { + return this._termsLength; + }, + QueryInterface: ChromeUtils.generateQI(["nsISearchParseSubmissionResult"]), +}; + +const gEmptyParseSubmissionResult = Object.freeze( + new ParseSubmissionResult(null, "", "", -1, 0) +); + +/** + * The search service handles loading and maintaining of search engines. It will + * also work out the default lists for each locale/region. + * + * @implements {nsISearchService} + */ +function SearchService() { + this._initObservers = PromiseUtils.defer(); + this._engines = new Map(); + this._settings = new SearchSettings(this); +} + +SearchService.prototype = { + classID: Components.ID("{7319788a-fe93-4db3-9f39-818cf08f4256}"), + + // The current status of initialization. Note that it does not determine if + // initialization is complete, only if an error has been encountered so far. + _initRV: Cr.NS_OK, + + // The boolean indicates that the initialization has started or not. + _initStarted: false, + + // The boolean that indicates if initialization has been completed (successful + // or not). + _initialized: false, + + // Indicates if we're already waiting for maybeReloadEngines to be called. + _maybeReloadDebounce: false, + + // Indicates if we're currently in maybeReloadEngines. + _reloadingEngines: false, + + // The engine selector singleton that is managing the engine configuration. + _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. + */ + _submissionURLIgnoreList: [], + + /** + * Various search engines may be ignored if their load path is contained + * in this list. The list is controlled via remote settings. + */ + _loadPathIgnoreList: [], + + /** + * A map of engine display names to `SearchEngine`. + */ + _engines: null, + + /** + * An array of engine short names sorted into display order. + */ + __sortedEngines: null, + + /** + * A flag to prevent setting of useSavedOrder when there's non-user + * activity happening. + */ + _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. + */ + _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. + */ + _searchPrivateDefault: null, + + /** + * A Set of installed search extensions reported by AddonManager + * startup before SearchSevice has started. Will be installed + * during init(). + */ + _startupExtensions: new Set(), + + /** + * A Set of removed search extensions reported by AddonManager + * startup before SearchSevice has started. Will be removed + * during init(). + */ + _startupRemovedExtensions: new Set(), + + // A reference to the handler for the default override allow list. + _defaultOverrideAllowlist: null, + + // 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. + get _separatePrivateDefault() { + return ( + this._separatePrivateDefaultPrefValue && + this._separatePrivateDefaultEnabledPrefValue + ); + }, + + // If initialization has not been completed yet, perform synchronous + // initialization. + // Throws in case of initialization error. + _ensureInitialized() { + if (this._initialized) { + if (!Components.isSuccessCode(this._initRV)) { + logConsole.debug("_ensureInitialized: failure"); + throw Components.Exception( + "SearchService previously failed to initialize", + this._initRV + ); + } + return; + } + + let err = new Error( + "Something tried to use the search service before it's been " + + "properly intialized. Please examine the stack trace to figure out what and " + + "where to fix it:\n" + ); + err.message += err.stack; + throw err; + }, + + /** + * Asynchronous implementation of the initializer. + * + * @returns {number} + * A Components.results success code on success, otherwise a failure code. + */ + async _init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_separatePrivateDefaultPrefValue", + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false, + this._onSeparateDefaultPrefChanged.bind(this) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_separatePrivateDefaultEnabledPrefValue", + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + false, + this._onSeparateDefaultPrefChanged.bind(this) + ); + + // We need to catch the region being updated + // during initialisation so we start listening + // straight away. + Services.obs.addObserver(this, Region.REGION_TOPIC); + + try { + // Create the search engine selector. + this._engineSelector = new SearchEngineSelector( + this._handleConfigurationUpdated.bind(this) + ); + + // See if we have a settings file so we don't have to parse a bunch of XML. + let settings = await this._settings.get(); + + this._setupRemoteSettings().catch(Cu.reportError); + + await this._loadEngines(settings); + + // 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) { + logConsole.warn("_init: abandoning init due to shutting down"); + this._initRV = Cr.NS_ERROR_ABORT; + this._initObservers.reject(this._initRV); + return this._initRV; + } + + // Make sure the current list of engines is persisted, without the need to wait. + logConsole.debug("_init: engines loaded, writing settings"); + this._addObservers(); + } catch (ex) { + this._initRV = ex.result !== undefined ? ex.result : Cr.NS_ERROR_FAILURE; + logConsole.error("_init: failure initializing search:", ex.result); + } + + this._initialized = true; + if (Components.isSuccessCode(this._initRV)) { + this._initObservers.resolve(this._initRV); + } else { + this._initObservers.reject(this._initRV); + } + Services.obs.notifyObservers( + null, + SearchUtils.TOPIC_SEARCH_SERVICE, + "init-complete" + ); + + logConsole.debug("Completed _init"); + return this._initRV; + }, + + /** + * Obtains the remote settings for the search service. This should only be + * called from init(). Any subsequent updates to the remote settings are + * handled via a sync listener. + * + * For desktop, the initial remote settings are obtained from dumps in + * `services/settings/dumps/main/`. + * + * When enabling for Android, be aware the dumps are not shipped there, and + * hence the `get` may take a while to return. + */ + async _setupRemoteSettings() { + // Now we have the values, listen for future updates. + let listener = this._handleIgnoreListUpdated.bind(this); + + const current = await 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, + 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) { + 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]; + } + } + + // If we have not finished initializing, then we wait for the initialization + // to complete. + if (!this.isInitialized) { + await this._initObservers; + } + // 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(Cu.reportError); + } + }, + + /** + * 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 + ._getURLOfType("text/html") + .getSubmission("dummy", engine) + .uri.spec.toLowerCase(); + if ( + this._submissionURLIgnoreList.some(code => + url.includes(code.toLowerCase()) + ) + ) { + return true; + } + return false; + }, + + async maybeSetAndOverrideDefault(extension) { + let searchProvider = + extension.manifest.chrome_settings_overrides.search_provider; + let engine = this._engines.get(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 (!this._defaultOverrideAllowlist) { + this._defaultOverrideAllowlist = new SearchDefaultOverrideAllowlistHandler(); + } + + 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 this._defaultOverrideAllowlist.canOverride( + extension, + engine._extensionID + )) + ) { + 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.overrideWithExtension(extension.id, extension.manifest); + 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 this._defaultOverrideAllowlist.canOverride( + extension, + engine._extensionID + )) + ) { + engine.overrideWithExtension(extension.id, extension.manifest); + logConsole.debug( + "Re-enabling overriding of core extension by", + extension.id + ); + return { + canChangeToAppProvided: true, + canInstallEngine: false, + }; + } + + return { + canChangeToAppProvided: false, + canInstallEngine: 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); + }, + + get _sortedEngines() { + if (!this.__sortedEngines) { + return this._buildSortedEngineList(); + } + return this.__sortedEngines; + }, + + /** + * 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. + */ + _originalDefaultEngine(privateMode = false) { + let defaultEngine = this._getEngineByWebExtensionDetails( + privateMode && this._searchPrivateDefault + ? this._searchPrivateDefault + : this._searchDefault + ); + + 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._originalDefaultEngine(false); + } + + // Something unexpected as happened. In order to recover the original + // default engine, use the first visible engine which is the best we can do. + return this._getSortedEngines(false)[0]; + }, + + /** + * @returns {SearchEngine} + * The engine that is the default for this locale/region, ignoring any + * user changes to the default engine. + */ + get originalDefaultEngine() { + return this._originalDefaultEngine(); + }, + + /** + * @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 originalPrivateDefaultEngine() { + return this._originalDefaultEngine(this._separatePrivateDefault); + }, + + resetToOriginalDefaultEngine() { + let originalDefaultEngine = this.originalDefaultEngine; + originalDefaultEngine.hidden = false; + this.defaultEngine = originalDefaultEngine; + }, + + /** + * Loads engines asynchronously. + * + * @param {object} settings + * An object representing the search engine settings. + */ + async _loadEngines(settings) { + logConsole.debug("_loadEngines: start"); + let { engines, privateDefault } = await this._fetchEngineSelectorEngines(); + this._setDefaultAndOrdersFromSelector(engines, privateDefault); + + let newEngines = await this._loadEnginesFromConfig(engines); + for (let engine of newEngines) { + this._addEngineToStore(engine); + } + + logConsole.debug( + "_loadEngines: loading", + this._startupExtensions.size, + "engines reported by AddonManager startup" + ); + for (let extension of this._startupExtensions) { + await this._installExtensionEngine( + extension, + [SearchUtils.DEFAULT_TAG], + true + ); + } + this._startupExtensions.clear(); + + this._loadEnginesFromSettings(settings.engines); + + this._loadEnginesMetadataFromSettings(settings.engines); + + logConsole.debug("_loadEngines: done"); + }, + + /** + * 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. + * @returns {array.<nsISearchEngine>} + * Returns an array of the loaded search engines. This may be + * smaller than the original list if not all engines can be loaded. + */ + async _loadEnginesFromConfig(engineConfigs) { + logConsole.debug("_loadEnginesFromConfig"); + let engines = []; + for (let config of engineConfigs) { + try { + let engine = await this.makeEngineFromConfig(config); + engines.push(engine); + } catch (ex) { + console.error( + `Could not load engine ${ + "webExtension" in config ? config.webExtension.id : "unknown" + }: ${ex}` + ); + } + } + return engines; + }, + + /** + * Reloads engines asynchronously, but only when + * the service has already been initialized. + */ + async _maybeReloadEngines() { + if (this._maybeReloadDebounce) { + logConsole.debug("We're already waiting to reload engines."); + return; + } + + if (!this._initialized || 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().catch(Cu.reportError); + }, 10000); + 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(); + + logConsole.debug("Running maybeReloadEngines"); + this._reloadingEngines = true; + + try { + await this._reloadEngines(settings); + } catch (ex) { + logConsole.error("maybeReloadEngines failed", ex); + } + this._reloadingEngines = false; + logConsole.debug("maybeReloadEngines complete"); + }, + + async _reloadEngines(settings) { + // Capture the current engine state, in case we need to notify below. + const prevCurrentEngine = this._currentEngine; + const prevPrivateEngine = this._currentPrivateEngine; + + // 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; + + // 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) Update the default engines. + // 4) Remove any old engines. + + let { + engines: originalConfigEngines, + privateDefault, + } = await this._fetchEngineSelectorEngines(); + + let enginesToRemove = []; + let configEngines = [...originalConfigEngines]; + let oldEngineList = [...this._engines.values()]; + + for (let engine of oldEngineList) { + if (!engine.isAppProvided) { + continue; + } + + let index = configEngines.findIndex( + e => + e.webExtension.id == engine._extensionID && + e.webExtension.locale == engine._locale + ); + + let policy, manifest, 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) { + enginesToRemove.push(engine); + continue; + } + + policy = await this._getExtensionPolicy(engine._extensionID); + manifest = policy.extension.manifest; + locale = + replacementEngines[0].webExtension.locale || SearchUtils.DEFAULT_TAG; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await policy.extension.getLocalizedManifest(locale); + } + if ( + manifest.name != + manifest.chrome_settings_overrides.search_provider.name.trim() + ) { + // No matching name, so just remove it. + enginesToRemove.push(engine); + 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 + ); + } else { + // This is an existing engine that we should update (we don't know if + // the configuration for this engine has changed or not). + policy = await this._getExtensionPolicy(engine._extensionID); + + manifest = policy.extension.manifest; + locale = engine._locale || SearchUtils.DEFAULT_TAG; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await policy.extension.getLocalizedManifest(locale); + } + } + engine._updateFromManifest( + policy.extension.id, + policy.extension.baseURI, + manifest, + locale, + configEngines[index] + ); + + configEngines.splice(index, 1); + } + + // Any remaining configuration engines are ones that we need to add. + for (let engine of configEngines) { + try { + let newEngine = await this.makeEngineFromConfig(engine); + this._addEngineToStore(newEngine, true); + } catch (ex) { + logConsole.warn( + `Could not load engine ${ + "webExtension" in engine ? engine.webExtension.id : "unknown" + }: ${ex}` + ); + } + } + this._loadEnginesMetadataFromSettings(settings.engines); + + // Now set the sort out the default engines and notify as appropriate. + this._currentEngine = null; + this._currentPrivateEngine = null; + + this._setDefaultAndOrdersFromSelector( + originalConfigEngines, + privateDefault + ); + + // If the defaultEngine has changed between the previous load and this one, + // dispatch the appropriate notifications. + if (prevCurrentEngine && this.defaultEngine !== prevCurrentEngine) { + SearchUtils.notifyAction( + this._currentEngine, + 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) { + SearchUtils.notifyAction( + this._currentEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + } + if ( + this._separatePrivateDefault && + prevPrivateEngine && + this.defaultPrivateEngine !== prevPrivateEngine + ) { + SearchUtils.notifyAction( + this._currentPrivateEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + + // Finally, remove any engines that need removing. + + for (let engine of enginesToRemove) { + // 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); + + let addon = await 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); + } + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.REMOVED); + } + + this._dontSetUseSavedOrder = false; + // Clear out the sorted engines settings, so that we re-sort it if necessary. + this.__sortedEngines = null; + Services.obs.notifyObservers( + null, + SearchUtils.TOPIC_SEARCH_SERVICE, + "engines-reloaded" + ); + }, + + /** + * Test only - reset SearchService data. Ideally this should be replaced + */ + reset() { + this._initialized = false; + this._initObservers = PromiseUtils.defer(); + this._initStarted = false; + this._startupExtensions = new Set(); + this._engines.clear(); + this.__sortedEngines = null; + this._currentEngine = null; + this._currentPrivateEngine = null; + this._searchDefault = null; + this._searchPrivateDefault = null; + this._maybeReloadDebounce = false; + }, + + _addEngineToStore(engine, skipDuplicateCheck = false) { + if (this._engineMatchesIgnoreLists(engine)) { + logConsole.debug("_addEngineToStore: Ignoring engine"); + return; + } + + logConsole.debug("_addEngineToStore: Adding engine:", engine.name); + + // See if there is an existing engine with the same name. However, if this + // engine is updating another engine, it's allowed to have the same name. + var hasSameNameAsUpdate = + engine._engineToUpdate && engine.name == engine._engineToUpdate.name; + if ( + !skipDuplicateCheck && + this._engines.has(engine.name) && + !hasSameNameAsUpdate + ) { + logConsole.debug("_addEngineToStore: Duplicate engine found, aborting!"); + return; + } + + if (engine._engineToUpdate) { + // We need to replace engineToUpdate with the engine that just loaded. + var oldEngine = engine._engineToUpdate; + + // Remove the old engine from the hash, since it's keyed by name, and our + // name might change (the update might have a new name). + this._engines.delete(oldEngine.name); + + // Hack: we want to replace the old engine with the new one, but since + // people may be holding refs to the nsISearchEngine objects themselves, + // we'll just copy over all "private" properties (those without a getter + // or setter) from one object to the other. + for (var p in engine) { + if (!(engine.__lookupGetter__(p) || engine.__lookupSetter__(p))) { + oldEngine[p] = engine[p]; + } + } + engine = oldEngine; + engine._engineToUpdate = null; + + // Add the engine back + this._engines.set(engine.name, engine); + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.CHANGED); + } else { + // Not an update, just add the new engine. + this._engines.set(engine.name, engine); + // Only add the engine to the list of sorted engines if the initial list + // has already been built (i.e. if this.__sortedEngines 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.__sortedEngines && !this._dontSetUseSavedOrder) { + this.__sortedEngines.push(engine); + this._saveSortedEngineList(); + } + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.ADDED); + } + + // Let the engine know it can start notifying new updates. + engine._engineAddedToStore = true; + + if (engine._hasUpdates) { + // Schedule the engine's next update, if it isn't already. + if (!engine.getAttr("updateexpir")) { + engineUpdateService.scheduleNextUpdate(engine); + } + } + }, + + _loadEnginesMetadataFromSettings(engines) { + if (!engines) { + return; + } + + for (let engine of engines) { + let name = engine._name; + if (this._engines.has(name)) { + logConsole.debug( + "_loadEnginesMetadataFromSettings, transfering metadata for", + name + ); + let eng = this._engines.get(name); + // We used to store the alias in metadata.alias, in 1621892 that was + // changed to only store the user set alias in metadata.alias, remove + // it from metadata if it was previously set to the internal value. + if (eng._alias === engine?._metaData?.alias) { + delete engine._metaData.alias; + } + eng._metaData = engine._metaData || {}; + } + } + }, + + _loadEnginesFromSettings(enginesCache) { + if (!enginesCache) { + return; + } + + logConsole.debug( + "_loadEnginesFromSettings: Loading", + enginesCache.length, + "engines from settings" + ); + + let skippedEngines = 0; + for (let engineJSON of enginesCache) { + // We renamed isBuiltin to isAppProvided in 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")) + ) { + continue; + } + + try { + let engine = new SearchEngine({ + isAppProvided: false, + loadPath: engineJSON._loadPath, + }); + engine._initWithJSON(engineJSON); + this._addEngineToStore(engine); + } catch (ex) { + logConsole.error( + "Failed to load", + engineJSON._name, + "from settings:", + ex, + engineJSON + ); + } + } + + if (skippedEngines) { + logConsole.debug( + "_loadEnginesFromSettings: skipped", + skippedEngines, + "built-in engines." + ); + } + }, + + async _fetchEngineSelectorEngines() { + let locale = Services.locale.appLocaleAsBCP47; + let region = Region.home || "default"; + + let channel = AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "esr" + : AppConstants.MOZ_UPDATE_CHANNEL; + + let { + engines, + privateDefault, + } = await this._engineSelector.fetchEngineConfiguration({ + locale, + region, + channel, + experiment: gExperiment, + distroID: SearchUtils.distroID, + }); + + for (let e of engines) { + if (!e.webExtension) { + e.webExtension = {}; + } + e.webExtension.locale = e.webExtension?.locale ?? SearchUtils.DEFAULT_TAG; + } + + 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() { + 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.setAttribute("useSavedOrder", true); + + var engines = this._getSortedEngines(true); + + for (var i = 0; i < engines.length; ++i) { + engines[i].setAttr("order", i + 1); + } + }, + + _buildSortedEngineList() { + // We must initialise __sortedEngines 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.__sortedEngines = []; + + // If the user has specified a custom engine order, read the order + // information from the metadata instead of the default prefs. + if (this._settings.getAttribute("useSavedOrder")) { + 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.__sortedEngines[orderNumber - 1]) { + this.__sortedEngines[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.__sortedEngines.filter(function(a) { + return !!a; + }); + if (this.__sortedEngines.length != filteredEngines.length) { + needToSaveEngineList = true; + } + this.__sortedEngines = 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.__sortedEngines = this.__sortedEngines.concat(alphaEngines)); + } + logConsole.debug("_buildSortedEngineList: using default orders"); + + return (this.__sortedEngines = 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. + */ + _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 original default engine should always be first in the list (except + // for distros, that we should respect). + const originalDefault = this.originalDefaultEngine; + maybeAddEngineToSort(originalDefault); + + // If there's a private default, and it is different to the normal + // default, then it should be second in the list. + const originalPrivateDefault = this.originalPrivateDefaultEngine; + if (originalPrivateDefault && originalPrivateDefault != originalDefault) { + maybeAddEngineToSort(originalPrivateDefault); + } + + 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 engines. + * + * @param {boolean} withHidden + * True if hidden plugins should be included in the result. + * @returns {Array<SearchEngine>} + * The sorted array. + */ + _getSortedEngines(withHidden) { + if (withHidden) { + return this._sortedEngines; + } + + return this._sortedEngines.filter(function(engine) { + return !engine.hidden; + }); + }, + + // nsISearchService + async init() { + logConsole.debug("init"); + if (this._initStarted) { + return this._initObservers.promise; + } + + TelemetryStopwatch.start("SEARCH_SERVICE_INIT_MS"); + this._initStarted = true; + try { + // Complete initialization by calling asynchronous initializer. + await this._init(); + TelemetryStopwatch.finish("SEARCH_SERVICE_INIT_MS"); + } catch (ex) { + Services.telemetry.scalarSet( + "browser.searchinit.init_result_status_code", + // Scalar is a string due to bug 1651210 when the scalar was created. + ex.result?.toString(10) + ); + TelemetryStopwatch.cancel("SEARCH_SERVICE_INIT_MS"); + this._initObservers.reject(ex.result); + throw ex; + } + Services.telemetry.scalarSet( + "browser.searchinit.init_result_status_code", + // Scalar is a string due to bug 1651210 when the scalar was created. + this._initRV?.toString(10) + ); + + if (!Components.isSuccessCode(this._initRV)) { + throw Components.Exception( + "SearchService initialization failed", + this._initRV + ); + } else 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. + 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(); + }); + } + return this._initRV; + }, + + get isInitialized() { + return this._initialized; + }, + + /** + * Checks if Search Engines associated with WebExtensions are valid and + * up-to-date, and reports them via telemetry if not. + */ + async checkWebExtensionEngines() { + await this.init(); + logConsole.debug("Running check on WebExtension engines"); + + for (let engine of this._engines.values()) { + if ( + engine.isAppProvided || + !engine._extensionID || + engine._extensionID == "set-via-policy" || + engine._extensionID == "set-via-user" + ) { + continue; + } + + let addon = await AddonManager.getAddonByID(engine._extensionID); + + if (!addon) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} is not installed!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 1 + ); + } else if (!addon.isActive) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} is not active!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 2 + ); + } else { + let policy = await this._getExtensionPolicy(engine._extensionID); + let providerSettings = + policy.extension.manifest?.chrome_settings_overrides?.search_provider; + + if (!providerSettings) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} no longer has an engine defined` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 4 + ); + } else if (engine.name != providerSettings.name) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} has a different name!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 5 + ); + } else if (!engine.checkSearchUrlMatchesManifest(providerSettings)) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} has out-of-date manifest!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 6 + ); + } + } + } + logConsole.debug("WebExtension engine check complete"); + }, + + async getEngines() { + await this.init(); + logConsole.debug("getEngines: getting all engines"); + return this._getSortedEngines(true); + }, + + async getVisibleEngines() { + await this.init(true); + logConsole.debug("getVisibleEngines: getting all visible engines"); + return this._getSortedEngines(false); + }, + + async getAppProvidedEngines() { + await this.init(); + + return this._sortEnginesByDefaults( + this._sortedEngines.filter(e => e.isAppProvided) + ); + }, + + async getEnginesByExtensionID(extensionID) { + await this.init(); + return this._getEnginesByExtensionID(extensionID); + }, + + _getEnginesByExtensionID(extensionID) { + logConsole.debug("getEngines: getting all engines for", extensionID); + var engines = this._getSortedEngines(true).filter(function(engine) { + return engine._extensionID == extensionID; + }); + return engines; + }, + + /** + * 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._engines.get(engineName) || null; + }, + + async getEngineByAlias(alias) { + await this.init(); + for (var engine of this._engines.values()) { + if (engine && engine.aliases.includes(alias)) { + return engine; + } + } + return null; + }, + + /** + * Returns the engine associated with the WebExtension details. + * + * @param {object} details + * @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; + }, + + /** + * Adds a search engine that is specified from enterprise policies. + * + * @param {object} details + * An object that simulates the manifest object from a WebExtension. See + * the idl for more details. + */ + async addPolicyEngine(details) { + await this._createAndAddEngine({ + extensionID: "set-via-policy", + extensionBaseURI: "", + isAppProvided: false, + manifest: details, + }); + }, + + /** + * Adds a search engine that is specified by the user. + * + * @param {string} name + * @param {string} url + * @param {string} alias + */ + async addUserEngine(name, url, alias) { + await this._createAndAddEngine({ + extensionID: "set-via-user", + extensionBaseURI: "", + isAppProvided: false, + manifest: { + chrome_settings_overrides: { + search_provider: { + name, + search_url: encodeURI(url), + keyword: alias, + }, + }, + }, + }); + }, + + /** + * Adds an engine with specific details, only used for tests and should + * be considered obsolete, see bug 1649186. + * + * @param {string} name + * The name of the engine to add. + * @param {object} details + * The details of the engine to add. + */ + async addEngineWithDetails(name, details) { + let manifest = { + description: details.description, + iconURL: details.iconURL, + chrome_settings_overrides: { + search_provider: { + name, + encoding: details.encoding || SearchUtils.DEFAULT_QUERY_CHARSET, + search_url: encodeURI(details.template), + keyword: details.alias, + search_url_get_params: details.searchGetParams, + search_url_post_params: details.postData || details.searchPostParams, + suggest_url: details.suggestURL, + }, + }, + }; + return this._createAndAddEngine({ + extensionID: details.extensionID ?? `${name}@test.engine`, + extensionBaseURI: "", + isAppProvided: false, + manifest, + }); + }, + + /** + * Creates and adds a WebExtension based engine. + * Note: this is currently used for enterprise policy engines as well. + * + * @param {object} options + * @param {string} options.extensionID + * The extension ID being added for the engine. + * @param {nsIURI} [options.extensionBaseURI] + * The base URI of the extension. + * @param {boolean} options.isAppProvided + * True if the WebExtension is built-in or installed into the system scope. + * @param {object} options.manifest + * An object that represents the extension's manifest. + * @param {stirng} [options.locale] + * The locale to use within the WebExtension. Defaults to the WebExtension's + * default locale. + * @param {initEngine} [options.initEngine] + * Set to true if this engine is being loaded during initialisation. + */ + async _createAndAddEngine({ + extensionID, + extensionBaseURI, + isAppProvided, + manifest, + locale = SearchUtils.DEFAULT_TAG, + initEngine = false, + }) { + if (!extensionID) { + throw Components.Exception( + "Empty extensionID passed to _createAndAddEngine!", + Cr.NS_ERROR_INVALID_ARG + ); + } + let searchProvider = manifest.chrome_settings_overrides.search_provider; + let name = searchProvider.name.trim(); + logConsole.debug("_createAndAddEngine: Adding", name); + let isCurrent = false; + + // 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._initialized && !isAppProvided && !initEngine) { + await this.init(); + } + let existingEngine = this._engines.get(name); + if (existingEngine) { + if ( + extensionID && + existingEngine._loadPath.startsWith( + `jar:[profile]/extensions/${extensionID}` + ) + ) { + // This is a legacy extension engine that needs to be migrated to WebExtensions. + isCurrent = this.defaultEngine == existingEngine; + await this.removeEngine(existingEngine); + } else { + throw Components.Exception( + "An engine with that name already exists!", + Cr.NS_ERROR_FILE_ALREADY_EXISTS + ); + } + } + + let newEngine = new SearchEngine({ + name, + isAppProvided, + loadPath: `[other]addEngineWithDetails:${extensionID}`, + }); + newEngine._initFromManifest( + extensionID, + extensionBaseURI, + manifest, + locale + ); + + this._addEngineToStore(newEngine); + if (isCurrent) { + this.defaultEngine = newEngine; + } + return 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) { + 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" + ) { + return this._upgradeExtensionEngine(extension); + } + + 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._initialized && !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) + ); + } + } + logConsole.debug("addEnginesFromExtension: Ignoring builtIn engine."); + return []; + } + + // If we havent started SearchService yet, store this extension + // to install in SearchService.init(). + if (!this._initialized) { + this._startupExtensions.add(extension); + return []; + } + + return this._installExtensionEngine(extension, [SearchUtils.DEFAULT_TAG]); + }, + + /** + * 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 manifest = extension.manifest; + let locale = engine._locale || SearchUtils.DEFAULT_TAG; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await extension.getLocalizedManifest(locale); + } + let configuration = + engines.find( + e => + e.webExtension.id == extension.id && e.webExtension.locale == locale + ) ?? {}; + engine._updateFromManifest( + extension.id, + extension.baseURI, + manifest, + locale, + configuration + ); + } + return extensionEngines; + }, + + /** + * Create an engine object from the search configuration details. + * + * @param {object} config + * The configuration object that defines the details of the engine + * webExtensionId etc. + * @returns {nsISearchEngine} + * Returns the search engine object. + */ + async makeEngineFromConfig(config) { + logConsole.debug("makeEngineFromConfig:", config); + let policy = await this._getExtensionPolicy(config.webExtension.id); + let locale = + "locale" in config.webExtension + ? config.webExtension.locale + : SearchUtils.DEFAULT_TAG; + + let manifest = policy.extension.manifest; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await policy.extension.getLocalizedManifest(locale); + } + + let engine = new SearchEngine({ + name: manifest.chrome_settings_overrides.search_provider.name.trim(), + isAppProvided: policy.extension.isAppProvided, + loadPath: `[other]addEngineWithDetails:${policy.extension.id}`, + }); + engine._initFromManifest( + policy.extension.id, + policy.extension.baseURI, + manifest, + locale, + config + ); + return engine; + }, + + async _installExtensionEngine(extension, locales, initEngine = false) { + logConsole.debug("installExtensionEngine:", extension.id); + + let installLocale = async locale => { + let manifest = + locale == SearchUtils.DEFAULT_TAG + ? extension.manifest + : await extension.getLocalizedManifest(locale); + return this._addEngineForManifest( + extension, + manifest, + locale, + initEngine + ); + }; + + let engines = []; + for (let locale of locales) { + logConsole.debug( + "addEnginesFromExtension: installing:", + extension.id, + ":", + locale + ); + engines.push(await installLocale(locale)); + } + return engines; + }, + + async _addEngineForManifest( + extension, + manifest, + locale = SearchUtils.DEFAULT_TAG, + 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) { + logConsole.debug( + "Engine already loaded via settings, skipping due to APP_STARTUP:", + extension.id + ); + return engine; + } + } + + return this._createAndAddEngine({ + extensionID: extension.id, + extensionBaseURI: extension.baseURI, + isAppProvided: extension.isAppProvided, + manifest, + locale, + initEngine, + }); + }, + + async addOpenSearchEngine(engineURL, iconURL) { + logConsole.debug("addEngine: Adding", engineURL); + await this.init(); + let errCode; + try { + var engine = new OpenSearchEngine({ + uri: engineURL, + isAppProvided: false, + }); + engine._setIcon(iconURL, false); + errCode = await new Promise(resolve => { + engine._initFromURIAndLoad(engineURL, errorCode => { + resolve(errorCode); + }); + }); + if (errCode) { + throw errCode; + } + } catch (ex) { + throw Components.Exception( + "addEngine: Error adding engine:\n" + ex, + errCode || Cr.NS_ERROR_FAILURE + ); + } + return engine; + }, + + async removeWebExtensionEngine(id) { + if (!this.isInitialized) { + logConsole.debug("Delaying removing extension engine on startup:", id); + this._startupRemovedExtensions.add(id); + return; + } + + 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 + ); + } + + if (engineToRemove == this.defaultEngine) { + this._currentEngine = null; + } + + // 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._currentPrivateEngine = null; + } + + if (engineToRemove._isAppProvided) { + // 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; + } 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(); + } + } + SearchUtils.notifyAction(engineToRemove, SearchUtils.MODIFIED_TYPE.REMOVED); + }, + + _internalRemoveEngine(engine) { + // Remove the engine from _sortedEngines + if (this.__sortedEngines) { + var index = this.__sortedEngines.indexOf(engine); + if (index == -1) { + throw Components.Exception( + "Can't find engine to remove in _sortedEngines!", + Cr.NS_ERROR_FAILURE + ); + } + this.__sortedEngines.splice(index, 1); + } + + // Remove the engine from the internal store + this._engines.delete(engine.name); + }, + + async moveEngine(engine, newIndex) { + await this.init(); + if (newIndex > this._sortedEngines.length || newIndex < 0) { + throw Components.Exception("moveEngine: Index out of bounds!"); + } + if ( + !(engine instanceof Ci.nsISearchEngine) && + !(engine instanceof 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._getSortedEngines(false)[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.__sortedEngines.splice(currentIndex, 1)[0]; + this.__sortedEngines.splice(newIndex, 0, movedEngine); + + SearchUtils.notifyAction(engine, 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; + } + } + }, + + /** + * Helper function to get the current default 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. + */ + _getEngineDefault(privateMode) { + this._ensureInitialized(); + const currentEngine = privateMode + ? "_currentPrivateEngine" + : "_currentEngine"; + if (!this[currentEngine]) { + const attributeName = privateMode ? "private" : "current"; + let name = this._settings.getAttribute(attributeName); + let engine = this.getEngineByName(name); + if ( + engine && + (engine.isAppProvided || + this._settings.getVerifiedAttribute(attributeName)) + ) { + // If the current engine is a default one, we can relax the + // verification hash check to reduce the annoyance for users who + // backup/sync their profile in custom ways. + this[currentEngine] = engine; + } + if (!name) { + this[currentEngine] = privateMode + ? this.originalPrivateDefaultEngine + : this.originalDefaultEngine; + } + } + + // If the current engine is not set or hidden, we fallback... + if (!this[currentEngine] || this[currentEngine].hidden) { + // first to the original default engine + let originalDefault = privateMode + ? this.originalPrivateDefaultEngine + : this.originalDefaultEngine; + if (!originalDefault || originalDefault.hidden) { + // then to the first visible engine + let firstVisible = this._getSortedEngines(false)[0]; + if (firstVisible && !firstVisible.hidden) { + if (privateMode) { + this.defaultPrivateEngine = firstVisible; + } else { + this.defaultEngine = firstVisible; + } + return firstVisible; + } + // and finally as a last resort we unhide the original default engine. + if (originalDefault) { + originalDefault.hidden = false; + } + } + if (!originalDefault) { + 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. + if (privateMode) { + this.defaultPrivateEngine = originalDefault; + } else { + this.defaultEngine = originalDefault; + } + } + + return 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 + */ + _setEngineDefault(privateMode, newEngine) { + this._ensureInitialized(); + // 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 SearchEngine) + ) { + throw Components.Exception( + "Invalid argument passed to defaultEngine setter", + Cr.NS_ERROR_INVALID_ARG + ); + } + + const newCurrentEngine = this.getEngineByName(newEngine.name); + 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 = SearchUtils.getVerificationHash( + newCurrentEngine._loadPath + ); + let currentHash = newCurrentEngine.getAttr("loadPathHash"); + if (!currentHash || currentHash != loadPathHash) { + newCurrentEngine.setAttr("loadPathHash", loadPathHash); + SearchUtils.notifyAction( + newCurrentEngine, + SearchUtils.MODIFIED_TYPE.CHANGED + ); + } + } + + const currentEngine = `_current${privateMode ? "Private" : ""}Engine`; + + if (newCurrentEngine == this[currentEngine]) { + return; + } + + // Ensure that we reset an engine override if it was previously overridden. + this[currentEngine]?.removeExtensionOverride(); + + 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 "default" + // engine. So clear the user pref when the currentEngine is set to the + // build's default engine, so that the currentEngine getter falls back to + // whatever the default is. + let newName = this[currentEngine].name; + const originalDefault = privateMode + ? this.originalPrivateDefaultEngine + : this.originalDefaultEngine; + if (this[currentEngine] == originalDefault) { + newName = ""; + } + + this._settings.setVerifiedAttribute( + privateMode ? "private" : "current", + newName + ); + + SearchUtils.notifyAction( + this[currentEngine], + 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) { + SearchUtils.notifyAction( + this[currentEngine], + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + }, + + get defaultEngine() { + return this._getEngineDefault(false); + }, + + set defaultEngine(newEngine) { + this._setEngineDefault(false, newEngine); + }, + + get defaultPrivateEngine() { + return this._getEngineDefault(this._separatePrivateDefault); + }, + + set defaultPrivateEngine(newEngine) { + if (!this._separatePrivateDefaultPrefValue) { + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + } + this._setEngineDefault(this._separatePrivateDefault, newEngine); + }, + + async getDefault() { + await this.init(); + return this.defaultEngine; + }, + + async setDefault(engine) { + await this.init(); + return (this.defaultEngine = engine); + }, + + async getDefaultPrivate() { + await this.init(); + return this.defaultPrivateEngine; + }, + + async setDefaultPrivate(engine) { + await this.init(); + return (this.defaultPrivateEngine = engine); + }, + + _onSeparateDefaultPrefChanged() { + // Clear out the sorted engines settings, so that we re-sort it if necessary. + this.__sortedEngines = 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)) { + SearchUtils.notifyAction( + // Always notify with the new private engine, the function checks + // the preference value for us. + this.defaultPrivateEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + }, + + async _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. + Cu.reportError("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 = 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._getURLOfType(SearchUtils.URL_TYPE.SEARCH) + .templateHost; + for (let innerEngine of this._engines.values()) { + if (!innerEngine.isAppProvided) { + continue; + } + + let innerEngineURL = innerEngine._getURLOfType( + SearchUtils.URL_TYPE.SEARCH + ); + if (innerEngineURL.templateHost == 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 + ._getURLOfType("text/html") + .getSubmission("", engine, "searchbar").uri; + uri = uri + .mutate() + .setUserPass("") // Avoid reporting a username or password. + .finalize(); + engineData.submissionURL = uri.spec; + } + + return [engine.telemetryId, engineData]; + }, + + async getDefaultEngineInfo() { + let [telemetryId, defaultSearchEngineData] = await this._getEngineInfo( + this.defaultEngine + ); + const result = { + defaultSearchEngine: telemetryId, + defaultSearchEngineData, + }; + + if (this._separatePrivateDefault) { + let [ + privateTelemetryId, + defaultPrivateSearchEngineData, + ] = await this._getEngineInfo(this.defaultPrivateEngine); + result.defaultPrivateSearchEngine = privateTelemetryId; + result.defaultPrivateSearchEngineData = defaultPrivateSearchEngineData; + } + + return result; + }, + + /** + * 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, + + _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); + SearchStaticData.getAlternateDomains( + urlParsingInfo.mainDomain + ).forEach(d => processDomain(d, true)); + } + }, + + parseSubmissionURL(url) { + if (!this._initialized) { + // If search is not initialized, 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).QueryInterface(Ci.nsIURL); + + // Exclude any URL that is not HTTP or HTTPS from the beginning. + if (soughtUrl.scheme != "http" && soughtUrl.scheme != "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". + 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; + } + + let length = 0; + let offset = url.indexOf("?") + 1; + let query = url.slice(offset); + // Iterate a second time over the original input string to determine the + // correct search term offset and length in the original encoding. + for (let param of query.split("&")) { + let equalPos = param.indexOf("="); + if ( + equalPos != -1 && + param.substr(0, equalPos) == mapEntry.termsParameterName + ) { + // This is the parameter we are looking for. + offset += equalPos + 1; + length = param.length - equalPos - 1; + break; + } + offset += param.length + 1; + } + + // 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; + } + + let submission = new ParseSubmissionResult( + mapEntry.engine, + terms, + mapEntry.termsParameterName, + offset, + length + ); + return submission; + }, + + /** + * Gets the WebExtensionPolicy for an add-on. + * + * @param {string} id + * The WebExtension id. + * @returns {WebExtensionPolicy} + */ + async _getExtensionPolicy(id) { + let policy = WebExtensionPolicy.getByID(id); + if (!policy) { + let idPrefix = id.split("@")[0]; + let path = `resource://search-extensions/${idPrefix}/`; + await AddonManager.installBuiltinAddon(path); + policy = WebExtensionPolicy.getByID(id); + } + // On startup the extension may have not finished parsing the + // manifest, wait for that here. + await policy.readyPromise; + return policy; + }, + + // nsIObserver + observe(engine, topic, verb) { + switch (topic) { + case SearchUtils.TOPIC_ENGINE_MODIFIED: + switch (verb) { + case SearchUtils.MODIFIED_TYPE.LOADED: + engine = engine.QueryInterface(Ci.nsISearchEngine); + logConsole.debug("observe: Done installation of ", engine.name); + this._addEngineToStore(engine.wrappedJSObject); + // The addition of the engine to the store always triggers an ADDED + // or a CHANGED notification, that will trigger the task below. + break; + case SearchUtils.MODIFIED_TYPE.ADDED: + case SearchUtils.MODIFIED_TYPE.CHANGED: + case 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; + logConsole.debug( + "Reloading engines after idle due to configuration change" + ); + this._maybeReloadEngines().catch(Cu.reportError); + 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().catch(Cu.reportError); + } + }); + break; + case Region.REGION_TOPIC: + if (verb == Region.REGION_UPDATED) { + logConsole.debug("Region updated:", Region.home); + this._maybeReloadEngines().catch(Cu.reportError); + } + break; + } + }, + + // nsITimerCallback + notify(timer) { + logConsole.debug("_notify: checking for updates"); + + if ( + !Services.prefs.getBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "update", + true + ) + ) { + return; + } + + // Our timer has expired, but unfortunately, we can't get any data from it. + // Therefore, we need to walk our engine-list, looking for expired engines + var currentTime = Date.now(); + logConsole.debug("currentTime:" + currentTime); + for (let e of this._engines.values()) { + let engine = e.wrappedJSObject; + if (!engine._hasUpdates) { + continue; + } + + var expirTime = engine.getAttr("updateexpir"); + logConsole.debug( + engine.name, + "expirTime:", + expirTime, + "updateURL:", + engine._updateURL, + "iconUpdateURL:", + engine._iconUpdateURL + ); + + var engineExpired = expirTime <= currentTime; + + if (!expirTime || !engineExpired) { + logConsole.debug("skipping engine"); + continue; + } + + logConsole.debug(engine.name, "has expired"); + + engineUpdateService.update(engine); + + // Schedule the next update + engineUpdateService.scheduleNextUpdate(engine); + } // end engine iteration + }, + + _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; + + Services.obs.addObserver(this, 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, + }, + }; + OS.File.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._initialized) { + 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 + ); + }, + _observersAdded: false, + + _removeObservers() { + if (this._ignoreListListener) { + IgnoreLists.unsubscribe(this._ignoreListListener); + delete this._ignoreListListener; + } + if (this._queuedIdle) { + this.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC); + this._queuedIdle = false; + } + + this._settings.removeObservers(); + + Services.obs.removeObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED); + Services.obs.removeObserver(this, QUIT_APPLICATION_TOPIC); + Services.obs.removeObserver(this, TOPIC_LOCALES_CHANGE); + Services.obs.removeObserver(this, Region.REGION_TOPIC); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISearchService", + "nsIObserver", + "nsITimerCallback", + ]), +}; + +var engineUpdateService = { + scheduleNextUpdate(engine) { + var interval = engine._updateInterval || SEARCH_DEFAULT_UPDATE_INTERVAL; + var milliseconds = interval * 86400000; // |interval| is in days + engine.setAttr("updateexpir", Date.now() + milliseconds); + }, + + update(engine) { + engine = engine.wrappedJSObject; + logConsole.debug("update called for", engine._name); + if ( + !Services.prefs.getBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "update", + true + ) || + !engine._hasUpdates + ) { + return; + } + + let testEngine = null; + let updateURL = engine._getURLOfType(SearchUtils.URL_TYPE.OPENSEARCH); + let updateURI = + updateURL && updateURL._hasRelation("self") + ? updateURL.getSubmission("", engine).uri + : SearchUtils.makeURI(engine._updateURL); + if (updateURI) { + if (engine.isAppProvided && !updateURI.schemeIs("https")) { + logConsole.debug("Invalid scheme for default engine update"); + return; + } + + logConsole.debug("updating", engine.name, updateURI.spec); + testEngine = new OpenSearchEngine({ + uri: updateURI, + isAppProvided: false, + }); + testEngine._engineToUpdate = engine; + testEngine._initFromURIAndLoad(updateURI); + } else { + logConsole.debug("invalid updateURI"); + } + + if (engine._iconUpdateURL) { + // If we're updating the engine too, use the new engine object, + // otherwise use the existing engine object. + (testEngine || engine)._setIcon(engine._iconUpdateURL, true); + } + }, +}; + +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 = RemoteSettings(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 + ); + } + + /** + * 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. + Cu.reportError(ex); + } + logConsole.debug("Allow list is:", result); + return result; + } +} + +var EXPORTED_SYMBOLS = ["SearchService"]; |