tor-browser

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

UrlbarValueFormatter.sys.mjs (20884B)


      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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 const lazy = XPCOMUtils.declareLazy({
      8  BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
      9  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     10  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     11  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     12 });
     13 
     14 /**
     15 * Applies URL highlighting and other styling to the text in the urlbar input,
     16 * depending on the text.
     17 */
     18 export class UrlbarValueFormatter {
     19  /**
     20   * @param {UrlbarInput} urlbarInput
     21   *   The parent instance of UrlbarInput
     22   */
     23  constructor(urlbarInput) {
     24    this.#urlbarInput = urlbarInput;
     25 
     26    this.#window.addEventListener("resize", this);
     27  }
     28 
     29  async update() {
     30    let instance = (this.#updateInstance = {});
     31 
     32    // #getUrlMetaData does URI fixup, which depends on the search service, so
     33    // make sure it's initialized, or URIFixup may force synchronous
     34    // initialization. It can be uninitialized here on session restore. Skip
     35    // this if the service is already initialized in order to avoid the async
     36    // call in the common case. However, we can't access Service.search before
     37    // first paint (delayed startup) because there's a performance test that
     38    // prohibits it, so first await delayed startup.
     39    if (!this.#window.gBrowserInit.delayedStartupFinished) {
     40      await this.#window.delayedStartupPromise;
     41      if (this.#updateInstance != instance) {
     42        return;
     43      }
     44    }
     45    if (!Services.search.isInitialized) {
     46      try {
     47        await Services.search.init();
     48      } catch {}
     49 
     50      if (this.#updateInstance != instance) {
     51        return;
     52      }
     53    }
     54 
     55    // If this window is being torn down, stop here
     56    if (!this.#window.docShell) {
     57      return;
     58    }
     59 
     60    // Cleanup that must be done in any case, even if there's no value.
     61    this.#urlbarInput.removeAttribute("domaindir");
     62    this.#scheme.value = "";
     63 
     64    if (!this.#urlbarInput.value) {
     65      return;
     66    }
     67 
     68    // Remove the current formatting.
     69    this.#removeURLFormat();
     70    this.#removeSearchAliasFormat();
     71 
     72    // Apply new formatting.  Formatter methods should return true if they
     73    // successfully formatted the value and false if not.  We apply only
     74    // one formatter at a time, so we stop at the first successful one.
     75    this.#window.requestAnimationFrame(() => {
     76      if (this.#updateInstance != instance) {
     77        return;
     78      }
     79      this.#formattingApplied = this.#formatURL() || this.#formatSearchAlias();
     80    });
     81  }
     82 
     83  /**
     84   * The parent instance of UrlbarInput
     85   */
     86  #urlbarInput;
     87 
     88  get #document() {
     89    return this.#urlbarInput.document;
     90  }
     91 
     92  get #inputField() {
     93    return this.#urlbarInput.inputField;
     94  }
     95 
     96  get #window() {
     97    return this.#urlbarInput.window;
     98  }
     99 
    100  get #scheme() {
    101    return /** @type {HTMLInputElement} */ (
    102      this.#urlbarInput.querySelector("#urlbar-scheme")
    103    );
    104  }
    105 
    106  #ensureFormattedHostVisible(urlMetaData) {
    107    // Make sure the host is always visible. Since it is aligned on
    108    // the first strong directional character, we set scrollLeft
    109    // appropriately to ensure the domain stays visible in case of an
    110    // overflow.
    111    // In the future, for example in bug 525831, we may add a forceRTL
    112    // char just after the domain, and in such a case we should not
    113    // scroll to the left.
    114    urlMetaData = urlMetaData || this.#getUrlMetaData();
    115    if (!urlMetaData) {
    116      this.#urlbarInput.removeAttribute("domaindir");
    117      return;
    118    }
    119    let { url, preDomain, domain } = urlMetaData;
    120    let directionality = this.#window.windowUtils.getDirectionFromText(domain);
    121    if (
    122      directionality == this.#window.windowUtils.DIRECTION_RTL &&
    123      url[preDomain.length + domain.length] != "\u200E"
    124    ) {
    125      this.#urlbarInput.setAttribute("domaindir", "rtl");
    126      this.#inputField.scrollLeft = this.#inputField.scrollLeftMax;
    127    } else {
    128      this.#urlbarInput.setAttribute("domaindir", "ltr");
    129      this.#inputField.scrollLeft = 0;
    130    }
    131    this.#urlbarInput.updateTextOverflow();
    132  }
    133 
    134  #getUrlMetaData() {
    135    if (this.#urlbarInput.focused) {
    136      return null;
    137    }
    138 
    139    let inputValue = this.#urlbarInput.value;
    140    // getFixupURIInfo logs an error if the URL is empty. Avoid that by
    141    // returning early.
    142    if (!inputValue) {
    143      return null;
    144    }
    145    let browser = this.#window.gBrowser.selectedBrowser;
    146    let browserState = this.#urlbarInput.getBrowserState(browser);
    147 
    148    // Since doing a full URIFixup and offset calculations is expensive, we
    149    // keep the metadata cached in the browser itself, so when switching tabs
    150    // we can skip most of this.
    151    if (
    152      browserState.urlMetaData &&
    153      browserState.urlMetaData.inputValue == inputValue &&
    154      browserState.urlMetaData.untrimmedValue ==
    155        this.#urlbarInput.untrimmedValue
    156    ) {
    157      return browserState.urlMetaData.data;
    158    }
    159    browserState.urlMetaData = {
    160      inputValue,
    161      untrimmedValue: this.#urlbarInput.untrimmedValue,
    162      data: null,
    163    };
    164 
    165    // Get the URL from the fixup service:
    166    let flags =
    167      Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS |
    168      Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
    169    if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.#window)) {
    170      flags |= Services.uriFixup.FIXUP_FLAG_PRIVATE_CONTEXT;
    171    }
    172 
    173    let uriInfo;
    174    try {
    175      uriInfo = Services.uriFixup.getFixupURIInfo(
    176        this.#urlbarInput.untrimmedValue,
    177        flags
    178      );
    179    } catch (ex) {}
    180    // Ignore if we couldn't make a URI out of this, the URI resulted in a search,
    181    // or the URI has a non-http(s) protocol.
    182    if (
    183      !uriInfo ||
    184      !uriInfo.fixedURI ||
    185      uriInfo.keywordProviderName ||
    186      !["http", "https"].includes(uriInfo.fixedURI.scheme)
    187    ) {
    188      return null;
    189    }
    190 
    191    // We must ensure the protocol is present in the parsed string, so we don't
    192    // get confused by user:pass@host. It may not have been present originally,
    193    // or it may have been trimmed. We later use trimmedLength to ensure we
    194    // don't count the length of a trimmed protocol when determining which parts
    195    // of the input value to de-emphasize as `preDomain`.
    196    let url = inputValue;
    197    let trimmedLength = 0;
    198    let trimmedProtocol = lazy.BrowserUIUtils.trimURLProtocol;
    199    if (
    200      this.#urlbarInput.untrimmedValue.startsWith(trimmedProtocol) &&
    201      !inputValue.startsWith(trimmedProtocol)
    202    ) {
    203      // The protocol has been trimmed, so we add it back.
    204      url = trimmedProtocol + inputValue;
    205      trimmedLength = trimmedProtocol.length;
    206    } else if (
    207      uriInfo.schemelessInput == Ci.nsILoadInfo.SchemelessInputTypeSchemeless
    208    ) {
    209      // The original string didn't have a protocol, but it was identified as
    210      // a URL. It's not important which scheme we use for parsing, so we'll
    211      // just copy URIFixup.
    212      let scheme = uriInfo.fixedURI.scheme + "://";
    213      url = scheme + url;
    214      trimmedLength = scheme.length;
    215    }
    216 
    217    // This RegExp is not a perfect match, and for specially crafted URLs it may
    218    // get the host wrong; for safety reasons we will later compare the found
    219    // host with the one that will actually be loaded.
    220    let matchedURL = url.match(
    221      /^(([a-z]+:\/\/)(?:[^\/#?]+@)?)(\S+?)(?::\d+)?\s*(?:[\/#?]|$)/
    222    );
    223    if (!matchedURL) {
    224      return null;
    225    }
    226    let [, preDomain, schemeWSlashes, domain] = matchedURL;
    227 
    228    // If the found host differs from the fixed URI one, we can't properly
    229    // highlight it. To stay on the safe side, we clobber user's input with
    230    // the fixed URI and apply highlight to that one instead.
    231    let replaceUrl = false;
    232    try {
    233      replaceUrl =
    234        Services.io.newURI("http://" + domain).displayHost !=
    235        uriInfo.fixedURI.displayHost;
    236    } catch (ex) {
    237      return null;
    238    }
    239    if (replaceUrl) {
    240      if (this.#inGetUrlMetaData) {
    241        // Protect from infinite recursion.
    242        return null;
    243      }
    244      try {
    245        this.#inGetUrlMetaData = true;
    246        this.#window.gBrowser.userTypedValue = null;
    247        this.#urlbarInput.setURI({ uri: uriInfo.fixedURI });
    248        return this.#getUrlMetaData();
    249      } finally {
    250        this.#inGetUrlMetaData = false;
    251      }
    252    }
    253 
    254    return (browserState.urlMetaData.data = {
    255      domain,
    256      origin: uriInfo.fixedURI.host,
    257      preDomain,
    258      schemeWSlashes,
    259      trimmedLength,
    260      url,
    261    });
    262  }
    263 
    264  #removeURLFormat() {
    265    if (!this.#formattingApplied) {
    266      return;
    267    }
    268    let controller = this.#urlbarInput.editor.selectionController;
    269    let strikeOut = controller.getSelection(controller.SELECTION_URLSTRIKEOUT);
    270    strikeOut.removeAllRanges();
    271    let selection = controller.getSelection(controller.SELECTION_URLSECONDARY);
    272    selection.removeAllRanges();
    273    this.#formatScheme(controller.SELECTION_URLSTRIKEOUT, true);
    274    this.#formatScheme(controller.SELECTION_URLSECONDARY, true);
    275    this.#inputField.style.setProperty("--urlbar-scheme-size", "0px");
    276  }
    277 
    278  /**
    279   * Whether formatting is enabled.
    280   *
    281   * @returns {boolean}
    282   */
    283  get formattingEnabled() {
    284    return lazy.UrlbarPrefs.get("formatting.enabled");
    285  }
    286 
    287  /**
    288   * Whether a striked out active mixed content protocol will show for the
    289   * currently loaded input field value.
    290   *
    291   * @param {string} val The value to evaluate. If it's not the currently
    292   *   loaded page, this will return false, as we cannot know if a page has
    293   *   active mixed content until it's loaded.
    294   * @returns {boolean}
    295   */
    296  willShowFormattedMixedContentProtocol(val) {
    297    return (
    298      this.formattingEnabled &&
    299      !lazy.UrlbarPrefs.get("security.insecure_connection_text.enabled") &&
    300      val.startsWith("https://") &&
    301      val == this.#urlbarInput.value &&
    302      this.#showingMixedContentLoadedPageUrl
    303    );
    304  }
    305 
    306  /**
    307   * This is used only as an optimization to avoid removing formatting in
    308   * the _remove* format methods when no formatting is actually applied.
    309   *
    310   * @type {boolean}
    311   */
    312  #formattingApplied = false;
    313 
    314  /**
    315   * An empty object, which is used as a lock to avoid updating old instances.
    316   *
    317   * @type {?object}
    318   */
    319  #updateInstance;
    320 
    321  /**
    322   * The previously selected result.
    323   *
    324   * @type {?UrlbarResult}
    325   */
    326  #selectedResult;
    327 
    328  /**
    329   * The timer handling the resize throttling.
    330   *
    331   * @type {?number}
    332   */
    333  #resizeThrottleTimeout;
    334 
    335  /**
    336   * An empty object, which is used to avoid updating old instances.
    337   *
    338   * @type {?object}
    339   */
    340  #resizeInstance;
    341 
    342  /**
    343   * Used to protect against re-entry in getUrlMetaData.
    344   *
    345   * @type {boolean}
    346   */
    347  #inGetUrlMetaData = false;
    348 
    349  /**
    350   * Whether the currently loaded page is in mixed content mode.
    351   *
    352   * @returns {boolean} whether the loaded page has active mixed content.
    353   */
    354  get #showingMixedContentLoadedPageUrl() {
    355    return (
    356      this.#urlbarInput.getAttribute("pageproxystate") == "valid" &&
    357      !!(
    358        this.#window.gBrowser.securityUI.state &
    359        Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT
    360      )
    361    );
    362  }
    363 
    364  /**
    365   * If the input value is a URL and the input is not focused, this
    366   * formatter method highlights the domain, and if mixed content is present,
    367   * it crosses out the https scheme.  It also ensures that the host is
    368   * visible (not scrolled out of sight).
    369   *
    370   * @returns {boolean}
    371   *   True if formatting was applied and false if not.
    372   */
    373  #formatURL() {
    374    let urlMetaData = this.#getUrlMetaData();
    375    if (!urlMetaData) {
    376      return false;
    377    }
    378    let state = this.#urlbarInput.getBrowserState(
    379      this.#window.gBrowser.selectedBrowser
    380    );
    381    if (state.searchTerms) {
    382      return false;
    383    }
    384 
    385    let { domain, origin, preDomain, schemeWSlashes, trimmedLength, url } =
    386      urlMetaData;
    387 
    388    // When RTL domains cause the address bar to overflow to the left, the
    389    // protocol may get hidden, if it was not trimmed. We then set the
    390    // `--urlbar-scheme-size` property to show the protocol in a floating box.
    391    // We don't show the floating protocol box if:
    392    //  - The insecure label is enabled, as it is a sufficient indicator.
    393    //  - The current page is mixed content but formatting is disabled, as it
    394    //    may be confusing for the user to see a non striked out protocol.
    395    //  - The protocol was trimmed.
    396    let isUnformattedMixedContent =
    397      this.#showingMixedContentLoadedPageUrl && !this.formattingEnabled;
    398    if (
    399      !lazy.UrlbarPrefs.get("security.insecure_connection_text.enabled") &&
    400      !isUnformattedMixedContent &&
    401      this.#urlbarInput.value.startsWith(schemeWSlashes)
    402    ) {
    403      this.#scheme.value = schemeWSlashes;
    404      this.#inputField.style.setProperty(
    405        "--urlbar-scheme-size",
    406        schemeWSlashes.length + "ch"
    407      );
    408    }
    409 
    410    this.#ensureFormattedHostVisible(urlMetaData);
    411 
    412    if (!this.formattingEnabled) {
    413      return false;
    414    }
    415 
    416    let editor = this.#urlbarInput.editor;
    417    let controller = editor.selectionController;
    418 
    419    this.#formatScheme(controller.SELECTION_URLSECONDARY);
    420 
    421    let textNode = editor.rootElement.firstChild;
    422 
    423    // Strike out the "https" part if mixed active content status should be
    424    // shown.
    425    if (this.willShowFormattedMixedContentProtocol(this.#urlbarInput.value)) {
    426      let range = this.#document.createRange();
    427      range.setStart(textNode, 0);
    428      range.setEnd(textNode, 5);
    429      let strikeOut = controller.getSelection(
    430        controller.SELECTION_URLSTRIKEOUT
    431      );
    432      strikeOut.addRange(range);
    433      this.#formatScheme(controller.SELECTION_URLSTRIKEOUT);
    434    }
    435 
    436    let baseDomain = domain;
    437    let subDomain = "";
    438    try {
    439      baseDomain = Services.eTLD.getBaseDomainFromHost(origin);
    440      if (!domain.endsWith(baseDomain)) {
    441        // getBaseDomainFromHost converts its resultant to ACE.
    442        let IDNService = Cc["@mozilla.org/network/idn-service;1"].getService(
    443          Ci.nsIIDNService
    444        );
    445        // XXX This should probably convert to display IDN instead.
    446        // https://bugzilla.mozilla.org/show_bug.cgi?id=1906048
    447        baseDomain = IDNService.convertACEtoUTF8(baseDomain);
    448      }
    449    } catch (e) {}
    450    if (baseDomain != domain) {
    451      subDomain = domain.slice(0, -baseDomain.length);
    452    }
    453 
    454    let selection = controller.getSelection(controller.SELECTION_URLSECONDARY);
    455 
    456    let rangeLength = preDomain.length + subDomain.length - trimmedLength;
    457    if (rangeLength) {
    458      let range = this.#document.createRange();
    459      range.setStart(textNode, 0);
    460      range.setEnd(textNode, rangeLength);
    461      selection.addRange(range);
    462    }
    463 
    464    let startRest = preDomain.length + domain.length - trimmedLength;
    465    if (startRest < url.length - trimmedLength) {
    466      let range = this.#document.createRange();
    467      range.setStart(textNode, startRest);
    468      range.setEnd(textNode, url.length - trimmedLength);
    469      selection.addRange(range);
    470    }
    471 
    472    return true;
    473  }
    474 
    475  #formatScheme(selectionType, clear) {
    476    let editor = this.#scheme.editor;
    477    let controller = editor.selectionController;
    478    let textNode = editor.rootElement.firstChild;
    479    let selection = controller.getSelection(selectionType);
    480    if (clear) {
    481      selection.removeAllRanges();
    482    } else {
    483      let r = this.#document.createRange();
    484      r.setStart(textNode, 0);
    485      r.setEnd(textNode, textNode.textContent.length);
    486      selection.addRange(r);
    487    }
    488  }
    489 
    490  #removeSearchAliasFormat() {
    491    if (!this.#formattingApplied) {
    492      return;
    493    }
    494    let selection = this.#urlbarInput.editor.selectionController.getSelection(
    495      Ci.nsISelectionController.SELECTION_FIND
    496    );
    497    selection.removeAllRanges();
    498  }
    499 
    500  /**
    501   * If the input value starts with an @engine search alias, this highlights it.
    502   *
    503   * @returns {boolean}
    504   *   True if formatting was applied and false if not.
    505   */
    506  #formatSearchAlias() {
    507    if (!this.formattingEnabled) {
    508      return false;
    509    }
    510 
    511    let editor = this.#urlbarInput.editor;
    512    let textNode = editor.rootElement.firstChild;
    513    let value = textNode.textContent;
    514    let trimmedValue = value.trim();
    515 
    516    if (
    517      !trimmedValue.startsWith("@") ||
    518      this.#urlbarInput.view.oneOffSearchButtons.selectedButton
    519    ) {
    520      return false;
    521    }
    522 
    523    let alias = this.#findEngineAliasOrRestrictKeyword();
    524    if (!alias) {
    525      return false;
    526    }
    527 
    528    // Make sure the current input starts with the alias because it can change
    529    // without the popup results changing.  Most notably that happens when the
    530    // user performs a search using an alias: The popup closes (preserving its
    531    // results), the search results page loads, and the input value is set to
    532    // the URL of the page.
    533    if (trimmedValue != alias && !trimmedValue.startsWith(alias + " ")) {
    534      return false;
    535    }
    536 
    537    let index = value.indexOf(alias);
    538    if (index < 0) {
    539      return false;
    540    }
    541 
    542    // We abuse the SELECTION_FIND selection type to do our highlighting.
    543    // It's the only type that works with Selection.setColors().
    544    let selection = editor.selectionController.getSelection(
    545      Ci.nsISelectionController.SELECTION_FIND
    546    );
    547 
    548    let range = this.#document.createRange();
    549    range.setStart(textNode, index);
    550    range.setEnd(textNode, index + alias.length);
    551    selection.addRange(range);
    552 
    553    let fg = "#2362d7";
    554    let bg = "#d2e6fd";
    555 
    556    // Selection.setColors() will swap the given foreground and background
    557    // colors if it detects that the contrast between the background
    558    // color and the frame color is too low.  Normally we don't want that
    559    // to happen; we want it to use our colors as given (even if setColors
    560    // thinks the contrast is too low).  But it's a nice feature for non-
    561    // default themes, where the contrast between our background color and
    562    // the input's frame color might actually be too low.  We can
    563    // (hackily) force setColors to use our colors as given by passing
    564    // them as the alternate colors.  Otherwise, allow setColors to swap
    565    // them, which we can do by passing "currentColor".  See
    566    // nsTextPaintStyle::GetHighlightColors for details.
    567    if (
    568      this.#document.documentElement.hasAttribute("lwtheme") ||
    569      this.#window.matchMedia("(prefers-contrast)").matches
    570    ) {
    571      // non-default theme(s)
    572      selection.setColors(fg, bg, "currentColor", "currentColor");
    573    } else {
    574      // default themes
    575      selection.setColors(fg, bg, fg, bg);
    576    }
    577 
    578    return true;
    579  }
    580 
    581  #findEngineAliasOrRestrictKeyword() {
    582    // To determine whether the input contains a valid alias, check if the
    583    // selected result is a search result with an alias. If there is no selected
    584    // result, we check the first result in the view, for cases when we do not
    585    // highlight token alias results. The selected result is null when the popup
    586    // is closed, but we want to continue highlighting the alias when the popup
    587    // is closed, and that's why we keep around the previously selected result
    588    // in #selectedResult.
    589    this.#selectedResult =
    590      this.#urlbarInput.view.selectedResult ||
    591      this.#urlbarInput.view.getResultAtIndex(0) ||
    592      this.#selectedResult;
    593 
    594    if (!this.#selectedResult) {
    595      return null;
    596    }
    597 
    598    let { type, payload } = this.#selectedResult;
    599 
    600    if (type === lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
    601      return payload.keyword || null;
    602    }
    603 
    604    if (type === lazy.UrlbarUtils.RESULT_TYPE.RESTRICT) {
    605      return payload.autofillKeyword || null;
    606    }
    607 
    608    return null;
    609  }
    610 
    611  /**
    612   * Passes DOM events to the _on_<event type> methods.
    613   *
    614   * @param {Event} event
    615   *   DOM event.
    616   */
    617  handleEvent(event) {
    618    let methodName = "_on_" + event.type;
    619    if (methodName in this) {
    620      this[methodName](event);
    621    } else {
    622      throw new Error("Unrecognized UrlbarValueFormatter event: " + event.type);
    623    }
    624  }
    625 
    626  _on_resize(event) {
    627    if (event.target != this.#window) {
    628      return;
    629    }
    630    // Make sure the host remains visible in the input field when the window is
    631    // resized.  We don't want to hurt resize performance though, so do this
    632    // only after resize events have stopped and a small timeout has elapsed.
    633    if (this.#resizeThrottleTimeout) {
    634      this.#window.clearTimeout(this.#resizeThrottleTimeout);
    635    }
    636    this.#resizeThrottleTimeout = this.#window.setTimeout(() => {
    637      this.#resizeThrottleTimeout = null;
    638      let instance = (this.#resizeInstance = {});
    639      this.#window.requestAnimationFrame(() => {
    640        if (instance == this.#resizeInstance) {
    641          this.#ensureFormattedHostVisible();
    642        }
    643      });
    644    }, 100);
    645  }
    646 }