diff options
Diffstat (limited to 'intl/locale/LangPackMatcher.sys.mjs')
-rw-r--r-- | intl/locale/LangPackMatcher.sys.mjs | 395 |
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, +}; |