tor-browser

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

SearchModeSwitcher.sys.mjs (17308B)


      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 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  OpenSearchManager:
      9    "moz-src:///browser/components/search/OpenSearchManager.sys.mjs",
     10  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     11  SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs",
     12  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     13  UrlbarSearchUtils:
     14    "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs",
     15  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     16 });
     17 
     18 ChromeUtils.defineLazyGetter(lazy, "SearchModeSwitcherL10n", () => {
     19  return new Localization(["browser/browser.ftl"]);
     20 });
     21 ChromeUtils.defineLazyGetter(lazy, "searchModeNewBadge", () => {
     22  return lazy.SearchModeSwitcherL10n.formatValue("urlbar-searchmode-new");
     23 });
     24 
     25 // The maximum number of openSearch engines available to install
     26 // to display.
     27 const MAX_OPENSEARCH_ENGINES = 3;
     28 
     29 // Default icon used for engines that do not have icons loaded.
     30 const DEFAULT_ENGINE_ICON =
     31  "chrome://browser/skin/search-engine-placeholder@2x.png";
     32 
     33 /**
     34 * Implements the SearchModeSwitcher in the urlbar.
     35 */
     36 export class SearchModeSwitcher {
     37  static DEFAULT_ICON = lazy.UrlbarUtils.ICON.SEARCH_GLASS;
     38  static DEFAULT_ICON_KEYWORD_DISABLED = lazy.UrlbarUtils.ICON.GLOBE;
     39  #popup;
     40  #input;
     41  #toolbarbutton;
     42 
     43  /**
     44   * @param {UrlbarInput} input
     45   */
     46  constructor(input) {
     47    this.#input = input;
     48 
     49    this.QueryInterface = ChromeUtils.generateQI([
     50      "nsIObserver",
     51      "nsISupportsWeakReference",
     52    ]);
     53 
     54    lazy.UrlbarPrefs.addObserver(this);
     55 
     56    this.#popup = /** @type {XULPopupElement} */ (
     57      input.querySelector(".searchmode-switcher-popup")
     58    );
     59 
     60    this.#toolbarbutton = input.querySelector(".searchmode-switcher");
     61 
     62    if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
     63      this.#enableObservers();
     64    }
     65  }
     66 
     67  async #onPopupShowing() {
     68    await this.#buildSearchModeList();
     69    this.#input.view.close({ showFocusBorder: false });
     70 
     71    if (this.#input.sapName == "urlbar") {
     72      Glean.urlbarUnifiedsearchbutton.opened.add(1);
     73    }
     74  }
     75 
     76  /**
     77   * Close the SearchSwitcher popup.
     78   */
     79  closePanel() {
     80    this.#popup.hidePopup();
     81  }
     82 
     83  #openPreferences(event) {
     84    if (
     85      (event.type == "click" && event.button != 0) ||
     86      (event.type == "keypress" &&
     87        event.charCode != KeyEvent.DOM_VK_SPACE &&
     88        event.keyCode != KeyEvent.DOM_VK_RETURN)
     89    ) {
     90      return; // Left click, space or enter only
     91    }
     92 
     93    event.preventDefault();
     94    event.stopPropagation();
     95 
     96    this.#input.window.openPreferences("paneSearch");
     97    this.#popup.hidePopup();
     98 
     99    if (this.#input.sapName == "urlbar") {
    100      Glean.urlbarUnifiedsearchbutton.picked.settings.add(1);
    101    }
    102  }
    103 
    104  /**
    105   * Exit the engine specific searchMode.
    106   *
    107   * @param {Event} event
    108   *        The event that triggered the searchMode exit.
    109   */
    110  exitSearchMode(event) {
    111    event.preventDefault();
    112    this.#input.searchMode = null;
    113    // Update the result by the default engine.
    114    this.#input.startQuery();
    115  }
    116 
    117  /**
    118   * Called when the value of the searchMode attribute on UrlbarInput is changed.
    119   */
    120  onSearchModeChanged() {
    121    if (!this.#input.window || this.#input.window.closed) {
    122      return;
    123    }
    124 
    125    if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
    126      this.updateSearchIcon();
    127 
    128      if (
    129        this.#input.searchMode?.engineName == "Perplexity" &&
    130        !lazy.UrlbarPrefs.get("perplexity.hasBeenInSearchMode")
    131      ) {
    132        lazy.UrlbarPrefs.set("perplexity.hasBeenInSearchMode", true);
    133      }
    134    }
    135  }
    136 
    137  handleEvent(event) {
    138    if (event.type == "focus") {
    139      this.#input.setUnifiedSearchButtonAvailability(true);
    140      return;
    141    }
    142    if (event.type == "popupshowing") {
    143      this.#toolbarbutton.setAttribute("aria-expanded", "true");
    144      this.#onPopupShowing();
    145      return;
    146    }
    147    if (event.type == "popuphiding") {
    148      // This moves the focus to the urlbar when the popup is closed.
    149      this.#input.document.commandDispatcher.focusedElement =
    150        this.#input.inputField;
    151      this.#toolbarbutton.setAttribute("aria-expanded", "false");
    152      return;
    153    }
    154    if (event.type == "keydown") {
    155      if (this.#input.view.isOpen) {
    156        // The urlbar view is open, which means the unified search button got
    157        // focus by tab key from urlbar.
    158        switch (event.keyCode) {
    159          case KeyEvent.DOM_VK_TAB: {
    160            // Move the focus to urlbar view to make cyclable.
    161            this.#input.focus();
    162            this.#input.view.selectBy(1, {
    163              reverse: event.shiftKey,
    164              userPressedTab: true,
    165            });
    166            event.preventDefault();
    167            return;
    168          }
    169          case KeyEvent.DOM_VK_ESCAPE: {
    170            this.#input.view.close();
    171            this.#input.focus();
    172            event.preventDefault();
    173            return;
    174          }
    175        }
    176      }
    177 
    178      // Manually open the popup on down.
    179      if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
    180        this.#popup.openPopup(null, {
    181          triggerEvent: event,
    182        });
    183      }
    184 
    185      return;
    186    }
    187 
    188    let action = event.currentTarget.dataset.action ?? event.type;
    189 
    190    switch (action) {
    191      case "exitsearchmode": {
    192        this.exitSearchMode(event);
    193        break;
    194      }
    195      case "openpreferences": {
    196        this.#openPreferences(event);
    197        break;
    198      }
    199    }
    200  }
    201 
    202  observe(_subject, topic, data) {
    203    if (
    204      !this.#input.window ||
    205      this.#input.window.closed ||
    206      // TODO bug 2005783 stop observing when input is disconnected.
    207      !this.#input.isConnected
    208    ) {
    209      return;
    210    }
    211 
    212    switch (topic) {
    213      case "browser-search-engine-modified": {
    214        if (
    215          data === "engine-default" ||
    216          data === "engine-default-private" ||
    217          data === "engine-icon-changed"
    218        ) {
    219          this.updateSearchIcon();
    220        }
    221        break;
    222      }
    223    }
    224  }
    225 
    226  /**
    227   * Called when a urlbar pref changes.
    228   *
    229   * @param {string} pref
    230   *   The name of the pref relative to `browser.urlbar`.
    231   */
    232  onPrefChanged(pref) {
    233    if (!this.#input.window || this.#input.window.closed) {
    234      return;
    235    }
    236 
    237    switch (pref) {
    238      case "scotchBonnet.enableOverride": {
    239        if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
    240          this.#enableObservers();
    241          this.updateSearchIcon();
    242        } else {
    243          this.#disableObservers();
    244        }
    245        break;
    246      }
    247      case "keyword.enabled": {
    248        if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
    249          this.updateSearchIcon();
    250        }
    251        break;
    252      }
    253    }
    254  }
    255 
    256  /**
    257   * If the user presses Option+Up or Option+Down we open the engine list.
    258   *
    259   * @param {KeyboardEvent} event
    260   *   The key down event.
    261   */
    262  handleKeyDown(event) {
    263    if (
    264      (event.keyCode == KeyEvent.DOM_VK_UP ||
    265        event.keyCode == KeyEvent.DOM_VK_DOWN) &&
    266      event.altKey
    267    ) {
    268      this.#input.controller.focusOnUnifiedSearchButton();
    269      this.#popup.openPopup(null, {
    270        triggerEvent: event,
    271      });
    272      event.stopPropagation();
    273      event.preventDefault();
    274      return true;
    275    }
    276    return false;
    277  }
    278 
    279  async updateSearchIcon() {
    280    let searchMode = this.#input.searchMode;
    281 
    282    try {
    283      await lazy.UrlbarSearchUtils.init();
    284    } catch {
    285      console.error("Search service failed to init");
    286    }
    287 
    288    let { label, icon } = await this.#getDisplayedEngineDetails(
    289      this.#input.searchMode
    290    );
    291 
    292    if (searchMode?.source != this.#input.searchMode?.source) {
    293      return;
    294    }
    295 
    296    const inSearchMode = this.#input.searchMode;
    297    if (!lazy.UrlbarPrefs.get("unifiedSearchButton.always")) {
    298      const keywordEnabled = lazy.UrlbarPrefs.get("keyword.enabled");
    299      if (
    300        this.#input.sapName != "searchbar" &&
    301        !keywordEnabled &&
    302        !inSearchMode
    303      ) {
    304        icon = SearchModeSwitcher.DEFAULT_ICON_KEYWORD_DISABLED;
    305      }
    306    } else if (!inSearchMode) {
    307      // Use default icon set in CSS.
    308      icon = null;
    309    }
    310 
    311    let iconUrl = icon ? `url(${icon})` : null;
    312    // Bug 1984069 - This uses an intermediate variable to keep documentation
    313    // generation happy.
    314    let element = /** @type {HTMLImageElement} */ (
    315      this.#input.querySelector(".searchmode-switcher-icon")
    316    );
    317    element.style.listStyleImage = iconUrl;
    318 
    319    if (label) {
    320      this.#input.document.l10n.setAttributes(
    321        this.#toolbarbutton,
    322        "urlbar-searchmode-button2",
    323        { engine: label }
    324      );
    325    } else {
    326      this.#input.document.l10n.setAttributes(
    327        this.#toolbarbutton,
    328        "urlbar-searchmode-button-no-engine"
    329      );
    330    }
    331 
    332    let labelEl = this.#input.querySelector(".searchmode-switcher-title");
    333 
    334    if (!inSearchMode) {
    335      labelEl.replaceChildren();
    336    } else {
    337      labelEl.textContent = label;
    338    }
    339 
    340    if (
    341      !lazy.UrlbarPrefs.get("keyword.enabled") &&
    342      this.#input.sapName != "searchbar"
    343    ) {
    344      this.#input.document.l10n.setAttributes(
    345        this.#toolbarbutton,
    346        "urlbar-searchmode-no-keyword"
    347      );
    348    }
    349  }
    350 
    351  async #getSearchModeLabel(source) {
    352    let mode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
    353      m => m.source == source
    354    );
    355    let [str] = await lazy.SearchModeSwitcherL10n.formatMessages([
    356      { id: mode.uiLabel },
    357    ]);
    358    return str.attributes[0].value;
    359  }
    360 
    361  async #getDisplayedEngineDetails(searchMode = null) {
    362    if (!Services.search.hasSuccessfullyInitialized) {
    363      return { label: null, icon: SearchModeSwitcher.DEFAULT_ICON };
    364    }
    365 
    366    if (!searchMode || searchMode.engineName) {
    367      let engine = searchMode
    368        ? lazy.UrlbarSearchUtils.getEngineByName(searchMode.engineName)
    369        : lazy.UrlbarSearchUtils.getDefaultEngine(
    370            lazy.PrivateBrowsingUtils.isWindowPrivate(this.#input.window)
    371          );
    372      let icon = (await engine.getIconURL()) ?? SearchModeSwitcher.DEFAULT_ICON;
    373      return { label: engine.name, icon };
    374    }
    375 
    376    let mode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
    377      m => m.source == searchMode.source
    378    );
    379    return {
    380      label: await this.#getSearchModeLabel(searchMode.source),
    381      icon: mode.icon,
    382    };
    383  }
    384 
    385  /**
    386   * Builds the popup and dispatches a rebuild event on the popup when finished.
    387   */
    388  async #buildSearchModeList() {
    389    // Remove all menuitems added.
    390    for (let item of this.#popup.querySelectorAll(
    391      ".searchmode-switcher-addEngine, .searchmode-switcher-installed, .searchmode-switcher-local"
    392    )) {
    393      item.remove();
    394    }
    395 
    396    let browser = this.#input.window.gBrowser;
    397    let separator = this.#popup.querySelector(
    398      ".searchmode-switcher-popup-footer-separator"
    399    );
    400 
    401    let openSearchEngines = lazy.OpenSearchManager.getEngines(
    402      browser.selectedBrowser
    403    );
    404    openSearchEngines = openSearchEngines.slice(0, MAX_OPENSEARCH_ENGINES);
    405 
    406    for (let engine of openSearchEngines) {
    407      let menuitem = this.#createButton(engine.title, engine.icon);
    408      menuitem.classList.add("searchmode-switcher-addEngine");
    409      menuitem.addEventListener("command", e => {
    410        this.#installOpenSearchEngine(e, engine);
    411      });
    412      this.#popup.insertBefore(menuitem, separator);
    413    }
    414 
    415    // Add engines installed.
    416    let engines = [];
    417    try {
    418      engines = await Services.search.getVisibleEngines();
    419    } catch {
    420      console.error("Failed to fetch engines");
    421    }
    422 
    423    for (let engine of engines) {
    424      if (engine.hideOneOffButton) {
    425        continue;
    426      }
    427      let icon = await engine.getIconURL();
    428      let menuitem = this.#createButton(engine.name, icon);
    429      menuitem.classList.add("searchmode-switcher-installed");
    430      menuitem.setAttribute("label", engine.name);
    431 
    432      if (engine.isNew() && engine.isAppProvided) {
    433        menuitem.setAttribute("badge", await lazy.searchModeNewBadge);
    434        menuitem.classList.add("badge-new");
    435      }
    436 
    437      menuitem.addEventListener(
    438        "command",
    439        /** @param {KeyboardEvent} e */ e => {
    440          this.search({ engine, openEngineHomePage: e.shiftKey });
    441        }
    442      );
    443      this.#popup.insertBefore(menuitem, separator);
    444    }
    445 
    446    await this.#buildLocalSearchModeList(separator);
    447 
    448    this.#popup.dispatchEvent(new Event("rebuild"));
    449  }
    450 
    451  /**
    452   * Adds local options to the popup.
    453   *
    454   * @param {Element} separator
    455   */
    456  async #buildLocalSearchModeList(separator) {
    457    if (this.#input.sapName != "urlbar") {
    458      return;
    459    }
    460 
    461    for (let { source, pref, restrict } of lazy.UrlbarUtils
    462      .LOCAL_SEARCH_MODES) {
    463      if (!lazy.UrlbarPrefs.get(pref)) {
    464        continue;
    465      }
    466      if (
    467        source === lazy.UrlbarUtils.RESULT_SOURCE.HISTORY &&
    468        lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
    469      ) {
    470        // Do not show the search history option in PBM. tor-browser#43864.
    471        // Although, it can still be triggered with "^" restrict keyword or
    472        // through an app menu item. See also mozilla bug 1980928.
    473        continue;
    474      }
    475      let name = lazy.UrlbarUtils.getResultSourceName(source);
    476      let { icon } = await this.#getDisplayedEngineDetails({
    477        source,
    478        pref,
    479        restrict,
    480      });
    481      let menuitem = this.#createButton(name, icon);
    482      menuitem.id = `search-button-${name}`;
    483      menuitem.classList.add("searchmode-switcher-local");
    484      menuitem.addEventListener("command", () => {
    485        this.search({ restrict });
    486      });
    487 
    488      this.#input.document.l10n.setAttributes(
    489        menuitem,
    490        `urlbar-searchmode-${name}`,
    491        {
    492          restrict,
    493        }
    494      );
    495 
    496      this.#popup.insertBefore(menuitem, separator);
    497    }
    498  }
    499 
    500  search({ engine = null, restrict = null, openEngineHomePage = false } = {}) {
    501    let search = "";
    502    /** @type {Parameters<UrlbarInput["search"]>[1]} */
    503    let opts = null;
    504    if (engine) {
    505      search = this.#input.value;
    506      opts = {
    507        searchEngine: engine,
    508        searchModeEntry: "searchbutton",
    509      };
    510    } else if (restrict) {
    511      search = restrict + " " + this.#input.value;
    512      opts = { searchModeEntry: "searchbutton" };
    513    }
    514 
    515    if (openEngineHomePage) {
    516      this.#input.openEngineHomePage(search, {
    517        searchEngine: opts.searchEngine,
    518      });
    519    } else {
    520      this.#input.search(search, opts);
    521    }
    522 
    523    this.#popup.hidePopup();
    524 
    525    if (engine) {
    526      if (this.#input.sapName == "urlbar") {
    527        // TODO do we really need to distinguish here?
    528        Glean.urlbarUnifiedsearchbutton.picked[
    529          engine.isConfigEngine ? "builtin_search" : "addon_search"
    530        ].add(1);
    531      }
    532    } else if (restrict) {
    533      if (this.#input.sapName == "urlbar") {
    534        Glean.urlbarUnifiedsearchbutton.picked.local_search.add(1);
    535      }
    536    } else {
    537      console.warn(
    538        `Unexpected search: ${JSON.stringify({ engine, restrict, openEngineHomePage })}`
    539      );
    540    }
    541  }
    542 
    543  #enableObservers() {
    544    Services.obs.addObserver(this, "browser-search-engine-modified", true);
    545 
    546    this.#toolbarbutton.addEventListener("focus", this);
    547    this.#toolbarbutton.addEventListener("keydown", this);
    548 
    549    this.#popup.addEventListener("popupshowing", this);
    550    this.#popup.addEventListener("popuphiding", this);
    551 
    552    let closebutton = this.#input.querySelector(".searchmode-switcher-close");
    553    closebutton.addEventListener("command", this);
    554 
    555    let prefsbutton = this.#input.querySelector(
    556      ".searchmode-switcher-popup-search-settings-button"
    557    );
    558    prefsbutton.addEventListener("command", this);
    559  }
    560 
    561  #disableObservers() {
    562    Services.obs.removeObserver(this, "browser-search-engine-modified");
    563 
    564    this.#toolbarbutton.removeEventListener("focus", this);
    565    this.#toolbarbutton.removeEventListener("keydown", this);
    566 
    567    this.#popup.removeEventListener("popupshowing", this);
    568    this.#popup.removeEventListener("popuphiding", this);
    569 
    570    let closebutton = this.#input.querySelector(".searchmode-switcher-close");
    571    closebutton.removeEventListener("command", this);
    572 
    573    let prefsbutton = this.#input.querySelector(
    574      ".searchmode-switcher-popup-search-settings-button"
    575    );
    576    prefsbutton.removeEventListener("command", this);
    577  }
    578 
    579  #createButton(label, icon) {
    580    let menuitem = this.#input.window.document.createXULElement("menuitem");
    581    menuitem.setAttribute("label", label);
    582    menuitem.setAttribute("class", "menuitem-iconic");
    583    menuitem.setAttribute("image", icon ?? DEFAULT_ENGINE_ICON);
    584    return menuitem;
    585  }
    586 
    587  async #installOpenSearchEngine(e, engine) {
    588    let topic = "browser-search-engine-modified";
    589 
    590    let observer = engineObj => {
    591      Services.obs.removeObserver(observer, topic);
    592      let eng = Services.search.getEngineByName(engineObj.wrappedJSObject.name);
    593      this.search({
    594        engine: eng,
    595        openEngineHomePage: e.shiftKey,
    596      });
    597    };
    598    Services.obs.addObserver(observer, topic);
    599 
    600    await lazy.SearchUIUtils.addOpenSearchEngine(
    601      engine.uri,
    602      engine.icon,
    603      this.#input.window.gBrowser.selectedBrowser.browsingContext
    604    );
    605  }
    606 }