Bug 1890689 remove DynamicResampler::mSetBufferDuration r=pehrsons
[gecko.git] / intl / locale / LangPackMatcher.sys.mjs
blob977398b082e8bc3889c6d6eb8a70a516402b81a4
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 ChromeUtils.defineESModuleGetters(lazy, {
8   AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
9   AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
10 });
12 if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
13   // This check ensures that the `mockable` API calls can be consisently mocked in tests.
14   // If this requirement needs to be eased, please ensure the test logic remains valid.
15   throw new Error("This code is assumed to run in the parent process.");
18 /**
19  * Attempts to find an appropriate langpack for a given language. The async function
20  * is infallible, but may not return a langpack.
21  *
22  * @returns {{
23  *   langPack: LangPack | null,
24  *   langPackDisplayName: string | null
25  * }}
26  */
27 async function negotiateLangPackForLanguageMismatch() {
28   const localeInfo = getAppAndSystemLocaleInfo();
29   const nullResult = {
30     langPack: null,
31     langPackDisplayName: null,
32   };
33   if (!localeInfo.systemLocale) {
34     // The system locale info was not valid.
35     return nullResult;
36   }
38   /**
39    * Fetch the available langpacks from AMO.
40    *
41    * @type {Array<LangPack>}
42    */
43   const availableLangpacks = await mockable.getAvailableLangpacks();
44   if (!availableLangpacks) {
45     return nullResult;
46   }
48   /**
49    * Figure out a langpack to recommend.
50    * @type {LangPack | null}
51    */
52   const langPack =
53     // First look for a langpack that matches the baseName, which may include a script.
54     // e.g. system "fr-FR" matches langpack "fr-FR"
55     //      system "en-GB" matches langpack "en-GB".
56     //      system "zh-Hant-CN" matches langpack "zh-Hant-CN".
57     availableLangpacks.find(
58       ({ target_locale }) => target_locale === localeInfo.systemLocale.baseName
59     ) ||
60     // Next try matching language and region while excluding script
61     // e.g. system "zh-Hant-TW" matches langpack "zh-TW" but not "zh-CN".
62     availableLangpacks.find(
63       ({ target_locale }) =>
64         target_locale ===
65         `${localeInfo.systemLocale.language}-${localeInfo.systemLocale.region}`
66     ) ||
67     // Next look for langpacks that just match the language.
68     // e.g. system "fr-FR" matches langpack "fr".
69     //      system "en-AU" matches langpack "en".
70     availableLangpacks.find(
71       ({ target_locale }) => target_locale === localeInfo.systemLocale.language
72     ) ||
73     // Next look for a langpack that matches the language, but not the region.
74     // e.g. "es-CL" (Chilean Spanish) as a system language matching
75     //      "es-ES" (European Spanish)
76     availableLangpacks.find(({ target_locale }) =>
77       target_locale.startsWith(`${localeInfo.systemLocale.language}-`)
78     ) ||
79     null;
81   if (!langPack) {
82     return nullResult;
83   }
85   return {
86     langPack,
87     langPackDisplayName: Services.intl.getLocaleDisplayNames(
88       undefined,
89       [langPack.target_locale],
90       { preferNative: true }
91     )[0],
92   };
95 // If a langpack is being installed, allow blocking on that.
96 let installingLangpack = new Map();
98 /**
99  * @typedef {LangPack}
100  * @type {object}
101  * @property {string} target_locale
102  * @property {string} url
103  * @property {string} hash
104  */
107  * Ensure that a given lanpack is installed.
109  * @param {LangPack} langPack
110  * @returns {Promise<boolean>} Success or failure.
111  */
112 function ensureLangPackInstalled(langPack) {
113   if (!langPack) {
114     throw new Error("Expected a LangPack to install.");
115   }
116   // Make sure any outstanding calls get resolved before attempting another call.
117   // This guards against any quick page refreshes attempting to install the langpack
118   // twice.
119   const inProgress = installingLangpack.get(langPack.hash);
120   if (inProgress) {
121     return inProgress;
122   }
123   const promise = _ensureLangPackInstalledImpl(langPack);
124   installingLangpack.set(langPack.hash, promise);
125   promise.finally(() => {
126     installingLangpack.delete(langPack.hash);
127   });
128   return promise;
132  * @param {LangPack} langPack
133  * @returns {boolean} Success or failure.
134  */
135 async function _ensureLangPackInstalledImpl(langPack) {
136   const availablelocales = await getAvailableLocales();
137   if (availablelocales.includes(langPack.target_locale)) {
138     // The langpack is already installed.
139     return true;
140   }
142   return mockable.installLangPack(langPack);
146  * These are all functions with side effects or configuration options that should be
147  * mockable for tests.
148  */
149 const mockable = {
150   /**
151    * @returns {LangPack[] | null}
152    */
153   async getAvailableLangpacks() {
154     try {
155       return lazy.AddonRepository.getAvailableLangpacks();
156     } catch (error) {
157       console.error(
158         `Failed to get the list of available language packs: ${error?.message}`
159       );
160       return null;
161     }
162   },
164   /**
165    * Use the AddonManager to install an addon from the URL.
166    * @param {LangPack} langPack
167    */
168   async installLangPack(langPack) {
169     let install;
170     try {
171       install = await lazy.AddonManager.getInstallForURL(langPack.url, {
172         hash: langPack.hash,
173         telemetryInfo: {
174           source: "about:welcome",
175         },
176       });
177     } catch (error) {
178       console.error(error);
179       return false;
180     }
182     try {
183       await install.install();
184     } catch (error) {
185       console.error(error);
186       return false;
187     }
188     return true;
189   },
191   /**
192    * Returns the available locales, including the fallback locale, which may not include
193    * all of the resources, in cases where the defaultLocale is not "en-US".
194    *
195    * @returns {string[]}
196    */
197   getAvailableLocalesIncludingFallback() {
198     return Services.locale.availableLocales;
199   },
201   /**
202    * @returns {string}
203    */
204   getDefaultLocale() {
205     return Services.locale.defaultLocale;
206   },
208   /**
209    * @returns {string}
210    */
211   getLastFallbackLocale() {
212     return Services.locale.lastFallbackLocale;
213   },
215   /**
216    * @returns {string}
217    */
218   getAppLocaleAsBCP47() {
219     return Services.locale.appLocaleAsBCP47;
220   },
222   /**
223    * @returns {string}
224    */
225   getSystemLocale() {
226     // Allow the system locale to be overridden for manual testing.
227     const systemLocaleOverride = Services.prefs.getCharPref(
228       "intl.multilingual.aboutWelcome.systemLocaleOverride",
229       null
230     );
231     if (systemLocaleOverride) {
232       try {
233         // If the locale can't be parsed, ignore the pref.
234         new Services.intl.Locale(systemLocaleOverride);
235         return systemLocaleOverride;
236       } catch (_error) {}
237     }
239     const osPrefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(
240       Ci.mozIOSPreferences
241     );
242     return osPrefs.systemLocale;
243   },
245   /**
246    * @param {string[]} locales The BCP 47 locale identifiers.
247    */
248   setRequestedAppLocales(locales) {
249     Services.locale.requestedLocales = locales;
250   },
254  * This function is really only setting `Services.locale.requestedLocales`, but it's
255  * using the `mockable` object to allow this behavior to be mocked in tests.
257  * @param {string[]} locales The BCP 47 locale identifiers.
258  */
259 function setRequestedAppLocales(locales) {
260   mockable.setRequestedAppLocales(locales);
264  * A serializable Intl.Locale.
266  * @typedef StructuredLocale
267  * @type {object}
268  * @property {string} baseName
269  * @property {string} language
270  * @property {string} region
271  */
274  * In telemetry data, some of the system locales show up as blank. Guard against this
275  * and any other malformed locale information provided by the system by wrapping the call
276  * into a catch/try.
278  * @param {string} locale
279  * @returns {StructuredLocale | null}
280  */
281 function getStructuredLocaleOrNull(localeString) {
282   try {
283     const locale = new Services.intl.Locale(localeString);
284     return {
285       baseName: locale.baseName,
286       language: locale.language,
287       region: locale.region,
288     };
289   } catch (_err) {
290     return null;
291   }
295  * Determine the system and app locales, and how much the locales match.
297  * @returns {{
298  *  systemLocale: StructuredLocale,
299  *  appLocale: StructuredLocale,
300  *  matchType: "unknown" | "language-mismatch" | "region-mismatch" | "match",
301  * }}
302  */
303 function getAppAndSystemLocaleInfo() {
304   // Convert locale strings into structured locale objects.
305   const systemLocaleRaw = mockable.getSystemLocale();
306   const appLocaleRaw = mockable.getAppLocaleAsBCP47();
308   const systemLocale = getStructuredLocaleOrNull(systemLocaleRaw);
309   const appLocale = getStructuredLocaleOrNull(appLocaleRaw);
311   let matchType = "unknown";
312   if (systemLocale && appLocale) {
313     if (systemLocale.language !== appLocale.language) {
314       matchType = "language-mismatch";
315     } else if (systemLocale.region !== appLocale.region) {
316       matchType = "region-mismatch";
317     } else {
318       matchType = "match";
319     }
320   }
322   // Live reloading with bidi switching may not be supported.
323   let canLiveReload = null;
324   if (systemLocale && appLocale) {
325     const systemDirection = Services.intl.getScriptDirection(
326       systemLocale.language
327     );
328     const appDirection = Services.intl.getScriptDirection(appLocale.language);
329     const supportsBidiSwitching = Services.prefs.getBoolPref(
330       "intl.multilingual.liveReloadBidirectional",
331       false
332     );
333     canLiveReload = systemDirection === appDirection || supportsBidiSwitching;
334   }
335   return {
336     // Return the Intl.Locale in a serializable form.
337     systemLocaleRaw,
338     systemLocale,
339     appLocaleRaw,
340     appLocale,
341     matchType,
342     canLiveReload,
344     // These can be used as Fluent message args.
345     displayNames: {
346       systemLanguage: systemLocale
347         ? Services.intl.getLocaleDisplayNames(
348             undefined,
349             [systemLocale.baseName],
350             { preferNative: true }
351           )[0]
352         : null,
353       appLanguage: appLocale
354         ? Services.intl.getLocaleDisplayNames(undefined, [appLocale.baseName], {
355             preferNative: true,
356           })[0]
357         : null,
358     },
359   };
363  * Filter the lastFallbackLocale from availableLocales if it doesn't have all
364  * of the needed strings.
366  * When the lastFallbackLocale isn't the defaultLocale, then by default only
367  * fluent strings are included. To fully use that locale you need the langpack
368  * to be installed, so if it isn't installed remove it from availableLocales.
369  */
370 async function getAvailableLocales() {
371   const availableLocales = mockable.getAvailableLocalesIncludingFallback();
372   const defaultLocale = mockable.getDefaultLocale();
373   const lastFallbackLocale = mockable.getLastFallbackLocale();
374   // If defaultLocale isn't lastFallbackLocale, then we still need the langpack
375   // for lastFallbackLocale for it to be useful.
376   if (defaultLocale != lastFallbackLocale) {
377     let lastFallbackId = `langpack-${lastFallbackLocale}@firefox.mozilla.org`;
378     let lastFallbackInstalled = await lazy.AddonManager.getAddonByID(
379       lastFallbackId
380     );
381     if (!lastFallbackInstalled) {
382       return availableLocales.filter(locale => locale != lastFallbackLocale);
383     }
384   }
385   return availableLocales;
388 export var LangPackMatcher = {
389   negotiateLangPackForLanguageMismatch,
390   ensureLangPackInstalled,
391   getAppAndSystemLocaleInfo,
392   setRequestedAppLocales,
393   getAvailableLocales,
394   mockable,