diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarUtils.sys.mjs | 2806 |
1 files changed, 2806 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarUtils.sys.mjs b/browser/components/urlbar/UrlbarUtils.sys.mjs new file mode 100644 index 0000000000..433aaf7472 --- /dev/null +++ b/browser/components/urlbar/UrlbarUtils.sys.mjs @@ -0,0 +1,2806 @@ +/* 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/. */ + +/** + * This module exports the UrlbarUtils singleton, which contains constants and + * helper functions that are useful to all components of the urlbar. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarProviderSearchTips: + "resource:///modules/UrlbarProviderSearchTips.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +export var UrlbarUtils = { + // Results are categorized into groups to help the muxer compose them. See + // UrlbarUtils.getResultGroup. Since result groups are stored in result + // groups and result groups are stored in prefs, additions and changes to + // result groups may require adding UI migrations to BrowserGlue. Be careful + // about making trivial changes to existing groups, like renaming them, + // because we don't want to make downgrades unnecessarily hard. + RESULT_GROUP: { + ABOUT_PAGES: "aboutPages", + GENERAL: "general", + GENERAL_PARENT: "generalParent", + FORM_HISTORY: "formHistory", + HEURISTIC_AUTOFILL: "heuristicAutofill", + HEURISTIC_ENGINE_ALIAS: "heuristicEngineAlias", + HEURISTIC_EXTENSION: "heuristicExtension", + HEURISTIC_FALLBACK: "heuristicFallback", + HEURISTIC_BOOKMARK_KEYWORD: "heuristicBookmarkKeyword", + HEURISTIC_HISTORY_URL: "heuristicHistoryUrl", + HEURISTIC_OMNIBOX: "heuristicOmnibox", + HEURISTIC_PRELOADED: "heuristicPreloaded", + HEURISTIC_SEARCH_TIP: "heuristicSearchTip", + HEURISTIC_TEST: "heuristicTest", + HEURISTIC_TOKEN_ALIAS_ENGINE: "heuristicTokenAliasEngine", + INPUT_HISTORY: "inputHistory", + OMNIBOX: "extension", + PRELOADED: "preloaded", + REMOTE_SUGGESTION: "remoteSuggestion", + REMOTE_TAB: "remoteTab", + SUGGESTED_INDEX: "suggestedIndex", + TAIL_SUGGESTION: "tailSuggestion", + }, + + // 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, + ACTIONS: 7, + ADDON: 8, + }, + + // 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://mozapps/skin/extensions/extension.svg", + HISTORY: "chrome://browser/skin/history.svg", + SEARCH_GLASS: "chrome://global/skin/icons/search-glass.svg", + TRENDING: "chrome://global/skin/icons/trending.svg", + TIP: "chrome://global/skin/icons/lightbulb.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, + }, + + // UrlbarProviderPlaces's autocomplete results store their titles and tags + // together in their comments. This separator is used to separate them. + // After bug 1717511, 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+)?$/, + + // 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", + "historymenu", + "other", + "shortcut", + "tabmenu", + "tabtosearch", + "tabtosearch_onboard", + "topsites_newtab", + "topsites_urlbar", + "touchbar", + "typed", + ]), + + // The favicon service stores icons for URLs with the following protocols. + PROTOCOLS_WITH_ICONS: [ + "chrome:", + "moz-extension:", + "about:", + "http:", + "https:", + "ftp:", + ], + + // Valid URI schemes that are considered safe but don't contain + // an authority component (e.g host:port). There are many URI schemes + // that do not contain an authority, but these in particular have + // some likelihood of being entered or bookmarked by a user. + // `file:` is an exceptional case because an authority is optional + PROTOCOLS_WITHOUT_AUTHORITY: [ + "about:", + "data:", + "file:", + "javascript:", + "view-source:", + ], + + // 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: lazy.UrlbarTokenizer.RESTRICT.BOOKMARK, + icon: "chrome://browser/skin/bookmark.svg", + pref: "shortcuts.bookmarks", + telemetryLabel: "bookmarks", + }, + { + source: UrlbarUtils.RESULT_SOURCE.TABS, + restrict: lazy.UrlbarTokenizer.RESTRICT.OPENPAGE, + icon: "chrome://browser/skin/tab.svg", + pref: "shortcuts.tabs", + telemetryLabel: "tabs", + }, + { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + restrict: lazy.UrlbarTokenizer.RESTRICT.HISTORY, + icon: "chrome://browser/skin/history.svg", + pref: "shortcuts.history", + telemetryLabel: "history", + }, + { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + restrict: lazy.UrlbarTokenizer.RESTRICT.ACTION, + icon: "chrome://browser/skin/quickactions.svg", + pref: "shortcuts.quickactions", + telemetryLabel: "actions", + }, + ]; + }, + + /** + * 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 ( + !lazy.PrivateBrowsingUtils.isWindowPrivate(window) && + url && + !url.includes(" ") && + // eslint-disable-next-line no-control-regex + !/[\x00-\x1F]/.test(url) + ) { + lazy.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<{ 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 lazy.PlacesUtils.keywords.fetch(keyword); + } catch (ex) { + console.error(`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 lazy.KeywordUtils.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 i = 0, totalTokensLength = 0; i < tokens.length; i++) { + const { lowerCaseValue: needle } = tokens[i]; + + // 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++; + } + } + } + + totalTokensLength += needle.length; + if (totalTokensLength > UrlbarUtils.MAX_TEXT_LENGTH) { + // Limit the number of tokens to reduce calculate time. + break; + } + } + // 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; + }, + + /** + * Returns the group for a result. + * + * @param {UrlbarResult} result + * The result. + * @returns {UrlbarUtils.RESULT_GROUP} + * The reuslt's group. + */ + getResultGroup(result) { + if (result.group) { + return result.group; + } + + if (result.hasSuggestedIndex && !result.isSuggestedIndexRelativeToGroup) { + return UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX; + } + if (result.heuristic) { + switch (result.providerName) { + case "AliasEngines": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS; + case "Autofill": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL; + case "BookmarkKeywords": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD; + case "HeuristicFallback": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK; + case "Omnibox": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX; + case "PreloadedSites": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_PRELOADED; + case "TokenAliasEngines": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE; + case "UrlbarProviderSearchTips": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP; + case "HistoryUrlHeuristic": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL; + default: + if (result.providerName.startsWith("TestProvider")) { + return UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST; + } + break; + } + if (result.providerType == UrlbarUtils.PROVIDER_TYPE.EXTENSION) { + return UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION; + } + console.error( + "Returning HEURISTIC_FALLBACK for unrecognized heuristic result: ", + result + ); + return UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK; + } + + switch (result.providerName) { + case "AboutPages": + return UrlbarUtils.RESULT_GROUP.ABOUT_PAGES; + case "InputHistory": + return UrlbarUtils.RESULT_GROUP.INPUT_HISTORY; + case "PreloadedSites": + return UrlbarUtils.RESULT_GROUP.PRELOADED; + case "UrlbarProviderQuickSuggest": + return UrlbarUtils.RESULT_GROUP.GENERAL_PARENT; + default: + break; + } + + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) { + return UrlbarUtils.RESULT_GROUP.FORM_HISTORY; + } + if (result.payload.tail) { + return UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION; + } + if (result.payload.suggestion) { + return UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION; + } + break; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return UrlbarUtils.RESULT_GROUP.OMNIBOX; + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + return UrlbarUtils.RESULT_GROUP.REMOTE_TAB; + } + return UrlbarUtils.RESULT_GROUP.GENERAL; + }, + + /** + * 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; + } + } + 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; + } + + // We know this result will be hidden in the final view so assign it + // a span of zero. + if (result.exposureResultHidden) { + return 0; + } + + 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; + }, + + /** + * Gets a default icon for a URL. + * + * @param {string} url + * The URL to get the icon for. + * @returns {string} A URI pointing to an icon for `url`. + */ + getIconForUrl(url) { + if (typeof url == "string") { + return UrlbarUtils.PROTOCOLS_WITH_ICONS.some(p => url.startsWith(p)) + ? "page-icon:" + url + : UrlbarUtils.ICON.DEFAULT; + } + if ( + URL.isInstance(url) && + UrlbarUtils.PROTOCOLS_WITH_ICONS.includes(url.protocol) + ) { + return "page-icon:" + url.href; + } + return UrlbarUtils.ICON.DEFAULT; + }, + + /** + * 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 == lazy.UrlbarTokenizer.RESTRICT.SEARCH) { + return { + engineName: lazy.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. + * + * Note: This is not infallible, if a speculative connection cannot be + * initialized, it will be a no-op. + * + * @param {nsISearchEngine|nsIURI|URL|string} urlOrEngine entity to initiate + * a speculative connection for. + * @param {window} window the window from where the connection is initialized. + */ + setupSpeculativeConnection(urlOrEngine, window) { + if (!lazy.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 (URL.isInstance(urlOrEngine)) { + urlOrEngine = urlOrEngine.href; + } + + try { + let uri = + urlOrEngine instanceof Ci.nsIURI + ? urlOrEngine + : Services.io.newURI(urlOrEngine); + Services.io.speculativeConnect( + uri, + window.gBrowser.contentPrincipal, + null, + false + ); + } 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] + * The options object. + * @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 `#`. + * @param {boolean} options.trimTrailingDot + * Whether to trim a trailing '.'. + * @returns {string[]} [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; + } + if (options.trimTrailingDot && spec.endsWith(".")) { + spec = spec.slice(0, -1); + suffix = "." + suffix; + } + return [spec, prefix, suffix]; + }, + + /** + * Strips a PSL verified public suffix from an hostname. + * + * 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. + * + * @param {string} host A host name. + * @returns {string} Host name without the public suffix. + */ + 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 lazy.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: input.toLowerCase() } + ); + }); + }, + + /** + * 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 + * The string to check. + * @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 zero to two + * slashes (see `UrlbarTokenizer.REGEXP_PREFIX`). If the given string is not + * actually a URL or it has a prefix we don't recognize, 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 with a prefix we recognize, + * then [prefix, remainder]. Otherwise, ["", str]. + */ + stripURLPrefix(str) { + let match = lazy.UrlbarTokenizer.REGEXP_PREFIX.exec(str); + if (!match) { + return ["", str]; + } + let prefix = match[0]; + if (prefix.length < str.length && str[prefix.length] == " ") { + // A space following a prefix: + // e.g. "http:// some search string", "about: some search string" + return ["", str]; + } + if ( + prefix.endsWith(":") && + !UrlbarUtils.PROTOCOLS_WITHOUT_AUTHORITY.includes(prefix.toLowerCase()) + ) { + // Something that looks like a URI scheme but we won't treat as one: + // e.g. "localhost:8888" + return ["", str]; + } + return [prefix, str.substring(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) { + if (!searchString) { + throw new Error("Must pass a non-null search string"); + } + + let options = { + allowAutofill: false, + isPrivate: lazy.PrivateBrowsingUtils.isWindowPrivate(window), + maxResults: 1, + searchString, + userContextId: + window.gBrowser.selectedBrowser.getAttribute("usercontextid"), + prohibitRemoteResults: true, + providers: ["AliasEngines", "BookmarkKeywords", "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 lazy.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 = lazy.Log.repository.getLogger("urlbar"); + this._logger.manageLevelFromPref("browser.urlbar.loglevel"); + this._logger.addAppender( + new lazy.Log.ConsoleAppender(new lazy.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 lazy.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 > + lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) { + return Promise.resolve(); + } + return lazy.FormHistory.update({ + op: "bump", + fieldname: input.formHistoryName, + value, + source, + }); + }, + + /** + * Returns whether a URL can be autofilled from a candidate string. This + * function is specifically designed for origin and up-to-the-next-slash URL + * autofill. It should not be used for other types of autofill. + * + * @param {string} url + * The URL to test + * @param {string} candidate + * The candidate string to test against + * @param {string} checkFragmentOnly + * If want to check the fragment only, pass true. + * Otherwise, check whole url. + * @returns {boolean} true: can autofill + */ + canAutofillURL(url, candidate, checkFragmentOnly = false) { + // If the URL does not start with the candidate, it can't be autofilled. + // The length check is an optimization to short-circuit the `startsWith()`. + if ( + !checkFragmentOnly && + (url.length <= candidate.length || + !url.toLocaleLowerCase().startsWith(candidate.toLocaleLowerCase())) + ) { + return false; + } + + // Create `URL` objects to make the logic below easier. The strings must + // include schemes for this to work. + if (!lazy.UrlbarTokenizer.REGEXP_PREFIX.test(url)) { + url = "http://" + url; + } + if (!lazy.UrlbarTokenizer.REGEXP_PREFIX.test(candidate)) { + candidate = "http://" + candidate; + } + try { + url = new URL(url); + candidate = new URL(candidate); + } catch (e) { + return false; + } + + if (checkFragmentOnly) { + return url.hash.startsWith(candidate.hash); + } + + // For both origin and URL autofill, autofill should stop when the user + // types a trailing slash. This is a fundamental part of autofill's + // up-to-the-next-slash behavior. We handle that here in the else-if branch. + // The length and hash checks in the else-if condition aren't strictly + // necessary -- the else-if branch could simply be an else-branch that + // returns false -- but they mean this function will return true when the + // URL and candidate have the same case-insenstive path and no hash. In + // other words, we allow a URL to autofill itself. + if (!candidate.href.endsWith("/")) { + // The candidate doesn't end in a slash. The URL can't be autofilled if + // its next slash is not at the end. + let nextSlashIndex = url.pathname.indexOf("/", candidate.pathname.length); + if (nextSlashIndex >= 0 && nextSlashIndex != url.pathname.length - 1) { + return false; + } + } else if (url.pathname.length > candidate.pathname.length || url.hash) { + return false; + } + + return url.hash.startsWith(candidate.hash); + }, + + /** + * Extracts a telemetry type from a result, used by scalars and event + * 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. + * + * @param {UrlbarResult} result The result to analyze. + * @returns {string} A string type for telemetry. + */ + 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"; + } + if (result.payload.suggestion) { + let type = result.payload.trending ? "trending" : "searchsuggestion"; + if (result.payload.isRichSuggestion) { + type += "_rich"; + } + return type; + } + return "searchengine"; + case UrlbarUtils.RESULT_TYPE.URL: + if (result.autofill) { + let { type } = result.autofill; + if (!type) { + type = "other"; + console.error( + new Error( + "`result.autofill.type` not set, falling back to 'other'" + ) + ); + } + return `autofill_${type}`; + } + if ( + result.source == UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL && + result.heuristic + ) { + return "visiturl"; + } + if (result.providerName == "UrlbarProviderQuickSuggest") { + // Don't add any more `urlbar.picked` legacy telemetry if possible! + // Return "quicksuggest" here and rely on Glean instead. + switch (result.payload.telemetryType) { + case "top_picks": + return "navigational"; + case "wikipedia": + return "dynamic_wikipedia"; + } + return "quicksuggest"; + } + 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"; + } else if (result.providerName == "quickactions") { + return "quickaction"; + } else if (result.providerName == "Weather") { + return "weather"; + } + return "dynamic"; + } + return "unknown"; + }, + + /** + * Unescape the given uri to use as UI. + * NOTE: If the length of uri is over MAX_TEXT_LENGTH, + * return the given uri as it is. + * + * @param {string} uri will be unescaped. + * @returns {string} Unescaped uri. + */ + unEscapeURIForUI(uri) { + return uri.length > UrlbarUtils.MAX_TEXT_LENGTH + ? uri + : Services.textToSubURI.unEscapeURIForUI(uri); + }, + + /** + * Extracts a group for search engagement telemetry from a result. + * + * @param {UrlbarResult} result The result to analyze. + * @returns {string} Group name as string. + */ + searchEngagementTelemetryGroup(result) { + if (!result) { + return "unknown"; + } + if (result.isBestMatch) { + return "top_pick"; + } + if (result.providerName === "UrlbarProviderTopSites") { + return "top_site"; + } + + switch (this.getResultGroup(result)) { + case UrlbarUtils.RESULT_GROUP.INPUT_HISTORY: { + return "adaptive_history"; + } + case UrlbarUtils.RESULT_GROUP.FORM_HISTORY: { + return "search_history"; + } + case UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION: + case UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION: { + let group = result.payload.trending + ? "trending_search" + : "search_suggest"; + if (result.payload.isRichSuggestion) { + group += "_rich"; + } + return group; + } + case UrlbarUtils.RESULT_GROUP.REMOTE_TAB: { + return "remote_tab"; + } + case UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX: + case UrlbarUtils.RESULT_GROUP.OMNIBOX: { + return "addon"; + } + case UrlbarUtils.RESULT_GROUP.GENERAL: { + return "general"; + } + // Group of UrlbarProviderQuickSuggest is GENERAL_PARENT. + case UrlbarUtils.RESULT_GROUP.GENERAL_PARENT: { + return "suggest"; + } + case UrlbarUtils.RESULT_GROUP.ABOUT_PAGES: { + return "about_page"; + } + case UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX: { + return "suggested_index"; + } + } + + return result.heuristic ? "heuristic" : "unknown"; + }, + + /** + * Extracts a type for search engagement telemetry from a result. + * + * @param {UrlbarResult} result The result to analyze. + * @param {string} selType An optional parameter for the selected type. + * @returns {string} Type as string. + */ + searchEngagementTelemetryType(result, selType = null) { + if (!result) { + return selType === "oneoff" ? "search_shortcut_button" : "input_field"; + } + + if ( + result.providerType === UrlbarUtils.PROVIDER_TYPE.EXTENSION && + result.providerName != "Omnibox" + ) { + return "experimental_addon"; + } + + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.DYNAMIC: + switch (result.providerName) { + case "calculator": + return "calc"; + case "quickactions": + return "action"; + case "TabToSearch": + return "tab_to_search"; + case "UnitConversion": + return "unit"; + case "UrlbarProviderContextualSearch": + return "site_specific_contextual_search"; + case "UrlbarProviderQuickSuggest": + return this._getQuickSuggestTelemetryType(result); + case "Weather": + return "weather"; + } + break; + case UrlbarUtils.RESULT_TYPE.KEYWORD: + return "keyword"; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return "addon"; + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + return "remote_tab"; + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.providerName === "TabToSearch") { + return "tab_to_search"; + } + if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) { + return "search_history"; + } + if (result.payload.suggestion) { + let type = result.payload.trending + ? "trending_search" + : "search_suggest"; + if (result.payload.isRichSuggestion) { + type += "_rich"; + } + return type; + } + return "search_engine"; + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + return "tab"; + case UrlbarUtils.RESULT_TYPE.TIP: + if (result.providerName === "UrlbarProviderInterventions") { + switch (result.payload.type) { + case lazy.UrlbarProviderInterventions.TIP_TYPE.CLEAR: + return "intervention_clear"; + case lazy.UrlbarProviderInterventions.TIP_TYPE.REFRESH: + return "intervention_refresh"; + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_ASK: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_CHECKING: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_REFRESH: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_RESTART: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_WEB: + return "intervention_update"; + default: + return "intervention_unknown"; + } + } + + switch (result.payload.type) { + case lazy.UrlbarProviderSearchTips.TIP_TYPE.ONBOARD: + return "tip_onboard"; + case lazy.UrlbarProviderSearchTips.TIP_TYPE.PERSIST: + return "tip_persist"; + case lazy.UrlbarProviderSearchTips.TIP_TYPE.REDIRECT: + return "tip_redirect"; + case "dismissalAcknowledgment": + return "tip_dismissal_acknowledgment"; + default: + return "tip_unknown"; + } + case UrlbarUtils.RESULT_TYPE.URL: + if ( + result.source === UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL && + result.heuristic + ) { + return "url"; + } + if (result.autofill) { + return `autofill_${result.autofill.type ?? "unknown"}`; + } + if (result.providerName === "UrlbarProviderQuickSuggest") { + return this._getQuickSuggestTelemetryType(result); + } + if (result.providerName === "UrlbarProviderTopSites") { + return "top_site"; + } + return result.source === UrlbarUtils.RESULT_SOURCE.BOOKMARKS + ? "bookmark" + : "history"; + } + + return "unknown"; + }, + + /** + * Extracts a subtype for search engagement telemetry from a result and the picked element. + * + * @param {UrlbarResult} result The result to analyze. + * @param {DOMElement} element The picked view element. Nullable. + * @returns {string} Subtype as string. + */ + searchEngagementTelemetrySubtype(result, element) { + if (!result) { + return ""; + } + + if ( + result.providerName === "quickactions" && + element?.classList.contains("urlbarView-quickaction-row") + ) { + return element.dataset.key; + } + + return ""; + }, + + _getQuickSuggestTelemetryType(result) { + let source = result.payload.source; + if (source == "remote-settings") { + source = "rs"; + } + return `${source}_${result.payload.telemetryType}`; + }, +}; + +XPCOMUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => { + return lazy.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.sys.mjs. + */ +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: { + description: { + type: "string", + }, + displayUrl: { + type: "string", + }, + engine: { + type: "string", + }, + icon: { + type: "string", + }, + inPrivateWindow: { + type: "boolean", + }, + isPinned: { + type: "boolean", + }, + isPrivateEngine: { + type: "boolean", + }, + isGeneralPurposeEngine: { + type: "boolean", + }, + isRichSuggestion: { + 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", + }, + trending: { + type: "boolean", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.URL]: { + type: "object", + required: ["url"], + properties: { + // l10n { id, args } + blockL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + displayUrl: { + type: "string", + }, + dupedHeuristic: { + type: "boolean", + }, + fallbackTitle: { + type: "string", + }, + // l10n { id, args } + helpL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + helpUrl: { + type: "string", + }, + icon: { + type: "string", + }, + isBlockable: { + type: "boolean", + }, + isPinned: { + type: "boolean", + }, + isSponsored: { + type: "boolean", + }, + originalUrl: { + type: "string", + }, + qsSuggestion: { + type: "string", + }, + requestId: { + type: "string", + }, + sendAttributionRequest: { + type: "boolean", + }, + source: { + type: "string", + }, + sponsoredAdvertiser: { + type: "string", + }, + sponsoredBlockId: { + type: "number", + }, + sponsoredClickUrl: { + type: "string", + }, + sponsoredIabCategory: { + type: "string", + }, + sponsoredImpressionUrl: { + type: "string", + }, + sponsoredTileId: { + type: "number", + }, + subtype: { + type: "string", + }, + tags: { + type: "array", + items: { + type: "string", + }, + }, + telemetryType: { + type: "string", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + urlTimestampIndex: { + type: "number", + }, + }, + }, + [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: { + blockL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + content: { + type: "string", + }, + icon: { + type: "string", + }, + isBlockable: { + type: "boolean", + }, + keyword: { + type: "string", + }, + title: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.REMOTE_TAB]: { + type: "object", + required: ["device", "url", "lastUsed"], + properties: { + device: { + type: "string", + }, + displayUrl: { + type: "string", + }, + icon: { + type: "string", + }, + lastUsed: { + type: "number", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.TIP]: { + type: "object", + required: ["type"], + properties: { + buttons: { + type: "array", + items: { + type: "object", + required: ["l10n"], + properties: { + l10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + url: { + type: "string", + }, + }, + }, + }, + // TODO: This is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + buttonText: { + type: "string", + }, + // TODO: This is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + buttonUrl: { + type: "string", + }, + // l10n { id, args } + helpL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + helpUrl: { + type: "string", + }, + icon: { + type: "string", + }, + // TODO: This is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + text: { + type: "string", + }, + // l10n { id, args } + titleL10n: { + 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: [ + "dismissalAcknowledgment", + "extension", + "intervention_clear", + "intervention_refresh", + "intervention_update_ask", + "intervention_update_refresh", + "intervention_update_restart", + "intervention_update_web", + "searchTip_onboard", + "searchTip_persist", + "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. + */ +export 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.prohibitRemoteResults] + * This provides a short-circuit override for `context.allowRemoteResults`. + * If it's false, then `allowRemoteResults` will do its usual checks to + * determine whether remote results are allowed. If it's true, then + * `allowRemoteResults` will immediately return false. Defaults to false. + * @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 [ + ["currentPage", v => typeof v == "string" && !!v.length], + ["formHistoryName", v => typeof v == "string" && !!v.length], + ["prohibitRemoteResults", v => true, false], + ["providers", v => Array.isArray(v) && v.length], + ["searchMode", v => v && typeof v == "object"], + ["sources", v => Array.isArray(v) && v.length], + ["view", v => true], + ]) { + 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. + * + * @returns {{ href: string; isSearch: boolean; }?} + */ + 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, + scheme: info.fixedURI.scheme, + }; + } 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. + * + * @returns {any?} + */ + get fixupError() { + if (!this.fixupInfo) { + return this._fixupError; + } + + return null; + } + + /** + * Returns whether results from remote services are generally allowed for the + * context. Callers can impose further restrictions as appropriate, but + * typically they should not fetch remote results if this returns false. + * + * @param {string} [searchString] + * Usually this is just the context's search string, but if you need to + * fetch remote results based on a modified version, you can pass it here. + * @param {boolean} [allowEmptySearchString] + * Whether to check for the minimum length of the search string. + * @returns {boolean} + * Whether remote results are allowed. + */ + allowRemoteResults( + searchString = this.searchString, + allowEmptySearchString = false + ) { + if (this.prohibitRemoteResults) { + return false; + } + + // We're unlikely to get useful remote results for a single character. + if (searchString.length < 2 && !allowEmptySearchString) { + return false; + } + + // Disallow remote results if only an origin is typed to avoid disclosing + // sites the user visits. This also catches partially typed origins, like + // mozilla.o, because the fixup check below can't validate them. + if ( + this.tokens.length == 1 && + this.tokens[0].type == lazy.UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN + ) { + return false; + } + + // Disallow remote results for strings containing tokens that look like URIs + // to avoid disclosing information about networks and passwords. + if (this.fixupInfo?.href && !this.fixupInfo?.isSearch) { + return false; + } + + // Allow remote results. + return true; + } +} + +/** + * Base class for a muxer. + * The muxer scope is to sort a given list of results. + */ +export 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. + */ +export 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) { + console.error(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. + * + * Note: Extended classes should return a Promise resolved when the provider + * is done searching AND returning results. + * + * @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. + * @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 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 the following strings: + * + * start + * A new query has started in the urlbar. + * engagement + * The user picked a result in the urlbar or used paste-and-go. + * abandonment + * The urlbar was blurred (i.e., lost focus). + * discard + * This doesn't correspond to a user action, but it means that the + * urlbar has discarded the engagement for some reason, and the + * `onEngagement` implementation should ignore it. + * + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This object is non-empty only when `state` is "engagement" or + * "abandonment", and it describes the search string and engaged result. + * + * For "engagement", it has the following properties: + * + * {UrlbarResult} result + * The engaged result. If a result itself was picked, this will be it. + * If an element related to a result was picked (like a button or menu + * command), this will be that result. This property will be present if + * and only if `state` == "engagement", so it can be used to quickly + * tell when the user engaged with a result. + * {Element} element + * The picked DOM element. + * {boolean} isSessionOngoing + * True if the search session remains ongoing or false if the engagement + * ended it. Typically picking a result ends the session but not always. + * Picking a button or menu command may not end the session; dismissals + * do not, for example. + * {string} searchString + * The search string for the engagement's query. + * {number} selIndex + * The index of the picked result. + * {string} selType + * The type of the selected result. See TelemetryEvent.record() in + * UrlbarController.jsm. + * {string} provider + * The name of the provider that produced the picked result. + * + * For "abandonment", only `searchString` is defined. + */ + onEngagement(isPrivate, state, queryContext, details) {} + + /** + * 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 onEngagement. 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; + } + + /** + * Gets the list of commands that should be shown in the result menu for a + * given result from the provider. All commands returned by this method should + * be handled by implementing `onEngagement()` with the possible exception of + * commands automatically handled by the urlbar, like "help". + * + * @param {UrlbarResult} result + * The menu will be shown for this result. + * @returns {Array} + * If the result doesn't have any commands, this should return null. + * Otherwise it should return an array of command objects that look like: + * `{ name, l10n, children}` + * + * {string} name + * The name of the command. Must be specified unless `children` is + * present. When a command is picked, its name will be passed as + * `details.selType` to `onEngagement()`. The special name "separator" + * will create a menu separator. + * {object} l10n + * An l10n object for the command's label: `{ id, args }` + * Must be specified unless `name` is "separator". + * {array} children + * If specified, a submenu will be created with the given child commands. + * Each object in the array must be a command object. + */ + getResultCommands(result) { + return null; + } + + /** + * Defines whether the view should defer user selection events while waiting + * for the first result from this provider. + * + * Note: UrlbarEventBufferer has a timeout after which user events will be + * processed regardless. + * + * @returns {boolean} Whether the provider wants to defer user selection + * events. + * @see {@link UrlbarEventBufferer} + */ + 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; + */ +export 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 = "<anonymous timer>", + 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); + this._timer = null; + resolve(); + }, + time, + Ci.nsITimer.TYPE_ONE_SHOT + ); + this._log(`Started`); + }); + + let firePromise = new Promise(resolve => { + this.fire = async () => { + if (this._timer) { + if (!this._canceled) { + this._log(`Skipped`); + } + this._timer.cancel(); + this._timer = null; + resolve(); + } + await this.promise; + }; + }); + + this.promise = Promise.race([timerPromise, firePromise]).then(() => { + // If we've been canceled, don't call back. + if (callback && !this._canceled) { + 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. + */ + async cancel() { + if (this._timer) { + this._log(`Canceling`); + this._canceled = true; + } + await this.fire(); + } + + _log(msg, isError = false) { + let line = `SkippableTimer :: ${this.name} :: ${msg}`; + if (this.logger) { + this.logger.debug(line); + } + if (isError) { + console.error(line); + } + } +} + +/** + * This class implements a cache for l10n strings. Cached strings can be + * accessed synchronously, avoiding the asynchronicity of `data-l10n-id` and + * `document.l10n.setAttributes`, which can lead to text pop-in and flickering + * as strings are fetched from Fluent. (`document.l10n.formatValueSync` is also + * sync but should not be used since it may perform sync I/O.) + * + * Values stored and returned by the cache are JS objects similar to + * `L10nMessage` objects, not bare strings. This allows the cache to store not + * only l10n strings with bare values but also strings that define attributes + * (e.g., ".label = My label value"). See `get` for details. + */ +export class L10nCache { + /** + * @param {Localization} l10n + * A `Localization` object like `document.l10n`. This class keeps a weak + * reference to this object, so the caller or something else must hold onto + * it. + */ + constructor(l10n) { + this.l10n = Cu.getWeakReference(l10n); + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + Services.obs.addObserver(this, "intl:app-locales-changed", true); + } + + /** + * Gets a cached l10n message. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true if the string was cached using a key that excluded arguments. + * @returns {object} + * The message object or undefined if it's not cached. The message object is + * similar to `L10nMessage` (defined in Localization.webidl) but its + * attributes are stored differently for convenience. It looks like this: + * + * { value, attributes } + * + * The properties are: + * + * {string} value + * The bare value of the string. If the string does not have a bare + * value (i.e., it has only attributes), this will be null. + * {object} attributes + * A mapping from attribute names to their values. If the string doesn't + * have any attributes, this will be null. + * + * For example, if we cache these strings from an ftl file: + * + * foo = Foo's value + * bar = + * .label = Bar's label value + * + * Then: + * + * cache.get("foo") + * // => { value: "Foo's value", attributes: null } + * cache.get("bar") + * // => { value: null, attributes: { label: "Bar's label value" }} + */ + get({ id, args = undefined, excludeArgsFromCacheKey = false }) { + return this._messagesByKey.get( + this._key({ id, args, excludeArgsFromCacheKey }) + ); + } + + /** + * Fetches a string from Fluent and caches it. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true to cache the string using a key that excludes the arguments. + * The string will be cached only by its ID. This is useful if the string is + * used only once in the UI, its arguments vary, and it's acceptable to + * fetch and show a cached value with old arguments until the string is + * relocalized with new arguments. + */ + async add({ id, args = undefined, excludeArgsFromCacheKey = false }) { + let l10n = this.l10n.get(); + if (!l10n) { + return; + } + let messages = await l10n.formatMessages([{ id, args }]); + if (!messages?.length) { + console.error( + "l10n.formatMessages returned an unexpected value for ID: ", + id + ); + return; + } + let message = messages[0]; + if (message.attributes) { + // Convert `attributes` from an array of `{ name, value }` objects to one + // object mapping names to values. + message.attributes = message.attributes.reduce( + (valuesByName, { name, value }) => { + valuesByName[name] = value; + return valuesByName; + }, + {} + ); + } + this._messagesByKey.set( + this._key({ id, args, excludeArgsFromCacheKey }), + message + ); + } + + /** + * Fetches and caches a string if it's not already cached. This is just a + * slight optimization over `add` that avoids calling into Fluent + * unnecessarily. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true to cache the string using a key that excludes the arguments. + * The string will be cached only by its ID. See `add()` for more. + */ + async ensure({ id, args = undefined, excludeArgsFromCacheKey = false }) { + // Always re-cache if `excludeArgsFromCacheKey` is true. The values in + // `args` may be different from the values in the cached string. + if (excludeArgsFromCacheKey || !this.get({ id, args })) { + await this.add({ id, args, excludeArgsFromCacheKey }); + } + } + + /** + * Fetches and caches strings that aren't already cached. + * + * @param {Array} objects + * An array of objects as passed to `ensure()`. + */ + async ensureAll(objects) { + let promises = []; + for (let obj of objects) { + promises.push(this.ensure(obj)); + } + await Promise.all(promises); + } + + /** + * Removes a cached string. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true if the string was cached using a key that excludes the + * arguments. If true, `args` is ignored. + */ + delete({ id, args = undefined, excludeArgsFromCacheKey = false }) { + this._messagesByKey.delete( + this._key({ id, args, excludeArgsFromCacheKey }) + ); + } + + /** + * Removes all cached strings. + */ + clear() { + this._messagesByKey.clear(); + } + + /** + * Returns the number of cached messages. + * + * @returns {number} + */ + size() { + return this._messagesByKey.size; + } + + /** + * Observer method from Services.obs.addObserver. + * + * @param {nsISupports} subject + * The subject of the notification. + * @param {string} topic + * The topic of the notification. + * @param {string} data + * The data attached to the notification. + */ + async observe(subject, topic, data) { + switch (topic) { + case "intl:app-locales-changed": { + await this.l10n.ready; + this.clear(); + break; + } + } + } + + /** + * Cache keys => cached message objects + */ + _messagesByKey = new Map(); + + /** + * Returns a cache key for a string in `_messagesByKey`. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true to exclude the arguments from the key and include only the ID. + * @returns {string} + * The cache key. + */ + _key({ id, args, excludeArgsFromCacheKey }) { + // Keys are `id` plus JSON'ed `args` values. `JSON.stringify` doesn't + // guarantee a particular ordering of object properties, so instead of + // stringifying `args` as is, sort its entries by key and then pull out the + // values. The final key is a JSON'ed array of `id` concatenated with the + // sorted-by-key `args` values. + args = (!excludeArgsFromCacheKey && args) || []; + let argValues = Object.entries(args) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([_, value]) => value); + let parts = [id].concat(argValues); + return JSON.stringify(parts); + } +} + +/** + * This class provides a way of serializing access to a resource. It's a queue + * of callbacks (or "tasks") where each callback is called and awaited in order, + * one at a time. + */ +export class TaskQueue { + /** + * @returns {Promise} + * Resolves when the queue becomes empty. If the queue is already empty, + * then a resolved promise is returned. + */ + get emptyPromise() { + if (!this._queue.length) { + return Promise.resolve(); + } + return new Promise(resolve => this._emptyCallbacks.push(resolve)); + } + + /** + * Adds a callback function to the task queue. The callback will be called + * after all other callbacks before it in the queue. This method returns a + * promise that will be resolved after awaiting the callback. The promise will + * be resolved with the value returned by the callback. + * + * @param {Function} callback + * The function to queue. + * @returns {Promise} + * Resolved after the task queue calls and awaits `callback`. It will be + * resolved with the value returned by `callback`. If `callback` throws an + * error, then it will be rejected with the error. + */ + queue(callback) { + return new Promise((resolve, reject) => { + this._queue.push({ callback, resolve, reject }); + if (this._queue.length == 1) { + this._doNextTask(); + } + }); + } + + /** + * Calls the next function in the task queue and recurses until the queue is + * empty. Once empty, all empty callback functions are called. + */ + async _doNextTask() { + if (!this._queue.length) { + while (this._emptyCallbacks.length) { + let callback = this._emptyCallbacks.shift(); + callback(); + } + return; + } + + let { callback, resolve, reject } = this._queue[0]; + try { + let value = await callback(); + resolve(value); + } catch (error) { + console.error(error); + reject(error); + } + this._queue.shift(); + this._doNextTask(); + } + + _queue = []; + _emptyCallbacks = []; +} |