summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/private/SuggestBackendRust.sys.mjs')
-rw-r--r--browser/components/urlbar/private/SuggestBackendRust.sys.mjs407
1 files changed, 407 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/SuggestBackendRust.sys.mjs b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
new file mode 100644
index 0000000000..fe54feaee8
--- /dev/null
+++ b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
@@ -0,0 +1,407 @@
+/* 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/. */
+
+import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ RemoteSettingsConfig: "resource://gre/modules/RustRemoteSettings.sys.mjs",
+ SuggestIngestionConstraints: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestStore: "resource://gre/modules/RustSuggest.sys.mjs",
+ Suggestion: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestionProvider: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestionQuery: "resource://gre/modules/RustSuggest.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ Utils: "resource://services-settings/Utils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+const SUGGEST_STORE_BASENAME = "suggest.sqlite";
+
+// This ID is used to register our ingest timer with nsIUpdateTimerManager.
+const INGEST_TIMER_ID = "suggest-ingest";
+const INGEST_TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${INGEST_TIMER_ID}`;
+
+// Maps from `suggestion.constructor` to the corresponding name of the
+// suggestion type. See `getSuggestionType()` for details.
+const gSuggestionTypesByCtor = new WeakMap();
+
+/**
+ * The Suggest Rust backend. Not used when the remote settings JS backend is
+ * enabled.
+ *
+ * This class returns suggestions served by the Rust component. These are the
+ * primary related architectural pieces (see bug 1851256 for details):
+ *
+ * (1) The `suggest` Rust component, which lives in the application-services
+ * repo [1] and is periodically vendored into mozilla-central [2] and then
+ * built into the Firefox binary.
+ * (2) `suggest.udl`, which is part of the Rust component's source files and
+ * defines the interface exposed to foreign-function callers like JS [3, 4].
+ * (3) `RustSuggest.sys.mjs` [5], which contains the JS bindings generated from
+ * `suggest.udl` by UniFFI. The classes defined in `RustSuggest.sys.mjs` are
+ * what we consume here in this file. If you have a question about the JS
+ * interface to the Rust component, try checking `RustSuggest.sys.mjs`, but
+ * as you get accustomed to UniFFI JS conventions you may find it simpler to
+ * refer directly to `suggest.udl`.
+ * (4) `config.toml` [6], which defines which functions in the JS bindings are
+ * sync and which are async. Functions default to the "worker" thread, which
+ * means they are async. Some functions are "main", which means they are
+ * sync. Async functions return promises. This information is reflected in
+ * `RustSuggest.sys.mjs` of course: If a function is "worker", its JS
+ * binding will return a promise, and if it's "main" it won't.
+ *
+ * [1] https://github.com/mozilla/application-services/tree/main/components/suggest
+ * [2] https://searchfox.org/mozilla-central/source/third_party/rust/suggest
+ * [3] https://github.com/mozilla/application-services/blob/main/components/suggest/src/suggest.udl
+ * [4] https://searchfox.org/mozilla-central/source/third_party/rust/suggest/src/suggest.udl
+ * [5] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs
+ * [6] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/config.toml
+ */
+export class SuggestBackendRust extends BaseFeature {
+ /**
+ * @returns {object}
+ * The global Suggest config from the Rust component as returned from
+ * `SuggestStore.fetchGlobalConfig()`.
+ */
+ get config() {
+ return this.#config || {};
+ }
+
+ /**
+ * @returns {Promise}
+ * If ingest is pending this will be resolved when it's done. Otherwise it
+ * was resolved when the previous ingest finished.
+ */
+ get ingestPromise() {
+ return this.#ingestPromise;
+ }
+
+ get shouldEnable() {
+ return lazy.UrlbarPrefs.get("quickSuggestRustEnabled");
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ this.#init();
+ } else {
+ this.#uninit();
+ }
+ }
+
+ async query(searchString) {
+ this.logger.info("Handling query: " + JSON.stringify(searchString));
+
+ if (!this.#store) {
+ // There must have been an error creating `#store`.
+ this.logger.info("#store is null, returning");
+ return [];
+ }
+
+ // Build the list of enabled Rust providers to query.
+ let providers = this.#rustProviders.reduce(
+ (memo, { type, feature, provider }) => {
+ if (feature.isEnabled && feature.isRustSuggestionTypeEnabled(type)) {
+ this.logger.debug(
+ `Adding provider to query: '${type}' (${provider})`
+ );
+ memo.push(provider);
+ }
+ return memo;
+ },
+ []
+ );
+
+ let suggestions = await this.#store.query(
+ new lazy.SuggestionQuery({ keyword: searchString, providers })
+ );
+
+ for (let suggestion of suggestions) {
+ let type = getSuggestionType(suggestion);
+ if (!type) {
+ continue;
+ }
+
+ suggestion.source = "rust";
+ suggestion.provider = type;
+ suggestion.is_sponsored = type == "Amp" || type == "Yelp";
+ if (Array.isArray(suggestion.icon)) {
+ suggestion.icon_blob = new Blob(
+ [new Uint8Array(suggestion.icon)],
+ type == "Yelp" ? { type: "image/svg+xml" } : null
+ );
+ delete suggestion.icon;
+ }
+ }
+
+ this.logger.debug(
+ "Got suggestions: " + JSON.stringify(suggestions, null, 2)
+ );
+
+ return suggestions;
+ }
+
+ cancelQuery() {
+ this.#store?.interrupt();
+ }
+
+ /**
+ * Returns suggestion-type-specific configuration data set by the Rust
+ * backend.
+ *
+ * @param {string} type
+ * A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
+ * "Wikipedia", "Mdn", etc. See also `BaseFeature.rustSuggestionTypes`.
+ * @returns {object} config
+ * The config data for the type.
+ */
+ getConfigForSuggestionType(type) {
+ return this.#configsBySuggestionType.get(type);
+ }
+
+ /**
+ * nsITimerCallback
+ */
+ notify() {
+ this.logger.info("Ingest timer fired");
+ this.#ingest();
+ }
+
+ get #storePath() {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
+ SUGGEST_STORE_BASENAME
+ );
+ }
+
+ /**
+ * @returns {Array}
+ * Each item in this array contains metadata related to a Rust suggestion
+ * type, the `BaseFeature` that manages the type, and the corresponding
+ * suggestion provider as defined by Rust. Items look like this:
+ * `{ type, feature, provider }`
+ *
+ * {string} type
+ * The Rust suggestion type name (the same type of string values that are
+ * defined in `BaseFeature.rustSuggestionTypes`).
+ * {BaseFeature} feature
+ * The feature that manages the suggestion type.
+ * {number} provider
+ * An integer value defined on the `SuggestionProvider` object in
+ * `RustSuggest.sys.mjs` that identifies the suggestion provider to
+ * Rust.
+ */
+ get #rustProviders() {
+ let items = [];
+ for (let [type, feature] of lazy.QuickSuggest
+ .featuresByRustSuggestionType) {
+ let key = type.toUpperCase();
+ if (!lazy.SuggestionProvider.hasOwnProperty(key)) {
+ this.logger.error(`SuggestionProvider["${key}"] is not defined!`);
+ continue;
+ }
+ items.push({ type, feature, provider: lazy.SuggestionProvider[key] });
+ }
+ return items;
+ }
+
+ async #init() {
+ // Create the store.
+ let path = this.#storePath;
+ this.logger.info("Initializing SuggestStore: " + path);
+ try {
+ this.#store = lazy.SuggestStore.init(
+ path,
+ this.#test_remoteSettingsConfig ??
+ new lazy.RemoteSettingsConfig({
+ collectionName: "quicksuggest",
+ bucketName: lazy.Utils.actualBucketName("main"),
+ serverUrl: lazy.Utils.SERVER_URL,
+ })
+ );
+ } catch (error) {
+ this.logger.error("Error initializing SuggestStore:");
+ this.logger.error(error);
+ return;
+ }
+
+ // Before registering the ingest timer, check the last-update pref, which is
+ // created by the timer manager the first time we register it. If the pref
+ // doesn't exist, this is the first time the Rust backend has been enabled
+ // in this profile. In that case, perform ingestion immediately to make
+ // automated and manual testing easier. Otherwise we'd need to wait at least
+ // 30s (`app.update.timerFirstInterval`) for the timer manager to call us
+ // back (and we'd also need to pass false for `skipFirst` below).
+ let lastIngestSecs = Services.prefs.getIntPref(
+ INGEST_TIMER_LAST_UPDATE_PREF,
+ 0
+ );
+
+ // Register the ingest timer.
+ lazy.timerManager.registerTimer(
+ INGEST_TIMER_ID,
+ this,
+ lazy.UrlbarPrefs.get("quicksuggest.rustIngestIntervalSeconds"),
+ true // skipFirst
+ );
+
+ if (lastIngestSecs) {
+ this.logger.info(
+ `Last ingest: ${lastIngestSecs}s since epoch. Not ingesting now`
+ );
+ } else {
+ this.logger.info("Last ingest time not found. Ingesting now");
+ await this.#ingest();
+ }
+ }
+
+ #uninit() {
+ this.#store = null;
+ this.#configsBySuggestionType.clear();
+ lazy.timerManager.unregisterTimer(INGEST_TIMER_ID);
+ }
+
+ async #ingest() {
+ let instance = (this.#ingestInstance = {});
+ await this.#ingestPromise;
+ if (instance != this.#ingestInstance) {
+ return;
+ }
+ await (this.#ingestPromise = this.#ingestHelper());
+ }
+
+ async #ingestHelper() {
+ if (!this.#store) {
+ return;
+ }
+
+ this.logger.info("Starting ingest and configs fetch");
+
+ // Do the ingest.
+ this.logger.debug("Starting ingest");
+ try {
+ await this.#store.ingest(new lazy.SuggestIngestionConstraints());
+ } catch (error) {
+ // Ingest can throw a `SuggestApiError` subclass called `Other` that has a
+ // custom `reason` message, which is very helpful for diagnosing problems
+ // with remote settings data in tests in particular.
+ this.logger.error("Ingest error: " + (error.reason ?? error));
+ }
+ this.logger.debug("Finished ingest");
+
+ if (!this.#store) {
+ this.logger.info("#store became null, returning from ingest");
+ return;
+ }
+
+ // Fetch the global config.
+ this.logger.debug("Fetching global config");
+ this.#config = await this.#store.fetchGlobalConfig();
+ this.logger.debug("Got global config: " + JSON.stringify(this.#config));
+
+ if (!this.#store) {
+ this.logger.info("#store became null, returning from ingest");
+ return;
+ }
+
+ // Fetch all provider configs. We do this for all features, even ones that
+ // are currently disabled, because they may become enabled before the next
+ // ingest.
+ this.logger.debug("Fetching provider configs");
+ await Promise.all(
+ this.#rustProviders.map(async ({ type, provider }) => {
+ let config = await this.#store.fetchProviderConfig(provider);
+ this.logger.debug(
+ `Got '${type}' provider config: ` + JSON.stringify(config)
+ );
+ this.#configsBySuggestionType.set(type, config);
+ })
+ );
+ this.logger.debug("Finished fetching provider configs");
+
+ this.logger.info("Finished ingest and configs fetch");
+ }
+
+ async _test_setRemoteSettingsConfig(config) {
+ this.#test_remoteSettingsConfig = config;
+
+ if (this.isEnabled) {
+ // Recreate the store and re-ingest.
+ Services.prefs.clearUserPref(INGEST_TIMER_LAST_UPDATE_PREF);
+ this.#uninit();
+ await this.#init();
+ }
+ }
+
+ async _test_ingest() {
+ await this.#ingest();
+ }
+
+ // The `SuggestStore` instance.
+ #store;
+
+ // Global Suggest config as returned from `SuggestStore.fetchGlobalConfig()`.
+ #config = {};
+
+ // Maps from suggestion type to provider config as returned from
+ // `SuggestStore.fetchProviderConfig()`.
+ #configsBySuggestionType = new Map();
+
+ #ingestPromise;
+ #ingestInstance;
+ #test_remoteSettingsConfig;
+}
+
+/**
+ * Returns the type of a suggestion.
+ *
+ * @param {Suggestion} suggestion
+ * A suggestion object, an instance of one of the `Suggestion` subclasses.
+ * @returns {string}
+ * The suggestion's type, e.g., "Amp", "Wikipedia", etc.
+ */
+function getSuggestionType(suggestion) {
+ // Suggestion objects served by the Rust component don't have any inherent
+ // type information other than the classes they are instances of. There's no
+ // `type` property, for example. There's a base `Suggestion` class and many
+ // `Suggestion` subclasses, one per type of suggestion. Each suggestion object
+ // is an instance of one of these subclasses. We derive a suggestion's type
+ // from the subclass it's an instance of.
+ //
+ // Unfortunately the subclasses are all anonymous, which means
+ // `suggestion.constructor.name` is always an empty string. (This is due to
+ // how UniFFI generates JS bindings.) Instead, the subclasses are defined as
+ // properties on the base `Suggestion` class. For example,
+ // `Suggestion.Wikipedia` is the (anonymous) Wikipedia suggestion class. To
+ // find a suggestion's subclass, we loop through the keys on `Suggestion`
+ // until we find the value the suggestion is an instance of. To avoid doing
+ // this every time, we cache the mapping from suggestion constructor to key
+ // the first time we encounter a new suggestion subclass.
+ let type = gSuggestionTypesByCtor.get(suggestion.constructor);
+ if (!type) {
+ type = Object.keys(lazy.Suggestion).find(
+ key => suggestion instanceof lazy.Suggestion[key]
+ );
+ if (type) {
+ gSuggestionTypesByCtor.set(suggestion.constructor, type);
+ } else {
+ this.logger.error(
+ "Unexpected error: Suggestion class not found on `Suggestion`. " +
+ "Did the Rust component or its JS bindings change? " +
+ "The suggestion is: " +
+ JSON.stringify(suggestion)
+ );
+ }
+ }
+ return type;
+}