gecko-dev/browser/components/urlbar/UrlbarProvidersManager.jsm
2019-03-19 14:06:33 +00:00

503 lines
17 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* This module exports a component used to register search providers and manage
* the connection between such providers and a UrlbarController.
*/
var EXPORTED_SYMBOLS = ["UrlbarProvidersManager"];
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
Log: "resource://gre/modules/Log.jsm",
PlacesUtils: "resource://modules/PlacesUtils.jsm",
UrlbarMuxer: "resource:///modules/UrlbarUtils.jsm",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
UrlbarProvider: "resource:///modules/UrlbarUtils.jsm",
UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
});
XPCOMUtils.defineLazyGetter(this, "logger", () =>
Log.repository.getLogger("Urlbar.ProvidersManager"));
// List of available local providers, each is implemented in its own jsm module
// and will track different queries internally by queryContext.
var localProviderModules = {
UrlbarProviderUnifiedComplete: "resource:///modules/UrlbarProviderUnifiedComplete.jsm",
};
// List of available local muxers, each is implemented in its own jsm module.
var localMuxerModules = {
UrlbarMuxerUnifiedComplete: "resource:///modules/UrlbarMuxerUnifiedComplete.jsm",
};
// To improve dataflow and reduce UI work, when a match is added by a
// non-immediate provider, we notify it to the controller after a delay, so
// that we can chunk matches coming in that timeframe into a single call.
const CHUNK_MATCHES_DELAY_MS = 16;
const DEFAULT_PROVIDERS = ["UnifiedComplete"];
const DEFAULT_MUXER = "UnifiedComplete";
/**
* Class used to create a manager.
* The manager is responsible to keep a list of providers, instantiate query
* objects and pass those to the providers.
*/
class ProvidersManager {
constructor() {
// Tracks the available providers.
// This is a double map, first it maps by PROVIDER_TYPE, then
// registerProvider maps by provider.name: { type: { name: provider }}
this.providers = new Map();
for (let type of Object.values(UrlbarUtils.PROVIDER_TYPE)) {
this.providers.set(type, new Map());
}
for (let [symbol, module] of Object.entries(localProviderModules)) {
let {[symbol]: provider} = ChromeUtils.import(module, {});
this.registerProvider(provider);
}
// Tracks ongoing Query instances by queryContext.
this.queries = new Map();
// Interrupt() allows to stop any running SQL query, some provider may be
// running a query that shouldn't be interrupted, and if so it should
// bump this through disableInterrupt and enableInterrupt.
this.interruptLevel = 0;
// This maps muxer names to muxers.
this.muxers = new Map();
for (let [symbol, module] of Object.entries(localMuxerModules)) {
let {[symbol]: muxer} = ChromeUtils.import(module, {});
this.registerMuxer(muxer);
}
}
/**
* Registers a provider object with the manager.
* @param {object} provider
*/
registerProvider(provider) {
if (!provider || !(provider instanceof UrlbarProvider)) {
throw new Error(`Trying to register an invalid provider`);
}
if (!Object.values(UrlbarUtils.PROVIDER_TYPE).includes(provider.type)) {
throw new Error(`Unknown provider type ${provider.type}`);
}
logger.info(`Registering provider ${provider.name}`);
this.providers.get(provider.type).set(provider.name, provider);
}
/**
* Unregisters a previously registered provider object.
* @param {object} provider
*/
unregisterProvider(provider) {
logger.info(`Unregistering provider ${provider.name}`);
this.providers.get(provider.type).delete(provider.name);
}
/**
* Registers a muxer object with the manager.
* @param {object} muxer a UrlbarMuxer object
*/
registerMuxer(muxer) {
if (!muxer || !(muxer instanceof UrlbarMuxer)) {
throw new Error(`Trying to register an invalid muxer`);
}
logger.info(`Registering muxer ${muxer.name}`);
this.muxers.set(muxer.name, muxer);
}
/**
* Unregisters a previously registered muxer object.
* @param {object} muxer a UrlbarMuxer object or name.
*/
unregisterMuxer(muxer) {
let muxerName = typeof muxer == "string" ? muxer : muxer.name;
logger.info(`Unregistering muxer ${muxerName}`);
this.muxers.delete(muxerName);
}
/**
* Starts querying.
* @param {object} queryContext The query context object
* @param {object} controller a UrlbarController instance
*/
async startQuery(queryContext, controller) {
logger.info(`Query start ${queryContext.searchString}`);
// Define the muxer to use.
let muxerName = queryContext.muxer || DEFAULT_MUXER;
logger.info(`Using muxer ${muxerName}`);
let muxer = this.muxers.get(muxerName);
if (!muxer) {
throw new Error(`Muxer with name ${muxerName} not found`);
}
// Define the list of providers to use.
let providers = queryContext.providers || DEFAULT_PROVIDERS;
providers = filterProviders(this.providers, providers);
let query = new Query(queryContext, controller, muxer, providers);
this.queries.set(queryContext, query);
await query.start();
}
/**
* Cancels a running query.
* @param {object} queryContext
*/
cancelQuery(queryContext) {
logger.info(`Query cancel ${queryContext.searchString}`);
let query = this.queries.get(queryContext);
if (!query) {
throw new Error("Couldn't find a matching query for the given context");
}
query.cancel();
if (!this.interruptLevel) {
try {
let db = PlacesUtils.promiseLargeCacheDBConnection();
db.interrupt();
} catch (ex) {}
}
this.queries.delete(queryContext);
}
/**
* A provider can use this util when it needs to run a SQL query that can't
* be interrupted. Otherwise, when a query is canceled any running SQL query
* is interrupted abruptly.
* @param {function} taskFn a Task to execute in the critical section.
*/
async runInCriticalSection(taskFn) {
this.interruptLevel++;
try {
await taskFn();
} finally {
this.interruptLevel--;
}
}
}
var UrlbarProvidersManager = new ProvidersManager();
/**
* Tracks a query status.
* Multiple queries can potentially be executed at the same time by different
* controllers. Each query has to track its own status and delays separately,
* to avoid conflicting with other ones.
*/
class Query {
/**
* Initializes the query object.
* @param {object} queryContext
* The query context
* @param {object} controller
* The controller to be notified
* @param {object} muxer
* The muxer to sort results
* @param {object} providers
* Map of all the providers by type and name
*/
constructor(queryContext, controller, muxer, providers) {
this.context = queryContext;
this.context.results = [];
this.muxer = muxer;
this.controller = controller;
this.providers = providers;
this.started = false;
this.canceled = false;
this.complete = false;
// Array of acceptable RESULT_SOURCE values for this query. Providers not
// returning any of these will be skipped, as well as results not part of
// this subset (Note we still expect the provider to do its own internal
// filtering, our additional filtering will be for sanity).
this.acceptableSources = [];
}
/**
* Starts querying.
*/
async start() {
if (this.started) {
throw new Error("This Query has been started already");
}
this.started = true;
UrlbarTokenizer.tokenize(this.context);
this.acceptableSources = getAcceptableMatchSources(this.context);
logger.debug(`Acceptable sources ${this.acceptableSources}`);
let promises = [];
for (let provider of this.providers.get(UrlbarUtils.PROVIDER_TYPE.IMMEDIATE).values()) {
if (this.canceled) {
break;
}
// Immediate type providers may return heuristic results, that usually can
// bypass suggest.* preferences, so we always execute them, regardless of
// this.acceptableSources, and filter results in add().
promises.push(provider.startQuery(this.context, this.add.bind(this)));
}
// Tracks the delay timer. We will fire (in this specific case, cancel would
// do the same, since the callback is empty) the timer when the search is
// canceled, unblocking start().
this._sleepTimer = new SkippableTimer(() => {}, UrlbarPrefs.get("delay"));
await this._sleepTimer.promise;
for (let providerType of [UrlbarUtils.PROVIDER_TYPE.NETWORK,
UrlbarUtils.PROVIDER_TYPE.PROFILE,
UrlbarUtils.PROVIDER_TYPE.EXTENSION]) {
for (let provider of this.providers.get(providerType).values()) {
if (this.canceled) {
break;
}
if (this._providerHasAcceptableSources(provider)) {
promises.push(provider.startQuery(this.context, this.add.bind(this)));
}
}
}
logger.info(`Queried ${promises.length} providers`);
if (promises.length) {
await Promise.all(promises.map(p => p.catch(Cu.reportError)));
if (this._chunkTimer) {
// All the providers are done returning results, so we can stop chunking.
await this._chunkTimer.fire();
}
}
// Nothing should be failing above, since we catch all the promises, thus
// this is not in a finally for now.
this.complete = true;
}
/**
* Cancels this query.
* @note Invoking cancel multiple times is a no-op.
*/
cancel() {
if (this.canceled) {
return;
}
this.canceled = true;
for (let providers of this.providers.values()) {
for (let provider of providers.values()) {
provider.cancelQuery(this.context);
}
}
if (this._chunkTimer) {
this._chunkTimer.cancel().catch(Cu.reportError);
}
if (this._sleepTimer) {
this._sleepTimer.fire().catch(Cu.reportError);
}
}
/**
* Adds a match returned from a provider to the results set.
* @param {object} provider
* @param {object} match
*/
add(provider, match) {
if (!(provider instanceof UrlbarProvider)) {
throw new Error("Invalid provider passed to the add callback");
}
// Stop returning results as soon as we've been canceled.
if (this.canceled) {
return;
}
// Check if the result source should be filtered out. Pay attention to the
// heuristic result though, that is supposed to be added regardless.
if (!this.acceptableSources.includes(match.source) && !match.heuristic) {
return;
}
// Filter out javascript results for safety. The provider is supposed to do
// it, but we don't want to risk leaking these out.
if (match.payload.url && match.payload.url.startsWith("javascript:") &&
!this.context.searchString.startsWith("javascript:") &&
UrlbarPrefs.get("filter.javascript")) {
return;
}
this.context.results.push(match);
let notifyResults = () => {
if (this._chunkTimer) {
this._chunkTimer.cancel().catch(Cu.reportError);
delete this._chunkTimer;
}
this.muxer.sort(this.context);
// Crop results to the requested number.
logger.debug(`Cropping ${this.context.results.length} matches to ${this.context.maxResults}`);
this.context.results = this.context.results.slice(0, this.context.maxResults);
this.controller.receiveResults(this.context);
};
// If the provider is not of immediate type, chunk results, to improve the
// dataflow and reduce UI flicker.
if (provider.type == UrlbarUtils.PROVIDER_TYPE.IMMEDIATE) {
notifyResults();
} else if (!this._chunkTimer) {
this._chunkTimer = new SkippableTimer(notifyResults, CHUNK_MATCHES_DELAY_MS);
}
}
/**
* Returns whether a provider's sources are acceptable for this query.
* @param {object} provider A provider object.
* @returns {boolean}whether the provider sources are acceptable.
*/
_providerHasAcceptableSources(provider) {
return provider.sources.some(s => this.acceptableSources.includes(s));
}
}
/**
* Class used to create a timer that can be manually fired, to immediately
* invoke the callback, or canceled, as necessary.
* Examples:
* let timer = new SkippableTimer();
* // Invokes the callback immediately without waiting for the delay.
* await timer.fire();
* // Cancel the timer, the callback won't be invoked.
* await timer.cancel();
* // Wait for the timer to have elapsed.
* await timer.promise;
*/
class SkippableTimer {
/**
* Creates a skippable timer for the given callback and time.
* @param {function} callback To be invoked when requested
* @param {number} time A delay in milliseconds to wait for
*/
constructor(callback, time) {
let timerPromise = new Promise(resolve => {
this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
this._timer.initWithCallback(() => {
logger.debug(`Elapsed ${time}ms timer`);
resolve();
}, time, Ci.nsITimer.TYPE_ONE_SHOT);
logger.debug(`Started ${time}ms timer`);
});
let firePromise = new Promise(resolve => {
this.fire = () => {
logger.debug(`Skipped ${time}ms timer`);
resolve();
return this.promise;
};
});
this.promise = Promise.race([timerPromise, firePromise]).then(() => {
// If we've been canceled, don't call back.
if (this._timer) {
callback();
}
});
}
/**
* Allows to cancel the timer and the callback won't be invoked.
* It is not strictly necessary to await for this, the promise can just be
* used to ensure all the internal work is complete.
* @returns {promise} Resolved once all the cancelation work is complete.
*/
cancel() {
logger.debug(`Canceling timer for ${this._timer.delay}ms`);
this._timer.cancel();
delete this._timer;
return this.fire();
}
}
/**
* Gets an array of the provider sources accepted for a given UrlbarQueryContext.
* @param {UrlbarQueryContext} context The query context to examine
* @returns {array} Array of accepted sources
*/
function getAcceptableMatchSources(context) {
let acceptedSources = [];
// There can be only one restrict token about sources.
let restrictToken = context.tokens.find(t => [ UrlbarTokenizer.TYPE.RESTRICT_HISTORY,
UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK,
UrlbarTokenizer.TYPE.RESTRICT_TAG,
UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE,
UrlbarTokenizer.TYPE.RESTRICT_SEARCH,
].includes(t.type));
let restrictTokenType = restrictToken ? restrictToken.type : undefined;
for (let source of Object.values(UrlbarUtils.RESULT_SOURCE)) {
// Skip sources that the context doesn't care about.
if (context.sources && !context.sources.includes(source)) {
continue;
}
// Check prefs and restriction tokens.
switch (source) {
case UrlbarUtils.RESULT_SOURCE.BOOKMARKS:
if (UrlbarPrefs.get("suggest.bookmark") &&
(!restrictTokenType ||
restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK ||
restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_TAG)) {
acceptedSources.push(source);
}
break;
case UrlbarUtils.RESULT_SOURCE.HISTORY:
if (UrlbarPrefs.get("suggest.history") &&
(!restrictTokenType ||
restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_HISTORY)) {
acceptedSources.push(source);
}
break;
case UrlbarUtils.RESULT_SOURCE.SEARCH:
if (UrlbarPrefs.get("suggest.searches") &&
(!restrictTokenType ||
restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_SEARCH)) {
acceptedSources.push(source);
}
break;
case UrlbarUtils.RESULT_SOURCE.TABS:
if (UrlbarPrefs.get("suggest.openpage") &&
(!restrictTokenType ||
restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE)) {
acceptedSources.push(source);
}
break;
case UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK:
if (!context.isPrivate && !restrictTokenType) {
acceptedSources.push(source);
}
break;
case UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL:
default:
if (!restrictTokenType) {
acceptedSources.push(source);
}
break;
}
}
return acceptedSources;
}
/* Given a providers Map and a list of provider names, produces a filtered
* Map containing only the provided names.
* @param providersMap {Map} providers mapped by type and name
* @param names {array} list of provider names to retain
* @returns {Map} a new filtered providers Map
*/
function filterProviders(providersMap, names) {
let providers = new Map();
for (let [type, providersByName] of providersMap) {
providers.set(type, new Map());
for (let name of Array.from(providersByName.keys()).filter(n => names.includes(n))) {
providers.get(type).set(name, providersByName.get(name));
}
}
return providers;
}