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