UrlbarUtils.sys.mjs (118311B)
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 /** 6 * This module exports the UrlbarUtils singleton, which contains constants and 7 * helper functions that are useful to all components of the urlbar. 8 */ 9 10 /** 11 * @import {Query} from "UrlbarProvidersManager.sys.mjs" 12 * @import {UrlbarSearchStringTokenData} from "UrlbarTokenizer.sys.mjs" 13 */ 14 15 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 16 17 const lazy = {}; 18 19 ChromeUtils.defineESModuleGetters(lazy, { 20 ContextualIdentityService: 21 "resource://gre/modules/ContextualIdentityService.sys.mjs", 22 DEFAULT_FORM_HISTORY_PARAM: 23 "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs", 24 FormHistory: "resource://gre/modules/FormHistory.sys.mjs", 25 KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs", 26 PlacesUIUtils: "moz-src:///browser/components/places/PlacesUIUtils.sys.mjs", 27 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 28 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 29 SearchSuggestionController: 30 "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs", 31 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 32 UrlbarProviderInterventions: 33 "moz-src:///browser/components/urlbar/UrlbarProviderInterventions.sys.mjs", 34 UrlbarProviderOpenTabs: 35 "moz-src:///browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs", 36 UrlbarProviderSearchTips: 37 "moz-src:///browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs", 38 UrlbarSearchUtils: 39 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 40 UrlbarTokenizer: 41 "moz-src:///browser/components/urlbar/UrlbarTokenizer.sys.mjs", 42 BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs", 43 UrlUtils: "resource://gre/modules/UrlUtils.sys.mjs", 44 }); 45 46 XPCOMUtils.defineLazyServiceGetter( 47 lazy, 48 "parserUtils", 49 "@mozilla.org/parserutils;1", 50 Ci.nsIParserUtils 51 ); 52 53 export var UrlbarUtils = { 54 // Results are categorized into groups to help the muxer compose them. See 55 // UrlbarUtils.getResultGroup. Since result groups are stored in result 56 // groups and result groups are stored in prefs, additions and changes to 57 // result groups may require adding UI migrations to BrowserGlue. Be careful 58 // about making trivial changes to existing groups, like renaming them, 59 // because we don't want to make downgrades unnecessarily hard. 60 RESULT_GROUP: Object.freeze({ 61 ABOUT_PAGES: "aboutPages", 62 GENERAL: "general", 63 GENERAL_PARENT: "generalParent", 64 FORM_HISTORY: "formHistory", 65 HEURISTIC_AUTOFILL: "heuristicAutofill", 66 HEURISTIC_ENGINE_ALIAS: "heuristicEngineAlias", 67 HEURISTIC_EXTENSION: "heuristicExtension", 68 HEURISTIC_FALLBACK: "heuristicFallback", 69 HEURISTIC_BOOKMARK_KEYWORD: "heuristicBookmarkKeyword", 70 HEURISTIC_HISTORY_URL: "heuristicHistoryUrl", 71 HEURISTIC_OMNIBOX: "heuristicOmnibox", 72 HEURISTIC_RESTRICT_KEYWORD_AUTOFILL: "heuristicRestrictKeywordAutofill", 73 HEURISTIC_SEARCH_TIP: "heuristicSearchTip", 74 HEURISTIC_TEST: "heuristicTest", 75 HEURISTIC_TOKEN_ALIAS_ENGINE: "heuristicTokenAliasEngine", 76 INPUT_HISTORY: "inputHistory", 77 OMNIBOX: "extension", 78 RECENT_SEARCH: "recentSearch", 79 REMOTE_SUGGESTION: "remoteSuggestion", 80 REMOTE_TAB: "remoteTab", 81 RESTRICT_SEARCH_KEYWORD: "restrictSearchKeyword", 82 SUGGESTED_INDEX: "suggestedIndex", 83 TAIL_SUGGESTION: "tailSuggestion", 84 }), 85 86 // Defines provider types. 87 PROVIDER_TYPE: Object.freeze({ 88 // Should be executed immediately, because it returns heuristic results 89 // that must be handed to the user asap. 90 // WARNING: these providers must be extremely fast, because the urlbar will 91 // await for them before returning results to the user. In particular it is 92 // critical to reply quickly to isActive and startQuery. 93 HEURISTIC: 1, 94 // Can be delayed, contains results coming from the session or the profile. 95 PROFILE: 2, 96 // Can be delayed, contains results coming from the network. 97 NETWORK: 3, 98 // Can be delayed, contains results coming from unknown sources. 99 EXTENSION: 4, 100 }), 101 102 // Defines UrlbarResult types. 103 RESULT_TYPE: Object.freeze({ 104 // An open tab. 105 TAB_SWITCH: 1, 106 // A search suggestion or engine. 107 SEARCH: 2, 108 // A common url/title tuple, may be a bookmark with tags. 109 URL: 3, 110 // A bookmark keyword. 111 KEYWORD: 4, 112 // A WebExtension Omnibox result. 113 OMNIBOX: 5, 114 // A tab from another synced device. 115 REMOTE_TAB: 6, 116 // An actionable message to help the user with their query. 117 TIP: 7, 118 // A type of result which layout is defined at runtime. 119 DYNAMIC: 8, 120 // A restrict keyword result, could be @bookmarks, @history, or @tabs. 121 RESTRICT: 9, 122 123 // When you add a new type, also add its schema to 124 // UrlbarUtils.RESULT_PAYLOAD_SCHEMA below. Also consider checking if 125 // consumers of "urlbar-user-start-navigation" need updating. 126 }), 127 128 // This defines the source of results returned by a provider. Each provider 129 // can return results from more than one source. This is used by the 130 // ProvidersManager to decide which providers must be queried and which 131 // results can be returned. 132 // If you add new source types, consider checking if consumers of 133 // "urlbar-user-start-navigation" need update as well. 134 RESULT_SOURCE: Object.freeze({ 135 BOOKMARKS: 1, 136 HISTORY: 2, 137 SEARCH: 3, 138 TABS: 4, 139 OTHER_LOCAL: 5, 140 OTHER_NETWORK: 6, 141 ADDON: 7, 142 ACTIONS: 8, 143 }), 144 145 // Per-result exposure telemetry. 146 EXPOSURE_TELEMETRY: { 147 // Exposure telemetry will not be recorded for the result. 148 NONE: 0, 149 // Exposure telemetry will be recorded for the result and the result will be 150 // visible in the view as usual. 151 SHOWN: 1, 152 // Exposure telemetry will be recorded for the result but the result will 153 // not be present in the view. 154 HIDDEN: 2, 155 }, 156 157 // This defines icon locations that are commonly used in the UI. 158 ICON: { 159 // DEFAULT is defined lazily so it doesn't eagerly initialize PlacesUtils. 160 EXTENSION: "chrome://mozapps/skin/extensions/extension.svg", 161 HISTORY: "chrome://browser/skin/history.svg", 162 SEARCH_GLASS: "chrome://global/skin/icons/search-glass.svg", 163 TRENDING: "chrome://global/skin/icons/trending.svg", 164 TIP: "chrome://global/skin/icons/lightbulb.svg", 165 GLOBE: "chrome://global/skin/icons/defaultFavicon.svg", 166 }, 167 168 // The number of results by which Page Up/Down move the selection. 169 PAGE_UP_DOWN_DELTA: 5, 170 171 // IME composition states. 172 COMPOSITION: { 173 NONE: 1, 174 COMPOSING: 2, 175 COMMIT: 3, 176 CANCELED: 4, 177 }, 178 179 // Limit the length of titles and URLs we display so layout doesn't spend too 180 // much time building text runs. 181 MAX_TEXT_LENGTH: 255, 182 183 // Whether a result should be highlighted up to the point the user has typed 184 // or after that point. 185 HIGHLIGHT: Object.freeze({ 186 TYPED: 1, 187 SUGGESTED: 2, 188 ALL: 3, 189 }), 190 191 // UrlbarProviderPlaces's autocomplete results store their titles and tags 192 // together in their comments. This separator is used to separate them. 193 // After bug 1717511, we should stop using this old hack and store titles and 194 // tags separately. It's important that this be a character that no title 195 // would ever have. We use \x1F, the non-printable unit separator. 196 TITLE_TAGS_SEPARATOR: "\x1F", 197 198 // Regex matching single word hosts with an optional port; no spaces, auth or 199 // path-like chars are admitted. 200 REGEXP_SINGLE_WORD: /^[^\s@:/?#]+(:\d+)?$/, 201 202 // Valid entry points for search mode. If adding a value here, please update 203 // telemetry documentation and Scalars.yaml. 204 SEARCH_MODE_ENTRY: new Set([ 205 "bookmarkmenu", 206 "handoff", 207 "keywordoffer", 208 "oneoff", 209 "historymenu", 210 "other", 211 "searchbutton", 212 "shortcut", 213 "tabmenu", 214 "tabtosearch", 215 "tabtosearch_onboard", 216 "topsites_newtab", 217 "topsites_urlbar", 218 "touchbar", 219 "typed", 220 ]), 221 222 // The favicon service stores icons for URLs with the following protocols. 223 PROTOCOLS_WITH_ICONS: ["about:", "http:", "https:", "file:"], 224 225 // Valid URI schemes that are considered safe but don't contain 226 // an authority component (e.g host:port). There are many URI schemes 227 // that do not contain an authority, but these in particular have 228 // some likelihood of being entered or bookmarked by a user. 229 // `file:` is an exceptional case because an authority is optional 230 PROTOCOLS_WITHOUT_AUTHORITY: [ 231 "about:", 232 "data:", 233 "file:", 234 "javascript:", 235 "view-source:", 236 ], 237 238 // Search mode objects corresponding to the local shortcuts in the view, in 239 // order they appear. Pref names are relative to the `browser.urlbar` branch. 240 get LOCAL_SEARCH_MODES() { 241 return [ 242 { 243 source: this.RESULT_SOURCE.BOOKMARKS, 244 restrict: lazy.UrlbarTokenizer.RESTRICT.BOOKMARK, 245 icon: "chrome://browser/skin/bookmark.svg", 246 pref: "shortcuts.bookmarks", 247 telemetryLabel: "bookmarks", 248 uiLabel: "urlbar-searchmode-bookmarks", 249 }, 250 { 251 source: this.RESULT_SOURCE.TABS, 252 restrict: lazy.UrlbarTokenizer.RESTRICT.OPENPAGE, 253 icon: "chrome://browser/skin/tabs.svg", 254 pref: "shortcuts.tabs", 255 telemetryLabel: "tabs", 256 uiLabel: "urlbar-searchmode-tabs", 257 }, 258 { 259 source: this.RESULT_SOURCE.HISTORY, 260 restrict: lazy.UrlbarTokenizer.RESTRICT.HISTORY, 261 icon: "chrome://browser/skin/history.svg", 262 pref: "shortcuts.history", 263 telemetryLabel: "history", 264 uiLabel: "urlbar-searchmode-history", 265 }, 266 { 267 source: this.RESULT_SOURCE.ACTIONS, 268 restrict: lazy.UrlbarTokenizer.RESTRICT.ACTION, 269 icon: "chrome://browser/skin/quickactions.svg", 270 pref: "shortcuts.actions", 271 telemetryLabel: "actions", 272 uiLabel: "urlbar-searchmode-actions", 273 }, 274 ]; 275 }, 276 277 /** 278 * Returns the payload schema for the given type of result. 279 * 280 * @param {Values<typeof this.RESULT_TYPE>} type 281 * @returns {object} The schema for the given type. 282 */ 283 getPayloadSchema(type) { 284 return this.RESULT_PAYLOAD_SCHEMA[type]; 285 }, 286 287 /** 288 * Adds a url to history as long as it isn't in a private browsing window, 289 * and it is valid. 290 * 291 * @param {string} url The url to add to history. 292 * @param {nsIDOMWindow} window The window from where the url is being added. 293 */ 294 addToUrlbarHistory(url, window) { 295 if ( 296 !lazy.PrivateBrowsingUtils.isWindowPrivate(window) && 297 url && 298 !url.includes(" ") && 299 // eslint-disable-next-line no-control-regex 300 !/[\x00-\x1F]/.test(url) 301 ) { 302 lazy.PlacesUIUtils.markPageAsTyped(url); 303 } 304 }, 305 306 /** 307 * Given a string, will generate a more appropriate urlbar value if a Places 308 * keyword or a search alias is found at the beginning of it. 309 * 310 * @param {string} url 311 * A string that may begin with a keyword or an alias. 312 * 313 * @returns {Promise<{ url, postData, mayInheritPrincipal }>} 314 * If it's not possible to discern a keyword or an alias, url will be 315 * the input string. 316 */ 317 async getShortcutOrURIAndPostData(url) { 318 let mayInheritPrincipal = false; 319 let postData = null; 320 // Split on the first whitespace. 321 let [keyword, param = ""] = url.trim().split(/\s(.+)/, 2); 322 323 if (!keyword) { 324 return { url, postData, mayInheritPrincipal }; 325 } 326 327 /** @type {nsISearchEngine} */ 328 let engine = await Services.search.getEngineByAlias(keyword); 329 if (engine) { 330 let submission = engine.getSubmission(param, null); 331 return { 332 url: submission.uri.spec, 333 postData: submission.postData, 334 mayInheritPrincipal, 335 }; 336 } 337 338 // A corrupt Places database could make this throw, breaking navigation 339 // from the location bar. 340 let entry = null; 341 try { 342 entry = await lazy.PlacesUtils.keywords.fetch(keyword); 343 } catch (ex) { 344 console.error(`Unable to fetch Places keyword "${keyword}":`, ex); 345 } 346 if (!entry || !entry.url) { 347 // This is not a Places keyword. 348 return { url, postData, mayInheritPrincipal }; 349 } 350 351 try { 352 [url, postData] = await lazy.KeywordUtils.parseUrlAndPostData( 353 entry.url.href, 354 entry.postData, 355 param 356 ); 357 if (postData) { 358 postData = this.getPostDataStream(postData); 359 } 360 361 // Since this URL came from a bookmark, it's safe to let it inherit the 362 // current document's principal. 363 mayInheritPrincipal = true; 364 } catch (ex) { 365 // It was not possible to bind the param, just use the original url value. 366 } 367 368 return { url, postData, mayInheritPrincipal }; 369 }, 370 371 /** 372 * Returns an input stream wrapper for the given post data. 373 * 374 * @param {string} postDataString The string to wrap. 375 * @param {string} [type] The encoding type. 376 * @returns {nsIInputStream} An input stream of the wrapped post data. 377 */ 378 getPostDataStream( 379 postDataString, 380 type = "application/x-www-form-urlencoded" 381 ) { 382 let dataStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( 383 Ci.nsIStringInputStream 384 ); 385 dataStream.setByteStringData(postDataString); 386 387 let mimeStream = Cc[ 388 "@mozilla.org/network/mime-input-stream;1" 389 ].createInstance(Ci.nsIMIMEInputStream); 390 mimeStream.addHeader("Content-Type", type); 391 mimeStream.setData(dataStream); 392 return mimeStream.QueryInterface(Ci.nsIInputStream); 393 }, 394 395 _compareIgnoringDiacritics: null, 396 397 /** 398 * Returns a list of all the token substring matches in a string. Matching is 399 * case insensitive. Each match in the returned list is a tuple: [matchIndex, 400 * matchLength]. matchIndex is the index in the string of the match, and 401 * matchLength is the length of the match. 402 * 403 * @param {Array} tokens The tokens to search for. 404 * @param {string} str The string to match against. 405 * @param {Values<typeof this.HIGHLIGHT>} highlightType 406 * One of the HIGHLIGHT values: 407 * TYPED: match ranges matching the tokens; or 408 * SUGGESTED: match ranges for words not matching the tokens and the 409 * endings of words that start with a token. 410 * ALL: match all ranges of str. 411 * @returns {Array} An array: [ 412 * [matchIndex_0, matchLength_0], 413 * [matchIndex_1, matchLength_1], 414 * ... 415 * [matchIndex_n, matchLength_n] 416 * ]. 417 * The array is sorted by match indexes ascending. 418 */ 419 getTokenMatches(tokens, str, highlightType) { 420 if (highlightType == this.HIGHLIGHT.ALL) { 421 return [[0, str.length]]; 422 } 423 424 if (!tokens?.length) { 425 return []; 426 } 427 428 // Only search a portion of the string, because not more than a certain 429 // amount of characters are visible in the UI, matching over what is visible 430 // would be expensive and pointless. 431 str = str.substring(0, this.MAX_TEXT_LENGTH).toLocaleLowerCase(); 432 // To generate non-overlapping ranges, we start from a 0-filled array with 433 // the same length of the string, and use it as a collision marker, setting 434 // 1 where the text should be highlighted. 435 let hits = new Array(str.length).fill( 436 highlightType == this.HIGHLIGHT.SUGGESTED ? 1 : 0 437 ); 438 let compareIgnoringDiacritics; 439 for (let i = 0, totalTokensLength = 0; i < tokens.length; i++) { 440 const { lowerCaseValue: needle } = tokens[i]; 441 442 // Ideally we should never hit the empty token case, but just in case 443 // the `needle` check protects us from an infinite loop. 444 if (!needle) { 445 continue; 446 } 447 let index = 0; 448 let found = false; 449 // First try a diacritic-sensitive search. 450 for (;;) { 451 index = str.indexOf(needle, index); 452 if (index < 0) { 453 break; 454 } 455 456 if (highlightType == this.HIGHLIGHT.SUGGESTED) { 457 // We de-emphasize the match only if it's preceded by a space, thus 458 // it's a perfect match or the beginning of a longer word. 459 let previousSpaceIndex = str.lastIndexOf(" ", index) + 1; 460 if (index != previousSpaceIndex) { 461 index += needle.length; 462 // We found the token but we won't de-emphasize it, because it's not 463 // after a word boundary. 464 found = true; 465 continue; 466 } 467 } 468 469 hits.fill( 470 highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1, 471 index, 472 index + needle.length 473 ); 474 index += needle.length; 475 found = true; 476 } 477 // If that fails to match anything, try a (computationally intensive) 478 // diacritic-insensitive search. 479 if (!found) { 480 if (!compareIgnoringDiacritics) { 481 if (!this._compareIgnoringDiacritics) { 482 // Diacritic insensitivity in the search engine follows a set of 483 // general rules that are not locale-dependent, so use a generic 484 // English collator for highlighting matching words instead of a 485 // collator for the user's particular locale. 486 this._compareIgnoringDiacritics = new Intl.Collator("en", { 487 sensitivity: "base", 488 }).compare; 489 } 490 compareIgnoringDiacritics = this._compareIgnoringDiacritics; 491 } 492 index = 0; 493 while (index < str.length) { 494 let hay = str.substr(index, needle.length); 495 if (compareIgnoringDiacritics(needle, hay) === 0) { 496 if (highlightType == this.HIGHLIGHT.SUGGESTED) { 497 let previousSpaceIndex = str.lastIndexOf(" ", index) + 1; 498 if (index != previousSpaceIndex) { 499 index += needle.length; 500 continue; 501 } 502 } 503 hits.fill( 504 highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1, 505 index, 506 index + needle.length 507 ); 508 index += needle.length; 509 } else { 510 index++; 511 } 512 } 513 } 514 515 totalTokensLength += needle.length; 516 if (totalTokensLength > this.MAX_TEXT_LENGTH) { 517 // Limit the number of tokens to reduce calculate time. 518 break; 519 } 520 } 521 // Starting from the collision array, generate [start, len] tuples 522 // representing the ranges to be highlighted. 523 let ranges = []; 524 for (let index = hits.indexOf(1); index >= 0 && index < hits.length; ) { 525 let len = 0; 526 // eslint-disable-next-line no-empty 527 for (let j = index; j < hits.length && hits[j]; ++j, ++len) {} 528 ranges.push([index, len]); 529 // Move to the next 1. 530 index = hits.indexOf(1, index + len); 531 } 532 return ranges; 533 }, 534 535 /** 536 * Returns the group for a result. 537 * 538 * @param {UrlbarResult} result 539 * The result. 540 * @returns {Values<typeof this.RESULT_GROUP>} 541 * The result's group. 542 */ 543 getResultGroup(result) { 544 // Used for test_suggestedIndexRelativeToGroup.js to make it simpler 545 if (result.group) { 546 return result.group; 547 } 548 549 if (result.hasSuggestedIndex && !result.isSuggestedIndexRelativeToGroup) { 550 return this.RESULT_GROUP.SUGGESTED_INDEX; 551 } 552 if (result.heuristic) { 553 switch (result.providerName) { 554 case "UrlbarProviderAliasEngines": 555 return this.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS; 556 case "UrlbarProviderAutofill": 557 return this.RESULT_GROUP.HEURISTIC_AUTOFILL; 558 case "UrlbarProviderBookmarkKeywords": 559 return this.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD; 560 case "UrlbarProviderHeuristicFallback": 561 return this.RESULT_GROUP.HEURISTIC_FALLBACK; 562 case "UrlbarProviderHistoryUrlHeuristic": 563 return this.RESULT_GROUP.HEURISTIC_HISTORY_URL; 564 case "UrlbarProviderOmnibox": 565 return this.RESULT_GROUP.HEURISTIC_OMNIBOX; 566 case "UrlbarProviderRestrictKeywordsAutofill": 567 return this.RESULT_GROUP.HEURISTIC_RESTRICT_KEYWORD_AUTOFILL; 568 case "UrlbarProviderTokenAliasEngines": 569 return this.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE; 570 case "UrlbarProviderSearchTips": 571 return this.RESULT_GROUP.HEURISTIC_SEARCH_TIP; 572 default: 573 if (result.providerName.startsWith("TestProvider")) { 574 return this.RESULT_GROUP.HEURISTIC_TEST; 575 } 576 break; 577 } 578 if (result.providerType == this.PROVIDER_TYPE.EXTENSION) { 579 return this.RESULT_GROUP.HEURISTIC_EXTENSION; 580 } 581 console.error( 582 "Returning HEURISTIC_FALLBACK for unrecognized heuristic result: ", 583 result 584 ); 585 return this.RESULT_GROUP.HEURISTIC_FALLBACK; 586 } 587 588 switch (result.providerName) { 589 case "UrlbarProviderAboutPages": 590 return this.RESULT_GROUP.ABOUT_PAGES; 591 case "UrlbarProviderInputHistory": 592 return this.RESULT_GROUP.INPUT_HISTORY; 593 case "UrlbarProviderQuickSuggest": 594 return this.RESULT_GROUP.GENERAL_PARENT; 595 default: 596 break; 597 } 598 599 switch (result.type) { 600 case this.RESULT_TYPE.SEARCH: 601 if (result.source == this.RESULT_SOURCE.HISTORY) { 602 return result.providerName == "UrlbarProviderRecentSearches" 603 ? this.RESULT_GROUP.RECENT_SEARCH 604 : this.RESULT_GROUP.FORM_HISTORY; 605 } 606 if (result.payload.tail && !result.isRichSuggestion) { 607 return this.RESULT_GROUP.TAIL_SUGGESTION; 608 } 609 if (result.payload.suggestion) { 610 return this.RESULT_GROUP.REMOTE_SUGGESTION; 611 } 612 break; 613 case this.RESULT_TYPE.OMNIBOX: 614 return this.RESULT_GROUP.OMNIBOX; 615 case this.RESULT_TYPE.REMOTE_TAB: 616 return this.RESULT_GROUP.REMOTE_TAB; 617 case this.RESULT_TYPE.RESTRICT: 618 return this.RESULT_GROUP.RESTRICT_SEARCH_KEYWORD; 619 } 620 return this.RESULT_GROUP.GENERAL; 621 }, 622 623 /** 624 * Extracts the URL from a result. 625 * 626 * @param {UrlbarResult} result 627 * The result to extract from. 628 * @param {object} options 629 * Options object. 630 * @param {HTMLElement} [options.element] 631 * The element associated with the result that was selected or picked, if 632 * available. For results that have multiple selectable children, the URL 633 * may be taken from a child element rather than the result. 634 * @returns {object} 635 * An object: `{ url, postData }` 636 * `url` will be null if the result doesn't have a URL. `postData` will be 637 * null if the result doesn't have post data. 638 */ 639 getUrlFromResult(result, { element = null } = {}) { 640 if ( 641 result.payload.engine && 642 (result.type == this.RESULT_TYPE.SEARCH || 643 result.type == this.RESULT_TYPE.DYNAMIC) 644 ) { 645 let query = 646 element?.dataset.query || 647 result.payload.suggestion || 648 result.payload.query; 649 if (query) { 650 const engine = Services.search.getEngineByName(result.payload.engine); 651 let [url, postData] = this.getSearchQueryUrl(engine, query); 652 return { url, postData }; 653 } 654 } 655 656 return { 657 url: result.payload.url ?? null, 658 postData: result.payload.postData 659 ? this.getPostDataStream(result.payload.postData) 660 : null, 661 }; 662 }, 663 664 /** 665 * Get the url to load for the search query. 666 * 667 * @param {nsISearchEngine} engine 668 * The engine to generate the query for. 669 * @param {string} query 670 * The query string to search for. 671 * @returns {Array} 672 * Returns an array containing the query url (string) and the 673 * post data (object). 674 */ 675 getSearchQueryUrl(engine, query) { 676 let submission = engine.getSubmission(query); 677 return [submission.uri.spec, submission.postData]; 678 }, 679 680 /** 681 * Ranks a URL prefix from 3 - 0 with the following preferences: 682 * https:// > https://www. > http:// > http://www. 683 * Higher is better for the purposes of deduping URLs. 684 * Returns -1 if the prefix does not match any of the above. 685 * 686 * @param {string} prefix 687 */ 688 getPrefixRank(prefix) { 689 return ["http://www.", "http://", "https://www.", "https://"].indexOf( 690 prefix 691 ); 692 }, 693 694 /** 695 * Gets the number of rows a result should span in the view. 696 * 697 * @param {UrlbarResult} result 698 * The result. 699 * @param {object} [options] 700 * @param {boolean} [options.includeHiddenExposures] 701 * Whether a span should be returned if the result is a hidden exposure. If 702 * false and `result.isHiddenExposure` is true, zero will be returned since 703 * the result should be hidden and not take up any rows at all. Otherwise 704 * the result's true span is returned. 705 * @returns {number} 706 * The number of rows the result should span in the view. 707 */ 708 getSpanForResult(result, { includeHiddenExposures = false } = {}) { 709 if (!includeHiddenExposures && result.isHiddenExposure) { 710 return 0; 711 } 712 713 if (result.resultSpan) { 714 return result.resultSpan; 715 } 716 717 switch (result.type) { 718 case this.RESULT_TYPE.TIP: 719 return 3; 720 } 721 return 1; 722 }, 723 724 /** 725 * Gets a default icon for a URL. 726 * 727 * @param {string|URL} url 728 * The URL to get the icon for. 729 * @returns {string} A URI pointing to an icon for `url`. 730 */ 731 getIconForUrl(url) { 732 if (typeof url == "string") { 733 return this.PROTOCOLS_WITH_ICONS.some(p => url.startsWith(p)) 734 ? "page-icon:" + url 735 : this.ICON.DEFAULT; 736 } 737 if ( 738 URL.isInstance(url) && 739 this.PROTOCOLS_WITH_ICONS.includes(url.protocol) 740 ) { 741 return "page-icon:" + url.href; 742 } 743 return this.ICON.DEFAULT; 744 }, 745 746 /** 747 * Tries to initiate a speculative connection to a given url. 748 * 749 * Note: This is not infallible, if a speculative connection cannot be 750 * initialized, it will be a no-op. 751 * 752 * @param {nsISearchEngine|nsIURI|URL|string} urlOrEngine entity to initiate 753 * a speculative connection for. 754 * @param {window} window the window from where the connection is initialized. 755 */ 756 setupSpeculativeConnection(urlOrEngine, window) { 757 if (!lazy.UrlbarPrefs.get("speculativeConnect.enabled")) { 758 return; 759 } 760 if (urlOrEngine instanceof Ci.nsISearchEngine) { 761 try { 762 urlOrEngine.speculativeConnect({ 763 window, 764 originAttributes: window.gBrowser.contentPrincipal.originAttributes, 765 }); 766 } catch (ex) { 767 // Can't setup speculative connection for this url, just ignore it. 768 } 769 return; 770 } 771 772 if (URL.isInstance(urlOrEngine)) { 773 urlOrEngine = urlOrEngine.href; 774 } 775 776 try { 777 let uri = 778 urlOrEngine instanceof Ci.nsIURI 779 ? urlOrEngine 780 : Services.io.newURI(urlOrEngine); 781 Services.io.speculativeConnect( 782 uri, 783 window.gBrowser.contentPrincipal, 784 window.docShell.QueryInterface(Ci.nsIInterfaceRequestor), 785 false 786 ); 787 } catch (ex) { 788 // Can't setup speculative connection for this url, just ignore it. 789 } 790 }, 791 792 /** 793 * Splits a url into base and ref strings, according to nsIURI.idl. 794 * Base refers to the part of the url before the ref, excluding the #. 795 * 796 * @param {string} url 797 * The url to split. 798 * @returns {object} { base, ref } 799 * Base and ref parts of the given url. Ref is an empty string 800 * if there is no ref and undefined if url is not well-formed. 801 */ 802 extractRefFromUrl(url) { 803 let uri = URL.parse(url)?.URI; 804 if (uri) { 805 return { base: uri.specIgnoringRef, ref: uri.ref }; 806 } 807 return { base: url }; 808 }, 809 810 /** 811 * Strips parts of a URL defined in `options`. 812 * 813 * @param {string} spec 814 * The text to modify. 815 * @param {object} [options] 816 * The options object. 817 * @param {boolean} [options.stripHttp] 818 * Whether to strip http. 819 * @param {boolean} [options.stripHttps] 820 * Whether to strip https. 821 * @param {boolean} [options.stripWww] 822 * Whether to strip `www.`. 823 * @param {boolean} [options.trimSlash] 824 * Whether to trim the trailing slash. 825 * @param {boolean} [options.trimEmptyQuery] 826 * Whether to trim a trailing `?`. 827 * @param {boolean} [options.trimEmptyHash] 828 * Whether to trim a trailing `#`. 829 * @param {boolean} [options.trimTrailingDot] 830 * Whether to trim a trailing '.'. 831 * @returns {string[]} [modified, prefix, suffix] 832 * modified: {string} The modified spec. 833 * prefix: {string} The parts stripped from the prefix, if any. 834 * suffix: {string} The parts trimmed from the suffix, if any. 835 */ 836 stripPrefixAndTrim(spec, options = {}) { 837 let prefix = ""; 838 let suffix = ""; 839 if (options.stripHttp && spec.startsWith("http://")) { 840 spec = spec.slice(7); 841 prefix = "http://"; 842 } else if (options.stripHttps && spec.startsWith("https://")) { 843 spec = spec.slice(8); 844 prefix = "https://"; 845 } 846 if (options.stripWww && spec.startsWith("www.")) { 847 spec = spec.slice(4); 848 prefix += "www."; 849 } 850 if (options.trimEmptyHash && spec.endsWith("#")) { 851 spec = spec.slice(0, -1); 852 suffix = "#" + suffix; 853 } 854 if (options.trimEmptyQuery && spec.endsWith("?")) { 855 spec = spec.slice(0, -1); 856 suffix = "?" + suffix; 857 } 858 if (options.trimSlash && spec.endsWith("/")) { 859 spec = spec.slice(0, -1); 860 suffix = "/" + suffix; 861 } 862 if (options.trimTrailingDot && spec.endsWith(".")) { 863 spec = spec.slice(0, -1); 864 suffix = "." + suffix; 865 } 866 return [spec, prefix, suffix]; 867 }, 868 869 /** 870 * Strips a PSL verified public suffix from an hostname. 871 * 872 * Note: Because stripping the full suffix requires to verify it against the 873 * Public Suffix List, this call is not the cheapest, and thus it should 874 * not be used in hot paths. 875 * 876 * @param {string} host A host name. 877 * @returns {string} Host name without the public suffix. 878 */ 879 stripPublicSuffixFromHost(host) { 880 try { 881 return host.substring( 882 0, 883 host.length - Services.eTLD.getKnownPublicSuffixFromHost(host).length 884 ); 885 } catch (ex) { 886 if (ex.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS) { 887 throw ex; 888 } 889 } 890 return host; 891 }, 892 893 /** 894 * Used to filter out the javascript protocol from URIs, since we don't 895 * support LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL for those. 896 * 897 * @param {string} pasteData The data to check for javacript protocol. 898 * @returns {string} The modified paste data. 899 */ 900 stripUnsafeProtocolOnPaste(pasteData) { 901 for (;;) { 902 let scheme = ""; 903 try { 904 scheme = Services.io.extractScheme(pasteData); 905 } catch (ex) { 906 // If it throws, this is not a javascript scheme. 907 } 908 if (scheme != "javascript") { 909 break; 910 } 911 912 pasteData = pasteData.substring(pasteData.indexOf(":") + 1); 913 } 914 return pasteData; 915 }, 916 917 /** 918 * Add a (url, input) tuple to the input history table that drives adaptive 919 * results. 920 * 921 * @param {string} url The url to add input history for 922 * @param {string} input The associated search term 923 */ 924 async addToInputHistory(url, input) { 925 await lazy.PlacesUtils.withConnectionWrapper("addToInputHistory", db => { 926 // use_count will asymptotically approach the max of 10. 927 return db.executeCached( 928 ` 929 INSERT OR REPLACE INTO moz_inputhistory 930 SELECT h.id, IFNULL(i.input, :input), IFNULL(i.use_count, 0) * .9 + 1 931 FROM moz_places h 932 LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input 933 WHERE url_hash = hash(:url) AND url = :url 934 `, 935 { url, input: input.toLowerCase() } 936 ); 937 }); 938 }, 939 940 /** 941 * Remove a (url, input*) tuple from the input history table that drives 942 * adaptive results. 943 * Note the input argument is used as a wildcard so any match starting with 944 * it will also be removed. 945 * 946 * @param {string} url The url to add input history for 947 * @param {string} input The associated search term 948 */ 949 async removeInputHistory(url, input) { 950 await lazy.PlacesUtils.withConnectionWrapper("removeInputHistory", db => { 951 return db.executeCached( 952 ` 953 DELETE FROM moz_inputhistory 954 WHERE input BETWEEN :input AND :input || X'FFFF' 955 AND place_id = 956 (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) 957 `, 958 { url, input: input.toLowerCase() } 959 ); 960 }); 961 }, 962 963 /** 964 * Whether the passed-in input event is paste event. 965 * 966 * @param {InputEvent} event an input DOM event. 967 * @returns {boolean} Whether the event is a paste event. 968 */ 969 isPasteEvent(event) { 970 return ( 971 event.inputType && 972 (event.inputType.startsWith("insertFromPaste") || 973 event.inputType == "insertFromYank") 974 ); 975 }, 976 977 /** 978 * Given a string, checks if it looks like a single word host, not containing 979 * spaces nor dots (apart from a possible trailing one). 980 * 981 * Note: This matching should stay in sync with the related code in 982 * URIFixup::KeywordURIFixup 983 * 984 * @param {string} value 985 * The string to check. 986 * @returns {boolean} 987 * Whether the value looks like a single word host. 988 */ 989 looksLikeSingleWordHost(value) { 990 let str = value.trim(); 991 return this.REGEXP_SINGLE_WORD.test(str); 992 }, 993 994 /** 995 * Returns the portion of a string starting at the index where another string 996 * begins. 997 * 998 * @param {string} sourceStr 999 * The string to search within. 1000 * @param {string} targetStr 1001 * The string to search for. 1002 * @returns {string} The substring within sourceStr starting at targetStr, or 1003 * the empty string if targetStr does not occur in sourceStr. 1004 */ 1005 substringAt(sourceStr, targetStr) { 1006 let index = sourceStr.indexOf(targetStr); 1007 return index < 0 ? "" : sourceStr.substr(index); 1008 }, 1009 1010 /** 1011 * Returns the portion of a string starting at the index where another string 1012 * ends. 1013 * 1014 * @param {string} sourceStr 1015 * The string to search within. 1016 * @param {string} targetStr 1017 * The string to search for. 1018 * @returns {string} The substring within sourceStr where targetStr ends, or 1019 * the empty string if targetStr does not occur in sourceStr. 1020 */ 1021 substringAfter(sourceStr, targetStr) { 1022 let index = sourceStr.indexOf(targetStr); 1023 return index < 0 ? "" : sourceStr.substr(index + targetStr.length); 1024 }, 1025 1026 /** 1027 * Strips the prefix from a URL and returns the prefix and the remainder of 1028 * the URL. "Prefix" is defined to be the scheme and colon plus zero to two 1029 * slashes (see `UrlbarTokenizer.REGEXP_PREFIX`). If the given string is not 1030 * actually a URL or it has a prefix we don't recognize, then an empty prefix 1031 * and the string itself is returned. 1032 * 1033 * @param {string} str The possible URL to strip. 1034 * @returns {Array} If `str` is a URL with a prefix we recognize, 1035 * then [prefix, remainder]. Otherwise, ["", str]. 1036 */ 1037 stripURLPrefix(str) { 1038 let match = lazy.UrlUtils.REGEXP_PREFIX.exec(str); 1039 if (!match) { 1040 return ["", str]; 1041 } 1042 let prefix = match[0]; 1043 if (prefix.length < str.length && str[prefix.length] == " ") { 1044 // A space following a prefix: 1045 // e.g. "http:// some search string", "about: some search string" 1046 return ["", str]; 1047 } 1048 if ( 1049 prefix.endsWith(":") && 1050 !this.PROTOCOLS_WITHOUT_AUTHORITY.includes(prefix.toLowerCase()) 1051 ) { 1052 // Something that looks like a URI scheme but we won't treat as one: 1053 // e.g. "localhost:8888" 1054 return ["", str]; 1055 } 1056 return [prefix, str.substring(prefix.length)]; 1057 }, 1058 1059 /** 1060 * Runs a search for the given string, and returns the heuristic result. 1061 * 1062 * @param {string} searchString The string to search for. 1063 * @param {UrlbarInput} urlbarInput The input requesting it. 1064 * @returns {Promise<UrlbarResult>} an heuristic result. 1065 */ 1066 async getHeuristicResultFor(searchString, urlbarInput) { 1067 if (!searchString) { 1068 throw new Error("Must pass a non-null search string"); 1069 } 1070 1071 let gBrowser = urlbarInput.window.gBrowser; 1072 let options = { 1073 allowAutofill: false, 1074 isPrivate: urlbarInput.isPrivate, 1075 sapName: urlbarInput.sapName, 1076 maxResults: 1, 1077 searchString, 1078 userContextId: parseInt( 1079 gBrowser.selectedBrowser.getAttribute("usercontextid") || 0 1080 ), 1081 tabGroup: gBrowser.selectedTab.group?.id ?? null, 1082 prohibitRemoteResults: true, 1083 providers: [ 1084 "UrlbarProviderAliasEngines", 1085 "UrlbarProviderBookmarkKeywords", 1086 "UrlbarProviderHeuristicFallback", 1087 ], 1088 }; 1089 if (urlbarInput.searchMode) { 1090 let searchMode = urlbarInput.searchMode; 1091 options.searchMode = searchMode; 1092 if (searchMode.source) { 1093 options.sources = [searchMode.source]; 1094 } 1095 } 1096 let context = new UrlbarQueryContext(options); 1097 await urlbarInput.controller.manager.startQuery(context); 1098 if (!context.heuristicResult) { 1099 throw new Error("There should always be an heuristic result"); 1100 } 1101 return context.heuristicResult; 1102 }, 1103 1104 /** 1105 * Creates a console logger. 1106 * Logging level can be controlled through the `browser.urlbar.loglevel` 1107 * preference. 1108 * 1109 * @param {object} [options] Options for the logger. 1110 * @param {string} [options.prefix] Prefix to use for the logged messages. 1111 * @returns {ConsoleInstance} The console logger. 1112 */ 1113 getLogger({ prefix = "" } = {}) { 1114 if (!this._loggers) { 1115 this._loggers = new Map(); 1116 } 1117 let logger = this._loggers.get(prefix); 1118 if (!logger) { 1119 logger = console.createInstance({ 1120 prefix: `URLBar${prefix ? " - " + prefix : ""}`, 1121 maxLogLevelPref: "browser.urlbar.loglevel", 1122 }); 1123 this._loggers.set(prefix, logger); 1124 } 1125 return logger; 1126 }, 1127 1128 /** 1129 * Returns the name of a result source. The name is the lowercase name of the 1130 * corresponding property in the RESULT_SOURCE object. 1131 * 1132 * @param {Values<typeof this.RESULT_SOURCE>} source 1133 * A UrlbarUtils.RESULT_SOURCE value. 1134 * @returns {string} 1135 * The token's name, a lowercased name in the RESULT_SOURCE object. 1136 */ 1137 getResultSourceName(source) { 1138 if (!this._resultSourceNamesBySource) { 1139 this._resultSourceNamesBySource = new Map(); 1140 for (let [name, src] of Object.entries(this.RESULT_SOURCE)) { 1141 this._resultSourceNamesBySource.set(src, name.toLowerCase()); 1142 } 1143 } 1144 return this._resultSourceNamesBySource.get(source); 1145 }, 1146 1147 /** 1148 * Add the search to form history. This also updates any existing form 1149 * history for the search. 1150 * 1151 * @param {UrlbarInput} input The UrlbarInput object requesting the addition. 1152 * @param {string} value The value to add. 1153 * @param {string} [source] The source of the addition, usually 1154 * the name of the engine the search was made with. 1155 * @returns {Promise<void>} resolved once the operation is complete 1156 */ 1157 addToFormHistory(input, value, source) { 1158 // If the user types a search engine alias without a search string, 1159 // we have an empty search string and we can't bump it. 1160 // We also don't want to add history in private browsing mode. 1161 // Finally we don't want to store extremely long strings that would not be 1162 // particularly useful to the user. 1163 if ( 1164 !value || 1165 input.isPrivate || 1166 value.length > 1167 lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH 1168 ) { 1169 return Promise.resolve(); 1170 } 1171 return lazy.FormHistory.update({ 1172 op: "bump", 1173 fieldname: lazy.DEFAULT_FORM_HISTORY_PARAM, 1174 value, 1175 source, 1176 }); 1177 }, 1178 1179 /** 1180 * Returns whether a URL can be autofilled from a candidate string. This 1181 * function is specifically designed for origin and up-to-the-next-slash URL 1182 * autofill. It should not be used for other types of autofill. 1183 * 1184 * @param {string} urlString 1185 * The URL to test 1186 * @param {string} candidateString 1187 * The candidate string to test against 1188 * @param {boolean} [checkFragmentOnly] 1189 * If want to check the fragment only, pass true. 1190 * Otherwise, check whole url. 1191 * @returns {boolean} true: can autofill 1192 */ 1193 canAutofillURL(urlString, candidateString, checkFragmentOnly = false) { 1194 // If the URL does not start with the candidate, it can't be autofilled. 1195 // The length check is an optimization to short-circuit the `startsWith()`. 1196 if ( 1197 !checkFragmentOnly && 1198 (urlString.length <= candidateString.length || 1199 !urlString 1200 .toLocaleLowerCase() 1201 .startsWith(candidateString.toLocaleLowerCase())) 1202 ) { 1203 return false; 1204 } 1205 1206 // Create `URL` objects to make the logic below easier. The strings must 1207 // include schemes for this to work. 1208 if (!lazy.UrlUtils.REGEXP_PREFIX.test(urlString)) { 1209 urlString = "http://" + urlString; 1210 } 1211 if (!lazy.UrlUtils.REGEXP_PREFIX.test(candidateString)) { 1212 candidateString = "http://" + candidateString; 1213 } 1214 1215 let url = URL.parse(urlString); 1216 let candidate = URL.parse(candidateString); 1217 if (!url || !candidate) { 1218 return false; 1219 } 1220 1221 if (checkFragmentOnly) { 1222 return url.hash.startsWith(candidate.hash); 1223 } 1224 1225 // For both origin and URL autofill, autofill should stop when the user 1226 // types a trailing slash. This is a fundamental part of autofill's 1227 // up-to-the-next-slash behavior. We handle that here in the else-if branch. 1228 // The length and hash checks in the else-if condition aren't strictly 1229 // necessary -- the else-if branch could simply be an else-branch that 1230 // returns false -- but they mean this function will return true when the 1231 // URL and candidate have the same case-insenstive path and no hash. In 1232 // other words, we allow a URL to autofill itself. 1233 if (!candidate.href.endsWith("/")) { 1234 // The candidate doesn't end in a slash. The URL can't be autofilled if 1235 // its next slash is not at the end. 1236 let nextSlashIndex = url.pathname.indexOf("/", candidate.pathname.length); 1237 if (nextSlashIndex >= 0 && nextSlashIndex != url.pathname.length - 1) { 1238 return false; 1239 } 1240 } else if (url.pathname.length > candidate.pathname.length || url.hash) { 1241 return false; 1242 } 1243 1244 return url.hash.startsWith(candidate.hash); 1245 }, 1246 1247 /** 1248 * Extracts a telemetry type from a result, used by scalars and event 1249 * telemetry. 1250 * 1251 * @param {UrlbarResult} result The result to analyze. 1252 * @returns {string} A string type for telemetry. 1253 */ 1254 telemetryTypeFromResult(result) { 1255 if (!result) { 1256 return "unknown"; 1257 } 1258 switch (result.type) { 1259 case this.RESULT_TYPE.TAB_SWITCH: 1260 return "switchtab"; 1261 case this.RESULT_TYPE.SEARCH: 1262 if (result.providerName == "UrlbarProviderRecentSearches") { 1263 return "recent_search"; 1264 } 1265 if (result.source == this.RESULT_SOURCE.HISTORY) { 1266 return "formhistory"; 1267 } 1268 if (result.providerName == "UrlbarProviderTabToSearch") { 1269 return "tabtosearch"; 1270 } 1271 if (result.payload.suggestion) { 1272 let type = result.payload.trending ? "trending" : "searchsuggestion"; 1273 if (result.isRichSuggestion) { 1274 type += "_rich"; 1275 } 1276 return type; 1277 } 1278 return "searchengine"; 1279 case this.RESULT_TYPE.URL: 1280 if (result.autofill) { 1281 let { type } = result.autofill; 1282 if (!type) { 1283 type = "other"; 1284 console.error( 1285 new Error( 1286 "`result.autofill.type` not set, falling back to 'other'" 1287 ) 1288 ); 1289 } 1290 return `autofill_${type}`; 1291 } 1292 if ( 1293 result.source == this.RESULT_SOURCE.OTHER_LOCAL && 1294 result.heuristic 1295 ) { 1296 return "visiturl"; 1297 } 1298 if (result.providerName == "UrlbarProviderQuickSuggest") { 1299 return "quicksuggest"; 1300 } 1301 if (result.providerName == "UrlbarProviderClipboard") { 1302 return "clipboard"; 1303 } 1304 { 1305 let type = 1306 result.source == this.RESULT_SOURCE.BOOKMARKS 1307 ? "bookmark" 1308 : "history"; 1309 if (result.providerName == "UrlbarProviderInputHistory") { 1310 return type + "adaptive"; 1311 } 1312 return type; 1313 } 1314 case this.RESULT_TYPE.KEYWORD: 1315 return "keyword"; 1316 case this.RESULT_TYPE.OMNIBOX: 1317 return "extension"; 1318 case this.RESULT_TYPE.REMOTE_TAB: 1319 return "remotetab"; 1320 case this.RESULT_TYPE.TIP: 1321 return "tip"; 1322 case this.RESULT_TYPE.DYNAMIC: 1323 if (result.providerName == "UrlbarProviderTabToSearch") { 1324 // This is the onboarding result. 1325 return "tabtosearch"; 1326 } 1327 return "dynamic"; 1328 case this.RESULT_TYPE.RESTRICT: 1329 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.BOOKMARK) { 1330 return "restrict_keyword_bookmarks"; 1331 } 1332 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.OPENPAGE) { 1333 return "restrict_keyword_tabs"; 1334 } 1335 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.HISTORY) { 1336 return "restrict_keyword_history"; 1337 } 1338 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.ACTION) { 1339 return "restrict_keyword_actions"; 1340 } 1341 } 1342 return "unknown"; 1343 }, 1344 1345 /** 1346 * Unescape the given uri to use as UI. 1347 * NOTE: If the length of uri is over MAX_TEXT_LENGTH, 1348 * return the given uri as it is. 1349 * 1350 * @param {string} uri will be unescaped. 1351 * @returns {string} Unescaped uri. 1352 */ 1353 unEscapeURIForUI(uri) { 1354 return uri.length > this.MAX_TEXT_LENGTH 1355 ? uri 1356 : Services.textToSubURI.unEscapeURIForUI(uri); 1357 }, 1358 1359 /** 1360 * Checks whether a given text has right-to-left direction or not. 1361 * 1362 * @param {string} value The text which should be check for RTL direction. 1363 * @param {Window} window The window where 'value' is going to be displayed. 1364 * @returns {boolean} Returns true if text has right-to-left direction and 1365 * false otherwise. 1366 */ 1367 isTextDirectionRTL(value, window) { 1368 let directionality = window.windowUtils.getDirectionFromText(value); 1369 return directionality == window.windowUtils.DIRECTION_RTL; 1370 }, 1371 1372 /** 1373 * Unescape, decode punycode, and trim (both protocol and trailing slash) 1374 * the URL. Use for displaying purposes only! 1375 * 1376 * @param {string|URL} url The url that should be prepared for display. 1377 * @param {object} [options] Preparation options. 1378 * @param {boolean} [options.trimURL] Whether the displayed URL should be 1379 * trimmed or not. 1380 * @param {boolean} [options.schemeless] Trim `http(s)://`. 1381 * @returns {string} Prepared url. 1382 */ 1383 prepareUrlForDisplay(url, { trimURL = true, schemeless = false } = {}) { 1384 // Some domains are encoded in punycode. The following ensures we display 1385 // the url in utf-8. 1386 let displayString; 1387 if (typeof url == "string") { 1388 try { 1389 displayString = new URL(url).URI.displaySpec; 1390 } catch { 1391 // In some cases url is not a valid url, so we fallback to using the 1392 // string as-is. 1393 displayString = url; 1394 } 1395 } else { 1396 displayString = url.URI.displaySpec; 1397 } 1398 1399 if (displayString) { 1400 if (schemeless) { 1401 displayString = this.stripPrefixAndTrim(displayString, { 1402 stripHttp: true, 1403 stripHttps: true, 1404 })[0]; 1405 } else if (trimURL && lazy.UrlbarPrefs.get("trimURLs")) { 1406 displayString = 1407 lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(displayString); 1408 if (displayString.startsWith("https://")) { 1409 displayString = displayString.substring(8); 1410 if (displayString.startsWith("www.")) { 1411 displayString = displayString.substring(4); 1412 } 1413 } 1414 } 1415 } 1416 1417 return this.unEscapeURIForUI(displayString); 1418 }, 1419 1420 /** 1421 * Extracts a group for search engagement telemetry from a result. 1422 * 1423 * @param {UrlbarResult} result The result to analyze. 1424 * @returns {string} Group name as string. 1425 */ 1426 searchEngagementTelemetryGroup(result) { 1427 if (!result) { 1428 return "unknown"; 1429 } 1430 if (result.isBestMatch) { 1431 return "top_pick"; 1432 } 1433 if (result.providerName === "UrlbarProviderTopSites") { 1434 return "top_site"; 1435 } 1436 1437 switch (this.getResultGroup(result)) { 1438 case this.RESULT_GROUP.INPUT_HISTORY: { 1439 return "adaptive_history"; 1440 } 1441 case this.RESULT_GROUP.RECENT_SEARCH: { 1442 return "recent_search"; 1443 } 1444 case this.RESULT_GROUP.FORM_HISTORY: { 1445 return "search_history"; 1446 } 1447 case this.RESULT_GROUP.TAIL_SUGGESTION: 1448 case this.RESULT_GROUP.REMOTE_SUGGESTION: { 1449 let group = result.payload.trending 1450 ? "trending_search" 1451 : "search_suggest"; 1452 if (result.isRichSuggestion) { 1453 group += "_rich"; 1454 } 1455 return group; 1456 } 1457 case this.RESULT_GROUP.REMOTE_TAB: { 1458 return "remote_tab"; 1459 } 1460 case this.RESULT_GROUP.HEURISTIC_EXTENSION: 1461 case this.RESULT_GROUP.HEURISTIC_OMNIBOX: 1462 case this.RESULT_GROUP.OMNIBOX: { 1463 return "addon"; 1464 } 1465 case this.RESULT_GROUP.GENERAL: { 1466 return "general"; 1467 } 1468 // Group of UrlbarProviderQuickSuggest is GENERAL_PARENT. 1469 case this.RESULT_GROUP.GENERAL_PARENT: { 1470 return "suggest"; 1471 } 1472 case this.RESULT_GROUP.ABOUT_PAGES: { 1473 return "about_page"; 1474 } 1475 case this.RESULT_GROUP.SUGGESTED_INDEX: { 1476 return "suggested_index"; 1477 } 1478 case this.RESULT_GROUP.RESTRICT_SEARCH_KEYWORD: { 1479 return "restrict_keyword"; 1480 } 1481 } 1482 1483 return result.heuristic ? "heuristic" : "unknown"; 1484 }, 1485 1486 /** 1487 * Extracts a type for search engagement telemetry from a result. 1488 * 1489 * @param {UrlbarResult} result The result to analyze. 1490 * @param {string} [selType] An optional parameter for the selected type. 1491 * @returns {string} Type as string. 1492 */ 1493 searchEngagementTelemetryType(result, selType = null) { 1494 if (!result) { 1495 return selType === "oneoff" ? "search_shortcut_button" : "input_field"; 1496 } 1497 1498 // While product doesn't use experimental addons anymore, tests may still do 1499 // for testing purposes. 1500 if ( 1501 result.providerType === this.PROVIDER_TYPE.EXTENSION && 1502 result.providerName != "UrlbarProviderOmnibox" 1503 ) { 1504 return "experimental_addon"; 1505 } 1506 1507 if (result.providerName == "UrlbarProviderQuickSuggest") { 1508 return this._getQuickSuggestTelemetryType(result); 1509 } 1510 1511 // Appends subtype to certain result types. 1512 function checkForSubType(type, res) { 1513 if (res.providerName == "UrlbarProviderInputHistory") { 1514 type += "_adaptive"; 1515 } else if (res.providerName == "UrlbarProviderSemanticHistorySearch") { 1516 type += "_semantic"; 1517 } 1518 if ( 1519 lazy.UrlbarSearchUtils.resultIsSERP(res, [ 1520 UrlbarUtils.RESULT_SOURCE.BOOKMARKS, 1521 UrlbarUtils.RESULT_SOURCE.HISTORY, 1522 UrlbarUtils.RESULT_SOURCE.TABS, 1523 ]) 1524 ) { 1525 type += "_serp"; 1526 } 1527 return type; 1528 } 1529 1530 switch (result.type) { 1531 case this.RESULT_TYPE.DYNAMIC: 1532 switch (result.providerName) { 1533 case "UrlbarProviderCalculator": 1534 return "calc"; 1535 case "UrlbarProviderTabToSearch": 1536 return "tab_to_search"; 1537 case "UrlbarProviderUnitConversion": 1538 return "unit"; 1539 case "UrlbarProviderQuickSuggestContextualOptIn": 1540 return "fxsuggest_data_sharing_opt_in"; 1541 case "UrlbarProviderGlobalActions": 1542 case "UrlbarProviderActionsSearchMode": 1543 return "action"; 1544 } 1545 break; 1546 case this.RESULT_TYPE.KEYWORD: 1547 return "keyword"; 1548 case this.RESULT_TYPE.OMNIBOX: 1549 return "addon"; 1550 case this.RESULT_TYPE.REMOTE_TAB: 1551 return "remote_tab"; 1552 case this.RESULT_TYPE.SEARCH: 1553 if (result.providerName === "UrlbarProviderTabToSearch") { 1554 return "tab_to_search"; 1555 } 1556 if (result.source == this.RESULT_SOURCE.HISTORY) { 1557 return result.providerName == "UrlbarProviderRecentSearches" 1558 ? "recent_search" 1559 : "search_history"; 1560 } 1561 if (result.payload.suggestion) { 1562 let type = result.payload.trending 1563 ? "trending_search" 1564 : "search_suggest"; 1565 if (result.isRichSuggestion) { 1566 type += "_rich"; 1567 } 1568 return type; 1569 } 1570 return "search_engine"; 1571 case this.RESULT_TYPE.TAB_SWITCH: 1572 return checkForSubType("tab", result); 1573 case this.RESULT_TYPE.TIP: 1574 if (result.providerName === "UrlbarProviderInterventions") { 1575 // disable as part of tor-browser#41327 1576 switch (result.payload.type) { 1577 case lazy.UrlbarProviderInterventions.TIP_TYPE.CLEAR: 1578 // return "intervention_clear"; 1579 // fall-through 1580 case lazy.UrlbarProviderInterventions.TIP_TYPE.REFRESH: 1581 // return "intervention_refresh"; 1582 // fall-through 1583 case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_ASK: 1584 case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_CHECKING: 1585 case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_REFRESH: 1586 case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_RESTART: 1587 case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_WEB: 1588 // return "intervention_update"; 1589 // fall-through 1590 default: 1591 return "intervention_unknown"; 1592 } 1593 } 1594 switch (result.payload.type) { 1595 case lazy.UrlbarProviderSearchTips.TIP_TYPE.ONBOARD: 1596 return "tip_onboard"; 1597 case lazy.UrlbarProviderSearchTips.TIP_TYPE.REDIRECT: 1598 return "tip_redirect"; 1599 case "dismissalAcknowledgment": 1600 return "tip_dismissal_acknowledgment"; 1601 default: 1602 return "tip_unknown"; 1603 } 1604 case this.RESULT_TYPE.URL: 1605 if ( 1606 result.source === this.RESULT_SOURCE.OTHER_LOCAL && 1607 result.heuristic 1608 ) { 1609 return "url"; 1610 } 1611 if (result.autofill) { 1612 return `autofill_${result.autofill.type ?? "unknown"}`; 1613 } 1614 if (result.providerName === "UrlbarProviderTopSites") { 1615 return "top_site"; 1616 } 1617 if (result.providerName === "UrlbarProviderClipboard") { 1618 return "clipboard"; 1619 } 1620 if (result.source === this.RESULT_SOURCE.BOOKMARKS) { 1621 return checkForSubType("bookmark", result); 1622 } 1623 return checkForSubType("history", result); 1624 case this.RESULT_TYPE.RESTRICT: 1625 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.BOOKMARK) { 1626 return "restrict_keyword_bookmarks"; 1627 } 1628 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.OPENPAGE) { 1629 return "restrict_keyword_tabs"; 1630 } 1631 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.HISTORY) { 1632 return "restrict_keyword_history"; 1633 } 1634 if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.ACTION) { 1635 return "restrict_keyword_actions"; 1636 } 1637 } 1638 1639 return "unknown"; 1640 }, 1641 1642 searchEngagementTelemetryAction(result) { 1643 if (result.providerName != "UrlbarProviderGlobalActions") { 1644 return result.payload.action?.key ?? "none"; 1645 } 1646 return result.payload.actionsResults.map(({ key }) => key).join(","); 1647 }, 1648 1649 _getQuickSuggestTelemetryType(result) { 1650 if (result.payload.telemetryType == "weather") { 1651 // Return "weather" without the usual source prefix for consistency with 1652 // past reporting of weather suggestions. 1653 return "weather"; 1654 } 1655 return result.payload.source + "_" + result.payload.telemetryType; 1656 }, 1657 1658 /** 1659 * For use when we want to hash a pair of items in a dictionary 1660 * 1661 * @param {string[]} tokens 1662 * list of tokens to join into a string eg "a" "b" "c" 1663 * @returns {string} 1664 * the tokens joined in a string "a|b|c" 1665 */ 1666 tupleString(...tokens) { 1667 return tokens.filter(t => t).join("|"); 1668 }, 1669 1670 /** 1671 * Creates camelCase versions of snake_case keys in the given object and 1672 * recursively all nested objects. All objects are modified in place and the 1673 * original snake_case keys are preserved. 1674 * 1675 * @param {object} obj 1676 * The object to modify. 1677 * @param {boolean} [overwrite] 1678 * Controls what happens when a camelCase key is already defined for a 1679 * snake_case key (excluding keys that don't have underscores). If true the 1680 * existing key will be overwritten. If false an error will be thrown. 1681 * @returns {object} The passed-in modified-in-place object. 1682 */ 1683 copySnakeKeysToCamel(obj, overwrite = true) { 1684 for (let [key, value] of Object.entries(obj)) { 1685 // Trim off leading underscores since they'll interfere with the replace. 1686 // We'll tack them back on after. 1687 let match = key.match(/^_+/); 1688 if (match) { 1689 key = key.substring(match[0].length); 1690 } 1691 let camelKey = key.replace(/_([^_])/g, (m, p1) => p1.toUpperCase()); 1692 if (match) { 1693 camelKey = match[0] + camelKey; 1694 } 1695 if (!overwrite && camelKey != key && obj.hasOwnProperty(camelKey)) { 1696 throw new Error( 1697 `Can't copy snake_case key '${key}' to camelCase key ` + 1698 `'${camelKey}' because '${camelKey}' is already defined` 1699 ); 1700 } 1701 obj[camelKey] = value; 1702 if (value && typeof value == "object") { 1703 this.copySnakeKeysToCamel(value); 1704 } 1705 } 1706 return obj; 1707 }, 1708 1709 /** 1710 * Create secondary action button data for tab switch. 1711 * 1712 * @param {number} userContextId 1713 * The container id for the tab. 1714 * @returns {object} data to create secondary action button. 1715 */ 1716 createTabSwitchSecondaryAction(userContextId) { 1717 let action = { key: "tabswitch" }; 1718 let identity = 1719 lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId); 1720 1721 if (identity) { 1722 let label = 1723 lazy.ContextualIdentityService.getUserContextLabel( 1724 userContextId 1725 ).toLowerCase(); 1726 action.l10nId = "urlbar-result-action-switch-tab-with-container"; 1727 action.l10nArgs = { 1728 container: label, 1729 }; 1730 action.classList = [ 1731 "urlbarView-userContext", 1732 `identity-color-${identity.color}`, 1733 ]; 1734 } else { 1735 action.l10nId = "urlbar-result-action-switch-tab"; 1736 } 1737 1738 return action; 1739 }, 1740 1741 /** 1742 * Adds text content to a node, placing substrings that should be highlighted 1743 * inside <strong> nodes. 1744 * 1745 * @param {Element} parentNode 1746 * The text content will be added to this node. 1747 * @param {string} textContent 1748 * The text content to give the node. 1749 * @param {Array} highlights 1750 * Array of highlights as returned by `UrlbarUtils.getTokenMatches()` or 1751 * `UrlbarResult.getDisplayableValueAndHighlights()`. 1752 */ 1753 addTextContentWithHighlights(parentNode, textContent, highlights) { 1754 parentNode.textContent = ""; 1755 if (!textContent) { 1756 return; 1757 } 1758 1759 highlights = (highlights || []).concat([[textContent.length, 0]]); 1760 let index = 0; 1761 for (let [highlightIndex, highlightLength] of highlights) { 1762 if (highlightIndex - index > 0) { 1763 parentNode.appendChild( 1764 parentNode.ownerDocument.createTextNode( 1765 textContent.substring(index, highlightIndex) 1766 ) 1767 ); 1768 } 1769 if (highlightLength > 0) { 1770 let strong = parentNode.ownerDocument.createElement("strong"); 1771 strong.textContent = textContent.substring( 1772 highlightIndex, 1773 highlightIndex + highlightLength 1774 ); 1775 parentNode.appendChild(strong); 1776 } 1777 index = highlightIndex + highlightLength; 1778 } 1779 }, 1780 1781 /** 1782 * Formats the numerical portion of unit conversion results. 1783 * 1784 * @param {number} result 1785 * The raw unformatted unit conversion result. 1786 */ 1787 formatUnitConversionResult(result) { 1788 const DECIMAL_PRECISION = 10; 1789 const MAX_SIG_FIGURES = 10; 1790 const FULL_NUMBER_MAX_THRESHOLD = 1 * 10 ** 10; 1791 const FULL_NUMBER_MIN_THRESHOLD = 10 ** -5; 1792 1793 let locale = Services.locale.appLocaleAsBCP47; 1794 1795 if ( 1796 Math.abs(result) >= FULL_NUMBER_MAX_THRESHOLD || 1797 (Math.abs(result) <= FULL_NUMBER_MIN_THRESHOLD && result !== 0) 1798 ) { 1799 return new Intl.NumberFormat(locale, { 1800 style: "decimal", 1801 notation: "scientific", 1802 minimumFractionDigits: 1, 1803 maximumFractionDigits: DECIMAL_PRECISION, 1804 numberingSystem: "latn", 1805 }) 1806 .format(result) 1807 .toLowerCase(); 1808 } else if (Math.abs(result) >= 1) { 1809 return new Intl.NumberFormat(locale, { 1810 style: "decimal", 1811 maximumFractionDigits: DECIMAL_PRECISION, 1812 numberingSystem: "latn", 1813 }).format(result); 1814 } 1815 return new Intl.NumberFormat(locale, { 1816 style: "decimal", 1817 maximumSignificantDigits: MAX_SIG_FIGURES, 1818 numberingSystem: "latn", 1819 }).format(result); 1820 }, 1821 }; 1822 1823 ChromeUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => { 1824 return lazy.PlacesUtils.favicons.defaultFavicon.spec; 1825 }); 1826 1827 ChromeUtils.defineLazyGetter(UrlbarUtils, "strings", () => { 1828 return Services.strings.createBundle( 1829 "chrome://global/locale/autocomplete.properties" 1830 ); 1831 }); 1832 1833 const L10N_SCHEMA = { 1834 type: "object", 1835 required: ["id"], 1836 properties: { 1837 id: { 1838 type: "string", 1839 }, 1840 args: { 1841 type: "object", 1842 additionalProperties: true, 1843 }, 1844 // This object is parallel to args and should include an entry for each arg 1845 // to which highlights should be applied. See L10nCache.setElementL10n(). 1846 argsHighlights: { 1847 type: "object", 1848 additionalProperties: true, 1849 }, 1850 // The remaining properties are related to l10n string caching. See 1851 // `L10nCache`. All are optional and are false by default. 1852 parseMarkup: { 1853 type: "boolean", 1854 }, 1855 }, 1856 }; 1857 1858 /** 1859 * Payload JSON schemas for each result type. Payloads are validated against 1860 * these schemas using JsonSchemaValidator.sys.mjs. 1861 */ 1862 UrlbarUtils.RESULT_PAYLOAD_SCHEMA = { 1863 [UrlbarUtils.RESULT_TYPE.TAB_SWITCH]: { 1864 type: "object", 1865 required: ["url"], 1866 properties: { 1867 action: { 1868 type: "object", 1869 properties: { 1870 classList: { 1871 type: "array", 1872 items: { 1873 type: "string", 1874 }, 1875 }, 1876 l10nArgs: { 1877 type: "object", 1878 additionalProperties: true, 1879 }, 1880 l10nId: { 1881 type: "string", 1882 }, 1883 key: { 1884 type: "string", 1885 }, 1886 }, 1887 }, 1888 frecency: { 1889 type: "number", 1890 }, 1891 icon: { 1892 type: "string", 1893 }, 1894 isPinned: { 1895 type: "boolean", 1896 }, 1897 isSponsored: { 1898 type: "boolean", 1899 }, 1900 lastVisit: { 1901 type: "number", 1902 }, 1903 tabGroup: { 1904 type: "string", 1905 }, 1906 title: { 1907 type: "string", 1908 }, 1909 url: { 1910 type: "string", 1911 }, 1912 userContextId: { 1913 type: "number", 1914 }, 1915 }, 1916 }, 1917 [UrlbarUtils.RESULT_TYPE.SEARCH]: { 1918 type: "object", 1919 properties: { 1920 blockL10n: L10N_SCHEMA, 1921 description: { 1922 type: "string", 1923 }, 1924 descriptionL10n: L10N_SCHEMA, 1925 engine: { 1926 type: "string", 1927 }, 1928 helpUrl: { 1929 type: "string", 1930 }, 1931 icon: { 1932 type: "string", 1933 }, 1934 inPrivateWindow: { 1935 type: "boolean", 1936 }, 1937 isBlockable: { 1938 type: "boolean", 1939 }, 1940 isManageable: { 1941 type: "boolean", 1942 }, 1943 isPinned: { 1944 type: "boolean", 1945 }, 1946 isPrivateEngine: { 1947 type: "boolean", 1948 }, 1949 isGeneralPurposeEngine: { 1950 type: "boolean", 1951 }, 1952 keyword: { 1953 type: "string", 1954 }, 1955 keywords: { 1956 type: "string", 1957 }, 1958 lowerCaseSuggestion: { 1959 type: "string", 1960 }, 1961 providesSearchMode: { 1962 type: "boolean", 1963 }, 1964 query: { 1965 type: "string", 1966 }, 1967 satisfiesAutofillThreshold: { 1968 type: "boolean", 1969 }, 1970 searchUrlDomainWithoutSuffix: { 1971 type: "string", 1972 }, 1973 suggestion: { 1974 type: "string", 1975 }, 1976 tail: { 1977 type: "string", 1978 }, 1979 tailPrefix: { 1980 type: "string", 1981 }, 1982 tailOffsetIndex: { 1983 type: "number", 1984 }, 1985 title: { 1986 type: "string", 1987 }, 1988 trending: { 1989 type: "boolean", 1990 }, 1991 url: { 1992 type: "string", 1993 }, 1994 }, 1995 }, 1996 [UrlbarUtils.RESULT_TYPE.URL]: { 1997 type: "object", 1998 required: ["url"], 1999 properties: { 2000 blockL10n: L10N_SCHEMA, 2001 bottomTextL10n: L10N_SCHEMA, 2002 description: { 2003 type: "string", 2004 }, 2005 descriptionL10n: L10N_SCHEMA, 2006 dismissalKey: { 2007 type: "string", 2008 }, 2009 dupedHeuristic: { 2010 type: "boolean", 2011 }, 2012 frecency: { 2013 type: "number", 2014 }, 2015 helpL10n: L10N_SCHEMA, 2016 helpUrl: { 2017 type: "string", 2018 }, 2019 icon: { 2020 type: "string", 2021 }, 2022 iconBlob: { 2023 type: "object", 2024 }, 2025 isBlockable: { 2026 type: "boolean", 2027 }, 2028 isManageable: { 2029 type: "boolean", 2030 }, 2031 isPinned: { 2032 type: "boolean", 2033 }, 2034 isSponsored: { 2035 type: "boolean", 2036 }, 2037 lastVisit: { 2038 type: "number", 2039 }, 2040 originalUrl: { 2041 type: "string", 2042 }, 2043 provider: { 2044 type: "string", 2045 }, 2046 requestId: { 2047 type: "string", 2048 }, 2049 sendAttributionRequest: { 2050 type: "boolean", 2051 }, 2052 shouldShowUrl: { 2053 type: "boolean", 2054 }, 2055 source: { 2056 type: "string", 2057 }, 2058 sponsoredAdvertiser: { 2059 type: "string", 2060 }, 2061 sponsoredBlockId: { 2062 type: "number", 2063 }, 2064 sponsoredClickUrl: { 2065 type: "string", 2066 }, 2067 sponsoredIabCategory: { 2068 type: "string", 2069 }, 2070 sponsoredImpressionUrl: { 2071 type: "string", 2072 }, 2073 sponsoredTileId: { 2074 type: "number", 2075 }, 2076 subtype: { 2077 type: "string", 2078 }, 2079 suggestionObject: { 2080 type: "object", 2081 }, 2082 tags: { 2083 type: "array", 2084 items: { 2085 type: "string", 2086 }, 2087 }, 2088 telemetryType: { 2089 type: "string", 2090 }, 2091 title: { 2092 type: "string", 2093 }, 2094 titleL10n: L10N_SCHEMA, 2095 url: { 2096 type: "string", 2097 }, 2098 urlTimestampIndex: { 2099 type: "number", 2100 }, 2101 }, 2102 }, 2103 [UrlbarUtils.RESULT_TYPE.KEYWORD]: { 2104 type: "object", 2105 required: ["keyword", "url"], 2106 properties: { 2107 icon: { 2108 type: "string", 2109 }, 2110 input: { 2111 type: "string", 2112 }, 2113 keyword: { 2114 type: "string", 2115 }, 2116 postData: { 2117 type: "string", 2118 }, 2119 title: { 2120 type: "string", 2121 }, 2122 url: { 2123 type: "string", 2124 }, 2125 }, 2126 }, 2127 [UrlbarUtils.RESULT_TYPE.OMNIBOX]: { 2128 type: "object", 2129 required: ["keyword"], 2130 properties: { 2131 blockL10n: L10N_SCHEMA, 2132 content: { 2133 type: "string", 2134 }, 2135 icon: { 2136 type: "string", 2137 }, 2138 isBlockable: { 2139 type: "boolean", 2140 }, 2141 keyword: { 2142 type: "string", 2143 }, 2144 title: { 2145 type: "string", 2146 }, 2147 }, 2148 }, 2149 [UrlbarUtils.RESULT_TYPE.REMOTE_TAB]: { 2150 type: "object", 2151 required: ["device", "url", "lastUsed"], 2152 properties: { 2153 device: { 2154 type: "string", 2155 }, 2156 icon: { 2157 type: "string", 2158 }, 2159 lastUsed: { 2160 type: "number", 2161 }, 2162 title: { 2163 type: "string", 2164 }, 2165 url: { 2166 type: "string", 2167 }, 2168 }, 2169 }, 2170 [UrlbarUtils.RESULT_TYPE.TIP]: { 2171 type: "object", 2172 required: ["type"], 2173 properties: { 2174 buttons: { 2175 type: "array", 2176 items: { 2177 type: "object", 2178 required: ["l10n"], 2179 properties: { 2180 l10n: L10N_SCHEMA, 2181 url: { 2182 type: "string", 2183 }, 2184 command: { 2185 type: "string", 2186 }, 2187 input: { 2188 type: "string", 2189 }, 2190 attributes: { 2191 type: "object", 2192 properties: { 2193 primary: { 2194 type: "string", 2195 }, 2196 }, 2197 }, 2198 menu: { 2199 type: "array", 2200 items: { 2201 type: "object", 2202 properties: { 2203 l10n: L10N_SCHEMA, 2204 name: { 2205 type: "string", 2206 }, 2207 }, 2208 }, 2209 }, 2210 }, 2211 }, 2212 }, 2213 // TODO: This is intended only for WebExtensions. We should remove it and 2214 // the WebExtensions urlbar API since we're no longer using it. 2215 buttonText: { 2216 type: "string", 2217 }, 2218 // TODO: This is intended only for WebExtensions. We should remove it and 2219 // the WebExtensions urlbar API since we're no longer using it. 2220 buttonUrl: { 2221 type: "string", 2222 }, 2223 helpL10n: L10N_SCHEMA, 2224 helpUrl: { 2225 type: "string", 2226 }, 2227 icon: { 2228 type: "string", 2229 }, 2230 // TODO: This is intended only for WebExtensions. We should remove it and 2231 // the WebExtensions urlbar API since we're no longer using it. 2232 text: { 2233 type: "string", 2234 }, 2235 titleL10n: L10N_SCHEMA, 2236 descriptionL10n: L10N_SCHEMA, 2237 // If the `descriptionL10n` string includes a "Learn more" link, the 2238 // link anchor must have the attribute `data-l10n-name="learn-more-link"` 2239 // and the value of `descriptionLearnMoreTopic` must be the SUMO help 2240 // topic (the string appended to `app.support.baseURL`, e.g., 2241 // "firefox-suggest"). 2242 descriptionLearnMoreTopic: { 2243 type: "string", 2244 }, 2245 type: { 2246 type: "string", 2247 enum: [ 2248 "dismissalAcknowledgment", 2249 "extension", 2250 "intervention_clear", 2251 "intervention_refresh", 2252 "intervention_update_ask", 2253 "intervention_update_refresh", 2254 "intervention_update_restart", 2255 "intervention_update_web", 2256 "realtime_opt_in", 2257 "searchTip_onboard", 2258 "searchTip_redirect", 2259 "test", // for tests only 2260 ], 2261 }, 2262 }, 2263 }, 2264 [UrlbarUtils.RESULT_TYPE.DYNAMIC]: { 2265 type: "object", 2266 required: ["dynamicType"], 2267 properties: { 2268 dynamicType: { 2269 type: "string", 2270 }, 2271 }, 2272 }, 2273 [UrlbarUtils.RESULT_TYPE.RESTRICT]: { 2274 type: "object", 2275 properties: { 2276 icon: { 2277 type: "string", 2278 }, 2279 keyword: { 2280 type: "string", 2281 }, 2282 l10nRestrictKeywords: { 2283 type: "array", 2284 items: { 2285 type: "string", 2286 }, 2287 }, 2288 autofillKeyword: { 2289 type: "string", 2290 }, 2291 providesSearchMode: { 2292 type: "boolean", 2293 }, 2294 }, 2295 }, 2296 }; 2297 2298 /** 2299 * @typedef UrlbarSearchModeData 2300 * @property {Values<typeof UrlbarUtils.RESULT_SOURCE>} source 2301 * The source from which search mode was entered. 2302 * @property {string} [engineName] 2303 * The search engine name associated with the search mode. 2304 */ 2305 2306 /** 2307 * UrlbarQueryContext defines a user's autocomplete input from within the urlbar. 2308 * It supplements it with details of how the search results should be obtained 2309 * and what they consist of. 2310 */ 2311 export class UrlbarQueryContext { 2312 /** 2313 * Constructs the UrlbarQueryContext instance. 2314 * 2315 * @param {object} options 2316 * The initial options for UrlbarQueryContext. 2317 * @param {string} options.sapName 2318 * The search access point name of the UrlbarInput for use with telemetry or 2319 * logging, e.g. `urlbar`, `searchbar`. 2320 * @param {string} options.searchString 2321 * The string the user entered in autocomplete. Could be the empty string 2322 * in the case of the user opening the popup via the mouse. 2323 * @param {boolean} options.isPrivate 2324 * Set to true if this query was started from a private browsing window. 2325 * @param {number} options.maxResults 2326 * The maximum number of results that will be displayed for this query. 2327 * @param {boolean} options.allowAutofill 2328 * Whether or not to allow providers to include autofill results. 2329 * @param {number} [options.userContextId] 2330 * The container id where this context was generated, if any. 2331 * @param {string | null} [options.tabGroup] 2332 * The tab group where this context was generated, if any. 2333 * @param {Array} [options.sources] 2334 * A list of acceptable UrlbarUtils.RESULT_SOURCE for the context. 2335 * @param {object} [options.searchMode] 2336 * The input's current search mode. See UrlbarInput.setSearchMode for a 2337 * description. 2338 * @param {boolean} [options.prohibitRemoteResults] 2339 * This provides a short-circuit override for `context.allowRemoteResults`. 2340 * If it's false, then `allowRemoteResults` will do its usual checks to 2341 * determine whether remote results are allowed. If it's true, then 2342 * `allowRemoteResults` will immediately return false. Defaults to false. 2343 */ 2344 constructor(options) { 2345 // Clone to make sure all properties belong to the system realm. 2346 // This is required because this method is called from a window. 2347 // Not doing this causes a window leak if providers don't properly 2348 // clean up after a query and keep references to UrlbarQueryContext 2349 // properties (e.g. ProviderPlaces). 2350 options = structuredClone(options); 2351 2352 this._checkRequiredOptions(options, [ 2353 "allowAutofill", 2354 "isPrivate", 2355 "maxResults", 2356 "sapName", 2357 "searchString", 2358 ]); 2359 2360 if (isNaN(options.maxResults)) { 2361 throw new Error( 2362 `Invalid maxResults property provided to UrlbarQueryContext` 2363 ); 2364 } 2365 2366 /** 2367 * @type {[string, (v: any) => boolean, any?][]} 2368 */ 2369 const optionalProperties = [ 2370 ["currentPage", v => typeof v == "string" && !!v.length], 2371 ["prohibitRemoteResults", () => true, false], 2372 ["providers", v => Array.isArray(v) && !!v.length], 2373 ["searchMode", v => v && typeof v == "object"], 2374 ["sources", v => Array.isArray(v) && !!v.length], 2375 ]; 2376 2377 // Manage optional properties of options. 2378 for (let [prop, checkFn, defaultValue] of optionalProperties) { 2379 if (prop in options) { 2380 if (!checkFn(options[prop])) { 2381 throw new Error(`Invalid value for option "${prop}"`); 2382 } 2383 this[prop] = options[prop]; 2384 } else if (defaultValue !== undefined) { 2385 this[prop] = defaultValue; 2386 } 2387 } 2388 2389 this.lastResultCount = 0; 2390 // Note that Set is not serializable through JSON, so these may not be 2391 // easily shared with add-ons. 2392 this.pendingHeuristicProviders = new Set(); 2393 this.deferUserSelectionProviders = new Set(); 2394 this.trimmedSearchString = this.searchString.trim(); 2395 this.lowerCaseSearchString = this.searchString.toLowerCase(); 2396 this.trimmedLowerCaseSearchString = this.trimmedSearchString.toLowerCase(); 2397 this.userContextId = 2398 lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( 2399 options.userContextId, 2400 this.isPrivate 2401 ) || Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID; 2402 this.tabGroup = options.tabGroup || null; 2403 2404 // Used to store glean timing distribution timer ids. 2405 this.firstTimerId = 0; 2406 this.sixthTimerId = 0; 2407 } 2408 2409 /** 2410 * @type {boolean} 2411 * Whether or not to allow providers to include autofill results. 2412 */ 2413 allowAutofill; 2414 2415 /** 2416 * @type {boolean} 2417 * Whether or not the query has been cancelled. 2418 */ 2419 canceled = false; 2420 2421 /** 2422 * @type {string} 2423 * URL of the page that was loaded when the search began. 2424 */ 2425 currentPage; 2426 2427 /** 2428 * @type {UrlbarResult} 2429 * The current firstResult. 2430 */ 2431 firstResult; 2432 2433 /** 2434 * @type {boolean} 2435 * Indicates if the first result has been changed changed. 2436 */ 2437 firstResultChanged = false; 2438 2439 /** 2440 * @type {UrlbarResult} 2441 * The heuristic result associated with the context. 2442 */ 2443 heuristicResult; 2444 2445 /** 2446 * @type {boolean} 2447 * True if this query was started from a private browsing window. 2448 */ 2449 isPrivate; 2450 2451 /** 2452 * @type {number} 2453 * The maximum number of results that will be displayed for this query. 2454 */ 2455 maxResults; 2456 2457 /** 2458 * @type {string} 2459 * The name of the muxer to use for this query. 2460 */ 2461 muxer; 2462 2463 /** 2464 * @type {boolean} 2465 * Whether or not to prohibit remote results. 2466 */ 2467 prohibitRemoteResults; 2468 2469 /** 2470 * @type {string[]} 2471 * List of registered provider names. Providers can be registered through 2472 * the UrlbarProvidersManager. 2473 */ 2474 providers; 2475 2476 /** 2477 * @type {?Values<typeof UrlbarUtils.RESULT_SOURCE>} 2478 * Set if this context is restricted to a single source. 2479 */ 2480 restrictSource; 2481 2482 /** 2483 * @type {UrlbarSearchStringTokenData} 2484 * The restriction token used to restrict the sources for this search. 2485 */ 2486 restrictToken; 2487 2488 /** 2489 * @type {UrlbarResult[]} 2490 * The results associated with this context. 2491 */ 2492 results; 2493 2494 /** 2495 * @type {string} 2496 * The search access point name of the UrlbarInput for use with telemetry or 2497 * logging, e.g. `urlbar`, `searchbar`. 2498 */ 2499 sapName; 2500 2501 /** 2502 * @type {UrlbarSearchModeData} 2503 * Details about the search mode associated with this context. 2504 */ 2505 searchMode; 2506 2507 /** 2508 * @type {string} 2509 * The string the user entered in autocomplete. 2510 */ 2511 searchString; 2512 2513 /** 2514 * @type {Values<typeof UrlbarUtils.RESULT_SOURCE>[]} 2515 * The possible sources of results for this context. 2516 */ 2517 sources; 2518 2519 /** 2520 * @type {UrlbarSearchStringTokenData[]} 2521 * A list of tokens extracted from the search string. 2522 */ 2523 tokens; 2524 2525 /** 2526 * Checks the required options, saving them as it goes. 2527 * 2528 * @param {object} options The options object to check. 2529 * @param {Array} optionNames The names of the options to check for. 2530 * @throws {Error} Throws if there is a missing option. 2531 */ 2532 _checkRequiredOptions(options, optionNames) { 2533 for (let optionName of optionNames) { 2534 if (!(optionName in options)) { 2535 throw new Error( 2536 `Missing or empty ${optionName} provided to UrlbarQueryContext` 2537 ); 2538 } 2539 this[optionName] = options[optionName]; 2540 } 2541 } 2542 2543 /** 2544 * Caches and returns fixup info from URIFixup for the current search string. 2545 * Only returns a subset of the properties from URIFixup. This is both to 2546 * reduce the memory footprint of UrlbarQueryContexts and to keep them 2547 * serializable so they can be sent to extensions. 2548 */ 2549 get fixupInfo() { 2550 if (!this._fixupError && !this._fixupInfo && this.trimmedSearchString) { 2551 let flags = 2552 Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | 2553 Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; 2554 if (this.isPrivate) { 2555 flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT; 2556 } 2557 2558 try { 2559 let info = Services.uriFixup.getFixupURIInfo(this.searchString, flags); 2560 2561 this._fixupInfo = { 2562 href: info.fixedURI.spec, 2563 isSearch: !!info.keywordAsSent, 2564 scheme: info.fixedURI.scheme, 2565 }; 2566 } catch (ex) { 2567 this._fixupError = ex.result; 2568 } 2569 } 2570 2571 return this._fixupInfo || null; 2572 } 2573 2574 /** 2575 * Returns the error that was thrown when fixupInfo was fetched, if any. If 2576 * fixupInfo has not yet been fetched for this queryContext, it is fetched 2577 * here. 2578 * 2579 * @returns {any?} 2580 */ 2581 get fixupError() { 2582 if (!this.fixupInfo) { 2583 return this._fixupError; 2584 } 2585 2586 return null; 2587 } 2588 2589 /** 2590 * Returns whether results from remote services are generally allowed for the 2591 * context. Callers can impose further restrictions as appropriate, but 2592 * typically they should not fetch remote results if this returns false. 2593 * 2594 * @param {string} [searchString] 2595 * Usually this is just the context's search string, but if you need to 2596 * fetch remote results based on a modified version, you can pass it here. 2597 * @param {boolean} [allowEmptySearchString] 2598 * Whether to check for the minimum length of the search string. 2599 * @returns {boolean} 2600 * Whether remote results are allowed. 2601 */ 2602 allowRemoteResults( 2603 searchString = this.searchString, 2604 allowEmptySearchString = false 2605 ) { 2606 if (this.prohibitRemoteResults) { 2607 return false; 2608 } 2609 2610 // We're unlikely to get useful remote results for a single character. 2611 if ( 2612 searchString.length < 2 && 2613 !(!searchString.length && allowEmptySearchString) 2614 ) { 2615 return false; 2616 } 2617 2618 // Prohibit remote results if the search string is likely an origin to avoid 2619 // disclosing sites the user visits. If the search string may or may not be 2620 // an origin but we've determined a search is allowed, then allow it. 2621 if (this.tokens.length == 1) { 2622 switch (this.tokens[0].type) { 2623 case lazy.UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN: 2624 return false; 2625 case lazy.UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN_BUT_SEARCH_ALLOWED: 2626 return true; 2627 } 2628 } 2629 2630 // Disallow remote results for strings containing tokens that look like URIs 2631 // to avoid disclosing information about networks and passwords. 2632 // (Unless the search is happening in the searchbar.) 2633 if ( 2634 this.sapName != "searchbar" && 2635 this.fixupInfo?.href && 2636 !this.fixupInfo?.isSearch 2637 ) { 2638 return false; 2639 } 2640 2641 // Allow remote results. 2642 return true; 2643 } 2644 } 2645 2646 /** 2647 * Base class for a muxer. 2648 * The muxer scope is to sort a given list of results. 2649 */ 2650 export class UrlbarMuxer { 2651 /** 2652 * Unique name for the muxer, used by the context to sort results. 2653 * Not using a unique name will cause the newest registration to win. 2654 * 2655 * @abstract 2656 */ 2657 get name() { 2658 return "UrlbarMuxerBase"; 2659 } 2660 2661 /** 2662 * Sorts queryContext results in-place. 2663 * 2664 * @param {UrlbarQueryContext} _queryContext the context to sort results for. 2665 * @param {Array} _unsortedResults 2666 * The array of UrlbarResult that is not sorted yet. 2667 * @abstract 2668 */ 2669 sort(_queryContext, _unsortedResults) { 2670 throw new Error("Trying to access the base class, must be overridden"); 2671 } 2672 } 2673 2674 /** 2675 * Base class for a provider. 2676 * The provider scope is to query a datasource and return results from it. 2677 */ 2678 export class UrlbarProvider { 2679 #lazy = XPCOMUtils.declareLazy({ 2680 logger: () => UrlbarUtils.getLogger({ prefix: `Provider.${this.name}` }), 2681 }); 2682 2683 get logger() { 2684 return this.#lazy.logger; 2685 } 2686 2687 /** 2688 * Unique name for the provider, used by the context to filter on providers. 2689 * By default, it will use the class name but it can also be overridden to 2690 * use a different name. 2691 * Not using a unique name will cause the newest registration to win. 2692 */ 2693 get name() { 2694 return this.constructor.name; 2695 } 2696 2697 /** 2698 * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. 2699 * 2700 * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>} 2701 * @abstract 2702 */ 2703 get type() { 2704 throw new Error("Trying to access the base class, must be overridden"); 2705 } 2706 2707 /** 2708 * @type {Query} 2709 * This can be used by the provider to check the query is still running 2710 * after executing async tasks: 2711 * 2712 * ``` 2713 * let instance = this.queryInstance; 2714 * await ... 2715 * if (instance != this.queryInstance) { 2716 * // Query was canceled or a new one started. 2717 * return; 2718 * } 2719 * ``` 2720 */ 2721 queryInstance; 2722 2723 /** 2724 * Calls a method on the provider in a try-catch block and reports any error. 2725 * Unlike most other provider methods, `tryMethod` is not intended to be 2726 * overridden. 2727 * 2728 * @param {string} methodName The name of the method to call. 2729 * @param {*} args The method arguments. 2730 * @returns {*} The return value of the method, or undefined if the method 2731 * throws an error. 2732 * @abstract 2733 */ 2734 tryMethod(methodName, ...args) { 2735 try { 2736 return this[methodName](...args); 2737 } catch (ex) { 2738 console.error(ex); 2739 } 2740 return undefined; 2741 } 2742 2743 /** 2744 * Whether this provider should be invoked for the given context. 2745 * If this method returns false, the providers manager won't start a query 2746 * with this provider, to save on resources. 2747 * 2748 * @param {UrlbarQueryContext} [_queryContext] 2749 * The query context object 2750 * @param {UrlbarController} [_controller] 2751 * The current controller. 2752 * @returns {Promise<boolean>} 2753 * Whether this provider should be invoked for the search. 2754 * @abstract 2755 */ 2756 async isActive(_queryContext, _controller) { 2757 throw new Error("Trying to access the base class, must be overridden"); 2758 } 2759 2760 /** 2761 * Gets the provider's priority. Priorities are numeric values starting at 2762 * zero and increasing in value. Smaller values are lower priorities, and 2763 * larger values are higher priorities. For a given query, `startQuery` is 2764 * called on only the active and highest-priority providers. 2765 * 2766 * @param {UrlbarQueryContext} _queryContext The query context object 2767 * @returns {number} The provider's priority for the given query. 2768 * @abstract 2769 */ 2770 getPriority(_queryContext) { 2771 // By default, all providers share the lowest priority. 2772 return 0; 2773 } 2774 2775 /** 2776 * Starts querying. 2777 * 2778 * Note: Extended classes should return a Promise resolved when the provider 2779 * is done searching AND returning results. 2780 * 2781 * @param {UrlbarQueryContext} _queryContext 2782 * The query context object 2783 * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} _addCallback 2784 * Callback invoked by the provider to add a new result. 2785 * @returns {void|Promise<void>} 2786 * @abstract 2787 */ 2788 startQuery(_queryContext, _addCallback) { 2789 throw new Error("Trying to access the base class, must be overridden"); 2790 } 2791 2792 /** 2793 * Cancels a running query, 2794 * 2795 * @param {UrlbarQueryContext} _queryContext the query context object to cancel 2796 * query for. 2797 * @abstract 2798 */ 2799 cancelQuery(_queryContext) { 2800 // Override this with your clean-up on cancel code. 2801 } 2802 2803 // The following `on{Event}` notification methods are invoked only when 2804 // defined, thus there is no base class implementation for them 2805 /** 2806 * Called when a user engages with a result in the urlbar. This is called for 2807 * all providers who have implemented this method. 2808 * 2809 * @param {UrlbarQueryContext} _queryContext 2810 * The engagement's query context. It will always be defined for 2811 * "engagement" and "abandonment". 2812 * @param {UrlbarController} _controller 2813 * The associated controller. 2814 * @param {object} _details 2815 * This object is non-empty only when `state` is "engagement" or 2816 * "abandonment", and it describes the search string and engaged result. 2817 * 2818 * For "engagement", it has the following properties: 2819 * 2820 * {UrlbarResult} result 2821 * The engaged result. If a result itself was picked, this will be it. 2822 * If an element related to a result was picked (like a button or menu 2823 * command), this will be that result. This property will be present if 2824 * and only if `state` == "engagement", so it can be used to quickly 2825 * tell when the user engaged with a result. 2826 * {Element} element 2827 * The picked DOM element. 2828 * {boolean} isSessionOngoing 2829 * True if the search session remains ongoing or false if the engagement 2830 * ended it. Typically picking a result ends the session but not always. 2831 * Picking a button or menu command may not end the session; dismissals 2832 * do not, for example. 2833 * {string} searchString 2834 * The search string for the engagement's query. 2835 * {number} selIndex 2836 * The index of the picked result. 2837 * {string} selType 2838 * The type of the selected result. See TelemetryEvent.record() in 2839 * UrlbarController.sys.mjs. 2840 * {string} provider 2841 * The name of the provider that produced the picked result. 2842 * 2843 * For "abandonment", only `searchString` is defined. 2844 * 2845 * onEngagement(_queryContext, _controller, _details) {} 2846 */ 2847 2848 /** 2849 * Called when the user abandons a search session without selecting a result. 2850 * This could be due to losing focus on the urlbar, switching tabs, or other 2851 * actions that imply the user is no longer actively engaging with the search 2852 * suggestions. The method is called for all providers who have implemented 2853 * this method and whose results were visible at the time of the abandonment. 2854 * 2855 * @param {UrlbarQueryContext} _queryContext 2856 * The query context at the time of abandonment. 2857 * @param {UrlbarController} _controller 2858 * The associated controller. 2859 * 2860 * onAbandonment(_queryContext, _controller) {} 2861 */ 2862 2863 /** 2864 * Called for providers whose results are visible at the time of either 2865 * engagement or abandonment. The method is called when a user actively 2866 * interacts with a search result. This interaction could be clicking on a 2867 * suggestion, using a keyboard to select a suggestion, or any other form of 2868 * direct engagement with the results displayed. It is also called 2869 * when a user decides to abandon the search session without engaging with any 2870 * of the presented results. This is called for all providers who have 2871 * implemented this method. 2872 * 2873 * @param {string} _state 2874 * The state of the user interaction, either "engagement" or "abandonment". 2875 * @param {UrlbarQueryContext} _queryContext 2876 * The current query context. 2877 * @param {UrlbarController} _controller 2878 * The associated controller. 2879 * @param {Array} _providerVisibleResults 2880 * Array of visible results at the time of either an engagement or 2881 * abandonment event relevant to the provider. Each object in the array 2882 * contains: 2883 * - `index`: The position of the visible result within the original list 2884 * visible results. 2885 * - `result`: The visible result itself 2886 * @param {object|null} _details 2887 * If the impression is due to an engagement, this will be the `details` 2888 * object that's also passed to `onEngagement()`. Otherwise it will be 2889 * null. See `onEngagement()` documentation for info. 2890 * 2891 * onImpression(_state, _queryContext, _controller, _providerVisibleResults, _details) 2892 * {} 2893 */ 2894 2895 /** 2896 * Called when a search session concludes regardless of how it ends - 2897 * whether through engagement or abandonment or otherwise. This is 2898 * called for all providers who have implemented this method. 2899 * 2900 * @param {UrlbarQueryContext} _queryContext 2901 * The current query context. 2902 * @param {UrlbarController} _controller 2903 * The associated controller. 2904 * 2905 * onSearchSessionEnd(_queryContext, _controller) {} 2906 */ 2907 2908 /** 2909 * Called before a result from the provider is selected. See `onSelection` 2910 * for details on what that means. 2911 * 2912 * @param {UrlbarResult} _result 2913 * The result that was selected. 2914 * @param {Element} _element 2915 * The element in the result's view that was selected. 2916 * @abstract 2917 */ 2918 onBeforeSelection(_result, _element) {} 2919 2920 /** 2921 * Called when a result from the provider is selected. "Selected" refers to 2922 * the user highlighing the result with the arrow keys/Tab, before it is 2923 * picked. onSelection is also called when a user clicks a result. In the 2924 * event of a click, onSelection is called just before onEngagement. Note that 2925 * this is called when heuristic results are pre-selected. 2926 * 2927 * @param {UrlbarResult} _result 2928 * The result that was selected. 2929 * @param {Element} _element 2930 * The element in the result's view that was selected. 2931 * @abstract 2932 */ 2933 onSelection(_result, _element) {} 2934 2935 /** 2936 * This is called only for dynamic result types, when the urlbar view updates 2937 * the view of one of the results of the provider. It should return an object 2938 * describing the view update that looks like this: 2939 * 2940 * { 2941 * nodeNameFoo: { 2942 * attributes: { 2943 * someAttribute: someValue, 2944 * }, 2945 * style: { 2946 * someStyleProperty: someValue, 2947 * "another-style-property": someValue, 2948 * }, 2949 * l10n: { 2950 * id: someL10nId, 2951 * args: someL10nArgs, 2952 * }, 2953 * textContent: "some text content", 2954 * }, 2955 * nodeNameBar: { 2956 * ... 2957 * }, 2958 * nodeNameBaz: { 2959 * ... 2960 * }, 2961 * } 2962 * 2963 * The object should contain a property for each element to update in the 2964 * dynamic result type view. The names of these properties are the names 2965 * declared in the view template of the dynamic result type; see 2966 * UrlbarView.addDynamicViewTemplate(). The values are similar to the nested 2967 * objects specified in the view template but not quite the same; see below. 2968 * For each property, the element in the view subtree with the specified name 2969 * is updated according to the object in the property's value. If an 2970 * element's name is not specified, then it will not be updated and will 2971 * retain its current state. 2972 * 2973 * @param {UrlbarResult} _result 2974 * The result whose view will be updated. 2975 * @param {Map} _idsByName 2976 * A Map from an element's name, as defined by the provider; to its ID in 2977 * the DOM, as defined by the browser. The browser manages element IDs for 2978 * dynamic results to prevent collisions. However, a provider may need to 2979 * access the IDs of the elements created for its results. For example, to 2980 * set various `aria` attributes. 2981 * @returns {object} 2982 * A view update object as described above. The names of properties are the 2983 * the names of elements declared in the view template. The values of 2984 * properties are objects that describe how to update each element, and 2985 * these objects may include the following properties, all of which are 2986 * optional: 2987 * 2988 * {object} [attributes] 2989 * A mapping from attribute names to values. Each name-value pair results 2990 * in an attribute being added to the element. The `id` attribute is 2991 * reserved and cannot be set by the provider. 2992 * {Array} [classList] 2993 * An array of CSS classes to set on the element. If this is defined, the 2994 * element's previous classes will be cleared first! 2995 * {object} [dataset] 2996 * Maps element dataset keys to values. Values should be strings with the 2997 * following exceptions: `undefined` is ignored, and `null` causes the key 2998 * to be removed from the dataset. 2999 * {object} [style] 3000 * A plain object that can be used to add inline styles to the element, 3001 * like `display: none`. `element.style` is updated for each name-value 3002 * pair in this object. 3003 * {object} [l10n] 3004 * An { id, args } object that will be passed to 3005 * document.l10n.setAttributes(). 3006 * {string} [textContent] 3007 * A string that will be set as `element.textContent`. 3008 */ 3009 getViewUpdate(_result, _idsByName) { 3010 return null; 3011 } 3012 3013 /** 3014 * Gets the list of commands that should be shown in the result menu for a 3015 * given result from the provider. All commands returned by this method should 3016 * be handled by implementing `onEngagement()` with the possible exception of 3017 * commands automatically handled by the urlbar, like "help". 3018 * 3019 * @param {UrlbarResult} _result 3020 * The menu will be shown for this result. 3021 * @returns {?UrlbarResultCommand[]} 3022 */ 3023 getResultCommands(_result) { 3024 return null; 3025 } 3026 3027 /** 3028 * Defines whether the view should defer user selection events while waiting 3029 * for the first result from this provider. 3030 * 3031 * Note: UrlbarEventBufferer has a timeout after which user events will be 3032 * processed regardless. 3033 * 3034 * @returns {boolean} Whether the provider wants to defer user selection 3035 * events. 3036 * @see {@link UrlbarEventBufferer} 3037 */ 3038 get deferUserSelection() { 3039 return false; 3040 } 3041 } 3042 3043 /** 3044 * Class used to create a timer that can be manually fired, to immediately 3045 * invoke the callback, or canceled, as necessary. 3046 * Examples: 3047 * let timer = new SkippableTimer(); 3048 * // Invokes the callback immediately without waiting for the delay. 3049 * await timer.fire(); 3050 * // Cancel the timer, the callback won't be invoked. 3051 * await timer.cancel(); 3052 * // Wait for the timer to have elapsed. 3053 * await timer.promise; 3054 */ 3055 export class SkippableTimer { 3056 /** 3057 * This can be used to track whether the timer completed. 3058 */ 3059 done = false; 3060 3061 /** 3062 * Creates a skippable timer for the given callback and time. 3063 * 3064 * @param {object} [options] An object that configures the timer 3065 * @param {string} [options.name] The name of the timer, logged when necessary 3066 * @param {Function} [options.callback] To be invoked when requested 3067 * @param {number} [options.time] A delay in milliseconds to wait for 3068 * @param {boolean} [options.reportErrorOnTimeout] If true and the timer times 3069 * out, an error will be logged with Cu.reportError 3070 * @param {ConsoleInstance} [options.logger] An optional logger 3071 */ 3072 constructor({ 3073 name = "<anonymous timer>", 3074 callback = null, 3075 time = 0, 3076 reportErrorOnTimeout = false, 3077 logger = null, 3078 } = {}) { 3079 this.name = name; 3080 this.logger = logger; 3081 3082 let timerPromise = new Promise(resolve => { 3083 this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 3084 this._timer.initWithCallback( 3085 () => { 3086 this._log(`Timed out!`, reportErrorOnTimeout); 3087 this.done = true; 3088 this._timer = null; 3089 resolve(); 3090 }, 3091 time, 3092 Ci.nsITimer.TYPE_ONE_SHOT 3093 ); 3094 this._log(`Started`); 3095 }); 3096 3097 let firePromise = new Promise(resolve => { 3098 this.fire = async () => { 3099 this.done = true; 3100 if (this._timer) { 3101 if (!this._canceled) { 3102 this._log(`Skipped`); 3103 } 3104 this._timer.cancel(); 3105 this._timer = null; 3106 resolve(); 3107 } 3108 await this.promise; 3109 }; 3110 }); 3111 3112 this.promise = Promise.race([timerPromise, firePromise]).then(() => { 3113 // If we've been canceled, don't call back. 3114 if (callback && !this._canceled) { 3115 callback(); 3116 } 3117 }); 3118 } 3119 3120 /** 3121 * Allows to cancel the timer and the callback won't be invoked. 3122 * It is not strictly necessary to await for this, the promise can just be 3123 * used to ensure all the internal work is complete. 3124 */ 3125 async cancel() { 3126 if (this._timer) { 3127 this._log(`Canceling`); 3128 this._canceled = true; 3129 } 3130 await this.fire(); 3131 } 3132 3133 _log(msg, isError = false) { 3134 let line = `SkippableTimer :: ${this.name} :: ${msg}`; 3135 if (this.logger) { 3136 this.logger.debug(line); 3137 } 3138 if (isError) { 3139 console.error(line); 3140 } 3141 } 3142 } 3143 3144 /** 3145 * @typedef L10nCachedMessage 3146 * A cached L10n message object is similar to `L10nMessage` (defined in 3147 * Localization.webidl) but its attributes are stored differently for 3148 * convenience. 3149 * 3150 * For example, if we cache these strings from an ftl file: 3151 * 3152 * foo = Foo's value 3153 * bar = 3154 * .label = Bar's label value 3155 * 3156 * Then: 3157 * 3158 * cache.get("foo") 3159 * // => { value: "Foo's value", attributes: null } 3160 * cache.get("bar") 3161 * // => { value: null, attributes: { label: "Bar's label value" }} 3162 * @property {string} [value] 3163 * The bare value of the string. If the string does not have a bare value 3164 * (i.e., it has only attributes), this will be null. 3165 * @property {{[key: string]: string}|null} [attributes] 3166 * A mapping from attribute names to their values. If the string doesn't have 3167 * any attributes, this will be null. 3168 */ 3169 3170 /** 3171 * This class implements a cache for l10n strings. Cached strings can be 3172 * accessed synchronously, avoiding the asynchronicity of `data-l10n-id` and 3173 * `document.l10n.setAttributes`, which can lead to text pop-in and flickering 3174 * as strings are fetched from Fluent. (`document.l10n.formatValueSync` is also 3175 * sync but should not be used since it may perform sync I/O.) 3176 * 3177 * Values stored and returned by the cache are JS objects similar to 3178 * `L10nMessage` objects, not bare strings. This allows the cache to store not 3179 * only l10n strings with bare values but also strings that define attributes 3180 * (e.g., ".label = My label value"). See `get` for details. 3181 * 3182 * The cache stores up to `MAX_ENTRIES_PER_ID` entries per l10n ID, and entries 3183 * are sorted from least recently cached to most recently cached. This only 3184 * matters for strings that have arguments. For strings that don't have 3185 * arguments, there can be only one cached value, so there can be only one cache 3186 * entry. But for strings that do have arguments, their cached values depend on 3187 * the arguments they were cached with. The cache will store up to 3188 * `MAX_ENTRIES_PER_ID` of the most recently cached values for a given l10n ID. 3189 * 3190 * For example, given the following string from an ftl file: 3191 * 3192 * foo = My arg value is { $bar } 3193 * 3194 * And the following cache calls: 3195 * 3196 * cache.add({ id: "foo", args: { bar: "aaa" }}); 3197 * cache.add({ id: "foo", args: { bar: "bbb" }}); 3198 * cache.add({ id: "foo", args: { bar: "ccc" }}); 3199 * 3200 * Then three different versions of the "foo" string will be cached, from least 3201 * recently cached to most recently cached: 3202 * 3203 * "My arg value is aaa" 3204 * "My arg value is bbb" 3205 * "My arg value is ccc" 3206 * 3207 * If `MAX_ENTRIES_PER_ID` is 3 and we cache a fourth version like this: 3208 * 3209 * cache.add({ id: "foo", args: { bar: "zzz" }}); 3210 * 3211 * Then the least recently cached version -- the "aaa" one -- will be evicted 3212 * from the cache, and the remaining cached versions will be: 3213 * 3214 * "My arg value is bbb" 3215 * "My arg value is ccc" 3216 * "My arg value is zzz" 3217 */ 3218 export class L10nCache { 3219 static MAX_ENTRIES_PER_ID = 5; 3220 3221 /** 3222 * @param {Localization} l10n 3223 * A `Localization` object like `document.l10n`. This class keeps a weak 3224 * reference to this object, so the caller or something else must hold onto 3225 * it. 3226 */ 3227 constructor(l10n) { 3228 this.l10n = Cu.getWeakReference(l10n); 3229 this.QueryInterface = ChromeUtils.generateQI([ 3230 "nsIObserver", 3231 "nsISupportsWeakReference", 3232 ]); 3233 Services.obs.addObserver(this, "intl:app-locales-changed", true); 3234 } 3235 3236 /** 3237 * Gets a cached l10n message. 3238 * 3239 * @param {object} options 3240 * Options 3241 * @param {string} options.id 3242 * The string's Fluent ID. 3243 * @param {object} [options.args] 3244 * The Fluent arguments as passed to `l10n.setAttributes`. Required if the 3245 * l10n string has arguments. 3246 * @returns {L10nCachedMessage|null} 3247 * The cached message or null if it's not cached. 3248 */ 3249 get({ id, args = undefined }) { 3250 return this.#messagesByArgsById.get(id)?.get(this.#argsKey(args)) ?? null; 3251 } 3252 3253 /** 3254 * Fetches a string from Fluent and caches it. 3255 * 3256 * @param {object} options 3257 * Options 3258 * @param {string} options.id 3259 * The string's Fluent ID. 3260 * @param {object} [options.args] 3261 * The Fluent arguments as passed to `l10n.setAttributes`. Required if the 3262 * l10n string has arguments. 3263 */ 3264 async add({ id, args = undefined }) { 3265 let l10n = this.l10n.get(); 3266 if (!l10n) { 3267 return; 3268 } 3269 3270 let messages = await l10n.formatMessages([{ id, args }]); 3271 if (!messages?.length) { 3272 console.error( 3273 "l10n.formatMessages returned an unexpected value for ID: ", 3274 id 3275 ); 3276 return; 3277 } 3278 3279 /** @type {L10nCachedMessage} */ 3280 let message = { value: messages[0].value, attributes: null }; 3281 if (messages[0].attributes) { 3282 // Convert `attributes` from an array of `{ name, value }` objects to one 3283 // object mapping names to values. 3284 message.attributes = messages[0].attributes.reduce( 3285 (valuesByName, { name, value }) => { 3286 valuesByName[name] = value; 3287 return valuesByName; 3288 }, 3289 {} 3290 ); 3291 } 3292 3293 this.#update({ id, args, message }); 3294 } 3295 3296 /** 3297 * Ensures that a string is the most recently cached for its ID. If the string 3298 * is not already cached, then it's fetched from Fluent. This is just a slight 3299 * optimization over `add` that avoids calling into Fluent unnecessarily. 3300 * 3301 * @param {object} options 3302 * Options 3303 * @param {string} options.id 3304 * The string's Fluent ID. 3305 * @param {object} [options.args] 3306 * The Fluent arguments as passed to `l10n.setAttributes`. Required if the 3307 * l10n string has arguments. 3308 */ 3309 async ensure({ id, args = undefined }) { 3310 let message = this.get({ id, args }); 3311 if (message) { 3312 await this.#update({ id, args, message }); 3313 } else { 3314 await this.add({ id, args }); 3315 } 3316 } 3317 3318 /** 3319 * A version of `ensure` that ensures multiple strings are cached at once. 3320 * 3321 * @param {object[]} objects 3322 * An array of objects as passed to `ensure()`. 3323 */ 3324 async ensureAll(objects) { 3325 let promises = []; 3326 for (let obj of objects) { 3327 promises.push(this.ensure(obj)); 3328 } 3329 await Promise.all(promises); 3330 } 3331 3332 /** 3333 * Removes a cached string. 3334 * 3335 * @param {object} options 3336 * Options 3337 * @param {string} options.id 3338 * The string's Fluent ID. 3339 * @param {object} [options.args] 3340 * The Fluent arguments as passed to `l10n.setAttributes`. Required if the 3341 * l10n string has arguments. 3342 */ 3343 delete({ id, args = undefined }) { 3344 let messagesByArgs = this.#messagesByArgsById.get(id); 3345 if (messagesByArgs) { 3346 messagesByArgs.delete(this.#argsKey(args)); 3347 if (!messagesByArgs.size) { 3348 this.#messagesByArgsById.delete(id); 3349 } 3350 } 3351 } 3352 3353 /** 3354 * Removes all cached strings. 3355 */ 3356 clear() { 3357 this.#messagesByArgsById.clear(); 3358 } 3359 3360 /** 3361 * Returns the number of cached messages. 3362 */ 3363 size() { 3364 return this.#messagesByArgsById 3365 .values() 3366 .reduce((total, messagesByArg) => total + messagesByArg.size, 0); 3367 } 3368 3369 /** 3370 * Sets an element's content or attribute to a cached l10n string. If the 3371 * string isn't cached, then this falls back to the usual 3372 * `document.l10n.setAttributes()` using the given l10n ID and args, which 3373 * means the string will pop in on a later animation frame. 3374 * 3375 * This also caches the string so that it will be ready the next time. It 3376 * returns a promise that will be resolved when the string has been cached. 3377 * Typically there's no need to await it unless you want to be sure the string 3378 * is cached before continuing. 3379 * 3380 * @param {Element} element 3381 * The l10n string will be applied to this element. 3382 * @param {object} options 3383 * Options object. 3384 * @param {string} options.id 3385 * The l10n string ID. 3386 * @param {object} [options.args] 3387 * The l10n string arguments. 3388 * @param {object} [options.argsHighlights] 3389 * If this is set, apply substring highlighting to the corresponding l10n 3390 * arguments in `args`. Each value in this object should be an array of 3391 * highlights as returned by `UrlbarUtils.getTokenMatches()` or 3392 * `UrlbarResult.getDisplayableValueAndHighlights()`. 3393 * @param {string} [options.attribute] 3394 * If the string applies to an attribute on the element, pass the name of 3395 * the attribute. The string in the Fluent file should define a value for 3396 * the attribute, like ".foo = My value". If the string applies to the 3397 * element's content, leave this undefined. 3398 * @param {boolean} [options.parseMarkup] 3399 * This controls whether the cached string is applied to the element's 3400 * `textContent` or its `innerHTML`. It's not relevant if the string is 3401 * applied to an attribute. Typically it should be set to true when the 3402 * string is expected to contain markup. When true, the cached string is 3403 * essentially assigned to the element's `innerHTML`. When false, it's 3404 * assigned to the element's `textContent`. 3405 * @returns {Promise} 3406 * A promise that's resolved when the string has been cached. You can ignore 3407 * it and do not need to await it unless you want to make sure the string is 3408 * cached before continuing. 3409 */ 3410 setElementL10n( 3411 element, 3412 { 3413 id, 3414 args = undefined, 3415 argsHighlights = undefined, 3416 attribute = undefined, 3417 parseMarkup = false, 3418 } 3419 ) { 3420 // If the message is cached, apply it to the element. 3421 let message = this.get({ id, args }); 3422 if (message) { 3423 if (message.attributes) { 3424 for (let [name, value] of Object.entries(message.attributes)) { 3425 element.setAttribute(name, value); 3426 } 3427 } 3428 if (typeof message.value == "string") { 3429 if (!parseMarkup) { 3430 element.textContent = message.value; 3431 } else { 3432 element.innerHTML = ""; 3433 element.append( 3434 lazy.parserUtils.parseFragment( 3435 message.value, 3436 Ci.nsIParserUtils.SanitizerDropNonCSSPresentation | 3437 Ci.nsIParserUtils.SanitizerDropForms | 3438 Ci.nsIParserUtils.SanitizerDropMedia, 3439 false, 3440 Services.io.newURI(element.ownerDocument.documentURI), 3441 element 3442 ) 3443 ); 3444 } 3445 } 3446 } 3447 3448 // If the message isn't cached and args highlights were specified, apply 3449 // them now. 3450 if (!message && !attribute && argsHighlights) { 3451 // To avoid contaminated args because we cache it, create a new instance. 3452 args = { ...args }; 3453 3454 let span = element.ownerDocument.createElement("span"); 3455 for (let key in argsHighlights) { 3456 UrlbarUtils.addTextContentWithHighlights( 3457 span, 3458 args[key], 3459 argsHighlights[key] 3460 ); 3461 args[key] = span.innerHTML; 3462 } 3463 } 3464 3465 // If an attribute was passed in, make sure it's allowed to be localized by 3466 // setting `data-l10n-attrs`. This isn't required for attrbutes already in 3467 // the Fluent allowlist but it doesn't hurt. 3468 if (attribute) { 3469 element.setAttribute("data-l10n-attrs", attribute); 3470 } else { 3471 element.removeAttribute("data-l10n-attrs"); 3472 } 3473 3474 // Set the l10n attributes. If the message wasn't cached, `DOMLocalization` 3475 // will do its asynchronous translation and the text content will pop in. If 3476 // the message was cached, then we already set the cached attributes and 3477 // text content above, but we set the l10n attributes anyway because some 3478 // tests rely on them being set. It shouldn't hurt anyway. 3479 element.ownerDocument.l10n.setAttributes(element, id, args); 3480 3481 // Cache the string. We specifically do not do this first and await it 3482 // because the whole point of the l10n cache is to synchronously update the 3483 // element's content when possible. Here, we return a promise rather than 3484 // making this function async and awaiting so it's clearer to callers that 3485 // they probably don't need to wait for caching to finish. 3486 return this.ensure({ id, args }); 3487 } 3488 3489 /** 3490 * Removes content and attributes set by `setElementL10n()`. 3491 * 3492 * @param {Element} element 3493 * The content and attributes will be removed from this element. 3494 * @param {object} [options] 3495 * Options object. 3496 * @param {string} [options.attribute] 3497 * If you passed an attribute to `setElementL10n()`, pass it here too. 3498 */ 3499 removeElementL10n(element, { attribute = undefined } = {}) { 3500 if (attribute) { 3501 element.removeAttribute(attribute); 3502 element.removeAttribute("data-l10n-attrs"); 3503 } else { 3504 element.textContent = ""; 3505 } 3506 element.removeAttribute("data-l10n-id"); 3507 element.removeAttribute("data-l10n-args"); 3508 } 3509 3510 /** 3511 * Observer method from Services.obs.addObserver. 3512 * 3513 * @param {nsISupports} subject 3514 * The subject of the notification. 3515 * @param {string} topic 3516 * The topic of the notification. 3517 */ 3518 async observe(subject, topic) { 3519 switch (topic) { 3520 case "intl:app-locales-changed": { 3521 this.clear(); 3522 break; 3523 } 3524 } 3525 } 3526 3527 /** 3528 * L10n ID => l10n args cache key => cached message object 3529 * 3530 * We rely on the fact that `Map` remembers insertion order to keep track of 3531 * which cache entries are least recent, per l10n ID. The inner `Map`s will 3532 * iterate their entries in order from least recently inserted to most 3533 * recently inserted, i.e., least recently cached to most recently cached. 3534 * 3535 * @type {Map<string, Map<string, L10nCachedMessage>>} 3536 */ 3537 #messagesByArgsById = new Map(); 3538 3539 /** 3540 * Max entries per l10n ID for this cache. 3541 * 3542 * @type {number} 3543 */ 3544 #maxEntriesPerId = L10nCache.MAX_ENTRIES_PER_ID; 3545 3546 /** 3547 * Inserts a message into the cache and makes it most recently cached. 3548 * 3549 * @param {object} options 3550 * Options 3551 * @param {string} options.id 3552 * The string's Fluent ID. 3553 * @param {object} options.args 3554 * The Fluent arguments as passed to `l10n.setAttributes`. 3555 * @param {L10nCachedMessage} options.message 3556 * The message to cache. 3557 */ 3558 #update({ id, args, message }) { 3559 let messagesByArgs = this.#messagesByArgsById.get(id); 3560 if (!messagesByArgs) { 3561 messagesByArgs = new Map(); 3562 this.#messagesByArgsById.set(id, messagesByArgs); 3563 } 3564 3565 // We rely on the fact that `Map` remembers insertion order to keep track of 3566 // which cache entries are least recent. To make `message` the most recent 3567 // for its ID, delete it from `messagesByArgs` (step 1) and then reinsert it 3568 // (step 2). That way it will move to the end of iteration. 3569 let argsKey = this.#argsKey(args); 3570 3571 // step 1 3572 messagesByArgs.delete(argsKey); 3573 3574 if (messagesByArgs.size == this.#maxEntriesPerId) { 3575 // The cache entries are full for this ID. Remove the least recently 3576 // cached entry, which will be the first entry returned by the map's 3577 // iterator. 3578 messagesByArgs.delete(messagesByArgs.keys().next().value); 3579 } 3580 3581 // step 2 3582 messagesByArgs.set(argsKey, message); 3583 } 3584 3585 /** 3586 * Returns a cache key for the inner `Maps` inside `#messagesByArgsById`. 3587 * These `Map`s are keyed on l10n args. 3588 * 3589 * @param {object} args 3590 * The Fluent arguments as passed to `l10n.setAttributes`. 3591 * @returns {string} 3592 * The args cache key. 3593 */ 3594 #argsKey(args) { 3595 // `JSON.stringify` doesn't guarantee a particular ordering of object 3596 // properties, so instead of stringifying `args` as is, sort its entries by 3597 // key and then pull out the values. The final key is a JSON'ed array of 3598 // sorted-by-key `args` values. 3599 let argValues = Object.entries(args ?? []) 3600 .sort(([key1], [key2]) => key1.localeCompare(key2)) 3601 .map(([_, value]) => value); 3602 return JSON.stringify(argValues); 3603 } 3604 } 3605 3606 /** 3607 * This class provides a way of serializing access to a resource. It's a queue 3608 * of callbacks (or "tasks") where each callback is called and awaited in order, 3609 * one at a time. 3610 */ 3611 export class TaskQueue { 3612 /** 3613 * @returns {Promise} 3614 * Resolves when the queue becomes empty. If the queue is already empty, 3615 * then a resolved promise is returned. 3616 */ 3617 get emptyPromise() { 3618 return this.#emptyPromise; 3619 } 3620 3621 /** 3622 * Adds a callback function to the task queue. The callback will be called 3623 * after all other callbacks before it in the queue. This method returns a 3624 * promise that will be resolved after awaiting the callback. The promise will 3625 * be resolved with the value returned by the callback. 3626 * 3627 * @param {Function} callback 3628 * The function to queue. 3629 * @returns {Promise} 3630 * Resolved after the task queue calls and awaits `callback`. It will be 3631 * resolved with the value returned by `callback`. If `callback` throws an 3632 * error, then it will be rejected with the error. 3633 */ 3634 queue(callback) { 3635 return new Promise((resolve, reject) => { 3636 this.#queue.push({ callback, resolve, reject }); 3637 if (this.#queue.length == 1) { 3638 this.#emptyDeferred = Promise.withResolvers(); 3639 this.#emptyPromise = this.#emptyDeferred.promise; 3640 this.#doNextTask(); 3641 } 3642 }); 3643 } 3644 3645 /** 3646 * Adds a callback function to the task queue that will be called on idle. 3647 * 3648 * @param {Function} callback 3649 * The function to queue. 3650 * @returns {Promise} 3651 * Resolved after the task queue calls and awaits `callback`. It will be 3652 * resolved with the value returned by `callback`. If `callback` throws an 3653 * error, then it will be rejected with the error. 3654 */ 3655 queueIdleCallback(callback) { 3656 return this.queue(async () => { 3657 await new Promise((resolve, reject) => { 3658 ChromeUtils.idleDispatch(async () => { 3659 try { 3660 let value = await callback(); 3661 resolve(value); 3662 } catch (error) { 3663 console.error(error); 3664 reject(error); 3665 } 3666 }); 3667 }); 3668 }); 3669 } 3670 3671 /** 3672 * Calls the next function in the task queue and recurses until the queue is 3673 * empty. Once empty, all empty callback functions are called. 3674 */ 3675 async #doNextTask() { 3676 if (!this.#queue.length) { 3677 this.#emptyDeferred.resolve(); 3678 this.#emptyDeferred = null; 3679 return; 3680 } 3681 3682 // Leave the callback in the queue while awaiting it. If we remove it now 3683 // the queue could become empty, and if `queue()` were called while we're 3684 // awaiting the callback, `#doNextTask()` would be re-entered. 3685 let { callback, resolve, reject } = this.#queue[0]; 3686 try { 3687 let value = await callback(); 3688 resolve(value); 3689 } catch (error) { 3690 console.error(error); 3691 reject(error); 3692 } 3693 this.#queue.shift(); 3694 this.#doNextTask(); 3695 } 3696 3697 #queue = []; 3698 #emptyDeferred = null; 3699 #emptyPromise = Promise.resolve(); 3700 }