/* 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/. */ "use strict"; /** * This module exports the UrlbarUtils singleton, which contains constants and * helper functions that are useful to all components of the urlbar. */ var EXPORTED_SYMBOLS = [ "UrlbarMuxer", "UrlbarProvider", "UrlbarQueryContext", "UrlbarUtils", "SkippableTimer", ]; const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); XPCOMUtils.defineLazyModuleGetters(this, { BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", FormHistory: "resource://gre/modules/FormHistory.jsm", Log: "resource://gre/modules/Log.jsm", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", PlacesUIUtils: "resource:///modules/PlacesUIUtils.jsm", PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", SearchSuggestionController: "resource://gre/modules/SearchSuggestionController.jsm", Services: "resource://gre/modules/Services.jsm", UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", }); var UrlbarUtils = { // Extensions are allowed to add suggestions if they have registered a keyword // with the omnibox API. This is the maximum number of suggestions an extension // is allowed to add for a given search string. // This value includes the heuristic result. MAXIMUM_ALLOWED_EXTENSION_MATCHES: 6, // This is used by UnifiedComplete, the new implementation will use // PROVIDER_TYPE and RESULT_TYPE RESULT_GROUP: { HEURISTIC: "heuristic", GENERAL: "general", SUGGESTION: "suggestion", EXTENSION: "extension", }, // Defines provider types. PROVIDER_TYPE: { // Should be executed immediately, because it returns heuristic results // that must be handed to the user asap. HEURISTIC: 1, // Can be delayed, contains results coming from the session or the profile. PROFILE: 2, // Can be delayed, contains results coming from the network. NETWORK: 3, // Can be delayed, contains results coming from unknown sources. EXTENSION: 4, }, // Defines UrlbarResult types. RESULT_TYPE: { // An open tab. TAB_SWITCH: 1, // A search suggestion or engine. SEARCH: 2, // A common url/title tuple, may be a bookmark with tags. URL: 3, // A bookmark keyword. KEYWORD: 4, // A WebExtension Omnibox result. OMNIBOX: 5, // A tab from another synced device. REMOTE_TAB: 6, // An actionable message to help the user with their query. TIP: 7, // A type of result created at runtime, for example by an extension. DYNAMIC: 8, // When you add a new type, also add its schema to // UrlbarUtils.RESULT_PAYLOAD_SCHEMA below. Also consider checking if // consumers of "urlbar-user-start-navigation" need updating. }, // This defines the source of results returned by a provider. Each provider // can return results from more than one source. This is used by the // ProvidersManager to decide which providers must be queried and which // results can be returned. // If you add new source types, consider checking if consumers of // "urlbar-user-start-navigation" need update as well. RESULT_SOURCE: { BOOKMARKS: 1, HISTORY: 2, SEARCH: 3, TABS: 4, OTHER_LOCAL: 5, OTHER_NETWORK: 6, }, /** * Buckets used for logging telemetry to the FX_URLBAR_SELECTED_RESULT_TYPE_2 * histogram. */ SELECTED_RESULT_TYPES: { autofill: 0, bookmark: 1, history: 2, keyword: 3, searchengine: 4, searchsuggestion: 5, switchtab: 6, tag: 7, visiturl: 8, remotetab: 9, extension: 10, "preloaded-top-site": 11, // This is currently unused. tip: 12, topsite: 13, formhistory: 14, dynamic: 15, tabtosearch: 16, // n_values = 32, so you'll need to create a new histogram if you need more. }, // This defines icon locations that are commonly used in the UI. ICON: { // DEFAULT is defined lazily so it doesn't eagerly initialize PlacesUtils. EXTENSION: "chrome://browser/content/extension.svg", HISTORY: "chrome://browser/skin/history.svg", SEARCH_GLASS: "chrome://browser/skin/search-glass.svg", SEARCH_GLASS_INVERTED: "chrome://browser/skin/search-glass-inverted.svg", TIP: "chrome://browser/skin/tip.svg", }, // The number of results by which Page Up/Down move the selection. PAGE_UP_DOWN_DELTA: 5, // IME composition states. COMPOSITION: { NONE: 1, COMPOSING: 2, COMMIT: 3, CANCELED: 4, }, // Limit the length of titles and URLs we display so layout doesn't spend too // much time building text runs. MAX_TEXT_LENGTH: 255, // Whether a result should be highlighted up to the point the user has typed // or after that point. HIGHLIGHT: { NONE: 0, TYPED: 1, SUGGESTED: 2, }, // UnifiedComplete's autocomplete results store their titles and tags together // in their comments. This separator is used to separate them. When we // rewrite UnifiedComplete for quantumbar, we should stop using this old hack // and store titles and tags separately. It's important that this be a // character that no title would ever have. We use \x1F, the non-printable // unit separator. TITLE_TAGS_SEPARATOR: "\x1F", // Regex matching single word hosts with an optional port; no spaces, auth or // path-like chars are admitted. REGEXP_SINGLE_WORD: /^[^\s@:/?#]+(:\d+)?$/, // Names of engines shipped in Firefox that search the web in general. These // are used to update the input placeholder when entering search mode. // TODO (Bug 1658661): Don't hardcode this list; store search engine category // information someplace better. WEB_ENGINE_NAMES: new Set([ "百度", // Baidu "百度搜索", // "Baidu Search", the name of Baidu's OpenSearch engine. "Bing", "DuckDuckGo", "Ecosia", "Google", "Qwant", "Yandex", "Яндекс", // Yandex, non-EN ]), // Valid entry points for search mode. If adding a value here, please update // telemetry documentation and Scalars.yaml. SEARCH_MODE_ENTRY: new Set([ "bookmarkmenu", "handoff", "keywordoffer", "oneoff", "other", "shortcut", "tabmenu", "tabtosearch", "tabtosearch_onboard", "topsites_newtab", "topsites_urlbar", "touchbar", "typed", ]), // Search mode objects corresponding to the local shortcuts in the view, in // order they appear. Pref names are relative to the `browser.urlbar` branch. get LOCAL_SEARCH_MODES() { return [ { source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, restrict: UrlbarTokenizer.RESTRICT.BOOKMARK, icon: "chrome://browser/skin/bookmark.svg", pref: "shortcuts.bookmarks", }, { source: UrlbarUtils.RESULT_SOURCE.TABS, restrict: UrlbarTokenizer.RESTRICT.OPENPAGE, icon: "chrome://browser/skin/tab.svg", pref: "shortcuts.tabs", }, { source: UrlbarUtils.RESULT_SOURCE.HISTORY, restrict: UrlbarTokenizer.RESTRICT.HISTORY, icon: "chrome://browser/skin/history.svg", pref: "shortcuts.history", }, ]; }, /** * Returns the payload schema for the given type of result. * * @param {number} type One of the UrlbarUtils.RESULT_TYPE values. * @returns {object} The schema for the given type. */ getPayloadSchema(type) { return UrlbarUtils.RESULT_PAYLOAD_SCHEMA[type]; }, /** * Adds a url to history as long as it isn't in a private browsing window, * and it is valid. * * @param {string} url The url to add to history. * @param {nsIDomWindow} window The window from where the url is being added. */ addToUrlbarHistory(url, window) { if ( !PrivateBrowsingUtils.isWindowPrivate(window) && url && !url.includes(" ") && // eslint-disable-next-line no-control-regex !/[\x00-\x1F]/.test(url) ) { PlacesUIUtils.markPageAsTyped(url); } }, /** * Given a string, will generate a more appropriate urlbar value if a Places * keyword or a search alias is found at the beginning of it. * * @param {string} url * A string that may begin with a keyword or an alias. * * @returns {Promise} * @resolves { url, postData, mayInheritPrincipal }. If it's not possible * to discern a keyword or an alias, url will be the input string. */ async getShortcutOrURIAndPostData(url) { let mayInheritPrincipal = false; let postData = null; // Split on the first whitespace. let [keyword, param = ""] = url.trim().split(/\s(.+)/, 2); if (!keyword) { return { url, postData, mayInheritPrincipal }; } let engine = await Services.search.getEngineByAlias(keyword); if (engine) { let submission = engine.getSubmission(param, null, "keyword"); return { url: submission.uri.spec, postData: submission.postData, mayInheritPrincipal, }; } // A corrupt Places database could make this throw, breaking navigation // from the location bar. let entry = null; try { entry = await PlacesUtils.keywords.fetch(keyword); } catch (ex) { Cu.reportError(`Unable to fetch Places keyword "${keyword}": ${ex}`); } if (!entry || !entry.url) { // This is not a Places keyword. return { url, postData, mayInheritPrincipal }; } try { [url, postData] = await BrowserUtils.parseUrlAndPostData( entry.url.href, entry.postData, param ); if (postData) { postData = this.getPostDataStream(postData); } // Since this URL came from a bookmark, it's safe to let it inherit the // current document's principal. mayInheritPrincipal = true; } catch (ex) { // It was not possible to bind the param, just use the original url value. } return { url, postData, mayInheritPrincipal }; }, /** * Returns an input stream wrapper for the given post data. * * @param {string} postDataString The string to wrap. * @param {string} [type] The encoding type. * @returns {nsIInputStream} An input stream of the wrapped post data. */ getPostDataStream( postDataString, type = "application/x-www-form-urlencoded" ) { let dataStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( Ci.nsIStringInputStream ); dataStream.data = postDataString; let mimeStream = Cc[ "@mozilla.org/network/mime-input-stream;1" ].createInstance(Ci.nsIMIMEInputStream); mimeStream.addHeader("Content-Type", type); mimeStream.setData(dataStream); return mimeStream.QueryInterface(Ci.nsIInputStream); }, _compareIgnoringDiacritics: null, /** * Returns a list of all the token substring matches in a string. Matching is * case insensitive. Each match in the returned list is a tuple: [matchIndex, * matchLength]. matchIndex is the index in the string of the match, and * matchLength is the length of the match. * * @param {array} tokens The tokens to search for. * @param {string} str The string to match against. * @param {boolean} highlightType * One of the HIGHLIGHT values: * TYPED: match ranges matching the tokens; or * SUGGESTED: match ranges for words not matching the tokens and the * endings of words that start with a token. * @returns {array} An array: [ * [matchIndex_0, matchLength_0], * [matchIndex_1, matchLength_1], * ... * [matchIndex_n, matchLength_n] * ]. * The array is sorted by match indexes ascending. */ getTokenMatches(tokens, str, highlightType) { // Only search a portion of the string, because not more than a certain // amount of characters are visible in the UI, matching over what is visible // would be expensive and pointless. str = str.substring(0, UrlbarUtils.MAX_TEXT_LENGTH).toLocaleLowerCase(); // To generate non-overlapping ranges, we start from a 0-filled array with // the same length of the string, and use it as a collision marker, setting // 1 where the text should be highlighted. let hits = new Array(str.length).fill( highlightType == this.HIGHLIGHT.SUGGESTED ? 1 : 0 ); let compareIgnoringDiacritics; for (let { lowerCaseValue: needle } of tokens) { // Ideally we should never hit the empty token case, but just in case // the `needle` check protects us from an infinite loop. if (!needle) { continue; } let index = 0; let found = false; // First try a diacritic-sensitive search. for (;;) { index = str.indexOf(needle, index); if (index < 0) { break; } if (highlightType == UrlbarUtils.HIGHLIGHT.SUGGESTED) { // We de-emphasize the match only if it's preceded by a space, thus // it's a perfect match or the beginning of a longer word. let previousSpaceIndex = str.lastIndexOf(" ", index) + 1; if (index != previousSpaceIndex) { index += needle.length; // We found the token but we won't de-emphasize it, because it's not // after a word boundary. found = true; continue; } } hits.fill( highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1, index, index + needle.length ); index += needle.length; found = true; } // If that fails to match anything, try a (computationally intensive) // diacritic-insensitive search. if (!found) { if (!compareIgnoringDiacritics) { if (!this._compareIgnoringDiacritics) { // Diacritic insensitivity in the search engine follows a set of // general rules that are not locale-dependent, so use a generic // English collator for highlighting matching words instead of a // collator for the user's particular locale. this._compareIgnoringDiacritics = new Intl.Collator("en", { sensitivity: "base", }).compare; } compareIgnoringDiacritics = this._compareIgnoringDiacritics; } index = 0; while (index < str.length) { let hay = str.substr(index, needle.length); if (compareIgnoringDiacritics(needle, hay) === 0) { if (highlightType == UrlbarUtils.HIGHLIGHT.SUGGESTED) { let previousSpaceIndex = str.lastIndexOf(" ", index) + 1; if (index != previousSpaceIndex) { index += needle.length; continue; } } hits.fill( highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1, index, index + needle.length ); index += needle.length; } else { index++; } } } } // Starting from the collision array, generate [start, len] tuples // representing the ranges to be highlighted. let ranges = []; for (let index = hits.indexOf(1); index >= 0 && index < hits.length; ) { let len = 0; // eslint-disable-next-line no-empty for (let j = index; j < hits.length && hits[j]; ++j, ++len) {} ranges.push([index, len]); // Move to the next 1. index = hits.indexOf(1, index + len); } return ranges; }, /** * Extracts an url from a result, if possible. * @param {UrlbarResult} result The result to extract from. * @returns {object} a {url, postData} object, or null if a url can't be built * from this result. */ getUrlFromResult(result) { switch (result.type) { case UrlbarUtils.RESULT_TYPE.URL: case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: return { url: result.payload.url, postData: null }; case UrlbarUtils.RESULT_TYPE.KEYWORD: return { url: result.payload.url, postData: result.payload.postData ? this.getPostDataStream(result.payload.postData) : null, }; case UrlbarUtils.RESULT_TYPE.SEARCH: { if (result.payload.engine) { const engine = Services.search.getEngineByName(result.payload.engine); let [url, postData] = this.getSearchQueryUrl( engine, result.payload.suggestion || result.payload.query ); return { url, postData }; } break; } case UrlbarUtils.RESULT_TYPE.TIP: { // Return the button URL. Consumers must check payload.helpUrl // themselves if they need the tip's help link. return { url: result.payload.buttonUrl, postData: null }; } } return { url: null, postData: null }; }, /** * Get the url to load for the search query. * * @param {nsISearchEngine} engine * The engine to generate the query for. * @param {string} query * The query string to search for. * @returns {array} * Returns an array containing the query url (string) and the * post data (object). */ getSearchQueryUrl(engine, query) { let submission = engine.getSubmission(query, null, "keyword"); return [submission.uri.spec, submission.postData]; }, // Ranks a URL prefix from 3 - 0 with the following preferences: // https:// > https://www. > http:// > http://www. // Higher is better for the purposes of deduping URLs. // Returns -1 if the prefix does not match any of the above. getPrefixRank(prefix) { return ["http://www.", "http://", "https://www.", "https://"].indexOf( prefix ); }, /** * Get the number of rows a result should span in the autocomplete dropdown. * * @param {UrlbarResult} result The result being created. * @returns {number} * The number of rows the result should span in the autocomplete * dropdown. */ getSpanForResult(result) { if (result.resultSpan) { return result.resultSpan; } switch (result.type) { case UrlbarUtils.RESULT_TYPE.URL: case UrlbarUtils.RESULT_TYPE.BOOKMARKS: case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: case UrlbarUtils.RESULT_TYPE.KEYWORD: case UrlbarUtils.RESULT_TYPE.SEARCH: case UrlbarUtils.RESULT_TYPE.OMNIBOX: return 1; case UrlbarUtils.RESULT_TYPE.TIP: return 3; } return 1; }, /** * Returns a search mode object if a token should enter search mode when * typed. This does not handle engine aliases. * * @param {UrlbarUtils.RESTRICT} token * A restriction token to convert to search mode. * @returns {object} * A search mode object. Null if search mode should not be entered. See * setSearchMode documentation for details. */ searchModeForToken(token) { if (token == UrlbarTokenizer.RESTRICT.SEARCH) { return { engineName: UrlbarSearchUtils.getDefaultEngine(this.isPrivate).name, }; } let mode = UrlbarUtils.LOCAL_SEARCH_MODES.find(m => m.restrict == token); if (!mode) { return null; } // Return a copy so callers don't modify the object in LOCAL_SEARCH_MODES. return { ...mode }; }, /** * Tries to initiate a speculative connection to a given url. * @param {nsISearchEngine|nsIURI|URL|string} urlOrEngine entity to initiate * a speculative connection for. * @param {window} window the window from where the connection is initialized. * @note This is not infallible, if a speculative connection cannot be * initialized, it will be a no-op. */ setupSpeculativeConnection(urlOrEngine, window) { if (!UrlbarPrefs.get("speculativeConnect.enabled")) { return; } if (urlOrEngine instanceof Ci.nsISearchEngine) { try { urlOrEngine.speculativeConnect({ window, originAttributes: window.gBrowser.contentPrincipal.originAttributes, }); } catch (ex) { // Can't setup speculative connection for this url, just ignore it. } return; } if (urlOrEngine instanceof URL) { urlOrEngine = urlOrEngine.href; } try { let uri = urlOrEngine instanceof Ci.nsIURI ? urlOrEngine : Services.io.newURI(urlOrEngine); Services.io.speculativeConnect( uri, window.gBrowser.contentPrincipal, null ); } catch (ex) { // Can't setup speculative connection for this url, just ignore it. } }, /** * Strips parts of a URL defined in `options`. * * @param {string} spec * The text to modify. * @param {object} options * @param {boolean} options.stripHttp * Whether to strip http. * @param {boolean} options.stripHttps * Whether to strip https. * @param {boolean} options.stripWww * Whether to strip `www.`. * @param {boolean} options.trimSlash * Whether to trim the trailing slash. * @param {boolean} options.trimEmptyQuery * Whether to trim a trailing `?`. * @param {boolean} options.trimEmptyHash * Whether to trim a trailing `#`. * @returns {array} [modified, prefix, suffix] * modified: {string} The modified spec. * prefix: {string} The parts stripped from the prefix, if any. * suffix: {string} The parts trimmed from the suffix, if any. */ stripPrefixAndTrim(spec, options = {}) { let prefix = ""; let suffix = ""; if (options.stripHttp && spec.startsWith("http://")) { spec = spec.slice(7); prefix = "http://"; } else if (options.stripHttps && spec.startsWith("https://")) { spec = spec.slice(8); prefix = "https://"; } if (options.stripWww && spec.startsWith("www.")) { spec = spec.slice(4); prefix += "www."; } if (options.trimEmptyHash && spec.endsWith("#")) { spec = spec.slice(0, -1); suffix = "#" + suffix; } if (options.trimEmptyQuery && spec.endsWith("?")) { spec = spec.slice(0, -1); suffix = "?" + suffix; } if (options.trimSlash && spec.endsWith("/")) { spec = spec.slice(0, -1); suffix = "/" + suffix; } return [spec, prefix, suffix]; }, /** * Strips a PSL verified public suffix from an hostname. * @param {string} host A host name. * @returns {string} Host name without the public suffix. * @note Because stripping the full suffix requires to verify it against the * Public Suffix List, this call is not the cheapest, and thus it should * not be used in hot paths. */ stripPublicSuffixFromHost(host) { try { return host.substring( 0, host.length - Services.eTLD.getKnownPublicSuffixFromHost(host).length ); } catch (ex) { if (ex.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS) { throw ex; } } return host; }, /** * Used to filter out the javascript protocol from URIs, since we don't * support LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL for those. * @param {string} pasteData The data to check for javacript protocol. * @returns {string} The modified paste data. */ stripUnsafeProtocolOnPaste(pasteData) { while (true) { let scheme = ""; try { scheme = Services.io.extractScheme(pasteData); } catch (ex) { // If it throws, this is not a javascript scheme. } if (scheme != "javascript") { break; } pasteData = pasteData.substring(pasteData.indexOf(":") + 1); } return pasteData; }, async addToInputHistory(url, input) { await PlacesUtils.withConnectionWrapper("addToInputHistory", db => { // use_count will asymptotically approach the max of 10. return db.executeCached( ` INSERT OR REPLACE INTO moz_inputhistory SELECT h.id, IFNULL(i.input, :input), IFNULL(i.use_count, 0) * .9 + 1 FROM moz_places h LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input WHERE url_hash = hash(:url) AND url = :url `, { url, input } ); }); }, /** * Whether the passed-in input event is paste event. * @param {DOMEvent} event an input DOM event. * @returns {boolean} Whether the event is a paste event. */ isPasteEvent(event) { return ( event.inputType && (event.inputType.startsWith("insertFromPaste") || event.inputType == "insertFromYank") ); }, /** * Given a string, checks if it looks like a single word host, not containing * spaces nor dots (apart from a possible trailing one). * @note This matching should stay in sync with the related code in * URIFixup::KeywordURIFixup * @param {string} value * @returns {boolean} Whether the value looks like a single word host. */ looksLikeSingleWordHost(value) { let str = value.trim(); return this.REGEXP_SINGLE_WORD.test(str); }, /** * Returns the portion of a string starting at the index where another string * begins. * * @param {string} sourceStr * The string to search within. * @param {string} targetStr * The string to search for. * @returns {string} The substring within sourceStr starting at targetStr, or * the empty string if targetStr does not occur in sourceStr. */ substringAt(sourceStr, targetStr) { let index = sourceStr.indexOf(targetStr); return index < 0 ? "" : sourceStr.substr(index); }, /** * Returns the portion of a string starting at the index where another string * ends. * * @param {string} sourceStr * The string to search within. * @param {string} targetStr * The string to search for. * @returns {string} The substring within sourceStr where targetStr ends, or * the empty string if targetStr does not occur in sourceStr. */ substringAfter(sourceStr, targetStr) { let index = sourceStr.indexOf(targetStr); return index < 0 ? "" : sourceStr.substr(index + targetStr.length); }, /** * Strips the prefix from a URL and returns the prefix and the remainder of the * URL. "Prefix" is defined to be the scheme and colon, plus, if present, two * slashes. If the given string is not actually a URL, then an empty prefix and * the string itself is returned. * * @param {string} str The possible URL to strip. * @returns {array} If `str` is a URL, then [prefix, remainder]. Otherwise, ["", str]. */ stripURLPrefix(str) { const REGEXP_STRIP_PREFIX = /^[a-z]+:(?:\/){0,2}/i; let match = REGEXP_STRIP_PREFIX.exec(str); if (!match) { return ["", str]; } let prefix = match[0]; if (prefix.length < str.length && str[prefix.length] == " ") { return ["", str]; } return [prefix, str.substr(prefix.length)]; }, /** * Runs a search for the given string, and returns the heuristic result. * @param {string} searchString The string to search for. * @param {nsIDOMWindow} window The window requesting it. * @returns {UrlbarResult} an heuristic result. */ async getHeuristicResultFor( searchString, window = BrowserWindowTracker.getTopWindow() ) { if (!searchString) { throw new Error("Must pass a non-null search string"); } let options = { allowAutofill: false, isPrivate: PrivateBrowsingUtils.isWindowPrivate(window), maxResults: 1, searchString, userContextId: window.gBrowser.selectedBrowser.getAttribute( "usercontextid" ), allowSearchSuggestions: false, providers: ["UnifiedComplete", "HeuristicFallback"], }; if (window.gURLBar.searchMode) { let searchMode = window.gURLBar.searchMode; options.searchMode = searchMode; if (searchMode.source) { options.sources = [searchMode.source]; } } let context = new UrlbarQueryContext(options); await UrlbarProvidersManager.startQuery(context); if (!context.heuristicResult) { throw new Error("There should always be an heuristic result"); } return context.heuristicResult; }, /** * Creates a logger. * Logging level can be controlled through browser.urlbar.loglevel. * @param {string} [prefix] Prefix to use for the logged messages, "::" will * be appended automatically to the prefix. * @returns {object} The logger. */ getLogger({ prefix = "" } = {}) { if (!this._logger) { this._logger = Log.repository.getLogger("urlbar"); this._logger.manageLevelFromPref("browser.urlbar.loglevel"); this._logger.addAppender( new Log.ConsoleAppender(new Log.BasicFormatter()) ); } if (prefix) { // This is not an early return because it is necessary to invoke getLogger // at least once before getLoggerWithMessagePrefix; it replaces a // method of the original logger, rather than using an actual Proxy. return Log.repository.getLoggerWithMessagePrefix("urlbar", prefix + "::"); } return this._logger; }, /** * Returns the name of a result source. The name is the lowercase name of the * corresponding property in the RESULT_SOURCE object. * * @param {string} source A UrlbarUtils.RESULT_SOURCE value. * @returns {string} The token's name, a lowercased name in the RESULT_SOURCE * object. */ getResultSourceName(source) { if (!this._resultSourceNamesBySource) { this._resultSourceNamesBySource = new Map(); for (let [name, src] of Object.entries(this.RESULT_SOURCE)) { this._resultSourceNamesBySource.set(src, name.toLowerCase()); } } return this._resultSourceNamesBySource.get(source); }, /** * Add the search to form history. This also updates any existing form * history for the search. * @param {UrlbarInput} input The UrlbarInput object requesting the addition. * @param {string} value The value to add. * @param {string} [source] The source of the addition, usually * the name of the engine the search was made with. * @returns {Promise} resolved once the operation is complete */ addToFormHistory(input, value, source) { // If the user types a search engine alias without a search string, // we have an empty search string and we can't bump it. // We also don't want to add history in private browsing mode. // Finally we don't want to store extremely long strings that would not be // particularly useful to the user. if ( !value || input.isPrivate || value.length > SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH ) { return Promise.resolve(); } return new Promise((resolve, reject) => { FormHistory.update( { op: "bump", fieldname: input.formHistoryName, value, source, }, { handleError: reject, handleCompletion: resolve, } ); }); }, /** * Extracts a telemetry type from a result, used by scalars and event * telemetry. * * @param {UrlbarResult} result The result to analyze. * @returns {string} A string type for telemetry. * @note New types should be added to Scalars.yaml under the urlbar.picked * category and documented in the in-tree documentation. A data-review * is always necessary. */ telemetryTypeFromResult(result) { if (!result) { return "unknown"; } switch (result.type) { case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: return "switchtab"; case UrlbarUtils.RESULT_TYPE.SEARCH: if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) { return "formhistory"; } if (result.providerName == "TabToSearch") { return "tabtosearch"; } return result.payload.suggestion ? "searchsuggestion" : "searchengine"; case UrlbarUtils.RESULT_TYPE.URL: if (result.autofill) { return "autofill"; } if ( result.source == UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL && result.heuristic ) { return "visiturl"; } return result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS ? "bookmark" : "history"; case UrlbarUtils.RESULT_TYPE.KEYWORD: return "keyword"; case UrlbarUtils.RESULT_TYPE.OMNIBOX: return "extension"; case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: return "remotetab"; case UrlbarUtils.RESULT_TYPE.TIP: return "tip"; case UrlbarUtils.RESULT_TYPE.DYNAMIC: if (result.providerName == "TabToSearch") { // This is the onboarding result. return "tabtosearch"; } return "dynamic"; } return "unknown"; }, }; XPCOMUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => { return PlacesUtils.favicons.defaultFavicon.spec; }); XPCOMUtils.defineLazyGetter(UrlbarUtils, "strings", () => { return Services.strings.createBundle( "chrome://global/locale/autocomplete.properties" ); }); /** * Payload JSON schemas for each result type. Payloads are validated against * these schemas using JsonSchemaValidator.jsm. */ UrlbarUtils.RESULT_PAYLOAD_SCHEMA = { [UrlbarUtils.RESULT_TYPE.TAB_SWITCH]: { type: "object", required: ["url"], properties: { displayUrl: { type: "string", }, icon: { type: "string", }, title: { type: "string", }, url: { type: "string", }, userContextId: { type: "number", }, }, }, [UrlbarUtils.RESULT_TYPE.SEARCH]: { type: "object", properties: { displayUrl: { type: "string", }, engine: { type: "string", }, icon: { type: "string", }, inPrivateWindow: { type: "boolean", }, isPinned: { type: "boolean", }, isPrivateEngine: { type: "boolean", }, keyword: { type: "string", }, lowerCaseSuggestion: { type: "string", }, providesSearchMode: { type: "boolean", }, query: { type: "string", }, satisfiesAutofillThreshold: { type: "boolean", }, suggestion: { type: "string", }, tail: { type: "string", }, tailPrefix: { type: "string", }, tailOffsetIndex: { type: "number", }, title: { type: "string", }, url: { type: "string", }, }, }, [UrlbarUtils.RESULT_TYPE.URL]: { type: "object", required: ["url"], properties: { displayUrl: { type: "string", }, icon: { type: "string", }, isPinned: { type: "boolean", }, isSponsored: { type: "boolean", }, sendAttributionRequest: { type: "boolean", }, tags: { type: "array", items: { type: "string", }, }, title: { type: "string", }, url: { type: "string", }, }, }, [UrlbarUtils.RESULT_TYPE.KEYWORD]: { type: "object", required: ["keyword", "url"], properties: { displayUrl: { type: "string", }, icon: { type: "string", }, input: { type: "string", }, keyword: { type: "string", }, postData: { type: "string", }, title: { type: "string", }, url: { type: "string", }, }, }, [UrlbarUtils.RESULT_TYPE.OMNIBOX]: { type: "object", required: ["keyword"], properties: { content: { type: "string", }, icon: { type: "string", }, keyword: { type: "string", }, title: { type: "string", }, }, }, [UrlbarUtils.RESULT_TYPE.REMOTE_TAB]: { type: "object", required: ["device", "url"], properties: { device: { type: "string", }, displayUrl: { type: "string", }, icon: { type: "string", }, title: { type: "string", }, url: { type: "string", }, }, }, [UrlbarUtils.RESULT_TYPE.TIP]: { type: "object", required: ["type"], properties: { // Prefer `buttonTextData` if your string is translated. This is for // untranslated strings. buttonText: { type: "string", }, // l10n { id, args } buttonTextData: { type: "object", required: ["id"], properties: { id: { type: "string", }, args: { type: "array", }, }, }, buttonUrl: { type: "string", }, helpUrl: { type: "string", }, icon: { type: "string", }, // Prefer `text` if your string is translated. This is for untranslated // strings. text: { type: "string", }, // l10n { id, args } textData: { type: "object", required: ["id"], properties: { id: { type: "string", }, args: { type: "array", }, }, }, // `type` is used in the names of keys in the `urlbar.tips` keyed scalar // telemetry (see telemetry.rst). If you add a new type, then you are // also adding new `urlbar.tips` keys and therefore need an expanded data // collection review. type: { type: "string", enum: [ "extension", "intervention_clear", "intervention_refresh", "intervention_update_ask", "intervention_update_refresh", "intervention_update_restart", "intervention_update_web", "searchTip_onboard", "searchTip_redirect", "test", // for tests only ], }, }, }, [UrlbarUtils.RESULT_TYPE.DYNAMIC]: { type: "object", required: ["dynamicType"], properties: { dynamicType: { type: "string", }, // If `shouldNavigate` is `true` and the payload contains a `url` // property, when the result is selected the browser will navigate to the // `url`. shouldNavigate: { type: "boolean", }, }, }, }; /** * UrlbarQueryContext defines a user's autocomplete input from within the urlbar. * It supplements it with details of how the search results should be obtained * and what they consist of. */ class UrlbarQueryContext { /** * Constructs the UrlbarQueryContext instance. * * @param {object} options * The initial options for UrlbarQueryContext. * @param {string} options.searchString * The string the user entered in autocomplete. Could be the empty string * in the case of the user opening the popup via the mouse. * @param {boolean} options.isPrivate * Set to true if this query was started from a private browsing window. * @param {number} options.maxResults * The maximum number of results that will be displayed for this query. * @param {boolean} options.allowAutofill * Whether or not to allow providers to include autofill results. * @param {number} options.userContextId * The container id where this context was generated, if any. * @param {array} [options.sources] * A list of acceptable UrlbarUtils.RESULT_SOURCE for the context. * @param {object} [options.searchMode] * The input's current search mode. See UrlbarInput.setSearchMode for a * description. * @param {boolean} [options.allowSearchSuggestions] * Whether to allow search suggestions. This is a veto, meaning that when * false, suggestions will not be fetched, but when true, some other * condition may still prohibit suggestions, like private browsing mode. * Defaults to true. * @param {string} [options.formHistoryName] * The name under which the local form history is registered. */ constructor(options = {}) { this._checkRequiredOptions(options, [ "allowAutofill", "isPrivate", "maxResults", "searchString", ]); if (isNaN(parseInt(options.maxResults))) { throw new Error( `Invalid maxResults property provided to UrlbarQueryContext` ); } // Manage optional properties of options. for (let [prop, checkFn, defaultValue] of [ ["allowSearchSuggestions", v => true, true], ["currentPage", v => typeof v == "string" && !!v.length], ["formHistoryName", v => typeof v == "string" && !!v.length], ["providers", v => Array.isArray(v) && v.length], ["searchMode", v => v && typeof v == "object"], ["sources", v => Array.isArray(v) && v.length], ]) { if (prop in options) { if (!checkFn(options[prop])) { throw new Error(`Invalid value for option "${prop}"`); } this[prop] = options[prop]; } else if (defaultValue !== undefined) { this[prop] = defaultValue; } } this.lastResultCount = 0; // Note that Set is not serializable through JSON, so these may not be // easily shared with add-ons. this.pendingHeuristicProviders = new Set(); this.deferUserSelectionProviders = new Set(); this.trimmedSearchString = this.searchString.trim(); this.userContextId = options.userContextId || Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID; } /** * Checks the required options, saving them as it goes. * * @param {object} options The options object to check. * @param {array} optionNames The names of the options to check for. * @throws {Error} Throws if there is a missing option. */ _checkRequiredOptions(options, optionNames) { for (let optionName of optionNames) { if (!(optionName in options)) { throw new Error( `Missing or empty ${optionName} provided to UrlbarQueryContext` ); } this[optionName] = options[optionName]; } } /** * Caches and returns fixup info from URIFixup for the current search string. * Only returns a subset of the properties from URIFixup. This is both to * reduce the memory footprint of UrlbarQueryContexts and to keep them * serializable so they can be sent to extensions. */ get fixupInfo() { if (this.trimmedSearchString && !this._fixupInfo) { 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 { let info = Services.uriFixup.getFixupURIInfo( this.trimmedSearchString, flags ); this._fixupInfo = { href: info.fixedURI.spec, isSearch: !!info.keywordAsSent, }; } catch (ex) { this._fixupError = ex.result; } } return this._fixupInfo || null; } /** * Returns the error that was thrown when fixupInfo was fetched, if any. If * fixupInfo has not yet been fetched for this queryContext, it is fetched * here. */ get fixupError() { if (!this.fixupInfo) { return this._fixupError; } return null; } } /** * Base class for a muxer. * The muxer scope is to sort a given list of results. */ class UrlbarMuxer { /** * Unique name for the muxer, used by the context to sort results. * Not using a unique name will cause the newest registration to win. * @abstract */ get name() { return "UrlbarMuxerBase"; } /** * Sorts queryContext results in-place. * @param {UrlbarQueryContext} queryContext the context to sort results for. * @abstract */ sort(queryContext) { throw new Error("Trying to access the base class, must be overridden"); } } /** * Base class for a provider. * The provider scope is to query a datasource and return results from it. */ class UrlbarProvider { constructor() { XPCOMUtils.defineLazyGetter(this, "logger", () => UrlbarUtils.getLogger({ prefix: `Provider.${this.name}` }) ); } /** * Unique name for the provider, used by the context to filter on providers. * Not using a unique name will cause the newest registration to win. * @abstract */ get name() { return "UrlbarProviderBase"; } /** * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. * @abstract */ get type() { throw new Error("Trying to access the base class, must be overridden"); } /** * Calls a method on the provider in a try-catch block and reports any error. * Unlike most other provider methods, `tryMethod` is not intended to be * overridden. * * @param {string} methodName The name of the method to call. * @param {*} args The method arguments. * @returns {*} The return value of the method, or undefined if the method * throws an error. * @abstract */ tryMethod(methodName, ...args) { try { return this[methodName](...args); } catch (ex) { Cu.reportError(ex); } return undefined; } /** * Whether this provider should be invoked for the given context. * If this method returns false, the providers manager won't start a query * with this provider, to save on resources. * @param {UrlbarQueryContext} queryContext The query context object * @returns {boolean} Whether this provider should be invoked for the search. * @abstract */ isActive(queryContext) { throw new Error("Trying to access the base class, must be overridden"); } /** * Gets the provider's priority. Priorities are numeric values starting at * zero and increasing in value. Smaller values are lower priorities, and * larger values are higher priorities. For a given query, `startQuery` is * called on only the active and highest-priority providers. * @param {UrlbarQueryContext} queryContext The query context object * @returns {number} The provider's priority for the given query. * @abstract */ getPriority(queryContext) { // By default, all providers share the lowest priority. return 0; } /** * Starts querying. * @param {UrlbarQueryContext} queryContext The query context object * @param {function} addCallback Callback invoked by the provider to add a new * result. A UrlbarResult should be passed to it. * @note Extended classes should return a Promise resolved when the provider * is done searching AND returning results. * @abstract */ startQuery(queryContext, addCallback) { throw new Error("Trying to access the base class, must be overridden"); } /** * Cancels a running query, * @param {UrlbarQueryContext} queryContext the query context object to cancel * query for. * @abstract */ cancelQuery(queryContext) { // Override this with your clean-up on cancel code. } /** * Called when a result from the provider is picked, but currently only for * tip and dynamic results. The provider should handle the pick. For tip * results, this is called only when the tip's payload doesn't have a URL. * For dynamic results, this is called when any selectable element in the * result's view is picked. * * @param {UrlbarResult} result * The result that was picked. * @param {Element} element * The element in the result's view that was picked. * @abstract */ pickResult(result, element) {} /** * Called when the user starts and ends an engagement with the urlbar. * * @param {boolean} isPrivate True if the engagement is in a private context. * @param {string} state The state of the engagement, one of: start, * engagement, abandonment, discard. */ onEngagement(isPrivate, state) {} /** * Called when a result from the provider is selected. "Selected" refers to * the user highlighing the result with the arrow keys/Tab, before it is * picked. onSelection is also called when a user clicks a result. In the * event of a click, onSelection is called just before pickResult. Note that * this is called when heuristic results are pre-selected. * * @param {UrlbarResult} result * The result that was selected. * @param {Element} element * The element in the result's view that was selected. * @abstract */ onSelection(result, element) {} /** * This is called only for dynamic result types, when the urlbar view updates * the view of one of the results of the provider. It should return an object * describing the view update that looks like this: * * { * nodeNameFoo: { * attributes: { * someAttribute: someValue, * }, * style: { * someStyleProperty: someValue, * }, * l10n: { * id: someL10nId, * args: someL10nArgs, * }, * textContent: "some text content", * }, * nodeNameBar: { * ... * }, * nodeNameBaz: { * ... * }, * } * * The object should contain a property for each element to update in the * dynamic result type view. The names of these properties are the names * declared in the view template of the dynamic result type; see * UrlbarView.addDynamicViewTemplate(). The values are similar to the nested * objects specified in the view template but not quite the same; see below. * For each property, the element in the view subtree with the specified name * is updated according to the object in the property's value. If an * element's name is not specified, then it will not be updated and will * retain its current state. * * @param {UrlbarResult} result * The result whose view will be updated. * @param {Map} idsByName * A Map from an element's name, as defined by the provider; to its ID in * the DOM, as defined by the browser. The browser manages element IDs for * dynamic results to prevent collisions. However, a provider may need to * access the IDs of the elements created for its results. For example, to * set various `aria` attributes. * @returns {object} * A view update object as described above. The names of properties are the * the names of elements declared in the view template. The values of * properties are objects that describe how to update each element, and * these objects may include the following properties, all of which are * optional: * * {object} [attributes] * A mapping from attribute names to values. Each name-value pair results * in an attribute being added to the element. The `id` attribute is * reserved and cannot be set by the provider. * {object} [style] * A plain object that can be used to add inline styles to the element, * like `display: none`. `element.style` is updated for each name-value * pair in this object. * {object} [l10n] * An { id, args } object that will be passed to * document.l10n.setAttributes(). * {string} [textContent] * A string that will be set as `element.textContent`. */ getViewUpdate(result, idsByName) { return null; } /** * Defines whether the view should defer user selection events while waiting * for the first result from this provider. * * @returns {boolean} Whether the provider wants to defer user selection * events. * @see UrlbarEventBufferer * @note UrlbarEventBufferer has a timeout after which user events will be * processed regardless. */ get deferUserSelection() { return false; } } /** * Class used to create a timer that can be manually fired, to immediately * invoke the callback, or canceled, as necessary. * Examples: * let timer = new SkippableTimer(); * // Invokes the callback immediately without waiting for the delay. * await timer.fire(); * // Cancel the timer, the callback won't be invoked. * await timer.cancel(); * // Wait for the timer to have elapsed. * await timer.promise; */ class SkippableTimer { /** * Creates a skippable timer for the given callback and time. * @param {object} options An object that configures the timer * @param {string} options.name The name of the timer, logged when necessary * @param {function} options.callback To be invoked when requested * @param {number} options.time A delay in milliseconds to wait for * @param {boolean} options.reportErrorOnTimeout If true and the timer times * out, an error will be logged with Cu.reportError * @param {logger} options.logger An optional logger */ constructor({ name = "", callback = null, time = 0, reportErrorOnTimeout = false, logger = null, } = {}) { this.name = name; this.logger = logger; let timerPromise = new Promise(resolve => { this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this._timer.initWithCallback( () => { this._log(`Timed out!`, reportErrorOnTimeout); resolve(); }, time, Ci.nsITimer.TYPE_ONE_SHOT ); this._log(`Started`); }); let firePromise = new Promise(resolve => { this.fire = () => { this._log(`Skipped`); resolve(); return this.promise; }; }); this.promise = Promise.race([timerPromise, firePromise]).then(() => { // If we've been canceled, don't call back. if (this._timer && callback) { callback(); } }); } /** * Allows to cancel the timer and the callback won't be invoked. * It is not strictly necessary to await for this, the promise can just be * used to ensure all the internal work is complete. * @returns {promise} Resolved once all the cancelation work is complete. */ cancel() { this._log(`Canceling`); this._timer.cancel(); delete this._timer; return this.fire(); } _log(msg, isError = false) { let line = `SkippableTimer :: ${this.name} :: ${msg}`; if (this.logger) { this.logger.debug(line); } if (isError) { Cu.reportError(line); } } }