tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SuggestBackendRust.sys.mjs (34331B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { SuggestBackend } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs";
      6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      7 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      8 
      9 const lazy = {};
     10 
     11 ChromeUtils.defineESModuleGetters(lazy, {
     12  AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
     13  InterruptKind:
     14    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     15  ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
     16  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     17  SharedRemoteSettingsService:
     18    "resource://gre/modules/RustSharedRemoteSettingsService.sys.mjs",
     19  SuggestIngestionConstraints:
     20    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     21  SuggestStoreBuilder:
     22    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     23  Suggestion:
     24    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     25  SuggestionProvider:
     26    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     27  SuggestionProviderConstraints:
     28    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     29  SuggestionQuery:
     30    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     31  TaskQueue: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     32  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     33  Utils: "resource://services-settings/Utils.sys.mjs",
     34 });
     35 
     36 /**
     37 * @import {SuggestProvider} from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"
     38 * @import {
     39 *   GeonameAlternates, Geoname, GeonameMatch, GeonameType, Suggestion
     40 * } from "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs"
     41 */
     42 
     43 XPCOMUtils.defineLazyServiceGetter(
     44  lazy,
     45  "timerManager",
     46  "@mozilla.org/updates/timer-manager;1",
     47  Ci.nsIUpdateTimerManager
     48 );
     49 
     50 const SUGGEST_DATA_STORE_BASENAME = "suggest.sqlite";
     51 
     52 // This ID is used to register our ingest timer with nsIUpdateTimerManager.
     53 const INGEST_TIMER_ID = "suggest-ingest";
     54 const INGEST_TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${INGEST_TIMER_ID}`;
     55 
     56 // Maps from `suggestion.constructor` to the corresponding name of the
     57 // suggestion type. See `getSuggestionType()` for details.
     58 const gSuggestionTypesByCtor = new WeakMap();
     59 
     60 /**
     61 * The Suggest Rust backend. Not used when the remote settings JS backend is
     62 * enabled.
     63 *
     64 * This class returns suggestions served by the Rust component. These are the
     65 * primary related architectural pieces (see bug 1851256 for details):
     66 *
     67 * (1) The `suggest` Rust component, which lives in the application-services
     68 *     repo [1] and is periodically vendored into mozilla-central [2] and then
     69 *     built into the Firefox binary.
     70 * (2) `suggest.udl`, which is part of the Rust component's source files and
     71 *     defines the interface exposed to foreign-function callers like JS [3, 4].
     72 * (3) `RustSuggest.sys.mjs` [5], which contains the JS bindings generated from
     73 *     `suggest.udl` by UniFFI. The classes defined in `RustSuggest.sys.mjs` are
     74 *     what we consume here in this file. If you have a question about the JS
     75 *     interface to the Rust component, try checking `RustSuggest.sys.mjs`, but
     76 *     as you get accustomed to UniFFI JS conventions you may find it simpler to
     77 *     refer directly to `suggest.udl`.
     78 * (4) `config.toml` [6], which defines which functions in the JS bindings are
     79 *     sync and which are async. Functions default to the "worker" thread, which
     80 *     means they are async. Some functions are "main", which means they are
     81 *     sync. Async functions return promises. This information is reflected in
     82 *     `RustSuggest.sys.mjs` of course: If a function is "worker", its JS
     83 *     binding will return a promise, and if it's "main" it won't.
     84 *
     85 * [1] https://github.com/mozilla/application-services/tree/main/components/suggest
     86 * [2] https://searchfox.org/mozilla-central/source/third_party/rust/suggest
     87 * [3] https://github.com/mozilla/application-services/blob/main/components/suggest/src/suggest.udl
     88 * [4] https://searchfox.org/mozilla-central/source/third_party/rust/suggest/src/suggest.udl
     89 * [5] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs
     90 * [6] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/config.toml
     91 */
     92 export class SuggestBackendRust extends SuggestBackend {
     93  constructor() {
     94    super();
     95    this.#ingestQueue = new lazy.TaskQueue();
     96 
     97    // The remote settings server URL returned by `Utils.SERVER_URL` comes from
     98    // the `services.settings.server` pref. The xpcshell and browser test
     99    // harnesses set this pref to `"data:,#remote-settings-dummy/v1"` so that
    100    // browser features that use RS and remain enabled during tests don't hit
    101    // the real server. Suggest tests use a mock RS server and set this pref to
    102    // that server's URL, but during other tests the pref remains the dummy URL.
    103    // During those other tests, Suggest remains enabled, which means if we
    104    // initialize the Suggest store with the dummy URL, the Rust Suggest and RS
    105    // components will attempt to use it (when the store is initialized and on
    106    // initial ingest). Unfortunately the Rust RS component logs an error each
    107    // time it tries to manipulate the dummy URL because it's a `data` URI,
    108    // which is a "cannot-be-a-base" URL. The error is harmless, but it can be
    109    // logged many times during a test suite.
    110    //
    111    // To prevent Suggest from using the dummy URL, we skip setting the
    112    // remoteSettingsService, which prevents the Suggest store from being
    113    // created, effectively disabling Rust suggestions. Suggest tests manually
    114    // set the RS config when they set up the mock RS server, so they'll work
    115    // fine. Alternatively the test harnesses could disable Suggest by default
    116    // just like they set the server pref to the dummy URL, but Suggest is more
    117    // than Rust suggestions.
    118    if (!lazy.Utils.shouldSkipRemoteActivityDueToTests) {
    119      this.#remoteSettingsService =
    120        lazy.SharedRemoteSettingsService.rustService();
    121    }
    122  }
    123 
    124  get enablingPreferences() {
    125    return ["quicksuggest.rustEnabled"];
    126  }
    127 
    128  /**
    129   * @returns {object}
    130   *   The global Suggest config from the Rust component as returned from
    131   *   `SuggestStore.fetchGlobalConfig()`.
    132   */
    133  get config() {
    134    return this.#config || {};
    135  }
    136 
    137  /**
    138   * @returns {Promise}
    139   *   Resolved when all pending ingests are done.
    140   */
    141  get ingestPromise() {
    142    return this.#ingestQueue.emptyPromise;
    143  }
    144 
    145  enable(enabled) {
    146    if (enabled) {
    147      this.#init();
    148    } else {
    149      this.#uninit();
    150    }
    151  }
    152 
    153  /**
    154   * Queries the Rust component and returns all matching suggestions.
    155   *
    156   * @param {string} searchString
    157   *   The search string.
    158   * @param {object} [options]
    159   *   Options object.
    160   * @param {UrlbarQueryContext} [options._queryContext]
    161   *   The query context.
    162   * @param {?Array} [options.types]
    163   *   This is only intended to be used in special circumstances and normally
    164   *   should not be specified. Array of suggestion types to query. By default
    165   *   all enabled suggestion types are queried.
    166   * @returns {Promise<Array>}
    167   *   Matching Rust suggestions.
    168   */
    169  async query(searchString, { _queryContext, types = null } = {}) {
    170    if (!this.#store) {
    171      return [];
    172    }
    173 
    174    this.logger.debug("Handling query", { searchString });
    175 
    176    // Build a list of Rust providers to query and an object containing Rust
    177    // provider constraints for all queried providers.
    178    let uniqueProviders = new Set();
    179    let allProviderConstraints = {};
    180    let typeItems = types
    181      ? types.map(type => ({ type }))
    182      : this.#enabledSuggestionTypes;
    183    for (let { feature, type, provider } of typeItems) {
    184      if (!provider) {
    185        provider = this.#providerFromSuggestionType(type);
    186        if (!provider) {
    187          throw new Error("Unknown Rust suggestion type: " + type);
    188        }
    189      }
    190      this.logger.debug("Adding type to query", { type, provider });
    191      uniqueProviders.add(provider);
    192      if (feature) {
    193        allProviderConstraints = SuggestBackendRust.mergeProviderConstraints(
    194          allProviderConstraints,
    195          feature.rustProviderConstraints
    196        );
    197      }
    198    }
    199 
    200    // Do the query.
    201    const { suggestions, queryTimes } = await this.#store.queryWithMetrics(
    202      new lazy.SuggestionQuery({
    203        providers: [...uniqueProviders],
    204        keyword: searchString,
    205        providerConstraints: new lazy.SuggestionProviderConstraints(
    206          allProviderConstraints
    207        ),
    208      })
    209    );
    210 
    211    // Update query telemetry.
    212    for (let { label, value } of queryTimes) {
    213      Glean.suggest.queryTime[label].accumulateSingleSample(value);
    214    }
    215 
    216    // Build the list of suggestions to return.
    217    let liftedSuggestions = [];
    218    for (let s of suggestions) {
    219      let type = getSuggestionType(s);
    220      if (!type) {
    221        continue;
    222      }
    223 
    224      let suggestion = liftSuggestion(s);
    225      if (!suggestion) {
    226        continue;
    227      }
    228 
    229      // Set `suggestion.source` and `provider`, which `QuickSuggest` uses to
    230      // look up the feature that manages the suggestion.
    231      suggestion.source = "rust";
    232      suggestion.provider = type;
    233 
    234      if (suggestion.icon) {
    235        suggestion.icon_blob = new Blob([suggestion.icon], {
    236          type: suggestion.iconMimetype ?? "",
    237        });
    238        delete suggestion.icon;
    239        delete suggestion.iconMimetype;
    240      }
    241 
    242      liftedSuggestions.push(suggestion);
    243    }
    244 
    245    this.logger.debug("Got suggestions", liftedSuggestions);
    246 
    247    return liftedSuggestions;
    248  }
    249 
    250  cancelQuery() {
    251    this.#store?.interrupt(lazy.InterruptKind.READ);
    252  }
    253 
    254  /**
    255   * Returns suggestion-type-specific configuration data set by the Rust
    256   * backend.
    257   *
    258   * @param {string} type
    259   *   A suggestion type name as defined in Rust, e.g., "Amp", "Wikipedia",
    260   *   "Mdn", etc.
    261   * @returns {object} config
    262   *   The config data for the type.
    263   */
    264  getConfigForSuggestionType(type) {
    265    return this.#configsBySuggestionType.get(type);
    266  }
    267 
    268  /**
    269   * Ingests a feature's enabled suggestion types and updates staleness
    270   * bookkeeping. By default only stale suggestion types are ingested. A
    271   * suggestion type is stale if (a) it hasn't been ingested during this app
    272   * session or (b) the last time this method was called the suggestion type or
    273   * its feature was disabled.
    274   *
    275   * @param {SuggestProvider} feature
    276   *   A feature that manages Rust suggestion types.
    277   * @param {object} [options]
    278   *   Options object.
    279   * @param {boolean} [options.evenIfFresh]
    280   *   Set to true to force ingest for all the feature's suggestion types, even
    281   *   ones that aren't stale.
    282   */
    283  ingestEnabledSuggestions(feature, { evenIfFresh = false } = {}) {
    284    let type = feature.rustSuggestionType;
    285    if (!type) {
    286      return;
    287    }
    288 
    289    if (!this.isEnabled || !feature.isEnabled) {
    290      // Mark this type as stale so we'll ingest next time this method is
    291      // called.
    292      this.#providerConstraintsOnLastIngestByFeature.delete(feature);
    293    } else {
    294      let providerConstraints = feature.rustProviderConstraints;
    295      if (
    296        evenIfFresh ||
    297        !this.#providerConstraintsOnLastIngestByFeature.has(feature) ||
    298        !lazy.ObjectUtils.deepEqual(
    299          providerConstraints,
    300          this.#providerConstraintsOnLastIngestByFeature.get(feature)
    301        )
    302      ) {
    303        this.#providerConstraintsOnLastIngestByFeature.set(
    304          feature,
    305          providerConstraints
    306        );
    307        this.#ingestSuggestionType({ type, providerConstraints });
    308      }
    309    }
    310  }
    311 
    312  /**
    313   * Registers a dismissal for a Rust suggestion.
    314   *
    315   * @param {Suggestion} suggestion
    316   *   The suggestion to dismiss, an instance of one of the `Suggestion`
    317   *   subclasses exposed over FFI, e.g., `Suggestion.Wikipedia`. Typically the
    318   *   suggestion will have been returned from the Rust component, but tests may
    319   *   find it useful to make a `Suggestion` object directly.
    320   */
    321  async dismissRustSuggestion(suggestion) {
    322    let lowered = lowerSuggestion(suggestion);
    323    try {
    324      await this.#store?.dismissBySuggestion(lowered);
    325    } catch (error) {
    326      this.logger.error("Error: dismissRustSuggestion", { error, suggestion });
    327    }
    328  }
    329 
    330  /**
    331   * Registers a dismissal using a dismissal key. If you have a suggestion
    332   * object returned from the Rust component, use `dismissRustSuggestion()`
    333   * instead. This method can be used to record dismissals for suggestions from
    334   * other backends, like Merino.
    335   *
    336   * @param {string} dismissalKey
    337   *   The dismissal key.
    338   */
    339  async dismissByKey(dismissalKey) {
    340    try {
    341      await this.#store?.dismissByKey(dismissalKey);
    342    } catch (error) {
    343      this.logger.error("Error: dismissByKey", { error, dismissalKey });
    344    }
    345  }
    346 
    347  /**
    348   * Returns whether a dismissal is recorded for a Rust suggestion.
    349   *
    350   * @param {Suggestion} suggestion
    351   *   The suggestion to dismiss, an instance of one of the `Suggestion`
    352   *   subclasses exposed over FFI, e.g., `Suggestion.Wikipedia`. Typically the
    353   *   suggestion will have been returned from the Rust component, but tests may
    354   *   find it useful to make a `Suggestion` object directly.
    355   * @returns {Promise<boolean>}
    356   *   Whether the suggestion has been dismissed.
    357   */
    358  async isRustSuggestionDismissed(suggestion) {
    359    let lowered = lowerSuggestion(suggestion);
    360    try {
    361      return await this.#store?.isDismissedBySuggestion(lowered);
    362    } catch (error) {
    363      this.logger.error("Error: isDismissedBySuggestion", {
    364        error,
    365        suggestion,
    366      });
    367    }
    368    return false;
    369  }
    370 
    371  /**
    372   * Returns whether a dismissal is recorded for a dismissal key. If you have a
    373   * suggestion object returned from the Rust component, use
    374   * `isRustSuggestionDismissed()` instead. This method can be used to determine
    375   * whether suggestions from other backends, like Merino, have been dismissed.
    376   *
    377   * @param {string} dismissalKey
    378   *   The dismissal key.
    379   * @returns {Promise<boolean>}
    380   *   Whether a dismissal is recorded for the key.
    381   */
    382  async isDismissedByKey(dismissalKey) {
    383    try {
    384      return await this.#store?.isDismissedByKey(dismissalKey);
    385    } catch (error) {
    386      this.logger.error("Error: isDismissedByKey", { error, dismissalKey });
    387    }
    388    return false;
    389  }
    390 
    391  /**
    392   * Returns whether any dismissals are recorded.
    393   *
    394   * @returns {Promise<boolean>}
    395   *   Whether any suggestions have been dismissed.
    396   */
    397  async anyDismissedSuggestions() {
    398    try {
    399      return await this.#store?.anyDismissedSuggestions();
    400    } catch (error) {
    401      this.logger.error("Error: anyDismissedSuggestions", error);
    402    }
    403    // Return true because there may be dismissed suggestions, we don't know.
    404    return true;
    405  }
    406 
    407  /**
    408   * Removes all registered dismissals.
    409   */
    410  async clearDismissedSuggestions() {
    411    try {
    412      await this.#store?.clearDismissedSuggestions();
    413    } catch (error) {
    414      this.logger.error("Error clearing dismissed suggestions", error);
    415    }
    416  }
    417 
    418  /**
    419   * Fetches geonames stored in the Suggest database. A geoname represents a
    420   * geographic place.
    421   *
    422   * See `SuggestStore::fetch_geonames()` in the Rust component for full
    423   * documentation.
    424   *
    425   * @param {string} searchString
    426   *   The string to match against geonames.
    427   * @param {boolean} matchNamePrefix
    428   *   Whether prefix matching is performed on names excluding abbreviations and
    429   *   airport codes.
    430   * @param {GeonameType} geonameType
    431   *   Restricts returned geonames to a type.
    432   * @param {Array} filter
    433   *   Restricts returned geonames to certain cities or regions. Optional.
    434   * @returns {Promise<GeonameMatch[]>}
    435   *   Array of `GeonameMatch` objects. An empty array if there are no matches.
    436   */
    437  async fetchGeonames(searchString, matchNamePrefix, geonameType, filter) {
    438    if (!this.#store) {
    439      return [];
    440    }
    441    let geonames = await this.#store.fetchGeonames(
    442      searchString,
    443      matchNamePrefix,
    444      geonameType,
    445      filter
    446    );
    447    return geonames;
    448  }
    449 
    450  /**
    451   * Fetches geonames alternate names stored in the Suggest database. A single
    452   * geoname can have many alternate names since a place can have many different
    453   * variations of its name. Alternate names also include translations of names
    454   * into different languages.
    455   *
    456   * See `SuggestStore::fetch_geoname_alternates()` in the Rust component for
    457   * full documentation.
    458   *
    459   * @param {Geoname} geoname
    460   *   A `Geoname` object returned from `fetchGeonames()`.
    461   * @returns {Promise<GeonameAlternates>}
    462   *   A `GeonameAlternates` object containing the alternates for the geoname,
    463   *   its administrative divisions, and its country. See the Rust component for
    464   *   details.
    465   */
    466  async fetchGeonameAlternates(geoname) {
    467    let alts = await this.#store?.fetchGeonameAlternates(geoname);
    468    return alts;
    469  }
    470 
    471  /**
    472   * nsITimerCallback
    473   */
    474  notify() {
    475    this.logger.info("Ingest timer fired");
    476    this.#ingestAll();
    477  }
    478 
    479  /**
    480   * Merges two Rust provider constraints and returns a new object. The
    481   * constraints should be plain JS objects appropriate for passing to the
    482   * `SuggestionProviderConstraints` constructor.
    483   *
    484   * TODO: This should be a function in the Rust component.
    485   *
    486   * @param {object} a
    487   *   The first constraints object.
    488   * @param {object} b
    489   *   The second constraints object.
    490   * @returns {object}
    491   *   A plain JS object resulting from merging `a` and `b`.
    492   */
    493  static mergeProviderConstraints(a, b) {
    494    if (!a || !b) {
    495      return a ?? b;
    496    }
    497 
    498    let merged = { ...a, ...b };
    499 
    500    // Merge the `dynamicSuggestionTypes` arrays.
    501    if (
    502      a.hasOwnProperty("dynamicSuggestionTypes") ||
    503      b.hasOwnProperty("dynamicSuggestionTypes")
    504    ) {
    505      if (!a.dynamicSuggestionTypes || !b.dynamicSuggestionTypes) {
    506        merged.dynamicSuggestionTypes =
    507          a.dynamicSuggestionTypes ?? b.dynamicSuggestionTypes;
    508      } else {
    509        // We only sort to make the behavior stable for tests.
    510        merged.dynamicSuggestionTypes = [
    511          ...new Set(
    512            a.dynamicSuggestionTypes.concat(b.dynamicSuggestionTypes).sort()
    513          ),
    514        ];
    515      }
    516    }
    517 
    518    return merged;
    519  }
    520 
    521  /**
    522   * @returns {string}
    523   *   The path of `suggest.sqlite`, where the Rust component stores ingested
    524   *   suggestions. It also stores dismissed suggestions, which is why we keep
    525   *   this file in the profile directory, but desktop doesn't currently use the
    526   *   Rust component for that.
    527   */
    528  get #storeDataPath() {
    529    return PathUtils.join(
    530      Services.dirsvc.get("ProfD", Ci.nsIFile).path,
    531      SUGGEST_DATA_STORE_BASENAME
    532    );
    533  }
    534 
    535  /**
    536   * @returns {Array}
    537   *   Each item in this array identifies an enabled Rust suggestion type and
    538   *   related data. Items have the following properties:
    539   *
    540   *   {SuggestProvider} feature
    541   *     The feature that manages the Rust suggestion type.
    542   *   {string} type
    543   *     A Rust suggestion type name as defined in Rust, e.g., "Amp",
    544   *     "Wikipedia", "Mdn", etc.
    545   *   {number} provider
    546   *     An integer that identifies the provider of the suggestion type to Rust.
    547   */
    548  get #enabledSuggestionTypes() {
    549    let items = [];
    550    for (let feature of lazy.QuickSuggest.rustFeatures) {
    551      if (feature.isEnabled) {
    552        let type = feature.rustSuggestionType;
    553        let provider = this.#providerFromSuggestionType(type);
    554        if (provider) {
    555          items.push({ feature, type, provider });
    556        }
    557      }
    558    }
    559    return items;
    560  }
    561 
    562  #init() {
    563    this.#store = this.#makeStore();
    564    if (!this.#store) {
    565      return;
    566    }
    567 
    568    // Log the last ingest time for debugging.
    569    let lastIngestSecs = Services.prefs.getIntPref(
    570      INGEST_TIMER_LAST_UPDATE_PREF,
    571      0
    572    );
    573    this.logger.debug("Last ingest time (seconds)", lastIngestSecs);
    574 
    575    // Add our shutdown blocker.
    576    this.#shutdownBlocker = () => {
    577      // Interrupt any ongoing ingests (WRITE) and queries (READ).
    578      // `interrupt()` runs on the main thread and is not async; see
    579      // toolkit/components/uniffi-bindgen-gecko-js/config.toml
    580      this.#store?.interrupt(lazy.InterruptKind.READ_WRITE);
    581 
    582      // Null the store so it's destroyed now instead of later when `this` is
    583      // collected. The store's Sqlite DBs are synced when dropped (its DB and
    584      // its RS client's DB), which causes a `LateWriteObserver` test failure if
    585      // it happens too late during shutdown.
    586      this.#store = null;
    587      this.#shutdownBlocker = null;
    588    };
    589    lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
    590      "QuickSuggest: Interrupt the Rust component",
    591      this.#shutdownBlocker
    592    );
    593 
    594    // Register the ingest timer.
    595    lazy.timerManager.registerTimer(
    596      INGEST_TIMER_ID,
    597      this,
    598      lazy.UrlbarPrefs.get("quicksuggest.rustIngestIntervalSeconds"),
    599      true // skipFirst
    600    );
    601 
    602    // Do an initial ingest for all enabled suggestion types. When a type
    603    // becomes enabled after this point, its `SuggestProvider` will update and
    604    // call `ingestEnabledSuggestions()`, which will be its initial ingest.
    605    this.#ingestAll();
    606 
    607    this.#migrateBlockedDigests().then(() => {
    608      // Now that the backend has finished initializing, send
    609      // `quicksuggest-dismissals-changed` to let consumers know that the
    610      // dismissal API is available. about:preferences relies on this to update
    611      // its "Restore" button if it's open at this time.
    612      Services.obs.notifyObservers(null, "quicksuggest-dismissals-changed");
    613    });
    614  }
    615 
    616  #makeStore() {
    617    this.logger.info("Creating SuggestStore");
    618    if (!this.#remoteSettingsService) {
    619      return null;
    620    }
    621 
    622    let builder;
    623    try {
    624      builder = lazy.SuggestStoreBuilder.init()
    625        .dataPath(this.#storeDataPath)
    626        .remoteSettingsService(this.#remoteSettingsService)
    627        .loadExtension(
    628          AppConstants.SQLITE_LIBRARY_FILENAME,
    629          "sqlite3_fts5_init"
    630        );
    631    } catch (error) {
    632      this.logger.error("Error creating SuggestStoreBuilder", error);
    633      return null;
    634    }
    635 
    636    let store;
    637    try {
    638      store = builder.build();
    639    } catch (error) {
    640      this.logger.error("Error creating SuggestStore", error);
    641      return null;
    642    }
    643 
    644    return store;
    645  }
    646 
    647  #uninit() {
    648    this.#store = null;
    649    this.#providerConstraintsOnLastIngestByFeature.clear();
    650    this.#configsBySuggestionType.clear();
    651    lazy.timerManager.unregisterTimer(INGEST_TIMER_ID);
    652 
    653    lazy.AsyncShutdown.profileChangeTeardown.removeBlocker(
    654      this.#shutdownBlocker
    655    );
    656    this.#shutdownBlocker = null;
    657  }
    658 
    659  /**
    660   * Ingests the given suggestion type.
    661   *
    662   * @param {object} options
    663   *   Options object.
    664   * @param {string} options.type
    665   *   A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
    666   *   "Wikipedia", "Mdn", etc.
    667   * @param {object|null} options.providerConstraints
    668   *   A plain JS object version of the type's provider constraints, if any.
    669   */
    670  #ingestSuggestionType({ type, providerConstraints }) {
    671    this.#ingestQueue.queueIdleCallback(async () => {
    672      if (!this.#store) {
    673        return;
    674      }
    675 
    676      let provider = this.#providerFromSuggestionType(type);
    677      if (!provider) {
    678        return;
    679      }
    680 
    681      this.logger.debug("Starting ingest", { type });
    682      try {
    683        const metrics = await this.#store.ingest(
    684          new lazy.SuggestIngestionConstraints({
    685            providers: [provider],
    686            providerConstraints: providerConstraints
    687              ? new lazy.SuggestionProviderConstraints(providerConstraints)
    688              : null,
    689          })
    690        );
    691        for (let { label, value } of metrics.downloadTimes) {
    692          Glean.suggest.ingestDownloadTime[label].accumulateSingleSample(value);
    693        }
    694        for (let { label, value } of metrics.ingestionTimes) {
    695          Glean.suggest.ingestTime[label].accumulateSingleSample(value);
    696        }
    697      } catch (error) {
    698        // Ingest can throw a `SuggestApiError` subclass called `Other` with a
    699        // `reason` message, which is very helpful for diagnosing problems with
    700        // remote settings data in tests in particular.
    701        this.logger.error("Ingest error", {
    702          type,
    703          error,
    704          reason: error.reason,
    705        });
    706      }
    707      this.logger.debug("Finished ingest", { type });
    708 
    709      if (!this.#store) {
    710        return;
    711      }
    712 
    713      // Fetch the provider config.
    714      this.logger.debug("Fetching provider config", { type });
    715      let config = await this.#store.fetchProviderConfig(provider);
    716      this.logger.debug("Got provider config", { type, config });
    717      this.#configsBySuggestionType.set(type, config);
    718      this.logger.debug("Finished fetching provider config", { type });
    719    });
    720  }
    721 
    722  #ingestAll() {
    723    // Ingest all enabled suggestion types.
    724    for (let feature of lazy.QuickSuggest.rustFeatures) {
    725      this.ingestEnabledSuggestions(feature, { evenIfFresh: true });
    726    }
    727 
    728    // Fetch the global config.
    729    this.#ingestQueue.queueIdleCallback(async () => {
    730      if (!this.#store) {
    731        return;
    732      }
    733      this.logger.debug("Fetching global config");
    734      this.#config = await this.#store.fetchGlobalConfig();
    735      this.logger.debug("Got global config", this.#config);
    736    });
    737  }
    738 
    739  /**
    740   * Given a Rust suggestion type, gets the integer value that identifies the
    741   * corresponding suggestion provider to Rust.
    742   *
    743   * @param {string} type
    744   *   A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
    745   *   "Wikipedia", "Mdn", etc.
    746   * @returns {number}
    747   *   An integer that identifies the provider of the suggestion type to Rust.
    748   */
    749  #providerFromSuggestionType(type) {
    750    let key = type.toUpperCase();
    751    if (!lazy.SuggestionProvider.hasOwnProperty(key)) {
    752      // Normally this shouldn't happen but it can during development when the
    753      // Rust component and desktop integration are out of sync.
    754      this.logger.error("SuggestionProvider[key] not defined!", { key });
    755      return null;
    756    }
    757    return lazy.SuggestionProvider[key];
    758  }
    759 
    760  /**
    761   * Dismissals are stored in the Rust component but were previously stored as
    762   * URL digests in a pref. This method migrates the pref to the Rust component
    763   * by registering each digest as a dismissal key in the Rust component. The
    764   * pref is cleared when the migration successfully finishes.
    765   */
    766  async #migrateBlockedDigests() {
    767    if (!this.#store) {
    768      return;
    769    }
    770 
    771    let pref = "browser.urlbar.quicksuggest.blockedDigests";
    772    this.logger.debug("Checking blockedDigests migration", { pref });
    773 
    774    let json;
    775    // eslint-disable-next-line mozilla/use-default-preference-values
    776    try {
    777      json = Services.prefs.getCharPref(pref);
    778    } catch (error) {
    779      if (error.result != Cr.NS_ERROR_UNEXPECTED) {
    780        throw error;
    781      }
    782      this.logger.debug(
    783        "blockedDigests pref does not exist, migration not necessary"
    784      );
    785      return;
    786    }
    787 
    788    await this.#migrateBlockedDigestsJson(json);
    789 
    790    // Don't clear the pref until migration finishes successfully, in case
    791    // there's some uncaught error. We don't want to lose the user's data.
    792    Services.prefs.clearUserPref(pref);
    793  }
    794 
    795  // This assumes `this.#store` is non-null!
    796  async #migrateBlockedDigestsJson(json) {
    797    let digests;
    798    try {
    799      digests = JSON.parse(json);
    800    } catch (error) {
    801      this.logger.debug("blockedDigests is not valid JSON, discarding it");
    802      return;
    803    }
    804 
    805    if (!digests) {
    806      this.logger.debug("blockedDigests is falsey, discarding it");
    807      return;
    808    }
    809 
    810    if (!Array.isArray(digests)) {
    811      this.logger.debug("blockedDigests is not an array, discarding it");
    812      return;
    813    }
    814 
    815    let promises = [];
    816    for (let digest of digests) {
    817      if (typeof digest != "string") {
    818        continue;
    819      }
    820      promises.push(this.#store.dismissByKey(digest));
    821    }
    822    await Promise.all(promises);
    823  }
    824 
    825  get _test_store() {
    826    return this.#store;
    827  }
    828 
    829  get _test_enabledSuggestionTypes() {
    830    return this.#enabledSuggestionTypes;
    831  }
    832 
    833  async _test_setRemoteSettingsService(remoteSettingsService) {
    834    this.#remoteSettingsService = remoteSettingsService;
    835    if (this.isEnabled) {
    836      // Recreate the store and re-ingest.
    837      Services.prefs.clearUserPref(INGEST_TIMER_LAST_UPDATE_PREF);
    838      this.#uninit();
    839      this.#init();
    840      await this.ingestPromise;
    841    }
    842  }
    843 
    844  async _test_ingest() {
    845    this.#ingestAll();
    846    await this.ingestPromise;
    847  }
    848 
    849  // The `SuggestStore` instance.
    850  #store;
    851 
    852  // Global Suggest config as returned from `SuggestStore.fetchGlobalConfig()`.
    853  #config = {};
    854 
    855  // Maps from suggestion type to provider config as returned from
    856  // `SuggestStore.fetchProviderConfig()`.
    857  #configsBySuggestionType = new Map();
    858 
    859  // Keeps track of features with fresh (non-stale) ingests. Maps
    860  // `SuggestProvider`s to their `rustProviderConstraints` on last ingest.
    861  #providerConstraintsOnLastIngestByFeature = new Map();
    862 
    863  #ingestQueue;
    864  #shutdownBlocker;
    865  #remoteSettingsService;
    866 }
    867 
    868 /**
    869 * Returns the type of a suggestion.
    870 *
    871 * @param {Suggestion} suggestion
    872 *   A suggestion object, an instance of one of the `Suggestion` subclasses.
    873 * @returns {string}
    874 *   The suggestion's type, e.g., "Amp", "Wikipedia", etc.
    875 */
    876 function getSuggestionType(suggestion) {
    877  // Suggestion objects served by the Rust component don't have any inherent
    878  // type information other than the classes they are instances of. There's no
    879  // `type` property, for example. There's a base `Suggestion` class and many
    880  // `Suggestion` subclasses, one per type of suggestion. Each suggestion object
    881  // is an instance of one of these subclasses. We derive a suggestion's type
    882  // from the subclass it's an instance of.
    883  //
    884  // Unfortunately the subclasses are all anonymous, which means
    885  // `suggestion.constructor.name` is always an empty string. (This is due to
    886  // how UniFFI generates JS bindings.) Instead, the subclasses are defined as
    887  // properties on the base `Suggestion` class. For example,
    888  // `Suggestion.Wikipedia` is the (anonymous) Wikipedia suggestion class. To
    889  // find a suggestion's subclass, we loop through the keys on `Suggestion`
    890  // until we find the value the suggestion is an instance of. To avoid doing
    891  // this every time, we cache the mapping from suggestion constructor to key
    892  // the first time we encounter a new suggestion subclass.
    893  let type = gSuggestionTypesByCtor.get(suggestion.constructor);
    894  if (!type) {
    895    type = Object.keys(lazy.Suggestion).find(
    896      key => suggestion instanceof lazy.Suggestion[key]
    897    );
    898    if (type) {
    899      gSuggestionTypesByCtor.set(suggestion.constructor, type);
    900    } else {
    901      console.error(
    902        "Unexpected error: Suggestion class not found on `Suggestion`. " +
    903          "Did the Rust component or its JS bindings change? ",
    904        { suggestion }
    905      );
    906    }
    907  }
    908  return type;
    909 }
    910 
    911 /**
    912 * The Rust component exports a custom UniFFI type called `JsonValue`, which is
    913 * just an alias of `serde_json::Value`. The type represents any value that can
    914 * be serialized as JSON, but UniFFI exports it as its JSON serialization rather
    915 * than the value itself. The UniFFI JS bindings don't currently deserialize the
    916 * JSON back to the underlying value, so we use this function to do it
    917 * ourselves. The process of converting the exported Rust value into a more
    918 * convenient JS representation is called "lifting".
    919 *
    920 * Currently dynamic suggestions are the only objects exported from the Rust
    921 * component that include a `JsonValue`.
    922 *
    923 * @param {Suggestion} suggestion
    924 *   A `Suggestion` instance from the Rust component.
    925 * @returns {Suggestion}
    926 *   If any properties of the suggestion need to be lifted, returns a new
    927 *   `Suggestion` that's a copy of it except the appropriate properties are
    928 *   lifted. Otherwise returns the passed-in suggestion itself.
    929 */
    930 function liftSuggestion(suggestion) {
    931  if (suggestion instanceof lazy.Suggestion.Dynamic) {
    932    let { data } = suggestion;
    933    if (typeof data == "string") {
    934      try {
    935        data = JSON.parse(data);
    936      } catch (error) {
    937        // This shouldn't ever happen since `suggestion.data` is serialized in
    938        // the Rust component and should therefore always be valid.
    939        return null;
    940      }
    941    }
    942    return new lazy.Suggestion.Dynamic({
    943      suggestionType: suggestion.suggestionType,
    944      data,
    945      dismissalKey: suggestion.dismissalKey,
    946      score: suggestion.score,
    947    });
    948  }
    949 
    950  return suggestion;
    951 }
    952 
    953 /**
    954 * This is the opposite of `liftSuggestion()`: It converts a lifted suggestion
    955 * object back to the value expected by the Rust component. This is only
    956 * necessary when passing a suggestion back in to the Rust component. This
    957 * process is called "lowering".
    958 *
    959 * @param {Suggestion|object} suggestion
    960 *   A suggestion object. Technically this can be a plain JS object or a
    961 *   `Suggestion` instance from the Rust component.
    962 * @returns {Suggestion}
    963 *   If any properties of the suggestion need to be lowered, returns a new
    964 *   `Suggestion` that's a copy of it except the appropriate properties are
    965 *   lowered. Otherwise returns the passed-in suggestion itself.
    966 */
    967 function lowerSuggestion(suggestion) {
    968  if (suggestion.provider == "Dynamic") {
    969    let { data } = suggestion;
    970    if (data !== null && data !== undefined) {
    971      data = JSON.stringify(data);
    972    }
    973    return new lazy.Suggestion.Dynamic({
    974      suggestionType: suggestion.suggestionType,
    975      data,
    976      dismissalKey: suggestion.dismissalKey,
    977      score: suggestion.score,
    978    });
    979  }
    980 
    981  return suggestion;
    982 }