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"use strict"; 6 7/** 8 * This module exports a provider that offers search engine suggestions. 9 */ 10 11var EXPORTED_SYMBOLS = ["UrlbarProviderSearchSuggestions"]; 12 13const { XPCOMUtils } = ChromeUtils.import( 14 "resource://gre/modules/XPCOMUtils.jsm" 15); 16XPCOMUtils.defineLazyModuleGetters(this, { 17 Log: "resource://gre/modules/Log.jsm", 18 PlacesSearchAutocompleteProvider: 19 "resource://gre/modules/PlacesSearchAutocompleteProvider.jsm", 20 SearchSuggestionController: 21 "resource://gre/modules/SearchSuggestionController.jsm", 22 Services: "resource://gre/modules/Services.jsm", 23 SkippableTimer: "resource:///modules/UrlbarUtils.jsm", 24 UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", 25 UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", 26 UrlbarResult: "resource:///modules/UrlbarResult.jsm", 27 UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", 28 UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", 29}); 30 31XPCOMUtils.defineLazyGetter(this, "logger", () => 32 Log.repository.getLogger("Urlbar.Provider.SearchSuggestions") 33); 34 35/** 36 * Returns whether the passed in string looks like a url. 37 * @param {string} str 38 * @param {boolean} [ignoreAlphanumericHosts] 39 * @returns {boolean} 40 * True if the query looks like a URL. 41 */ 42function looksLikeUrl(str, ignoreAlphanumericHosts = false) { 43 // Single word including special chars. 44 return ( 45 !UrlbarTokenizer.REGEXP_SPACES.test(str) && 46 (["/", "@", ":", "["].some(c => str.includes(c)) || 47 (ignoreAlphanumericHosts 48 ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str) 49 : str.includes("."))) 50 ); 51} 52 53/** 54 * Returns the portion of a string starting at the index where another string 55 * begins. 56 * 57 * @param {string} sourceStr 58 * The string to search within. 59 * @param {string} targetStr 60 * The string to search for. 61 * @returns {string} The substring within sourceStr starting at targetStr, or 62 * the empty string if targetStr does not occur in sourceStr. 63 */ 64function substringAt(sourceStr, targetStr) { 65 let index = sourceStr.indexOf(targetStr); 66 return index < 0 ? "" : sourceStr.substr(index); 67} 68 69/** 70 * Returns the portion of a string starting at the index where another string 71 * ends. 72 * 73 * @param {string} sourceStr 74 * The string to search within. 75 * @param {string} targetStr 76 * The string to search for. 77 * @returns {string} The substring within sourceStr where targetStr ends, or the 78 * empty string if targetStr does not occur in sourceStr. 79 */ 80function substringAfter(sourceStr, targetStr) { 81 let index = sourceStr.indexOf(targetStr); 82 return index < 0 ? "" : sourceStr.substr(index + targetStr.length); 83} 84 85/** 86 * Class used to create the provider. 87 */ 88class ProviderSearchSuggestions extends UrlbarProvider { 89 constructor() { 90 super(); 91 // Maps the running queries by queryContext. 92 this.queries = new Map(); 93 } 94 95 /** 96 * Returns the name of this provider. 97 * @returns {string} the name of this provider. 98 */ 99 get name() { 100 return "SearchSuggestions"; 101 } 102 103 /** 104 * Returns the type of this provider. 105 * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* 106 */ 107 get type() { 108 return UrlbarUtils.PROVIDER_TYPE.NETWORK; 109 } 110 111 /** 112 * Whether this provider should be invoked for the given context. 113 * If this method returns false, the providers manager won't start a query 114 * with this provider, to save on resources. 115 * @param {UrlbarQueryContext} queryContext The query context object 116 * @returns {boolean} Whether this provider should be invoked for the search. 117 */ 118 isActive(queryContext) { 119 // If the sources don't include search or the user used a restriction 120 // character other than search, don't allow any suggestions. 121 if ( 122 !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || 123 (queryContext.restrictSource && 124 queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) 125 ) { 126 return false; 127 } 128 129 // No suggestions for empty search strings. 130 if (!queryContext.searchString.trim()) { 131 return false; 132 } 133 134 return ( 135 this._getFormHistoryCount(queryContext) || 136 this._allowRemoteSuggestions(queryContext) 137 ); 138 } 139 140 /** 141 * Returns whether the user typed a token alias or a restriction token. We use 142 * this value to override the pref to disable search suggestions in the 143 * Urlbar. 144 * @param {UrlbarQueryContext} queryContext The query context object. 145 * @returns {boolean} True if the user typed a token alias or search 146 * restriction token. 147 */ 148 _isTokenOrRestrictionPresent(queryContext) { 149 return ( 150 queryContext.searchString.startsWith("@") || 151 (queryContext.restrictSource && 152 queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) || 153 queryContext.tokens.some( 154 t => t.type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH 155 ) 156 ); 157 } 158 159 /** 160 * Returns whether suggestions in general are allowed for a given query 161 * context. If this returns false, then we shouldn't fetch either form 162 * history or remote suggestions. Otherwise further checks are necessary to 163 * determine whether to allow either form history or remote suggestions; see 164 * _getFormHistoryCount and _allowRemoteSuggestions. 165 * 166 * @param {object} queryContext The query context object 167 * @returns {boolean} True if suggestions in general are allowed and false if 168 * not. 169 */ 170 _allowSuggestions(queryContext) { 171 if ( 172 !queryContext.allowSearchSuggestions || 173 // If the user typed a restriction token or token alias, we ignore the 174 // pref to disable suggestions in the Urlbar. 175 (!UrlbarPrefs.get("suggest.searches") && 176 !this._isTokenOrRestrictionPresent(queryContext)) || 177 !UrlbarPrefs.get("browser.search.suggest.enabled") || 178 (queryContext.isPrivate && 179 !UrlbarPrefs.get("browser.search.suggest.enabled.private")) 180 ) { 181 return false; 182 } 183 return true; 184 } 185 186 /** 187 * Returns whether remote suggestions are allowed for a given query context. 188 * 189 * @param {object} queryContext The query context object 190 * @returns {boolean} True if remote suggestions are allowed and false if not. 191 */ 192 _allowRemoteSuggestions(queryContext) { 193 // Check whether suggestions in general are allowed. 194 if (!this._allowSuggestions(queryContext)) { 195 return false; 196 } 197 198 // Skip all remaining checks and allow remote suggestions at this point if 199 // the user used a token alias or restriction token. We want "@engine query" 200 // to return suggestions from the engine. We'll return early from startQuery 201 // if the query doesn't match an alias. 202 if (this._isTokenOrRestrictionPresent(queryContext)) { 203 return true; 204 } 205 206 // If the user is just adding on to a query that previously didn't return 207 // many remote suggestions, we are unlikely to get any more results. 208 if ( 209 !!this._lastLowResultsSearchSuggestion && 210 queryContext.searchString.length > 211 this._lastLowResultsSearchSuggestion.length && 212 queryContext.searchString.startsWith(this._lastLowResultsSearchSuggestion) 213 ) { 214 return false; 215 } 216 217 // We're unlikely to get useful remote suggestions for single characters. 218 if (queryContext.searchString.length < 2) { 219 return false; 220 } 221 222 // Disallow remote suggestions if only an origin is typed to avoid 223 // disclosing information about sites the user visits. 224 if ( 225 queryContext.tokens.length == 1 && 226 queryContext.tokens[0].type == UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN 227 ) { 228 return false; 229 } 230 231 // Disallow remote suggestions for strings containing tokens that look like 232 // URLs or non-alphanumeric origins, to avoid disclosing information about 233 // networks or passwords. 234 if ( 235 queryContext.tokens.some( 236 t => 237 t.type == UrlbarTokenizer.TYPE.POSSIBLE_URL || 238 (t.type == UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN && 239 !UrlbarTokenizer.REGEXP_SINGLE_WORD_HOST.test(t.value)) 240 ) 241 ) { 242 return false; 243 } 244 245 // Allow remote suggestions. 246 return true; 247 } 248 249 /** 250 * Starts querying. 251 * @param {object} queryContext The query context object 252 * @param {function} addCallback Callback invoked by the provider to add a new 253 * result. 254 * @returns {Promise} resolved when the query stops. 255 */ 256 async startQuery(queryContext, addCallback) { 257 logger.info(`Starting query for ${queryContext.searchString}`); 258 let instance = {}; 259 this.queries.set(queryContext, instance); 260 261 let trimmedOriginalSearchString = queryContext.searchString.trim(); 262 263 let aliasEngine = await this._maybeGetAlias(queryContext); 264 if (!aliasEngine) { 265 // Autofill matches queries starting with "@" to token alias engines. 266 // If the string starts with "@", but an alias engine is not yet 267 // matched, then autofill might still be filtering token alias 268 // engine results. We don't want to mix search suggestions with those 269 // engine results, so we return early. See bug 1551049 comment 1 for 270 // discussion on how to improve this behavior. 271 if (queryContext.searchString.startsWith("@")) { 272 return; 273 } 274 } 275 276 let query = aliasEngine 277 ? aliasEngine.query 278 : substringAt(queryContext.searchString, queryContext.tokens[0].value); 279 if (!query) { 280 return; 281 } 282 283 let leadingRestrictionToken = null; 284 if ( 285 UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && 286 (queryContext.tokens.length > 1 || 287 queryContext.tokens[0].type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH) 288 ) { 289 leadingRestrictionToken = queryContext.tokens[0].value; 290 } 291 292 // If the heuristic result is a search engine result with an empty query 293 // and we have either a token alias or the search restriction char, then 294 // we're done. 295 // For the restriction character case, also consider a single char query 296 // or just the char itself, anyway we don't return search suggestions 297 // unless at least 2 chars have been typed. Thus "?__" and "? a" should 298 // finish here, while "?aa" should continue. 299 let emptyQueryTokenAlias = 300 aliasEngine && aliasEngine.isTokenAlias && !aliasEngine.query; 301 let emptySearchRestriction = 302 trimmedOriginalSearchString.length <= 3 && 303 leadingRestrictionToken == UrlbarTokenizer.RESTRICT.SEARCH && 304 /\s*\S?$/.test(trimmedOriginalSearchString); 305 if (emptySearchRestriction || emptyQueryTokenAlias) { 306 return; 307 } 308 309 // Strip a leading search restriction char, because we prepend it to text 310 // when the search shortcut is used and it's not user typed. Don't strip 311 // other restriction chars, so that it's possible to search for things 312 // including one of those (e.g. "c#"). 313 if (leadingRestrictionToken === UrlbarTokenizer.RESTRICT.SEARCH) { 314 query = substringAfter(query, leadingRestrictionToken).trim(); 315 } 316 317 // Find our search engine. It may have already been set with an alias. 318 let engine; 319 if (aliasEngine) { 320 engine = aliasEngine.engine; 321 } else { 322 engine = queryContext.engineName 323 ? Services.search.getEngineByName(queryContext.engineName) 324 : await PlacesSearchAutocompleteProvider.currentEngine( 325 queryContext.isPrivate 326 ); 327 if (!engine) { 328 return; 329 } 330 } 331 332 let alias = (aliasEngine && aliasEngine.alias) || ""; 333 let results = await this._fetchSearchSuggestions( 334 queryContext, 335 engine, 336 query, 337 alias 338 ); 339 340 if (!results || !this.queries.has(queryContext)) { 341 return; 342 } 343 344 for (let result of results) { 345 addCallback(this, result); 346 } 347 348 this.queries.delete(queryContext); 349 } 350 351 /** 352 * Gets the provider's priority. 353 * @param {UrlbarQueryContext} queryContext The query context object 354 * @returns {number} The provider's priority for the given query. 355 */ 356 getPriority(queryContext) { 357 return 0; 358 } 359 360 /** 361 * Cancels a running query. 362 * @param {object} queryContext The query context object 363 */ 364 cancelQuery(queryContext) { 365 logger.info(`Canceling query for ${queryContext.searchString}`); 366 367 if (this._suggestionsController) { 368 this._suggestionsController.stop(); 369 this._suggestionsController = null; 370 } 371 372 this.queries.delete(queryContext); 373 } 374 375 /** 376 * Returns the number of form history entries we should fetch from the 377 * suggestions controller for a given query context. 378 * 379 * @param {object} queryContext The query context object 380 * @returns {number} The number of form history results we should fetch. 381 */ 382 _getFormHistoryCount(queryContext) { 383 if (!this._allowSuggestions(queryContext)) { 384 return 0; 385 } 386 387 let count = UrlbarPrefs.get("maxHistoricalSearchSuggestions"); 388 if (!count) { 389 return 0; 390 } 391 392 // If there's a form history entry that equals the search string, the search 393 // suggestions controller will include it, and we'll make a result for it. 394 // If the heuristic result ends up being a search result, the muxer will 395 // exclude the form history result since it dupes the heuristic, and the 396 // final list of results would be left with `count` - 1 form history results 397 // instead of `count`. Therefore we request `count` + 1 entries. The muxer 398 // will dedupe and limit the final form history count as appropriate. 399 return count + 1; 400 } 401 402 async _fetchSearchSuggestions(queryContext, engine, searchString, alias) { 403 if (!engine || !searchString) { 404 return null; 405 } 406 407 this._suggestionsController = new SearchSuggestionController(); 408 this._suggestionsController.formHistoryParam = queryContext.formHistoryName; 409 this._suggestionsController.maxLocalResults = this._getFormHistoryCount( 410 queryContext 411 ); 412 413 let allowRemote = this._allowRemoteSuggestions(queryContext); 414 415 // Request maxResults + 1 remote suggestions for the same reason we request 416 // maxHistoricalSearchSuggestions + 1 form history entries; see 417 // _getFormHistoryCount. We allow for the possibility that the engine may 418 // return a suggestion that's the same as the search string. 419 this._suggestionsController.maxRemoteResults = allowRemote 420 ? queryContext.maxResults + 1 421 : 0; 422 423 this._suggestionsFetchCompletePromise = this._suggestionsController.fetch( 424 searchString, 425 queryContext.isPrivate, 426 engine, 427 queryContext.userContextId 428 ); 429 430 // See `SearchSuggestionsController.fetch` documentation for a description 431 // of `fetchData`. 432 let fetchData = await this._suggestionsFetchCompletePromise; 433 // The fetch was canceled. 434 if (!fetchData) { 435 return null; 436 } 437 438 let results = []; 439 440 for (let entry of fetchData.local) { 441 results.push( 442 new UrlbarResult( 443 UrlbarUtils.RESULT_TYPE.SEARCH, 444 UrlbarUtils.RESULT_SOURCE.HISTORY, 445 ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { 446 engine: engine.name, 447 suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], 448 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 449 }) 450 ) 451 ); 452 } 453 454 // If we don't return many results, then keep track of the query. If the 455 // user just adds on to the query, we won't fetch more suggestions if the 456 // query is very long since we are unlikely to get any. 457 if ( 458 allowRemote && 459 !fetchData.remote.length && 460 searchString.length > UrlbarPrefs.get("maxCharsForSearchSuggestions") 461 ) { 462 this._lastLowResultsSearchSuggestion = searchString; 463 } 464 465 // If we have only tail suggestions, we only show them if we have no other 466 // results. We need to wait for other results to arrive to avoid flickering. 467 // We will wait for this timer unless we have suggestions that don't have a 468 // tail. 469 let tailTimer = new SkippableTimer({ 470 name: "ProviderSearchSuggestions", 471 time: 100, 472 logger, 473 }); 474 475 for (let entry of fetchData.remote) { 476 if (looksLikeUrl(entry.value)) { 477 continue; 478 } 479 480 if (entry.tail && entry.tailOffsetIndex < 0) { 481 Cu.reportError( 482 `Error in tail suggestion parsing. Value: ${entry.value}, tail: ${entry.tail}.` 483 ); 484 continue; 485 } 486 487 let tail = entry.tail; 488 let tailPrefix = entry.matchPrefix; 489 490 // Skip tail suggestions if the pref is disabled. 491 if (tail && !UrlbarPrefs.get("richSuggestions.tail")) { 492 continue; 493 } 494 495 if (!tail) { 496 await tailTimer.fire().catch(Cu.reportError); 497 } 498 499 try { 500 results.push( 501 new UrlbarResult( 502 UrlbarUtils.RESULT_TYPE.SEARCH, 503 UrlbarUtils.RESULT_SOURCE.SEARCH, 504 ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { 505 engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], 506 suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], 507 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 508 tailPrefix, 509 tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], 510 tailOffsetIndex: entry.tailOffsetIndex, 511 keyword: [alias ? alias : undefined, UrlbarUtils.HIGHLIGHT.TYPED], 512 query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE], 513 isSearchHistory: false, 514 icon: [engine.iconURI && !entry.value ? engine.iconURI.spec : ""], 515 keywordOffer: UrlbarUtils.KEYWORD_OFFER.NONE, 516 }) 517 ) 518 ); 519 } catch (err) { 520 Cu.reportError(err); 521 continue; 522 } 523 } 524 525 await tailTimer.promise; 526 return results; 527 } 528 529 /** 530 * Searches for an engine alias given the queryContext. 531 * @param {UrlbarQueryContext} queryContext 532 * @returns {object} aliasEngine 533 * A representation of the aliased engine. Null if there's no match. 534 * @returns {nsISearchEngine} aliasEngine.engine 535 * @returns {string} aliasEngine.alias 536 * @returns {string} aliasEngine.query 537 * @returns {boolean} aliasEngine.isTokenAlias 538 * 539 */ 540 async _maybeGetAlias(queryContext) { 541 if ( 542 queryContext.restrictSource && 543 queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH && 544 queryContext.engineName && 545 !queryContext.searchString.startsWith("@") 546 ) { 547 // If an engineName was passed in from the queryContext in restrict mode, 548 // we'll set our engine in startQuery based on engineName. 549 return null; 550 } 551 552 let possibleAlias = queryContext.tokens[0]?.value.trim(); 553 // The "@" character on its own is handled by UnifiedComplete and returns a 554 // list of every available token alias. 555 if (!possibleAlias || possibleAlias == "@") { 556 return null; 557 } 558 559 // Check if the user entered an engine alias directly. 560 let engineMatch = await PlacesSearchAutocompleteProvider.engineForAlias( 561 possibleAlias 562 ); 563 if (engineMatch) { 564 return { 565 engine: engineMatch, 566 alias: possibleAlias, 567 query: substringAfter(queryContext.searchString, possibleAlias).trim(), 568 isTokenAlias: possibleAlias.startsWith("@"), 569 }; 570 } 571 572 // Check if the user is matching a token alias. 573 let engines = await PlacesSearchAutocompleteProvider.tokenAliasEngines(); 574 if (!engines || !engines.length) { 575 return null; 576 } 577 578 for (let { engine, tokenAliases } of engines) { 579 if (tokenAliases.includes(possibleAlias)) { 580 return { 581 engine, 582 alias: possibleAlias, 583 query: substringAfter( 584 queryContext.searchString, 585 possibleAlias 586 ).trim(), 587 isTokenAlias: true, 588 }; 589 } 590 } 591 return null; 592 } 593} 594 595var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions(); 596