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 }