tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

UrlbarProviderSearchTips.sys.mjs (15922B)


      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 a provider that might show a tip when the user opens
      7 * the newtab or starts an organic search with their default search engine.
      8 */
      9 
     10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
     11 
     12 import {
     13  UrlbarProvider,
     14  UrlbarUtils,
     15 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs";
     16 
     17 const lazy = {};
     18 
     19 ChromeUtils.defineESModuleGetters(lazy, {
     20  AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
     21  DefaultBrowserCheck:
     22    "moz-src:///browser/components/DefaultBrowserCheck.sys.mjs",
     23  LaterRun: "resource:///modules/LaterRun.sys.mjs",
     24  SearchStaticData:
     25    "moz-src:///toolkit/components/search/SearchStaticData.sys.mjs",
     26  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     27  UrlbarProviderTopSites:
     28    "moz-src:///browser/components/urlbar/UrlbarProviderTopSites.sys.mjs",
     29  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
     30  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     31 });
     32 
     33 XPCOMUtils.defineLazyPreferenceGetter(
     34  lazy,
     35  "cfrFeaturesUserPref",
     36  "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
     37  true
     38 );
     39 
     40 // The possible tips to show.
     41 const TIPS = {
     42  NONE: "",
     43  ONBOARD: "searchTip_onboard",
     44  REDIRECT: "searchTip_redirect",
     45 };
     46 
     47 ChromeUtils.defineLazyGetter(lazy, "SUPPORTED_ENGINES", () => {
     48  // Converts a list of Google domains to a pipe separated string of escaped TLDs.
     49  // [www.google.com, ..., www.google.co.uk] => "com|...|co\.uk"
     50  const googleTLDs = lazy.SearchStaticData.getAlternateDomains("www.google.com")
     51    .map(str => str.slice("www.google.".length).replaceAll(".", "\\."))
     52    .join("|");
     53 
     54  // This maps engine names to regexes matching their homepages. We show the
     55  // redirect tip on these pages.
     56  return new Map([
     57    ["Bing", { domainPath: /^www\.bing\.com\/$/ }],
     58    [
     59      "DuckDuckGo",
     60      {
     61        domainPath: /^(start\.)?duckduckgo\.com\/$/,
     62        prohibitedSearchParams: ["q"],
     63      },
     64    ],
     65    [
     66      "Google",
     67      {
     68        domainPath: new RegExp(`^www\.google\.(?:${googleTLDs})\/(webhp)?$`),
     69      },
     70    ],
     71  ]);
     72 });
     73 
     74 // The maximum number of times we'll show a tip across all sessions.
     75 const MAX_SHOWN_COUNT = 4;
     76 
     77 // Amount of time to wait before showing a tip after selecting a tab or
     78 // navigating to a page where we should show a tip.
     79 const SHOW_TIP_DELAY_MS = 200;
     80 
     81 // We won't show a tip if the browser has been updated in the past
     82 // LAST_UPDATE_THRESHOLD_HOURS.
     83 const LAST_UPDATE_THRESHOLD_HOURS = 24;
     84 
     85 /**
     86 * A provider that sometimes returns a tip result when the user visits the
     87 * newtab page or their default search engine's homepage.
     88 *
     89 * This class supports only one instance.
     90 */
     91 export class UrlbarProviderSearchTips extends UrlbarProvider {
     92  /** @type {?UrlbarProviderSearchTips} */
     93  static #instance = null;
     94 
     95  constructor() {
     96    super();
     97    if (UrlbarProviderSearchTips.#instance) {
     98      throw new Error("Can only have one instance of UrlbarProviderSearchTips");
     99    }
    100    UrlbarProviderSearchTips.#instance = this;
    101 
    102    // Whether we should disable tips for the current browser session, for
    103    // example because a tip was already shown.
    104    this.disableTipsForCurrentSession = true;
    105    for (let tip of Object.values(TIPS)) {
    106      if (
    107        tip &&
    108        lazy.UrlbarPrefs.get(`tipShownCount.${tip}`) < MAX_SHOWN_COUNT
    109      ) {
    110        this.disableTipsForCurrentSession = false;
    111        break;
    112      }
    113    }
    114 
    115    // Whether and what kind of tip we've shown in the current engagement.
    116    this.showedTipTypeInCurrentEngagement = TIPS.NONE;
    117 
    118    // Used to track browser windows we've seen.
    119    this._seenWindows = new WeakSet();
    120  }
    121 
    122  /**
    123   * Enum of the types of search tips.
    124   *
    125   * @returns {{ NONE: string; ONBOARD: string; REDIRECT: string; }}
    126   */
    127  static get TIP_TYPE() {
    128    return TIPS;
    129  }
    130 
    131  static get PRIORITY() {
    132    // Search tips are prioritized over the Places and top sites providers.
    133    return lazy.UrlbarProviderTopSites.PRIORITY + 1;
    134  }
    135 
    136  /**
    137   * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>}
    138   */
    139  get type() {
    140    return UrlbarUtils.PROVIDER_TYPE.PROFILE;
    141  }
    142 
    143  /**
    144   * Whether this provider should be invoked for the given context.
    145   * If this method returns false, the providers manager won't start a query
    146   * with this provider, to save on resources.
    147   */
    148  async isActive() {
    149    return !!this.currentTip && lazy.cfrFeaturesUserPref;
    150  }
    151 
    152  /**
    153   * Gets the provider's priority.
    154   *
    155   * @returns {number} The provider's priority for the given query.
    156   */
    157  getPriority() {
    158    return UrlbarProviderSearchTips.PRIORITY;
    159  }
    160 
    161  /**
    162   * Starts querying.
    163   *
    164   * @param {UrlbarQueryContext} queryContext
    165   * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback
    166   *   Callback invoked by the provider to add a new result.
    167   */
    168  async startQuery(queryContext, addCallback) {
    169    let instance = this.queryInstance;
    170 
    171    let tip = this.currentTip;
    172    this.showedTipTypeInCurrentEngagement = this.currentTip;
    173    this.currentTip = TIPS.NONE;
    174 
    175    let defaultEngine = await Services.search.getDefault();
    176    let icon = await defaultEngine.getIconURL();
    177    if (instance != this.queryInstance) {
    178      return;
    179    }
    180 
    181    let result;
    182    switch (tip) {
    183      case TIPS.ONBOARD:
    184        result = this.#makeResult({
    185          tip,
    186          icon,
    187          titleL10n: {
    188            id: "urlbar-search-tips-onboard",
    189            args: {
    190              engineName: defaultEngine.name,
    191            },
    192          },
    193          heuristic: true,
    194        });
    195        break;
    196      case TIPS.REDIRECT:
    197        result = this.#makeResult({
    198          tip,
    199          icon,
    200          titleL10n: {
    201            id: "urlbar-search-tips-redirect-2",
    202            args: {
    203              engineName: defaultEngine.name,
    204            },
    205          },
    206        });
    207        break;
    208    }
    209    addCallback(this, result);
    210  }
    211 
    212  /**
    213   * Called when the tip is selected.
    214   *
    215   * @param {UrlbarResult} result
    216   *   The result that was picked.
    217   * @param {window} window
    218   *   The browser window in which the tip is being displayed.
    219   */
    220  #pickResult(result, window) {
    221    window.gURLBar.value = "";
    222    window.gURLBar.setPageProxyState("invalid");
    223    window.gURLBar.removeAttribute("suppress-focus-border");
    224    window.gURLBar.focus();
    225 
    226    // The user either clicked the tip's "Okay, Got It" button, or they clicked
    227    // in the urlbar while the tip was showing. We treat both as the user's
    228    // acknowledgment of the tip, and we don't show tips again in any session.
    229    // Set the shown count to the max.
    230    lazy.UrlbarPrefs.set(
    231      `tipShownCount.${result.payload.type}`,
    232      MAX_SHOWN_COUNT
    233    );
    234  }
    235 
    236  onEngagement(queryContext, controller, details) {
    237    this.#pickResult(details.result, controller.browserWindow);
    238  }
    239 
    240  onSearchSessionEnd() {
    241    this.showedTipTypeInCurrentEngagement = TIPS.NONE;
    242  }
    243 
    244  /**
    245   * Called from `onLocationChange` in browser.js.
    246   *
    247   * @param {window} window
    248   *  The browser window where the location change happened.
    249   * @param {nsIURI} uri
    250   *  The URI being navigated to.
    251   * @param {nsIWebProgress} webProgress
    252   *   The progress object, which can have event listeners added to it.
    253   * @param {number} flags
    254   *   Load flags. See nsIWebProgressListener.idl for possible values.
    255   */
    256  static async onLocationChange(window, uri, webProgress, flags) {
    257    if (UrlbarProviderSearchTips.#instance) {
    258      UrlbarProviderSearchTips.#instance.onLocationChange(
    259        window,
    260        uri,
    261        webProgress,
    262        flags
    263      );
    264    }
    265  }
    266 
    267  /**
    268   * Called by the static function with the same name.
    269   *
    270   * @param {window} window
    271   *  The browser window where the location change happened.
    272   * @param {nsIURI} uri
    273   *  The URI being navigated to.
    274   * @param {nsIWebProgress} webProgress
    275   *   The progress object, which can have event listeners added to it.
    276   * @param {number} flags
    277   *   Load flags. See nsIWebProgressListener.idl for possible values.
    278   */
    279  async onLocationChange(window, uri, webProgress, flags) {
    280    let instance = (this._onLocationChangeInstance = {});
    281 
    282    // If this is the first time we've seen this browser window, we take some
    283    // precautions to avoid impacting ts_paint.
    284    if (!this._seenWindows.has(window)) {
    285      this._seenWindows.add(window);
    286 
    287      // First, wait until MozAfterPaint is fired in the current content window.
    288      await window.gBrowserInit.firstContentWindowPaintPromise;
    289      if (instance != this._onLocationChangeInstance) {
    290        return;
    291      }
    292 
    293      // Second, wait 500ms.  ts_paint waits at most 500ms after MozAfterPaint
    294      // before ending.  We use XPCOM directly instead of Timer.sys.mjs to avoid the
    295      // perf impact of loading Timer.sys.mjs, in case it's not already loaded.
    296      await new Promise(resolve => {
    297        let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    298        timer.initWithCallback(resolve, 500, Ci.nsITimer.TYPE_ONE_SHOT);
    299      });
    300      if (instance != this._onLocationChangeInstance) {
    301        return;
    302      }
    303    }
    304 
    305    // Ignore events that don't change the document. Google is known to do this.
    306    // Also ignore changes in sub-frames. See bug 1623978.
    307    if (
    308      flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT ||
    309      !webProgress.isTopLevel
    310    ) {
    311      return;
    312    }
    313 
    314    // The UrlbarView is usually closed on location change when the input is
    315    // blurred. Since we open the view to show the redirect tip without focusing
    316    // the input, the view won't close in that case. We need to close it
    317    // manually.
    318    if (this.showedTipTypeInCurrentEngagement != TIPS.NONE) {
    319      window.gURLBar.view.close();
    320    }
    321 
    322    // Check if we are supposed to show a tip for the current session.
    323    if (
    324      !lazy.cfrFeaturesUserPref ||
    325      (this.disableTipsForCurrentSession &&
    326        !lazy.UrlbarPrefs.get("searchTips.test.ignoreShowLimits"))
    327    ) {
    328      return;
    329    }
    330 
    331    this._maybeShowTipForUrl(uri.spec, window).catch(ex =>
    332      this.logger.error(ex)
    333    );
    334  }
    335 
    336  /**
    337   * Determines whether we should show a tip for the current tab, sets
    338   * this.currentTip, and starts a search on an empty string.
    339   *
    340   * @param {string} urlStr
    341   *   The URL of the page being loaded, in string form.
    342   * @param {window} window
    343   *   The browser window in which the tip is being displayed.
    344   */
    345  async _maybeShowTipForUrl(urlStr, window) {
    346    let instance = {};
    347    this._maybeShowTipForUrlInstance = instance;
    348 
    349    let ignoreShowLimits = lazy.UrlbarPrefs.get(
    350      "searchTips.test.ignoreShowLimits"
    351    );
    352 
    353    // Determine which tip we should show for the tab.  Do this check first
    354    // before the others below.  It has less of a performance impact than the
    355    // others, so in the common case where the URL is not one we're interested
    356    // in, we can return immediately.
    357    let tip;
    358    let isNewtab = ["about:newtab", "about:home"].includes(urlStr);
    359    let isSearchHomepage = !isNewtab && (await isDefaultEngineHomepage(urlStr));
    360 
    361    if (isNewtab) {
    362      tip = TIPS.ONBOARD;
    363    } else if (isSearchHomepage) {
    364      tip = TIPS.REDIRECT;
    365    } else {
    366      // No tip.
    367      return;
    368    }
    369 
    370    // If we've shown this type of tip the maximum number of times over all
    371    // sessions, don't show it again.
    372    let shownCount = lazy.UrlbarPrefs.get(`tipShownCount.${tip}`);
    373    if (shownCount >= MAX_SHOWN_COUNT && !ignoreShowLimits) {
    374      return;
    375    }
    376 
    377    // Don't show a tip if the browser has been updated recently.
    378    let hoursSinceUpdate = Math.min(
    379      lazy.LaterRun.hoursSinceInstall,
    380      lazy.LaterRun.hoursSinceUpdate
    381    );
    382    if (hoursSinceUpdate < LAST_UPDATE_THRESHOLD_HOURS && !ignoreShowLimits) {
    383      return;
    384    }
    385 
    386    // Start a search.
    387    lazy.setTimeout(async () => {
    388      if (this._maybeShowTipForUrlInstance != instance) {
    389        return;
    390      }
    391 
    392      // We don't want to interrupt a user's typed query with a Search Tip.
    393      // See bugs 1613662 and 1619547.
    394      if (
    395        window.gURLBar.getAttribute("pageproxystate") == "invalid" &&
    396        window.gURLBar.value != ""
    397      ) {
    398        return;
    399      }
    400 
    401      // Don't show a tip if the browser is already showing some other
    402      // notification.
    403      if (
    404        (!ignoreShowLimits && (await isBrowserShowingNotification(window))) ||
    405        this._maybeShowTipForUrlInstance != instance
    406      ) {
    407        return;
    408      }
    409 
    410      // At this point, we're showing a tip.
    411      this.disableTipsForCurrentSession = true;
    412 
    413      // Store the new shown count.
    414      lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, shownCount + 1);
    415 
    416      this.currentTip = tip;
    417 
    418      window.gURLBar.search("", { focus: tip == TIPS.ONBOARD });
    419    }, SHOW_TIP_DELAY_MS);
    420  }
    421 
    422  #makeResult({ tip, icon, titleL10n, heuristic = false }) {
    423    return new lazy.UrlbarResult({
    424      type: UrlbarUtils.RESULT_TYPE.TIP,
    425      source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
    426      heuristic,
    427      payload: {
    428        type: tip,
    429        buttons: [{ l10n: { id: "urlbar-search-tips-confirm" } }],
    430        icon,
    431        titleL10n,
    432      },
    433    });
    434  }
    435 }
    436 
    437 async function isBrowserShowingNotification(window) {
    438  // urlbar view and notification box (info bar)
    439  if (
    440    window.gURLBar.view.isOpen ||
    441    window.gNotificationBox.currentNotification ||
    442    window.gBrowser.getNotificationBox().currentNotification
    443  ) {
    444    return true;
    445  }
    446 
    447  // app menu notification doorhanger
    448  if (
    449    lazy.AppMenuNotifications.activeNotification &&
    450    !lazy.AppMenuNotifications.activeNotification.dismissed &&
    451    !lazy.AppMenuNotifications.activeNotification.options.badgeOnly
    452  ) {
    453    return true;
    454  }
    455 
    456  // PopupNotifications (e.g. Tracking Protection, Identity Box Doorhangers)
    457  if (window.PopupNotifications.isPanelOpen) {
    458    return true;
    459  }
    460 
    461  // page action button panels
    462  let pageActions = window.document.getElementById("page-action-buttons");
    463  if (pageActions) {
    464    for (let child of pageActions.childNodes) {
    465      if (child.getAttribute("open") == "true") {
    466        return true;
    467      }
    468    }
    469  }
    470 
    471  // toolbar button panels
    472  let navbar = window.document.getElementById("nav-bar-customization-target");
    473  for (let node of navbar.querySelectorAll("toolbarbutton")) {
    474    if (node.getAttribute("open") == "true") {
    475      return true;
    476    }
    477  }
    478 
    479  // Other modals like spotlight messages or default browser prompt
    480  // can be shown at startup
    481  if (window.gDialogBox.isOpen) {
    482    return true;
    483  }
    484 
    485  // On startup, the default browser check normally opens after the Search Tip.
    486  // As a result, we can't check for the prompt's presence, but we can check if
    487  // it plans on opening.
    488  const willPrompt = await lazy.DefaultBrowserCheck.willCheckDefaultBrowser(
    489    /* isStartupCheck */ false
    490  );
    491  if (willPrompt) {
    492    return true;
    493  }
    494 
    495  return false;
    496 }
    497 
    498 /**
    499 * Checks if the given URL is the homepage of the current default search engine.
    500 * Returns false if the default engine is not listed in SUPPORTED_ENGINES.
    501 *
    502 * @param {string} urlStr
    503 *   The URL to check, in string form.
    504 *
    505 * @returns {Promise<boolean>}
    506 */
    507 async function isDefaultEngineHomepage(urlStr) {
    508  let defaultEngine = await Services.search.getDefault();
    509  if (!defaultEngine) {
    510    return false;
    511  }
    512 
    513  let homepageMatches = lazy.SUPPORTED_ENGINES.get(defaultEngine.name);
    514  if (!homepageMatches) {
    515    return false;
    516  }
    517 
    518  let url = URL.parse(urlStr);
    519  if (!url) {
    520    return false;
    521  }
    522 
    523  if (url.searchParams.has(homepageMatches.prohibitedSearchParams)) {
    524    return false;
    525  }
    526 
    527  // Strip protocol and query params.
    528  urlStr = url.hostname.concat(url.pathname);
    529 
    530  return homepageMatches.domainPath.test(urlStr);
    531 }