Source code

Revision control

Copy as Markdown

Other Tools

/* 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.
*/
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SearchSuggestionController:
UrlbarProviderInterventions:
UrlbarProviderSearchTips:
});
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_SEARCH_TIP: "heuristicSearchTip",
HEURISTIC_TEST: "heuristicTest",
HEURISTIC_TOKEN_ALIAS_ENGINE: "heuristicTokenAliasEngine",
INPUT_HISTORY: "inputHistory",
OMNIBOX: "extension",
RECENT_SEARCH: "recentSearch",
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.
// WARNING: these providers must be extremely fast, because the urlbar will
// await for them before returning results to the user. In particular it is
// critical to reply quickly to isActive and startQuery.
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 which layout is defined at runtime.
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.
},
// 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,
pref: "shortcuts.bookmarks",
telemetryLabel: "bookmarks",
},
{
source: UrlbarUtils.RESULT_SOURCE.TABS,
restrict: lazy.UrlbarTokenizer.RESTRICT.OPENPAGE,
pref: "shortcuts.tabs",
telemetryLabel: "tabs",
},
{
source: UrlbarUtils.RESULT_SOURCE.HISTORY,
restrict: lazy.UrlbarTokenizer.RESTRICT.HISTORY,
pref: "shortcuts.history",
telemetryLabel: "history",
},
{
source: UrlbarUtils.RESULT_SOURCE.ACTIONS,
restrict: lazy.UrlbarTokenizer.RESTRICT.ACTION,
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 "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 "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 result.providerName == "RecentSearches"
? UrlbarUtils.RESULT_GROUP.RECENT_SEARCH
: UrlbarUtils.RESULT_GROUP.FORM_HISTORY;
}
if (result.payload.tail && !result.isRichSuggestion) {
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.
* @param {bool} includeExposureResultHidden If false and
* `result.exposureResultHidden` is true, zero will be returned since the
* result should be hidden and not take up any rows at all. Otherwise the
* result's true span is returned.
* @returns {number}
* The number of rows the result should span in the autocomplete
* dropdown.
*/
getSpanForResult(result, { includeExposureResultHidden = false } = {}) {
// We know this result will be hidden in the final view so assign it
// a span of zero.
if (result.exposureResultHidden && !includeExposureResultHidden) {
return 0;
}
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;
},
/**
* 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) {
for (;;) {
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;
},
/**
* Add a (url, input) tuple to the input history table that drives adaptive
* results.
*
* @param {string} url The url to add input history for
* @param {string} input The associated search term
*/
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() }
);
});
},
/**
* Remove a (url, input*) tuple from the input history table that drives
* adaptive results.
* Note the input argument is used as a wildcard so any match starting with
* it will also be removed.
*
* @param {string} url The url to add input history for
* @param {string} input The associated search term
*/
async removeInputHistory(url, input) {
await lazy.PlacesUtils.withConnectionWrapper("removeInputHistory", db => {
return db.executeCached(
`
DELETE FROM moz_inputhistory
WHERE input BETWEEN :input AND :input || X'FFFF'
AND place_id =
(SELECT id FROM moz_places 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: parseInt(
window.gBrowser.selectedBrowser.getAttribute("usercontextid") || 0
),
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 console logger.
* Logging level can be controlled through the `browser.urlbar.loglevel`
* preference.
*
* @param {object} [options] Options for the logger.
* @param {string} [options.prefix] Prefix to use for the logged messages.
* @returns {ConsoleInstance} The console logger.
*/
getLogger({ prefix = "" } = {}) {
if (!this._loggers) {
this._loggers = new Map();
}
let logger = this._loggers.get(prefix);
if (!logger) {
logger = console.createInstance({
prefix: `URLBar${prefix ? " - " + prefix : ""}`,
maxLogLevelPref: "browser.urlbar.loglevel",
});
this._loggers.set(prefix, logger);
}
return 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.providerName == "RecentSearches") {
return "recent_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.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";
}
if (result.providerName == "UrlbarProviderClipboard") {
return "clipboard";
}
if (result.providerName == "InputHistory") {
return result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS
? "bookmark_adaptive"
: "history_adaptive";
}
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);
},
/**
* Checks whether a given text has right-to-left direction or not.
*
* @param {string} value The text which should be check for RTL direction.
* @param {Window} window The window where 'value' is going to be displayed.
* @returns {boolean} Returns true if text has right-to-left direction and
* false otherwise.
*/
isTextDirectionRTL(value, window) {
let directionality = window.windowUtils.getDirectionFromText(value);
return directionality == window.windowUtils.DIRECTION_RTL;
},
/**
* Unescape, decode punycode, and trim (both protocol and trailing slash)
* the URL. Use for displaying purposes only!
*
* @param {string} url The url that should be prepared for display.
* @param {object} [options] Preparation options.
* @param {boolean} [options.trimURL] Whether the displayed URL should be
* trimmed or not.
* @returns {string} Prepared url.
*/
prepareUrlForDisplay(url, { trimURL = true } = {}) {
// Some domains are encoded in punycode. The following ensures we display
// the url in utf-8.
try {
url = new URL(url).URI.displaySpec;
} catch {} // In some cases url is not a valid url.
if (url && trimURL && lazy.UrlbarPrefs.get("trimURLs")) {
url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url);
if (url.startsWith("https://")) {
url = url.substring(8);
if (url.startsWith("www.")) {
url = url.substring(4);
}
}
}
return this.unEscapeURIForUI(url);
},
/**
* 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.RECENT_SEARCH: {
return "recent_search";
}
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.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";
}
// While product doesn't use experimental addons anymore, tests may still do
// for testing purposes.
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 "UrlbarProviderQuickSuggestContextualOptIn":
return "fxsuggest_data_sharing_opt_in";
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 result.providerName == "RecentSearches"
? "recent_search"
: "search_history";
}
if (result.payload.suggestion) {
let type = result.payload.trending
? "trending_search"
: "search_suggest";
if (result.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";
}
if (result.providerName === "UrlbarProviderClipboard") {
return "clipboard";
}
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-button")
) {
return element.dataset.key;
}
return "";
},
_getQuickSuggestTelemetryType(result) {
if (result.payload.telemetryType == "weather") {
// Return "weather" without the usual source prefix for consistency with
// the weather result returned by UrlbarProviderWeather.
return "weather";
}
let source = result.payload.source;
if (source == "remote-settings") {
source = "rs";
}
return `${source}_${result.payload.telemetryType}`;
},
/**
* For use when we want to hash a pair of items in a dictionary
*
* @param {string[]} tokens
* list of tokens to join into a string eg "a" "b" "c"
* @returns {string}
* the tokens joined in a string "a|b|c"
*/
tupleString(...tokens) {
return tokens.filter(t => t).join("|");
},
/**
* Creates camelCase versions of snake_case keys in the given object and
* recursively all nested objects. All objects are modified in place and the
* original snake_case keys are preserved.
*
* @param {object} obj
* The object to modify.
* @param {boolean} overwrite
* Controls what happens when a camelCase key is already defined for a
* snake_case key (excluding keys that don't have underscores). If true the
* existing key will be overwritten. If false an error will be thrown.
* @returns {object} The passed-in modified-in-place object.
*/
copySnakeKeysToCamel(obj, overwrite = true) {
for (let [key, value] of Object.entries(obj)) {
// Trim off leading underscores since they'll interfere with the replace.
// We'll tack them back on after.
let match = key.match(/^_+/);
if (match) {
key = key.substring(match[0].length);
}
let camelKey = key.replace(/_([^_])/g, (m, p1) => p1.toUpperCase());
if (match) {
camelKey = match[0] + camelKey;
}
if (!overwrite && camelKey != key && obj.hasOwnProperty(camelKey)) {
throw new Error(
`Can't copy snake_case key '${key}' to camelCase key ` +
`'${camelKey}' because '${camelKey}' is already defined`
);
}
obj[camelKey] = value;
if (value && typeof value == "object") {
this.copySnakeKeysToCamel(value);
}
}
return obj;
},
};
ChromeUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => {
return lazy.PlacesUtils.favicons.defaultFavicon.spec;
});
ChromeUtils.defineLazyGetter(UrlbarUtils, "strings", () => {
return Services.strings.createBundle(
);
});
/**
* 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",
},
isPinned: {
type: "boolean",
},
isSponsored: {
type: "boolean",
},
title: {
type: "string",
},
url: {
type: "string",
},
userContextId: {
type: "number",
},
},
},
[UrlbarUtils.RESULT_TYPE.SEARCH]: {
type: "object",
properties: {
blockL10n: {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
},
args: {
type: "array",
},
},
},
description: {
type: "string",
},
displayUrl: {
type: "string",
},
engine: {
type: "string",
},
helpUrl: {
type: "string",
},
icon: {
type: "string",
},
inPrivateWindow: {
type: "boolean",
},
isBlockable: {
type: "boolean",
},
isPinned: {
type: "boolean",
},
isPrivateEngine: {
type: "boolean",
},
isGeneralPurposeEngine: {
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: "object",
additionalProperties: true,
},
},
},
// l10n { id, args }
bottomTextL10n: {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
},
args: {
type: "object",
additionalProperties: true,
},
},
},
description: {
type: "string",
},
// l10n { id, args }
descriptionL10n: {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
},
args: {
type: "object",
additionalProperties: true,
},
},
},
displayUrl: {
type: "string",
},
dupedHeuristic: {
type: "boolean",
},
fallbackTitle: {
type: "string",
},
// l10n { id, args }
helpL10n: {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
},
args: {
type: "object",
additionalProperties: true,
},
},
},
helpUrl: {
type: "string",
},
icon: {
type: "string",
},
iconBlob: {
type: "object",
},
isBlockable: {
type: "boolean",
},
isManageable: {
type: "boolean",
},
isPinned: {
type: "boolean",
},
isSponsored: {
type: "boolean",
},
originalUrl: {
type: "string",
},
provider: {
type: "string",
},
qsSuggestion: {
type: "string",
},
requestId: {
type: "string",
},
sendAttributionRequest: {
type: "boolean",
},
shouldShowUrl: {
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: "object",
additionalProperties: true,
},
},
},
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: "object",
additionalProperties: true,
},
},
},
// `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", () => true, false],
["providers", v => Array.isArray(v) && v.length],
["searchMode", v => v && typeof v == "object"],
["sources", v => Array.isArray(v) && v.length],
["view", () => 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.lowerCaseSearchString = this.searchString.toLowerCase();
this.trimmedLowerCaseSearchString = this.trimmedSearchString.toLowerCase();
this.userContextId =
lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable(
options.userContextId,
this.isPrivate
) || 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._fixupError && !this._fixupInfo && this.trimmedSearchString) {
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.searchString, 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() {
ChromeUtils.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 {string} _state
* The state of the engagement, one of the following strings:
*
* engagement
* The user picked a result in the urlbar or used paste-and-go.
* abandonment
* The urlbar was blurred (i.e., lost focus).
* @param {UrlbarQueryContext} _queryContext
* The engagement's query context.
* @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.sys.mjs.
* {string} provider
* The name of the provider that produced the picked result.
*
* For "abandonment", only `searchString` is defined.
* @param {UrlbarController} _controller
* The associated controller.
*/
onLegacyEngagement(_state, _queryContext, _details, _controller) {}
/**
* Called before a result from the provider is selected. See `onSelection`
* for details on what that means.
*
* @param {UrlbarResult} _result
* The result that was selected.
* @param {Element} _element
* The element in the result's view that was selected.
* @abstract
*/
onBeforeSelection(_result, _element) {}
/**
* 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 onLegacyEngagement.
* 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 `onLegacyEngagement()` 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 `onLegacyEngagement()`. 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 {
/**
* This can be used to track whether the timer completed.
*/
done = false;
/**
* 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.done = true;
this._timer = null;
resolve();
},
time,
Ci.nsITimer.TYPE_ONE_SHOT
);
this._log(`Started`);
});
let firePromise = new Promise(resolve => {
this.fire = async () => {
this.done = true;
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.
*/
async observe(subject, topic) {
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 = [];
}