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 }