Bug 1944416: Restore individual tabs from closed groups in closed windows r=dao,sessi...
[gecko.git] / browser / components / urlbar / UrlbarSearchTermsPersistence.sys.mjs
blob93d4953557e44454b7d214642ae27fccb091ca37
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/. */
5 const lazy = {};
7 import { UrlbarUtils } from "resource:///modules/UrlbarUtils.sys.mjs";
9 ChromeUtils.defineESModuleGetters(lazy, {
10   RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
11 });
13 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
14   UrlbarUtils.getLogger({ prefix: "UrlbarSearchTermsPersistence" })
17 const URLBAR_PERSISTENCE_SETTINGS_KEY = "urlbar-persisted-search-terms";
19 /**
20  * Provides utilities to manage and validate search terms persistence in the URL
21  * bar. This class is designed to handle the identification of default search
22  * engine results pages (SERPs), retrieval of search terms, and validation of
23  * conditions for persisting search terms based on predefined provider
24  * information.
25  */
26 class _UrlbarSearchTermsPersistence {
27   // Whether or not this class is initialised.
28   #initialized = false;
30   // The original provider information, mainly used for tests.
31   #originalProviderInfo = [];
33   // The current search provider info.
34   #searchProviderInfo = [];
36   // An instance of remote settings that is used to access the provider info.
37   #urlbarSearchTermsPersistenceSettings;
39   // Callback used when syncing Urlbar Search Terms Persistence config settings.
40   #urlbarSearchTermsPersistenceSettingsSync;
42   async init() {
43     if (this.#initialized) {
44       return;
45     }
47     this.#urlbarSearchTermsPersistenceSettings = lazy.RemoteSettings(
48       URLBAR_PERSISTENCE_SETTINGS_KEY
49     );
50     let rawProviderInfo = [];
51     try {
52       rawProviderInfo = await this.#urlbarSearchTermsPersistenceSettings.get();
53     } catch (ex) {
54       lazy.logger.error("Could not get settings:", ex);
55     }
57     this.#urlbarSearchTermsPersistenceSettingsSync = event =>
58       this.#onSettingsSync(event);
59     this.#urlbarSearchTermsPersistenceSettings.on(
60       "sync",
61       this.#urlbarSearchTermsPersistenceSettingsSync
62     );
64     this.#originalProviderInfo = rawProviderInfo;
65     this.#setSearchProviderInfo(rawProviderInfo);
67     this.#initialized = true;
68   }
70   uninit() {
71     if (!this.#initialized) {
72       return;
73     }
75     try {
76       this.#urlbarSearchTermsPersistenceSettings.off(
77         "sync",
78         this.#urlbarSearchTermsPersistenceSettingsSync
79       );
80     } catch (ex) {
81       lazy.logger.error(
82         "Failed to shutdown UrlbarSearchTermsPersistence Remote Settings.",
83         ex
84       );
85     }
86     this.#urlbarSearchTermsPersistenceSettings = null;
87     this.#urlbarSearchTermsPersistenceSettingsSync = null;
89     this.#initialized = false;
90   }
92   getSearchProviderInfo() {
93     return this.#searchProviderInfo;
94   }
96   /**
97    * Test-only function, used to override the provider information, so that
98    * unit tests can set it to easy to test values.
99    *
100    * @param {Array} providerInfo
101    *   An array of provider information to set.
102    */
103   overrideSearchTermsPersistenceForTests(providerInfo) {
104     let info = providerInfo ? providerInfo : this.#originalProviderInfo;
105     this.#setSearchProviderInfo(info);
106   }
108   /**
109    * Determines if the URIs represent an application provided search
110    * engine results page (SERP) and retrieves the search terms used.
111    *
112    * @param {nsIURI} uri
113    *   The primary URI that is checked to determine if it matches the expected
114    *   structure of a default SERP.
115    * @returns {string}
116    *   The search terms used.
117    *   Will return an empty string if it's not a default SERP, the search term
118    *   looks too similar to a URL, the string exceeds the maximum characters,
119    *   or the default engine hasn't been initialized.
120    */
121   getSearchTerm(uri) {
122     if (!Services.search.hasSuccessfullyInitialized || !uri?.spec) {
123       return "";
124     }
126     // Avoid inspecting URIs if they are non-http(s).
127     if (!/^https?:\/\//.test(uri.spec)) {
128       return "";
129     }
131     let searchTerm = "";
133     // If we have a provider, we have specific rules for dealing and can
134     // understand changes to params.
135     let provider = this.#getProviderInfoForURL(uri.spec);
136     if (provider) {
137       let result = Services.search.parseSubmissionURL(uri.spec);
138       if (!result.engine?.isAppProvided || !this.isDefaultPage(uri, provider)) {
139         return "";
140       }
141       searchTerm = result.terms;
142     } else {
143       let result = Services.search.parseSubmissionURL(uri.spec);
144       if (!result.engine?.isAppProvided) {
145         return "";
146       }
147       searchTerm = result.engine.searchTermFromResult(uri);
148     }
150     if (!searchTerm || searchTerm.length > UrlbarUtils.MAX_TEXT_LENGTH) {
151       return "";
152     }
154     let searchTermWithSpacesRemoved = searchTerm.replaceAll(/\s/g, "");
156     // Check if the search string uses a commonly used URL protocol. This
157     // avoids doing a fixup if we already know it matches a URL. Additionally,
158     // it ensures neither http:// nor https:// will appear by themselves in
159     // UrlbarInput. This is important because http:// can be trimmed, which in
160     // the Persisted Search Terms case, will cause the UrlbarInput to appear
161     // blank.
162     if (
163       searchTermWithSpacesRemoved.startsWith("https://") ||
164       searchTermWithSpacesRemoved.startsWith("http://")
165     ) {
166       return "";
167     }
169     // We pass the search term to URIFixup to determine if it could be
170     // interpreted as a URL, including typos in the scheme and/or the domain
171     // suffix. This is to prevent search terms from persisting in the Urlbar if
172     // they look too similar to a URL, but still allow phrases with periods
173     // that are unlikely to be a URL.
174     try {
175       let info = Services.uriFixup.getFixupURIInfo(
176         searchTermWithSpacesRemoved,
177         Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS |
178           Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP
179       );
180       if (info.keywordAsSent) {
181         return searchTerm;
182       }
183     } catch (e) {}
185     return "";
186   }
188   shouldPersist(state, { uri, isSameDocument, userTypedValue, firstView }) {
189     let persist = state.persist;
190     if (!persist) {
191       return false;
192     }
194     // Don't persist if there are no search terms to show.
195     if (!persist.searchTerms) {
196       return false;
197     }
199     // If there is a userTypedValue and it differs from the search terms, the
200     // user must've modified the text.
201     if (userTypedValue && userTypedValue !== persist.searchTerms) {
202       return false;
203     }
205     // For some search engines, particularly single page applications, check
206     // if the URL matches a default search results page as page changes will
207     // occur within the same document.
208     if (
209       isSameDocument &&
210       state.persist.provider &&
211       !this.isDefaultPage(uri, state.persist.provider)
212     ) {
213       return false;
214     }
216     // The first page view will set the search mode but after that, the search
217     // mode could differ. Since persisting the search guarantees the correct
218     // search mode is shown, we don't want to undo changes the user could've
219     // done, like removing/adding the search mode.
220     if (
221       !firstView &&
222       !this.searchModeMatchesState(state.searchModes?.confirmed, state)
223     ) {
224       return false;
225     }
227     return true;
228   }
230   // Resets and assigns initial values for Search Terms Persistence state.
231   setPersistenceState(state, uri) {
232     state.persist = {
233       // Whether the engine that loaded the URI is the default search engine.
234       isDefaultEngine: null,
236       // The name of the engine that was used to load the URI.
237       originalEngineName: null,
239       // The search provider associated with the URI. If one exists, it means
240       // we have custom rules for this search provider to determine whether or
241       // not the URI corresponds to a default search engine results page.
242       provider: null,
244       // The search string within the URI.
245       searchTerms: this.getSearchTerm(uri),
247       // Whether the search terms should persist.
248       shouldPersist: null,
249     };
251     if (!state.persist.searchTerms) {
252       return;
253     }
255     let provider = this.#getProviderInfoForURL(uri?.spec);
256     // If we have specific Remote Settings defined providers for the URL,
257     // it's because changing the page won't clear the search terms unless we
258     // observe changes of the params in the URL.
259     if (provider) {
260       state.persist.provider = provider;
261     }
263     let result = this.#searchModeForUrl(uri.spec);
264     state.persist.originalEngineName = result.engineName;
265     state.persist.isDefaultEngine = result.isDefaultEngine;
266   }
268   /**
269    * Determines if search mode is in alignment with the persisted
270    * search state. Returns true in either of these cases:
271    *
272    * - The search mode engine is the same as the persisted engine.
273    * - There's no search mode, but the persisted engine is a default engine.
274    *
275    * @param {object} searchMode
276    *   The search mode for the address bar.
277    * @param {object} state
278    *   The address bar state associated with the browser.
279    * @returns {boolean}
280    */
281   searchModeMatchesState(searchMode, state) {
282     if (searchMode?.engineName === state.persist?.originalEngineName) {
283       return true;
284     }
285     if (!searchMode && state.persist?.isDefaultEngine) {
286       return true;
287     }
288     return false;
289   }
291   onSearchModeChanged(window) {
292     let urlbar = window.gURLBar;
293     if (!urlbar) {
294       return;
295     }
296     let state = urlbar.getBrowserState(window.gBrowser.selectedBrowser);
297     if (!state?.persist) {
298       return;
299     }
301     // Exit search terms persistence when search mode changes and it's not
302     // consistent with the persisted engine.
303     if (
304       state.persist.shouldPersist &&
305       !this.searchModeMatchesState(state.searchModes?.confirmed, state)
306     ) {
307       state.persist.shouldPersist = false;
308       urlbar.removeAttribute("persistsearchterms");
309     }
310   }
312   async #onSettingsSync(event) {
313     let current = event.data?.current;
314     if (current) {
315       lazy.logger.debug("Update provider info due to Remote Settings sync.");
316       this.#originalProviderInfo = current;
317       this.#setSearchProviderInfo(current);
318     } else {
319       lazy.logger.debug(
320         "Ignoring Remote Settings sync data due to missing records."
321       );
322     }
323     Services.obs.notifyObservers(null, "urlbar-persisted-search-terms-synced");
324   }
326   #searchModeForUrl(url) {
327     // If there's no default engine, no engines are available.
328     if (!Services.search.defaultEngine) {
329       return null;
330     }
331     let result = Services.search.parseSubmissionURL(url);
332     if (!result.engine?.isAppProvided) {
333       return null;
334     }
335     return {
336       engineName: result.engine.name,
337       isDefaultEngine: result.engine === Services.search.defaultEngine,
338     };
339   }
341   /**
342    * Used to set the local version of the search provider information.
343    * This automatically maps the regexps to RegExp objects so that
344    * we don't have to create a new instance each time.
345    *
346    * @param {Array} providerInfo
347    *   A raw array of provider information to set.
348    */
349   #setSearchProviderInfo(providerInfo) {
350     this.#searchProviderInfo = providerInfo.map(provider => {
351       let newProvider = {
352         ...provider,
353         searchPageRegexp: new RegExp(provider.searchPageRegexp),
354       };
355       return newProvider;
356     });
357   }
359   /**
360    * Searches for provider information for a given url.
361    *
362    * @param {string} url The url to match for a provider.
363    * @returns {Array | null} Returns an array of provider name and the provider
364    *   information.
365    */
366   #getProviderInfoForURL(url) {
367     return this.#searchProviderInfo.find(info =>
368       info.searchPageRegexp.test(url)
369     );
370   }
372   /**
373    * Determines whether the search terms in the provided URL should be persisted
374    * based on whether we find it's a default web SERP.
375    *
376    * @param {nsIURI} currentURI
377    *   The current URI
378    * @param {Array} provider
379    *   An array of provider information
380    * @returns {string | null} Returns null if there is no provider match, an
381    *   empty string if search terms should not be persisted, or the value of the
382    *   first matched query parameter to be persisted.
383    */
384   isDefaultPage(currentURI, provider) {
385     let searchParams;
386     try {
387       searchParams = new URL(currentURI.spec).searchParams;
388     } catch (ex) {
389       return false;
390     }
391     if (provider.includeParams) {
392       let foundMatch = false;
393       for (let param of provider.includeParams) {
394         // The param might not be present on page load.
395         if (param.canBeMissing && !searchParams.has(param.key)) {
396           foundMatch = true;
397           break;
398         }
400         // If we didn't provide a specific param value,
401         // the presence of the name is sufficient.
402         if (searchParams.has(param.key) && !param.values?.length) {
403           foundMatch = true;
404           break;
405         }
407         let value = searchParams.get(param.key);
408         // The param name and value must be present.
409         if (value && param?.values.includes(value)) {
410           foundMatch = true;
411           break;
412         }
413       }
414       if (!foundMatch) {
415         return false;
416       }
417     }
419     if (provider.excludeParams) {
420       for (let param of provider.excludeParams) {
421         let value = searchParams.get(param.key);
422         // If we found a value for a key but didn't
423         // provide a specific value to match.
424         if (!param.values?.length && value) {
425           return false;
426         }
427         // If we provided a value and it was present.
428         if (param.values?.includes(value)) {
429           return false;
430         }
431       }
432     }
433     return true;
434   }
437 export var UrlbarSearchTermsPersistence = new _UrlbarSearchTermsPersistence();