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