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 5const { AppConstants } = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); 6const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 7// eslint-disable-next-line mozilla/use-services 8const appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); 9const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); 10XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]); 11ChromeUtils.defineModuleGetter( 12 this, 13 "NetUtil", 14 "resource://gre/modules/NetUtil.jsm" 15); 16 17const isParentProcess = appinfo.processType === appinfo.PROCESS_TYPE_DEFAULT; 18/** 19 * L10nRegistry is a localization resource management system for Gecko. 20 * 21 * It manages the list of resource sources provided with the app and allows 22 * for additional sources to be added and updated. 23 * 24 * It's primary purpose is to allow for building an iterator over FluentBundle objects 25 * that will be utilized by a localization API. 26 * 27 * The generator creates all possible permutations of locales and sources to allow for 28 * complete fallbacking. 29 * 30 * Example: 31 * 32 * FileSource1: 33 * name: 'app' 34 * locales: ['en-US', 'de'] 35 * resources: [ 36 * '/browser/menu.ftl', 37 * '/platform/toolkit.ftl', 38 * ] 39 * FileSource2: 40 * name: 'platform' 41 * locales: ['en-US', 'de'] 42 * resources: [ 43 * '/platform/toolkit.ftl', 44 * ] 45 * 46 * If the user will request: 47 * L10nRegistry.generateBundles(['de', 'en-US'], [ 48 * '/browser/menu.ftl', 49 * '/platform/toolkit.ftl' 50 * ]); 51 * 52 * the generator will return an async iterator over the following contexts: 53 * 54 * { 55 * locale: 'de', 56 * resources: [ 57 * ['app', '/browser/menu.ftl'], 58 * ['app', '/platform/toolkit.ftl'], 59 * ] 60 * }, 61 * { 62 * locale: 'de', 63 * resources: [ 64 * ['app', '/browser/menu.ftl'], 65 * ['platform', '/platform/toolkit.ftl'], 66 * ] 67 * }, 68 * { 69 * locale: 'en-US', 70 * resources: [ 71 * ['app', '/browser/menu.ftl'], 72 * ['app', '/platform/toolkit.ftl'], 73 * ] 74 * }, 75 * { 76 * locale: 'en-US', 77 * resources: [ 78 * ['app', '/browser/menu.ftl'], 79 * ['platform', '/platform/toolkit.ftl'], 80 * ] 81 * } 82 * 83 * This allows the localization API to consume the FluentBundle and lazily fallback 84 * on the next in case of a missing string or error. 85 * 86 * If during the life-cycle of the app a new source is added, the generator can be called again 87 * and will produce a new set of permutations placing the language pack provided resources 88 * at the top. 89 * 90 * Notice: L10nRegistry is primarily an asynchronous API, but 91 * it does provide a synchronous version of it's main method 92 * for use by the `Localization` class when in `sync` state. 93 * This API should be only used in very specialized cases and 94 * the uses should be reviewed by the toolkit owner/peer. 95 */ 96class L10nRegistryService { 97 constructor() { 98 this.sources = new Map(); 99 100 if (isParentProcess) { 101 const locales = Services.locale.packagedLocales; 102 // Categories are sorted alphabetically, so we name our sources: 103 // - 0-toolkit 104 // - 5-browser 105 // - langpack-{locale} 106 // 107 // This should ensure that they're returned in the correct order. 108 let fileSources = []; 109 for (let {entry, value} of Services.catMan.enumerateCategory("l10n-registry")) { 110 if (!this.hasSource(entry)) { 111 fileSources.push(new FileSource(entry, locales, value)); 112 } 113 } 114 this.registerSources(fileSources); 115 } else { 116 this._setSourcesFromSharedData(); 117 Services.cpmm.sharedData.addEventListener("change", this); 118 } 119 } 120 121 /** 122 * Empty the sources to mimic shutdown for testing from xpcshell. 123 */ 124 clearSources() { 125 this.sources = new Map(); 126 Services.locale.availableLocales = this.getAvailableLocales(); 127 } 128 129 handleEvent(event) { 130 if (event.type === "change") { 131 if (event.changedKeys.includes("L10nRegistry:Sources")) { 132 this._setSourcesFromSharedData(); 133 } 134 } 135 } 136 137 /** 138 * Based on the list of requested languages and resource Ids, 139 * this function returns an lazy iterator over message context permutations. 140 * 141 * Notice: Any changes to this method should be copied 142 * to the `generateBundlesSync` equivalent below. 143 * 144 * @param {Array} requestedLangs 145 * @param {Array} resourceIds 146 * @returns {AsyncIterator<FluentBundle>} 147 */ 148 async* generateBundles(requestedLangs, resourceIds) { 149 const resourceIdsDedup = Array.from(new Set(resourceIds)); 150 const sourcesOrder = Array.from(this.sources.keys()).reverse(); 151 const pseudoStrategy = Services.prefs.getStringPref("intl.l10n.pseudo", ""); 152 for (const locale of requestedLangs) { 153 for await (const dataSets of generateResourceSetsForLocale(locale, sourcesOrder, resourceIdsDedup)) { 154 const bundle = new FluentBundle(locale, { 155 ...MSG_CONTEXT_OPTIONS, 156 pseudoStrategy, 157 }); 158 for (const data of dataSets) { 159 if (data === null) { 160 return; 161 } 162 bundle.addResource(data); 163 } 164 yield bundle; 165 } 166 } 167 } 168 169 /** 170 * This is a synchronous version of the `generateBundles` 171 * method and should stay completely in sync with it at all 172 * times except of the async/await changes. 173 * 174 * Notice: This method should be avoided at all costs 175 * You can think of it similarly to a synchronous XMLHttpRequest. 176 * 177 * @param {Array} requestedLangs 178 * @param {Array} resourceIds 179 * @returns {Iterator<FluentBundle>} 180 */ 181 * generateBundlesSync(requestedLangs, resourceIds) { 182 const resourceIdsDedup = Array.from(new Set(resourceIds)); 183 const sourcesOrder = Array.from(this.sources.keys()).reverse(); 184 const pseudoStrategy = Services.prefs.getStringPref("intl.l10n.pseudo", ""); 185 for (const locale of requestedLangs) { 186 for (const dataSets of generateResourceSetsForLocaleSync(locale, sourcesOrder, resourceIdsDedup)) { 187 const bundle = new FluentBundle(locale, { 188 ...MSG_CONTEXT_OPTIONS, 189 pseudoStrategy 190 }); 191 for (const data of dataSets) { 192 if (data === null) { 193 return; 194 } 195 bundle.addResource(data); 196 } 197 yield bundle; 198 } 199 } 200 } 201 202 /** 203 * Check whether a source with the given known is already registered. 204 * 205 * @param {String} sourceName 206 * @returns {boolean} whether or not a source by that name is known. 207 */ 208 hasSource(sourceName) { 209 return this.sources.has(sourceName); 210 } 211 212 /** 213 * Adds new resource source(s) to the L10nRegistry. 214 * 215 * Notice: Each invocation of this method flushes any changes out to extant 216 * content processes, which is expensive. Please coalesce multiple 217 * registrations into a single sources array and then call this method once. 218 * 219 * @param {Array<FileSource>} sources 220 */ 221 registerSources(sources) { 222 for (const source of sources) { 223 if (this.hasSource(source.name)) { 224 throw new Error(`Source with name "${source.name}" already registered.`); 225 } 226 this.sources.set(source.name, source); 227 } 228 if (isParentProcess && sources.length > 0) { 229 this._synchronizeSharedData(); 230 Services.locale.availableLocales = this.getAvailableLocales(); 231 } 232 } 233 234 /** 235 * Updates existing sources in the L10nRegistry 236 * 237 * That will usually happen when a new version of a source becomes 238 * available (for example, an updated version of a language pack). 239 * 240 * Notice: Each invocation of this method flushes any changes out to extant 241 * content processes, which is expensive. Please coalesce multiple updates 242 * into a single sources array and then call this method once. 243 * 244 * @param {Array<FileSource>} sources 245 */ 246 updateSources(sources) { 247 for (const source of sources) { 248 if (!this.hasSource(source.name)) { 249 throw new Error(`Source with name "${source.name}" is not registered.`); 250 } 251 this.sources.set(source.name, source); 252 } 253 if (isParentProcess && sources.length > 0) { 254 this._synchronizeSharedData(); 255 Services.locale.availableLocales = this.getAvailableLocales(); 256 } 257 } 258 259 /** 260 * Removes sources from the L10nRegistry. 261 * 262 * Notice: Each invocation of this method flushes any changes out to extant 263 * content processes, which is expensive. Please coalesce multiple removals 264 * into a single sourceNames array and then call this method once. 265 * 266 * @param {Array<String>} sourceNames 267 */ 268 removeSources(sourceNames) { 269 for (const sourceName of sourceNames) { 270 this.sources.delete(sourceName); 271 } 272 if (isParentProcess && sourceNames.length > 0) { 273 this._synchronizeSharedData(); 274 Services.locale.availableLocales = this.getAvailableLocales(); 275 } 276 } 277 278 _synchronizeSharedData() { 279 const sources = new Map(); 280 for (const [name, source] of this.sources.entries()) { 281 if (source.indexed) { 282 continue; 283 } 284 sources.set(name, { 285 locales: source.locales, 286 prePath: source.prePath, 287 }); 288 } 289 let sharedData = Services.ppmm.sharedData; 290 sharedData.set("L10nRegistry:Sources", sources); 291 // We must explicitly flush or else flushing won't happen until the main 292 // thread goes idle. 293 sharedData.flush(); 294 } 295 296 _setSourcesFromSharedData() { 297 let sources = Services.cpmm.sharedData.get("L10nRegistry:Sources"); 298 if (!sources) { 299 console.warn(`[l10nregistry] Failed to fetch sources from shared data.`); 300 return; 301 } 302 let registerSourcesList = []; 303 for (let [name, data] of sources.entries()) { 304 if (!this.hasSource(name)) { 305 const source = new FileSource(name, data.locales, data.prePath); 306 registerSourcesList.push(source); 307 } 308 } 309 this.registerSources(registerSourcesList); 310 let removeSourcesList = []; 311 for (let name of this.sources.keys()) { 312 if (!sources.has(name)) { 313 removeSourcesList.push(name); 314 } 315 } 316 this.removeSources(removeSourcesList); 317 } 318 319 /** 320 * Returns a list of locales for which at least one source 321 * has resources. 322 * 323 * @returns {Array<String>} 324 */ 325 getAvailableLocales() { 326 const locales = new Set(); 327 328 for (const source of this.sources.values()) { 329 for (const locale of source.locales) { 330 locales.add(locale); 331 } 332 } 333 return Array.from(locales); 334 } 335} 336 337/** 338 * This function generates an iterator over FluentBundles for a single locale 339 * for a given list of resourceIds for all possible combinations of sources. 340 * 341 * This function is called recursively to generate all possible permutations 342 * and uses the last, optional parameter, to pass the already resolved 343 * sources order. 344 * 345 * Notice: Any changes to this method should be copied 346 * to the `generateResourceSetsForLocaleSync` equivalent below. 347 * 348 * @param {String} locale 349 * @param {Array} sourcesOrder 350 * @param {Array} resourceIds 351 * @param {Array} [resolvedOrder] 352 * @returns {AsyncIterator<FluentBundle>} 353 */ 354async function* generateResourceSetsForLocale(locale, sourcesOrder, resourceIds, resolvedOrder = []) { 355 const resolvedLength = resolvedOrder.length; 356 const resourcesLength = resourceIds.length; 357 358 // Inside that loop we have a list of resources and the sources for them, like this: 359 // ['test.ftl', 'menu.ftl', 'foo.ftl'] 360 // ['app', 'platform', 'app'] 361 for (const sourceName of sourcesOrder) { 362 const order = resolvedOrder.concat(sourceName); 363 364 // We want to bail out early if we know that any of 365 // the (res)x(source) combinations in the permutation 366 // are unavailable. 367 // The combination may have been `undefined` when we 368 // stepped into this branch, and now is resolved to 369 // `false`. 370 // 371 // If the combination resolved to `false` is the last 372 // in the resolvedOrder, we want to continue in this 373 // loop, but if it's somewhere in the middle, we can 374 // safely bail from the whole branch. 375 for (let [idx, sourceName] of order.entries()) { 376 const source = L10nRegistry.sources.get(sourceName); 377 if (!source || source.hasFile(locale, resourceIds[idx]) === false) { 378 if (idx === order.length - 1) { 379 continue; 380 } else { 381 return; 382 } 383 } 384 } 385 386 // If the number of resolved sources equals the number of resources, 387 // create the right context and return it if it loads. 388 if (resolvedLength + 1 === resourcesLength) { 389 let dataSet = await generateResourceSet(locale, order, resourceIds); 390 // Here we check again to see if the newly resolved 391 // resources returned `false` on any position. 392 if (!dataSet.includes(false)) { 393 yield dataSet; 394 } 395 } else if (resolvedLength < resourcesLength) { 396 // otherwise recursively load another generator that walks over the 397 // partially resolved list of sources. 398 yield * generateResourceSetsForLocale(locale, sourcesOrder, resourceIds, order); 399 } 400 } 401} 402 403/** 404 * This is a synchronous version of the `generateResourceSetsForLocale` 405 * method and should stay completely in sync with it at all 406 * times except of the async/await changes. 407 * 408 * @param {String} locale 409 * @param {Array} sourcesOrder 410 * @param {Array} resourceIds 411 * @param {Array} [resolvedOrder] 412 * @returns {Iterator<FluentBundle>} 413 */ 414function* generateResourceSetsForLocaleSync(locale, sourcesOrder, resourceIds, resolvedOrder = []) { 415 const resolvedLength = resolvedOrder.length; 416 const resourcesLength = resourceIds.length; 417 418 // Inside that loop we have a list of resources and the sources for them, like this: 419 // ['test.ftl', 'menu.ftl', 'foo.ftl'] 420 // ['app', 'platform', 'app'] 421 for (const sourceName of sourcesOrder) { 422 const order = resolvedOrder.concat(sourceName); 423 424 // We want to bail out early if we know that any of 425 // the (res)x(source) combinations in the permutation 426 // are unavailable. 427 // The combination may have been `undefined` when we 428 // stepped into this branch, and now is resolved to 429 // `false`. 430 // 431 // If the combination resolved to `false` is the last 432 // in the resolvedOrder, we want to continue in this 433 // loop, but if it's somewhere in the middle, we can 434 // safely bail from the whole branch. 435 for (let [idx, sourceName] of order.entries()) { 436 const source = L10nRegistry.sources.get(sourceName); 437 if (!source || source.hasFile(locale, resourceIds[idx]) === false) { 438 if (idx === order.length - 1) { 439 continue; 440 } else { 441 return; 442 } 443 } 444 } 445 446 // If the number of resolved sources equals the number of resources, 447 // create the right context and return it if it loads. 448 if (resolvedLength + 1 === resourcesLength) { 449 let dataSet = generateResourceSetSync(locale, order, resourceIds); 450 // Here we check again to see if the newly resolved 451 // resources returned `false` on any position. 452 if (!dataSet.includes(false)) { 453 yield dataSet; 454 } 455 } else if (resolvedLength < resourcesLength) { 456 // otherwise recursively load another generator that walks over the 457 // partially resolved list of sources. 458 yield * generateResourceSetsForLocaleSync(locale, sourcesOrder, resourceIds, order); 459 } 460 } 461} 462 463const MSG_CONTEXT_OPTIONS = { 464 // Temporarily disable bidi isolation due to Microsoft not supporting FSI/PDI. 465 // See bug 1439018 for details. 466 useIsolating: Services.prefs.getBoolPref("intl.l10n.enable-bidi-marks", false), 467}; 468 469/** 470 * Generates a single FluentBundle by loading all resources 471 * from the listed sources for a given locale. 472 * 473 * The function casts all error cases into a Promise that resolves with 474 * value `null`. 475 * This allows the caller to be an async generator without using 476 * try/catch clauses. 477 * 478 * Notice: Any changes to this method should be copied 479 * to the `generateResourceSetSync` equivalent below. 480 * 481 * @param {String} locale 482 * @param {Array} sourcesOrder 483 * @param {Array} resourceIds 484 * @returns {Promise<FluentBundle>} 485 */ 486function generateResourceSet(locale, sourcesOrder, resourceIds) { 487 return Promise.all(resourceIds.map((resourceId, i) => { 488 const source = L10nRegistry.sources.get(sourcesOrder[i]); 489 if (!source) { 490 return false; 491 } 492 return source.fetchFile(locale, resourceId); 493 })); 494} 495 496/** 497 * This is a synchronous version of the `generateResourceSet` 498 * method and should stay completely in sync with it at all 499 * times except of the async/await changes. 500 * 501 * @param {String} locale 502 * @param {Array} sourcesOrder 503 * @param {Array} resourceIds 504 * @returns {FluentBundle} 505 */ 506function generateResourceSetSync(locale, sourcesOrder, resourceIds) { 507 return resourceIds.map((resourceId, i) => { 508 const source = L10nRegistry.sources.get(sourcesOrder[i]); 509 if (!source) { 510 return false; 511 } 512 return source.fetchFile(locale, resourceId, {sync: true}); 513 }); 514} 515 516/** 517 * This is a basic Source for L10nRegistry. 518 * It registers its own locales and a pre-path, and when asked for a file 519 * it attempts to download and cache it. 520 * 521 * The Source caches the downloaded files so any consecutive loads will 522 * come from the cache. 523 **/ 524class FileSource { 525 /** 526 * @param {string} name 527 * @param {Array<string>} locales 528 * @param {string} prePath 529 * 530 * @returns {FileSource} 531 */ 532 constructor(name, locales, prePath) { 533 this.name = name; 534 this.locales = locales; 535 this.prePath = prePath; 536 this.indexed = false; 537 538 // The cache object stores information about the resources available 539 // in the Source. 540 // 541 // It can take one of three states: 542 // * true - the resource is available but not fetched yet 543 // * false - the resource is not available 544 // * Promise - the resource has been fetched 545 // 546 // If the cache has no entry for a given path, that means that there 547 // is no information available about whether the resource is available. 548 // 549 // If the `indexed` property is set to `true` it will be treated as the 550 // resource not being available. Otherwise, the resource may be 551 // available and we do not have any information about it yet. 552 this.cache = {}; 553 } 554 555 getPath(locale, path) { 556 // This is a special case for the only not BCP47-conformant locale 557 // code we have resources for. 558 if (locale === "ja-JP-macos") { 559 locale = "ja-JP-mac"; 560 } 561 return (this.prePath + path).replace(/\{locale\}/g, locale); 562 } 563 564 hasFile(locale, path) { 565 if (!this.locales.includes(locale)) { 566 return false; 567 } 568 569 const fullPath = this.getPath(locale, path); 570 if (!this.cache.hasOwnProperty(fullPath)) { 571 return this.indexed ? false : undefined; 572 } 573 if (this.cache[fullPath] === false) { 574 return false; 575 } 576 if (this.cache[fullPath].then) { 577 return undefined; 578 } 579 return true; 580 } 581 582 fetchFile(locale, path, options = {sync: false}) { 583 if (!this.locales.includes(locale)) { 584 return false; 585 } 586 587 const fullPath = this.getPath(locale, path); 588 589 if (this.cache.hasOwnProperty(fullPath)) { 590 if (this.cache[fullPath] === false) { 591 return false; 592 } 593 // `true` means that the file is indexed, but hasn't 594 // been fetched yet. 595 if (this.cache[fullPath] !== true) { 596 if (this.cache[fullPath] instanceof Promise && options.sync) { 597 console.warn(`[l10nregistry] Attempting to synchronously load file 598 ${fullPath} while it's being loaded asynchronously.`); 599 } else { 600 return this.cache[fullPath]; 601 } 602 } 603 } else if (this.indexed) { 604 return false; 605 } 606 if (options.sync) { 607 let data = L10nRegistry.loadSync(fullPath); 608 609 if (data === false) { 610 this.cache[fullPath] = false; 611 } else { 612 this.cache[fullPath] = new FluentResource(data); 613 } 614 615 return this.cache[fullPath]; 616 } 617 618 // async 619 return this.cache[fullPath] = L10nRegistry.load(fullPath).then( 620 data => { 621 return this.cache[fullPath] = new FluentResource(data); 622 }, 623 err => { 624 this.cache[fullPath] = false; 625 return false; 626 } 627 ); 628 } 629} 630 631/** 632 * This is an extension of the FileSource which should be used 633 * for sources that can provide the list of files available in the source. 634 * 635 * This allows for a faster lookup in cases where the source does not 636 * contain most of the files that the app will request for (e.g. an addon). 637 **/ 638class IndexedFileSource extends FileSource { 639 /** 640 * @param {string} name 641 * @param {Array<string>} locales 642 * @param {string} prePath 643 * @param {Array<string>} paths 644 * 645 * @returns {IndexedFileSource} 646 */ 647 constructor(name, locales, prePath, paths) { 648 super(name, locales, prePath); 649 this.indexed = true; 650 for (const path of paths) { 651 this.cache[path] = true; 652 } 653 } 654} 655 656this.L10nRegistry = new L10nRegistryService(); 657 658/** 659 * The low level wrapper around Fetch API. It unifies the error scenarios to 660 * always produce a promise rejection. 661 * 662 * We keep it as a method to make it easier to override for testing purposes. 663 * 664 * @param {string} url 665 * 666 * @returns {Promise<string>} 667 */ 668L10nRegistry.load = function(url) { 669 return fetch(url).then(response => { 670 if (!response.ok) { 671 return Promise.reject(response.statusText); 672 } 673 return response.text(); 674 }); 675}; 676 677/** 678 * This is a synchronous version of the `load` 679 * function and should stay completely in sync with it at all 680 * times except of the async/await changes. 681 * 682 * Notice: Any changes to this method should be copied 683 * to the `generateResourceSetSync` equivalent below. 684 * 685 * @param {string} url 686 * 687 * @returns {string} 688 */ 689L10nRegistry.loadSync = function(uri) { 690 try { 691 let url = Services.io.newURI(uri); 692 let data = Cu.readUTF8URI(url); 693 return data; 694 } catch (e) { 695 if ( 696 e.result == Cr.NS_ERROR_INVALID_ARG || 697 e.result == Cr.NS_ERROR_NOT_INITIALIZED 698 ) { 699 try { 700 // The preloader doesn't support this url or isn't initialized 701 // (xpcshell test). Try a synchronous channel load. 702 let stream = NetUtil.newChannel({ 703 uri, 704 loadUsingSystemPrincipal: true, 705 }).open(); 706 707 return NetUtil.readInputStreamToString(stream, stream.available(), { 708 charset: "UTF-8", 709 }); 710 } catch (e) { 711 if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) { 712 Cu.reportError(e); 713 } 714 } 715 } else if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) { 716 Cu.reportError(e); 717 } 718 } 719 720 return false; 721}; 722 723this.FileSource = FileSource; 724this.IndexedFileSource = IndexedFileSource; 725 726var EXPORTED_SYMBOLS = ["L10nRegistry", "FileSource", "IndexedFileSource"]; 727