diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarUtils.jsm | 1758 |
1 files changed, 1758 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarUtils.jsm b/browser/components/urlbar/UrlbarUtils.jsm new file mode 100644 index 0000000000..b8fd02a5fe --- /dev/null +++ b/browser/components/urlbar/UrlbarUtils.jsm @@ -0,0 +1,1758 @@ +/* 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 = "<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); + 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); + } + } +} |