summaryrefslogtreecommitdiffstats
path: root/intl/locale/LangPackMatcher.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'intl/locale/LangPackMatcher.sys.mjs')
-rw-r--r--intl/locale/LangPackMatcher.sys.mjs395
1 files changed, 395 insertions, 0 deletions
diff --git a/intl/locale/LangPackMatcher.sys.mjs b/intl/locale/LangPackMatcher.sys.mjs
new file mode 100644
index 0000000000..977398b082
--- /dev/null
+++ b/intl/locale/LangPackMatcher.sys.mjs
@@ -0,0 +1,395 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+});
+
+if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
+ // This check ensures that the `mockable` API calls can be consisently mocked in tests.
+ // If this requirement needs to be eased, please ensure the test logic remains valid.
+ throw new Error("This code is assumed to run in the parent process.");
+}
+
+/**
+ * Attempts to find an appropriate langpack for a given language. The async function
+ * is infallible, but may not return a langpack.
+ *
+ * @returns {{
+ * langPack: LangPack | null,
+ * langPackDisplayName: string | null
+ * }}
+ */
+async function negotiateLangPackForLanguageMismatch() {
+ const localeInfo = getAppAndSystemLocaleInfo();
+ const nullResult = {
+ langPack: null,
+ langPackDisplayName: null,
+ };
+ if (!localeInfo.systemLocale) {
+ // The system locale info was not valid.
+ return nullResult;
+ }
+
+ /**
+ * Fetch the available langpacks from AMO.
+ *
+ * @type {Array<LangPack>}
+ */
+ const availableLangpacks = await mockable.getAvailableLangpacks();
+ if (!availableLangpacks) {
+ return nullResult;
+ }
+
+ /**
+ * Figure out a langpack to recommend.
+ * @type {LangPack | null}
+ */
+ const langPack =
+ // First look for a langpack that matches the baseName, which may include a script.
+ // e.g. system "fr-FR" matches langpack "fr-FR"
+ // system "en-GB" matches langpack "en-GB".
+ // system "zh-Hant-CN" matches langpack "zh-Hant-CN".
+ availableLangpacks.find(
+ ({ target_locale }) => target_locale === localeInfo.systemLocale.baseName
+ ) ||
+ // Next try matching language and region while excluding script
+ // e.g. system "zh-Hant-TW" matches langpack "zh-TW" but not "zh-CN".
+ availableLangpacks.find(
+ ({ target_locale }) =>
+ target_locale ===
+ `${localeInfo.systemLocale.language}-${localeInfo.systemLocale.region}`
+ ) ||
+ // Next look for langpacks that just match the language.
+ // e.g. system "fr-FR" matches langpack "fr".
+ // system "en-AU" matches langpack "en".
+ availableLangpacks.find(
+ ({ target_locale }) => target_locale === localeInfo.systemLocale.language
+ ) ||
+ // Next look for a langpack that matches the language, but not the region.
+ // e.g. "es-CL" (Chilean Spanish) as a system language matching
+ // "es-ES" (European Spanish)
+ availableLangpacks.find(({ target_locale }) =>
+ target_locale.startsWith(`${localeInfo.systemLocale.language}-`)
+ ) ||
+ null;
+
+ if (!langPack) {
+ return nullResult;
+ }
+
+ return {
+ langPack,
+ langPackDisplayName: Services.intl.getLocaleDisplayNames(
+ undefined,
+ [langPack.target_locale],
+ { preferNative: true }
+ )[0],
+ };
+}
+
+// If a langpack is being installed, allow blocking on that.
+let installingLangpack = new Map();
+
+/**
+ * @typedef {LangPack}
+ * @type {object}
+ * @property {string} target_locale
+ * @property {string} url
+ * @property {string} hash
+ */
+
+/**
+ * Ensure that a given lanpack is installed.
+ *
+ * @param {LangPack} langPack
+ * @returns {Promise<boolean>} Success or failure.
+ */
+function ensureLangPackInstalled(langPack) {
+ if (!langPack) {
+ throw new Error("Expected a LangPack to install.");
+ }
+ // Make sure any outstanding calls get resolved before attempting another call.
+ // This guards against any quick page refreshes attempting to install the langpack
+ // twice.
+ const inProgress = installingLangpack.get(langPack.hash);
+ if (inProgress) {
+ return inProgress;
+ }
+ const promise = _ensureLangPackInstalledImpl(langPack);
+ installingLangpack.set(langPack.hash, promise);
+ promise.finally(() => {
+ installingLangpack.delete(langPack.hash);
+ });
+ return promise;
+}
+
+/**
+ * @param {LangPack} langPack
+ * @returns {boolean} Success or failure.
+ */
+async function _ensureLangPackInstalledImpl(langPack) {
+ const availablelocales = await getAvailableLocales();
+ if (availablelocales.includes(langPack.target_locale)) {
+ // The langpack is already installed.
+ return true;
+ }
+
+ return mockable.installLangPack(langPack);
+}
+
+/**
+ * These are all functions with side effects or configuration options that should be
+ * mockable for tests.
+ */
+const mockable = {
+ /**
+ * @returns {LangPack[] | null}
+ */
+ async getAvailableLangpacks() {
+ try {
+ return lazy.AddonRepository.getAvailableLangpacks();
+ } catch (error) {
+ console.error(
+ `Failed to get the list of available language packs: ${error?.message}`
+ );
+ return null;
+ }
+ },
+
+ /**
+ * Use the AddonManager to install an addon from the URL.
+ * @param {LangPack} langPack
+ */
+ async installLangPack(langPack) {
+ let install;
+ try {
+ install = await lazy.AddonManager.getInstallForURL(langPack.url, {
+ hash: langPack.hash,
+ telemetryInfo: {
+ source: "about:welcome",
+ },
+ });
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+
+ try {
+ await install.install();
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Returns the available locales, including the fallback locale, which may not include
+ * all of the resources, in cases where the defaultLocale is not "en-US".
+ *
+ * @returns {string[]}
+ */
+ getAvailableLocalesIncludingFallback() {
+ return Services.locale.availableLocales;
+ },
+
+ /**
+ * @returns {string}
+ */
+ getDefaultLocale() {
+ return Services.locale.defaultLocale;
+ },
+
+ /**
+ * @returns {string}
+ */
+ getLastFallbackLocale() {
+ return Services.locale.lastFallbackLocale;
+ },
+
+ /**
+ * @returns {string}
+ */
+ getAppLocaleAsBCP47() {
+ return Services.locale.appLocaleAsBCP47;
+ },
+
+ /**
+ * @returns {string}
+ */
+ getSystemLocale() {
+ // Allow the system locale to be overridden for manual testing.
+ const systemLocaleOverride = Services.prefs.getCharPref(
+ "intl.multilingual.aboutWelcome.systemLocaleOverride",
+ null
+ );
+ if (systemLocaleOverride) {
+ try {
+ // If the locale can't be parsed, ignore the pref.
+ new Services.intl.Locale(systemLocaleOverride);
+ return systemLocaleOverride;
+ } catch (_error) {}
+ }
+
+ const osPrefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(
+ Ci.mozIOSPreferences
+ );
+ return osPrefs.systemLocale;
+ },
+
+ /**
+ * @param {string[]} locales The BCP 47 locale identifiers.
+ */
+ setRequestedAppLocales(locales) {
+ Services.locale.requestedLocales = locales;
+ },
+};
+
+/**
+ * This function is really only setting `Services.locale.requestedLocales`, but it's
+ * using the `mockable` object to allow this behavior to be mocked in tests.
+ *
+ * @param {string[]} locales The BCP 47 locale identifiers.
+ */
+function setRequestedAppLocales(locales) {
+ mockable.setRequestedAppLocales(locales);
+}
+
+/**
+ * A serializable Intl.Locale.
+ *
+ * @typedef StructuredLocale
+ * @type {object}
+ * @property {string} baseName
+ * @property {string} language
+ * @property {string} region
+ */
+
+/**
+ * In telemetry data, some of the system locales show up as blank. Guard against this
+ * and any other malformed locale information provided by the system by wrapping the call
+ * into a catch/try.
+ *
+ * @param {string} locale
+ * @returns {StructuredLocale | null}
+ */
+function getStructuredLocaleOrNull(localeString) {
+ try {
+ const locale = new Services.intl.Locale(localeString);
+ return {
+ baseName: locale.baseName,
+ language: locale.language,
+ region: locale.region,
+ };
+ } catch (_err) {
+ return null;
+ }
+}
+
+/**
+ * Determine the system and app locales, and how much the locales match.
+ *
+ * @returns {{
+ * systemLocale: StructuredLocale,
+ * appLocale: StructuredLocale,
+ * matchType: "unknown" | "language-mismatch" | "region-mismatch" | "match",
+ * }}
+ */
+function getAppAndSystemLocaleInfo() {
+ // Convert locale strings into structured locale objects.
+ const systemLocaleRaw = mockable.getSystemLocale();
+ const appLocaleRaw = mockable.getAppLocaleAsBCP47();
+
+ const systemLocale = getStructuredLocaleOrNull(systemLocaleRaw);
+ const appLocale = getStructuredLocaleOrNull(appLocaleRaw);
+
+ let matchType = "unknown";
+ if (systemLocale && appLocale) {
+ if (systemLocale.language !== appLocale.language) {
+ matchType = "language-mismatch";
+ } else if (systemLocale.region !== appLocale.region) {
+ matchType = "region-mismatch";
+ } else {
+ matchType = "match";
+ }
+ }
+
+ // Live reloading with bidi switching may not be supported.
+ let canLiveReload = null;
+ if (systemLocale && appLocale) {
+ const systemDirection = Services.intl.getScriptDirection(
+ systemLocale.language
+ );
+ const appDirection = Services.intl.getScriptDirection(appLocale.language);
+ const supportsBidiSwitching = Services.prefs.getBoolPref(
+ "intl.multilingual.liveReloadBidirectional",
+ false
+ );
+ canLiveReload = systemDirection === appDirection || supportsBidiSwitching;
+ }
+ return {
+ // Return the Intl.Locale in a serializable form.
+ systemLocaleRaw,
+ systemLocale,
+ appLocaleRaw,
+ appLocale,
+ matchType,
+ canLiveReload,
+
+ // These can be used as Fluent message args.
+ displayNames: {
+ systemLanguage: systemLocale
+ ? Services.intl.getLocaleDisplayNames(
+ undefined,
+ [systemLocale.baseName],
+ { preferNative: true }
+ )[0]
+ : null,
+ appLanguage: appLocale
+ ? Services.intl.getLocaleDisplayNames(undefined, [appLocale.baseName], {
+ preferNative: true,
+ })[0]
+ : null,
+ },
+ };
+}
+
+/**
+ * Filter the lastFallbackLocale from availableLocales if it doesn't have all
+ * of the needed strings.
+ *
+ * When the lastFallbackLocale isn't the defaultLocale, then by default only
+ * fluent strings are included. To fully use that locale you need the langpack
+ * to be installed, so if it isn't installed remove it from availableLocales.
+ */
+async function getAvailableLocales() {
+ const availableLocales = mockable.getAvailableLocalesIncludingFallback();
+ const defaultLocale = mockable.getDefaultLocale();
+ const lastFallbackLocale = mockable.getLastFallbackLocale();
+ // If defaultLocale isn't lastFallbackLocale, then we still need the langpack
+ // for lastFallbackLocale for it to be useful.
+ if (defaultLocale != lastFallbackLocale) {
+ let lastFallbackId = `langpack-${lastFallbackLocale}@firefox.mozilla.org`;
+ let lastFallbackInstalled = await lazy.AddonManager.getAddonByID(
+ lastFallbackId
+ );
+ if (!lastFallbackInstalled) {
+ return availableLocales.filter(locale => locale != lastFallbackLocale);
+ }
+ }
+ return availableLocales;
+}
+
+export var LangPackMatcher = {
+ negotiateLangPackForLanguageMismatch,
+ ensureLangPackInstalled,
+ getAppAndSystemLocaleInfo,
+ setRequestedAppLocales,
+ getAvailableLocales,
+ mockable,
+};