mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			422 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			422 lines
		
	
	
	
		
			14 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/. */
 | 
						|
 | 
						|
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: suggestion.iconMimetype ?? "",
 | 
						|
        });
 | 
						|
 | 
						|
        delete suggestion.icon;
 | 
						|
        delete suggestion.iconMimetype;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    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() {
 | 
						|
    // Important note on schema updates:
 | 
						|
    //
 | 
						|
    // The first time the Suggest store is accessed after a schema version
 | 
						|
    // update, its backing database will be deleted and a new empty database
 | 
						|
    // will be created. The database will remain empty until we tell the store
 | 
						|
    // to ingest. If we wait to ingest as usual until our ingest timer fires,
 | 
						|
    // the store will remain empty for up to 24 hours, which means we won't
 | 
						|
    // serve any suggestions at all during that time.
 | 
						|
    //
 | 
						|
    // Therefore we simply always ingest here in `#init()`. We'll sometimes
 | 
						|
    // ingest unnecessarily but that's better than the alternative. (As a
 | 
						|
    // reminder, for users who have Suggest enabled `#init()` is called whenever
 | 
						|
    // the Rust backend is enabled, including on startup.)
 | 
						|
 | 
						|
    // Initialize 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;
 | 
						|
    }
 | 
						|
 | 
						|
    // Log the last ingest time for debugging.
 | 
						|
    let lastIngestSecs = Services.prefs.getIntPref(
 | 
						|
      INGEST_TIMER_LAST_UPDATE_PREF,
 | 
						|
      0
 | 
						|
    );
 | 
						|
    if (lastIngestSecs) {
 | 
						|
      this.logger.debug(
 | 
						|
        `Last ingest time: ${lastIngestSecs}s (${
 | 
						|
          Math.round(Date.now() / 1000) - lastIngestSecs
 | 
						|
        }s ago)`
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      this.logger.debug("Last ingest time: none");
 | 
						|
    }
 | 
						|
 | 
						|
    // Register the ingest timer.
 | 
						|
    lazy.timerManager.registerTimer(
 | 
						|
      INGEST_TIMER_ID,
 | 
						|
      this,
 | 
						|
      lazy.UrlbarPrefs.get("quicksuggest.rustIngestIntervalSeconds"),
 | 
						|
      true // skipFirst
 | 
						|
    );
 | 
						|
 | 
						|
    // Ingest.
 | 
						|
    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;
 | 
						|
    }
 | 
						|
    this.#ingestPromise = new Promise(resolve => {
 | 
						|
      ChromeUtils.idleDispatch(() => this.#ingestHelper().finally(resolve));
 | 
						|
    });
 | 
						|
    await this.#ingestPromise;
 | 
						|
  }
 | 
						|
 | 
						|
  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;
 | 
						|
}
 |