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 SearchSuggestionController: 18 "resource://gre/modules/SearchSuggestionController.jsm", 19 Services: "resource://gre/modules/Services.jsm", 20 SkippableTimer: "resource:///modules/UrlbarUtils.jsm", 21 UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", 22 UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", 23 UrlbarResult: "resource:///modules/UrlbarResult.jsm", 24 UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", 25 UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", 26 UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", 27}); 28 29/** 30 * Returns whether the passed in string looks like a url. 31 * @param {string} str 32 * @param {boolean} [ignoreAlphanumericHosts] 33 * @returns {boolean} 34 * True if the query looks like a URL. 35 */ 36function looksLikeUrl(str, ignoreAlphanumericHosts = false) { 37 // Single word including special chars. 38 return ( 39 !UrlbarTokenizer.REGEXP_SPACES.test(str) && 40 (["/", "@", ":", "["].some(c => str.includes(c)) || 41 (ignoreAlphanumericHosts 42 ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str) 43 : str.includes("."))) 44 ); 45} 46 47/** 48 * Class used to create the provider. 49 */ 50class ProviderSearchSuggestions extends UrlbarProvider { 51 constructor() { 52 super(); 53 } 54 55 /** 56 * Returns the name of this provider. 57 * @returns {string} the name of this provider. 58 */ 59 get name() { 60 return "SearchSuggestions"; 61 } 62 63 /** 64 * Returns the type of this provider. 65 * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* 66 */ 67 get type() { 68 return UrlbarUtils.PROVIDER_TYPE.NETWORK; 69 } 70 71 /** 72 * Whether this provider should be invoked for the given context. 73 * If this method returns false, the providers manager won't start a query 74 * with this provider, to save on resources. 75 * @param {UrlbarQueryContext} queryContext The query context object 76 * @returns {boolean} Whether this provider should be invoked for the search. 77 */ 78 isActive(queryContext) { 79 // If the sources don't include search or the user used a restriction 80 // character other than search, don't allow any suggestions. 81 if ( 82 !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || 83 (queryContext.restrictSource && 84 queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) 85 ) { 86 return false; 87 } 88 89 // No suggestions for empty search strings, unless we are restricting to 90 // search. 91 if ( 92 !queryContext.trimmedSearchString && 93 !this._isTokenOrRestrictionPresent(queryContext) 94 ) { 95 return false; 96 } 97 98 if (!this._allowSuggestions(queryContext)) { 99 return false; 100 } 101 102 let wantsLocalSuggestions = 103 UrlbarPrefs.get("maxHistoricalSearchSuggestions") && 104 (queryContext.trimmedSearchString || 105 UrlbarPrefs.get("update2.emptySearchBehavior") != 0); 106 107 return wantsLocalSuggestions || this._allowRemoteSuggestions(queryContext); 108 } 109 110 /** 111 * Returns whether the user typed a token alias or restriction token, or is in 112 * search mode. We use this value to override the pref to disable search 113 * suggestions in the Urlbar. 114 * @param {UrlbarQueryContext} queryContext The query context object. 115 * @returns {boolean} True if the user typed a token alias or search 116 * restriction token. 117 */ 118 _isTokenOrRestrictionPresent(queryContext) { 119 return ( 120 queryContext.searchString.startsWith("@") || 121 (queryContext.restrictSource && 122 queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) || 123 queryContext.tokens.some( 124 t => t.type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH 125 ) || 126 (queryContext.searchMode && 127 queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH)) 128 ); 129 } 130 131 /** 132 * Returns whether suggestions in general are allowed for a given query 133 * context. If this returns false, then we shouldn't fetch either form 134 * history or remote suggestions. 135 * 136 * @param {object} queryContext The query context object 137 * @returns {boolean} True if suggestions in general are allowed and false if 138 * not. 139 */ 140 _allowSuggestions(queryContext) { 141 if ( 142 // If the user typed a restriction token or token alias, we ignore the 143 // pref to disable suggestions in the Urlbar. 144 (!UrlbarPrefs.get("suggest.searches") && 145 !this._isTokenOrRestrictionPresent(queryContext)) || 146 !UrlbarPrefs.get("browser.search.suggest.enabled") || 147 (queryContext.isPrivate && 148 !UrlbarPrefs.get("browser.search.suggest.enabled.private")) 149 ) { 150 return false; 151 } 152 return true; 153 } 154 155 /** 156 * Returns whether remote suggestions are allowed for a given query context. 157 * 158 * @param {object} queryContext The query context object 159 * @param {string} [searchString] The effective search string without 160 * restriction tokens or aliases. Defaults to the context searchString. 161 * @returns {boolean} True if remote suggestions are allowed and false if not. 162 */ 163 _allowRemoteSuggestions( 164 queryContext, 165 searchString = queryContext.searchString 166 ) { 167 // This is checked by `queryContext.allowRemoteResults` below, but we can 168 // short-circuit that call with the `_isTokenOrRestrictionPresent` block 169 // before that. Make sure we don't allow remote suggestions if this is set. 170 if (queryContext.prohibitRemoteResults) { 171 return false; 172 } 173 174 // TODO (Bug 1626964): Support zero prefix suggestions. 175 if (!searchString.trim()) { 176 return false; 177 } 178 179 // Skip all remaining checks and allow remote suggestions at this point if 180 // the user used a token alias or restriction token. We want "@engine query" 181 // to return suggestions from the engine. We'll return early from startQuery 182 // if the query doesn't match an alias. 183 if (this._isTokenOrRestrictionPresent(queryContext)) { 184 return true; 185 } 186 187 // If the user is just adding on to a query that previously didn't return 188 // many remote suggestions, we are unlikely to get any more results. 189 if ( 190 !!this._lastLowResultsSearchSuggestion && 191 searchString.length > this._lastLowResultsSearchSuggestion.length && 192 searchString.startsWith(this._lastLowResultsSearchSuggestion) 193 ) { 194 return false; 195 } 196 197 return queryContext.allowRemoteResults(searchString); 198 } 199 200 /** 201 * Starts querying. 202 * @param {object} queryContext The query context object 203 * @param {function} addCallback Callback invoked by the provider to add a new 204 * result. 205 * @returns {Promise} resolved when the query stops. 206 */ 207 async startQuery(queryContext, addCallback) { 208 let instance = this.queryInstance; 209 210 let aliasEngine = await this._maybeGetAlias(queryContext); 211 if (!aliasEngine) { 212 // Autofill matches queries starting with "@" to token alias engines. 213 // If the string starts with "@", but an alias engine is not yet 214 // matched, then autofill might still be filtering token alias 215 // engine results. We don't want to mix search suggestions with those 216 // engine results, so we return early. See bug 1551049 comment 1 for 217 // discussion on how to improve this behavior. 218 if (queryContext.searchString.startsWith("@")) { 219 return; 220 } 221 } 222 223 let query = aliasEngine 224 ? aliasEngine.query 225 : UrlbarUtils.substringAt( 226 queryContext.searchString, 227 queryContext.tokens[0]?.value || "" 228 ).trim(); 229 230 let leadingRestrictionToken = null; 231 if ( 232 UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && 233 (queryContext.tokens.length > 1 || 234 queryContext.tokens[0].type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH) 235 ) { 236 leadingRestrictionToken = queryContext.tokens[0].value; 237 } 238 239 // Strip a leading search restriction char, because we prepend it to text 240 // when the search shortcut is used and it's not user typed. Don't strip 241 // other restriction chars, so that it's possible to search for things 242 // including one of those (e.g. "c#"). 243 if (leadingRestrictionToken === UrlbarTokenizer.RESTRICT.SEARCH) { 244 query = UrlbarUtils.substringAfter(query, leadingRestrictionToken).trim(); 245 } 246 247 // Find our search engine. It may have already been set with an alias. 248 let engine; 249 if (aliasEngine) { 250 engine = aliasEngine.engine; 251 } else if (queryContext.searchMode?.engineName) { 252 engine = Services.search.getEngineByName( 253 queryContext.searchMode.engineName 254 ); 255 } else { 256 engine = UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); 257 } 258 259 if (!engine) { 260 return; 261 } 262 263 let alias = (aliasEngine && aliasEngine.alias) || ""; 264 let results = await this._fetchSearchSuggestions( 265 queryContext, 266 engine, 267 query, 268 alias 269 ); 270 271 if (!results || instance != this.queryInstance) { 272 return; 273 } 274 275 for (let result of results) { 276 addCallback(this, result); 277 } 278 } 279 280 /** 281 * Gets the provider's priority. 282 * @param {UrlbarQueryContext} queryContext The query context object 283 * @returns {number} The provider's priority for the given query. 284 */ 285 getPriority(queryContext) { 286 return 0; 287 } 288 289 /** 290 * Cancels a running query. 291 * @param {object} queryContext The query context object 292 */ 293 cancelQuery(queryContext) { 294 if (this._suggestionsController) { 295 this._suggestionsController.stop(); 296 this._suggestionsController = null; 297 } 298 } 299 300 async _fetchSearchSuggestions(queryContext, engine, searchString, alias) { 301 if (!engine) { 302 return null; 303 } 304 305 this._suggestionsController = new SearchSuggestionController(); 306 this._suggestionsController.formHistoryParam = queryContext.formHistoryName; 307 308 // If there's a form history entry that equals the search string, the search 309 // suggestions controller will include it, and we'll make a result for it. 310 // If the heuristic result ends up being a search result, the muxer will 311 // discard the form history result since it dupes the heuristic, and the 312 // final list of results would be left with `count` - 1 form history results 313 // instead of `count`. Therefore we request `count` + 1 entries. The muxer 314 // will dedupe and limit the final form history count as appropriate. 315 this._suggestionsController.maxLocalResults = queryContext.maxResults + 1; 316 317 // Request maxResults + 1 remote suggestions for the same reason we request 318 // maxResults + 1 form history entries. 319 let allowRemote = this._allowRemoteSuggestions(queryContext, searchString); 320 this._suggestionsController.maxRemoteResults = allowRemote 321 ? queryContext.maxResults + 1 322 : 0; 323 324 this._suggestionsFetchCompletePromise = this._suggestionsController.fetch( 325 searchString, 326 queryContext.isPrivate, 327 engine, 328 queryContext.userContextId, 329 this._isTokenOrRestrictionPresent(queryContext), 330 false 331 ); 332 333 // See `SearchSuggestionsController.fetch` documentation for a description 334 // of `fetchData`. 335 let fetchData = await this._suggestionsFetchCompletePromise; 336 // The fetch was canceled. 337 if (!fetchData) { 338 return null; 339 } 340 341 let results = []; 342 343 // maxHistoricalSearchSuggestions used to determine the initial number of 344 // form history results, with the special case where zero means to never 345 // show form history at all. With the introduction of flexed result 346 // groups, we now use it only as a boolean: Zero means don't show form 347 // history at all (as before), non-zero means show it. 348 if (UrlbarPrefs.get("maxHistoricalSearchSuggestions")) { 349 for (let entry of fetchData.local) { 350 results.push(makeFormHistoryResult(queryContext, engine, entry)); 351 } 352 } 353 354 // If we don't return many results, then keep track of the query. If the 355 // user just adds on to the query, we won't fetch more suggestions if the 356 // query is very long since we are unlikely to get any. 357 if ( 358 allowRemote && 359 !fetchData.remote.length && 360 searchString.length > UrlbarPrefs.get("maxCharsForSearchSuggestions") 361 ) { 362 this._lastLowResultsSearchSuggestion = searchString; 363 } 364 365 // If we have only tail suggestions, we only show them if we have no other 366 // results. We need to wait for other results to arrive to avoid flickering. 367 // We will wait for this timer unless we have suggestions that don't have a 368 // tail. 369 let tailTimer = new SkippableTimer({ 370 name: "ProviderSearchSuggestions", 371 time: 100, 372 logger: this.logger, 373 }); 374 375 for (let entry of fetchData.remote) { 376 if (looksLikeUrl(entry.value)) { 377 continue; 378 } 379 380 if (entry.tail && entry.tailOffsetIndex < 0) { 381 this.logger.error( 382 `Error in tail suggestion parsing. Value: ${entry.value}, tail: ${entry.tail}.` 383 ); 384 continue; 385 } 386 387 let tail = entry.tail; 388 let tailPrefix = entry.matchPrefix; 389 390 // Skip tail suggestions if the pref is disabled. 391 if (tail && !UrlbarPrefs.get("richSuggestions.tail")) { 392 continue; 393 } 394 395 if (!tail) { 396 await tailTimer.fire().catch(ex => this.logger.error(ex)); 397 } 398 399 try { 400 results.push( 401 new UrlbarResult( 402 UrlbarUtils.RESULT_TYPE.SEARCH, 403 UrlbarUtils.RESULT_SOURCE.SEARCH, 404 ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { 405 engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], 406 suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], 407 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 408 tailPrefix, 409 tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], 410 tailOffsetIndex: tail ? entry.tailOffsetIndex : undefined, 411 keyword: [alias ? alias : undefined, UrlbarUtils.HIGHLIGHT.TYPED], 412 query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE], 413 icon: !entry.value ? engine.iconURI?.spec : undefined, 414 }) 415 ) 416 ); 417 } catch (err) { 418 this.logger.error(err); 419 continue; 420 } 421 } 422 423 await tailTimer.promise; 424 return results; 425 } 426 427 /** 428 * Searches for an engine alias given the queryContext. 429 * @param {UrlbarQueryContext} queryContext 430 * @returns {object} aliasEngine 431 * A representation of the aliased engine. Null if there's no match. 432 * @returns {nsISearchEngine} aliasEngine.engine 433 * @returns {string} aliasEngine.alias 434 * @returns {string} aliasEngine.query 435 * @returns {object} { engine, alias, query } 436 * 437 */ 438 async _maybeGetAlias(queryContext) { 439 if (queryContext.searchMode) { 440 // If we're in search mode, don't try to parse an alias at all. 441 return null; 442 } 443 444 let possibleAlias = queryContext.tokens[0]?.value; 445 // "@" on its own is handled by UrlbarProviderTokenAliasEngines and returns 446 // a list of every available token alias. 447 if (!possibleAlias || possibleAlias == "@") { 448 return null; 449 } 450 451 let query = UrlbarUtils.substringAfter( 452 queryContext.searchString, 453 possibleAlias 454 ); 455 456 // Match an alias only when it has a space after it. If there's no trailing 457 // space, then continue to treat it as part of the search string. 458 if (!UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { 459 return null; 460 } 461 462 // Check if the user entered an engine alias directly. 463 let engineMatch = await UrlbarSearchUtils.engineForAlias(possibleAlias); 464 if (engineMatch) { 465 return { 466 engine: engineMatch, 467 alias: possibleAlias, 468 query: query.trim(), 469 }; 470 } 471 472 return null; 473 } 474} 475 476function makeFormHistoryResult(queryContext, engine, entry) { 477 return new UrlbarResult( 478 UrlbarUtils.RESULT_TYPE.SEARCH, 479 UrlbarUtils.RESULT_SOURCE.HISTORY, 480 ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { 481 engine: engine.name, 482 suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], 483 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 484 }) 485 ); 486} 487 488var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions(); 489