summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs')
-rw-r--r--browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs395
1 files changed, 395 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs b/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs
new file mode 100644
index 0000000000..326b649780
--- /dev/null
+++ b/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs
@@ -0,0 +1,395 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const RS_COLLECTION = "quicksuggest";
+
+// Default score for remote settings suggestions.
+const DEFAULT_SUGGESTION_SCORE = 0.2;
+
+// Entries are added to `SuggestionsMap` map in chunks, and each chunk will add
+// at most this many entries.
+const SUGGESTIONS_MAP_CHUNK_SIZE = 1000;
+
+const TELEMETRY_LATENCY = "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS";
+
+/**
+ * Manages quick suggest remote settings data.
+ */
+class _QuickSuggestRemoteSettings {
+ /**
+ * @returns {number}
+ * The default score for remote settings suggestions, a value in the range
+ * [0, 1]. All suggestions require a score that can be used for comparison,
+ * so if a remote settings suggestion does not have one, it's assigned this
+ * value.
+ */
+ get DEFAULT_SUGGESTION_SCORE() {
+ return DEFAULT_SUGGESTION_SCORE;
+ }
+
+ constructor() {
+ this.#emitter = new lazy.EventEmitter();
+ }
+
+ /**
+ * @returns {RemoteSettings}
+ * The underlying `RemoteSettings` client object.
+ */
+ get rs() {
+ return this.#rs;
+ }
+
+ /**
+ * @returns {EventEmitter}
+ * The client will emit events on this object.
+ */
+ get emitter() {
+ return this.#emitter;
+ }
+
+ /**
+ * @returns {object}
+ * Global quick suggest configuration stored in remote settings. When the
+ * config changes the `emitter` property will emit a "config-set" event. The
+ * config is an object that looks like this:
+ *
+ * {
+ * best_match: {
+ * min_search_string_length,
+ * blocked_suggestion_ids,
+ * },
+ * impression_caps: {
+ * nonsponsored: {
+ * lifetime,
+ * custom: [
+ * { interval_s, max_count },
+ * ],
+ * },
+ * sponsored: {
+ * lifetime,
+ * custom: [
+ * { interval_s, max_count },
+ * ],
+ * },
+ * },
+ * show_less_frequently_cap,
+ * }
+ */
+ get config() {
+ return this.#config;
+ }
+
+ /**
+ * @returns {Array}
+ * Array of `BasicFeature` instances.
+ */
+ get features() {
+ return [...this.#features];
+ }
+
+ get logger() {
+ if (!this.#logger) {
+ this.#logger = lazy.UrlbarUtils.getLogger({
+ prefix: "QuickSuggestRemoteSettings",
+ });
+ }
+ return this.#logger;
+ }
+
+ /**
+ * Registers a quick suggest feature that uses remote settings.
+ *
+ * @param {BaseFeature} feature
+ * An instance of a `BaseFeature` subclass. See `BaseFeature` for methods
+ * that the subclass must implement.
+ */
+ register(feature) {
+ this.logger.debug("Registering feature: " + feature.name);
+ this.#features.add(feature);
+ if (this.#features.size == 1) {
+ this.#enableSettings(true);
+ }
+ this.#syncFeature(feature);
+ }
+
+ /**
+ * Unregisters a quick suggest feature that uses remote settings.
+ *
+ * @param {BaseFeature} feature
+ * An instance of a `BaseFeature` subclass.
+ */
+ unregister(feature) {
+ this.logger.debug("Unregistering feature: " + feature.name);
+ this.#features.delete(feature);
+ if (!this.#features.size) {
+ this.#enableSettings(false);
+ }
+ }
+
+ /**
+ * Queries remote settings suggestions from all registered features.
+ *
+ * @param {string} searchString
+ * The search string.
+ * @returns {Array}
+ * The remote settings suggestions. If there are no matches, an empty array
+ * is returned.
+ */
+ async query(searchString) {
+ let suggestions;
+ let stopwatchInstance = {};
+ TelemetryStopwatch.start(TELEMETRY_LATENCY, stopwatchInstance);
+ try {
+ suggestions = await this.#queryHelper(searchString);
+ TelemetryStopwatch.finish(TELEMETRY_LATENCY, stopwatchInstance);
+ } catch (error) {
+ TelemetryStopwatch.cancel(TELEMETRY_LATENCY, stopwatchInstance);
+ this.logger.error("Query error: " + error);
+ }
+
+ return suggestions || [];
+ }
+
+ async #queryHelper(searchString) {
+ this.logger.info("Handling query: " + JSON.stringify(searchString));
+
+ let results = await Promise.all(
+ [...this.#features].map(async feature => {
+ let suggestions = await feature.queryRemoteSettings(searchString);
+ return [feature, suggestions ?? []];
+ })
+ );
+
+ let allSuggestions = [];
+ for (let [feature, suggestions] of results) {
+ for (let suggestion of suggestions) {
+ suggestion.source = "remote-settings";
+ suggestion.provider = feature.name;
+ if (typeof suggestion.score != "number") {
+ suggestion.score = DEFAULT_SUGGESTION_SCORE;
+ }
+ allSuggestions.push(suggestion);
+ }
+ }
+
+ return allSuggestions;
+ }
+
+ async #enableSettings(enabled) {
+ if (enabled && !this.#rs) {
+ this.logger.debug("Creating RemoteSettings client");
+ this.#onSettingsSync = event => this.#syncAll({ event });
+ this.#rs = lazy.RemoteSettings(RS_COLLECTION);
+ this.#rs.on("sync", this.#onSettingsSync);
+ await this.#syncConfig();
+ } else if (!enabled && this.#rs) {
+ this.logger.debug("Destroying RemoteSettings client");
+ this.#rs.off("sync", this.#onSettingsSync);
+ this.#rs = null;
+ this.#onSettingsSync = null;
+ }
+ }
+
+ async #syncConfig() {
+ this.logger.debug("Syncing config");
+ let rs = this.#rs;
+
+ let configArray = await rs.get({ filters: { type: "configuration" } });
+ if (rs != this.#rs) {
+ return;
+ }
+
+ this.logger.debug("Got config array: " + JSON.stringify(configArray));
+ this.#setConfig(configArray?.[0]?.configuration || {});
+ }
+
+ async #syncFeature(feature) {
+ this.logger.debug("Syncing feature: " + feature.name);
+ await feature.onRemoteSettingsSync(this.#rs);
+ }
+
+ async #syncAll({ event = null } = {}) {
+ this.logger.debug("Syncing all");
+ let rs = this.#rs;
+
+ // Remove local files of deleted records
+ if (event?.data?.deleted) {
+ await Promise.all(
+ event.data.deleted
+ .filter(d => d.attachment)
+ .map(entry =>
+ Promise.all([
+ this.#rs.attachments.deleteDownloaded(entry), // type: data
+ this.#rs.attachments.deleteFromDisk(entry), // type: icon
+ ])
+ )
+ );
+ if (rs != this.#rs) {
+ return;
+ }
+ }
+
+ let promises = [this.#syncConfig()];
+ for (let feature of this.#features) {
+ promises.push(this.#syncFeature(feature));
+ }
+ await Promise.all(promises);
+ }
+
+ /**
+ * Sets the quick suggest config and emits a "config-set" event.
+ *
+ * @param {object} config
+ * The config object.
+ */
+ #setConfig(config) {
+ config ??= {};
+ this.logger.debug("Setting config: " + JSON.stringify(config));
+ this.#config = config;
+ this.#emitter.emit("config-set");
+ }
+
+ // The `RemoteSettings` client.
+ #rs = null;
+
+ // Registered `BaseFeature` instances.
+ #features = new Set();
+
+ // Configuration data synced from remote settings. See the `config` getter.
+ #config = {};
+
+ #emitter = null;
+ #logger = null;
+ #onSettingsSync = null;
+}
+
+export var QuickSuggestRemoteSettings = new _QuickSuggestRemoteSettings();
+
+/**
+ * A wrapper around `Map` that handles quick suggest suggestions from remote
+ * settings. It maps keywords to suggestions. It has two benefits over `Map`:
+ *
+ * - The main benefit is that map entries are added in batches on idle to avoid
+ * blocking the main thread for too long, since there can be many suggestions
+ * and keywords.
+ * - A secondary benefit is that the interface is tailored to quick suggest
+ * suggestions, which have a `keywords` property.
+ */
+export class SuggestionsMap {
+ /**
+ * Returns the list of suggestions for a keyword.
+ *
+ * @param {string} keyword
+ * The keyword.
+ * @returns {Array}
+ * The array of suggestions for the keyword. If the keyword isn't in the
+ * map, the array will be empty.
+ */
+ get(keyword) {
+ let object = this.#suggestionsByKeyword.get(keyword.toLocaleLowerCase());
+ if (!object) {
+ return [];
+ }
+ return Array.isArray(object) ? object : [object];
+ }
+
+ /**
+ * Adds a list of suggestion objects to the results map. Each suggestion must
+ * have a `keywords` property whose value is an array of keyword strings. The
+ * suggestion's keywords will be taken from this array either exactly as they
+ * are specified or by generating new keywords from them; see `mapKeyword`.
+ *
+ * @param {Array} suggestions
+ * Array of suggestion objects.
+ * @param {Function} mapKeyword
+ * If null, each suggestion's keywords will be taken from its `keywords`
+ * array exactly as they are specified. Otherwise, as each suggestion is
+ * processed, this function will be called for each string in its `keywords`
+ * array. The function should return an array of strings. The suggestion's
+ * final list of keywords will be all the keywords returned by this function
+ * as it is called for each string in `keywords`.
+ */
+ async add(suggestions, mapKeyword = null) {
+ // There can be many suggestions, and each suggestion can have many
+ // keywords. To avoid blocking the main thread for too long, update the map
+ // in chunks, and to avoid blocking the UI and other higher priority work,
+ // do each chunk only when the main thread is idle. During each chunk, we'll
+ // add at most `chunkSize` entries to the map.
+ let suggestionIndex = 0;
+ let keywordIndex = 0;
+
+ // Keep adding chunks until all suggestions have been fully added.
+ while (suggestionIndex < suggestions.length) {
+ await new Promise(resolve => {
+ Services.tm.idleDispatchToMainThread(() => {
+ // Keep updating the map until the current chunk is done.
+ let indexInChunk = 0;
+ while (
+ indexInChunk < SuggestionsMap.chunkSize &&
+ suggestionIndex < suggestions.length
+ ) {
+ let suggestion = suggestions[suggestionIndex];
+ if (keywordIndex == suggestion.keywords.length) {
+ // We've added entries for all keywords of the current suggestion.
+ // Move on to the next suggestion.
+ suggestionIndex++;
+ keywordIndex = 0;
+ continue;
+ }
+
+ let originalKeyword = suggestion.keywords[keywordIndex];
+ let keywords = mapKeyword?.(originalKeyword) ?? [originalKeyword];
+ for (let keyword of keywords) {
+ // If the keyword's only suggestion is `suggestion`, store it
+ // directly as the value. Otherwise store an array of unique
+ // suggestions. See the `#suggestionsByKeyword` comment.
+ let object = this.#suggestionsByKeyword.get(keyword);
+ if (!object) {
+ this.#suggestionsByKeyword.set(keyword, suggestion);
+ } else {
+ let isArray = Array.isArray(object);
+ if (!isArray && object != suggestion) {
+ this.#suggestionsByKeyword.set(keyword, [object, suggestion]);
+ } else if (isArray && !object.includes(suggestion)) {
+ object.push(suggestion);
+ }
+ }
+ }
+
+ keywordIndex++;
+ indexInChunk++;
+ }
+
+ // The current chunk is done.
+ resolve();
+ });
+ });
+ }
+ }
+
+ clear() {
+ this.#suggestionsByKeyword.clear();
+ }
+
+ // Maps each keyword in the dataset to one or more suggestions for the
+ // keyword. If only one suggestion uses a keyword, the keyword's value in the
+ // map will be the suggestion object. If more than one suggestion uses the
+ // keyword, the value will be an array of the suggestions. The reason for not
+ // always using an array is that we expect the vast majority of keywords to be
+ // used by only one suggestion, and since there are potentially very many
+ // keywords and suggestions and we keep them in memory all the time, we want
+ // to save as much memory as possible.
+ #suggestionsByKeyword = new Map();
+
+ // This is only defined as a property so that tests can override it.
+ static chunkSize = SUGGESTIONS_MAP_CHUNK_SIZE;
+}