tor-browser

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

sitePermissions.js (22902B)


      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 ../extensionControlled.js */
      6 
      7 var { AppConstants } = ChromeUtils.importESModule(
      8  "resource://gre/modules/AppConstants.sys.mjs"
      9 );
     10 const { SitePermissions } = ChromeUtils.importESModule(
     11  "resource:///modules/SitePermissions.sys.mjs"
     12 );
     13 
     14 const sitePermissionsL10n = {
     15  "desktop-notification": {
     16    window: "permissions-site-notification-window2",
     17    description: "permissions-site-notification-desc",
     18    disableLabel: "permissions-site-notification-disable-label",
     19    disableDescription: "permissions-site-notification-disable-desc",
     20  },
     21  geo: {
     22    window: "permissions-site-location-window2",
     23    description: "permissions-site-location-desc",
     24    disableLabel: "permissions-site-location-disable-label",
     25    disableDescription: "permissions-site-location-disable-desc",
     26  },
     27  xr: {
     28    window: "permissions-site-xr-window2",
     29    description: "permissions-site-xr-desc",
     30    disableLabel: "permissions-site-xr-disable-label",
     31    disableDescription: "permissions-site-xr-disable-desc",
     32  },
     33  camera: {
     34    window: "permissions-site-camera-window2",
     35    description: "permissions-site-camera-desc",
     36    disableLabel: "permissions-site-camera-disable-label",
     37    disableDescription: "permissions-site-camera-disable-desc",
     38  },
     39  microphone: {
     40    window: "permissions-site-microphone-window2",
     41    description: "permissions-site-microphone-desc",
     42    disableLabel: "permissions-site-microphone-disable-label",
     43    disableDescription: "permissions-site-microphone-disable-desc",
     44  },
     45  speaker: {
     46    window: "permissions-site-speaker-window",
     47    description: "permissions-site-speaker-desc",
     48  },
     49  "autoplay-media": {
     50    window: "permissions-site-autoplay-window2",
     51    description: "permissions-site-autoplay-desc",
     52  },
     53  localhost: {
     54    window: "permissions-site-localhost-window",
     55    description: "permissions-site-localhost-desc",
     56    disableLabel: "permissions-site-localhost-disable-label",
     57    disableDescription: "permissions-site-localhost-disable-desc",
     58  },
     59  "local-network": {
     60    window: "permissions-site-local-network-window",
     61    description: "permissions-site-local-network-desc",
     62    disableLabel: "permissions-site-local-network-disable-label",
     63    disableDescription: "permissions-site-local-network-disable-desc",
     64  },
     65 };
     66 
     67 const sitePermissionsConfig = {
     68  "autoplay-media": {
     69    _getCapabilityString(capability) {
     70      switch (capability) {
     71        case SitePermissions.ALLOW:
     72          return "permissions-capabilities-autoplay-allow";
     73        case SitePermissions.BLOCK:
     74          return "permissions-capabilities-autoplay-block";
     75        case SitePermissions.AUTOPLAY_BLOCKED_ALL:
     76          return "permissions-capabilities-autoplay-blockall";
     77      }
     78      throw new Error(`Unknown capability: ${capability}`);
     79    },
     80  },
     81 };
     82 
     83 // A set of permissions for a single origin.  One PermissionGroup instance
     84 // corresponds to one row in the gSitePermissionsManager._list richlistbox.
     85 // Permissions may be single or double keyed, but the primary key of all
     86 // permissions matches the permission type of the dialog.
     87 class PermissionGroup {
     88  #changedCapability;
     89 
     90  constructor(perm) {
     91    this.principal = perm.principal;
     92    this.origin = perm.principal.origin;
     93    this.perms = [perm];
     94  }
     95  addPermission(perm) {
     96    this.perms.push(perm);
     97  }
     98  removePermission(perm) {
     99    this.perms = this.perms.filter(p => p.type != perm.type);
    100  }
    101  set capability(cap) {
    102    this.#changedCapability = cap;
    103  }
    104  get capability() {
    105    if (this.#changedCapability) {
    106      return this.#changedCapability;
    107    }
    108    return this.savedCapability;
    109  }
    110  revert() {
    111    this.#changedCapability = null;
    112  }
    113  get savedCapability() {
    114    // This logic to present a single capability for permissions of different
    115    // keys and capabilities caters for speaker-selection, where a block
    116    // permission may be set for all devices with no second key, which would
    117    // override any device-specific double-keyed allow permissions.
    118    let cap;
    119    for (let perm of this.perms) {
    120      let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER);
    121      if (type == perm.type) {
    122        // No second key.  This overrides double-keyed perms.
    123        return perm.capability;
    124      }
    125      // Double-keyed perms are not expected to have different capabilities.
    126      cap = perm.capability;
    127    }
    128    return cap;
    129  }
    130 }
    131 
    132 const PERMISSION_STATES = [
    133  SitePermissions.ALLOW,
    134  SitePermissions.BLOCK,
    135  SitePermissions.PROMPT,
    136  SitePermissions.AUTOPLAY_BLOCKED_ALL,
    137 ];
    138 
    139 const NOTIFICATIONS_PERMISSION_OVERRIDE_KEY = "webNotificationsDisabled";
    140 const NOTIFICATIONS_PERMISSION_PREF =
    141  "permissions.default.desktop-notification";
    142 
    143 const AUTOPLAY_PREF = "media.autoplay.default";
    144 
    145 var gSitePermissionsManager = {
    146  _type: "",
    147  _isObserving: false,
    148  _permissionGroups: new Map(),
    149  _permissionsToChange: new Map(),
    150  _permissionsToDelete: new Map(),
    151  _list: null,
    152  _removeButton: null,
    153  _removeAllButton: null,
    154  _searchBox: null,
    155  _checkbox: null,
    156  _currentDefaultPermissionsState: null,
    157  _defaultPermissionStatePrefName: null,
    158 
    159  onLoad() {
    160    let params = window.arguments[0];
    161    document.mozSubdialogReady = this.init(params);
    162  },
    163 
    164  async init(params) {
    165    if (!this._isObserving) {
    166      Services.obs.addObserver(this, "perm-changed");
    167      this._isObserving = true;
    168    }
    169 
    170    document.addEventListener("command", this);
    171    document.addEventListener("dialogaccept", this);
    172    window.addEventListener("unload", this);
    173 
    174    document
    175      .getElementById("siteCol")
    176      .addEventListener("click", event =>
    177        this.buildPermissionsList(event.target)
    178      );
    179    document
    180      .getElementById("statusCol")
    181      .addEventListener("click", event =>
    182        this.buildPermissionsList(event.target)
    183      );
    184 
    185    this._type = params.permissionType;
    186    this._list = document.getElementById("permissionsBox");
    187    this._removeButton = document.getElementById("removePermission");
    188    this._removeAllButton = document.getElementById("removeAllPermissions");
    189    this._searchBox = document.getElementById("searchBox");
    190    this._searchBox.addEventListener("MozInputSearch:search", () =>
    191      this.buildPermissionsList()
    192    );
    193    this._checkbox = document.getElementById("permissionsDisableCheckbox");
    194    this._disableExtensionButton = document.getElementById(
    195      "disableNotificationsPermissionExtension"
    196    );
    197    this._permissionsDisableDescription = document.getElementById(
    198      "permissionsDisableDescription"
    199    );
    200    this._setAutoplayPref = document.getElementById("setAutoplayPref");
    201 
    202    this._list.addEventListener("keypress", event =>
    203      this.onPermissionKeyPress(event)
    204    );
    205    this._list.addEventListener("select", () => this.onPermissionSelect());
    206 
    207    let permissionsText = document.getElementById("permissionsText");
    208 
    209    document.l10n.pauseObserving();
    210    let l10n = sitePermissionsL10n[this._type];
    211    document.l10n.setAttributes(permissionsText, l10n.description);
    212    if (l10n.disableLabel) {
    213      document.l10n.setAttributes(this._checkbox, l10n.disableLabel);
    214    }
    215    if (l10n.disableDescription) {
    216      document.l10n.setAttributes(
    217        this._permissionsDisableDescription,
    218        l10n.disableDescription
    219      );
    220    }
    221    document.l10n.setAttributes(document.documentElement, l10n.window);
    222 
    223    await document.l10n.translateElements([
    224      permissionsText,
    225      this._checkbox,
    226      this._permissionsDisableDescription,
    227      document.documentElement,
    228    ]);
    229    document.l10n.resumeObserving();
    230 
    231    // Initialize the checkbox state and handle showing notification permission UI
    232    // when it is disabled by an extension.
    233    this._defaultPermissionStatePrefName = "permissions.default." + this._type;
    234    this._watchPermissionPrefChange();
    235 
    236    this._loadPermissions();
    237    this.buildPermissionsList();
    238 
    239    if (params.permissionType == "autoplay-media") {
    240      await this.buildAutoplayMenulist();
    241      this._setAutoplayPref.hidden = false;
    242    }
    243 
    244    this._searchBox.focus();
    245  },
    246 
    247  uninit() {
    248    if (this._isObserving) {
    249      Services.obs.removeObserver(this, "perm-changed");
    250      this._isObserving = false;
    251    }
    252    if (this._setAutoplayPref) {
    253      this._setAutoplayPref.hidden = true;
    254    }
    255  },
    256 
    257  observe(subject, topic, data) {
    258    if (topic !== "perm-changed") {
    259      return;
    260    }
    261 
    262    let permission = subject.QueryInterface(Ci.nsIPermission);
    263    let [type] = permission.type.split(SitePermissions.PERM_KEY_DELIMITER);
    264 
    265    // Ignore unrelated permission types and permissions with unknown states.
    266    if (
    267      type !== this._type ||
    268      !PERMISSION_STATES.includes(permission.capability)
    269    ) {
    270      return;
    271    }
    272 
    273    if (data == "added") {
    274      this._addPermissionToList(permission);
    275    } else {
    276      let group = this._permissionGroups.get(permission.principal.origin);
    277      if (!group) {
    278        // already moved to _permissionsToDelete
    279        // or private browsing session permission
    280        return;
    281      }
    282      if (data == "changed") {
    283        group.removePermission(permission);
    284        group.addPermission(permission);
    285      } else if (data == "deleted") {
    286        group.removePermission(permission);
    287        if (!group.perms.length) {
    288          this._removePermissionFromList(permission.principal.origin);
    289          return;
    290        }
    291      }
    292    }
    293    this.buildPermissionsList();
    294  },
    295 
    296  handleEvent(event) {
    297    switch (event.type) {
    298      case "command":
    299        switch (event.target.id) {
    300          case "key_close":
    301            window.close();
    302            break;
    303          case "removePermission":
    304            this.onPermissionDelete();
    305            break;
    306          case "removeAllPermissions":
    307            this.onAllPermissionsDelete();
    308            break;
    309        }
    310        break;
    311      case "dialogaccept":
    312        this.onApplyChanges();
    313        break;
    314      case "unload":
    315        this.uninit();
    316        break;
    317    }
    318  },
    319 
    320  _handleCheckboxUIUpdates() {
    321    let pref = Services.prefs.getPrefType(this._defaultPermissionStatePrefName);
    322    if (pref != Services.prefs.PREF_INVALID) {
    323      this._currentDefaultPermissionsState = Services.prefs.getIntPref(
    324        this._defaultPermissionStatePrefName
    325      );
    326    }
    327 
    328    if (this._currentDefaultPermissionsState === null) {
    329      this._checkbox.hidden = true;
    330      this._permissionsDisableDescription.hidden = true;
    331    } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) {
    332      this._checkbox.checked = true;
    333    } else {
    334      this._checkbox.checked = false;
    335    }
    336 
    337    if (Services.prefs.prefIsLocked(this._defaultPermissionStatePrefName)) {
    338      this._checkbox.disabled = true;
    339    }
    340  },
    341 
    342  /**
    343   * Listen for changes to the permissions.default.* pref and make
    344   * necessary changes to the UI.
    345   */
    346  _watchPermissionPrefChange() {
    347    this._handleCheckboxUIUpdates();
    348 
    349    if (this._type == "desktop-notification") {
    350      this._handleWebNotificationsDisable();
    351 
    352      this._disableExtensionButton.addEventListener(
    353        "command",
    354        makeDisableControllingExtension(
    355          PREF_SETTING_TYPE,
    356          NOTIFICATIONS_PERMISSION_OVERRIDE_KEY
    357        )
    358      );
    359    }
    360 
    361    let observer = () => {
    362      this._handleCheckboxUIUpdates();
    363      if (this._type == "desktop-notification") {
    364        this._handleWebNotificationsDisable();
    365      }
    366    };
    367    Services.prefs.addObserver(this._defaultPermissionStatePrefName, observer);
    368    window.addEventListener("unload", () => {
    369      Services.prefs.removeObserver(
    370        this._defaultPermissionStatePrefName,
    371        observer
    372      );
    373    });
    374  },
    375 
    376  /**
    377   * Handles the UI update for web notifications disable by extensions.
    378   */
    379  async _handleWebNotificationsDisable() {
    380    let prefLocked = Services.prefs.prefIsLocked(NOTIFICATIONS_PERMISSION_PREF);
    381    if (prefLocked) {
    382      // An extension can't control these settings if they're locked.
    383      hideControllingExtension(NOTIFICATIONS_PERMISSION_OVERRIDE_KEY);
    384    } else {
    385      let isControlled = await handleControllingExtension(
    386        PREF_SETTING_TYPE,
    387        NOTIFICATIONS_PERMISSION_OVERRIDE_KEY
    388      );
    389      this._checkbox.disabled = isControlled;
    390    }
    391  },
    392 
    393  _getCapabilityL10nId(element, type, capability) {
    394    if (
    395      type in sitePermissionsConfig &&
    396      sitePermissionsConfig[type]._getCapabilityString
    397    ) {
    398      return sitePermissionsConfig[type]._getCapabilityString(capability);
    399    }
    400    switch (element.tagName) {
    401      case "menuitem":
    402        switch (capability) {
    403          case Services.perms.ALLOW_ACTION:
    404            return "permissions-capabilities-allow";
    405          case Services.perms.DENY_ACTION:
    406            return "permissions-capabilities-block";
    407          case Services.perms.PROMPT_ACTION:
    408            return "permissions-capabilities-prompt";
    409          default:
    410            throw new Error(`Unknown capability: ${capability}`);
    411        }
    412      case "label":
    413        switch (capability) {
    414          case Services.perms.ALLOW_ACTION:
    415            return "permissions-capabilities-listitem-allow";
    416          case Services.perms.DENY_ACTION:
    417            return "permissions-capabilities-listitem-block";
    418          default:
    419            throw new Error(`Unexpected capability: ${capability}`);
    420        }
    421      default:
    422        throw new Error(`Unexpected tag: ${element.tagName}`);
    423    }
    424  },
    425 
    426  _addPermissionToList(perm) {
    427    let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER);
    428    // Ignore unrelated permission types and permissions with unknown states.
    429    if (
    430      type !== this._type ||
    431      !PERMISSION_STATES.includes(perm.capability) ||
    432      // Skip private browsing session permissions
    433      (perm.principal.privateBrowsingId !==
    434        Services.scriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID &&
    435        perm.expireType === Services.perms.EXPIRE_SESSION)
    436    ) {
    437      return;
    438    }
    439    let group = this._permissionGroups.get(perm.principal.origin);
    440    if (group) {
    441      group.addPermission(perm);
    442    } else {
    443      group = new PermissionGroup(perm);
    444      this._permissionGroups.set(group.origin, group);
    445    }
    446  },
    447 
    448  _removePermissionFromList(origin) {
    449    this._permissionGroups.delete(origin);
    450    this._permissionsToChange.delete(origin);
    451    let permissionlistitem = document.getElementsByAttribute(
    452      "origin",
    453      origin
    454    )[0];
    455    if (permissionlistitem) {
    456      permissionlistitem.remove();
    457    }
    458  },
    459 
    460  _loadPermissions() {
    461    // load permissions into a table.
    462    for (let nextPermission of Services.perms.all) {
    463      this._addPermissionToList(nextPermission);
    464    }
    465  },
    466 
    467  _createPermissionListItem(permissionGroup) {
    468    let richlistitem = document.createXULElement("richlistitem");
    469    richlistitem.setAttribute("origin", permissionGroup.origin);
    470    let row = document.createXULElement("hbox");
    471 
    472    let website = document.createXULElement("label");
    473    website.textContent = permissionGroup.origin;
    474    website.className = "website-name";
    475 
    476    let states = SitePermissions.getAvailableStates(this._type).filter(
    477      state => state != SitePermissions.UNKNOWN
    478    );
    479    // Handle the cases of a double-keyed ALLOW permission or a PROMPT
    480    // permission after the default has been changed back to UNKNOWN.
    481    if (!states.includes(permissionGroup.savedCapability)) {
    482      states.unshift(permissionGroup.savedCapability);
    483    }
    484    let siteStatus;
    485    if (states.length == 1) {
    486      // Only a single state is available.  Show a label.
    487      siteStatus = document.createXULElement("label");
    488      document.l10n.setAttributes(
    489        siteStatus,
    490        this._getCapabilityL10nId(
    491          siteStatus,
    492          this._type,
    493          permissionGroup.capability
    494        )
    495      );
    496    } else {
    497      // Multiple states are available.  Show a menulist.
    498      siteStatus = document.createXULElement("menulist");
    499      for (let state of states) {
    500        let m = siteStatus.appendItem(undefined, state);
    501        document.l10n.setAttributes(
    502          m,
    503          this._getCapabilityL10nId(m, this._type, state)
    504        );
    505      }
    506      siteStatus.addEventListener("select", () => {
    507        this.onPermissionChange(permissionGroup, Number(siteStatus.value));
    508      });
    509    }
    510    siteStatus.className = "website-status";
    511    siteStatus.value = permissionGroup.capability;
    512 
    513    row.appendChild(website);
    514    row.appendChild(siteStatus);
    515    richlistitem.appendChild(row);
    516    return richlistitem;
    517  },
    518 
    519  onPermissionKeyPress(event) {
    520    if (!this._list.selectedItem) {
    521      return;
    522    }
    523 
    524    if (
    525      event.keyCode == KeyEvent.DOM_VK_DELETE ||
    526      (AppConstants.platform == "macosx" &&
    527        event.keyCode == KeyEvent.DOM_VK_BACK_SPACE)
    528    ) {
    529      this.onPermissionDelete();
    530      event.preventDefault();
    531    }
    532  },
    533 
    534  _setRemoveButtonState() {
    535    if (!this._list) {
    536      return;
    537    }
    538 
    539    let hasSelection = this._list.selectedIndex >= 0;
    540    let hasRows = this._list.itemCount > 0;
    541    this._removeButton.disabled = !hasSelection;
    542    this._removeAllButton.disabled = !hasRows;
    543  },
    544 
    545  onPermissionDelete() {
    546    let richlistitem = this._list.selectedItem;
    547    let origin = richlistitem.getAttribute("origin");
    548    let permissionGroup = this._permissionGroups.get(origin);
    549 
    550    this._removePermissionFromList(origin);
    551    this._permissionsToDelete.set(permissionGroup.origin, permissionGroup);
    552 
    553    this._setRemoveButtonState();
    554  },
    555 
    556  onAllPermissionsDelete() {
    557    for (let permissionGroup of this._permissionGroups.values()) {
    558      this._removePermissionFromList(permissionGroup.origin);
    559      this._permissionsToDelete.set(permissionGroup.origin, permissionGroup);
    560    }
    561 
    562    this._setRemoveButtonState();
    563  },
    564 
    565  onPermissionSelect() {
    566    this._setRemoveButtonState();
    567  },
    568 
    569  onPermissionChange(perm, capability) {
    570    let group = this._permissionGroups.get(perm.origin);
    571    if (group.capability == capability) {
    572      return;
    573    }
    574    if (capability == group.savedCapability) {
    575      group.revert();
    576      this._permissionsToChange.delete(group.origin);
    577    } else {
    578      group.capability = capability;
    579      this._permissionsToChange.set(group.origin, group);
    580    }
    581 
    582    // enable "remove all" button as needed
    583    this._setRemoveButtonState();
    584  },
    585 
    586  onApplyChanges() {
    587    // Stop observing permission changes since we are about
    588    // to write out the pending adds/deletes and don't need
    589    // to update the UI
    590    this.uninit();
    591 
    592    // Delete even _permissionsToChange to clear out double-keyed permissions
    593    for (let group of [
    594      ...this._permissionsToDelete.values(),
    595      ...this._permissionsToChange.values(),
    596    ]) {
    597      for (let perm of group.perms) {
    598        SitePermissions.removeFromPrincipal(perm.principal, perm.type);
    599      }
    600    }
    601 
    602    for (let group of this._permissionsToChange.values()) {
    603      SitePermissions.setForPrincipal(
    604        group.principal,
    605        this._type,
    606        group.capability
    607      );
    608    }
    609 
    610    if (this._checkbox.checked) {
    611      Services.prefs.setIntPref(
    612        this._defaultPermissionStatePrefName,
    613        SitePermissions.BLOCK
    614      );
    615    } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) {
    616      Services.prefs.setIntPref(
    617        this._defaultPermissionStatePrefName,
    618        SitePermissions.UNKNOWN
    619      );
    620    }
    621  },
    622 
    623  buildPermissionsList(sortCol) {
    624    // Clear old entries.
    625    let oldItems = this._list.querySelectorAll("richlistitem");
    626    for (let item of oldItems) {
    627      item.remove();
    628    }
    629    let frag = document.createDocumentFragment();
    630 
    631    let permissionGroups = Array.from(this._permissionGroups.values());
    632 
    633    let keyword = this._searchBox.value.toLowerCase().trim();
    634    for (let permissionGroup of permissionGroups) {
    635      if (keyword && !permissionGroup.origin.includes(keyword)) {
    636        continue;
    637      }
    638 
    639      let richlistitem = this._createPermissionListItem(permissionGroup);
    640      frag.appendChild(richlistitem);
    641    }
    642 
    643    // Sort permissions.
    644    this._sortPermissions(this._list, frag, sortCol);
    645 
    646    this._list.appendChild(frag);
    647 
    648    this._setRemoveButtonState();
    649  },
    650 
    651  async buildAutoplayMenulist() {
    652    let menulist = document.createXULElement("menulist");
    653    let states = SitePermissions.getAvailableStates("autoplay-media");
    654    document.l10n.pauseObserving();
    655    for (let state of states) {
    656      let m = menulist.appendItem(undefined, state);
    657      document.l10n.setAttributes(
    658        m,
    659        this._getCapabilityL10nId(m, "autoplay-media", state)
    660      );
    661    }
    662 
    663    menulist.value = SitePermissions.getDefault("autoplay-media");
    664 
    665    menulist.addEventListener("select", () => {
    666      SitePermissions.setDefault("autoplay-media", Number(menulist.value));
    667    });
    668 
    669    menulist.menupopup.setAttribute("incontentshell", "false");
    670 
    671    menulist.disabled = Services.prefs.prefIsLocked(AUTOPLAY_PREF);
    672 
    673    document.getElementById("setAutoplayPref").appendChild(menulist);
    674    await document.l10n.translateFragment(menulist);
    675    document.l10n.resumeObserving();
    676  },
    677 
    678  _sortPermissions(list, frag, column) {
    679    let sortDirection;
    680 
    681    if (!column) {
    682      column = document.querySelector("treecol[data-isCurrentSortCol=true]");
    683      sortDirection =
    684        column.getAttribute("data-last-sortDirection") || "ascending";
    685    } else {
    686      sortDirection = column.getAttribute("data-last-sortDirection");
    687      sortDirection =
    688        sortDirection === "ascending" ? "descending" : "ascending";
    689    }
    690 
    691    let sortFunc = null;
    692    switch (column.id) {
    693      case "siteCol":
    694        sortFunc = (a, b) => {
    695          return comp.compare(
    696            a.getAttribute("origin"),
    697            b.getAttribute("origin")
    698          );
    699        };
    700        break;
    701 
    702      case "statusCol":
    703        sortFunc = (a, b) => {
    704          return (
    705            parseInt(a.querySelector(".website-status").value) >
    706            parseInt(b.querySelector(".website-status").value)
    707          );
    708        };
    709        break;
    710    }
    711 
    712    let comp = new Services.intl.Collator(undefined, {
    713      usage: "sort",
    714    });
    715 
    716    let items = Array.from(frag.querySelectorAll("richlistitem"));
    717 
    718    if (sortDirection === "descending") {
    719      items.sort((a, b) => sortFunc(b, a));
    720    } else {
    721      items.sort(sortFunc);
    722    }
    723 
    724    // Re-append items in the correct order:
    725    items.forEach(item => frag.appendChild(item));
    726 
    727    let cols = list.previousElementSibling.querySelectorAll("treecol");
    728    cols.forEach(c => {
    729      c.removeAttribute("data-isCurrentSortCol");
    730      c.removeAttribute("sortDirection");
    731    });
    732    column.setAttribute("data-isCurrentSortCol", "true");
    733    column.setAttribute("sortDirection", sortDirection);
    734    column.setAttribute("data-last-sortDirection", sortDirection);
    735  },
    736 };
    737 
    738 window.addEventListener("load", () => gSitePermissionsManager.onLoad());