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 !queryContext.allowSearchSuggestions || 143 // If the user typed a restriction token or token alias, we ignore the 144 // pref to disable suggestions in the Urlbar. 145 (!UrlbarPrefs.get("suggest.searches") && 146 !this._isTokenOrRestrictionPresent(queryContext)) || 147 !UrlbarPrefs.get("browser.search.suggest.enabled") || 148 (queryContext.isPrivate && 149 !UrlbarPrefs.get("browser.search.suggest.enabled.private")) 150 ) { 151 return false; 152 } 153 return true; 154 } 155 156 /** 157 * Returns whether remote suggestions are allowed for a given query context. 158 * 159 * @param {object} queryContext The query context object 160 * @param {string} [searchString] The effective search string without 161 * restriction tokens or aliases. Defaults to the context searchString. 162 * @returns {boolean} True if remote suggestions are allowed and false if not. 163 */ 164 _allowRemoteSuggestions( 165 queryContext, 166 searchString = queryContext.searchString 167 ) { 168 // TODO (Bug 1626964): Support zero prefix suggestions. 169 if (!searchString.trim()) { 170 return false; 171 } 172 173 // Skip all remaining checks and allow remote suggestions at this point if 174 // the user used a token alias or restriction token. We want "@engine query" 175 // to return suggestions from the engine. We'll return early from startQuery 176 // if the query doesn't match an alias. 177 if (this._isTokenOrRestrictionPresent(queryContext)) { 178 return true; 179 } 180 181 // If the user is just adding on to a query that previously didn't return 182 // many remote suggestions, we are unlikely to get any more results. 183 if ( 184 !!this._lastLowResultsSearchSuggestion && 185 searchString.length > this._lastLowResultsSearchSuggestion.length && 186 searchString.startsWith(this._lastLowResultsSearchSuggestion) 187 ) { 188 return false; 189 } 190 191 // We're unlikely to get useful remote suggestions for a single character. 192 if (searchString.length < 2) { 193 return false; 194 } 195 196 // Disallow remote suggestions if only an origin is typed to avoid 197 // disclosing information about sites the user visits. This also catches 198 // partially-typed origins, like mozilla.o, because the URIFixup check 199 // below can't validate those. 200 if ( 201 queryContext.tokens.length == 1 && 202 queryContext.tokens[0].type == UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN 203 ) { 204 return false; 205 } 206 207 // Disallow remote suggestions for strings containing tokens that look like 208 // URIs, to avoid disclosing information about networks or passwords. 209 if (queryContext.fixupInfo?.href && !queryContext.fixupInfo?.isSearch) { 210 return false; 211 } 212 213 // Allow remote suggestions. 214 return true; 215 } 216 217 /** 218 * Starts querying. 219 * @param {object} queryContext The query context object 220 * @param {function} addCallback Callback invoked by the provider to add a new 221 * result. 222 * @returns {Promise} resolved when the query stops. 223 */ 224 async startQuery(queryContext, addCallback) { 225 let instance = this.queryInstance; 226 227 let aliasEngine = await this._maybeGetAlias(queryContext); 228 if (!aliasEngine) { 229 // Autofill matches queries starting with "@" to token alias engines. 230 // If the string starts with "@", but an alias engine is not yet 231 // matched, then autofill might still be filtering token alias 232 // engine results. We don't want to mix search suggestions with those 233 // engine results, so we return early. See bug 1551049 comment 1 for 234 // discussion on how to improve this behavior. 235 if (queryContext.searchString.startsWith("@")) { 236 return; 237 } 238 } 239 240 let query = aliasEngine 241 ? aliasEngine.query 242 : UrlbarUtils.substringAt( 243 queryContext.searchString, 244 queryContext.tokens[0]?.value || "" 245 ).trim(); 246 247 let leadingRestrictionToken = null; 248 if ( 249 UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && 250 (queryContext.tokens.length > 1 || 251 queryContext.tokens[0].type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH) 252 ) { 253 leadingRestrictionToken = queryContext.tokens[0].value; 254 } 255 256 // Strip a leading search restriction char, because we prepend it to text 257 // when the search shortcut is used and it's not user typed. Don't strip 258 // other restriction chars, so that it's possible to search for things 259 // including one of those (e.g. "c#"). 260 if (leadingRestrictionToken === UrlbarTokenizer.RESTRICT.SEARCH) { 261 query = UrlbarUtils.substringAfter(query, leadingRestrictionToken).trim(); 262 } 263 264 // Find our search engine. It may have already been set with an alias. 265 let engine; 266 if (aliasEngine) { 267 engine = aliasEngine.engine; 268 } else if (queryContext.searchMode?.engineName) { 269 engine = Services.search.getEngineByName( 270 queryContext.searchMode.engineName 271 ); 272 } else { 273 engine = UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); 274 } 275 276 if (!engine) { 277 return; 278 } 279 280 let alias = (aliasEngine && aliasEngine.alias) || ""; 281 let results = await this._fetchSearchSuggestions( 282 queryContext, 283 engine, 284 query, 285 alias 286 ); 287 288 if (!results || instance != this.queryInstance) { 289 return; 290 } 291 292 for (let result of results) { 293 addCallback(this, result); 294 } 295 } 296 297 /** 298 * Gets the provider's priority. 299 * @param {UrlbarQueryContext} queryContext The query context object 300 * @returns {number} The provider's priority for the given query. 301 */ 302 getPriority(queryContext) { 303 return 0; 304 } 305 306 /** 307 * Cancels a running query. 308 * @param {object} queryContext The query context object 309 */ 310 cancelQuery(queryContext) { 311 if (this._suggestionsController) { 312 this._suggestionsController.stop(); 313 this._suggestionsController = null; 314 } 315 } 316 317 async _fetchSearchSuggestions(queryContext, engine, searchString, alias) { 318 if (!engine) { 319 return null; 320 } 321 322 this._suggestionsController = new SearchSuggestionController(); 323 this._suggestionsController.formHistoryParam = queryContext.formHistoryName; 324 325 // If there's a form history entry that equals the search string, the search 326 // suggestions controller will include it, and we'll make a result for it. 327 // If the heuristic result ends up being a search result, the muxer will 328 // discard the form history result since it dupes the heuristic, and the 329 // final list of results would be left with `count` - 1 form history results 330 // instead of `count`. Therefore we request `count` + 1 entries. The muxer 331 // will dedupe and limit the final form history count as appropriate. 332 this._suggestionsController.maxLocalResults = queryContext.maxResults + 1; 333 334 // Request maxResults + 1 remote suggestions for the same reason we request 335 // maxResults + 1 form history entries. 336 let allowRemote = this._allowRemoteSuggestions(queryContext, searchString); 337 this._suggestionsController.maxRemoteResults = allowRemote 338 ? queryContext.maxResults + 1 339 : 0; 340 341 this._suggestionsFetchCompletePromise = this._suggestionsController.fetch( 342 searchString, 343 queryContext.isPrivate, 344 engine, 345 queryContext.userContextId, 346 this._isTokenOrRestrictionPresent(queryContext), 347 false 348 ); 349 350 // See `SearchSuggestionsController.fetch` documentation for a description 351 // of `fetchData`. 352 let fetchData = await this._suggestionsFetchCompletePromise; 353 // The fetch was canceled. 354 if (!fetchData) { 355 return null; 356 } 357 358 let results = []; 359 360 // maxHistoricalSearchSuggestions used to determine the initial number of 361 // form history results, with the special case where zero means to never 362 // show form history at all. With the introduction of flexed result 363 // buckets, we now use it only as a boolean: Zero means don't show form 364 // history at all (as before), non-zero means show it. 365 if (UrlbarPrefs.get("maxHistoricalSearchSuggestions")) { 366 for (let entry of fetchData.local) { 367 results.push(makeFormHistoryResult(queryContext, engine, entry)); 368 } 369 } 370 371 // If we don't return many results, then keep track of the query. If the 372 // user just adds on to the query, we won't fetch more suggestions if the 373 // query is very long since we are unlikely to get any. 374 if ( 375 allowRemote && 376 !fetchData.remote.length && 377 searchString.length > UrlbarPrefs.get("maxCharsForSearchSuggestions") 378 ) { 379 this._lastLowResultsSearchSuggestion = searchString; 380 } 381 382 // If we have only tail suggestions, we only show them if we have no other 383 // results. We need to wait for other results to arrive to avoid flickering. 384 // We will wait for this timer unless we have suggestions that don't have a 385 // tail. 386 let tailTimer = new SkippableTimer({ 387 name: "ProviderSearchSuggestions", 388 time: 100, 389 logger: this.logger, 390 }); 391 392 for (let entry of fetchData.remote) { 393 if (looksLikeUrl(entry.value)) { 394 continue; 395 } 396 397 if (entry.tail && entry.tailOffsetIndex < 0) { 398 Cu.reportError( 399 `Error in tail suggestion parsing. Value: ${entry.value}, tail: ${entry.tail}.` 400 ); 401 continue; 402 } 403 404 let tail = entry.tail; 405 let tailPrefix = entry.matchPrefix; 406 407 // Skip tail suggestions if the pref is disabled. 408 if (tail && !UrlbarPrefs.get("richSuggestions.tail")) { 409 continue; 410 } 411 412 if (!tail) { 413 await tailTimer.fire().catch(Cu.reportError); 414 } 415 416 try { 417 results.push( 418 new UrlbarResult( 419 UrlbarUtils.RESULT_TYPE.SEARCH, 420 UrlbarUtils.RESULT_SOURCE.SEARCH, 421 ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { 422 engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], 423 suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], 424 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 425 tailPrefix, 426 tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], 427 tailOffsetIndex: tail ? entry.tailOffsetIndex : undefined, 428 keyword: [alias ? alias : undefined, UrlbarUtils.HIGHLIGHT.TYPED], 429 query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE], 430 icon: !entry.value ? engine.iconURI?.spec : undefined, 431 }) 432 ) 433 ); 434 } catch (err) { 435 Cu.reportError(err); 436 continue; 437 } 438 } 439 440 await tailTimer.promise; 441 return results; 442 } 443 444 /** 445 * Searches for an engine alias given the queryContext. 446 * @param {UrlbarQueryContext} queryContext 447 * @returns {object} aliasEngine 448 * A representation of the aliased engine. Null if there's no match. 449 * @returns {nsISearchEngine} aliasEngine.engine 450 * @returns {string} aliasEngine.alias 451 * @returns {string} aliasEngine.query 452 * @returns {object} { engine, alias, query } 453 * 454 */ 455 async _maybeGetAlias(queryContext) { 456 if (queryContext.searchMode) { 457 // If we're in search mode, don't try to parse an alias at all. 458 return null; 459 } 460 461 let possibleAlias = queryContext.tokens[0]?.value; 462 // "@" on its own is handled by UrlbarProviderTokenAliasEngines and returns 463 // a list of every available token alias. 464 if (!possibleAlias || possibleAlias == "@") { 465 return null; 466 } 467 468 let query = UrlbarUtils.substringAfter( 469 queryContext.searchString, 470 possibleAlias 471 ); 472 473 // Match an alias only when it has a space after it. If there's no trailing 474 // space, then continue to treat it as part of the search string. 475 if (!UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { 476 return null; 477 } 478 479 // Check if the user entered an engine alias directly. 480 let engineMatch = await UrlbarSearchUtils.engineForAlias(possibleAlias); 481 if (engineMatch) { 482 return { 483 engine: engineMatch, 484 alias: possibleAlias, 485 query: query.trim(), 486 }; 487 } 488 489 return null; 490 } 491} 492 493function makeFormHistoryResult(queryContext, engine, entry) { 494 return new UrlbarResult( 495 UrlbarUtils.RESULT_TYPE.SEARCH, 496 UrlbarUtils.RESULT_SOURCE.HISTORY, 497 ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { 498 engine: engine.name, 499 suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], 500 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 501 }) 502 ); 503} 504 505var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions(); 506