forked from mirrors/gecko-dev
This removes `UrlbarProvider.pickResult()` and `blockResult()` in favor of handling picks and dismissals through `onEngagement()`. A number of providers use those two methods, so this revision touches a lot of files. Handling dismissals through `onEngagement()` means `UrlbarInput.pickResult()` can no longer tell whether a result is successfully dismissed, so it can't remove the result anymore. (Maybe `onEngagement()` could return some value indicating it dismissed the result, but I don't want to go down that road.) Instead, I split `UrlbarController.handleDeleteEntry()` into two methods: a public one that removes the result and notifies listeners, and a private one that handles dismissing the selected result internally in UrlbarController. Providers that have dismissable results should now implement `onEngagement()` and call `controller.removeResult()`. I made some other improvements to engagement handling. There's still room for more but this patch is big enough already. Other notable changes: Include the engaged result in engagement notifications so providers have easy access to it and can respond to clicks and dismissals more easily. That also lets us stop passing `selIndex` and `provider` to `engagementEvent.record()` since now it can compute those from the passed-in result. Add the concept of `isSessionOngoing` to engagement notifications so providers can tell whether an engagement ended the search session. Right now, providers like quick suggest that record a bunch of provider-specific legacy telemetry assume that `onEngagement()` ends the session, but that's no longer true. Unify result buttons and result menu commands by setting `element.dataset.command` on buttons (hopefully we can remove buttons soon, at least the ones that aren't tip buttons) Make sure we always notify providers on engagement even on dismissals or when skipping legacy telemetry Move dismissal of restyled search suggestions and history results from `UrlbarController.handleDeleteEntry()` to the Places provider Move dismissal of form history results from `UrlbarController.handleDeleteEntry()` to the search suggestions provider In the Places provider, remove the unused `_addSearchEngineMatch()` method. Also remove the code in the "searchengine" case that creates a non-search-history result. This code is unreached because the only time the provider creates a "searchengine" match it also sets `isSearchHistory` to true. In `UrlbarTestUtils.promiseAutocompleteResultPopup()`, change the default value of the `fireInputEvent` param from false to true. This is necessary because without a starting input event, the start event info in `engagementEvent` will be null, so when `engagementEvent.record()` is called at the end of the engagement, it will bail, and providers will not be notified of the engagement. IMO true is a better default value anyway because input events will typically be fired when the user performs a search. Differential Revision: https://phabricator.services.mozilla.com/D174941
433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
/**
|
|
* 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)
|
|
)
|
|
);
|
|
}
|