Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = XPCOMUtils.declareLazy({
BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
});
/**
* Applies URL highlighting and other styling to the text in the urlbar input,
* depending on the text.
*/
export class UrlbarValueFormatter {
/**
* @param {UrlbarInput} urlbarInput
* The parent instance of UrlbarInput
*/
constructor(urlbarInput) {
this.#urlbarInput = urlbarInput;
this.#window.addEventListener("resize", this);
}
async update() {
let instance = (this.#updateInstance = {});
// #getUrlMetaData does URI fixup, which depends on the search service, so
// make sure it's initialized, or URIFixup may force synchronous
// initialization. It can be uninitialized here on session restore. Skip
// this if the service is already initialized in order to avoid the async
// call in the common case. However, we can't access Service.search before
// first paint (delayed startup) because there's a performance test that
// prohibits it, so first await delayed startup.
if (!this.#window.gBrowserInit.delayedStartupFinished) {
await this.#window.delayedStartupPromise;
if (this.#updateInstance != instance) {
return;
}
}
if (!Services.search.isInitialized) {
try {
await Services.search.init();
} catch {}
if (this.#updateInstance != instance) {
return;
}
}
// If this window is being torn down, stop here
if (!this.#window.docShell) {
return;
}
// Cleanup that must be done in any case, even if there's no value.
this.#urlbarInput.removeAttribute("domaindir");
this.#scheme.value = "";
if (!this.#urlbarInput.value) {
return;
}
// Remove the current formatting.
this.#removeURLFormat();
this.#removeSearchAliasFormat();
// Apply new formatting. Formatter methods should return true if they
// successfully formatted the value and false if not. We apply only
// one formatter at a time, so we stop at the first successful one.
this.#window.requestAnimationFrame(() => {
if (this.#updateInstance != instance) {
return;
}
this.#formattingApplied = this.#formatURL() || this.#formatSearchAlias();
});
}
/**
* The parent instance of UrlbarInput
*/
#urlbarInput;
get #document() {
return this.#urlbarInput.document;
}
get #inputField() {
return this.#urlbarInput.inputField;
}
get #window() {
return this.#urlbarInput.window;
}
get #scheme() {
return /** @type {HTMLInputElement} */ (
this.#urlbarInput.querySelector("#urlbar-scheme")
);
}
#ensureFormattedHostVisible(urlMetaData) {
// Make sure the host is always visible. Since it is aligned on
// the first strong directional character, we set scrollLeft
// appropriately to ensure the domain stays visible in case of an
// overflow.
// In the future, for example in bug 525831, we may add a forceRTL
// char just after the domain, and in such a case we should not
// scroll to the left.
urlMetaData = urlMetaData || this.#getUrlMetaData();
if (!urlMetaData) {
this.#urlbarInput.removeAttribute("domaindir");
return;
}
let { url, preDomain, domain } = urlMetaData;
let directionality = this.#window.windowUtils.getDirectionFromText(domain);
if (
directionality == this.#window.windowUtils.DIRECTION_RTL &&
url[preDomain.length + domain.length] != "\u200E"
) {
this.#urlbarInput.setAttribute("domaindir", "rtl");
this.#inputField.scrollLeft = this.#inputField.scrollLeftMax;
} else {
this.#urlbarInput.setAttribute("domaindir", "ltr");
this.#inputField.scrollLeft = 0;
}
this.#urlbarInput.updateTextOverflow();
}
#getUrlMetaData() {
if (this.#urlbarInput.focused) {
return null;
}
let inputValue = this.#urlbarInput.value;
// getFixupURIInfo logs an error if the URL is empty. Avoid that by
// returning early.
if (!inputValue) {
return null;
}
let browser = this.#window.gBrowser.selectedBrowser;
let browserState = this.#urlbarInput.getBrowserState(browser);
// Since doing a full URIFixup and offset calculations is expensive, we
// keep the metadata cached in the browser itself, so when switching tabs
// we can skip most of this.
if (
browserState.urlMetaData &&
browserState.urlMetaData.inputValue == inputValue &&
browserState.urlMetaData.untrimmedValue ==
this.#urlbarInput.untrimmedValue
) {
return browserState.urlMetaData.data;
}
browserState.urlMetaData = {
inputValue,
untrimmedValue: this.#urlbarInput.untrimmedValue,
data: null,
};
// Get the URL from the fixup service:
let flags =
Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS |
Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.#window)) {
flags |= Services.uriFixup.FIXUP_FLAG_PRIVATE_CONTEXT;
}
let uriInfo;
try {
uriInfo = Services.uriFixup.getFixupURIInfo(
this.#urlbarInput.untrimmedValue,
flags
);
} catch (ex) {}
// Ignore if we couldn't make a URI out of this, the URI resulted in a search,
// or the URI has a non-http(s) protocol.
if (
!uriInfo ||
!uriInfo.fixedURI ||
uriInfo.keywordProviderName ||
!["http", "https"].includes(uriInfo.fixedURI.scheme)
) {
return null;
}
// We must ensure the protocol is present in the parsed string, so we don't
// get confused by user:pass@host. It may not have been present originally,
// or it may have been trimmed. We later use trimmedLength to ensure we
// don't count the length of a trimmed protocol when determining which parts
// of the input value to de-emphasize as `preDomain`.
let url = inputValue;
let trimmedLength = 0;
let trimmedProtocol = lazy.BrowserUIUtils.trimURLProtocol;
if (
this.#urlbarInput.untrimmedValue.startsWith(trimmedProtocol) &&
!inputValue.startsWith(trimmedProtocol)
) {
// The protocol has been trimmed, so we add it back.
url = trimmedProtocol + inputValue;
trimmedLength = trimmedProtocol.length;
} else if (
uriInfo.schemelessInput == Ci.nsILoadInfo.SchemelessInputTypeSchemeless
) {
// The original string didn't have a protocol, but it was identified as
// a URL. It's not important which scheme we use for parsing, so we'll
// just copy URIFixup.
let scheme = uriInfo.fixedURI.scheme + "://";
url = scheme + url;
trimmedLength = scheme.length;
}
// This RegExp is not a perfect match, and for specially crafted URLs it may
// get the host wrong; for safety reasons we will later compare the found
// host with the one that will actually be loaded.
let matchedURL = url.match(
/^(([a-z]+:\/\/)(?:[^\/#?]+@)?)(\S+?)(?::\d+)?\s*(?:[\/#?]|$)/
);
if (!matchedURL) {
return null;
}
let [, preDomain, schemeWSlashes, domain] = matchedURL;
// If the found host differs from the fixed URI one, we can't properly
// highlight it. To stay on the safe side, we clobber user's input with
// the fixed URI and apply highlight to that one instead.
let replaceUrl = false;
try {
replaceUrl =
Services.io.newURI("http://" + domain).displayHost !=
uriInfo.fixedURI.displayHost;
} catch (ex) {
return null;
}
if (replaceUrl) {
if (this.#inGetUrlMetaData) {
// Protect from infinite recursion.
return null;
}
try {
this.#inGetUrlMetaData = true;
this.#window.gBrowser.userTypedValue = null;
this.#urlbarInput.setURI(uriInfo.fixedURI);
return this.#getUrlMetaData();
} finally {
this.#inGetUrlMetaData = false;
}
}
return (browserState.urlMetaData.data = {
domain,
origin: uriInfo.fixedURI.host,
preDomain,
schemeWSlashes,
trimmedLength,
url,
});
}
#removeURLFormat() {
if (!this.#formattingApplied) {
return;
}
let controller = this.#urlbarInput.editor.selectionController;
let strikeOut = controller.getSelection(controller.SELECTION_URLSTRIKEOUT);
strikeOut.removeAllRanges();
let selection = controller.getSelection(controller.SELECTION_URLSECONDARY);
selection.removeAllRanges();
this.#formatScheme(controller.SELECTION_URLSTRIKEOUT, true);
this.#formatScheme(controller.SELECTION_URLSECONDARY, true);
this.#inputField.style.setProperty("--urlbar-scheme-size", "0px");
}
/**
* Whether formatting is enabled.
*
* @returns {boolean}
*/
get formattingEnabled() {
return lazy.UrlbarPrefs.get("formatting.enabled");
}
/**
* Whether a striked out active mixed content protocol will show for the
* currently loaded input field value.
*
* @param {string} val The value to evaluate. If it's not the currently
* loaded page, this will return false, as we cannot know if a page has
* active mixed content until it's loaded.
* @returns {boolean}
*/
willShowFormattedMixedContentProtocol(val) {
return (
this.formattingEnabled &&
!lazy.UrlbarPrefs.get("security.insecure_connection_text.enabled") &&
val.startsWith("https://") &&
val == this.#urlbarInput.value &&
this.#showingMixedContentLoadedPageUrl
);
}
/**
* This is used only as an optimization to avoid removing formatting in
* the _remove* format methods when no formatting is actually applied.
*
* @type {boolean}
*/
#formattingApplied = false;
/**
* An empty object, which is used as a lock to avoid updating old instances.
*
* @type {?object}
*/
#updateInstance;
/**
* The previously selected result.
*
* @type {?UrlbarResult}
*/
#selectedResult;
/**
* The timer handling the resize throttling.
*
* @type {?number}
*/
#resizeThrottleTimeout;
/**
* An empty object, which is used to avoid updating old instances.
*
* @type {?object}
*/
#resizeInstance;
/**
* Used to protect against re-entry in getUrlMetaData.
*
* @type {boolean}
*/
#inGetUrlMetaData = false;
/**
* Whether the currently loaded page is in mixed content mode.
*
* @returns {boolean} whether the loaded page has active mixed content.
*/
get #showingMixedContentLoadedPageUrl() {
return (
this.#urlbarInput.getAttribute("pageproxystate") == "valid" &&
!!(
this.#window.gBrowser.securityUI.state &
Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT
)
);
}
/**
* If the input value is a URL and the input is not focused, this
* formatter method highlights the domain, and if mixed content is present,
* it crosses out the https scheme. It also ensures that the host is
* visible (not scrolled out of sight).
*
* @returns {boolean}
* True if formatting was applied and false if not.
*/
#formatURL() {
let urlMetaData = this.#getUrlMetaData();
if (!urlMetaData) {
return false;
}
let state = this.#urlbarInput.getBrowserState(
this.#window.gBrowser.selectedBrowser
);
if (state.searchTerms) {
return false;
}
let { domain, origin, preDomain, schemeWSlashes, trimmedLength, url } =
urlMetaData;
// When RTL domains cause the address bar to overflow to the left, the
// protocol may get hidden, if it was not trimmed. We then set the
// `--urlbar-scheme-size` property to show the protocol in a floating box.
// We don't show the floating protocol box if:
// - The insecure label is enabled, as it is a sufficient indicator.
// - The current page is mixed content but formatting is disabled, as it
// may be confusing for the user to see a non striked out protocol.
// - The protocol was trimmed.
let isUnformattedMixedContent =
this.#showingMixedContentLoadedPageUrl && !this.formattingEnabled;
if (
!lazy.UrlbarPrefs.get("security.insecure_connection_text.enabled") &&
!isUnformattedMixedContent &&
this.#urlbarInput.value.startsWith(schemeWSlashes)
) {
this.#scheme.value = schemeWSlashes;
this.#inputField.style.setProperty(
"--urlbar-scheme-size",
schemeWSlashes.length + "ch"
);
}
this.#ensureFormattedHostVisible(urlMetaData);
if (!this.formattingEnabled) {
return false;
}
let editor = this.#urlbarInput.editor;
let controller = editor.selectionController;
this.#formatScheme(controller.SELECTION_URLSECONDARY);
let textNode = editor.rootElement.firstChild;
// Strike out the "https" part if mixed active content status should be
// shown.
if (this.willShowFormattedMixedContentProtocol(this.#urlbarInput.value)) {
let range = this.#document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, 5);
let strikeOut = controller.getSelection(
controller.SELECTION_URLSTRIKEOUT
);
strikeOut.addRange(range);
this.#formatScheme(controller.SELECTION_URLSTRIKEOUT);
}
let baseDomain = domain;
let subDomain = "";
try {
baseDomain = Services.eTLD.getBaseDomainFromHost(origin);
if (!domain.endsWith(baseDomain)) {
// getBaseDomainFromHost converts its resultant to ACE.
let IDNService = Cc["@mozilla.org/network/idn-service;1"].getService(
Ci.nsIIDNService
);
// XXX This should probably convert to display IDN instead.
baseDomain = IDNService.convertACEtoUTF8(baseDomain);
}
} catch (e) {}
if (baseDomain != domain) {
subDomain = domain.slice(0, -baseDomain.length);
}
let selection = controller.getSelection(controller.SELECTION_URLSECONDARY);
let rangeLength = preDomain.length + subDomain.length - trimmedLength;
if (rangeLength) {
let range = this.#document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, rangeLength);
selection.addRange(range);
}
let startRest = preDomain.length + domain.length - trimmedLength;
if (startRest < url.length - trimmedLength) {
let range = this.#document.createRange();
range.setStart(textNode, startRest);
range.setEnd(textNode, url.length - trimmedLength);
selection.addRange(range);
}
return true;
}
#formatScheme(selectionType, clear) {
let editor = this.#scheme.editor;
let controller = editor.selectionController;
let textNode = editor.rootElement.firstChild;
let selection = controller.getSelection(selectionType);
if (clear) {
selection.removeAllRanges();
} else {
let r = this.#document.createRange();
r.setStart(textNode, 0);
r.setEnd(textNode, textNode.textContent.length);
selection.addRange(r);
}
}
#removeSearchAliasFormat() {
if (!this.#formattingApplied) {
return;
}
let selection = this.#urlbarInput.editor.selectionController.getSelection(
Ci.nsISelectionController.SELECTION_FIND
);
selection.removeAllRanges();
}
/**
* If the input value starts with an @engine search alias, this highlights it.
*
* @returns {boolean}
* True if formatting was applied and false if not.
*/
#formatSearchAlias() {
if (!this.formattingEnabled) {
return false;
}
let editor = this.#urlbarInput.editor;
let textNode = editor.rootElement.firstChild;
let value = textNode.textContent;
let trimmedValue = value.trim();
if (
!trimmedValue.startsWith("@") ||
this.#urlbarInput.view.oneOffSearchButtons.selectedButton
) {
return false;
}
let alias = this.#findEngineAliasOrRestrictKeyword();
if (!alias) {
return false;
}
// Make sure the current input starts with the alias because it can change
// without the popup results changing. Most notably that happens when the
// user performs a search using an alias: The popup closes (preserving its
// results), the search results page loads, and the input value is set to
// the URL of the page.
if (trimmedValue != alias && !trimmedValue.startsWith(alias + " ")) {
return false;
}
let index = value.indexOf(alias);
if (index < 0) {
return false;
}
// We abuse the SELECTION_FIND selection type to do our highlighting.
// It's the only type that works with Selection.setColors().
let selection = editor.selectionController.getSelection(
Ci.nsISelectionController.SELECTION_FIND
);
let range = this.#document.createRange();
range.setStart(textNode, index);
range.setEnd(textNode, index + alias.length);
selection.addRange(range);
let fg = "#2362d7";
let bg = "#d2e6fd";
// Selection.setColors() will swap the given foreground and background
// colors if it detects that the contrast between the background
// color and the frame color is too low. Normally we don't want that
// to happen; we want it to use our colors as given (even if setColors
// thinks the contrast is too low). But it's a nice feature for non-
// default themes, where the contrast between our background color and
// the input's frame color might actually be too low. We can
// (hackily) force setColors to use our colors as given by passing
// them as the alternate colors. Otherwise, allow setColors to swap
// them, which we can do by passing "currentColor". See
// nsTextPaintStyle::GetHighlightColors for details.
if (
this.#document.documentElement.hasAttribute("lwtheme") ||
this.#window.matchMedia("(prefers-contrast)").matches
) {
// non-default theme(s)
selection.setColors(fg, bg, "currentColor", "currentColor");
} else {
// default themes
selection.setColors(fg, bg, fg, bg);
}
return true;
}
#findEngineAliasOrRestrictKeyword() {
// To determine whether the input contains a valid alias, check if the
// selected result is a search result with an alias. If there is no selected
// result, we check the first result in the view, for cases when we do not
// highlight token alias results. The selected result is null when the popup
// is closed, but we want to continue highlighting the alias when the popup
// is closed, and that's why we keep around the previously selected result
// in #selectedResult.
this.#selectedResult =
this.#urlbarInput.view.selectedResult ||
this.#urlbarInput.view.getResultAtIndex(0) ||
this.#selectedResult;
if (!this.#selectedResult) {
return null;
}
let { type, payload } = this.#selectedResult;
if (type === lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
return payload.keyword || null;
}
if (type === lazy.UrlbarUtils.RESULT_TYPE.RESTRICT) {
return payload.autofillKeyword || null;
}
return null;
}
/**
* Passes DOM events to the _on_<event type> methods.
*
* @param {Event} event
* DOM event.
*/
handleEvent(event) {
let methodName = "_on_" + event.type;
if (methodName in this) {
this[methodName](event);
} else {
throw new Error("Unrecognized UrlbarValueFormatter event: " + event.type);
}
}
_on_resize(event) {
if (event.target != this.#window) {
return;
}
// Make sure the host remains visible in the input field when the window is
// resized. We don't want to hurt resize performance though, so do this
// only after resize events have stopped and a small timeout has elapsed.
if (this.#resizeThrottleTimeout) {
this.#window.clearTimeout(this.#resizeThrottleTimeout);
}
this.#resizeThrottleTimeout = this.#window.setTimeout(() => {
this.#resizeThrottleTimeout = null;
let instance = (this.#resizeInstance = {});
this.#window.requestAnimationFrame(() => {
if (instance == this.#resizeInstance) {
this.#ensureFormattedHostVisible();
}
});
}, 100);
}
}