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