1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
8 } from "resource:///modules/UrlbarUtils.sys.mjs";
12 ChromeUtils.defineESModuleGetters(lazy, {
13 AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
14 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
15 NLP: "resource://gre/modules/NLP.sys.mjs",
16 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
17 ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs",
18 Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
19 UrlbarProviderGlobalActions:
20 "resource:///modules/UrlbarProviderGlobalActions.sys.mjs",
21 UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
22 UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
25 ChromeUtils.defineLazyGetter(lazy, "appUpdater", () => new lazy.AppUpdater());
27 // The possible tips to show.
30 CLEAR: "intervention_clear",
31 REFRESH: "intervention_refresh",
33 // There's an update available, but the user's pref says we should ask them to
34 // download and apply it.
35 UPDATE_ASK: "intervention_update_ask",
37 // The updater is currently checking. We don't actually show a tip for this,
38 // but we use it to tell whether we should wait for the check to complete in
39 // startQuery. See startQuery for details.
40 UPDATE_CHECKING: "intervention_update_checking",
42 // The user's browser is up to date, but they triggered the update
43 // intervention. We show this special refresh intervention instead.
44 UPDATE_REFRESH: "intervention_update_refresh",
46 // There's an update and it's been downloaded and applied. The user needs to
48 UPDATE_RESTART: "intervention_update_restart",
50 // We can't update the browser or possibly even check for updates for some
51 // reason, so the user should download the latest version from the web.
52 UPDATE_WEB: "intervention_update_web",
55 const EN_LOCALE_MATCH = /^en(-.*)$/;
57 // The search "documents" corresponding to each tip type.
61 "clear cache firefox",
62 "clear cache in firefox",
63 "clear cookies firefox",
64 "clear firefox cache",
65 "clear history firefox",
67 "delete cookies firefox",
68 "delete history firefox",
70 "firefox clear cache",
71 "firefox clear cookies",
72 "firefox clear history",
75 "firefox delete cookies",
76 "firefox delete history",
78 "firefox not loading pages",
81 "how to clear history",
85 "firefox keeps crashing",
86 "firefox not responding",
87 "firefox not working",
90 "how to reset firefox",
100 "firefox for windows",
101 "firefox free download",
104 "firefox latest version",
111 "how to update firefox",
114 "mozilla firefox 2019",
115 "mozilla firefox 2020",
116 "mozilla firefox download",
117 "mozilla firefox for mac",
118 "mozilla firefox for windows",
119 "mozilla firefox free download",
120 "mozilla firefox mac",
121 "mozilla firefox update",
122 "mozilla firefox windows",
130 // In order to determine whether we should show an update tip, we check for app
131 // updates, but only once per this time period.
132 const UPDATE_CHECK_PERIOD_MS = 12 * 60 * 60 * 1000; // 12 hours
135 * A node in the QueryScorer's phrase tree.
137 // eslint-disable-next-line no-shadow
141 this.documents = new Set();
142 this.childrenByWord = new Map();
147 * This class scores a query string against sets of phrases. To refer to a
148 * single set of phrases, we borrow the term "document" from search engine
149 * terminology. To use this class, first add your documents with `addDocument`,
150 * and then call `score` with a query string. `score` returns a sorted array of
151 * document-score pairs.
153 * The scoring method is fairly simple and is based on Levenshtein edit
154 * distance. Therefore, lower scores indicate a better match than higher
155 * scores. In summary, a query matches a phrase if the query starts with the
156 * phrase. So a query "firefox update foo bar" matches the phrase "firefox
157 * update" for example. A query matches a document if it matches any phrase in
158 * the document. The query and phrases are compared word for word, and we allow
159 * fuzzy matching by computing the Levenshtein edit distance in each comparison.
160 * The amount of fuzziness allowed is controlled with `distanceThreshold`. If
161 * the distance in a comparison is greater than this threshold, then the phrase
162 * does not match the query. The final score for a document is the minimum edit
163 * distance between its phrases and the query.
165 * As mentioned, `score` returns a sorted array of document-score pairs. It's
166 * up to you to filter the array to exclude scores above a certain threshold, or
167 * to take the top scorer, etc.
169 export class QueryScorer {
171 * @param {object} options
172 * Constructor options.
173 * @param {number} [options.distanceThreshold]
174 * Edit distances no larger than this value are considered matches.
175 * @param {Map} [options.variations]
176 * For convenience, the scorer can augment documents by replacing certain
177 * words with other words and phrases. This mechanism is called variations.
178 * This keys of this map are words that should be replaced, and the values
179 * are the replacement words or phrases. For example, if you add a document
180 * whose only phrase is "firefox update", normally the scorer will register
181 * only this single phrase for the document. However, if you pass the value
182 * `new Map(["firefox", ["fire fox", "fox fire", "foxfire"]])` for this
183 * parameter, it will register 4 total phrases for the document: "fire fox
184 * update", "fox fire update", "foxfire update", and the original "firefox
187 constructor({ distanceThreshold = 1, variations = new Map() } = {}) {
188 this._distanceThreshold = distanceThreshold;
189 this._variations = variations;
190 this._documents = new Set();
191 this._rootNode = new Node();
195 * Adds a document to the scorer.
197 * @param {object} doc
199 * @param {string} doc.id
201 * @param {Array} doc.phrases
202 * The set of phrases in the document. Each phrase should be a string.
205 this._documents.add(doc);
207 for (let phraseStr of doc.phrases) {
208 // Split the phrase and lowercase the words.
209 let phrase = phraseStr
212 .map(word => word.toLocaleLowerCase());
214 // Build a phrase list that contains the original phrase plus its
215 // variations, if any.
216 let phrases = [phrase];
217 for (let [triggerWord, variations] of this._variations) {
218 let index = phrase.indexOf(triggerWord);
220 for (let variation of variations) {
221 let variationPhrase = Array.from(phrase);
222 variationPhrase.splice(index, 1, ...variation.split(/\s+/));
223 phrases.push(variationPhrase);
228 // Finally, add the phrases to the phrase tree.
229 for (let completedPhrase of phrases) {
230 this._buildPhraseTree(this._rootNode, doc, completedPhrase, 0);
236 * Scores a query string against the documents in the scorer.
238 * @param {string} queryString
239 * The query string to score.
241 * An array of objects: { document, score }. Each element in the array is a
242 * a document and its score against the query string. The elements are
243 * ordered by score from low to high. Scores represent edit distance, so
244 * lower scores are better.
247 let queryWords = queryString
250 .map(word => word.toLocaleLowerCase());
251 let minDistanceByDoc = this._traverse({ queryWords });
253 for (let doc of this._documents) {
254 let distance = minDistanceByDoc.get(doc);
257 score: distance === undefined ? Infinity : distance,
260 results.sort((a, b) => a.score - b.score);
265 * Builds the phrase tree based on the current documents.
267 * The phrase tree lets us efficiently match queries against phrases. Each
268 * path through the tree starting from the root and ending at a leaf
269 * represents a complete phrase in a document (or more than one document, if
270 * the same phrase is present in multiple documents). Each node in the path
271 * represents a word in the phrase. To match a query, we start at the root,
272 * and in the root we look up the query's first word. If the word matches the
273 * first word of any phrase, then the root will have a child node representing
274 * that word, and we move on to the child node. Then we look up the query's
275 * second word in the child node, and so on, until either a lookup fails or we
279 * The current node being visited.
280 * @param {object} doc
281 * The document whose phrases are being added to the tree.
282 * @param {Array} phrase
283 * The phrase to add to the tree.
284 * @param {number} wordIndex
285 * The index in the phrase of the current word.
287 _buildPhraseTree(node, doc, phrase, wordIndex) {
288 if (phrase.length == wordIndex) {
289 // We're done with this phrase.
293 let word = phrase[wordIndex].toLocaleLowerCase();
294 let child = node.childrenByWord.get(word);
296 child = new Node(word);
297 node.childrenByWord.set(word, child);
299 child.documents.add(doc);
301 // Recurse with the next word in the phrase.
302 this._buildPhraseTree(child, doc, phrase, wordIndex + 1);
306 * Traverses a path in the phrase tree in order to score a query. See
307 * `_buildPhraseTree` for a description of how this works.
309 * @param {object} options
311 * @param {Array} options.queryWords
312 * The query being scored, split into words.
313 * @param {Node} [options.node]
314 * The node currently being visited.
315 * @param {Map} [options.minDistanceByDoc]
316 * Keeps track of the minimum edit distance for each document as the
317 * traversal continues.
318 * @param {number} [options.queryWordsIndex]
319 * The current index in the query words array.
320 * @param {number} [options.phraseDistance]
321 * The total edit distance between the query and the path in the tree that's
322 * been traversed so far.
323 * @returns {Map} minDistanceByDoc
327 node = this._rootNode,
328 minDistanceByDoc = new Map(),
332 if (!node.childrenByWord.size) {
333 // We reached a leaf node. The query has matched a phrase. If the query
334 // and the phrase have the same number of words, then queryWordsIndex ==
335 // queryWords.length also. Otherwise the query contains more words than
336 // the phrase. We still count that as a match.
337 for (let doc of node.documents) {
338 minDistanceByDoc.set(
342 minDistanceByDoc.has(doc) ? minDistanceByDoc.get(doc) : Infinity
346 return minDistanceByDoc;
349 if (queryWordsIndex == queryWords.length) {
350 // We exhausted all the words in the query but have not reached a leaf
351 // node. No match; the query has matched a phrase(s) up to this point,
352 // but it doesn't have enough words.
353 return minDistanceByDoc;
356 // Compare each word in the node to the current query word.
357 let queryWord = queryWords[queryWordsIndex];
358 for (let [childWord, child] of node.childrenByWord) {
359 let distance = lazy.NLP.levenshtein(queryWord, childWord);
360 if (distance <= this._distanceThreshold) {
361 // The word represented by this child node matches the current query
362 // word. Recurse into the child node.
366 queryWordsIndex: queryWordsIndex + 1,
367 phraseDistance: phraseDistance + distance,
371 // Else, the path that continues at the child node can't possibly match
372 // the query, so don't recurse into it.
375 return minDistanceByDoc;
380 * Gets appropriate values for each tip's payload.
382 * @param {string} tip a value from the TIPS enum
383 * @returns {object} Properties to include in the payload
385 function getPayloadForTip(tip) {
386 const baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
390 titleL10n: { id: "intervention-clear-data" },
391 buttons: [{ l10n: { id: "intervention-clear-data-confirm" } }],
392 helpUrl: baseURL + "delete-browsing-search-download-history-firefox",
396 titleL10n: { id: "intervention-refresh-profile" },
397 buttons: [{ l10n: { id: "intervention-refresh-profile-confirm" } }],
398 helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings",
400 case TIPS.UPDATE_ASK:
402 titleL10n: { id: "intervention-update-ask" },
403 buttons: [{ l10n: { id: "intervention-update-ask-confirm" } }],
404 helpUrl: baseURL + "update-firefox-latest-release",
406 case TIPS.UPDATE_REFRESH:
408 titleL10n: { id: "intervention-update-refresh" },
409 buttons: [{ l10n: { id: "intervention-update-refresh-confirm" } }],
410 helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings",
412 case TIPS.UPDATE_RESTART:
414 titleL10n: { id: "intervention-update-restart" },
415 buttons: [{ l10n: { id: "intervention-update-restart-confirm" } }],
416 helpUrl: baseURL + "update-firefox-latest-release",
418 case TIPS.UPDATE_WEB:
420 titleL10n: { id: "intervention-update-web" },
421 buttons: [{ l10n: { id: "intervention-update-web-confirm" } }],
422 helpUrl: baseURL + "update-firefox-latest-release",
425 throw new Error("Unknown TIP type.");
430 * A provider that returns actionable tip results when the user is performing
431 * a search related to those actions.
433 class ProviderInterventions extends UrlbarProvider {
436 // The tip we should currently show.
437 this.currentTip = TIPS.NONE;
439 // This object is used to match the user's queries to tips.
440 ChromeUtils.defineLazyGetter(this, "queryScorer", () => {
441 let queryScorer = new QueryScorer({
442 variations: new Map([
443 // Recognize "fire fox", "fox fire", and "foxfire" as "firefox".
444 ["firefox", ["fire fox", "fox fire", "foxfire"]],
445 // Recognize "mozila" as "mozilla". This will catch common mispellings
446 // "mozila", "mozzila", and "mozzilla" (among others) due to the edit
447 // distance threshold of 1.
448 ["mozilla", ["mozila"]],
451 for (let [id, phrases] of Object.entries(DOCUMENTS)) {
452 queryScorer.addDocument({ id, phrases });
459 * Enum of the types of intervention tips.
461 * @returns {{ NONE: string; CLEAR: string; REFRESH: string; UPDATE_ASK: string; UPDATE_CHECKING: string; UPDATE_REFRESH: string; UPDATE_RESTART: string; UPDATE_WEB: string; }}
468 * Unique name for the provider, used by the context to filter on providers.
473 return "UrlbarProviderInterventions";
477 * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE.
479 * @returns {UrlbarUtils.PROVIDER_TYPE}
482 return UrlbarUtils.PROVIDER_TYPE.PROFILE;
486 * Whether this provider should be invoked for the given context.
487 * If this method returns false, the providers manager won't start a query
488 * with this provider, to save on resources.
490 * @param {UrlbarQueryContext} queryContext The query context object
491 * @returns {boolean} Whether this provider should be invoked for the search.
493 isActive(queryContext) {
495 !queryContext.searchString ||
496 queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH ||
497 lazy.UrlbarTokenizer.REGEXP_LIKE_PROTOCOL.test(
498 queryContext.searchString
500 !EN_LOCALE_MATCH.test(Services.locale.appLocaleAsBCP47) ||
501 !Services.policies.isAllowed("urlbarinterventions") ||
502 lazy.UrlbarProviderGlobalActions.isActive(queryContext)
507 this.currentTip = TIPS.NONE;
509 // Get the scores and the top score.
510 let docScores = this.queryScorer.score(queryContext.searchString);
511 let topDocScore = docScores[0];
513 // Multiple docs may have the top score, so collect them all.
514 let topDocIDs = new Set();
515 if (topDocScore.score != Infinity) {
516 for (let { score, document } of docScores) {
517 if (score != topDocScore.score) {
520 topDocIDs.add(document.id);
524 // Determine the tip to show, if any. If there are multiple top-score docs,
525 // prefer them in the following order.
526 if (topDocIDs.has("update")) {
527 this._setCurrentTipFromAppUpdaterStatus();
528 } else if (topDocIDs.has("clear")) {
529 let window = lazy.BrowserWindowTracker.getTopWindow();
530 if (!lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
531 this.currentTip = TIPS.CLEAR;
533 } else if (topDocIDs.has("refresh")) {
534 // Note that the "update" case can set currentTip to TIPS.REFRESH too.
535 this.currentTip = TIPS.REFRESH;
539 this.currentTip != TIPS.NONE &&
540 (this.currentTip != TIPS.REFRESH ||
541 Services.policies.isAllowed("profileRefresh"))
545 async _setCurrentTipFromAppUpdaterStatus() {
546 // The update tips depend on the app's update status, so check for updates
547 // now (if we haven't already checked within the update-check period). If
548 // we're running in an xpcshell test, then checkForBrowserUpdate's attempt
549 // to use appUpdater will throw an exception because it won't be available.
550 // In that case, return false to disable the provider.
552 // This causes synchronous IO within the updater the first time it's called
553 // (at least) so be careful not to do it the first time the urlbar is used.
555 this.checkForBrowserUpdate();
560 // There are several update tips. Figure out which one to show.
561 switch (lazy.appUpdater.status) {
562 case lazy.AppUpdater.STATUS.READY_FOR_RESTART:
563 // Prompt the user to restart.
564 this.currentTip = TIPS.UPDATE_RESTART;
566 case lazy.AppUpdater.STATUS.DOWNLOAD_AND_INSTALL:
567 // There's an update available, but the user's pref says we should ask
568 // them to download and apply it.
569 this.currentTip = TIPS.UPDATE_ASK;
571 case lazy.AppUpdater.STATUS.NO_UPDATES_FOUND:
572 // We show a special refresh tip when the browser is up to date.
573 this.currentTip = TIPS.UPDATE_REFRESH;
575 case lazy.AppUpdater.STATUS.CHECKING:
576 // This will be the case the first time we check. See startQuery for
577 // how this special tip is handled.
578 this.currentTip = TIPS.UPDATE_CHECKING;
580 case lazy.AppUpdater.STATUS.NO_UPDATER:
581 case lazy.AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY:
582 // If the updater is disabled at build time or at runtime, either by
583 // policy or because we're in a package, do not select any update tips.
584 this.currentTip = TIPS.NONE;
587 // Give up and ask the user to download the latest version from the
588 // web. We default to this case when the update is still downloading
589 // because an update doesn't actually occur if the user were to
590 // restart the browser. See bug 1625241.
591 this.currentTip = TIPS.UPDATE_WEB;
599 * @param {UrlbarQueryContext} queryContext The query context object
600 * @param {Function} addCallback Callback invoked by the provider to add a new
601 * result. A UrlbarResult should be passed to it.
603 async startQuery(queryContext, addCallback) {
604 let instance = this.queryInstance;
606 // TIPS.UPDATE_CHECKING is special, and we never actually show a tip that
607 // reflects a "checking" status. Instead it's handled like this. We call
608 // appUpdater.check() to start an update check. If we haven't called it
609 // before, then when it returns, appUpdater.status will be
610 // AppUpdater.STATUS.CHECKING, and it will remain CHECKING until the check
611 // finishes. We can add a listener to appUpdater to be notified when the
612 // check finishes. We don't want to wait for it to finish in isActive
613 // because that would block other providers from adding their results, so
614 // instead we wait here in startQuery. The results from other providers
615 // will be added while we're waiting. When the check finishes, we call
616 // addCallback and add our result. It doesn't matter how long the check
617 // takes because if another query starts, the view is closed, or the user
618 // changes the selection, the query will be canceled.
619 if (this.currentTip == TIPS.UPDATE_CHECKING) {
620 // First check the status because it may have changed between the time
621 // isActive was called and now.
622 this._setCurrentTipFromAppUpdaterStatus();
623 if (this.currentTip == TIPS.UPDATE_CHECKING) {
624 // The updater is still checking, so wait for it to finish.
625 await new Promise(resolve => {
626 this._appUpdaterListener = () => {
627 lazy.appUpdater.removeListener(this._appUpdaterListener);
628 delete this._appUpdaterListener;
631 lazy.appUpdater.addListener(this._appUpdaterListener);
633 if (instance != this.queryInstance) {
634 // The query was canceled before the check finished.
637 // Finally, set the tip from the updater status. The updater should no
638 // longer be checking, but guard against it just in case by returning
640 this._setCurrentTipFromAppUpdaterStatus();
641 if (this.currentTip == TIPS.UPDATE_CHECKING) {
646 // At this point, this.currentTip != TIPS.UPDATE_CHECKING because we
647 // returned early above if it was.
649 let result = new lazy.UrlbarResult(
650 UrlbarUtils.RESULT_TYPE.TIP,
651 UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
653 ...getPayloadForTip(this.currentTip),
654 type: this.currentTip,
655 icon: UrlbarUtils.ICON.TIP,
657 id: "urlbar-result-menu-tip-get-help",
661 result.suggestedIndex = 1;
662 addCallback(this, result);
666 * Cancels a running query,
669 // If we're waiting for appUpdater to finish its update check,
670 // this._appUpdaterListener will be defined. We can stop listening now.
671 if (this._appUpdaterListener) {
672 lazy.appUpdater.removeListener(this._appUpdaterListener);
673 delete this._appUpdaterListener;
677 #pickResult(result, window) {
678 let tip = result.payload.type;
680 // Do the tip action.
683 openClearHistoryDialog(window);
686 case TIPS.UPDATE_REFRESH:
687 resetBrowser(window);
689 case TIPS.UPDATE_ASK:
690 installBrowserUpdateAndRestart();
692 case TIPS.UPDATE_RESTART:
695 case TIPS.UPDATE_WEB:
696 window.gBrowser.selectedTab = window.gBrowser.addWebTab(
697 "https://www.mozilla.org/firefox/new/"
703 onEngagement(queryContext, controller, details) {
704 // `selType` is "tip" when the tip's main button is picked. Ignore clicks on
705 // the help command ("tiphelp"), which is handled by UrlbarInput since we
706 // set `helpUrl` on the result payload. Currently there aren't any other
707 // buttons or commands but this will ignore clicks on them too.
708 if (details.selType == "tip") {
709 this.#pickResult(details.result, controller.browserWindow);
714 * Checks for app updates.
716 * @param {boolean} force If false, this only checks for updates if we haven't
717 * already checked within the update-check period. If true, we check
720 checkForBrowserUpdate(force = false) {
723 !this._lastUpdateCheckTime ||
724 Date.now() - this._lastUpdateCheckTime >= UPDATE_CHECK_PERIOD_MS
726 this._lastUpdateCheckTime = Date.now();
727 lazy.appUpdater.check();
732 * Resets the provider's app updater state by making a new app updater. This
733 * is intended to be used by tests.
736 // Reset only if the object has already been initialized.
737 if (!Object.getOwnPropertyDescriptor(lazy, "appUpdater").get) {
738 lazy.appUpdater = new lazy.AppUpdater();
743 export var UrlbarProviderInterventions = new ProviderInterventions();
746 * Tip callbacks follow.
749 function installBrowserUpdateAndRestart() {
750 if (lazy.appUpdater.status != lazy.AppUpdater.STATUS.DOWNLOAD_AND_INSTALL) {
751 return Promise.resolve();
753 return new Promise(resolve => {
754 let listener = () => {
755 // Once we call allowUpdateDownload, there are two possible end
756 // states: DOWNLOAD_FAILED and READY_FOR_RESTART.
758 lazy.appUpdater.status != lazy.AppUpdater.STATUS.READY_FOR_RESTART &&
759 lazy.appUpdater.status != lazy.AppUpdater.STATUS.DOWNLOAD_FAILED
763 lazy.appUpdater.removeListener(listener);
764 if (lazy.appUpdater.status == lazy.AppUpdater.STATUS.READY_FOR_RESTART) {
769 lazy.appUpdater.addListener(listener);
770 lazy.appUpdater.allowUpdateDownload();
774 function openClearHistoryDialog(window) {
775 // The behaviour of the Clear Recent History dialog in PBM does
776 // not have the expected effect (bug 463607).
777 if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
780 lazy.Sanitizer.showUI(window);
783 function restartBrowser() {
784 // Notify all windows that an application quit has been requested.
785 let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
788 Services.obs.notifyObservers(
790 "quit-application-requested",
793 // Something aborted the quit process.
794 if (cancelQuit.data) {
797 // If already in safe mode restart in safe mode.
798 if (Services.appinfo.inSafeMode) {
799 Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
801 Services.startup.quit(
802 Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
807 function resetBrowser(window) {
808 if (!lazy.ResetProfile.resetSupported()) {
811 lazy.ResetProfile.openConfirmationDialog(window);