diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderExtension.sys.mjs | 432 |
1 files changed, 432 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderExtension.sys.mjs b/browser/components/urlbar/UrlbarProviderExtension.sys.mjs new file mode 100644 index 0000000000..82b04240c1 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderExtension.sys.mjs @@ -0,0 +1,432 @@ +/* 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/. */ + +/** + * This module exports a provider class that is used for providers created by + * extensions. + */ + +import { + SkippableTimer, + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +// The set of `UrlbarQueryContext` properties that aren't serializable. +const NONSERIALIZABLE_CONTEXT_PROPERTIES = new Set(["view"]); + +/** + * The browser.urlbar extension API allows extensions to create their own urlbar + * providers. The results from extension providers are integrated into the + * urlbar view just like the results from providers that are built into Firefox. + * + * This class is the interface between the provider-related parts of the + * browser.urlbar extension API implementation and our internal urlbar + * implementation. The API implementation should use this class to manage + * providers created by extensions. All extension providers must be instances + * of this class. + * + * When an extension requires a provider, the API implementation should call + * getOrCreate() to get or create it. When an extension adds an event listener + * related to a provider, the API implementation should call setEventListener() + * to register its own event listener with the provider. + */ +export class UrlbarProviderExtension extends UrlbarProvider { + /** + * Returns the extension provider with the given name, creating it first if + * it doesn't exist. + * + * @param {string} name + * The provider name. + * @returns {UrlbarProviderExtension} + * The provider. + */ + static getOrCreate(name) { + let provider = lazy.UrlbarProvidersManager.getProvider(name); + if (!provider) { + provider = new UrlbarProviderExtension(name); + lazy.UrlbarProvidersManager.registerProvider(provider); + } + return provider; + } + + /** + * Constructor. + * + * @param {string} name + * The provider's name. + */ + constructor(name) { + super(); + this._name = name; + this._eventListeners = new Map(); + this.behavior = "inactive"; + } + + /** + * The provider's name. + * + * @returns {string} + */ + get name() { + return this._name; + } + + /** + * The provider's type. The type of extension providers is always + * UrlbarUtils.PROVIDER_TYPE.EXTENSION. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.EXTENSION; + } + + /** + * Whether the provider should be invoked for the given context. If this + * method returns false, the providers manager won't start a query with this + * provider, to save on resources. + * + * @param {UrlbarQueryContext} context + * The query context object. + * @returns {boolean} + * Whether this provider should be invoked for the search. + */ + isActive(context) { + return this.behavior != "inactive"; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} context + * The query context object. + * @returns {number} + * The provider's priority for the given query. + */ + getPriority(context) { + // We give restricting extension providers a very high priority so that they + // normally override all built-in providers, but not Infinity so that we can + // still override them if necessary. + return this.behavior == "restricting" ? 999 : 0; + } + + /** + * Sets the listener function for an event. The extension API implementation + * should call this from its EventManager.register() implementations. Since + * EventManager.register() is called at most only once for each extension + * event (the first time the extension adds a listener for the event), each + * provider instance needs at most only one listener per event, and that's why + * this method is named setEventListener instead of addEventListener. + * + * The given listener function may return a promise that's resolved once the + * extension responds to the event, or if the event requires no response from + * the extension, it may return a non-promise value (possibly nothing). + * + * To remove the previously set listener, call this method again but pass null + * as the listener function. + * + * The event name should be one of the following: + * + * behaviorRequested + * This event is fired when the provider's behavior is needed from the + * extension. The listener should return a behavior string. + * queryCanceled + * This event is fired when an ongoing query is canceled. The listener + * shouldn't return anything. + * resultsRequested + * This event is fired when the provider's results are needed from the + * extension. The listener should return an array of results. + * + * @param {string} eventName + * The name of the event to listen to. + * @param {Function} listener + * The function that will be called when the event is fired. + */ + setEventListener(eventName, listener) { + if (listener) { + this._eventListeners.set(eventName, listener); + } else { + this._eventListeners.delete(eventName); + if (!this._eventListeners.size) { + lazy.UrlbarProvidersManager.unregisterProvider(this); + } + } + } + + /** + * This method is called by the providers manager before a query starts to + * update each extension provider's behavior. It fires the behaviorRequested + * event. + * + * @param {UrlbarQueryContext} context + * The query context. + */ + async updateBehavior(context) { + let behavior = await this._notifyListener( + "behaviorRequested", + makeSerializable(context) + ); + if (behavior) { + this.behavior = behavior; + } + } + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update. See the base UrlbarProvider class for more. + * + * @param {UrlbarResult} result The result whose view will be updated. + * @param {Map} idsByName + * A Map from an element's name, as defined by the provider; to its ID in + * the DOM, as defined by the browser. + * @returns {object} An object describing the view update. + */ + async getViewUpdate(result, idsByName) { + return this._notifyListener("getViewUpdate", result, idsByName); + } + + /** + * This method is called by the providers manager when a query starts to fetch + * each extension provider's results. It fires the resultsRequested event. + * + * @param {UrlbarQueryContext} context + * The query context. + * @param {Function} addCallback + * The callback invoked by this method to add each result. + */ + async startQuery(context, addCallback) { + let extResults = await this._notifyListener( + "resultsRequested", + makeSerializable(context) + ); + if (extResults) { + for (let extResult of extResults) { + let result = await this._makeUrlbarResult(context, extResult).catch( + ex => this.logger.error(ex) + ); + if (result) { + addCallback(this, result); + } + } + } + } + + /** + * This method is called by the providers manager when an ongoing query is + * canceled. It fires the queryCanceled event. + * + * @param {UrlbarQueryContext} context + * The query context. + */ + cancelQuery(context) { + this._notifyListener("queryCanceled", makeSerializable(context)); + } + + #pickResult(result, element) { + let dynamicElementName = ""; + if (element && result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + dynamicElementName = element.getAttribute("name"); + } + this._notifyListener("resultPicked", result.payload, dynamicElementName); + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + */ + onEngagement(isPrivate, state, queryContext, details) { + let { result, element } = details; + // By design, the "resultPicked" extension event should not be fired when + // the picked element has a URL. + if (result?.providerName == this.name && !element?.dataset.url) { + this.#pickResult(result, element); + } + + this._notifyListener("engagement", isPrivate, state); + } + + /** + * Calls a listener function set by the extension API implementation, if any. + * + * @param {string} eventName + * The name of the listener to call (i.e., the name of the event to fire). + * @param {*} args + * Arguments to the listener function. + * @returns {*} + * The value returned by the listener function, if any. + */ + async _notifyListener(eventName, ...args) { + let listener = this._eventListeners.get(eventName); + if (!listener) { + return undefined; + } + let result; + try { + result = listener(...args); + } catch (error) { + this.logger.error(error); + return undefined; + } + if (result.catch) { + // The result is a promise, so wait for it to be resolved. Set up a timer + // so that we're not stuck waiting forever. + let timer = new SkippableTimer({ + name: "UrlbarProviderExtension notification timer", + time: lazy.UrlbarPrefs.get("extension.timeout"), + reportErrorOnTimeout: true, + logger: this.logger, + }); + result = await Promise.race([ + timer.promise, + result.catch(ex => this.logger.error(ex)), + ]); + timer.cancel(); + } + return result; + } + + /** + * Converts a plain-JS-object result created by the extension into a + * UrlbarResult object. + * + * @param {UrlbarQueryContext} context + * The query context. + * @param {object} extResult + * A plain JS object representing a result created by the extension. + * @returns {UrlbarResult} + * The UrlbarResult object. + */ + async _makeUrlbarResult(context, extResult) { + // If the result is a search result, make sure its payload has a valid + // `engine` property, which is the name of an engine, and which we use later + // on to look up the nsISearchEngine. We allow the extension to specify the + // engine by its name, alias, or domain. Prefer aliases over domains since + // one domain can have many engines. + if (extResult.type == "search") { + let engine; + if (extResult.payload.engine) { + // Validate the engine name by looking it up. + engine = Services.search.getEngineByName(extResult.payload.engine); + } else if (extResult.payload.keyword) { + // Look up the engine by its alias. + engine = await lazy.UrlbarSearchUtils.engineForAlias( + extResult.payload.keyword + ); + } else if (extResult.payload.url) { + // Look up the engine by its domain. + let host; + try { + host = new URL(extResult.payload.url).hostname; + } catch (err) {} + if (host) { + engine = ( + await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host) + )[0]; + } + } + if (!engine) { + // No engine found. + throw new Error("Invalid or missing engine specified by extension"); + } + extResult.payload.engine = engine.name; + } + + let type = UrlbarProviderExtension.RESULT_TYPES[extResult.type]; + if (type == UrlbarUtils.RESULT_TYPE.TIP) { + extResult.payload.type ||= "extension"; + extResult.payload.helpL10n = { + id: lazy.UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-tip-get-help" + : "urlbar-tip-help-icon", + }; + } + + let result = new lazy.UrlbarResult( + UrlbarProviderExtension.RESULT_TYPES[extResult.type], + UrlbarProviderExtension.SOURCE_TYPES[extResult.source], + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + context.tokens, + extResult.payload || {} + ) + ); + if (extResult.heuristic && this.behavior == "restricting") { + // The muxer chooses the final heuristic result by taking the first one + // that claims to be the heuristic. We don't want extensions to clobber + // the default heuristic, so we allow this only if the provider is + // restricting. + result.heuristic = extResult.heuristic; + } + if (extResult.suggestedIndex !== undefined) { + result.suggestedIndex = extResult.suggestedIndex; + } + return result; + } +} + +// Maps extension result type enums to internal result types. +UrlbarProviderExtension.RESULT_TYPES = { + dynamic: UrlbarUtils.RESULT_TYPE.DYNAMIC, + keyword: UrlbarUtils.RESULT_TYPE.KEYWORD, + omnibox: UrlbarUtils.RESULT_TYPE.OMNIBOX, + remote_tab: UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + search: UrlbarUtils.RESULT_TYPE.SEARCH, + tab: UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + tip: UrlbarUtils.RESULT_TYPE.TIP, + url: UrlbarUtils.RESULT_TYPE.URL, +}; + +// Maps extension source type enums to internal source types. +UrlbarProviderExtension.SOURCE_TYPES = { + bookmarks: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + history: UrlbarUtils.RESULT_SOURCE.HISTORY, + local: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + network: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + search: UrlbarUtils.RESULT_SOURCE.SEARCH, + tabs: UrlbarUtils.RESULT_SOURCE.TABS, + actions: UrlbarUtils.RESULT_SOURCE.ACTIONS, +}; + +/** + * Returns a copy of a query context stripped of non-serializable properties. + * This is necessary because query contexts are passed to extensions where they + * become `Query` objects, as defined in the urlbar extensions schema. The + * WebExtensions framework automatically excludes serializable properties that + * aren't defined in the schema, but it chokes on non-serializable properties. + * + * @param {UrlbarQueryContext} context + * The query context. + * @returns {object} + * A copy of `context` with only serializable properties. + */ +function makeSerializable(context) { + return Object.fromEntries( + Object.entries(context).filter( + ([key]) => !NONSERIALIZABLE_CONTEXT_PROPERTIES.has(key) + ) + ); +} |