summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderInterventions.sys.mjs')
-rw-r--r--browser/components/urlbar/UrlbarProviderInterventions.sys.mjs835
1 files changed, 835 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs
new file mode 100644
index 0000000000..2c2b13d9cb
--- /dev/null
+++ b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs
@@ -0,0 +1,835 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import {
+ UrlbarProvider,
+ UrlbarUtils,
+} from "resource:///modules/UrlbarUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ NLP: "resource://gre/modules/NLP.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs",
+ Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "appUpdater", () => new lazy.AppUpdater());
+
+// The possible tips to show. These names (except NONE) are used in the names
+// of keys in the `urlbar.tips` keyed scalar telemetry (see telemetry.rst).
+// Don't modify them unless you've considered that. If you do modify them or
+// add new tips, then you are also adding new `urlbar.tips` keys and therefore
+// need an expanded data collection review.
+const TIPS = {
+ NONE: "",
+ CLEAR: "intervention_clear",
+ REFRESH: "intervention_refresh",
+
+ // There's an update available, but the user's pref says we should ask them to
+ // download and apply it.
+ UPDATE_ASK: "intervention_update_ask",
+
+ // The updater is currently checking. We don't actually show a tip for this,
+ // but we use it to tell whether we should wait for the check to complete in
+ // startQuery. See startQuery for details.
+ UPDATE_CHECKING: "intervention_update_checking",
+
+ // The user's browser is up to date, but they triggered the update
+ // intervention. We show this special refresh intervention instead.
+ UPDATE_REFRESH: "intervention_update_refresh",
+
+ // There's an update and it's been downloaded and applied. The user needs to
+ // restart to finish.
+ UPDATE_RESTART: "intervention_update_restart",
+
+ // We can't update the browser or possibly even check for updates for some
+ // reason, so the user should download the latest version from the web.
+ UPDATE_WEB: "intervention_update_web",
+};
+
+const EN_LOCALE_MATCH = /^en(-.*)$/;
+
+// The search "documents" corresponding to each tip type.
+const DOCUMENTS = {
+ clear: [
+ "cache firefox",
+ "clear cache firefox",
+ "clear cache in firefox",
+ "clear cookies firefox",
+ "clear firefox cache",
+ "clear history firefox",
+ "cookies firefox",
+ "delete cookies firefox",
+ "delete history firefox",
+ "firefox cache",
+ "firefox clear cache",
+ "firefox clear cookies",
+ "firefox clear history",
+ "firefox cookie",
+ "firefox cookies",
+ "firefox delete cookies",
+ "firefox delete history",
+ "firefox history",
+ "firefox not loading pages",
+ "history firefox",
+ "how to clear cache",
+ "how to clear history",
+ ],
+ refresh: [
+ "firefox crashing",
+ "firefox keeps crashing",
+ "firefox not responding",
+ "firefox not working",
+ "firefox refresh",
+ "firefox slow",
+ "how to reset firefox",
+ "refresh firefox",
+ "reset firefox",
+ ],
+ update: [
+ "download firefox",
+ "download mozilla",
+ "firefox browser",
+ "firefox download",
+ "firefox for mac",
+ "firefox for windows",
+ "firefox free download",
+ "firefox install",
+ "firefox installer",
+ "firefox latest version",
+ "firefox mac",
+ "firefox quantum",
+ "firefox update",
+ "firefox version",
+ "firefox windows",
+ "get firefox",
+ "how to update firefox",
+ "install firefox",
+ "mozilla download",
+ "mozilla firefox 2019",
+ "mozilla firefox 2020",
+ "mozilla firefox download",
+ "mozilla firefox for mac",
+ "mozilla firefox for windows",
+ "mozilla firefox free download",
+ "mozilla firefox mac",
+ "mozilla firefox update",
+ "mozilla firefox windows",
+ "mozilla update",
+ "update firefox",
+ "update mozilla",
+ "www.firefox.com",
+ ],
+};
+
+// In order to determine whether we should show an update tip, we check for app
+// updates, but only once per this time period.
+const UPDATE_CHECK_PERIOD_MS = 12 * 60 * 60 * 1000; // 12 hours
+
+/**
+ * A node in the QueryScorer's phrase tree.
+ */
+class Node {
+ constructor(word) {
+ this.word = word;
+ this.documents = new Set();
+ this.childrenByWord = new Map();
+ }
+}
+
+/**
+ * This class scores a query string against sets of phrases. To refer to a
+ * single set of phrases, we borrow the term "document" from search engine
+ * terminology. To use this class, first add your documents with `addDocument`,
+ * and then call `score` with a query string. `score` returns a sorted array of
+ * document-score pairs.
+ *
+ * The scoring method is fairly simple and is based on Levenshtein edit
+ * distance. Therefore, lower scores indicate a better match than higher
+ * scores. In summary, a query matches a phrase if the query starts with the
+ * phrase. So a query "firefox update foo bar" matches the phrase "firefox
+ * update" for example. A query matches a document if it matches any phrase in
+ * the document. The query and phrases are compared word for word, and we allow
+ * fuzzy matching by computing the Levenshtein edit distance in each comparison.
+ * The amount of fuzziness allowed is controlled with `distanceThreshold`. If
+ * the distance in a comparison is greater than this threshold, then the phrase
+ * does not match the query. The final score for a document is the minimum edit
+ * distance between its phrases and the query.
+ *
+ * As mentioned, `score` returns a sorted array of document-score pairs. It's
+ * up to you to filter the array to exclude scores above a certain threshold, or
+ * to take the top scorer, etc.
+ */
+export class QueryScorer {
+ /**
+ * @param {object} options
+ * Constructor options.
+ * @param {number} [options.distanceThreshold]
+ * Edit distances no larger than this value are considered matches.
+ * @param {Map} [options.variations]
+ * For convenience, the scorer can augment documents by replacing certain
+ * words with other words and phrases. This mechanism is called variations.
+ * This keys of this map are words that should be replaced, and the values
+ * are the replacement words or phrases. For example, if you add a document
+ * whose only phrase is "firefox update", normally the scorer will register
+ * only this single phrase for the document. However, if you pass the value
+ * `new Map(["firefox", ["fire fox", "fox fire", "foxfire"]])` for this
+ * parameter, it will register 4 total phrases for the document: "fire fox
+ * update", "fox fire update", "foxfire update", and the original "firefox
+ * update".
+ */
+ constructor({ distanceThreshold = 1, variations = new Map() } = {}) {
+ this._distanceThreshold = distanceThreshold;
+ this._variations = variations;
+ this._documents = new Set();
+ this._rootNode = new Node();
+ }
+
+ /**
+ * Adds a document to the scorer.
+ *
+ * @param {object} doc
+ * The document.
+ * @param {string} doc.id
+ * The document's ID.
+ * @param {Array} doc.phrases
+ * The set of phrases in the document. Each phrase should be a string.
+ */
+ addDocument(doc) {
+ this._documents.add(doc);
+
+ for (let phraseStr of doc.phrases) {
+ // Split the phrase and lowercase the words.
+ let phrase = phraseStr
+ .trim()
+ .split(/\s+/)
+ .map(word => word.toLocaleLowerCase());
+
+ // Build a phrase list that contains the original phrase plus its
+ // variations, if any.
+ let phrases = [phrase];
+ for (let [triggerWord, variations] of this._variations) {
+ let index = phrase.indexOf(triggerWord);
+ if (index >= 0) {
+ for (let variation of variations) {
+ let variationPhrase = Array.from(phrase);
+ variationPhrase.splice(index, 1, ...variation.split(/\s+/));
+ phrases.push(variationPhrase);
+ }
+ }
+ }
+
+ // Finally, add the phrases to the phrase tree.
+ for (let completedPhrase of phrases) {
+ this._buildPhraseTree(this._rootNode, doc, completedPhrase, 0);
+ }
+ }
+ }
+
+ /**
+ * Scores a query string against the documents in the scorer.
+ *
+ * @param {string} queryString
+ * The query string to score.
+ * @returns {Array}
+ * An array of objects: { document, score }. Each element in the array is a
+ * a document and its score against the query string. The elements are
+ * ordered by score from low to high. Scores represent edit distance, so
+ * lower scores are better.
+ */
+ score(queryString) {
+ let queryWords = queryString
+ .trim()
+ .split(/\s+/)
+ .map(word => word.toLocaleLowerCase());
+ let minDistanceByDoc = this._traverse({ queryWords });
+ let results = [];
+ for (let doc of this._documents) {
+ let distance = minDistanceByDoc.get(doc);
+ results.push({
+ document: doc,
+ score: distance === undefined ? Infinity : distance,
+ });
+ }
+ results.sort((a, b) => a.score - b.score);
+ return results;
+ }
+
+ /**
+ * Builds the phrase tree based on the current documents.
+ *
+ * The phrase tree lets us efficiently match queries against phrases. Each
+ * path through the tree starting from the root and ending at a leaf
+ * represents a complete phrase in a document (or more than one document, if
+ * the same phrase is present in multiple documents). Each node in the path
+ * represents a word in the phrase. To match a query, we start at the root,
+ * and in the root we look up the query's first word. If the word matches the
+ * first word of any phrase, then the root will have a child node representing
+ * that word, and we move on to the child node. Then we look up the query's
+ * second word in the child node, and so on, until either a lookup fails or we
+ * reach a leaf node.
+ *
+ * @param {Node} node
+ * The current node being visited.
+ * @param {object} doc
+ * The document whose phrases are being added to the tree.
+ * @param {Array} phrase
+ * The phrase to add to the tree.
+ * @param {number} wordIndex
+ * The index in the phrase of the current word.
+ */
+ _buildPhraseTree(node, doc, phrase, wordIndex) {
+ if (phrase.length == wordIndex) {
+ // We're done with this phrase.
+ return;
+ }
+
+ let word = phrase[wordIndex].toLocaleLowerCase();
+ let child = node.childrenByWord.get(word);
+ if (!child) {
+ child = new Node(word);
+ node.childrenByWord.set(word, child);
+ }
+ child.documents.add(doc);
+
+ // Recurse with the next word in the phrase.
+ this._buildPhraseTree(child, doc, phrase, wordIndex + 1);
+ }
+
+ /**
+ * Traverses a path in the phrase tree in order to score a query. See
+ * `_buildPhraseTree` for a description of how this works.
+ *
+ * @param {object} options
+ * Options.
+ * @param {Array} options.queryWords
+ * The query being scored, split into words.
+ * @param {Node} [options.node]
+ * The node currently being visited.
+ * @param {Map} [options.minDistanceByDoc]
+ * Keeps track of the minimum edit distance for each document as the
+ * traversal continues.
+ * @param {number} [options.queryWordsIndex]
+ * The current index in the query words array.
+ * @param {number} [options.phraseDistance]
+ * The total edit distance between the query and the path in the tree that's
+ * been traversed so far.
+ * @returns {Map} minDistanceByDoc
+ */
+ _traverse({
+ queryWords,
+ node = this._rootNode,
+ minDistanceByDoc = new Map(),
+ queryWordsIndex = 0,
+ phraseDistance = 0,
+ } = {}) {
+ if (!node.childrenByWord.size) {
+ // We reached a leaf node. The query has matched a phrase. If the query
+ // and the phrase have the same number of words, then queryWordsIndex ==
+ // queryWords.length also. Otherwise the query contains more words than
+ // the phrase. We still count that as a match.
+ for (let doc of node.documents) {
+ minDistanceByDoc.set(
+ doc,
+ Math.min(
+ phraseDistance,
+ minDistanceByDoc.has(doc) ? minDistanceByDoc.get(doc) : Infinity
+ )
+ );
+ }
+ return minDistanceByDoc;
+ }
+
+ if (queryWordsIndex == queryWords.length) {
+ // We exhausted all the words in the query but have not reached a leaf
+ // node. No match; the query has matched a phrase(s) up to this point,
+ // but it doesn't have enough words.
+ return minDistanceByDoc;
+ }
+
+ // Compare each word in the node to the current query word.
+ let queryWord = queryWords[queryWordsIndex];
+ for (let [childWord, child] of node.childrenByWord) {
+ let distance = lazy.NLP.levenshtein(queryWord, childWord);
+ if (distance <= this._distanceThreshold) {
+ // The word represented by this child node matches the current query
+ // word. Recurse into the child node.
+ this._traverse({
+ node: child,
+ queryWords,
+ queryWordsIndex: queryWordsIndex + 1,
+ phraseDistance: phraseDistance + distance,
+ minDistanceByDoc,
+ });
+ }
+ // Else, the path that continues at the child node can't possibly match
+ // the query, so don't recurse into it.
+ }
+
+ return minDistanceByDoc;
+ }
+}
+
+/**
+ * Gets appropriate values for each tip's payload.
+ *
+ * @param {string} tip a value from the TIPS enum
+ * @returns {object} Properties to include in the payload
+ */
+function getPayloadForTip(tip) {
+ const baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
+ switch (tip) {
+ case TIPS.CLEAR:
+ return {
+ titleL10n: { id: "intervention-clear-data" },
+ buttons: [{ l10n: { id: "intervention-clear-data-confirm" } }],
+ helpUrl: baseURL + "delete-browsing-search-download-history-firefox",
+ };
+ case TIPS.REFRESH:
+ return {
+ titleL10n: { id: "intervention-refresh-profile" },
+ buttons: [{ l10n: { id: "intervention-refresh-profile-confirm" } }],
+ helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings",
+ };
+ case TIPS.UPDATE_ASK:
+ return {
+ titleL10n: { id: "intervention-update-ask" },
+ buttons: [{ l10n: { id: "intervention-update-ask-confirm" } }],
+ helpUrl: baseURL + "update-firefox-latest-release",
+ };
+ case TIPS.UPDATE_REFRESH:
+ return {
+ titleL10n: { id: "intervention-update-refresh" },
+ buttons: [{ l10n: { id: "intervention-update-refresh-confirm" } }],
+ helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings",
+ };
+ case TIPS.UPDATE_RESTART:
+ return {
+ titleL10n: { id: "intervention-update-restart" },
+ buttons: [{ l10n: { id: "intervention-update-restart-confirm" } }],
+ helpUrl: baseURL + "update-firefox-latest-release",
+ };
+ case TIPS.UPDATE_WEB:
+ return {
+ titleL10n: { id: "intervention-update-web" },
+ buttons: [{ l10n: { id: "intervention-update-web-confirm" } }],
+ helpUrl: baseURL + "update-firefox-latest-release",
+ };
+ default:
+ throw new Error("Unknown TIP type.");
+ }
+}
+
+/**
+ * A provider that returns actionable tip results when the user is performing
+ * a search related to those actions.
+ */
+class ProviderInterventions extends UrlbarProvider {
+ constructor() {
+ super();
+ // The tip we should currently show.
+ this.currentTip = TIPS.NONE;
+
+ this.tipsShownInCurrentEngagement = new Set();
+
+ // This object is used to match the user's queries to tips.
+ XPCOMUtils.defineLazyGetter(this, "queryScorer", () => {
+ let queryScorer = new QueryScorer({
+ variations: new Map([
+ // Recognize "fire fox", "fox fire", and "foxfire" as "firefox".
+ ["firefox", ["fire fox", "fox fire", "foxfire"]],
+ // Recognize "mozila" as "mozilla". This will catch common mispellings
+ // "mozila", "mozzila", and "mozzilla" (among others) due to the edit
+ // distance threshold of 1.
+ ["mozilla", ["mozila"]],
+ ]),
+ });
+ for (let [id, phrases] of Object.entries(DOCUMENTS)) {
+ queryScorer.addDocument({ id, phrases });
+ }
+ return queryScorer;
+ });
+ }
+
+ /**
+ * Enum of the types of intervention tips.
+ *
+ * @returns {{ NONE: string; CLEAR: string; REFRESH: string; UPDATE_ASK: string; UPDATE_CHECKING: string; UPDATE_REFRESH: string; UPDATE_RESTART: string; UPDATE_WEB: string; }}
+ */
+ get TIP_TYPE() {
+ return TIPS;
+ }
+
+ /**
+ * Unique name for the provider, used by the context to filter on providers.
+ *
+ * @returns {string}
+ */
+ get name() {
+ return "UrlbarProviderInterventions";
+ }
+
+ /**
+ * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE.
+ *
+ * @returns {UrlbarUtils.PROVIDER_TYPE}
+ */
+ get type() {
+ return UrlbarUtils.PROVIDER_TYPE.PROFILE;
+ }
+
+ /**
+ * 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.
+ */
+ isActive(queryContext) {
+ if (
+ !queryContext.searchString ||
+ queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH ||
+ lazy.UrlbarTokenizer.REGEXP_LIKE_PROTOCOL.test(
+ queryContext.searchString
+ ) ||
+ !EN_LOCALE_MATCH.test(Services.locale.appLocaleAsBCP47) ||
+ !Services.policies.isAllowed("urlbarinterventions")
+ ) {
+ return false;
+ }
+
+ this.currentTip = TIPS.NONE;
+
+ // Get the scores and the top score.
+ let docScores = this.queryScorer.score(queryContext.searchString);
+ let topDocScore = docScores[0];
+
+ // Multiple docs may have the top score, so collect them all.
+ let topDocIDs = new Set();
+ if (topDocScore.score != Infinity) {
+ for (let { score, document } of docScores) {
+ if (score != topDocScore.score) {
+ break;
+ }
+ topDocIDs.add(document.id);
+ }
+ }
+
+ // Determine the tip to show, if any. If there are multiple top-score docs,
+ // prefer them in the following order.
+ if (topDocIDs.has("update")) {
+ this._setCurrentTipFromAppUpdaterStatus();
+ } else if (topDocIDs.has("clear")) {
+ let window = lazy.BrowserWindowTracker.getTopWindow();
+ if (!lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
+ this.currentTip = TIPS.CLEAR;
+ }
+ } else if (topDocIDs.has("refresh")) {
+ // Note that the "update" case can set currentTip to TIPS.REFRESH too.
+ this.currentTip = TIPS.REFRESH;
+ }
+
+ return (
+ this.currentTip != TIPS.NONE &&
+ (this.currentTip != TIPS.REFRESH ||
+ Services.policies.isAllowed("profileRefresh"))
+ );
+ }
+
+ async _setCurrentTipFromAppUpdaterStatus(waitForCheck) {
+ // The update tips depend on the app's update status, so check for updates
+ // now (if we haven't already checked within the update-check period). If
+ // we're running in an xpcshell test, then checkForBrowserUpdate's attempt
+ // to use appUpdater will throw an exception because it won't be available.
+ // In that case, return false to disable the provider.
+ //
+ // This causes synchronous IO within the updater the first time it's called
+ // (at least) so be careful not to do it the first time the urlbar is used.
+ try {
+ this.checkForBrowserUpdate();
+ } catch (ex) {
+ return;
+ }
+
+ // There are several update tips. Figure out which one to show.
+ switch (lazy.appUpdater.status) {
+ case lazy.AppUpdater.STATUS.READY_FOR_RESTART:
+ // Prompt the user to restart.
+ this.currentTip = TIPS.UPDATE_RESTART;
+ break;
+ case lazy.AppUpdater.STATUS.DOWNLOAD_AND_INSTALL:
+ // There's an update available, but the user's pref says we should ask
+ // them to download and apply it.
+ this.currentTip = TIPS.UPDATE_ASK;
+ break;
+ case lazy.AppUpdater.STATUS.NO_UPDATES_FOUND:
+ // We show a special refresh tip when the browser is up to date.
+ this.currentTip = TIPS.UPDATE_REFRESH;
+ break;
+ case lazy.AppUpdater.STATUS.CHECKING:
+ // This will be the case the first time we check. See startQuery for
+ // how this special tip is handled.
+ this.currentTip = TIPS.UPDATE_CHECKING;
+ break;
+ case lazy.AppUpdater.STATUS.NO_UPDATER:
+ case lazy.AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY:
+ // If the updater is disabled at build time or at runtime, either by
+ // policy or because we're in a package, do not select any update tips.
+ this.currentTip = TIPS.NONE;
+ break;
+ default:
+ // Give up and ask the user to download the latest version from the
+ // web. We default to this case when the update is still downloading
+ // because an update doesn't actually occur if the user were to
+ // restart the browser. See bug 1625241.
+ this.currentTip = TIPS.UPDATE_WEB;
+ break;
+ }
+ }
+
+ /**
+ * 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.
+ */
+ async startQuery(queryContext, addCallback) {
+ let instance = this.queryInstance;
+
+ // TIPS.UPDATE_CHECKING is special, and we never actually show a tip that
+ // reflects a "checking" status. Instead it's handled like this. We call
+ // appUpdater.check() to start an update check. If we haven't called it
+ // before, then when it returns, appUpdater.status will be
+ // AppUpdater.STATUS.CHECKING, and it will remain CHECKING until the check
+ // finishes. We can add a listener to appUpdater to be notified when the
+ // check finishes. We don't want to wait for it to finish in isActive
+ // because that would block other providers from adding their results, so
+ // instead we wait here in startQuery. The results from other providers
+ // will be added while we're waiting. When the check finishes, we call
+ // addCallback and add our result. It doesn't matter how long the check
+ // takes because if another query starts, the view is closed, or the user
+ // changes the selection, the query will be canceled.
+ if (this.currentTip == TIPS.UPDATE_CHECKING) {
+ // First check the status because it may have changed between the time
+ // isActive was called and now.
+ this._setCurrentTipFromAppUpdaterStatus();
+ if (this.currentTip == TIPS.UPDATE_CHECKING) {
+ // The updater is still checking, so wait for it to finish.
+ await new Promise(resolve => {
+ this._appUpdaterListener = () => {
+ lazy.appUpdater.removeListener(this._appUpdaterListener);
+ delete this._appUpdaterListener;
+ resolve();
+ };
+ lazy.appUpdater.addListener(this._appUpdaterListener);
+ });
+ if (instance != this.queryInstance) {
+ // The query was canceled before the check finished.
+ return;
+ }
+ // Finally, set the tip from the updater status. The updater should no
+ // longer be checking, but guard against it just in case by returning
+ // early.
+ this._setCurrentTipFromAppUpdaterStatus();
+ if (this.currentTip == TIPS.UPDATE_CHECKING) {
+ return;
+ }
+ }
+ }
+ // At this point, this.currentTip != TIPS.UPDATE_CHECKING because we
+ // returned early above if it was.
+
+ let result = new lazy.UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.TIP,
+ UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
+ {
+ ...getPayloadForTip(this.currentTip),
+ type: this.currentTip,
+ icon: UrlbarUtils.ICON.TIP,
+ helpL10n: {
+ id: lazy.UrlbarPrefs.get("resultMenu")
+ ? "urlbar-result-menu-tip-get-help"
+ : "urlbar-tip-help-icon",
+ },
+ }
+ );
+ result.suggestedIndex = 1;
+ this.tipsShownInCurrentEngagement.add(this.currentTip);
+ addCallback(this, result);
+ }
+
+ /**
+ * Cancels a running query,
+ *
+ * @param {UrlbarQueryContext} queryContext the query context object to cancel
+ * query for.
+ */
+ cancelQuery(queryContext) {
+ // If we're waiting for appUpdater to finish its update check,
+ // this._appUpdaterListener will be defined. We can stop listening now.
+ if (this._appUpdaterListener) {
+ lazy.appUpdater.removeListener(this._appUpdaterListener);
+ delete this._appUpdaterListener;
+ }
+ }
+
+ #pickResult(result, window) {
+ let tip = result.payload.type;
+
+ // Do the tip action.
+ switch (tip) {
+ case TIPS.CLEAR:
+ openClearHistoryDialog(window);
+ break;
+ case TIPS.REFRESH:
+ case TIPS.UPDATE_REFRESH:
+ resetBrowser(window);
+ break;
+ case TIPS.UPDATE_ASK:
+ installBrowserUpdateAndRestart();
+ break;
+ case TIPS.UPDATE_RESTART:
+ restartBrowser();
+ break;
+ case TIPS.UPDATE_WEB:
+ window.gBrowser.selectedTab = window.gBrowser.addWebTab(
+ "https://www.mozilla.org/firefox/new/"
+ );
+ break;
+ }
+ }
+
+ onEngagement(isPrivate, state, queryContext, details, window) {
+ let { result } = details;
+
+ // `selType` is "tip" when the tip's main button is picked. Ignore clicks on
+ // the help command ("tiphelp"), which is handled by UrlbarInput since we
+ // set `helpUrl` on the result payload. Currently there aren't any other
+ // buttons or commands but this will ignore clicks on them too.
+ if (result?.providerName == this.name && details.selType == "tip") {
+ this.#pickResult(result, window);
+ }
+
+ if (["engagement", "abandonment"].includes(state)) {
+ for (let tip of this.tipsShownInCurrentEngagement) {
+ Services.telemetry.keyedScalarAdd("urlbar.tips", `${tip}-shown`, 1);
+ }
+ }
+ this.tipsShownInCurrentEngagement.clear();
+ }
+
+ /**
+ * Checks for app updates.
+ *
+ * @param {boolean} force If false, this only checks for updates if we haven't
+ * already checked within the update-check period. If true, we check
+ * regardless.
+ */
+ checkForBrowserUpdate(force = false) {
+ if (
+ force ||
+ !this._lastUpdateCheckTime ||
+ Date.now() - this._lastUpdateCheckTime >= UPDATE_CHECK_PERIOD_MS
+ ) {
+ this._lastUpdateCheckTime = Date.now();
+ lazy.appUpdater.check();
+ }
+ }
+
+ /**
+ * Resets the provider's app updater state by making a new app updater. This
+ * is intended to be used by tests.
+ */
+ resetAppUpdater() {
+ // Reset only if the object has already been initialized.
+ if (!Object.getOwnPropertyDescriptor(lazy, "appUpdater").get) {
+ lazy.appUpdater = new lazy.AppUpdater();
+ }
+ }
+}
+
+export var UrlbarProviderInterventions = new ProviderInterventions();
+
+/**
+ * Tip callbacks follow.
+ */
+
+function installBrowserUpdateAndRestart() {
+ if (lazy.appUpdater.status != lazy.AppUpdater.STATUS.DOWNLOAD_AND_INSTALL) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ let listener = () => {
+ // Once we call allowUpdateDownload, there are two possible end
+ // states: DOWNLOAD_FAILED and READY_FOR_RESTART.
+ if (
+ lazy.appUpdater.status != lazy.AppUpdater.STATUS.READY_FOR_RESTART &&
+ lazy.appUpdater.status != lazy.AppUpdater.STATUS.DOWNLOAD_FAILED
+ ) {
+ return;
+ }
+ lazy.appUpdater.removeListener(listener);
+ if (lazy.appUpdater.status == lazy.AppUpdater.STATUS.READY_FOR_RESTART) {
+ restartBrowser();
+ }
+ resolve();
+ };
+ lazy.appUpdater.addListener(listener);
+ lazy.appUpdater.allowUpdateDownload();
+ });
+}
+
+function openClearHistoryDialog(window) {
+ // The behaviour of the Clear Recent History dialog in PBM does
+ // not have the expected effect (bug 463607).
+ if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
+ return;
+ }
+ lazy.Sanitizer.showUI(window);
+}
+
+function restartBrowser() {
+ // Notify all windows that an application quit has been requested.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ return;
+ }
+ // If already in safe mode restart in safe mode.
+ if (Services.appinfo.inSafeMode) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ } else {
+ Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ );
+ }
+}
+
+function resetBrowser(window) {
+ if (!lazy.ResetProfile.resetSupported()) {
+ return;
+ }
+ lazy.ResetProfile.openConfirmationDialog(window);
+}