/* 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", BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs", ExtensionSearchHandler: "resource://gre/modules/ExtensionSearchHandler.sys.mjs", ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", ReaderMode: "resource://gre/modules/ReaderMode.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", UrlbarQueryContext: "resource:///modules/UrlbarUtils.sys.mjs", UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.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.defineLazyServiceGetter( lazy, "ClipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper" ); XPCOMUtils.defineLazyServiceGetter( lazy, "QueryStringStripper", "@mozilla.org/url-query-string-stripper;1", "nsIURLQueryStringStripper" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "QUERY_STRIPPING_STRIP_ON_SHARE", "privacy.query_stripping.strip_on_share.enabled", false ); 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(` `) ); 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"); ChromeUtils.defineLazyGetter(this, "valueFormatter", () => { return new lazy.UrlbarValueFormatter(this); }); ChromeUtils.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", "selectionchange", ]; 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(); this._initStripOnShare(); // Tracks IME composition. this._compositionState = lazy.UrlbarUtils.COMPOSITION.NONE; this._compositionClosedPopup = false; this.editor.newlineHandling = Ci.nsIEditor.eNewlinesStripSurroundingWhitespace; ChromeUtils.defineLazyGetter(this, "logger", () => lazy.UrlbarUtils.getLogger({ prefix: "Input" }) ); } /** * 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; } setSelectionRange(selectionStart, selectionEnd) { this.focus(); 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.setSelectionRange(selectionStart, selectionEnd); 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. * @param {boolean} [isSameDocument] * True if the caller of setURI loaded a new document and false * otherwise (e.g. the location change was from an anchor scroll * or a pushState event). */ setURI( uri = null, dueToTabSwitch = false, dueToSessionRestore = false, dontShowSearchTerms = false, isSameDocument = false ) { if (!this.window.gBrowser.userTypedValue) { this.window.gBrowser.selectedBrowser.searchTerms = ""; if ( !dontShowSearchTerms && lazy.UrlbarPrefs.isPersistedSearchTermsEnabled() ) { this.window.gBrowser.selectedBrowser.searchTerms = lazy.UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( this.window.gBrowser.selectedBrowser.originalURI ?? uri ); } } let value = this.window.gBrowser.userTypedValue; let valid = false; // If `value` is null or if it's an empty string and we're switching tabs // or the userTypedValue equals the search terms, set value to either // search terms or the browser's current URI. When a user empties the input, // switches tabs, and switches back, we want the URI to become visible again // so the user knows what URI they're viewing. // An exception to this is made in case of an auth request from a different // base domain. To avoid auth prompt spoofing we already display the url of // the cross domain resource, although the page is not loaded yet. // This url will be set/unset by PromptParent. See bug 791594 for reference. if ( value === null || (!value && dueToTabSwitch) || (value && value === this.window.gBrowser.selectedBrowser.searchTerms) ) { if (this.window.gBrowser.selectedBrowser.searchTerms) { value = this.window.gBrowser.selectedBrowser.searchTerms; valid = !dueToSessionRestore; if (!isSameDocument) { Services.telemetry.scalarAdd( "urlbar.persistedsearchterms.view_count", 1 ); } } else { uri = this.window.gBrowser.selectedBrowser.currentAuthPromptURI || uri || this.#isOpenedPageInBlankTargetLoading || this.window.gBrowser.currentURI; // Strip off usernames and passwords for the location bar try { uri = Services.io.createExposableURI(uri); } catch (e) {} let isInitialPageControlledByWebContent = false; // 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 { isInitialPageControlledByWebContent = true; // 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") || isInitialPageControlledByWebContent); } } 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.toggleAttribute("usertyping", !valid && value); 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_ 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 || { triggeringPrincipal }; // 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; // Whether the user has been editing the value in the URL bar after selecting // the result. However, if the result type is tip, pick as it is. The result // heuristic is also kept the behavior as is for safety. let safeToPickResult = result && (result.heuristic || !this.valueIsTyped || result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP || this.value == this._getValueFromResult(result)); if ( !isComposing && element && (!oneOffParams?.engine || selectedPrivateEngineResult) && safeToPickResult ) { 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( result, 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); lazy.UrlbarUtils.addToFormHistory( this, searchString, oneOffParams.engine.name ).catch(console.error); } 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, { element, selType, searchString: typedValue, result: selectedResult, }); let isValidUrl = false; try { new URL(url); isValidUrl = true; } catch (ex) {} if (isValidUrl) { // Annotate if the untrimmed value contained a scheme, to later potentially // be upgraded by schemeless HTTPS-First. openParams.wasSchemelessInput = this.#isSchemeless(this.untrimmedValue); 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, this.window) .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(() => { 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, keywordAsSent, } = Services.uriFixup.getFixupURIInfo(url, flags); if ( where != "current" || browser.lastLocationChange == lastLocationChange ) { openParams.postData = postData; if (!keywordAsSent) { // `uri` is not a search engine url, so we annotate if the untrimmed // value contained a scheme, to potentially be later upgraded by // schemeless HTTPS-First. openParams.wasSchemelessInput = this.#isSchemeless( this.untrimmedValue ); } 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(); } } maybeHandleRevertFromPopup(anchorElement) { if ( anchorElement?.closest("#urlbar") && this.window.gBrowser.selectedBrowser.searchTerms ) { // The Persist Search Tip can be open while a PopupNotification is queued // to appear, so ensure that the tip is closed. this.view.close(); this.handleRevert(true); Services.telemetry.scalarAdd( "urlbar.persistedsearchterms.revert_by_popup_count", 1 ); } } /** * 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); this.logger.debug( `pickElement ${element} with event ${event?.type}, result: ${result}` ); 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 ) { if (element?.classList.contains("urlbarView-button-menu")) { this.view.openResultMenu(result, element); return; } if (element?.dataset.command) { this.#pickMenuResult(result, event, element, browser); return; } // 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 ( result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP && result.payload.type == "dismissalAcknowledgment" ) { // The user clicked the "Got it" button inside the dismissal // acknowledgment tip. Dismiss the tip. this.controller.engagementEvent.record(event, { result, element, searchString: this._lastSearchString, selType: "dismiss", }); this.view.onQueryResultRemoved(result.rowIndex); return; } let resultUrl = element?.dataset.url; let originalUntrimmedValue = this.untrimmedValue; let isCanonized = this.setValueFromResult({ result, event, urlOverride: resultUrl, }); 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 ( resultUrl && 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"; } if (!result.payload.providesSearchMode) { this.view.close({ elementPicked: true }); } this.controller.recordSelectedResult(event, result); if (isCanonized) { this.controller.engagementEvent.record(event, { result, element, selType: "canonized", searchString: this._lastSearchString, }); this._loadURL(this._untrimmedValue, event, where, openParams, browser); return; } let { url, postData } = resultUrl ? { url: resultUrl, postData: null } : lazy.UrlbarUtils.getUrlFromResult(result); openParams.postData = postData; switch (result.type) { case lazy.UrlbarUtils.RESULT_TYPE.URL: { if (result.heuristic) { // 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 ( lazy.UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") && lazy.UrlbarUtils.looksLikeSingleWordHost(originalUntrimmedValue) ) { url = originalUntrimmedValue; } // Annotate if the untrimmed value contained a scheme, to later potentially // be upgraded by schemeless HTTPS-First. openParams.wasSchemelessInput = this.#isSchemeless( 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("action-override")) { 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, { result, element, searchString, searchMode, selType: "tabswitch", }); let switched = this.window.switchToTabHavingURI( Services.io.newURI(url), true, loadOpts, lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") && lazy.UrlbarProviderOpenTabs.isNonPrivateUserContextId( result.payload.userContextId ) ? result.payload.userContextId : null ); 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( console.error ); } // TODO (Bug 1865757): We should not show a "switchtotab" result for // tabs that are not currently open. Find out why tabs are not being // properly unregistered when they are being closed. if (!switched) { console.error(`Tried to switch to non existant tab: ${url}`); lazy.UrlbarProviderOpenTabs.unregisterOpenTab( url, result.payload.userContextId, this.isPrivate ); } return; } case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: { if (result.payload.providesSearchMode) { this.controller.engagementEvent.record(event, { result, element, searchString: this._lastSearchString, selType: this.controller.engagementEvent.typeFromElement( result, element ), }); this.maybeConfirmSearchModeFromResult({ result, checkValue: false, }); 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.sys.mjs 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, }; 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(console.error); } break; } case lazy.UrlbarUtils.RESULT_TYPE.TIP: { let scalarName = `${result.payload.type}-picked`; Services.telemetry.keyedScalarAdd("urlbar.tips", scalarName, 1); if (url) { break; } this.handleRevert(); this.controller.engagementEvent.record(event, { result, element, selType: "tip", searchString: this._lastSearchString, }); 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(); } // If we won't be navigating, this is the end of the engagement. if (!url || !result.payload.shouldNavigate) { this.controller.engagementEvent.record(event, { result, element, searchMode, searchString: this._lastSearchString, selType: this.controller.engagementEvent.typeFromElement( result, element ), }); return; } break; } case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: { this.controller.engagementEvent.record(event, { result, element, selType: "extension", searchString: this._lastSearchString, }); // 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(console.error); } } this.controller.engagementEvent.record(event, { result, element, searchString: this._lastSearchString, selType: this.controller.engagementEvent.typeFromElement(result, element), 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 = result.rowIndex + 1; Services.telemetry.keyedScalarAdd( SCALAR_CATEGORY_TOPSITES, `urlbar_${position}`, 1 ); } } 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); // Update placeholder selection and value to the current selected result to // prevent the on_selectionchange event to detect a "accent-character" // insertion. if (!result.autofill && this._autofillPlaceholder) { this._autofillPlaceholder.value = this.value; this._autofillPlaceholder.selectionStart = this.value.length; this._autofillPlaceholder.selectionEnd = this.value.length; } 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 }); } /** * Clears displayed autofill values and unsets the autofill placeholder. */ #clearAutofill() { if (!this._autofillPlaceholder) { return; } let currentSelectionStart = this.selectionStart; let currentSelectionEnd = this.selectionEnd; // Overriding this value clears the selection. this.inputField.value = this.value.substring( 0, this._autofillPlaceholder.selectionStart ); this._autofillPlaceholder = null; // Restore selection this.setSelectionRange(currentSelectionStart, currentSelectionEnd); } /** * 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); } 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, autofillIgnoresSelection = false, searchString, resetSearchState = true, event, } = {}) { 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"); } let queryContext = this.#makeQueryContext({ allowAutofill, event, searchString, }); if (event) { this.controller.engagementEvent.start(event, queryContext, searchString); } if (this._suppressStartQuery) { return; } this._autofillIgnoresSelection = autofillIgnoresSelection; if (resetSearchState) { this._resetSearchState(); } if (this.searchMode) { this.confirmSearchMode(); } this._lastSearchString = searchString; this._valueOnLastSearch = this.value; // 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(queryContext); } /** * 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 { console.error(`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) { console.error(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, { allowTrim: 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); 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.searchTerms && !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) { console.error( `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.formatValue(); this._resetSearchState(); // We don't use the original TabSelect event because caching it causes // leaks on MacOS. const event = new CustomEvent("tabswitch"); // If the urlbar is focused after a tab switch, record a potential // engagement event. When switching from a focused to a non-focused urlbar, // the blur event would record the abandonment. When switching from an // unfocused to a focused urlbar, there should be no search session ongoing, // so this will be a no-op. if (this.focused) { this.controller.engagementEvent.record(event, { searchString: this._lastSearchString, searchSource: this.getSearchSource(event), }); } // Switching tabs doesn't always change urlbar focus, so we must try to // reopen here too, not just on focus. if (this.view.autoOpen({ event })) { 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.setAttribute("breakout", "true"); this.textbox.parentNode.setAttribute("breakout", "true"); resolve(); }); }); } /** * Sets the input field value. * * @param {string} val The new value to set. * @param {object} [options] Options for setting. * @param {boolean} [options.allowTrim] Whether the value can be trimmed. * * @returns {string} The set value. */ _setValue(val, { allowTrim = false } = {}) { // 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.#clearAutofill(); 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; } /** * 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" && lazy.UrlbarUtils.isTextDirectionRTL(this.value, this.window); 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.#isOpenedPageInBlankTargetLoading ? this.window.gBrowser.selectedBrowser.browsingContext .nonWebControlledBlankURI : 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. // If the copied text is that autofilled value, return the url including // the protocol from its suggestion. let result = this._resultForCurrentValue; if (result?.autofill?.value == selectedVal) { return result.payload.url; } 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.toggleAttribute("action-override", true); this.view.panel.setAttribute("action-override", true); } else if ( this._actionOverrideKeyCount && --this._actionOverrideKeyCount == 0 ) { this._clearActionOverride(); } } } _clearActionOverride() { this._actionOverrideKeyCount = 0; this.removeAttribute("action-override"); this.view.panel.removeAttribute("action-override"); } /** * 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) { let trimmedValue = lazy.UrlbarPrefs.get("trimURLs") ? lazy.BrowserUIUtils.trimURL(val) : val; // Only trim value if the directionality doesn't change to RTL. return lazy.UrlbarUtils.isTextDirectionRTL(trimmedValue, this.window) ? val : trimmedValue; } /** * 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_FLAGS_MAKE_ALTERNATE_URI ); value = info.fixedURI.spec; } catch (ex) { console.error(`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); this.inputField.setSelectionRange(selectionStart, selectionEnd); this._autofillPlaceholder = { value, type, adaptiveHistoryInput, selectionStart, selectionEnd, }; } /** * Called when a menu item from results menu 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. */ #pickMenuResult(result, event, element, browser) { this.controller.engagementEvent.record(event, { result, element, searchString: this._lastSearchString, selType: element.dataset.command == "help" && result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP ? "tiphelp" : element.dataset.command, }); if (element.dataset.command == "manage") { this.window.openPreferences("search-locationBar"); return; } let url; if (element.dataset.command == "help") { url = result.payload.helpUrl; } url ||= element.dataset.url; if (!url) { return; } let where = this._whereToOpen(event); if (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"; } this.view.close({ elementPicked: true }); if (result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP) { let scalarName = `${result.payload.type}-help`; Services.telemetry.keyedScalarAdd("urlbar.tips", scalarName, 1); } this._loadURL( url, event, where, { allowInheritPrincipal: false, private: this.isPrivate, }, { source: result.source, type: result.type, }, browser ); } /** * 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 {boolean} [params.wasSchemelessInput] * Whether the search/URL term was without an explicit scheme. * @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") { // Make sure URL is formatted properly (don't show punycode). let formattedURL = url; try { formattedURL = losslessDecodeURI(new URL(url).URI); } catch {} this.value = lazy.UrlbarPrefs.get("showSearchTermsFeatureGate") && lazy.UrlbarPrefs.get("showSearchTerms.enabled") && resultDetails?.searchTerm ? resultDetails.searchTerm : formattedURL; 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. console.error(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); } /** * Searches the context menu for the location of a specific command. * * @param {string} menuItemCommand * The command to search for. * @returns {string} * Html element that matches the command or * the last element if we could not find the command. */ #findMenuItemLocation(menuItemCommand) { let inputBox = this.querySelector("moz-input-box"); let contextMenu = inputBox.menupopup; let insertLocation = contextMenu.firstElementChild; // find the location of the command while ( insertLocation.nextElementSibling && insertLocation.getAttribute("cmd") != menuItemCommand ) { insertLocation = insertLocation.nextElementSibling; } return insertLocation; } /** * Strips known tracking query parameters/ link decorators. * * @returns {nsIURI} * The stripped URI or original URI, if nothing can be * stripped */ #stripURI() { let copyString = this._getSelectedValueForClipboard(); if (!copyString) { return null; } let strippedURI = null; let uri = null; // Error check occurs during isClipboardURIValid uri = Services.io.newURI(copyString); strippedURI = lazy.QueryStringStripper.stripForCopyOrShare(uri); if (strippedURI) { return this.makeURIReadable(strippedURI); } return uri; } /** * Checks if the clipboard contains a valid URI * * @returns {true|false} */ #isClipboardURIValid() { let copyString = this._getSelectedValueForClipboard(); if (!copyString) { return false; } // throws if the selected string is not a valid URI try { Services.io.newURI(copyString); } catch (e) { return false; } return true; } // The strip-on-share feature will strip known tracking/decorational // query params from the URI and copy the stripped version to the clipboard. _initStripOnShare() { let contextMenu = this.querySelector("moz-input-box").menupopup; let insertLocation = this.#findMenuItemLocation("cmd_copy"); if (!insertLocation.getAttribute("cmd") == "cmd_copy") { return; } // set up the menu item let stripOnShare = this.document.createXULElement("menuitem"); this.document.l10n.setAttributes( stripOnShare, "text-action-strip-on-share" ); stripOnShare.setAttribute("anonid", "strip-on-share"); stripOnShare.id = "strip-on-share"; insertLocation.insertAdjacentElement("afterend", stripOnShare); // Register listener that returns the stripped url or falls back // to the original url if nothing can be stripped. stripOnShare.addEventListener("command", () => { let strippedURI = this.#stripURI(); lazy.ClipboardHelper.copyString(strippedURI.displaySpec); }); // Register a listener that hides the menu item if there is nothing to copy. contextMenu.addEventListener("popupshowing", () => { // feature is not enabled if (!lazy.QUERY_STRIPPING_STRIP_ON_SHARE) { stripOnShare.setAttribute("hidden", true); return; } let controller = this.document.commandDispatcher.getControllerForCommand("cmd_copy"); // url bar is empty if (!controller.isCommandEnabled("cmd_copy")) { stripOnShare.setAttribute("hidden", true); return; } // selection is not a valid url if (!this.#isClipboardURIValid()) { stripOnShare.setAttribute("hidden", true); return; } stripOnShare.setAttribute("hidden", false); }); } _initPasteAndGo() { let inputBox = this.querySelector("moz-input-box"); let contextMenu = inputBox.menupopup; let insertLocation = this.#findMenuItemLocation("cmd_paste"); 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.controller.clearLastQueryContextCache(); 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 selecting a // result menu item or entering search mode from a one-off, then they are // in the same engagement and we should not discard. if ( !event.target.classList.contains("urlbarView-result-menuitem") && (!event.target.classList.contains("searchbar-engine-one-off-item") || this.searchMode?.entry != "oneoff") ) { this.controller.engagementEvent.discard(); } } _on_blur(event) { this.logger.debug("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._revertOnBlurValue == this.value) { this.handleRevert(); } else 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; } else { // We're not updating the value, so just format it. this.formatValue(); } this._focusUntrimmedValue = null; this._revertOnBlurValue = null; 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 there were search terms shown in the URL bar and the user // didn't end up modifying the userTypedValue while it was // focused, change back to a valid pageproxystate. if ( this.window.gBrowser.selectedBrowser.searchTerms && this.window.gBrowser.userTypedValue == null ) { this.setPageProxyState("valid", true); } // 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) { this.logger.debug("Focus Event"); if (!this._hideFocus) { this.setAttribute("focused", "true"); } // When the search term matches the SERP, the URL bar is in a valid // pageproxystate. In order to only show the search icon, switch to // an invalid pageproxystate. if (this.window.gBrowser.selectedBrowser.searchTerms) { this.setPageProxyState("invalid", 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); if ( lazy.UrlbarPrefs.get("trimHttps") && this._untrimmedValue.startsWith("https://") ) { untrim = fixedURI.displaySpec.replace("http://", "https://") != expectedURI.displaySpec; // FIXME bug 1847723: Figure out a way to do this without manually messing with the fixed up URI. } else { untrim = fixedURI.displaySpec != expectedURI.displaySpec; } } catch (ex) { untrim = true; } } if (untrim) { this._focusUntrimmedValue = this._untrimmedValue; this._setValue(this._focusUntrimmedValue); } } 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() { this._updateUrlTooltip(); } _on_draggableregionleftmousedown() { 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")) { if (this.view.isOpen && !this.hasAttribute("focused")) { // In this case, as blur event never happen from the inputField, we // record abandonment event explicitly. let blurEvent = new FocusEvent("blur", { relatedTarget: this.inputField, }); this.controller.engagementEvent.record(blurEvent, { searchString: this._lastSearchString, searchSource: this.getSearchSource(blurEvent), }); } this.view.close(); } break; } } _on_input(event) { if ( this._autofillPlaceholder && this.value === this.window.gBrowser.userTypedValue && (event.inputType === "deleteContentBackward" || event.inputType === "deleteContentForward") ) { // Take a telemetry if user deleted whole autofilled value. Services.telemetry.scalarAdd("urlbar.autofill_deletion", 1); } 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; } this.toggleAttribute("usertyping", value); 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_selectionchange() { // Confirm placeholder as user text if it gets explicitly deselected. This // happens when the user wants to modify the autofilled text by either // clicking on it, or pressing HOME, END, RIGHT, … if ( this._autofillPlaceholder && this._autofillPlaceholder.value == this.value && (this._autofillPlaceholder.selectionStart != this.selectionStart || this._autofillPlaceholder.selectionEnd != this.selectionEnd) ) { this._autofillPlaceholder = null; this.window.gBrowser.userTypedValue = this.value; } } _on_select() { // 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.isClipboardTypeSupported( Services.clipboard.kSelectionClipboard ) ) { 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.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); const pasteData = this.sanitizeTextFromClipboard(originalPasteData); if (originalPasteData != pasteData) { // Unfortunately we're not allowed to set the bits being pasted // so cancel this event: event.preventDefault(); event.stopImmediatePropagation(); const value = oldStart + pasteData + oldEnd; this._setValue(value); this.window.gBrowser.userTypedValue = value; this.toggleAttribute("usertyping", this._untrimmedValue); // Fix up cursor/selection: let newCursorPos = oldStart.length + pasteData.length; this.inputField.setSelectionRange(newCursorPos, newCursorPos); this.startQuery({ searchString: this.value, allowAutofill: false, resetSearchState: false, event, }); } } /** * Sanitize and process data retrieved from the clipboard * * @param {string} clipboardData * The original data retrieved from the clipboard. * @returns {string} * The sanitized paste data, ready to use. */ sanitizeTextFromClipboard(clipboardData) { let fixedURI, keywordAsSent; try { ({ fixedURI, keywordAsSent } = Services.uriFixup.getFixupURIInfo( clipboardData, Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP )); } catch (e) {} let pasteData; if (keywordAsSent) { // For performance reasons, we don't want to beautify a long string. if (clipboardData.length < 500) { // For only keywords, replace any white spaces including line break // with white space. pasteData = clipboardData.replace(/\s/g, " "); } else { pasteData = clipboardData; } } else if ( fixedURI?.scheme == "data" && !fixedURI.spec.match(/^data:.+;base64,/) ) { // For data url without base64, replace line break with white space. pasteData = clipboardData.replace(/[\r\n]/g, " "); } else { // For normal url or data url having basic64, or if fixup failed, just // remove line breaks. pasteData = clipboardData.replace(/[\r\n]/g, ""); } return lazy.UrlbarUtils.stripUnsafeProtocolOnPaste(pasteData); } /** * Generate a UrlbarQueryContext from the current context. * * @param {object} [options] Optional params * @param {boolean} options.allowAutofill Whether autofill is enabled. * @param {string} options.searchString The string being searched. * @param {object} options.event The event triggering the query. * @returns {UrlbarQueryContext} * The queryContext object. */ #makeQueryContext({ allowAutofill = true, searchString = null, event = null, } = {}) { let options = { allowAutofill, isPrivate: this.isPrivate, maxResults: lazy.UrlbarPrefs.get("maxRichResults"), searchString, userContextId: parseInt( this.window.gBrowser.selectedBrowser.getAttribute("usercontextid") || 0 ), 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) { options.searchMode = this.searchMode; if (this.searchMode.source) { options.sources = [this.searchMode.source]; } } return new lazy.UrlbarQueryContext(options); } _on_scrollend() { this.updateTextOverflow(); } _on_TabSelect() { 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 = Promise.withResolvers(); 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() { 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/plain", href); event.dataTransfer.setData("text/html", `${title}`); 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. let queryContext = this.#makeQueryContext({ searchString: droppedURL }); this.controller.setLastQueryContextCache(queryContext); this.controller.engagementEvent.start(event, queryContext); 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(); this._initStripOnShare(); } /** * @param {string} value A untrimmed address bar input. * @returns {boolean} * `true` if the input doesn't start with a scheme relevant for * schemeless HTTPS-First (http://, https:// and file://). */ #isSchemeless(value) { return ["http://", "https://", "file://"].every( scheme => !value.trim().startsWith(scheme) ); } get #isOpenedPageInBlankTargetLoading() { return ( this.window.gBrowser.selectedBrowser.browsingContext.sessionHistory ?.count === 0 && this.window.gBrowser.selectedBrowser.browsingContext .nonWebControlledBlankURI ); } } /** * 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/plain"); } /** * 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"); this.input.document.l10n.setAttributes(elt, "search-one-offs-add-engine", { 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"); this.input.document.l10n.setAttributes( elt, "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(); } } }