tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

browserLanguages.js (21634B)


      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 /* import-globals-from /toolkit/content/preferencesBindings.js */
      6 
      7 // This is exported by preferences.js but we can't import that in a subdialog.
      8 let { LangPackMatcher } = window.top;
      9 
     10 ChromeUtils.defineESModuleGetters(this, {
     11  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
     12  AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
     13  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
     14  SelectionChangedMenulist:
     15    "resource:///modules/SelectionChangedMenulist.sys.mjs",
     16 });
     17 
     18 /* This dialog provides an interface for managing what language the browser is
     19 * displayed in.
     20 *
     21 * There is a list of "requested" locales and a list of "available" locales. The
     22 * requested locales must be installed and enabled. Available locales could be
     23 * installed and enabled, or fetched from the AMO language tools API.
     24 *
     25 * If a langpack is disabled, there is no way to determine what locale it is for and
     26 * it will only be listed as available if that locale is also available on AMO and
     27 * the user has opted to search for more languages.
     28 */
     29 
     30 async function installFromUrl(url, hash, callback) {
     31  let telemetryInfo = {
     32    source: "about:preferences",
     33  };
     34  let install = await AddonManager.getInstallForURL(url, {
     35    hash,
     36    telemetryInfo,
     37  });
     38  if (callback) {
     39    callback(install.installId.toString());
     40  }
     41  await install.install();
     42  return install.addon;
     43 }
     44 
     45 async function dictionaryIdsForLocale(locale) {
     46  let entries = await RemoteSettings("language-dictionaries").get({
     47    filters: { id: locale },
     48  });
     49  if (entries.length) {
     50    return entries[0].dictionaries;
     51  }
     52  return [];
     53 }
     54 
     55 class OrderedListBox {
     56  constructor({
     57    richlistbox,
     58    upButton,
     59    downButton,
     60    removeButton,
     61    onRemove,
     62    onReorder,
     63  }) {
     64    this.richlistbox = richlistbox;
     65    this.upButton = upButton;
     66    this.downButton = downButton;
     67    this.removeButton = removeButton;
     68    this.onRemove = onRemove;
     69    this.onReorder = onReorder;
     70 
     71    this.items = [];
     72 
     73    this.richlistbox.addEventListener("select", () => this.setButtonState());
     74    this.upButton.addEventListener("command", () => this.moveUp());
     75    this.downButton.addEventListener("command", () => this.moveDown());
     76    this.removeButton.addEventListener("command", () => this.removeItem());
     77  }
     78 
     79  get selectedItem() {
     80    return this.items[this.richlistbox.selectedIndex];
     81  }
     82 
     83  setButtonState() {
     84    let { upButton, downButton, removeButton } = this;
     85    let { selectedIndex, itemCount } = this.richlistbox;
     86    upButton.disabled = selectedIndex <= 0;
     87    downButton.disabled = selectedIndex == itemCount - 1;
     88    removeButton.disabled = itemCount <= 1 || !this.selectedItem.canRemove;
     89  }
     90 
     91  moveUp() {
     92    let { selectedIndex } = this.richlistbox;
     93    if (selectedIndex == 0) {
     94      return;
     95    }
     96    let { items } = this;
     97    let selectedItem = items[selectedIndex];
     98    let prevItem = items[selectedIndex - 1];
     99    items[selectedIndex - 1] = items[selectedIndex];
    100    items[selectedIndex] = prevItem;
    101    let prevEl = document.getElementById(prevItem.id);
    102    let selectedEl = document.getElementById(selectedItem.id);
    103    this.richlistbox.insertBefore(selectedEl, prevEl);
    104    this.richlistbox.ensureElementIsVisible(selectedEl);
    105    this.setButtonState();
    106 
    107    this.onReorder();
    108  }
    109 
    110  moveDown() {
    111    let { selectedIndex } = this.richlistbox;
    112    if (selectedIndex == this.items.length - 1) {
    113      return;
    114    }
    115    let { items } = this;
    116    let selectedItem = items[selectedIndex];
    117    let nextItem = items[selectedIndex + 1];
    118    items[selectedIndex + 1] = items[selectedIndex];
    119    items[selectedIndex] = nextItem;
    120    let nextEl = document.getElementById(nextItem.id);
    121    let selectedEl = document.getElementById(selectedItem.id);
    122    this.richlistbox.insertBefore(nextEl, selectedEl);
    123    this.richlistbox.ensureElementIsVisible(selectedEl);
    124    this.setButtonState();
    125 
    126    this.onReorder();
    127  }
    128 
    129  removeItem() {
    130    let { selectedIndex } = this.richlistbox;
    131 
    132    if (selectedIndex == -1) {
    133      return;
    134    }
    135 
    136    let [item] = this.items.splice(selectedIndex, 1);
    137    this.richlistbox.selectedItem.remove();
    138    this.richlistbox.selectedIndex = Math.min(
    139      selectedIndex,
    140      this.richlistbox.itemCount - 1
    141    );
    142    this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
    143    this.onRemove(item);
    144  }
    145 
    146  setItems(items) {
    147    this.items = items;
    148    this.populate();
    149    this.setButtonState();
    150  }
    151 
    152  /**
    153   * Add an item to the top of the ordered list.
    154   *
    155   * @param {object} item The item to insert.
    156   */
    157  addItem(item) {
    158    this.items.unshift(item);
    159    this.richlistbox.insertBefore(
    160      this.createItem(item),
    161      this.richlistbox.firstElementChild
    162    );
    163    this.richlistbox.selectedIndex = 0;
    164    this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
    165  }
    166 
    167  populate() {
    168    this.richlistbox.textContent = "";
    169 
    170    let frag = document.createDocumentFragment();
    171    for (let item of this.items) {
    172      frag.appendChild(this.createItem(item));
    173    }
    174    this.richlistbox.appendChild(frag);
    175 
    176    this.richlistbox.selectedIndex = 0;
    177    this.richlistbox.ensureElementIsVisible(this.richlistbox.selectedItem);
    178  }
    179 
    180  createItem({ id, label, value }) {
    181    let listitem = document.createXULElement("richlistitem");
    182    listitem.id = id;
    183    listitem.setAttribute("value", value);
    184 
    185    let labelEl = document.createXULElement("label");
    186    labelEl.textContent = label;
    187    listitem.appendChild(labelEl);
    188 
    189    return listitem;
    190  }
    191 }
    192 
    193 /**
    194 * The sorted select list of Locales available for the app.
    195 */
    196 class SortedItemSelectList {
    197  constructor({ menulist, button, onSelect, onChange, compareFn }) {
    198    /** @type {XULElement} */
    199    this.menulist = menulist;
    200 
    201    /** @type {XULElement} */
    202    this.popup = menulist.menupopup;
    203 
    204    /** @type {XULElement} */
    205    this.button = button;
    206 
    207    /** @type {(a: LocaleDisplayInfo, b: LocaleDisplayInfo) => number} */
    208    this.compareFn = compareFn;
    209 
    210    /** @type {Array<LocaleDisplayInfo>} */
    211    this.items = [];
    212 
    213    // This will register the "command" listener.
    214    new SelectionChangedMenulist(this.menulist, () => {
    215      button.disabled = !menulist.selectedItem;
    216      if (menulist.selectedItem) {
    217        onChange(this.items[menulist.selectedIndex]);
    218      }
    219    });
    220    button.addEventListener("command", () => {
    221      if (!menulist.selectedItem) {
    222        return;
    223      }
    224 
    225      let [item] = this.items.splice(menulist.selectedIndex, 1);
    226      menulist.selectedItem.remove();
    227      menulist.setAttribute("label", menulist.getAttribute("placeholder"));
    228      button.disabled = true;
    229      menulist.disabled = menulist.itemCount == 0;
    230      menulist.selectedIndex = -1;
    231 
    232      onSelect(item);
    233    });
    234  }
    235 
    236  /**
    237   * @param {Array<LocaleDisplayInfo>} items
    238   */
    239  setItems(items) {
    240    this.items = items.sort(this.compareFn);
    241    this.populate();
    242  }
    243 
    244  populate() {
    245    let { button, items, menulist, popup } = this;
    246    popup.textContent = "";
    247 
    248    let frag = document.createDocumentFragment();
    249    for (let item of items) {
    250      frag.appendChild(this.createItem(item));
    251    }
    252    popup.appendChild(frag);
    253 
    254    menulist.setAttribute("label", menulist.getAttribute("placeholder"));
    255    menulist.disabled = menulist.itemCount == 0;
    256    menulist.selectedIndex = -1;
    257    button.disabled = true;
    258  }
    259 
    260  /**
    261   * Add an item to the list sorted by the label.
    262   *
    263   * @param {object} item The item to insert.
    264   */
    265  addItem(item) {
    266    let { compareFn, items, menulist, popup } = this;
    267 
    268    // Find the index of the item to insert before.
    269    let i = items.findIndex(el => compareFn(el, item) >= 0);
    270    items.splice(i, 0, item);
    271    popup.insertBefore(this.createItem(item), menulist.getItemAtIndex(i));
    272 
    273    menulist.disabled = menulist.itemCount == 0;
    274  }
    275 
    276  createItem({ label, value, className, disabled }) {
    277    let item = document.createXULElement("menuitem");
    278    item.setAttribute("label", label);
    279    if (value) {
    280      item.value = value;
    281    }
    282    if (className) {
    283      item.classList.add(className);
    284    }
    285    if (disabled) {
    286      item.setAttribute("disabled", "true");
    287    }
    288    return item;
    289  }
    290 
    291  /**
    292   * Disable the inputs and set a data-l10n-id on the menulist. This can be
    293   * reverted with `enableWithMessageId()`.
    294   */
    295  disableWithMessageId(messageId) {
    296    document.l10n.setAttributes(this.menulist, messageId);
    297    this.menulist.setAttribute(
    298      "image",
    299      "chrome://global/skin/icons/loading.svg"
    300    );
    301    this.menulist.disabled = true;
    302    this.button.disabled = true;
    303  }
    304 
    305  /**
    306   * Enable the inputs and set a data-l10n-id on the menulist. This can be
    307   * reverted with `disableWithMessageId()`.
    308   */
    309  enableWithMessageId(messageId) {
    310    document.l10n.setAttributes(this.menulist, messageId);
    311    this.menulist.removeAttribute("image");
    312    this.menulist.disabled = this.menulist.itemCount == 0;
    313    this.button.disabled = !this.menulist.selectedItem;
    314  }
    315 }
    316 
    317 /**
    318 * @typedef LocaleDisplayInfo
    319 * @type {object}
    320 * @property {string} id - A unique ID.
    321 * @property {string} label - The localized display name.
    322 * @property {string} value - The BCP 47 locale identifier or the word "search".
    323 * @property {boolean} canRemove - The default locale cannot be removed.
    324 * @property {boolean} installed - Whether or not the locale is installed.
    325 */
    326 
    327 /**
    328 * @param {Array<string>} localeCodes - List of BCP 47 locale identifiers.
    329 * @returns {Array<LocaleDisplayInfo>}
    330 */
    331 async function getLocaleDisplayInfo(localeCodes) {
    332  let availableLocales = new Set(await LangPackMatcher.getAvailableLocales());
    333  let localeNames = Services.intl.getLocaleDisplayNames(
    334    undefined,
    335    localeCodes,
    336    { preferNative: true }
    337  );
    338  return localeCodes.map((code, i) => {
    339    return {
    340      id: "locale-" + code,
    341      label: localeNames[i],
    342      value: code,
    343      canRemove: code != Services.locale.defaultLocale,
    344      installed: availableLocales.has(code),
    345    };
    346  });
    347 }
    348 
    349 /**
    350 * @param {LocaleDisplayInfo} a
    351 * @param {LocaleDisplayInfo} b
    352 * @returns {number}
    353 */
    354 function compareItems(a, b) {
    355  // Sort by installed.
    356  if (a.installed != b.installed) {
    357    return a.installed ? -1 : 1;
    358 
    359    // The search label is always last.
    360  } else if (a.value == "search") {
    361    return 1;
    362  } else if (b.value == "search") {
    363    return -1;
    364 
    365    // If both items are locales, sort by label.
    366  } else if (a.value && b.value) {
    367    return a.label.localeCompare(b.label);
    368 
    369    // One of them is a label, put it first.
    370  } else if (a.value) {
    371    return 1;
    372  }
    373  return -1;
    374 }
    375 
    376 var gBrowserLanguagesDialog = {
    377  /**
    378   * The publicly readable list of selected locales. It is only set when the dialog is
    379   * accepted, and can be retrieved elsewhere by directly reading the property
    380   * on gBrowserLanguagesDialog.
    381   *
    382   *   let { selected } = gBrowserLanguagesDialog;
    383   *
    384   * @type {null | Array<string>}
    385   */
    386  selected: null,
    387 
    388  /**
    389   * @type {string | null} An ID used for telemetry pings. It is unique to the current
    390   * opening of the browser language.
    391   */
    392  _telemetryId: null,
    393 
    394  /**
    395   * @type {SortedItemSelectList}
    396   */
    397  _availableLocalesUI: null,
    398 
    399  /**
    400   * @type {OrderedListBox}
    401   */
    402  _selectedLocalesUI: null,
    403 
    404  get downloadEnabled() {
    405    // Downloading langpacks isn't always supported, check the pref.
    406    return Services.prefs.getBoolPref("intl.multilingual.downloadEnabled");
    407  },
    408 
    409  recordTelemetry(method, extra = {}) {
    410    extra.value = this._telemetryId;
    411    Glean.intlUiBrowserLanguage[method + "Dialog"].record(extra);
    412  },
    413 
    414  async onLoad() {
    415    /**
    416     * @typedef {object} Options - Options passed in to configure the subdialog.
    417     * @property {string} telemetryId,
    418     * @property {Array<string>} [selectedLocalesForRestart] The optional list of
    419     *   previously selected locales for when a restart is required. This list is
    420     *   preserved between openings of the dialog.
    421     * @property {boolean} search Whether the user opened this from "Search for more
    422     *   languages" option.
    423     */
    424 
    425    /** @type {Options} */
    426    let { telemetryId, selectedLocalesForRestart, search } =
    427      window.arguments[0];
    428 
    429    this._telemetryId = telemetryId;
    430 
    431    // This is a list of available locales that the user selected. It's more
    432    // restricted than the Intl notion of `requested` as it only contains
    433    // locale codes for which we have matching locales available.
    434    // The first time this dialog is opened, populate with appLocalesAsBCP47.
    435    let selectedLocales =
    436      selectedLocalesForRestart || Services.locale.appLocalesAsBCP47;
    437    let selectedLocaleSet = new Set(selectedLocales);
    438    let available = await LangPackMatcher.getAvailableLocales();
    439    let availableSet = new Set(available);
    440 
    441    // Filter selectedLocales since the user may select a locale when it is
    442    // available and then disable it.
    443    selectedLocales = selectedLocales.filter(locale =>
    444      availableSet.has(locale)
    445    );
    446    // Nothing in available should be in selectedSet.
    447    available = available.filter(locale => !selectedLocaleSet.has(locale));
    448 
    449    await this.initSelectedLocales(selectedLocales);
    450    await this.initAvailableLocales(available, search);
    451 
    452    this.initialized = true;
    453 
    454    // Now the component is initialized, it's safe to accept the results.
    455    document
    456      .getElementById("BrowserLanguagesDialog")
    457      .addEventListener("beforeaccept", () => {
    458        this.selected = this._selectedLocalesUI.items.map(item => item.value);
    459      });
    460  },
    461 
    462  /**
    463   * @param {string[]} selectedLocales - BCP 47 locale identifiers
    464   */
    465  async initSelectedLocales(selectedLocales) {
    466    this._selectedLocalesUI = new OrderedListBox({
    467      richlistbox: document.getElementById("selectedLocales"),
    468      upButton: document.getElementById("up"),
    469      downButton: document.getElementById("down"),
    470      removeButton: document.getElementById("remove"),
    471      onRemove: item => this.selectedLocaleRemoved(item),
    472      onReorder: () => this.recordTelemetry("reorder"),
    473    });
    474    this._selectedLocalesUI.setItems(
    475      await getLocaleDisplayInfo(selectedLocales)
    476    );
    477  },
    478 
    479  /**
    480   * @param {Set<string>} available - The set of available BCP 47 locale identifiers.
    481   * @param {boolean} search - Whether the user opened this from "Search for more
    482   *                           languages" option.
    483   */
    484  async initAvailableLocales(available, search) {
    485    this._availableLocalesUI = new SortedItemSelectList({
    486      menulist: document.getElementById("availableLocales"),
    487      button: document.getElementById("add"),
    488      compareFn: compareItems,
    489      onSelect: item => this.availableLanguageSelected(item),
    490      onChange: item => {
    491        this.hideError();
    492        if (item.value == "search") {
    493          // Record the search event here so we don't track the search from
    494          // the main preferences pane twice.
    495          this.recordTelemetry("search");
    496          this.loadLocalesFromAMO();
    497        }
    498      },
    499    });
    500 
    501    // Populate the list with the installed locales even if the user is
    502    // searching in case the download fails.
    503    await this.loadLocalesFromInstalled(available);
    504 
    505    // If the user opened this from the "Search for more languages" option,
    506    // search AMO for available locales.
    507    if (search) {
    508      return this.loadLocalesFromAMO();
    509    }
    510 
    511    return undefined;
    512  },
    513 
    514  async loadLocalesFromAMO() {
    515    if (!this.downloadEnabled) {
    516      return;
    517    }
    518 
    519    // Disable the dropdown while we hit the network.
    520    this._availableLocalesUI.disableWithMessageId(
    521      "browser-languages-searching"
    522    );
    523 
    524    // Fetch the available langpacks from AMO.
    525    let availableLangpacks;
    526    try {
    527      availableLangpacks = await AddonRepository.getAvailableLangpacks();
    528    } catch (e) {
    529      this.showError();
    530      return;
    531    }
    532 
    533    // Store the available langpack info for later use.
    534    this.availableLangpacks = new Map();
    535    for (let { target_locale, url, hash } of availableLangpacks) {
    536      this.availableLangpacks.set(target_locale, { url, hash });
    537    }
    538 
    539    // Remove the installed locales from the available ones.
    540    let installedLocales = new Set(await LangPackMatcher.getAvailableLocales());
    541    let notInstalledLocales = availableLangpacks
    542      .filter(({ target_locale }) => !installedLocales.has(target_locale))
    543      .map(lang => lang.target_locale);
    544 
    545    // Create the rows for the remote locales.
    546    let availableItems = await getLocaleDisplayInfo(notInstalledLocales);
    547    availableItems.push({
    548      label: await document.l10n.formatValue(
    549        "browser-languages-available-label"
    550      ),
    551      className: "label-item",
    552      disabled: true,
    553      installed: false,
    554    });
    555 
    556    // Remove the search option and add the remote locales.
    557    let items = this._availableLocalesUI.items;
    558    items.pop();
    559    items = items.concat(availableItems);
    560 
    561    // Update the dropdown and enable it again.
    562    this._availableLocalesUI.setItems(items);
    563    this._availableLocalesUI.enableWithMessageId(
    564      "browser-languages-select-language"
    565    );
    566  },
    567 
    568  /**
    569   * @param {Set<string>} available - The set of available (BCP 47) locales.
    570   */
    571  async loadLocalesFromInstalled(available) {
    572    let items;
    573    if (available.length) {
    574      items = await getLocaleDisplayInfo(available);
    575      items.push(await this.createInstalledLabel());
    576    } else {
    577      items = [];
    578    }
    579    if (this.downloadEnabled) {
    580      items.push({
    581        label: await document.l10n.formatValue("browser-languages-search"),
    582        value: "search",
    583      });
    584    }
    585    this._availableLocalesUI.setItems(items);
    586  },
    587 
    588  /**
    589   * @param {LocaleDisplayInfo} item
    590   */
    591  async availableLanguageSelected(item) {
    592    if ((await LangPackMatcher.getAvailableLocales()).includes(item.value)) {
    593      this.recordTelemetry("add");
    594      await this.requestLocalLanguage(item);
    595    } else if (this.availableLangpacks.has(item.value)) {
    596      // Telemetry is tracked in requestRemoteLanguage.
    597      await this.requestRemoteLanguage(item);
    598    } else {
    599      this.showError();
    600    }
    601  },
    602 
    603  /**
    604   * @param {LocaleDisplayInfo} item
    605   */
    606  async requestLocalLanguage(item) {
    607    this._selectedLocalesUI.addItem(item);
    608    let selectedCount = this._selectedLocalesUI.items.length;
    609    let availableCount = (await LangPackMatcher.getAvailableLocales()).length;
    610    if (selectedCount == availableCount) {
    611      // Remove the installed label, they're all installed.
    612      this._availableLocalesUI.items.shift();
    613      this._availableLocalesUI.setItems(this._availableLocalesUI.items);
    614    }
    615    // The label isn't always reset when the selected item is removed, so set it again.
    616    this._availableLocalesUI.enableWithMessageId(
    617      "browser-languages-select-language"
    618    );
    619  },
    620 
    621  /**
    622   * @param {LocaleDisplayInfo} item
    623   */
    624  async requestRemoteLanguage(item) {
    625    this._availableLocalesUI.disableWithMessageId(
    626      "browser-languages-downloading"
    627    );
    628 
    629    let { url, hash } = this.availableLangpacks.get(item.value);
    630    let addon;
    631 
    632    try {
    633      addon = await installFromUrl(url, hash, installId =>
    634        this.recordTelemetry("add", { installId })
    635      );
    636    } catch (e) {
    637      this.showError();
    638      return;
    639    }
    640 
    641    // If the add-on was previously installed, it might be disabled still.
    642    if (addon.userDisabled) {
    643      await addon.enable();
    644    }
    645 
    646    item.installed = true;
    647    this._selectedLocalesUI.addItem(item);
    648    this._availableLocalesUI.enableWithMessageId(
    649      "browser-languages-select-language"
    650    );
    651 
    652    // This is an async task that will install the recommended dictionaries for
    653    // this locale. This will fail silently at least until a management UI is
    654    // added in bug 1493705.
    655    this.installDictionariesForLanguage(item.value);
    656  },
    657 
    658  /**
    659   * @param {string} locale The BCP 47 locale identifier
    660   */
    661  async installDictionariesForLanguage(locale) {
    662    try {
    663      let ids = await dictionaryIdsForLocale(locale);
    664      let addonInfos = await AddonRepository.getAddonsByIDs(ids);
    665      await Promise.all(
    666        addonInfos.map(info => installFromUrl(info.sourceURI.spec))
    667      );
    668    } catch (e) {
    669      console.error(e);
    670    }
    671  },
    672 
    673  showError() {
    674    document.getElementById("warning-message").hidden = false;
    675    this._availableLocalesUI.enableWithMessageId(
    676      "browser-languages-select-language"
    677    );
    678 
    679    // The height has likely changed, find our SubDialog and tell it to resize.
    680    requestAnimationFrame(() => {
    681      let dialogs = window.opener.gSubDialog._dialogs;
    682      let index = dialogs.findIndex(d => d._frame.contentDocument == document);
    683      if (index != -1) {
    684        dialogs[index].resizeDialog();
    685      }
    686    });
    687  },
    688 
    689  hideError() {
    690    document.getElementById("warning-message").hidden = true;
    691  },
    692 
    693  /**
    694   * @param {LocaleDisplayInfo} item
    695   */
    696  async selectedLocaleRemoved(item) {
    697    this.recordTelemetry("remove");
    698 
    699    this._availableLocalesUI.addItem(item);
    700 
    701    // If the item we added is at the top of the list, it needs the label.
    702    if (this._availableLocalesUI.items[0] == item) {
    703      this._availableLocalesUI.addItem(await this.createInstalledLabel());
    704    }
    705  },
    706 
    707  async createInstalledLabel() {
    708    return {
    709      label: await document.l10n.formatValue(
    710        "browser-languages-installed-label"
    711      ),
    712      className: "label-item",
    713      disabled: true,
    714      installed: true,
    715    };
    716  },
    717 };
    718 
    719 window.addEventListener("load", () => gBrowserLanguagesDialog.onLoad());