diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarInput.sys.mjs | 4007 |
1 files changed, 4007 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarInput.sys.mjs b/browser/components/urlbar/UrlbarInput.sys.mjs new file mode 100644 index 0000000000..c6b31878a7 --- /dev/null +++ b/browser/components/urlbar/UrlbarInput.sys.mjs @@ -0,0 +1,4007 @@ +/* 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"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + ExtensionSearchHandler: + "resource://gre/modules/ExtensionSearchHandler.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarQueryContext: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarValueFormatter: "resource:///modules/UrlbarValueFormatter.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + ReaderMode: "resource://gre/modules/ReaderMode.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +const DEFAULT_FORM_HISTORY_NAME = "searchbar-history"; +const SEARCH_BUTTON_ID = "urlbar-search-button"; + +// The scalar category of TopSites click for Contextual Services +const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites.click"; + +let getBoundsWithoutFlushing = element => + element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element); +let px = number => number.toFixed(2) + "px"; + +/** + * Implements the text input part of the address bar UI. + */ +export class UrlbarInput { + /** + * @param {object} options + * The initial options for UrlbarInput. + * @param {object} options.textbox + * The container element. + */ + constructor(options = {}) { + this.textbox = options.textbox; + + this.window = this.textbox.ownerGlobal; + this.isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(this.window); + this.document = this.window.document; + + // Create the panel to contain results. + this.textbox.appendChild( + this.window.MozXULElement.parseXULToFragment(` + <vbox class="urlbarView" + role="group" + tooltip="aHTMLTooltip"> + <html:div class="urlbarView-body-outer"> + <html:div class="urlbarView-body-inner"> + <html:div id="urlbar-results" + class="urlbarView-results" + role="listbox"/> + </html:div> + </html:div> + <menupopup class="urlbarView-result-menu"/> + <hbox class="search-one-offs" + includecurrentengine="true" + disabletab="true"/> + </vbox> + `) + ); + this.panel = this.textbox.querySelector(".urlbarView"); + + this.searchButton = lazy.UrlbarPrefs.get("experimental.searchButton"); + if (this.searchButton) { + this.textbox.classList.add("searchButton"); + } + + this.controller = new lazy.UrlbarController({ + input: this, + eventTelemetryCategory: options.eventTelemetryCategory, + }); + this.view = new lazy.UrlbarView(this); + this.valueIsTyped = false; + this.formHistoryName = DEFAULT_FORM_HISTORY_NAME; + this.lastQueryContextPromise = Promise.resolve(); + this._actionOverrideKeyCount = 0; + this._autofillPlaceholder = null; + this._lastSearchString = ""; + this._lastValidURLStr = ""; + this._valueOnLastSearch = ""; + this._resultForCurrentValue = null; + this._suppressStartQuery = false; + this._suppressPrimaryAdjustment = false; + this._untrimmedValue = ""; + + // Search modes are per browser and are stored in this map. For a + // browser, search mode can be in preview mode, confirmed, or both. + // Typically, search mode is entered in preview mode with a particular + // source and is confirmed with the same source once a query starts. It's + // also possible for a confirmed search mode to be replaced with a preview + // mode with a different source, and in those cases, we need to re-confirm + // search mode when preview mode is exited. In addition, only confirmed + // search modes should be restored across sessions. We therefore need to + // keep track of both the current confirmed and preview modes, per browser. + // + // For each browser with a search mode, this maps the browser to an object + // like this: { preview, confirmed }. Both `preview` and `confirmed` are + // search mode objects; see the setSearchMode documentation. Either one may + // be undefined if that particular mode is not active for the browser. + this._searchModesByBrowser = new WeakMap(); + + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + this._addObservers(); + + // This exists only for tests. + this._enableAutofillPlaceholder = true; + + // Forward certain methods and properties. + const CONTAINER_METHODS = [ + "getAttribute", + "hasAttribute", + "querySelector", + "setAttribute", + "removeAttribute", + "toggleAttribute", + ]; + const INPUT_METHODS = ["addEventListener", "blur", "removeEventListener"]; + const READ_WRITE_PROPERTIES = [ + "placeholder", + "readOnly", + "selectionStart", + "selectionEnd", + ]; + + for (let method of CONTAINER_METHODS) { + this[method] = (...args) => { + return this.textbox[method](...args); + }; + } + + for (let method of INPUT_METHODS) { + this[method] = (...args) => { + return this.inputField[method](...args); + }; + } + + for (let property of READ_WRITE_PROPERTIES) { + Object.defineProperty(this, property, { + enumerable: true, + get() { + return this.inputField[property]; + }, + set(val) { + this.inputField[property] = val; + }, + }); + } + + this.inputField = this.querySelector("#urlbar-input"); + this._inputContainer = this.querySelector("#urlbar-input-container"); + this._identityBox = this.querySelector("#identity-box"); + this._searchModeIndicator = this.querySelector( + "#urlbar-search-mode-indicator" + ); + this._searchModeIndicatorTitle = this._searchModeIndicator.querySelector( + "#urlbar-search-mode-indicator-title" + ); + this._searchModeIndicatorClose = this._searchModeIndicator.querySelector( + "#urlbar-search-mode-indicator-close" + ); + this._searchModeLabel = this.querySelector("#urlbar-label-search-mode"); + this._toolbar = this.textbox.closest("toolbar"); + + XPCOMUtils.defineLazyGetter(this, "valueFormatter", () => { + return new lazy.UrlbarValueFormatter(this); + }); + + XPCOMUtils.defineLazyGetter(this, "addSearchEngineHelper", () => { + return new AddSearchEngineHelper(this); + }); + + // If the toolbar is not visible in this window or the urlbar is readonly, + // we'll stop here, so that most properties of the input object are valid, + // but we won't handle events. + if (!this.window.toolbar.visible || this.readOnly) { + return; + } + + // The event bufferer can be used to defer events that may affect users + // muscle memory; for example quickly pressing DOWN+ENTER should end up + // on a predictable result, regardless of the search status. The event + // bufferer will invoke the handling code at the right time. + this.eventBufferer = new lazy.UrlbarEventBufferer(this); + + this._inputFieldEvents = [ + "compositionstart", + "compositionend", + "contextmenu", + "dragover", + "dragstart", + "drop", + "focus", + "blur", + "input", + "beforeinput", + "keydown", + "keyup", + "mouseover", + "overflow", + "underflow", + "paste", + "scrollend", + "select", + ]; + for (let name of this._inputFieldEvents) { + this.addEventListener(name, this); + } + + this.window.addEventListener("mousedown", this); + if (AppConstants.platform == "win") { + this.window.addEventListener("draggableregionleftmousedown", this); + } + this.textbox.addEventListener("mousedown", this); + + // This listener handles clicks from our children too, included the search mode + // indicator close button. + this._inputContainer.addEventListener("click", this); + + // This is used to detect commands launched from the panel, to avoid + // recording abandonment events when the command causes a blur event. + this.view.panel.addEventListener("command", this, true); + + this.window.gBrowser.tabContainer.addEventListener("TabSelect", this); + + this.window.addEventListener("customizationstarting", this); + this.window.addEventListener("aftercustomization", this); + + this.updateLayoutBreakout(); + + this._initCopyCutController(); + this._initPasteAndGo(); + + // Tracks IME composition. + this._compositionState = lazy.UrlbarUtils.COMPOSITION.NONE; + this._compositionClosedPopup = false; + + this.editor.newlineHandling = + Ci.nsIEditor.eNewlinesStripSurroundingWhitespace; + } + + /** + * Applies styling to the text in the urlbar input, depending on the text. + */ + formatValue() { + // The editor may not exist if the toolbar is not visible. + if (this.editor) { + this.valueFormatter.update(); + } + } + + focus() { + let beforeFocus = new CustomEvent("beforefocus", { + bubbles: true, + cancelable: true, + }); + this.inputField.dispatchEvent(beforeFocus); + if (beforeFocus.defaultPrevented) { + return; + } + + this.inputField.focus(); + } + + select() { + let beforeSelect = new CustomEvent("beforeselect", { + bubbles: true, + cancelable: true, + }); + this.inputField.dispatchEvent(beforeSelect); + if (beforeSelect.defaultPrevented) { + return; + } + + // See _on_select(). HTMLInputElement.select() dispatches a "select" + // event but does not set the primary selection. + this._suppressPrimaryAdjustment = true; + this.inputField.select(); + this._suppressPrimaryAdjustment = false; + } + + /** + * Sets the URI to display in the location bar. + * + * @param {nsIURI} [uri] + * If this is unspecified, the current URI will be used. + * @param {boolean} [dueToTabSwitch] + * True if this is being called due to switching tabs and false + * otherwise. + * @param {boolean} [dueToSessionRestore] + * True if this is being called due to session restore and false + * otherwise. + * @param {boolean} [dontShowSearchTerms] + * True if userTypedValue should not be overidden by search terms + * and false otherwise. + */ + setURI( + uri = null, + dueToTabSwitch = false, + dueToSessionRestore = false, + dontShowSearchTerms = false + ) { + if ( + !dontShowSearchTerms && + (this.window.gBrowser.userTypedValue == null || + this.window.gBrowser.userTypedValue == "") + ) { + this.window.gBrowser.selectedBrowser.showingSearchTerms = false; + if (lazy.UrlbarPrefs.isPersistedSearchTermsEnabled()) { + let term = lazy.UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( + this.window.gBrowser.selectedBrowser.originalURI ?? uri + ); + if (term) { + this.window.gBrowser.userTypedValue = term; + this.window.gBrowser.selectedBrowser.showingSearchTerms = true; + } + } + } + + let value = this.window.gBrowser.userTypedValue; + let valid = false; + + // Restore the selected browser's current URI if `value` is null or if it's + // an empty string and we're switching tabs. In the latter case, when the + // user makes the input empty, switches tabs, and switches back, we want the + // URI to become visible again so the user knows what URI they're viewing. + if (value === null || (!value && dueToTabSwitch)) { + uri = uri || this.window.gBrowser.currentURI; + // Strip off usernames and passwords for the location bar + try { + uri = Services.io.createExposableURI(uri); + } catch (e) {} + + // Replace initial page URIs with an empty string + // only if there's no opener (bug 370555). + if ( + this.window.isInitialPage(uri) && + lazy.BrowserUIUtils.checkEmptyPageOrigin( + this.window.gBrowser.selectedBrowser, + uri + ) + ) { + value = ""; + } else { + // We should deal with losslessDecodeURI throwing for exotic URIs + try { + value = losslessDecodeURI(uri); + } catch (ex) { + value = "about:blank"; + } + } + // If we update the URI while restoring a session, set the proxyState to + // invalid, because we don't have a valid security state to show via site + // identity yet. See Bug 1746383. + valid = + !dueToSessionRestore && + (!this.window.isBlankPageURL(uri.spec) || + uri.schemeIs("moz-extension")); + } else if ( + this.window.isInitialPage(value) && + lazy.BrowserUIUtils.checkEmptyPageOrigin( + this.window.gBrowser.selectedBrowser + ) + ) { + value = ""; + valid = true; + } + + const previousUntrimmedValue = this.untrimmedValue; + const previousSelectionStart = this.selectionStart; + const previousSelectionEnd = this.selectionEnd; + + this.value = value; + this.valueIsTyped = !valid; + this.removeAttribute("usertyping"); + + if (this.focused && value != previousUntrimmedValue) { + if ( + previousSelectionStart != previousSelectionEnd && + value.substring(previousSelectionStart, previousSelectionEnd) === + previousUntrimmedValue.substring( + previousSelectionStart, + previousSelectionEnd + ) + ) { + // If the same text is in the same place as the previously selected text, + // the selection is kept. + this.inputField.setSelectionRange( + previousSelectionStart, + previousSelectionEnd + ); + } else if ( + previousSelectionEnd && + (previousUntrimmedValue.length === previousSelectionEnd || + value.length <= previousSelectionEnd) + ) { + // If the previous end caret is not 0 and the caret is at the end of the + // input or its position is beyond the end of the new value, keep the + // position at the end. + this.inputField.setSelectionRange(value.length, value.length); + } else { + // Otherwise clear selection and set the caret position to the previous + // caret end position. + this.inputField.setSelectionRange( + previousSelectionEnd, + previousSelectionEnd + ); + } + } + + // The proxystate must be set before setting search mode below because + // search mode depends on it. + this.setPageProxyState(valid ? "valid" : "invalid", dueToTabSwitch); + + // If we're switching tabs, restore the tab's search mode. Otherwise, if + // the URI is valid, exit search mode. This must happen after setting + // proxystate above because search mode depends on it. + if (dueToTabSwitch && !valid) { + this.restoreSearchModeState(); + } else if (valid) { + this.searchMode = null; + } + + // Dispatch URIUpdate event to synchronize the tab status when switching. + let event = new CustomEvent("SetURI", { bubbles: true }); + this.inputField.dispatchEvent(event); + } + + /** + * Converts an internal URI (e.g. a URI with a username or password) into one + * which we can expose to the user. + * + * @param {nsIURI} uri + * The URI to be converted + * @returns {nsIURI} + * The converted, exposable URI + */ + makeURIReadable(uri) { + // Avoid copying 'about:reader?url=', and always provide the original URI: + // Reader mode ensures we call createExposableURI itself. + let readerStrippedURI = lazy.ReaderMode.getOriginalUrlObjectForDisplay( + uri.displaySpec + ); + if (readerStrippedURI) { + return readerStrippedURI; + } + + try { + return Services.io.createExposableURI(uri); + } catch (ex) {} + + return uri; + } + + /** + * Passes DOM events to the _on_<event type> methods. + * + * @param {Event} event The event to handle. + */ + handleEvent(event) { + let methodName = "_on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized UrlbarInput event: " + event.type); + } + } + + /** + * Handles an event which might open text or a URL. If the event requires + * doing so, handleCommand forwards it to handleNavigation. + * + * @param {Event} [event] The event triggering the open. + */ + handleCommand(event = null) { + let isMouseEvent = this.window.MouseEvent.isInstance(event); + if (isMouseEvent && event.button == 2) { + // Do nothing for right clicks. + return; + } + + // Determine whether to use the selected one-off search button. In + // one-off search buttons parlance, "selected" means that the button + // has been navigated to via the keyboard. So we want to use it if + // the triggering event is not a mouse click -- i.e., it's a Return + // key -- or if the one-off was mouse-clicked. + if (this.view.isOpen) { + let selectedOneOff = this.view.oneOffSearchButtons.selectedButton; + if (selectedOneOff && (!isMouseEvent || event.target == selectedOneOff)) { + this.view.oneOffSearchButtons.handleSearchCommand(event, { + engineName: selectedOneOff.engine?.name, + source: selectedOneOff.source, + entry: "oneoff", + }); + return; + } + } + + this.handleNavigation({ event }); + } + + /** + * @typedef {object} HandleNavigationOneOffParams + * + * @property {string} openWhere + * Where we expect the result to be opened. + * @property {object} openParams + * The parameters related to where the result will be opened. + * @property {Node} engine + * The selected one-off's engine. + */ + + /** + * Handles an event which would cause a URL or text to be opened. + * + * @param {object} [options] + * Options for the navigation. + * @param {Event} [options.event] + * The event triggering the open. + * @param {HandleNavigationOneOffParams} [options.oneOffParams] + * Optional. Pass if this navigation was triggered by a one-off. Practically + * speaking, UrlbarSearchOneOffs passes this when the user holds certain key + * modifiers while picking a one-off. In those cases, we do an immediate + * search using the one-off's engine instead of entering search mode. + * @param {object} [options.triggeringPrincipal] + * The principal that the action was triggered from. + */ + handleNavigation({ event, oneOffParams, triggeringPrincipal }) { + let element = this.view.selectedElement; + let result = this.view.getResultFromElement(element); + let openParams = oneOffParams?.openParams || {}; + + // If the value was submitted during composition, the result may not have + // been updated yet, because the input event happens after composition end. + // We can't trust element nor _resultForCurrentValue targets in that case, + // so we'always generate a new heuristic to load. + let isComposing = this.editor.composing; + + // Use the selected element if we have one; this is usually the case + // when the view is open. + let selectedPrivateResult = + result && + result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.inPrivateWindow; + let selectedPrivateEngineResult = + selectedPrivateResult && result.payload.isPrivateEngine; + if ( + !isComposing && + element && + (!oneOffParams?.engine || selectedPrivateEngineResult) + ) { + this.pickElement(element, event); + return; + } + + // Use the hidden heuristic if it exists and there's no selection. + if ( + lazy.UrlbarPrefs.get("experimental.hideHeuristic") && + !element && + !isComposing && + !oneOffParams?.engine && + this._resultForCurrentValue?.heuristic + ) { + this.pickResult(this._resultForCurrentValue, event); + return; + } + + // We don't select a heuristic result when we're autofilling a token alias, + // but we want pressing Enter to behave like the first result was selected. + if (!result && this.value.startsWith("@")) { + let tokenAliasResult = this.view.getResultAtIndex(0); + if (tokenAliasResult?.autofill && tokenAliasResult?.payload.keyword) { + this.pickResult(tokenAliasResult, event); + return; + } + } + + let url; + let selType = this.controller.engagementEvent.typeFromElement(element); + let typedValue = this.value; + if (oneOffParams?.engine) { + selType = "oneoff"; + typedValue = this._lastSearchString; + // If there's a selected one-off button then load a search using + // the button's engine. + result = this._resultForCurrentValue; + let searchString = + (result && (result.payload.suggestion || result.payload.query)) || + this._lastSearchString; + [url, openParams.postData] = lazy.UrlbarUtils.getSearchQueryUrl( + oneOffParams.engine, + searchString + ); + this._recordSearch(oneOffParams.engine, event, { url }); + + lazy.UrlbarUtils.addToFormHistory( + this, + searchString, + oneOffParams.engine.name + ).catch(Cu.reportError); + } else { + // Use the current value if we don't have a UrlbarResult e.g. because the + // view is closed. + url = this.untrimmedValue; + openParams.postData = null; + } + + if (!url) { + return; + } + + // When the user hits enter in a local search mode and there's no selected + // result or one-off, don't do anything. + if ( + this.searchMode && + !this.searchMode.engineName && + !result && + !oneOffParams + ) { + return; + } + + let selectedResult = result || this.view.selectedResult; + this.controller.recordSelectedResult(event, selectedResult); + + let where = oneOffParams?.openWhere || this._whereToOpen(event); + if (selectedPrivateResult) { + where = "window"; + openParams.private = true; + } + openParams.allowInheritPrincipal = false; + url = this._maybeCanonizeURL(event, url) || url.trim(); + + this.controller.engagementEvent.record(event, { + searchString: typedValue, + selIndex: this.view.selectedRowIndex, + selType, + provider: selectedResult?.providerName, + }); + + let isValidUrl = false; + try { + new URL(url); + isValidUrl = true; + } catch (ex) {} + if (isValidUrl) { + this._loadURL(url, event, where, openParams); + return; + } + + // This is not a URL and there's no selected element, because likely the + // view is closed, or paste&go was used. + // We must act consistently here, having or not an open view should not + // make a difference if the search string is the same. + + // If we have a result for the current value, we can just use it. + if (!isComposing && this._resultForCurrentValue) { + this.pickResult(this._resultForCurrentValue, event); + return; + } + + // Otherwise, we must fetch the heuristic result for the current value. + // TODO (Bug 1604927): If the urlbar results are restricted to a specific + // engine, here we must search with that specific engine; indeed the + // docshell wouldn't know about our engine restriction. + // Also remember to invoke this._recordSearch, after replacing url with + // the appropriate engine submission url. + let browser = this.window.gBrowser.selectedBrowser; + let lastLocationChange = browser.lastLocationChange; + lazy.UrlbarUtils.getHeuristicResultFor(url) + .then(newResult => { + // Because this happens asynchronously, we must verify that the browser + // location did not change in the meanwhile. + if ( + where != "current" || + browser.lastLocationChange == lastLocationChange + ) { + this.pickResult(newResult, event, null, browser); + } + }) + .catch(ex => { + if (url) { + // Something went wrong, we should always have a heuristic result, + // otherwise it means we're not able to search at all, maybe because + // some parts of the profile are corrupt. + // The urlbar should still allow to search or visit the typed string, + // so that the user can look for help to resolve the problem. + let flags = + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (this.isPrivate) { + flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + let { + preferredURI: uri, + postData, + } = Services.uriFixup.getFixupURIInfo(url, flags); + if ( + where != "current" || + browser.lastLocationChange == lastLocationChange + ) { + openParams.postData = postData; + this._loadURL(uri.spec, event, where, openParams, null, browser); + } + } + }); + // Don't add further handling here, the catch above is our last resort. + } + + handleRevert(dontShowSearchTerms = false) { + this.window.gBrowser.userTypedValue = null; + // Nullify search mode before setURI so it won't try to restore it. + this.searchMode = null; + this.setURI(null, true, false, dontShowSearchTerms); + if (this.value && this.focused) { + this.select(); + } + } + + /** + * Called by inputs that resemble search boxes, but actually hand input off + * to the Urlbar. We use these fake inputs on the new tab page and + * about:privatebrowsing. + * + * @param {string} searchString + * The search string to use. + * @param {nsISearchEngine} [searchEngine] + * Optional. If included and the right prefs are set, we will enter search + * mode when handing `searchString` from the fake input to the Urlbar. + * @param {string} newtabSessionId + * Optional. The id of the newtab session that handed off this search. + * + */ + handoff(searchString, searchEngine, newtabSessionId) { + this._isHandoffSession = true; + this._handoffSession = newtabSessionId; + if (lazy.UrlbarPrefs.get("shouldHandOffToSearchMode") && searchEngine) { + this.search(searchString, { + searchEngine, + searchModeEntry: "handoff", + }); + } else { + this.search(searchString); + } + } + + /** + * Called when an element of the view is picked. + * + * @param {Element} element The element that was picked. + * @param {Event} event The event that picked the element. + */ + pickElement(element, event) { + let result = this.view.getResultFromElement(element); + if (!result) { + return; + } + this.pickResult(result, event, element); + } + + /** + * Called when a result is picked. + * + * @param {UrlbarResult} result The result that was picked. + * @param {Event} event The event that picked the result. + * @param {DOMElement} element the picked view element, if available. + * @param {object} browser The browser to use for the load. + */ + // eslint-disable-next-line complexity + pickResult( + result, + event, + element = null, + browser = this.window.gBrowser.selectedBrowser + ) { + // When a one-off is selected, we restyle heuristic results to look like + // search results. In the unlikely event that they are clicked, instead of + // picking the results as usual, we confirm search mode, same as if the user + // had selected them and pressed the enter key. Restyling results in this + // manner was agreed on as a compromise between consistent UX and + // engineering effort. See review discussion at bug 1667766. + if ( + result.heuristic && + this.searchMode?.isPreview && + this.view.oneOffSearchButtons.selectedButton + ) { + this.confirmSearchMode(); + this.search(this.value); + return; + } + + if (element?.classList.contains("urlbarView-button-menu")) { + this.view.openResultMenu(result, element); + return; + } + + if (element?.classList.contains("urlbarView-button-block")) { + this.controller.handleDeleteEntry(event, result); + return; + } + + let urlOverride = element?.dataset.url; + let originalUntrimmedValue = this.untrimmedValue; + let isCanonized = this.setValueFromResult({ result, event, urlOverride }); + let where = this._whereToOpen(event); + let openParams = { + allowInheritPrincipal: false, + globalHistoryOptions: { + triggeringSearchEngine: result.payload?.engine, + triggeringSponsoredURL: result.payload?.isSponsored + ? result.payload.url + : undefined, + }, + private: this.isPrivate, + }; + + if ( + urlOverride && + result.type != lazy.UrlbarUtils.RESULT_TYPE.TIP && + where == "current" + ) { + // Open non-tip help links in a new tab unless the user held a modifier. + // TODO (bug 1696232): Do this for tip help links, too. + where = "tab"; + } + + let selIndex = result.rowIndex; + if (!result.payload.providesSearchMode) { + this.view.close({ elementPicked: true }); + } + + this.controller.recordSelectedResult(event, result); + + if (result.providerName === "TabToSearch") { + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex: result.rowIndex, + provider: result.providerName, + }); + } + + if (isCanonized) { + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "canonized", + provider: result.providerName, + }); + this._loadURL(this.value, event, where, openParams, browser); + return; + } + + let { url, postData } = urlOverride + ? { url: urlOverride, postData: null } + : lazy.UrlbarUtils.getUrlFromResult(result); + openParams.postData = postData; + + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.URL: { + // Bug 1578856: both the provider and the docshell run heuristics to + // decide how to handle a non-url string, either fixing it to a url, or + // searching for it. + // Some preferences can control the docshell behavior, for example + // if dns_first_for_single_words is true, the docshell looks up the word + // against the dns server, and either loads it as an url or searches for + // it, depending on the lookup result. The provider instead will always + // return a fixed url in this case, because URIFixup is synchronous and + // can't do a synchronous dns lookup. A possible long term solution + // would involve sharing the docshell logic with the provider, along + // with the dns lookup. + // For now, in this specific case, we'll override the result's url + // with the input value, and let it pass through to _loadURL(), and + // finally to the docshell. + // This also means that in some cases the heuristic result will show a + // Visit entry, but the docshell will instead execute a search. It's a + // rare case anyway, most likely to happen for enterprises customizing + // the urifixup prefs. + if ( + result.heuristic && + lazy.UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") && + lazy.UrlbarUtils.looksLikeSingleWordHost(originalUntrimmedValue) + ) { + url = originalUntrimmedValue; + } + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: { + // If this result comes from a bookmark keyword, let it inherit the + // current document's principal, otherwise bookmarklets would break. + openParams.allowInheritPrincipal = true; + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: { + if (this.hasAttribute("actionoverride")) { + where = "current"; + break; + } + + // Keep the searchMode for telemetry since handleRevert sets it to null. + const searchMode = this.searchMode; + this.handleRevert(); + let prevTab = this.window.gBrowser.selectedTab; + let loadOpts = { + adoptIntoActiveWindow: lazy.UrlbarPrefs.get( + "switchTabs.adoptIntoActiveWindow" + ), + }; + + // We cache the search string because switching tab may clear it. + let searchString = this._lastSearchString; + this.controller.engagementEvent.record(event, { + searchString, + searchMode, + selIndex, + selType: "tabswitch", + provider: result.providerName, + }); + + let switched = this.window.switchToTabHavingURI( + Services.io.newURI(url), + false, + loadOpts + ); + if (switched && prevTab.isEmpty) { + this.window.gBrowser.removeTab(prevTab); + } + + if (switched && !this.isPrivate && !result.heuristic) { + // We don't await for this, because a rejection should not interrupt + // the load. Just reportError it. + lazy.UrlbarUtils.addToInputHistory(url, searchString).catch( + Cu.reportError + ); + } + + return; + } + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: { + if (result.payload.providesSearchMode) { + let searchModeParams = this._searchModeForResult(result); + if (searchModeParams) { + this.searchMode = searchModeParams; + this.search(""); + } + return; + } + + if ( + !this.searchMode && + result.heuristic && + // If we asked the DNS earlier, avoid the post-facto check. + !lazy.UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") && + // TODO (bug 1642623): for now there is no smart heuristic to skip the + // DNS lookup, so any value above 0 will run it. + lazy.UrlbarPrefs.get("dnsResolveSingleWordsAfterSearch") > 0 && + this.window.gKeywordURIFixup && + lazy.UrlbarUtils.looksLikeSingleWordHost(originalUntrimmedValue) + ) { + // When fixing a single word to a search, the docShell would also + // query the DNS and if resolved ask the user whether they would + // rather visit that as a host. On a positive answer, it adds the host + // to the list that we use to make decisions. + // Because we are directly asking for a search here, bypassing the + // docShell, we need to do the same ourselves. + // See also URIFixupChild.jsm and keyword-uri-fixup. + let fixupInfo = this._getURIFixupInfo(originalUntrimmedValue.trim()); + if (fixupInfo) { + this.window.gKeywordURIFixup.check( + this.window.gBrowser.selectedBrowser, + fixupInfo + ); + } + } + + if (result.payload.inPrivateWindow) { + where = "window"; + openParams.private = true; + } + + const actionDetails = { + isSuggestion: !!result.payload.suggestion, + isFormHistory: + result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY, + alias: result.payload.keyword, + url, + }; + const engine = Services.search.getEngineByName(result.payload.engine); + this._recordSearch(engine, event, actionDetails); + + if (!result.payload.inPrivateWindow) { + lazy.UrlbarUtils.addToFormHistory( + this, + result.payload.suggestion || result.payload.query, + engine.name + ).catch(Cu.reportError); + } + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.TIP: { + let scalarName = + element.dataset.name == "help" + ? `${result.payload.type}-help` + : `${result.payload.type}-picked`; + Services.telemetry.keyedScalarAdd("urlbar.tips", scalarName, 1); + if (url) { + break; + } + this.handleRevert(); + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "tip", + provider: result.providerName, + }); + let provider = lazy.UrlbarProvidersManager.getProvider( + result.providerName + ); + provider?.tryMethod("pickResult", result, element); + return; + } + case lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC: { + if (url) { + break; + } + url = result.payload.url; + // Keep the searchMode for telemetry since handleRevert sets it to null. + const searchMode = this.searchMode; + // Do not revert the Urlbar if we're going to navigate. We want the URL + // populated so we can navigate to it. + if (!url || !result.payload.shouldNavigate) { + this.handleRevert(); + } + let provider = lazy.UrlbarProvidersManager.getProvider( + result.providerName + ); + + // Keep startEventInfo for telemetry since the startEventInfo state might + // be changed if the URL Bar loses focus on pickResult. + const startEventInfo = this.controller.engagementEvent._startEventInfo; + provider?.tryMethod("pickResult", result, element); + + // If we won't be navigating, this is the end of the engagement. + if (!url || !result.payload.shouldNavigate) { + this.controller.engagementEvent.record(event, { + selIndex, + searchString: this._lastSearchString, + searchMode, + selType: this.controller.engagementEvent.typeFromElement(element), + provider: result.providerName, + element, + startEventInfo, + }); + return; + } + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: { + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "extension", + provider: result.providerName, + }); + + // The urlbar needs to revert to the loaded url when a command is + // handled by the extension. + this.handleRevert(); + // We don't directly handle a load when an Omnibox API result is picked, + // instead we forward the request to the WebExtension itself, because + // the value may not even be a url. + // We pass the keyword and content, that actually is the retrieved value + // prefixed by the keyword. ExtensionSearchHandler uses this keyword + // redundancy as a sanity check. + lazy.ExtensionSearchHandler.handleInputEntered( + result.payload.keyword, + result.payload.content, + where + ); + return; + } + } + + if (!url) { + throw new Error(`Invalid url for result ${JSON.stringify(result)}`); + } + + // Record input history but only in non-private windows. + if (!this.isPrivate) { + let input; + if (!result.heuristic) { + input = this._lastSearchString; + } else if (result.autofill?.type == "adaptive") { + input = result.autofill.adaptiveHistoryInput; + } + // `input` may be an empty string, so do a strict comparison here. + if (input !== undefined) { + // We don't await for this, because a rejection should not interrupt + // the load. Just reportError it. + lazy.UrlbarUtils.addToInputHistory(url, input).catch(Cu.reportError); + } + } + + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: this.controller.engagementEvent.typeFromElement(element), + provider: result.providerName, + searchSource: this.getSearchSource(event), + }); + + if (result.payload.sendAttributionRequest) { + lazy.PartnerLinkAttribution.makeRequest({ + targetURL: result.payload.url, + source: "urlbar", + campaignID: Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ), + }); + if (!this.isPrivate && result.providerName === "UrlbarProviderTopSites") { + // The position is 1-based for telemetry + const position = selIndex + 1; + Services.telemetry.keyedScalarAdd( + SCALAR_CATEGORY_TOPSITES, + `urlbar_${position}`, + 1 + ); + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + position, + source: "urlbar", + tile_id: result.payload.sponsoredTileId || -1, + reporting_url: result.payload.sponsoredClickUrl, + advertiser: result.payload.title.toLocaleLowerCase(), + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_SELECTION + ); + } + } + + this._loadURL( + url, + event, + where, + openParams, + { + source: result.source, + type: result.type, + searchTerm: result.payload.suggestion ?? result.payload.query, + }, + browser + ); + } + + /** + * Called by the view when moving through results with the keyboard, and when + * picking a result. This sets the input value to the value of the result and + * invalidates the pageproxystate. It also sets the result that is associated + * with the current input value. If you need to set this result but don't + * want to also set the input value, then use setResultForCurrentValue. + * + * @param {object} options + * Options. + * @param {UrlbarResult} [options.result] + * The result that was selected or picked, null if no result was selected. + * @param {Event} [options.event] + * The event that picked the result. + * @param {string} [options.urlOverride] + * Normally the URL is taken from `result.payload.url`, but if `urlOverride` + * is specified, it's used instead. + * @returns {boolean} + * Whether the value has been canonized + */ + setValueFromResult({ result = null, event = null, urlOverride = null } = {}) { + // Usually this is set by a previous input event, but in certain cases, like + // when opening Top Sites on a loaded page, it wouldn't happen. To avoid + // confusing the user, we always enforce it when a result changes our value. + this.setPageProxyState("invalid", true); + + // A previous result may have previewed search mode. If we don't expect that + // we might stay in a search mode of some kind, exit it now. + if ( + this.searchMode?.isPreview && + !result?.payload.providesSearchMode && + !this.view.oneOffSearchButtons.selectedButton + ) { + this.searchMode = null; + } + + if (!result) { + // This happens when there's no selection, for example when moving to the + // one-offs search settings button, or to the input field when Top Sites + // are shown; then we must reset the input value. + // Note that for Top Sites the last search string would be empty, thus we + // must restore the last text value. + // Note that unselected autofill results will still arrive in this + // function with a non-null `result`. They are handled below. + this.value = this._lastSearchString || this._valueOnLastSearch; + this.setResultForCurrentValue(result); + return false; + } + + // The value setter clobbers the actiontype attribute, so we need this + // helper to restore it afterwards. + const setValueAndRestoreActionType = (value, allowTrim) => { + this._setValue(value, allowTrim); + + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + this.setAttribute("actiontype", "switchtab"); + break; + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + this.setAttribute("actiontype", "extension"); + break; + } + }; + + // For autofilled results, the value that should be canonized is not the + // autofilled value but the value that the user typed. + let canonizedUrl = this._maybeCanonizeURL( + event, + result.autofill ? this._lastSearchString : this.value + ); + if (canonizedUrl) { + setValueAndRestoreActionType(canonizedUrl, true); + this.setResultForCurrentValue(result); + return true; + } + + if (result.autofill) { + this._autofillValue(result.autofill); + } + + if (result.payload.providesSearchMode) { + let enteredSearchMode; + // Only preview search mode if the result is selected. + if (this.view.resultIsSelected(result)) { + // Not starting a query means we will only preview search mode. + enteredSearchMode = this.maybeConfirmSearchModeFromResult({ + result, + checkValue: false, + startQuery: false, + }); + } + if (!enteredSearchMode) { + setValueAndRestoreActionType(this._getValueFromResult(result), true); + this.searchMode = null; + } + this.setResultForCurrentValue(result); + return false; + } + + // If the url is trimmed but it's invalid (for example it has an unknown + // single word host, or an unknown domain suffix), trimming + // it would end up executing a search instead of visiting it. + let allowTrim = true; + if ( + (urlOverride || result.type == lazy.UrlbarUtils.RESULT_TYPE.URL) && + lazy.UrlbarPrefs.get("trimURLs") + ) { + let url = urlOverride || result.payload.url; + if (url.startsWith(lazy.BrowserUIUtils.trimURLProtocol)) { + let fixupInfo = this._getURIFixupInfo(lazy.BrowserUIUtils.trimURL(url)); + if (fixupInfo?.keywordAsSent) { + allowTrim = false; + } + } + } + + if (!result.autofill) { + setValueAndRestoreActionType( + this._getValueFromResult(result, urlOverride), + allowTrim + ); + } + + this.setResultForCurrentValue(result); + return false; + } + + /** + * The input keeps track of the result associated with the current input + * value. This result can be set by calling either setValueFromResult or this + * method. Use this method when you need to set the result without also + * setting the input value. This can be the case when either the selection is + * cleared and no other result becomes selected, or when the result is the + * heuristic and we don't want to modify the value the user is typing. + * + * @param {UrlbarResult} result + * The result to associate with the current input value. + */ + setResultForCurrentValue(result) { + this._resultForCurrentValue = result; + } + + /** + * Called by the controller when the first result of a new search is received. + * If it's an autofill result, then it may need to be autofilled, subject to a + * few restrictions. + * + * @param {UrlbarResult} result + * The first result. + */ + _autofillFirstResult(result) { + if (!result.autofill) { + return; + } + + let isPlaceholderSelected = + this._autofillPlaceholder && + this.selectionEnd == this._autofillPlaceholder.value.length && + this.selectionStart == this._lastSearchString.length && + this._autofillPlaceholder.value + .toLocaleLowerCase() + .startsWith(this._lastSearchString.toLocaleLowerCase()); + + // Don't autofill if there's already a selection (with one caveat described + // next) or the cursor isn't at the end of the input. But if there is a + // selection and it's the autofill placeholder value, then do autofill. + if ( + !isPlaceholderSelected && + !this._autofillIgnoresSelection && + (this.selectionStart != this.selectionEnd || + this.selectionEnd != this._lastSearchString.length) + ) { + return; + } + + this.setValueFromResult({ result }); + } + + /** + * Invoked by the controller when the first result is received. + * + * @param {UrlbarResult} firstResult + * The first result received. + * @returns {boolean} + * True if this method canceled the query and started a new one. False + * otherwise. + */ + onFirstResult(firstResult) { + // If the heuristic result has a keyword but isn't a keyword offer, we may + // need to enter search mode. + if ( + firstResult.heuristic && + firstResult.payload.keyword && + !firstResult.payload.providesSearchMode && + this.maybeConfirmSearchModeFromResult({ + result: firstResult, + entry: "typed", + checkValue: false, + }) + ) { + return true; + } + + // To prevent selection flickering, we apply autofill on input through a + // placeholder, without waiting for results. But, if the first result is + // not an autofill one, the autofill prediction was wrong and we should + // restore the original user typed string. + if (firstResult.autofill) { + this._autofillFirstResult(firstResult); + } else if ( + this._autofillPlaceholder && + // Avoid clobbering added spaces (for token aliases, for example). + !this.value.endsWith(" ") + ) { + this._autofillPlaceholder = null; + this._setValue(this.window.gBrowser.userTypedValue, false); + } + + return false; + } + + /** + * Starts a query based on the current input value. + * + * @param {object} [options] + * Object options + * @param {boolean} [options.allowAutofill] + * Whether or not to allow providers to include autofill results. + * @param {boolean} [options.autofillIgnoresSelection] + * Normally we autofill only if the cursor is at the end of the string, + * if this is set we'll autofill regardless of selection. + * @param {string} [options.searchString] + * The search string. If not given, the current input value is used. + * Otherwise, the current input value must start with this value. + * @param {boolean} [options.resetSearchState] + * If this is the first search of a user interaction with the input, set + * this to true (the default) so that search-related state from the previous + * interaction doesn't interfere with the new interaction. Otherwise set it + * to false so that state is maintained during a single interaction. The + * intended use for this parameter is that it should be set to false when + * this method is called due to input events. + * @param {event} [options.event] + * The user-generated event that triggered the query, if any. If given, we + * will record engagement event telemetry for the query. + */ + startQuery({ + allowAutofill = true, + autofillIgnoresSelection = false, + searchString = null, + resetSearchState = true, + event = null, + } = {}) { + if (!searchString) { + searchString = + this.getAttribute("pageproxystate") == "valid" ? "" : this.value; + } else if (!this.value.startsWith(searchString)) { + throw new Error("The current value doesn't start with the search string"); + } + + if (event) { + this.controller.engagementEvent.start(event, searchString); + } + + if (this._suppressStartQuery) { + return; + } + + this._autofillIgnoresSelection = autofillIgnoresSelection; + if (resetSearchState) { + this._resetSearchState(); + } + + this._lastSearchString = searchString; + this._valueOnLastSearch = this.value; + + let options = { + allowAutofill, + isPrivate: this.isPrivate, + maxResults: lazy.UrlbarPrefs.get("maxRichResults"), + searchString, + view: this.view, + userContextId: this.window.gBrowser.selectedBrowser.getAttribute( + "usercontextid" + ), + currentPage: this.window.gBrowser.currentURI.spec, + formHistoryName: this.formHistoryName, + prohibitRemoteResults: + event && + lazy.UrlbarUtils.isPasteEvent(event) && + lazy.UrlbarPrefs.get("maxCharsForSearchSuggestions") < + event.data?.length, + }; + + if (this.searchMode) { + this.confirmSearchMode(); + options.searchMode = this.searchMode; + if (this.searchMode.source) { + options.sources = [this.searchMode.source]; + } + } + + // TODO (Bug 1522902): This promise is necessary for tests, because some + // tests are not listening for completion when starting a query through + // other methods than startQuery (input events for example). + this.lastQueryContextPromise = this.controller.startQuery( + new lazy.UrlbarQueryContext(options) + ); + } + + /** + * Sets the input's value, starts a search, and opens the view. + * + * @param {string} value + * The input's value will be set to this value, and the search will + * use it as its query. + * @param {object} [options] + * Object options + * @param {nsISearchEngine} [options.searchEngine] + * Search engine to use when the search is using a known alias. + * @param {UrlbarUtils.SEARCH_MODE_ENTRY} [options.searchModeEntry] + * If provided, we will record this parameter as the search mode entry point + * in Telemetry. Consumers should provide this if they expect their call + * to enter search mode. + * @param {boolean} [options.focus] + * If true, the urlbar will be focused. If false, the focus will remain + * unchanged. + */ + search(value, { searchEngine, searchModeEntry, focus = true } = {}) { + if (focus) { + this.focus(); + } + let trimmedValue = value.trim(); + let end = trimmedValue.search(lazy.UrlbarTokenizer.REGEXP_SPACES); + let firstToken = end == -1 ? trimmedValue : trimmedValue.substring(0, end); + // Enter search mode if the string starts with a restriction token. + let searchMode = lazy.UrlbarUtils.searchModeForToken(firstToken); + let firstTokenIsRestriction = !!searchMode; + if (!searchMode && searchEngine) { + searchMode = { engineName: searchEngine.name }; + firstTokenIsRestriction = searchEngine.aliases.includes(firstToken); + } + + if (searchMode) { + searchMode.entry = searchModeEntry; + this.searchMode = searchMode; + if (firstTokenIsRestriction) { + // Remove the restriction token/alias from the string to be searched for + // in search mode. + value = value.replace(firstToken, ""); + } + if (lazy.UrlbarTokenizer.REGEXP_SPACES.test(value[0])) { + // If there was a trailing space after the restriction token/alias, + // remove it. + value = value.slice(1); + } + this._revertOnBlurValue = value; + } else if ( + Object.values(lazy.UrlbarTokenizer.RESTRICT).includes(firstToken) + ) { + this.searchMode = null; + // If the entire value is a restricted token, append a space. + if (Object.values(lazy.UrlbarTokenizer.RESTRICT).includes(value)) { + value += " "; + } + this._revertOnBlurValue = value; + } + this.inputField.value = value; + // Avoid selecting the text if this method is called twice in a row. + this.selectionStart = -1; + + // Note: proper IME Composition handling depends on the fact this generates + // an input event, rather than directly invoking the controller; everything + // goes through _on_input, that will properly skip the search until the + // composition is committed. _on_input also skips the search when it's the + // same as the previous search, but we want to allow consecutive searches + // with the same string. So clear _lastSearchString first. + this._lastSearchString = ""; + let event = new UIEvent("input", { + bubbles: true, + cancelable: false, + view: this.window, + detail: 0, + }); + this.inputField.dispatchEvent(event); + } + + /** + * Focus without the focus styles. + * This is used by Activity Stream and about:privatebrowsing for search hand-off. + */ + setHiddenFocus() { + this._hideFocus = true; + if (this.focused) { + this.removeAttribute("focused"); + } else { + this.focus(); + } + } + + /** + * Restore focus styles. + * This is used by Activity Stream and about:privatebrowsing for search hand-off. + * + * @param {Browser} forceSuppressFocusBorder + * Set true to suppress-focus-border attribute if this flag is true. + */ + removeHiddenFocus(forceSuppressFocusBorder = false) { + this._hideFocus = false; + if (this.focused) { + this.setAttribute("focused", "true"); + + if (forceSuppressFocusBorder) { + this.toggleAttribute("suppress-focus-border", true); + } + } + } + + /** + * Gets the search mode for a specific browser instance. + * + * @param {Browser} browser + * The search mode for this browser will be returned. + * @param {boolean} [confirmedOnly] + * Normally, if the browser has both preview and confirmed modes, preview + * mode will be returned since it takes precedence. If this argument is + * true, then only confirmed search mode will be returned, or null if + * search mode hasn't been confirmed. + * @returns {object} + * A search mode object. See setSearchMode documentation. If the browser + * is not in search mode, then null is returned. + */ + getSearchMode(browser, confirmedOnly = false) { + let modes = this._searchModesByBrowser.get(browser); + + // Return copies so that callers don't modify the stored values. + if (!confirmedOnly && modes?.preview) { + return { ...modes.preview }; + } + if (modes?.confirmed) { + return { ...modes.confirmed }; + } + return null; + } + + /** + * Sets search mode for a specific browser instance. If the given browser is + * selected, then this will also enter search mode. + * + * @param {object} searchMode + * A search mode object. + * @param {string} searchMode.engineName + * The name of the search engine to restrict to. + * @param {UrlbarUtils.RESULT_SOURCE} searchMode.source + * A result source to restrict to. + * @param {string} searchMode.entry + * How search mode was entered. This is recorded in event telemetry. One of + * the values in UrlbarUtils.SEARCH_MODE_ENTRY. + * @param {boolean} [searchMode.isPreview] + * If true, we will preview search mode. Search mode preview does not record + * telemetry and has slighly different UI behavior. The preview is exited in + * favor of full search mode when a query is executed. False should be + * passed if the caller needs to enter search mode but expects it will not + * be interacted with right away. Defaults to true. + * @param {Browser} browser + * The browser for which to set search mode. + */ + async setSearchMode(searchMode, browser) { + let currentSearchMode = this.getSearchMode(browser); + let areSearchModesSame = + (!currentSearchMode && !searchMode) || + lazy.ObjectUtils.deepEqual(currentSearchMode, searchMode); + + // Exit search mode if the passed-in engine is invalid or hidden. + let engine; + if (searchMode?.engineName) { + if (!Services.search.isInitialized) { + await Services.search.init(); + } + engine = Services.search.getEngineByName(searchMode.engineName); + if (!engine || engine.hidden) { + searchMode = null; + } + } + + let { engineName, source, entry, isPreview = true } = searchMode || {}; + + searchMode = null; + + if (engineName) { + searchMode = { + engineName, + isGeneralPurposeEngine: engine.isGeneralPurposeEngine, + }; + if (source) { + searchMode.source = source; + } else if (searchMode.isGeneralPurposeEngine) { + // History results for general-purpose search engines are often not + // useful, so we hide them in search mode. See bug 1658646 for + // discussion. + searchMode.source = lazy.UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } else if (source) { + let sourceName = lazy.UrlbarUtils.getResultSourceName(source); + if (sourceName) { + searchMode = { source }; + } else { + Cu.reportError(`Unrecognized source: ${source}`); + } + } + + if (searchMode) { + searchMode.isPreview = isPreview; + if (lazy.UrlbarUtils.SEARCH_MODE_ENTRY.has(entry)) { + searchMode.entry = entry; + } else { + // If we see this value showing up in telemetry, we should review + // search mode's entry points. + searchMode.entry = "other"; + } + + // Add the search mode to the map. + if (!searchMode.isPreview) { + this._searchModesByBrowser.set(browser, { + confirmed: searchMode, + }); + } else { + let modes = this._searchModesByBrowser.get(browser) || {}; + modes.preview = searchMode; + this._searchModesByBrowser.set(browser, modes); + } + } else { + this._searchModesByBrowser.delete(browser); + } + + // Enter search mode if the browser is selected. + if (browser == this.window.gBrowser.selectedBrowser) { + this._updateSearchModeUI(searchMode); + if (searchMode) { + // Set userTypedValue to the query string so that it's properly restored + // when switching back to the current tab and across sessions. + this.window.gBrowser.userTypedValue = this.untrimmedValue; + this.valueIsTyped = true; + if (!searchMode.isPreview && !areSearchModesSame) { + try { + lazy.BrowserSearchTelemetry.recordSearchMode(searchMode); + } catch (ex) { + Cu.reportError(ex); + } + } + } + } + } + + /** + * Restores the current browser search mode from a previously stored state. + */ + restoreSearchModeState() { + let modes = this._searchModesByBrowser.get( + this.window.gBrowser.selectedBrowser + ); + this.searchMode = modes?.confirmed; + } + + /** + * Enters search mode with the default engine. + */ + searchModeShortcut() { + // We restrict to search results when entering search mode from this + // shortcut to honor historical behaviour. + this.searchMode = { + source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: lazy.UrlbarSearchUtils.getDefaultEngine(this.isPrivate).name, + entry: "shortcut", + }; + // The searchMode setter clears the input if pageproxystate is valid, so + // we know at this point this.value will either be blank or the user's + // typed string. + this.search(this.value); + this.select(); + } + + /** + * Confirms the current search mode. + */ + confirmSearchMode() { + let searchMode = this.searchMode; + if (searchMode?.isPreview) { + searchMode.isPreview = false; + this.searchMode = searchMode; + + // Unselect the one-off search button to ensure UI consistency. + this.view.oneOffSearchButtons.selectedButton = null; + } + } + + // Getters and Setters below. + + get editor() { + return this.inputField.editor; + } + + get focused() { + return this.document.activeElement == this.inputField; + } + + get goButton() { + return this.querySelector("#urlbar-go-button"); + } + + get value() { + return this.inputField.value; + } + + get untrimmedValue() { + return this._untrimmedValue; + } + + set value(val) { + this._setValue(val, true); + } + + get lastSearchString() { + return this._lastSearchString; + } + + get searchMode() { + return this.getSearchMode(this.window.gBrowser.selectedBrowser); + } + + set searchMode(searchMode) { + this.setSearchMode(searchMode, this.window.gBrowser.selectedBrowser); + } + + async updateLayoutBreakout() { + if (!this._toolbar) { + // Expanding requires a parent toolbar. + return; + } + if (this.document.fullscreenElement) { + // Toolbars are hidden in DOM fullscreen mode, so we can't get proper + // layout information and need to retry after leaving that mode. + this.window.addEventListener( + "fullscreen", + () => { + this.updateLayoutBreakout(); + }, + { once: true } + ); + return; + } + await this._updateLayoutBreakoutDimensions(); + } + + startLayoutExtend() { + // Do not expand if: + // The Urlbar does not support being expanded or it is already expanded + if ( + !this.hasAttribute("breakout") || + this.hasAttribute("breakout-extend") + ) { + return; + } + if (!this.view.isOpen) { + return; + } + + if (Cu.isInAutomation) { + if (lazy.UrlbarPrefs.get("disableExtendForTests")) { + this.setAttribute("breakout-extend-disabled", "true"); + return; + } + this.removeAttribute("breakout-extend-disabled"); + } + + this._toolbar.setAttribute("urlbar-exceeds-toolbar-bounds", "true"); + this.setAttribute("breakout-extend", "true"); + + // Enable the animation only after the first extend call to ensure it + // doesn't run when opening a new window. + if (!this.hasAttribute("breakout-extend-animate")) { + this.window.promiseDocumentFlushed(() => { + this.window.requestAnimationFrame(() => { + this.setAttribute("breakout-extend-animate", "true"); + }); + }); + } + } + + endLayoutExtend() { + // If reduce motion is enabled, we want to collapse the Urlbar here so the + // user sees only sees two states: not expanded, and expanded with the view + // open. + if (!this.hasAttribute("breakout-extend") || this.view.isOpen) { + return; + } + + this.removeAttribute("breakout-extend"); + this._toolbar.removeAttribute("urlbar-exceeds-toolbar-bounds"); + } + + /** + * Updates the user interface to indicate whether the URI in the address bar + * is different than the loaded page, because it's being edited or because a + * search result is currently selected and is displayed in the location bar. + * + * @param {string} state + * The string "valid" indicates that the security indicators and other + * related user interface elments should be shown because the URI in + * the location bar matches the loaded page. The string "invalid" + * indicates that the URI in the location bar is different than the + * loaded page. + * @param {boolean} [updatePopupNotifications] + * Indicates whether we should update the PopupNotifications + * visibility due to this change, otherwise avoid doing so as it is + * being handled somewhere else. + */ + setPageProxyState(state, updatePopupNotifications) { + let prevState = this.getAttribute("pageproxystate"); + + this.setAttribute("pageproxystate", state); + this._inputContainer.setAttribute("pageproxystate", state); + this._identityBox.setAttribute("pageproxystate", state); + + if (state == "valid") { + this._lastValidURLStr = this.value; + } + + if ( + updatePopupNotifications && + prevState != state && + this.window.UpdatePopupNotificationsVisibility + ) { + this.window.UpdatePopupNotificationsVisibility(); + } + } + + /** + * When switching tabs quickly, TabSelect sometimes happens before + * _adjustFocusAfterTabSwitch and due to the focus still being on the old + * tab, we end up flickering the results pane briefly. + */ + afterTabSwitchFocusChange() { + this._gotFocusChange = true; + this._afterTabSelectAndFocusChange(); + } + + /** + * Confirms search mode and starts a new search if appropriate for the given + * result. See also _searchModeForResult. + * + * @param {object} options + * Options object. + * @param {string} options.entry + * The search mode entry point. See setSearchMode documentation for details. + * @param {UrlbarResult} [options.result] + * The result to confirm. Defaults to the currently selected result. + * @param {boolean} [options.checkValue] + * If true, the trimmed input value must equal the result's keyword in order + * to enter search mode. + * @param {boolean} [options.startQuery] + * If true, start a query after entering search mode. Defaults to true. + * @returns {boolean} + * True if we entered search mode and false if not. + */ + maybeConfirmSearchModeFromResult({ + entry, + result = this._resultForCurrentValue, + checkValue = true, + startQuery = true, + }) { + if ( + !result || + (checkValue && this.value.trim() != result.payload.keyword?.trim()) + ) { + return false; + } + + let searchMode = this._searchModeForResult(result, entry); + if (!searchMode) { + return false; + } + + this.searchMode = searchMode; + + let value = result.payload.query?.trimStart() || ""; + this._setValue(value, false); + + if (startQuery) { + this.startQuery({ allowAutofill: false }); + } + + return true; + } + + observe(subject, topic, data) { + switch (topic) { + case lazy.SearchUtils.TOPIC_ENGINE_MODIFIED: { + switch (data) { + case lazy.SearchUtils.MODIFIED_TYPE.CHANGED: + case lazy.SearchUtils.MODIFIED_TYPE.REMOVED: { + let searchMode = this.searchMode; + let engine = subject.QueryInterface(Ci.nsISearchEngine); + if (searchMode?.engineName == engine.name) { + // Exit search mode if the current search mode engine was removed. + this.searchMode = searchMode; + } + break; + } + } + break; + } + } + } + + /** + * Get search source. + * + * @param {Event} event + * The event that triggered this query. + * @returns {string} + * The source name. + */ + getSearchSource(event) { + if (this._isHandoffSession) { + return "urlbar-handoff"; + } + + const isOneOff = this.view.oneOffSearchButtons.eventTargetIsAOneOff(event); + if (this.searchMode && !isOneOff) { + // Without checking !isOneOff, we might record the string + // oneoff_urlbar-searchmode in the SEARCH_COUNTS probe (in addition to + // oneoff_urlbar and oneoff_searchbar). The extra information is not + // necessary; the intent is the same regardless of whether the user is + // in search mode when they do a key-modified click/enter on a one-off. + return "urlbar-searchmode"; + } + + if (this.window.gBrowser.selectedBrowser.showingSearchTerms && !isOneOff) { + return "urlbar-persisted"; + } + + return "urlbar"; + } + + // Private methods below. + + _addObservers() { + Services.obs.addObserver( + this, + lazy.SearchUtils.TOPIC_ENGINE_MODIFIED, + true + ); + } + + _getURIFixupInfo(searchString) { + let flags = + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (this.isPrivate) { + flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + try { + return Services.uriFixup.getFixupURIInfo(searchString, flags); + } catch (ex) { + Cu.reportError( + `An error occured while trying to fixup "${searchString}": ${ex}` + ); + } + return null; + } + + _afterTabSelectAndFocusChange() { + // We must have seen both events to proceed safely. + if (!this._gotFocusChange || !this._gotTabSelect) { + return; + } + this._gotFocusChange = this._gotTabSelect = false; + + this._resetSearchState(); + + // Switching tabs doesn't always change urlbar focus, so we must try to + // reopen here too, not just on focus. + // We don't use the original TabSelect event because caching it causes + // leaks on MacOS. + if (this.view.autoOpen({ event: new CustomEvent("tabswitch") })) { + return; + } + // The input may retain focus when switching tabs in which case we + // need to close the view explicitly. + this.view.close(); + } + + async _updateLayoutBreakoutDimensions() { + // When this method gets called a second time before the first call + // finishes, we need to disregard the first one. + let updateKey = {}; + this._layoutBreakoutUpdateKey = updateKey; + + this.removeAttribute("breakout"); + this.textbox.parentNode.removeAttribute("breakout"); + + await this.window.promiseDocumentFlushed(() => {}); + await new Promise(resolve => { + this.window.requestAnimationFrame(() => { + if (this._layoutBreakoutUpdateKey != updateKey) { + return; + } + + this.textbox.parentNode.style.setProperty( + "--urlbar-container-height", + px(getBoundsWithoutFlushing(this.textbox.parentNode).height) + ); + this.textbox.style.setProperty( + "--urlbar-height", + px(getBoundsWithoutFlushing(this.textbox).height) + ); + this.textbox.style.setProperty( + "--urlbar-toolbar-height", + px(getBoundsWithoutFlushing(this._toolbar).height) + ); + + this.setAttribute("breakout", "true"); + this.textbox.parentNode.setAttribute("breakout", "true"); + + resolve(); + }); + }); + } + + _setValue(val, allowTrim) { + // Don't expose internal about:reader URLs to the user. + let originalUrl = lazy.ReaderMode.getOriginalUrlObjectForDisplay(val); + if (originalUrl) { + val = originalUrl.displaySpec; + } + this._untrimmedValue = val; + + if (allowTrim) { + val = this._trimValue(val); + } + + this.valueIsTyped = false; + this._resultForCurrentValue = null; + this.inputField.value = val; + this.formatValue(); + this.removeAttribute("actiontype"); + + // Dispatch ValueChange event for accessibility. + let event = this.document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + this.inputField.dispatchEvent(event); + + return val; + } + + _getValueFromResult(result, urlOverride = null) { + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + return result.payload.input; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: { + let value = ""; + if (result.payload.keyword) { + value += result.payload.keyword + " "; + } + value += result.payload.suggestion || result.payload.query; + return value; + } + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + return result.payload.content; + case lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC: + return result.payload.input || ""; + } + + if (urlOverride === "") { + // Allow callers to clear the input. + return ""; + } + + try { + let uri = Services.io.newURI(urlOverride || result.payload.url); + if (uri) { + return losslessDecodeURI(uri); + } + } catch (ex) {} + + return ""; + } + + /** + * Resets some state so that searches from the user's previous interaction + * with the input don't interfere with searches from a new interaction. + */ + _resetSearchState() { + this._lastSearchString = this.value; + this._autofillPlaceholder = null; + } + + /** + * Autofills the autofill placeholder string if appropriate, and determines + * whether autofill should be allowed for the new search started by an input + * event. + * + * @param {string} value + * The new search string. + * @returns {boolean} + * Whether autofill should be allowed in the new search. + */ + _maybeAutofillPlaceholder(value) { + // We allow autofill in local but not remote search modes. + let allowAutofill = + this.selectionEnd == value.length && + !this.searchMode?.engineName && + this.searchMode?.source != lazy.UrlbarUtils.RESULT_SOURCE.SEARCH; + if (!allowAutofill) { + this._autofillPlaceholder = null; + return false; + } + + // Determine whether we can autofill the placeholder. The placeholder is a + // value that we autofill now, when the search starts and before we wait on + // its first result, in order to prevent a flicker in the input caused by + // the previous autofilled substring disappearing and reappearing when the + // first result arrives. Of course we can only autofill the placeholder if + // it starts with the new search string, and we shouldn't autofill anything + // if the caret isn't at the end of the input. + let canAutofillPlaceholder = false; + if (this._autofillPlaceholder) { + if (this._autofillPlaceholder.type == "adaptive") { + canAutofillPlaceholder = + value.length >= + this._autofillPlaceholder.adaptiveHistoryInput.length && + this._autofillPlaceholder.value + .toLocaleLowerCase() + .startsWith(value.toLocaleLowerCase()); + } else { + canAutofillPlaceholder = lazy.UrlbarUtils.canAutofillURL( + this._autofillPlaceholder.value, + value + ); + } + } + + if (!canAutofillPlaceholder) { + this._autofillPlaceholder = null; + } else if ( + this._autofillPlaceholder && + this.selectionEnd == this.value.length && + this._enableAutofillPlaceholder + ) { + let autofillValue = + value + this._autofillPlaceholder.value.substring(value.length); + this._autofillValue({ + value: autofillValue, + selectionStart: value.length, + selectionEnd: autofillValue.length, + type: this._autofillPlaceholder.type, + adaptiveHistoryInput: this._autofillPlaceholder.adaptiveHistoryInput, + }); + } + + return true; + } + + _checkForRtlText(value) { + let directionality = this.window.windowUtils.getDirectionFromText(value); + if (directionality == this.window.windowUtils.DIRECTION_RTL) { + return true; + } + return false; + } + + /** + * Invoked on overflow/underflow/scrollend events to update attributes + * related to the input text directionality. Overflow fade masks use these + * attributes to appear at the proper side of the urlbar. + */ + updateTextOverflow() { + if (!this._overflowing) { + this.removeAttribute("textoverflow"); + return; + } + + let isRTL = + this.getAttribute("domaindir") === "rtl" && + this._checkForRtlText(this.value); + + this.window.promiseDocumentFlushed(() => { + // Check overflow again to ensure it didn't change in the meanwhile. + let input = this.inputField; + if (input && this._overflowing) { + // Normally we would overflow at the final side of text direction, + // though RTL domains may cause us to overflow at the opposite side. + // This happens dynamically as a consequence of the input field contents + // and the call to _ensureFormattedHostVisible, this code only reports + // the final state of all that scrolling into an attribute, because + // there's no other way to capture this in css. + // Note it's also possible to scroll an unfocused input field using + // SHIFT + mousewheel on Windows, or with just the mousewheel / touchpad + // scroll (without modifiers) on Mac. + let side = "both"; + if (isRTL) { + if (input.scrollLeft == 0) { + side = "left"; + } else if (input.scrollLeft == input.scrollLeftMin) { + side = "right"; + } + } else if (input.scrollLeft == 0) { + side = "right"; + } else if (input.scrollLeft == input.scrollLeftMax) { + side = "left"; + } + + this.window.requestAnimationFrame(() => { + // And check once again, since we might have stopped overflowing + // since the promiseDocumentFlushed callback fired. + if (this._overflowing) { + this.setAttribute("textoverflow", side); + } + }); + } + }); + } + + _updateUrlTooltip() { + if (this.focused || !this._overflowing) { + this.inputField.removeAttribute("title"); + } else { + this.inputField.setAttribute("title", this.untrimmedValue); + } + } + + _getSelectedValueForClipboard() { + let selection = this.editor.selection; + const flags = + Ci.nsIDocumentEncoder.OutputPreformatted | + Ci.nsIDocumentEncoder.OutputRaw; + let selectedVal = selection.toStringWithFormat("text/plain", flags, 0); + + // Handle multiple-range selection as a string for simplicity. + if (selection.rangeCount > 1) { + return selectedVal; + } + + // If the selection doesn't start at the beginning or doesn't span the + // full domain or the URL bar is modified or there is no text at all, + // nothing else to do here. + if (this.selectionStart > 0 || this.valueIsTyped || selectedVal == "") { + return selectedVal; + } + + // The selection doesn't span the full domain if it doesn't contain a slash and is + // followed by some character other than a slash. + if (!selectedVal.includes("/")) { + let remainder = this.value.replace(selectedVal, ""); + if (remainder != "" && remainder[0] != "/") { + return selectedVal; + } + } + + let uri; + if (this.getAttribute("pageproxystate") == "valid") { + uri = this.window.gBrowser.currentURI; + } else { + // The value could be: + // 1. a trimmed url, set by selecting a result + // 2. a search string set by selecting a result + // 3. a url that was confirmed but didn't finish loading yet + // If it's an url the untrimmedValue should resolve to a valid URI, + // otherwise it's a search string that should be copied as-is. + try { + uri = Services.io.newURI(this._untrimmedValue); + } catch (ex) { + return selectedVal; + } + } + uri = this.makeURIReadable(uri); + let displaySpec = uri.displaySpec; + + // If the entire URL is selected, just use the actual loaded URI, + // unless we want a decoded URI, or it's a data: or javascript: URI, + // since those are hard to read when encoded. + if ( + this.value == selectedVal && + !uri.schemeIs("javascript") && + !uri.schemeIs("data") && + !lazy.UrlbarPrefs.get("decodeURLsOnCopy") + ) { + return displaySpec; + } + + // Just the beginning of the URL is selected, or we want a decoded + // url. First check for a trimmed value. + + if ( + !selectedVal.startsWith(lazy.BrowserUIUtils.trimURLProtocol) && + // Note _trimValue may also trim a trailing slash, thus we can't just do + // a straight string compare to tell if the protocol was trimmed. + !displaySpec.startsWith(this._trimValue(displaySpec)) + ) { + selectedVal = lazy.BrowserUIUtils.trimURLProtocol + selectedVal; + } + + // If selection starts from the beginning and part or all of the URL + // is selected, we check for decoded characters and encode them. + // Unless decodeURLsOnCopy is set. Do not encode data: URIs. + if (!lazy.UrlbarPrefs.get("decodeURLsOnCopy") && !uri.schemeIs("data")) { + try { + new URL(selectedVal); + // Use encodeURI instead of URL.href because we don't want + // trailing slash. + selectedVal = encodeURI(selectedVal); + } catch (ex) { + // URL is invalid. Return original selected value. + } + } + + return selectedVal; + } + + _toggleActionOverride(event) { + // Ignore repeated KeyboardEvents. + if (event.repeat) { + return; + } + if ( + event.keyCode == KeyEvent.DOM_VK_SHIFT || + event.keyCode == KeyEvent.DOM_VK_ALT || + event.keyCode == + (AppConstants.platform == "macosx" + ? KeyEvent.DOM_VK_META + : KeyEvent.DOM_VK_CONTROL) + ) { + if (event.type == "keydown") { + this._actionOverrideKeyCount++; + this.setAttribute("actionoverride", "true"); + this.view.panel.setAttribute("actionoverride", "true"); + } else if ( + this._actionOverrideKeyCount && + --this._actionOverrideKeyCount == 0 + ) { + this._clearActionOverride(); + } + } + } + + _clearActionOverride() { + this._actionOverrideKeyCount = 0; + this.removeAttribute("actionoverride"); + this.view.panel.removeAttribute("actionoverride"); + } + + /** + * Get the url to load for the search query and records in telemetry that it + * is being loaded. + * + * @param {nsISearchEngine} engine + * The engine to generate the query for. + * @param {Event} event + * The event that triggered this query. + * @param {object} searchActionDetails + * The details associated with this search query. + * @param {boolean} searchActionDetails.isSuggestion + * True if this query was initiated from a suggestion from the search engine. + * @param {boolean} searchActionDetails.alias + * True if this query was initiated via a search alias. + * @param {boolean} searchActionDetails.isFormHistory + * True if this query was initiated from a form history result. + * @param {string} searchActionDetails.url + * The url this query was triggered with. + */ + _recordSearch(engine, event, searchActionDetails = {}) { + const isOneOff = this.view.oneOffSearchButtons.eventTargetIsAOneOff(event); + + lazy.BrowserSearchTelemetry.recordSearch( + this.window.gBrowser.selectedBrowser, + engine, + this.getSearchSource(event), + { + ...searchActionDetails, + isOneOff, + newtabSessionId: this._handoffSession, + } + ); + } + + /** + * Shortens the given value, usually by removing http:// and trailing slashes. + * + * @param {string} val + * The string to be trimmed if it appears to be URI + * @returns {string} + * The trimmed string + */ + _trimValue(val) { + return lazy.UrlbarPrefs.get("trimURLs") + ? lazy.BrowserUIUtils.trimURL(val) + : val; + } + + /** + * If appropriate, this prefixes a search string with 'www.' and suffixes it + * with browser.fixup.alternate.suffix prior to navigating. + * + * @param {Event} event + * The event that triggered this query. + * @param {string} value + * The search string that should be canonized. + * @returns {string} + * Returns the canonized URL if available and null otherwise. + */ + _maybeCanonizeURL(event, value) { + // Only add the suffix when the URL bar value isn't already "URL-like", + // and only if we get a keyboard event, to match user expectations. + if ( + !KeyboardEvent.isInstance(event) || + event._disableCanonization || + !event.ctrlKey || + !lazy.UrlbarPrefs.get("ctrlCanonizesURLs") || + !/^\s*[^.:\/\s]+(?:\/.*|\s*)$/i.test(value) + ) { + return null; + } + + let suffix = Services.prefs.getCharPref("browser.fixup.alternate.suffix"); + if (!suffix.endsWith("/")) { + suffix += "/"; + } + + // trim leading/trailing spaces (bug 233205) + value = value.trim(); + + // Tack www. and suffix on. If user has appended directories, insert + // suffix before them (bug 279035). Be careful not to get two slashes. + let firstSlash = value.indexOf("/"); + if (firstSlash >= 0) { + value = + value.substring(0, firstSlash) + + suffix + + value.substring(firstSlash + 1); + } else { + value = value + suffix; + } + + try { + const info = Services.uriFixup.getFixupURIInfo( + value, + Ci.nsIURIFixup.FIXUP_FLAG_FORCE_ALTERNATE_URI + ); + value = info.fixedURI.spec; + } catch (ex) { + Cu.reportError( + `An error occured while trying to fixup "${value}": ${ex}` + ); + } + + this.value = value; + return value; + } + + /** + * Autofills a value into the input. The value will be autofilled regardless + * of the input's current value. + * + * @param {object} options + * The options object. + * @param {string} options.value + * The value to autofill. + * @param {integer} options.selectionStart + * The new selectionStart. + * @param {integer} options.selectionEnd + * The new selectionEnd. + * @param {"origin" | "url" | "adaptive"} options.type + * The autofill type, one of: "origin", "url", "adaptive" + * @param {string} options.adaptiveHistoryInput + * If the autofill type is "adaptive", this is the matching `input` value + * from adaptive history. + */ + _autofillValue({ + value, + selectionStart, + selectionEnd, + type, + adaptiveHistoryInput, + }) { + // The autofilled value may be a URL that includes a scheme at the + // beginning. Do not allow it to be trimmed. + this._setValue(value, false); + this.inputField.setSelectionRange(selectionStart, selectionEnd); + this._autofillPlaceholder = { value, type, adaptiveHistoryInput }; + } + + /** + * Loads the url in the appropriate place. + * + * @param {string} url + * The URL to open. + * @param {Event} event + * The event that triggered to load the url. + * @param {string} openUILinkWhere + * Where we expect the result to be opened. + * @param {object} params + * The parameters related to how and where the result will be opened. + * Further supported paramters are listed in utilityOverlay.js#openUILinkIn. + * @param {object} params.triggeringPrincipal + * The principal that the action was triggered from. + * @param {nsIInputStream} [params.postData] + * The POST data associated with a search submission. + * @param {boolean} [params.allowInheritPrincipal] + * Whether the principal can be inherited. + * @param {object} [resultDetails] + * Details of the selected result, if any. + * @param {UrlbarUtils.RESULT_TYPE} [resultDetails.type] + * Details of the result type, if any. + * @param {string} [resultDetails.searchTerm] + * Search term of the result source, if any. + * @param {UrlbarUtils.RESULT_SOURCE} [resultDetails.source] + * Details of the result source, if any. + * @param {object} browser [optional] the browser to use for the load. + */ + _loadURL( + url, + event, + openUILinkWhere, + params, + resultDetails = null, + browser = this.window.gBrowser.selectedBrowser + ) { + // No point in setting these because we'll handleRevert() a few rows below. + if (openUILinkWhere == "current") { + this.value = + lazy.UrlbarPrefs.get("showSearchTermsFeatureGate") && + lazy.UrlbarPrefs.get("showSearchTerms.enabled") && + resultDetails?.searchTerm + ? resultDetails.searchTerm + : url; + browser.userTypedValue = this.value; + } + + // No point in setting this if we are loading in a new window. + if ( + openUILinkWhere != "window" && + this.window.gInitialPages.includes(url) + ) { + browser.initialPageLoadedFromUserAction = url; + } + + try { + lazy.UrlbarUtils.addToUrlbarHistory(url, this.window); + } catch (ex) { + // Things may go wrong when adding url to session history, + // but don't let that interfere with the loading of the url. + Cu.reportError(ex); + } + + // TODO: When bug 1498553 is resolved, we should be able to + // remove the !triggeringPrincipal condition here. + if ( + !params.triggeringPrincipal || + params.triggeringPrincipal.isSystemPrincipal + ) { + // Reset DOS mitigations for the basic auth prompt. + delete browser.authPromptAbuseCounter; + + // Reset temporary permissions on the current tab if the user reloads + // the tab via the urlbar. + if ( + openUILinkWhere == "current" && + browser.currentURI && + url === browser.currentURI.spec + ) { + this.window.SitePermissions.clearTemporaryBlockPermissions(browser); + } + } + + params.allowThirdPartyFixup = true; + + if (openUILinkWhere == "current") { + params.targetBrowser = browser; + params.indicateErrorPageLoad = true; + params.allowPinnedTabHostChange = true; + params.allowPopups = url.startsWith("javascript:"); + } else { + params.initiatingDoc = this.window.document; + } + + if ( + this._keyDownEnterDeferred && + event?.keyCode === KeyEvent.DOM_VK_RETURN && + openUILinkWhere === "current" + ) { + // In this case, we move the focus to the browser that loads the content + // upon key up the enter key. + // To do it, send avoidBrowserFocus flag to openTrustedLinkIn() to avoid + // focusing on the browser in the function. And also, set loadedContent + // flag that whether the content is loaded in the current tab by this enter + // key. _keyDownEnterDeferred promise is processed at key up the enter, + // focus on the browser passed by _keyDownEnterDeferred.resolve(). + params.avoidBrowserFocus = true; + this._keyDownEnterDeferred.loadedContent = true; + this._keyDownEnterDeferred.resolve(browser); + } + + // Ensure the window gets the `private` feature if the current window + // is private, unless the caller explicitly requested not to. + if (this.isPrivate && !("private" in params)) { + params.private = true; + } + + // Focus the content area before triggering loads, since if the load + // occurs in a new tab, we want focus to be restored to the content + // area when the current tab is re-selected. + if (!params.avoidBrowserFocus) { + browser.focus(); + // Make sure the domain name stays visible for spoof protection and usability. + this.inputField.setSelectionRange(0, 0); + } + + if (openUILinkWhere != "current") { + this.handleRevert(); + } + + // Notify about the start of navigation. + this._notifyStartNavigation(resultDetails); + + try { + this.window.openTrustedLinkIn(url, openUILinkWhere, params); + } catch (ex) { + // This load can throw an exception in certain cases, which means + // we'll want to replace the URL with the loaded URL: + if (ex.result != Cr.NS_ERROR_LOAD_SHOWED_ERRORPAGE) { + this.handleRevert(); + } + } + + // If we show the focus border after closing the view, it would appear to + // flash since this._on_blur would remove it immediately after. + this.view.close({ showFocusBorder: false }); + } + + /** + * Determines where a URL/page should be opened. + * + * @param {Event} event the event triggering the opening. + * @returns {"current" | "tabshifted" | "tab" | "save" | "window"} + */ + _whereToOpen(event) { + let isKeyboardEvent = KeyboardEvent.isInstance(event); + let reuseEmpty = isKeyboardEvent; + let where = undefined; + if ( + isKeyboardEvent && + (event.altKey || event.getModifierState("AltGraph")) + ) { + // We support using 'alt' to open in a tab, because ctrl/shift + // might be used for canonizing URLs: + where = event.shiftKey ? "tabshifted" : "tab"; + } else if ( + isKeyboardEvent && + event.ctrlKey && + lazy.UrlbarPrefs.get("ctrlCanonizesURLs") + ) { + // If we're allowing canonization, and this is a key event with ctrl + // pressed, open in current tab to allow ctrl-enter to canonize URL. + where = "current"; + } else { + where = this.window.whereToOpenLink(event, false, false); + } + if (lazy.UrlbarPrefs.get("openintab")) { + if (where == "current") { + where = "tab"; + } else if (where == "tab") { + where = "current"; + } + reuseEmpty = true; + } + if ( + where == "tab" && + reuseEmpty && + this.window.gBrowser.selectedTab.isEmpty + ) { + where = "current"; + } + return where; + } + + _initCopyCutController() { + this._copyCutController = new CopyCutController(this); + this.inputField.controllers.insertControllerAt(0, this._copyCutController); + } + + _initPasteAndGo() { + let inputBox = this.querySelector("moz-input-box"); + let contextMenu = inputBox.menupopup; + let insertLocation = contextMenu.firstElementChild; + while ( + insertLocation.nextElementSibling && + insertLocation.getAttribute("cmd") != "cmd_paste" + ) { + insertLocation = insertLocation.nextElementSibling; + } + if (!insertLocation) { + return; + } + + let pasteAndGo = this.document.createXULElement("menuitem"); + pasteAndGo.id = "paste-and-go"; + let label = Services.strings + .createBundle("chrome://browser/locale/browser.properties") + .GetStringFromName("pasteAndGo.label"); + pasteAndGo.setAttribute("label", label); + pasteAndGo.setAttribute("anonid", "paste-and-go"); + pasteAndGo.addEventListener("command", () => { + this._suppressStartQuery = true; + + this.select(); + this.window.goDoCommand("cmd_paste"); + this.setResultForCurrentValue(null); + this.handleCommand(); + + this._suppressStartQuery = false; + }); + + contextMenu.addEventListener("popupshowing", () => { + // Close the results pane when the input field contextual menu is open, + // because paste and go doesn't want a result selection. + this.view.close(); + + let controller = this.document.commandDispatcher.getControllerForCommand( + "cmd_paste" + ); + let enabled = controller.isCommandEnabled("cmd_paste"); + if (enabled) { + pasteAndGo.removeAttribute("disabled"); + } else { + pasteAndGo.setAttribute("disabled", "true"); + } + }); + + insertLocation.insertAdjacentElement("afterend", pasteAndGo); + } + + /** + * This notifies observers that the user has entered or selected something in + * the URL bar which will cause navigation. + * + * We use the observer service, so that we don't need to load extra facilities + * if they aren't being used, e.g. WebNavigation. + * + * @param {UrlbarResult} result + * Details of the result that was selected, if any. + */ + _notifyStartNavigation(result) { + Services.obs.notifyObservers({ result }, "urlbar-user-start-navigation"); + } + + /** + * Returns a search mode object if a result should enter search mode when + * selected. + * + * @param {UrlbarResult} result + * The result to check. + * @param {string} [entry] + * If provided, this will be recorded as the entry point into search mode. + * See setSearchMode() documentation for details. + * @returns {object} A search mode object. Null if search mode should not be + * entered. See setSearchMode documentation for details. + */ + _searchModeForResult(result, entry = null) { + // Search mode is determined by the result's keyword or engine. + if (!result.payload.keyword && !result.payload.engine) { + return null; + } + + let searchMode = lazy.UrlbarUtils.searchModeForToken( + result.payload.keyword + ); + // If result.originalEngine is set, then the user is Alt+Tabbing + // through the one-offs, so the keyword doesn't match the engine. + if ( + !searchMode && + result.payload.engine && + (!result.payload.originalEngine || + result.payload.engine == result.payload.originalEngine) + ) { + searchMode = { engineName: result.payload.engine }; + } + + if (searchMode) { + if (entry) { + searchMode.entry = entry; + } else { + switch (result.providerName) { + case "UrlbarProviderTopSites": + searchMode.entry = "topsites_urlbar"; + break; + case "TabToSearch": + if (result.payload.dynamicType) { + searchMode.entry = "tabtosearch_onboard"; + } else { + searchMode.entry = "tabtosearch"; + } + break; + default: + searchMode.entry = "keywordoffer"; + break; + } + } + } + + return searchMode; + } + + /** + * Updates the UI so that search mode is either entered or exited. + * + * @param {object} searchMode + * See setSearchMode documentation. If null, then search mode is exited. + */ + _updateSearchModeUI(searchMode) { + let { engineName, source, isGeneralPurposeEngine } = searchMode || {}; + + // As an optimization, bail if the given search mode is null but search mode + // is already inactive. Otherwise browser_preferences_usage.js fails due to + // accessing the browser.urlbar.placeholderName pref (via the call to + // BrowserSearch.initPlaceHolder below) too many times. That test does not + // enter search mode, but it triggers many calls to this method with a null + // search mode, via setURI. + if (!engineName && !source && !this.hasAttribute("searchmode")) { + return; + } + + this._searchModeIndicatorTitle.textContent = ""; + this._searchModeLabel.textContent = ""; + this._searchModeIndicatorTitle.removeAttribute("data-l10n-id"); + this._searchModeLabel.removeAttribute("data-l10n-id"); + this.removeAttribute("searchmodesource"); + + if (!engineName && !source) { + try { + // This will throw before DOMContentLoaded in + // PrivateBrowsingUtils.privacyContextFromWindow because + // aWindow.docShell is null. + this.window.BrowserSearch.initPlaceHolder(true); + } catch (ex) {} + this.removeAttribute("searchmode"); + return; + } + + if (engineName) { + // Set text content for the search mode indicator. + this._searchModeIndicatorTitle.textContent = engineName; + this._searchModeLabel.textContent = engineName; + this.document.l10n.setAttributes( + this.inputField, + isGeneralPurposeEngine + ? "urlbar-placeholder-search-mode-web-2" + : "urlbar-placeholder-search-mode-other-engine", + { name: engineName } + ); + } else if (source) { + let sourceName = lazy.UrlbarUtils.getResultSourceName(source); + let l10nID = `urlbar-search-mode-${sourceName}`; + this.document.l10n.setAttributes(this._searchModeIndicatorTitle, l10nID); + this.document.l10n.setAttributes(this._searchModeLabel, l10nID); + this.document.l10n.setAttributes( + this.inputField, + `urlbar-placeholder-search-mode-other-${sourceName}` + ); + this.setAttribute("searchmodesource", sourceName); + } + + this.toggleAttribute("searchmode", true); + // Clear autofill. + if (this._autofillPlaceholder && this.window.gBrowser.userTypedValue) { + this.value = this.window.gBrowser.userTypedValue; + } + // Search mode should only be active when pageproxystate is invalid. + if (this.getAttribute("pageproxystate") == "valid") { + this.value = ""; + this.setPageProxyState("invalid", true); + } + } + + /** + * Determines if we should select all the text in the Urlbar based on the + * Urlbar state, and whether the selection is empty. + */ + _maybeSelectAll() { + if ( + !this._preventClickSelectsAll && + this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING && + this.document.activeElement == this.inputField && + this.inputField.selectionStart == this.inputField.selectionEnd + ) { + this.select(); + } + } + + // Event handlers below. + + _on_command(event) { + // Something is executing a command, likely causing a focus change. This + // should not be recorded as an abandonment. If the user is entering search + // mode from a one-off, then they are in the same engagement and we should + // not discard. + if ( + !event.target.classList.contains("searchbar-engine-one-off-item") || + this.searchMode?.entry != "oneoff" + ) { + this.controller.engagementEvent.discard(); + } + } + + _on_blur(event) { + // We cannot count every blur events after a missed engagement as abandoment + // because the user may have clicked on some view element that executes + // a command causing a focus change. For example opening preferences from + // the oneoff settings button. + // For now we detect that case by discarding the event on command, but we + // may want to figure out a more robust way to detect abandonment. + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + searchSource: this.getSearchSource(event), + }); + + this.focusedViaMousedown = false; + this._handoffSession = undefined; + this._isHandoffSession = false; + this.removeAttribute("focused"); + + if (this._autofillPlaceholder && this.window.gBrowser.userTypedValue) { + // If we were autofilling, remove the autofilled portion, by restoring + // the value to the last typed one. + this.value = this.window.gBrowser.userTypedValue; + } else if (this.value == this._focusUntrimmedValue) { + // If the value was untrimmed by _on_focus and didn't change, trim it. + this.value = this._focusUntrimmedValue; + } + this._focusUntrimmedValue = null; + + this.formatValue(); + this._resetSearchState(); + + // In certain cases, like holding an override key and confirming an entry, + // we don't key a keyup event for the override key, thus we make this + // additional cleanup on blur. + this._clearActionOverride(); + + // The extension input sessions depends more on blur than on the fact we + // actually cancel a running query, so we do it here. + if (lazy.ExtensionSearchHandler.hasActiveInputSession()) { + lazy.ExtensionSearchHandler.handleInputCancelled(); + } + + // Respect the autohide preference for easier inspecting/debugging via + // the browser toolbox. + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + + if (this._revertOnBlurValue == this.value) { + this.handleRevert(); + } + this._revertOnBlurValue = null; + + // We may have hidden popup notifications, show them again if necessary. + if ( + this.getAttribute("pageproxystate") != "valid" && + this.window.UpdatePopupNotificationsVisibility + ) { + this.window.UpdatePopupNotificationsVisibility(); + } + + // If user move the focus to another component while pressing Enter key, + // then keyup at that component, as we can't get the event, clear the promise. + if (this._keyDownEnterDeferred) { + this._keyDownEnterDeferred.resolve(); + this._keyDownEnterDeferred = null; + } + this._isKeyDownWithCtrl = false; + + Services.obs.notifyObservers(null, "urlbar-blur"); + } + + _on_click(event) { + if ( + event.target == this.inputField || + event.target == this._inputContainer || + event.target.id == SEARCH_BUTTON_ID + ) { + this._maybeSelectAll(); + } + + if (event.target == this._searchModeIndicatorClose && event.button != 2) { + this.searchMode = null; + this.view.oneOffSearchButtons.selectedButton = null; + if (this.view.isOpen) { + this.startQuery({ + event, + }); + } + } + } + + _on_contextmenu(event) { + this.addSearchEngineHelper.refreshContextMenu(event); + + // Context menu opened via keyboard shortcut. + if (!event.button) { + return; + } + + this._maybeSelectAll(); + } + + _on_focus(event) { + if (!this._hideFocus) { + this.setAttribute("focused", "true"); + } + + // If the value was trimmed, check whether we should untrim it. + // This is necessary when a protocol was typed, but the whole url has + // invalid parts, like the origin, then editing and confirming the trimmed + // value would execute a search instead of visiting the typed url. + if (this.value != this._untrimmedValue) { + let untrim = false; + let fixedURI = this._getURIFixupInfo(this.value)?.preferredURI; + if (fixedURI) { + try { + let expectedURI = Services.io.newURI(this._untrimmedValue); + untrim = fixedURI.displaySpec != expectedURI.displaySpec; + } catch (ex) { + untrim = true; + } + } + if (untrim) { + this.inputField.value = this._focusUntrimmedValue = this._untrimmedValue; + } + } + + if (this.focusedViaMousedown) { + this.view.autoOpen({ event }); + } else if (this.inputField.hasAttribute("refocused-by-panel")) { + this._maybeSelectAll(); + } + + this._updateUrlTooltip(); + this.formatValue(); + + // Hide popup notifications, to reduce visual noise. + if ( + this.getAttribute("pageproxystate") != "valid" && + this.window.UpdatePopupNotificationsVisibility + ) { + this.window.UpdatePopupNotificationsVisibility(); + } + + Services.obs.notifyObservers(null, "urlbar-focus"); + } + + _on_mouseover(event) { + this._updateUrlTooltip(); + } + + _on_draggableregionleftmousedown(event) { + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + } + + _on_mousedown(event) { + switch (event.currentTarget) { + case this.textbox: + this._mousedownOnUrlbarDescendant = true; + + if ( + event.target != this.inputField && + event.target != this._inputContainer && + event.target.id != SEARCH_BUTTON_ID + ) { + break; + } + + this.focusedViaMousedown = !this.focused; + this._preventClickSelectsAll = this.focused; + + // Keep the focus status, since the attribute may be changed + // upon calling this.focus(). + const hasFocus = this.hasAttribute("focused"); + if (event.target != this.inputField) { + this.focus(); + } + + // The rest of this case only cares about left clicks. + if (event.button != 0) { + break; + } + + // Clear any previous selection unless we are focused, to ensure it + // doesn't affect drag selection. + if (this.focusedViaMousedown) { + this.inputField.setSelectionRange(0, 0); + } + + if (event.target.id == SEARCH_BUTTON_ID) { + this._preventClickSelectsAll = true; + this.search(lazy.UrlbarTokenizer.RESTRICT.SEARCH); + } else { + // Do not suppress the focus border if we are already focused. If we + // did, we'd hide the focus border briefly then show it again if the + // user has Top Sites disabled, creating a flashing effect. + this.view.autoOpen({ + event, + suppressFocusBorder: !hasFocus, + }); + } + break; + case this.window: + if (this._mousedownOnUrlbarDescendant) { + this._mousedownOnUrlbarDescendant = false; + break; + } + // Don't close the view when clicking on a tab; we may want to keep the + // view open on tab switch, and the TabSelect event arrived earlier. + if (event.target.closest("tab")) { + break; + } + // Close the view when clicking on toolbars and other UI pieces that + // might not automatically remove focus from the input. + // Respect the autohide preference for easier inspecting/debugging via + // the browser toolbox. + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + break; + } + } + + _on_input(event) { + let value = this.value; + this.valueIsTyped = true; + this._untrimmedValue = value; + this._resultForCurrentValue = null; + + this.window.gBrowser.userTypedValue = value; + // Unset userSelectionBehavior because the user is modifying the search + // string, thus there's no valid selection. This is also used by the view + // to set "aria-activedescendant", thus it should never get stale. + this.controller.userSelectionBehavior = "none"; + + let compositionState = this._compositionState; + let compositionClosedPopup = this._compositionClosedPopup; + + // Clear composition values if we're no more composing. + if (this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING) { + this._compositionState = lazy.UrlbarUtils.COMPOSITION.NONE; + this._compositionClosedPopup = false; + } + + if (value) { + this.setAttribute("usertyping", "true"); + } else { + this.removeAttribute("usertyping"); + } + this.removeAttribute("actiontype"); + + if ( + this.getAttribute("pageproxystate") == "valid" && + this.value != this._lastValidURLStr + ) { + this.setPageProxyState("invalid", true); + } + + if (!this.view.isOpen) { + this.view.clear(); + } else if (!value && !lazy.UrlbarPrefs.get("suggest.topsites")) { + this.view.clear(); + if (!this.searchMode || !this.view.oneOffSearchButtons.hasView) { + this.view.close(); + return; + } + } + + this.view.removeAccessibleFocus(); + + // During composition with an IME, the following events happen in order: + // 1. a compositionstart event + // 2. some input events + // 3. a compositionend event + // 4. an input event + + // We should do nothing during composition or if composition was canceled + // and we didn't close the popup on composition start. + if ( + !lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition") && + (compositionState == lazy.UrlbarUtils.COMPOSITION.COMPOSING || + (compositionState == lazy.UrlbarUtils.COMPOSITION.CANCELED && + !compositionClosedPopup)) + ) { + return; + } + + // Autofill only when text is inserted (i.e., event.data is not empty) and + // it's not due to pasting. + const allowAutofill = + (!lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition") || + compositionState !== lazy.UrlbarUtils.COMPOSITION.COMPOSING) && + !!event.data && + !lazy.UrlbarUtils.isPasteEvent(event) && + this._maybeAutofillPlaceholder(value); + + this.startQuery({ + searchString: value, + allowAutofill, + resetSearchState: false, + event, + }); + } + + _on_select(event) { + // On certain user input, AutoCopyListener::OnSelectionChange() updates + // the primary selection with user-selected text (when supported). + // Selection::NotifySelectionListeners() then dispatches a "select" event + // under similar conditions via TextInputListener::OnSelectionChange(). + // This event is received here in order to replace the primary selection + // from the editor with text having the adjustments of + // _getSelectedValueForClipboard(), such as adding the scheme for the url. + // + // Other "select" events are also received, however, and must be excluded. + if ( + // _suppressPrimaryAdjustment is set during select(). Don't update + // the primary selection because that is not the intent of user input, + // which may be new tab or urlbar focus. + this._suppressPrimaryAdjustment || + // The check on isHandlingUserInput filters out async "select" events + // from setSelectionRange(), which occur when autofill text is selected. + !this.window.windowUtils.isHandlingUserInput || + !Services.clipboard.supportsSelectionClipboard() + ) { + return; + } + + let val = this._getSelectedValueForClipboard(); + if (!val) { + return; + } + + lazy.ClipboardHelper.copyStringToClipboard( + val, + Services.clipboard.kSelectionClipboard + ); + } + + _on_overflow(event) { + const targetIsPlaceholder = + event.originalTarget.implementedPseudoElement == "::placeholder"; + // We only care about the non-placeholder text. + // This shouldn't be needed, see bug 1487036. + if (targetIsPlaceholder) { + return; + } + this._overflowing = true; + this.updateTextOverflow(); + } + + _on_underflow(event) { + const targetIsPlaceholder = + event.originalTarget.implementedPseudoElement == "::placeholder"; + // We only care about the non-placeholder text. + // This shouldn't be needed, see bug 1487036. + if (targetIsPlaceholder) { + return; + } + this._overflowing = false; + + this.updateTextOverflow(); + + this._updateUrlTooltip(); + } + + _on_paste(event) { + let originalPasteData = event.clipboardData.getData("text/plain"); + if (!originalPasteData) { + return; + } + + let oldValue = this.inputField.value; + let oldStart = oldValue.substring(0, this.selectionStart); + // If there is already non-whitespace content in the URL bar + // preceding the pasted content, it's not necessary to check + // protocols used by the pasted content: + if (oldStart.trim()) { + return; + } + let oldEnd = oldValue.substring(this.selectionEnd); + + let isURLAssumed = true; + try { + const { keywordAsSent } = Services.uriFixup.getFixupURIInfo( + originalPasteData, + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP + ); + isURLAssumed = !keywordAsSent; + } catch (e) {} + + // In some cases, the data pasted will contain newline codes. In order to + // achive the behavior expected by user, remove newline codes when URL is + // assumed. When keywords are assumed, replace all whitespace characters + // including newline with a space. + let pasteData = isURLAssumed + ? originalPasteData.replace(/[\r\n]/g, "") + : originalPasteData.replace(/\s/g, " "); + + pasteData = lazy.UrlbarUtils.stripUnsafeProtocolOnPaste(pasteData); + + if (originalPasteData != pasteData) { + // Unfortunately we're not allowed to set the bits being pasted + // so cancel this event: + event.preventDefault(); + event.stopImmediatePropagation(); + + this.inputField.value = oldStart + pasteData + oldEnd; + this._untrimmedValue = this.inputField.value; + + // Fix up cursor/selection: + let newCursorPos = oldStart.length + pasteData.length; + this.inputField.setSelectionRange(newCursorPos, newCursorPos); + + this.startQuery({ + searchString: this.inputField.value, + allowAutofill: false, + resetSearchState: false, + event, + }); + } + } + + _on_scrollend(event) { + this.updateTextOverflow(); + } + + _on_TabSelect(event) { + this._gotTabSelect = true; + this._afterTabSelectAndFocusChange(); + } + + _on_beforeinput(event) { + if (event.data && this._keyDownEnterDeferred) { + // Ignore char key input while processing enter key. + event.preventDefault(); + } + } + + _on_keydown(event) { + if (event.keyCode === KeyEvent.DOM_VK_RETURN) { + if (this._keyDownEnterDeferred) { + this._keyDownEnterDeferred.reject(); + } + this._keyDownEnterDeferred = lazy.PromiseUtils.defer(); + event._disableCanonization = this._isKeyDownWithCtrl; + } else if (event.keyCode !== KeyEvent.DOM_VK_CONTROL && event.ctrlKey) { + this._isKeyDownWithCtrl = true; + } + + // Due to event deferring, it's possible preventDefault() won't be invoked + // soon enough to actually prevent some of the default behaviors, thus we + // have to handle the event "twice". This first immediate call passes false + // as second argument so that handleKeyNavigation will only simulate the + // event handling, without actually executing actions. + // TODO (Bug 1541806): improve this handling, maybe by delaying actions + // instead of events. + if (this.eventBufferer.shouldDeferEvent(event)) { + this.controller.handleKeyNavigation(event, false); + } + this._toggleActionOverride(event); + this.eventBufferer.maybeDeferEvent(event, () => { + this.controller.handleKeyNavigation(event); + }); + } + + async _on_keyup(event) { + if (event.keyCode === KeyEvent.DOM_VK_CONTROL) { + this._isKeyDownWithCtrl = false; + } + + this._toggleActionOverride(event); + + // Pressing Enter key while pressing Meta key, and next, even when releasing + // Enter key before releasing Meta key, the keyup event is not fired. + // Therefore, if Enter keydown is detecting, continue the post processing + // for Enter key when any keyup event is detected. + if (this._keyDownEnterDeferred) { + if (this._keyDownEnterDeferred.loadedContent) { + try { + const loadingBrowser = await this._keyDownEnterDeferred.promise; + // Ensure the selected browser didn't change in the meanwhile. + if (this.window.gBrowser.selectedBrowser === loadingBrowser) { + loadingBrowser.focus(); + // Make sure the domain name stays visible for spoof protection and usability. + this.inputField.setSelectionRange(0, 0); + } + } catch (ex) { + // Not all the Enter actions in the urlbar will cause a navigation, then it + // is normal for this to be rejected. + // If _keyDownEnterDeferred was rejected on keydown, we don't nullify it here + // to ensure not overwriting the new value created by keydown. + } + } else { + // Discard the _keyDownEnterDeferred promise to receive any key inputs immediately. + this._keyDownEnterDeferred.resolve(); + } + + this._keyDownEnterDeferred = null; + } + } + + _on_compositionstart(event) { + if (this._compositionState == lazy.UrlbarUtils.COMPOSITION.COMPOSING) { + throw new Error("Trying to start a nested composition?"); + } + this._compositionState = lazy.UrlbarUtils.COMPOSITION.COMPOSING; + + if (lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition")) { + return; + } + + // Close the view. This will also stop searching. + if (this.view.isOpen) { + // We're closing the view, but we want to retain search mode if the + // selected result was previewing it. + if (this.searchMode) { + // If we entered search mode with an empty string, clear userTypedValue, + // otherwise confirmSearchMode may try to set it as value. + // This can happen for example if we entered search mode typing a + // a partial engine domain and selecting a tab-to-search result. + if (!this.value) { + this.window.gBrowser.userTypedValue = null; + } + this.confirmSearchMode(); + } + this._compositionClosedPopup = true; + this.view.close(); + } else { + this._compositionClosedPopup = false; + } + } + + _on_compositionend(event) { + if (this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING) { + throw new Error("Trying to stop a non existing composition?"); + } + + if (!lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition")) { + // Clear the selection and the cached result, since they refer to the + // state before this composition. A new input even will be generated + // after this. + this.view.clearSelection(); + this._resultForCurrentValue = null; + } + + // We can't yet retrieve the committed value from the editor, since it isn't + // completely committed yet. We'll handle it at the next input event. + this._compositionState = event.data + ? lazy.UrlbarUtils.COMPOSITION.COMMIT + : lazy.UrlbarUtils.COMPOSITION.CANCELED; + } + + _on_dragstart(event) { + // Drag only if the gesture starts from the input field. + let nodePosition = this.inputField.compareDocumentPosition( + event.originalTarget + ); + if ( + event.target != this.inputField && + !(nodePosition & Node.DOCUMENT_POSITION_CONTAINED_BY) + ) { + return; + } + + // Don't cover potential drop targets on the toolbars or in content. + this.view.close(); + + // Only customize the drag data if the entire value is selected and it's a + // loaded URI. Use default behavior otherwise. + if ( + this.selectionStart != 0 || + this.selectionEnd != this.inputField.textLength || + this.getAttribute("pageproxystate") != "valid" + ) { + return; + } + + let uri = this.makeURIReadable(this.window.gBrowser.currentURI); + let href = uri.displaySpec; + let title = this.window.gBrowser.contentTitle || href; + + event.dataTransfer.setData("text/x-moz-url", `${href}\n${title}`); + event.dataTransfer.setData("text/unicode", href); + event.dataTransfer.setData("text/html", `<a href="${href}">${title}</a>`); + event.dataTransfer.effectAllowed = "copyLink"; + event.stopPropagation(); + } + + _on_dragover(event) { + if (!getDroppableData(event)) { + event.dataTransfer.dropEffect = "none"; + } + } + + _on_drop(event) { + let droppedItem = getDroppableData(event); + let droppedURL = URL.isInstance(droppedItem) + ? droppedItem.href + : droppedItem; + if (droppedURL && droppedURL !== this.window.gBrowser.currentURI.spec) { + let principal = Services.droppedLinkHandler.getTriggeringPrincipal(event); + this.value = droppedURL; + this.setPageProxyState("invalid"); + this.focus(); + // To simplify tracking of events, register an initial event for event + // telemetry, to replace the missing input event. + this.controller.engagementEvent.start(event); + this.handleNavigation({ triggeringPrincipal: principal }); + // For safety reasons, in the drop case we don't want to immediately show + // the the dropped value, instead we want to keep showing the current page + // url until an onLocationChange happens. + // See the handling in `setURI` for further details. + this.window.gBrowser.userTypedValue = null; + this.setURI(null, true); + } + } + + _on_customizationstarting() { + this.blur(); + + this.inputField.controllers.removeController(this._copyCutController); + delete this._copyCutController; + } + + _on_aftercustomization() { + this._initCopyCutController(); + this._initPasteAndGo(); + } +} + +/** + * Tries to extract droppable data from a DND event. + * + * @param {Event} event The DND event to examine. + * @returns {URL|string|null} + * null if there's a security reason for which we should do nothing. + * A URL object if it's a value we can load. + * A string value otherwise. + */ +function getDroppableData(event) { + let links; + try { + links = Services.droppedLinkHandler.dropLinks(event); + } catch (ex) { + // This is either an unexpected failure or a security exception; in either + // case we should always return null. + return null; + } + // The URL bar automatically handles inputs with newline characters, + // so we can get away with treating text/x-moz-url flavours as text/plain. + if (links.length && links[0].url) { + event.preventDefault(); + let href = links[0].url; + if (lazy.UrlbarUtils.stripUnsafeProtocolOnPaste(href) != href) { + // We may have stripped an unsafe protocol like javascript: and if so + // there's no point in handling a partial drop. + event.stopImmediatePropagation(); + return null; + } + + try { + // If this throws, checkLoadURStrWithPrincipal would also throw, + // as that's what it does with things that don't pass the IO + // service's newURI constructor without fixup. It's conceivable we + // may want to relax this check in the future (so e.g. www.foo.com + // gets fixed up), but not right now. + let url = new URL(href); + // If we succeed, try to pass security checks. If this works, return the + // URL object. If the *security checks* fail, return null. + try { + let principal = Services.droppedLinkHandler.getTriggeringPrincipal( + event + ); + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + principal, + url.href, + Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL + ); + return url; + } catch (ex) { + return null; + } + } catch (ex) { + // We couldn't make a URL out of this. Continue on, and return text below. + } + } + // Handle as text. + return event.dataTransfer.getData("text/unicode"); +} + +/** + * Decodes the given URI for displaying it in the address bar without losing + * information, such that hitting Enter again will load the same URI. + * + * @param {nsIURI} aURI + * The URI to decode + * @returns {string} + * The decoded URI + */ +function losslessDecodeURI(aURI) { + let scheme = aURI.scheme; + let value = aURI.displaySpec; + + // Try to decode as UTF-8 if there's no encoding sequence that we would break. + if (!/%25(?:3B|2F|3F|3A|40|26|3D|2B|24|2C|23)/i.test(value)) { + let decodeASCIIOnly = !["https", "http", "file", "ftp"].includes(scheme); + if (decodeASCIIOnly) { + // This only decodes ascii characters (hex) 20-7e, except 25 (%). + // This avoids both cases stipulated below (%-related issues, and \r, \n + // and \t, which would be %0d, %0a and %09, respectively) as well as any + // non-US-ascii characters. + value = value.replace( + /%(2[0-4]|2[6-9a-f]|[3-6][0-9a-f]|7[0-9a-e])/g, + decodeURI + ); + } else { + try { + value = decodeURI(value) + // decodeURI decodes %25 to %, which creates unintended encoding + // sequences. Re-encode it, unless it's part of a sequence that + // survived decodeURI, i.e. one for: + // ';', '/', '?', ':', '@', '&', '=', '+', '$', ',', '#' + // (RFC 3987 section 3.2) + .replace( + /%(?!3B|2F|3F|3A|40|26|3D|2B|24|2C|23)/gi, + encodeURIComponent + ); + } catch (e) {} + } + } + + // Encode potentially invisible characters: + // U+0000-001F: C0/C1 control characters + // U+007F-009F: commands + // U+00A0, U+1680, U+2000-200A, U+202F, U+205F, U+3000: other spaces + // U+2028-2029: line and paragraph separators + // U+2800: braille empty pattern + // U+FFFC: object replacement character + // Encode any trailing whitespace that may be part of a pasted URL, so that it + // doesn't get eaten away by the location bar (bug 410726). + // Encode all adjacent space chars (U+0020), to prevent spoofing attempts + // where they would push part of the URL to overflow the location bar + // (bug 1395508). A single space, or the last space if the are many, is + // preserved to maintain readability of certain urls. We only do this for the + // common space, because others may be eaten when copied to the clipboard, so + // it's safer to preserve them encoded. + value = value.replace( + // eslint-disable-next-line no-control-regex + /[\u0000-\u001f\u007f-\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u2800\u3000\ufffc]|[\r\n\t]|\u0020(?=\u0020)|\s$/g, + encodeURIComponent + ); + + // Encode characters that are ignorable, can't be rendered usefully, or may + // confuse users. + // + // Default ignorable characters; ZWNJ (U+200C) and ZWJ (U+200D) are excluded + // per bug 582186: + // U+00AD, U+034F, U+06DD, U+070F, U+115F-1160, U+17B4, U+17B5, U+180B-180E, + // U+2060, U+FEFF, U+200B, U+2060-206F, U+3164, U+FE00-FE0F, U+FFA0, + // U+FFF0-FFFB, U+1D173-1D17A (U+D834 + DD73-DD7A), + // U+E0000-E0FFF (U+DB40-DB43 + U+DC00-DFFF) + // Bidi control characters (RFC 3987 sections 3.2 and 4.1 paragraph 6): + // U+061C, U+200E, U+200F, U+202A-202E, U+2066-2069 + // Other format characters in the Cf category that are unlikely to be rendered + // usefully: + // U+0600-0605, U+08E2, U+110BD (U+D804 + U+DCBD), + // U+110CD (U+D804 + U+DCCD), U+13430-13438 (U+D80D + U+DC30-DC38), + // U+1BCA0-1BCA3 (U+D82F + U+DCA0-DCA3) + // Mimicking UI parts: + // U+1F50F-1F513 (U+D83D + U+DD0F-DD13), U+1F6E1 (U+D83D + U+DEE1) + value = value.replace( + // eslint-disable-next-line no-misleading-character-class + /[\u00ad\u034f\u061c\u06dd\u070f\u115f\u1160\u17b4\u17b5\u180b-\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u206f\u3164\u0600-\u0605\u08e2\ufe00-\ufe0f\ufeff\uffa0\ufff0-\ufffb]|\ud804[\udcbd\udccd]|\ud80d[\udc30-\udc38]|\ud82f[\udca0-\udca3]|\ud834[\udd73-\udd7a]|[\udb40-\udb43][\udc00-\udfff]|\ud83d[\udd0f-\udd13\udee1]/g, + encodeURIComponent + ); + return value; +} + +/** + * Handles copy and cut commands for the urlbar. + */ +class CopyCutController { + /** + * @param {UrlbarInput} urlbar + * The UrlbarInput instance to use this controller for. + */ + constructor(urlbar) { + this.urlbar = urlbar; + } + + /** + * @param {string} command + * The name of the command to handle. + */ + doCommand(command) { + let urlbar = this.urlbar; + let val = urlbar._getSelectedValueForClipboard(); + if (!val) { + return; + } + + if (command == "cmd_cut" && this.isCommandEnabled(command)) { + let start = urlbar.selectionStart; + let end = urlbar.selectionEnd; + urlbar.inputField.value = + urlbar.inputField.value.substring(0, start) + + urlbar.inputField.value.substring(end); + urlbar.inputField.setSelectionRange(start, start); + + let event = new UIEvent("input", { + bubbles: true, + cancelable: false, + view: urlbar.window, + detail: 0, + }); + urlbar.inputField.dispatchEvent(event); + } + + lazy.ClipboardHelper.copyString(val); + } + + /** + * @param {string} command + * The name of the command to check. + * @returns {boolean} + * Whether the command is handled by this controller. + */ + supportsCommand(command) { + switch (command) { + case "cmd_copy": + case "cmd_cut": + return true; + } + return false; + } + + /** + * @param {string} command + * The name of the command to check. + * @returns {boolean} + * Whether the command should be enabled. + */ + isCommandEnabled(command) { + return ( + this.supportsCommand(command) && + (command != "cmd_cut" || !this.urlbar.readOnly) && + this.urlbar.selectionStart < this.urlbar.selectionEnd + ); + } + + onEvent() {} +} + +/** + * Manages the Add Search Engine contextual menu entries. + * + * Note: setEnginesFromBrowser must be invoked from the outside when the + * page provided engines list changes. + * refreshContextMenu must be invoked when the context menu is opened. + */ +class AddSearchEngineHelper { + /** + * @type {UrlbarSearchOneOffs} + */ + shortcutButtons; + + /** + * @param {UrlbarInput} input The parent UrlbarInput. + */ + constructor(input) { + this.input = input; + this.shortcutButtons = input.view.oneOffSearchButtons; + } + + /** + * If there's more than this number of engines, the context menu offers + * them in a submenu. + * + * @returns {number} + */ + get maxInlineEngines() { + return this.shortcutButtons._maxInlineAddEngines; + } + + /** + * Invoked by browser when the list of available engines changes. + * + * @param {object} browser The invoking browser. + */ + setEnginesFromBrowser(browser) { + this.browsingContext = browser.browsingContext; + // Make a copy of the array for state comparison. + let engines = browser.engines?.slice() || []; + if (!this._sameEngines(this.engines, engines)) { + this.engines = engines; + this.shortcutButtons.updateWebEngines(engines); + } + } + + _sameEngines(engines1, engines2) { + if (engines1?.length != engines2?.length) { + return false; + } + return lazy.ObjectUtils.deepEqual( + engines1.map(e => e.title), + engines2.map(e => e.title) + ); + } + + _createMenuitem(engine, index) { + let elt = this.input.document.createXULElement("menuitem"); + elt.setAttribute("anonid", `add-engine-${index}`); + elt.classList.add("menuitem-iconic"); + elt.classList.add("context-menu-add-engine"); + elt.setAttribute("data-l10n-id", "search-one-offs-add-engine"); + elt.setAttribute( + "data-l10n-args", + JSON.stringify({ engineName: engine.title }) + ); + elt.setAttribute("uri", engine.uri); + if (engine.icon) { + elt.setAttribute("image", engine.icon); + } else { + elt.removeAttribute("image", engine.icon); + } + elt.addEventListener("command", this._onCommand.bind(this)); + return elt; + } + + _createMenu(engine) { + let elt = this.input.document.createXULElement("menu"); + elt.setAttribute("anonid", "add-engine-menu"); + elt.classList.add("menu-iconic"); + elt.classList.add("context-menu-add-engine"); + elt.setAttribute("data-l10n-id", "search-one-offs-add-engine-menu"); + if (engine.icon) { + elt.setAttribute("image", engine.icon); + } + let popup = this.input.document.createXULElement("menupopup"); + elt.appendChild(popup); + return elt; + } + + refreshContextMenu() { + let engines = this.engines; + + // Certain operations, like customization, destroy and recreate widgets, + // so we cannot rely on cached elements. + if (!this.input.querySelector(".menuseparator-add-engine")) { + this.contextSeparator = this.input.document.createXULElement( + "menuseparator" + ); + this.contextSeparator.setAttribute("anonid", "add-engine-separator"); + this.contextSeparator.classList.add("menuseparator-add-engine"); + this.contextSeparator.collapsed = true; + let contextMenu = this.input.querySelector("moz-input-box").menupopup; + contextMenu.appendChild(this.contextSeparator); + } + + this.contextSeparator.collapsed = !engines.length; + let curElt = this.contextSeparator; + // Remove the previous items, if any. + for (let elt = curElt.nextElementSibling; elt; ) { + let nextElementSibling = elt.nextElementSibling; + elt.remove(); + elt = nextElementSibling; + } + + // If the page provides too many engines, we only show a single menu entry + // with engines in a submenu. + if (engines.length > this.maxInlineEngines) { + // Set the menu button's image to the image of the first engine. The + // offered engines may have differing images, so there's no perfect + // choice here. + let elt = this._createMenu(engines[0]); + this.contextSeparator.insertAdjacentElement("afterend", elt); + curElt = elt.lastElementChild; + } + + // Insert the engines, either in the contextual menu or the sub menu. + for (let i = 0; i < engines.length; ++i) { + let elt = this._createMenuitem(engines[i], i); + if (curElt.localName == "menupopup") { + curElt.appendChild(elt); + } else { + curElt.insertAdjacentElement("afterend", elt); + } + curElt = elt; + } + } + + async _onCommand(event) { + let added = await lazy.SearchUIUtils.addOpenSearchEngine( + event.target.getAttribute("uri"), + event.target.getAttribute("image"), + this.browsingContext + ).catch(console.error); + if (added) { + // Remove the offered engine from the list. The browser updated the + // engines list at this point, so we just have to refresh the menu.) + this.refreshContextMenu(); + } + } +} |