/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
  webrtcUI: "resource:///modules/webrtcUI.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "OSPermissions",
  "@mozilla.org/ospermissionrequest;1",
  "nsIOSPermissionRequest"
);

export class WebRTCParent extends JSWindowActorParent {
  didDestroy() {
    // Media stream tracks end on unload, so call stopRecording() on them early
    // *before* we go away, to ensure we're working with the right principal.
    this.stopRecording(this.manager.outerWindowId);
    lazy.webrtcUI.forgetStreamsFromBrowserContext(this.browsingContext);
    // Must clear activePerms here to prevent them from being read by laggard
    // stopRecording() calls, which due to IPC, may come in *after* navigation.
    // This is to prevent granting temporary grace periods to the wrong page.
    lazy.webrtcUI.activePerms.delete(this.manager.outerWindowId);
  }

  getBrowser() {
    return this.browsingContext.top.embedderElement;
  }

  receiveMessage(aMessage) {
    switch (aMessage.name) {
      case "rtcpeer:Request": {
        let params = Object.freeze(
          Object.assign(
            {
              origin: this.manager.documentPrincipal.origin,
            },
            aMessage.data
          )
        );

        let blockers = Array.from(lazy.webrtcUI.peerConnectionBlockers);

        (async function () {
          for (let blocker of blockers) {
            try {
              let result = await blocker(params);
              if (result == "deny") {
                return false;
              }
            } catch (err) {
              console.error(`error in PeerConnection blocker: ${err.message}`);
            }
          }
          return true;
        })().then(decision => {
          let message;
          if (decision) {
            lazy.webrtcUI.emitter.emit("peer-request-allowed", params);
            message = "rtcpeer:Allow";
          } else {
            lazy.webrtcUI.emitter.emit("peer-request-blocked", params);
            message = "rtcpeer:Deny";
          }

          this.sendAsyncMessage(message, {
            callID: params.callID,
            windowID: params.windowID,
          });
        });
        break;
      }
      case "rtcpeer:CancelRequest": {
        let params = Object.freeze({
          origin: this.manager.documentPrincipal.origin,
          callID: aMessage.data,
        });
        lazy.webrtcUI.emitter.emit("peer-request-cancel", params);
        break;
      }
      case "webrtc:Request": {
        let data = aMessage.data;

        // Record third party origins for telemetry.
        let isThirdPartyOrigin =
          this.manager.documentPrincipal.origin !=
          this.manager.topWindowContext.documentPrincipal.origin;
        data.isThirdPartyOrigin = isThirdPartyOrigin;

        data.origin = this.manager.topWindowContext.documentPrincipal.origin;

        let browser = this.getBrowser();
        if (browser.fxrPermissionPrompt) {
          // For Firefox Reality on Desktop, switch to a different mechanism to
          // prompt the user since fewer permissions are available and since many
          // UI dependencies are not available.
          browser.fxrPermissionPrompt(data);
        } else {
          prompt(this, this.getBrowser(), data);
        }
        break;
      }
      case "webrtc:StopRecording":
        this.stopRecording(
          aMessage.data.windowID,
          aMessage.data.mediaSource,
          aMessage.data.rawID
        );
        break;
      case "webrtc:CancelRequest": {
        let browser = this.getBrowser();
        // browser can be null when closing the window
        if (browser) {
          removePrompt(browser, aMessage.data);
        }
        break;
      }
      case "webrtc:UpdateIndicators": {
        let { data } = aMessage;
        data.documentURI = this.manager.documentURI?.spec;
        if (data.windowId) {
          if (!data.remove) {
            data.principal = this.manager.topWindowContext.documentPrincipal;
          }
          lazy.webrtcUI.streamAddedOrRemoved(this.browsingContext, data);
        }
        this.updateIndicators(data);
        break;
      }
    }
  }

  updateIndicators(aData) {
    let browsingContext = this.browsingContext;
    let state = lazy.webrtcUI.updateIndicators(browsingContext.top);

    let browser = this.getBrowser();
    if (!browser) {
      return;
    }

    state.browsingContext = browsingContext;
    state.windowId = aData.windowId;

    let tabbrowser = browser.ownerGlobal.gBrowser;
    if (tabbrowser) {
      tabbrowser.updateBrowserSharing(browser, {
        webRTC: state,
      });
    }
  }

  denyRequest(aRequest) {
    this.sendAsyncMessage("webrtc:Deny", {
      callID: aRequest.callID,
      windowID: aRequest.windowID,
    });
  }

  //
  // Deny the request because the browser does not have access to the
  // camera or microphone due to OS security restrictions. The user may
  // have granted camera/microphone access to the site, but not have
  // allowed the browser access in OS settings.
  //
  denyRequestNoPermission(aRequest) {
    this.sendAsyncMessage("webrtc:Deny", {
      callID: aRequest.callID,
      windowID: aRequest.windowID,
      noOSPermission: true,
    });
  }

  //
  // Check if we have permission to access the camera or screen-sharing and/or
  // microphone at the OS level. Triggers a request to access the device if access
  // is needed and the permission state has not yet been determined.
  //
  async checkOSPermission(camNeeded, micNeeded, scrNeeded) {
    // Don't trigger OS permission requests for fake devices. Fake devices don't
    // require OS permission and the dialogs are problematic in automated testing
    // (where fake devices are used) because they require user interaction.
    if (
      !scrNeeded &&
      Services.prefs.getBoolPref("media.navigator.streams.fake", false)
    ) {
      return true;
    }
    let camStatus = {},
      micStatus = {};
    if (camNeeded || micNeeded) {
      lazy.OSPermissions.getMediaCapturePermissionState(camStatus, micStatus);
    }
    if (camNeeded) {
      let camPermission = camStatus.value;
      let camAccessible = await this.checkAndGetOSPermission(
        camPermission,
        lazy.OSPermissions.requestVideoCapturePermission
      );
      if (!camAccessible) {
        return false;
      }
    }
    if (micNeeded) {
      let micPermission = micStatus.value;
      let micAccessible = await this.checkAndGetOSPermission(
        micPermission,
        lazy.OSPermissions.requestAudioCapturePermission
      );
      if (!micAccessible) {
        return false;
      }
    }
    let scrStatus = {};
    if (scrNeeded) {
      lazy.OSPermissions.getScreenCapturePermissionState(scrStatus);
      if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) {
        lazy.OSPermissions.maybeRequestScreenCapturePermission();
        return false;
      }
    }
    return true;
  }

  //
  // Given a device's permission, return true if the device is accessible. If
  // the device's permission is not yet determined, request access to the device.
  // |requestPermissionFunc| must return a promise that resolves with true
  // if the device is accessible and false otherwise.
  //
  async checkAndGetOSPermission(devicePermission, requestPermissionFunc) {
    if (
      devicePermission == lazy.OSPermissions.PERMISSION_STATE_DENIED ||
      devicePermission == lazy.OSPermissions.PERMISSION_STATE_RESTRICTED
    ) {
      return false;
    }
    if (devicePermission == lazy.OSPermissions.PERMISSION_STATE_NOTDETERMINED) {
      let deviceAllowed = await requestPermissionFunc();
      if (!deviceAllowed) {
        return false;
      }
    }
    return true;
  }

  stopRecording(aOuterWindowId, aMediaSource, aRawId) {
    for (let { browsingContext, state } of lazy.webrtcUI._streams) {
      if (browsingContext == this.browsingContext) {
        let { principal } = state;
        for (let { mediaSource, rawId } of state.devices) {
          if (aRawId && (aRawId != rawId || aMediaSource != mediaSource)) {
            continue;
          }
          // Deactivate this device (no aRawId means all devices).
          this.deactivateDevicePerm(
            aOuterWindowId,
            mediaSource,
            rawId,
            principal
          );
        }
      }
    }
  }

  /**
   * Add a device record to webrtcUI.activePerms, denoting a device as in use.
   * Important to call for permission grace periods to work correctly.
   */
  activateDevicePerm(aOuterWindowId, aMediaSource, aId) {
    if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) {
      lazy.webrtcUI.activePerms.set(this.manager.outerWindowId, new Map());
    }
    lazy.webrtcUI.activePerms
      .get(this.manager.outerWindowId)
      .set(aOuterWindowId + aMediaSource + aId, aMediaSource);
  }

  /**
   * Remove a device record from webrtcUI.activePerms, denoting a device as
   * no longer in use by the site. Meaning: gUM requests for this device will
   * no longer be implicitly granted through the webrtcUI.activePerms mechanism.
   *
   * However, if webrtcUI.deviceGracePeriodTimeoutMs is defined, the implicit
   * grant is extended for an additional period of time through SitePermissions.
   */
  deactivateDevicePerm(
    aOuterWindowId,
    aMediaSource,
    aId,
    aPermissionPrincipal
  ) {
    // If we don't have active permissions for the given window anymore don't
    // set a grace period. This happens if there has been a user revoke and
    // webrtcUI clears the permissions.
    if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) {
      return;
    }
    let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId);
    map.delete(aOuterWindowId + aMediaSource + aId);

    // Add a permission grace period for camera and microphone only
    if (
      (aMediaSource != "camera" && aMediaSource != "microphone") ||
      !this.browsingContext.top.embedderElement
    ) {
      return;
    }
    let gracePeriodMs = lazy.webrtcUI.deviceGracePeriodTimeoutMs;
    if (gracePeriodMs > 0) {
      // A grace period is extended (even past navigation) to this outer window
      // + origin + deviceId only. This avoids re-prompting without the user
      // having to persist permission to the site, in a common case of a web
      // conference asking them for the camera in a lobby page, before
      // navigating to the actual meeting room page. Does not survive tab close.
      //
      // Caution: since navigation causes deactivation, we may be in the middle
      // of one. We must pass in a principal & URI for SitePermissions to use
      // instead of browser.currentURI, because the latter may point to a new
      // page already, and we must not leak permission to unrelated pages.
      //
      let permissionName = [aMediaSource, aId].join("^");
      lazy.SitePermissions.setForPrincipal(
        aPermissionPrincipal,
        permissionName,
        lazy.SitePermissions.ALLOW,
        lazy.SitePermissions.SCOPE_TEMPORARY,
        this.browsingContext.top.embedderElement,
        gracePeriodMs
      );
    }
  }

  /**
   * Checks if the principal has sufficient permissions
   * to fulfill the given request. If the request can be
   * fulfilled, a message is sent to the child
   * signaling that WebRTC permissions were given and
   * this function will return true.
   */
  checkRequestAllowed(aRequest, aPrincipal) {
    if (!aRequest.secure) {
      return false;
    }
    // Always prompt for screen sharing
    if (aRequest.sharingScreen) {
      return false;
    }
    let {
      callID,
      windowID,
      audioInputDevices,
      videoInputDevices,
      audioOutputDevices,
      hasInherentAudioConstraints,
      hasInherentVideoConstraints,
      audioOutputId,
    } = aRequest;

    if (audioOutputDevices?.length) {
      // Prompt if a specific device is not requested, available and allowed.
      let device = audioOutputDevices.find(({ id }) => id == audioOutputId);
      if (
        !device ||
        !lazy.SitePermissions.getForPrincipal(
          aPrincipal,
          ["speaker", device.id].join("^"),
          this.getBrowser()
        ).state == lazy.SitePermissions.ALLOW
      ) {
        return false;
      }
      this.sendAsyncMessage("webrtc:Allow", {
        callID,
        windowID,
        devices: [device.deviceIndex],
      });
      return true;
    }

    let { perms } = Services;
    if (
      perms.testExactPermissionFromPrincipal(aPrincipal, "MediaManagerVideo")
    ) {
      perms.removeFromPrincipal(aPrincipal, "MediaManagerVideo");
    }

    // Don't use persistent permissions from the top-level principal
    // if we're handling a potentially insecure third party
    // through a wildcard ("*") allow attribute.
    let limited = aRequest.secondOrigin;

    let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId);
    // We consider a camera or mic active if it is active or was active within a
    // grace period of milliseconds ago.
    const isAllowed = ({ mediaSource, rawId }, permissionID) =>
      map?.get(windowID + mediaSource + rawId) ||
      (!limited &&
        (lazy.SitePermissions.getForPrincipal(aPrincipal, permissionID).state ==
          lazy.SitePermissions.ALLOW ||
          lazy.SitePermissions.getForPrincipal(
            aPrincipal,
            [mediaSource, rawId].join("^"),
            this.getBrowser()
          ).state == lazy.SitePermissions.ALLOW));

    let microphone;
    if (audioInputDevices.length) {
      for (let device of audioInputDevices) {
        if (isAllowed(device, "microphone")) {
          microphone = device;
          break;
        }
        if (hasInherentAudioConstraints) {
          // Inherent constraints suggest site is looking for a specific mic
          break;
        }
        // Some sites don't look too hard at what they get, and spam gUM without
        // adjusting what they ask for to match what they got last time. To keep
        // users in charge and reduce prompts, ignore other constraints by
        // returning the most-fit microphone a site already has access to.
      }
      if (!microphone) {
        return false;
      }
    }
    let camera;
    if (videoInputDevices.length) {
      for (let device of videoInputDevices) {
        if (isAllowed(device, "camera")) {
          camera = device;
          break;
        }
        if (hasInherentVideoConstraints) {
          // Inherent constraints suggest site is looking for a specific camera
          break;
        }
        // Some sites don't look too hard at what they get, and spam gUM without
        // adjusting what they ask for to match what they got last time. To keep
        // users in charge and reduce prompts, ignore other constraints by
        // returning the most-fit camera a site already has access to.
      }
      if (!camera) {
        return false;
      }
    }
    let devices = [];
    if (camera) {
      perms.addFromPrincipal(
        aPrincipal,
        "MediaManagerVideo",
        perms.ALLOW_ACTION,
        perms.EXPIRE_SESSION
      );
      devices.push(camera.deviceIndex);
      this.activateDevicePerm(windowID, camera.mediaSource, camera.rawId);
    }
    if (microphone) {
      devices.push(microphone.deviceIndex);
      this.activateDevicePerm(
        windowID,
        microphone.mediaSource,
        microphone.rawId
      );
    }
    this.checkOSPermission(!!camera, !!microphone, false).then(
      havePermission => {
        if (havePermission) {
          this.sendAsyncMessage("webrtc:Allow", { callID, windowID, devices });
        } else {
          this.denyRequestNoPermission(aRequest);
        }
      }
    );
    return true;
  }
}

function prompt(aActor, aBrowser, aRequest) {
  let {
    audioInputDevices,
    videoInputDevices,
    audioOutputDevices,
    sharingScreen,
    sharingAudio,
    requestTypes,
  } = aRequest;

  let principal =
    Services.scriptSecurityManager.createContentPrincipalFromOrigin(
      aRequest.origin
    );

  // For add-on principals, we immediately check for permission instead
  // of waiting for the notification to focus. This allows for supporting
  // cases such as browserAction popups where no prompt is shown.
  if (principal.addonPolicy) {
    let isPopup = false;
    let isBackground = false;

    for (let view of principal.addonPolicy.extension.views) {
      if (view.viewType == "popup" && view.xulBrowser == aBrowser) {
        isPopup = true;
      }
      if (view.viewType == "background" && view.xulBrowser == aBrowser) {
        isBackground = true;
      }
    }

    // Recording from background pages is considered too sensitive and will
    // always be denied.
    if (isBackground) {
      aActor.denyRequest(aRequest);
      return;
    }

    // If the request comes from a popup, we don't want to show the prompt,
    // but we do want to allow the request if the user previously gave permission.
    if (isPopup) {
      if (!aActor.checkRequestAllowed(aRequest, principal, aBrowser)) {
        aActor.denyRequest(aRequest);
      }
      return;
    }
  }

  // If the user has already denied access once in this tab,
  // deny again without even showing the notification icon.
  for (const type of requestTypes) {
    const permissionID =
      type == "AudioCapture" ? "microphone" : type.toLowerCase();
    if (
      lazy.SitePermissions.getForPrincipal(principal, permissionID, aBrowser)
        .state == lazy.SitePermissions.BLOCK
    ) {
      aActor.denyRequest(aRequest);
      return;
    }
  }

  let chromeDoc = aBrowser.ownerDocument;
  const localization = new Localization(
    ["browser/webrtcIndicator.ftl", "branding/brand.ftl"],
    true
  );

  /** @type {"Screen" | "Camera" | null} */
  let reqVideoInput = null;
  if (videoInputDevices.length) {
    reqVideoInput = sharingScreen ? "Screen" : "Camera";
  }
  /** @type {"AudioCapture" | "Microphone" | null} */
  let reqAudioInput = null;
  if (audioInputDevices.length) {
    reqAudioInput = sharingAudio ? "AudioCapture" : "Microphone";
  }
  const reqAudioOutput = !!audioOutputDevices.length;

  const stringId = getPromptMessageId(
    reqVideoInput,
    reqAudioInput,
    reqAudioOutput,
    !!aRequest.secondOrigin
  );
  let message;
  let originToShow;
  if (principal.schemeIs("file")) {
    message = localization.formatValueSync(stringId + "-with-file");
    originToShow = null;
  } else {
    message = localization.formatValueSync(stringId, {
      origin: "<>",
      thirdParty: "{}",
    });
    originToShow = lazy.webrtcUI.getHostOrExtensionName(principal.URI);
  }
  let notification; // Used by action callbacks.
  const actionL10nIds = [{ id: "webrtc-action-allow" }];

  let notificationSilencingEnabled = Services.prefs.getBoolPref(
    "privacy.webrtc.allowSilencingNotifications"
  );

  const isNotNowLabelEnabled =
    reqAudioOutput || allowedOrActiveCameraOrMicrophone(aBrowser);
  let secondaryActions = [];
  if (reqAudioOutput || (notificationSilencingEnabled && sharingScreen)) {
    // We want to free up the checkbox at the bottom of the permission
    // panel for the notification silencing option, so we use a
    // different configuration for the permissions panel when
    // notification silencing is enabled.

    let permissionName = reqAudioOutput ? "speaker" : "screen";
    // When selecting speakers, we always offer 'Not now' instead of 'Block'.
    // When selecting screens, we offer 'Not now' if and only if we have a
    // (temporary) allow permission for some mic/cam device.
    const id = isNotNowLabelEnabled
      ? "webrtc-action-not-now"
      : "webrtc-action-block";
    actionL10nIds.push({ id }, { id: "webrtc-action-always-block" });
    secondaryActions = [
      {
        callback() {
          aActor.denyRequest(aRequest);
          if (!isNotNowLabelEnabled) {
            lazy.SitePermissions.setForPrincipal(
              principal,
              permissionName,
              lazy.SitePermissions.BLOCK,
              lazy.SitePermissions.SCOPE_TEMPORARY,
              notification.browser
            );
          }
        },
      },
      {
        callback() {
          aActor.denyRequest(aRequest);
          lazy.SitePermissions.setForPrincipal(
            principal,
            permissionName,
            lazy.SitePermissions.BLOCK,
            lazy.SitePermissions.SCOPE_PERSISTENT,
            notification.browser
          );
        },
      },
    ];
  } else {
    // We have a (temporary) allow permission for some device
    // hence we offer a 'Not now' label instead of 'Block'.
    const id = isNotNowLabelEnabled
      ? "webrtc-action-not-now"
      : "webrtc-action-block";
    actionL10nIds.push({ id });
    secondaryActions = [
      {
        callback(aState) {
          aActor.denyRequest(aRequest);

          const isPersistent = aState?.checkboxChecked;

          // Choosing 'Not now' will not set a block permission
          // we just deny the request. This enables certain use cases
          // where sites want to switch devices, but users back out of the permission request
          // (See Bug 1609578).
          // Selecting 'Remember this decision' and clicking 'Not now' will set a persistent block
          if (!isPersistent && isNotNowLabelEnabled) {
            return;
          }

          // Denying a camera / microphone prompt means we set a temporary or
          // persistent permission block. There may still be active grace period
          // permissions at this point. We need to remove them.
          clearTemporaryGrants(
            notification.browser,
            reqVideoInput === "Camera",
            !!reqAudioInput
          );

          const scope = isPersistent
            ? lazy.SitePermissions.SCOPE_PERSISTENT
            : lazy.SitePermissions.SCOPE_TEMPORARY;
          if (reqAudioInput) {
            if (!isPersistent) {
              // After a temporary block, having permissions.query() calls
              // persistently report "granted" would be misleading
              maybeClearAlwaysAsk(
                principal,
                "microphone",
                notification.browser
              );
            }
            lazy.SitePermissions.setForPrincipal(
              principal,
              "microphone",
              lazy.SitePermissions.BLOCK,
              scope,
              notification.browser
            );
          }
          if (reqVideoInput) {
            if (!isPersistent && !sharingScreen) {
              // After a temporary block, having permissions.query() calls
              // persistently report "granted" would be misleading
              maybeClearAlwaysAsk(principal, "camera", notification.browser);
            }
            lazy.SitePermissions.setForPrincipal(
              principal,
              sharingScreen ? "screen" : "camera",
              lazy.SitePermissions.BLOCK,
              scope,
              notification.browser
            );
          }
        },
      },
    ];
  }

  // The formatMessagesSync method returns an array of results
  // for each message that was requested, and for the ones with
  // attributes, returns an attributes array with objects like:
  //     { name: "label", value: "somevalue" }
  const [mainMessage, ...secondaryMessages] = localization
    .formatMessagesSync(actionL10nIds)
    .map(msg =>
      msg.attributes.reduce(
        (acc, { name, value }) => ({ ...acc, [name]: value }),
        {}
      )
    );

  const mainAction = {
    label: mainMessage.label,
    accessKey: mainMessage.accesskey,
    // The real callback will be set during the "showing" event. The
    // empty function here is so that PopupNotifications.show doesn't
    // reject the action.
    callback() {},
  };

  for (let i = 0; i < secondaryActions.length; ++i) {
    secondaryActions[i].label = secondaryMessages[i].label;
    secondaryActions[i].accessKey = secondaryMessages[i].accesskey;
  }

  let options = {
    name: originToShow,
    persistent: true,
    hideClose: true,
    eventCallback(aTopic, aNewBrowser, isCancel) {
      if (aTopic == "swapping") {
        return true;
      }

      let doc = this.browser.ownerDocument;

      // Clean-up video streams of screensharing previews.
      if (
        reqVideoInput !== "Screen" ||
        aTopic == "dismissed" ||
        aTopic == "removed"
      ) {
        let video = doc.getElementById("webRTC-previewVideo");
        video.deviceId = null; // Abort previews still being started.
        if (video.stream) {
          video.stream.getTracks().forEach(t => t.stop());
          video.stream = null;
          video.src = null;
          doc.getElementById("webRTC-preview").hidden = true;
        }
        let menupopup = doc.getElementById("webRTC-selectWindow-menupopup");
        if (menupopup._commandEventListener) {
          menupopup.removeEventListener(
            "command",
            menupopup._commandEventListener
          );
          menupopup._commandEventListener = null;
        }
      }

      if (aTopic == "removed" && notification && isCancel) {
        // The notification has been cancelled (e.g. due to entering
        // full-screen).  Also cancel the webRTC request.
        aActor.denyRequest(aRequest);
      } else if (
        aTopic == "shown" &&
        audioOutputDevices.length > 1 &&
        !notification.wasDismissed
      ) {
        // Focus the list on first show so that arrow keys select the speaker.
        doc.getElementById("webRTC-selectSpeaker-richlistbox").focus();
      }

      if (aTopic != "showing") {
        return false;
      }

      // If BLOCK has been set persistently in the permission manager or has
      // been set on the tab, then it is handled synchronously before we add
      // the notification.
      // Handling of ALLOW is delayed until the popupshowing event,
      // to avoid granting permissions automatically to background tabs.
      if (aActor.checkRequestAllowed(aRequest, principal, aBrowser)) {
        this.remove();
        return true;
      }

      /**
       * Prepare the device selector for one kind of device.
       * @param {Object[]} devices - available devices of this kind.
       * @param {string} IDPrefix - indicating kind of device and so
       *   associated UI elements.
       * @param {string[]} describedByIDs - an array to which might be
       *   appended ids of elements that describe the panel, for the caller to
       *   use in the aria-describedby attribute.
       */
      function listDevices(devices, IDPrefix, describedByIDs) {
        let labelID = `${IDPrefix}-single-device-label`;
        let list;
        let itemParent;
        if (IDPrefix == "webRTC-selectSpeaker") {
          list = doc.getElementById(`${IDPrefix}-richlistbox`);
          itemParent = list;
        } else {
          itemParent = doc.getElementById(`${IDPrefix}-menupopup`);
          list = itemParent.parentNode; // menulist
        }
        while (itemParent.lastChild) {
          itemParent.removeChild(itemParent.lastChild);
        }

        // Removing the child nodes of a menupopup doesn't clear the value
        // attribute of its menulist. Similary for richlistbox state. This can
        // have unfortunate side effects when the list is rebuilt with a
        // different content, so we set the selectedIndex explicitly to reset
        // state.
        let defaultIndex = 0;

        for (let device of devices) {
          let item = addDeviceToList(list, device.name, device.deviceIndex);
          if (IDPrefix == "webRTC-selectSpeaker") {
            item.addEventListener("dblclick", event => {
              // Allow the chosen speakers via
              // .popup-notification-primary-button so that
              // "security.notification_enable_delay" is checked.
              event.target.closest("popupnotification").button.doCommand();
            });
            if (device.id == aRequest.audioOutputId) {
              defaultIndex = device.deviceIndex;
            }
          }
        }
        list.selectedIndex = defaultIndex;

        let label = doc.getElementById(labelID);
        if (devices.length == 1) {
          describedByIDs.push(`${IDPrefix}-icon`, labelID);
          label.value = devices[0].name;
          label.hidden = false;
          list.hidden = true;
        } else {
          label.hidden = true;
          list.hidden = false;
        }
      }

      let notificationElement = doc.getElementById(
        "webRTC-shareDevices-notification"
      );

      function checkDisabledWindowMenuItem() {
        let list = doc.getElementById("webRTC-selectWindow-menulist");
        let item = list.selectedItem;
        if (!item || item.hasAttribute("disabled")) {
          notificationElement.setAttribute("invalidselection", "true");
        } else {
          notificationElement.removeAttribute("invalidselection");
        }
      }

      function listScreenShareDevices(menupopup, devices) {
        while (menupopup.lastChild) {
          menupopup.removeChild(menupopup.lastChild);
        }

        // Removing the child nodes of the menupopup doesn't clear the value
        // attribute of the menulist. This can have unfortunate side effects
        // when the list is rebuilt with a different content, so we remove
        // the value attribute and unset the selectedItem explicitly.
        menupopup.parentNode.removeAttribute("value");
        menupopup.parentNode.selectedItem = null;

        // "Select a Window or Screen" is the default because we can't and don't
        // want to pick a 'default' window to share (Full screen is "scary").
        addDeviceToList(
          menupopup.parentNode,
          localization.formatValueSync("webrtc-pick-window-or-screen"),
          "-1"
        );
        menupopup.appendChild(doc.createXULElement("menuseparator"));

        let isPipeWireDetected = false;

        // Build the list of 'devices'.
        let monitorIndex = 1;
        for (let i = 0; i < devices.length; ++i) {
          let device = devices[i];
          let type = device.mediaSource;
          let name;
          if (device.canRequestOsLevelPrompt) {
            // When we share content by PipeWire add only one item to the device
            // list. When it's selected PipeWire portal dialog is opened and
            // user confirms actual window/screen sharing there.
            // Don't mark it as scary as there's an extra confirmation step by
            // PipeWire portal dialog.

            isPipeWireDetected = true;
            let item = addDeviceToList(
              menupopup.parentNode,
              localization.formatValueSync("webrtc-share-pipe-wire-portal"),
              i,
              type
            );
            item.deviceId = device.rawId;
            item.mediaSource = type;

            // In this case the OS sharing dialog will be the only option and
            // can be safely pre-selected.
            menupopup.parentNode.selectedItem = item;
            continue;
          } else if (type == "screen") {
            // Building screen list from available screens.
            if (device.name == "Primary Monitor") {
              name = localization.formatValueSync("webrtc-share-entire-screen");
            } else {
              name = localization.formatValueSync("webrtc-share-monitor", {
                monitorIndex,
              });
              ++monitorIndex;
            }
          } else {
            name = device.name;

            if (type == "application") {
              // The application names returned by the platform are of the form:
              // <window count>\x1e<application name>
              const [count, appName] = name.split("\x1e");
              name = localization.formatValueSync("webrtc-share-application", {
                appName,
                windowCount: parseInt(count),
              });
            }
          }
          let item = addDeviceToList(menupopup.parentNode, name, i, type);
          item.deviceId = device.rawId;
          item.mediaSource = type;
          if (device.scary) {
            item.scary = true;
          }
        }

        // Always re-select the "No <type>" item.
        doc
          .getElementById("webRTC-selectWindow-menulist")
          .removeAttribute("value");
        doc.getElementById("webRTC-all-windows-shared").hidden = true;

        menupopup._commandEventListener = event => {
          checkDisabledWindowMenuItem();
          let video = doc.getElementById("webRTC-previewVideo");
          if (video.stream) {
            video.stream.getTracks().forEach(t => t.stop());
            video.stream = null;
          }

          const { deviceId, mediaSource, scary } = event.target;
          if (deviceId == undefined) {
            doc.getElementById("webRTC-preview").hidden = true;
            video.src = null;
            return;
          }

          let warning = doc.getElementById("webRTC-previewWarning");
          let warningBox = doc.getElementById("webRTC-previewWarningBox");
          warningBox.hidden = !scary;
          let chromeWin = doc.defaultView;
          if (scary) {
            const warnId =
              mediaSource == "screen"
                ? "webrtc-share-screen-warning"
                : "webrtc-share-browser-warning";
            doc.l10n.setAttributes(warning, warnId);

            const learnMore = doc.getElementById(
              "webRTC-previewWarning-learnMore"
            );
            const baseURL = Services.urlFormatter.formatURLPref(
              "app.support.baseURL"
            );
            learnMore.setAttribute("href", baseURL + "screenshare-safety");
            doc.l10n.setAttributes(learnMore, "webrtc-share-screen-learn-more");

            // On Catalina, we don't want to blow our chance to show the
            // OS-level helper prompt to enable screen recording if the user
            // intends to reject anyway. OTOH showing it when they click Allow
            // is too late. A happy middle is to show it when the user makes a
            // choice in the picker. This already happens implicitly if the
            // user chooses "Entire desktop", as a side-effect of our preview,
            // we just need to also do it if they choose "Firefox". These are
            // the lone two options when permission is absent on Catalina.
            // Ironically, these are the two sources marked "scary" from a
            // web-sharing perspective, which is why this code resides here.
            // A restart doesn't appear to be necessary in spite of OS wording.
            let scrStatus = {};
            lazy.OSPermissions.getScreenCapturePermissionState(scrStatus);
            if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) {
              lazy.OSPermissions.maybeRequestScreenCapturePermission();
            }
          }

          let perms = Services.perms;
          let chromePrincipal =
            Services.scriptSecurityManager.getSystemPrincipal();
          perms.addFromPrincipal(
            chromePrincipal,
            "MediaManagerVideo",
            perms.ALLOW_ACTION,
            perms.EXPIRE_SESSION
          );

          // We don't have access to any screen content besides our browser tabs
          // on Wayland, therefore there are no previews we can show.
          if (
            (!isPipeWireDetected || mediaSource == "browser") &&
            Services.prefs.getBoolPref(
              "media.getdisplaymedia.previews.enabled",
              true
            )
          ) {
            video.deviceId = deviceId;
            let constraints = {
              video: { mediaSource, deviceId: { exact: deviceId } },
            };
            chromeWin.navigator.mediaDevices.getUserMedia(constraints).then(
              stream => {
                if (video.deviceId != deviceId) {
                  // The user has selected a different device or closed the panel
                  // before getUserMedia finished.
                  stream.getTracks().forEach(t => t.stop());
                  return;
                }
                video.srcObject = stream;
                video.stream = stream;
                doc.getElementById("webRTC-preview").hidden = false;
                video.onloadedmetadata = function () {
                  video.play();
                };
              },
              err => {
                if (
                  err.name == "OverconstrainedError" &&
                  err.constraint == "deviceId"
                ) {
                  // Window has disappeared since enumeration, which can happen.
                  // No preview for you.
                  return;
                }
                console.error(
                  `error in preview: ${err.message} ${err.constraint}`
                );
              }
            );
          }
        };
        menupopup.addEventListener("command", menupopup._commandEventListener);
      }

      function addDeviceToList(list, deviceName, deviceIndex, type) {
        let item = list.appendItem(deviceName, deviceIndex);
        item.setAttribute("tooltiptext", deviceName);
        if (type) {
          item.setAttribute("devicetype", type);
        }

        if (deviceIndex == "-1") {
          item.setAttribute("disabled", true);
        }

        return item;
      }

      doc.getElementById("webRTC-selectCamera").hidden =
        reqVideoInput !== "Camera";
      doc.getElementById("webRTC-selectWindowOrScreen").hidden =
        reqVideoInput !== "Screen";
      doc.getElementById("webRTC-selectMicrophone").hidden =
        reqAudioInput !== "Microphone";
      doc.getElementById("webRTC-selectSpeaker").hidden = !reqAudioOutput;

      let describedByIDs = ["webRTC-shareDevices-notification-description"];

      if (sharingScreen) {
        let windowMenupopup = doc.getElementById(
          "webRTC-selectWindow-menupopup"
        );
        listScreenShareDevices(windowMenupopup, videoInputDevices);
        checkDisabledWindowMenuItem();
      } else {
        listDevices(videoInputDevices, "webRTC-selectCamera", describedByIDs);
        notificationElement.removeAttribute("invalidselection");
      }
      if (!sharingAudio) {
        listDevices(
          audioInputDevices,
          "webRTC-selectMicrophone",
          describedByIDs
        );
      }
      listDevices(audioOutputDevices, "webRTC-selectSpeaker", describedByIDs);

      // PopupNotifications knows to clear the aria-describedby attribute
      // when hiding, so we don't have to worry about cleaning it up ourselves.
      chromeDoc.defaultView.PopupNotifications.panel.setAttribute(
        "aria-describedby",
        describedByIDs.join(" ")
      );

      this.mainAction.callback = async function (aState) {
        let remember = false;
        let silenceNotifications = false;

        if (notificationSilencingEnabled && sharingScreen) {
          silenceNotifications = aState && aState.checkboxChecked;
        } else {
          remember = aState && aState.checkboxChecked;
        }

        let allowedDevices = [];
        let perms = Services.perms;
        if (reqVideoInput) {
          let listId = sharingScreen
            ? "webRTC-selectWindow-menulist"
            : "webRTC-selectCamera-menulist";
          let videoDeviceIndex = doc.getElementById(listId).value;
          let allowVideoDevice = videoDeviceIndex != "-1";
          if (allowVideoDevice) {
            allowedDevices.push(videoDeviceIndex);
            // Session permission will be removed after use
            // (it's really one-shot, not for the entire session)
            perms.addFromPrincipal(
              principal,
              "MediaManagerVideo",
              perms.ALLOW_ACTION,
              perms.EXPIRE_SESSION
            );
            let { mediaSource, rawId } = videoInputDevices.find(
              ({ deviceIndex }) => deviceIndex == videoDeviceIndex
            );
            aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId);
            if (!sharingScreen) {
              persistGrantOrPromptPermission(principal, "camera", remember);
            }
          }
        }

        if (reqAudioInput === "Microphone") {
          let audioDeviceIndex = doc.getElementById(
            "webRTC-selectMicrophone-menulist"
          ).value;
          let allowMic = audioDeviceIndex != "-1";
          if (allowMic) {
            allowedDevices.push(audioDeviceIndex);
            let { mediaSource, rawId } = audioInputDevices.find(
              ({ deviceIndex }) => deviceIndex == audioDeviceIndex
            );
            aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId);
            persistGrantOrPromptPermission(principal, "microphone", remember);
          }
        } else if (reqAudioInput === "AudioCapture") {
          // Only one device possible for audio capture.
          allowedDevices.push(0);
        }

        if (reqAudioOutput) {
          let audioDeviceIndex = doc.getElementById(
            "webRTC-selectSpeaker-richlistbox"
          ).value;
          let allowSpeaker = audioDeviceIndex != "-1";
          if (allowSpeaker) {
            allowedDevices.push(audioDeviceIndex);
            let { id } = audioOutputDevices.find(
              ({ deviceIndex }) => deviceIndex == audioDeviceIndex
            );
            lazy.SitePermissions.setForPrincipal(
              principal,
              ["speaker", id].join("^"),
              lazy.SitePermissions.ALLOW
            );
          }
        }

        if (!allowedDevices.length) {
          aActor.denyRequest(aRequest);
          return;
        }

        const camNeeded = reqVideoInput === "Camera";
        const micNeeded = !!reqAudioInput;
        const scrNeeded = reqVideoInput === "Screen";
        const havePermission = await aActor.checkOSPermission(
          camNeeded,
          micNeeded,
          scrNeeded
        );
        if (!havePermission) {
          aActor.denyRequestNoPermission(aRequest);
          return;
        }

        aActor.sendAsyncMessage("webrtc:Allow", {
          callID: aRequest.callID,
          windowID: aRequest.windowID,
          devices: allowedDevices,
          suppressNotifications: silenceNotifications,
        });
      };

      // If we haven't handled the permission yet, we want to show the doorhanger.
      return false;
    },
  };

  function shouldShowAlwaysRemember() {
    // Don't offer "always remember" action in PB mode
    if (lazy.PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) {
      return false;
    }

    // Don't offer "always remember" action in maybe unsafe permission
    // delegation
    if (aRequest.secondOrigin) {
      return false;
    }

    // Speaker grants are always remembered, so no checkbox is required.
    if (reqAudioOutput) {
      return false;
    }

    return true;
  }

  function getRememberCheckboxLabel() {
    if (reqVideoInput == "Camera") {
      if (reqAudioInput == "Microphone") {
        return "webrtc-remember-allow-checkbox-camera-and-microphone";
      }
      return "webrtc-remember-allow-checkbox-camera";
    }

    if (reqAudioInput == "Microphone") {
      return "webrtc-remember-allow-checkbox-microphone";
    }

    return "webrtc-remember-allow-checkbox";
  }

  if (shouldShowAlwaysRemember()) {
    // Disable the permanent 'Allow' action if the connection isn't secure, or for
    // screen/audio sharing (because we can't guess which window the user wants to
    // share without prompting). Note that we never enter this block for private
    // browsing windows.
    let reason = "";
    if (sharingScreen) {
      reason = "webrtc-reason-for-no-permanent-allow-screen";
    } else if (sharingAudio) {
      reason = "webrtc-reason-for-no-permanent-allow-audio";
    } else if (!aRequest.secure) {
      reason = "webrtc-reason-for-no-permanent-allow-insecure";
    }

    options.checkbox = {
      label: localization.formatValueSync(getRememberCheckboxLabel()),
      checked: principal.isAddonOrExpandedAddonPrincipal,
      checkedState: reason
        ? {
            disableMainAction: true,
            warningLabel: localization.formatValueSync(reason),
          }
        : undefined,
    };
  }

  // If the notification silencing feature is enabled and we're sharing a
  // screen, then the checkbox for the permission panel is what controls
  // notification silencing.
  if (notificationSilencingEnabled && sharingScreen) {
    options.checkbox = {
      label: localization.formatValueSync("webrtc-mute-notifications-checkbox"),
      checked: false,
      checkedState: {
        disableMainAction: false,
      },
    };
  }

  let anchorId = "webRTC-shareDevices-notification-icon";
  if (reqVideoInput === "Screen") {
    anchorId = "webRTC-shareScreen-notification-icon";
  } else if (!reqVideoInput) {
    if (reqAudioInput && !reqAudioOutput) {
      anchorId = "webRTC-shareMicrophone-notification-icon";
    } else if (!reqAudioInput && reqAudioOutput) {
      anchorId = "webRTC-shareSpeaker-notification-icon";
    }
  }

  if (aRequest.secondOrigin) {
    options.secondName = lazy.webrtcUI.getHostOrExtensionName(
      null,
      aRequest.secondOrigin
    );
  }

  notification = chromeDoc.defaultView.PopupNotifications.show(
    aBrowser,
    "webRTC-shareDevices",
    message,
    anchorId,
    mainAction,
    secondaryActions,
    options
  );
  notification.callID = aRequest.callID;
}

/**
 * @param {"Screen" | "Camera" | null} reqVideoInput
 * @param {"AudioCapture" | "Microphone" | null} reqAudioInput
 * @param {boolean} reqAudioOutput
 * @param {boolean} delegation - Is the access delegated to a third party?
 * @returns {string} Localization message identifier
 */
function getPromptMessageId(
  reqVideoInput,
  reqAudioInput,
  reqAudioOutput,
  delegation
) {
  switch (reqVideoInput) {
    case "Camera":
      switch (reqAudioInput) {
        case "Microphone":
          return delegation
            ? "webrtc-allow-share-camera-and-microphone-unsafe-delegation"
            : "webrtc-allow-share-camera-and-microphone";
        case "AudioCapture":
          return delegation
            ? "webrtc-allow-share-camera-and-audio-capture-unsafe-delegation"
            : "webrtc-allow-share-camera-and-audio-capture";
        default:
          return delegation
            ? "webrtc-allow-share-camera-unsafe-delegation"
            : "webrtc-allow-share-camera";
      }

    case "Screen":
      switch (reqAudioInput) {
        case "Microphone":
          return delegation
            ? "webrtc-allow-share-screen-and-microphone-unsafe-delegation"
            : "webrtc-allow-share-screen-and-microphone";
        case "AudioCapture":
          return delegation
            ? "webrtc-allow-share-screen-and-audio-capture-unsafe-delegation"
            : "webrtc-allow-share-screen-and-audio-capture";
        default:
          return delegation
            ? "webrtc-allow-share-screen-unsafe-delegation"
            : "webrtc-allow-share-screen";
      }

    default:
      switch (reqAudioInput) {
        case "Microphone":
          return delegation
            ? "webrtc-allow-share-microphone-unsafe-delegation"
            : "webrtc-allow-share-microphone";
        case "AudioCapture":
          return delegation
            ? "webrtc-allow-share-audio-capture-unsafe-delegation"
            : "webrtc-allow-share-audio-capture";
        default:
          // This should be always true, if we've reached this far.
          if (reqAudioOutput) {
            return delegation
              ? "webrtc-allow-share-speaker-unsafe-delegation"
              : "webrtc-allow-share-speaker";
          }
          return undefined;
      }
  }
}

/**
 * Checks whether we have a microphone/camera in use by checking the activePerms map
 * or if we have an allow permission for a microphone/camera in sitePermissions
 * @param {Browser} browser - Browser to find all active and allowed microphone and camera devices for
 * @return true if one of the above conditions is met
 */
function allowedOrActiveCameraOrMicrophone(browser) {
  // Do we have an allow permission for cam/mic in the permissions manager?
  if (
    lazy.SitePermissions.getAllForBrowser(browser).some(perm => {
      return (
        perm.state == lazy.SitePermissions.ALLOW &&
        (perm.id.startsWith("camera") || perm.id.startsWith("microphone"))
      );
    })
  ) {
    // Return early, no need to check for active devices
    return true;
  }

  // Do we have an active device?
  return (
    // Find all windowIDs that belong to our browsing contexts
    browser.browsingContext
      .getAllBrowsingContextsInSubtree()
      // Only keep the outerWindowIds
      .map(bc => bc.currentWindowGlobal?.outerWindowId)
      .filter(id => id != null)
      // We have an active device if one of our windowIds has a non empty map in the activePerms map
      // that includes one device of type "camera" or "microphone"
      .some(id => {
        let map = lazy.webrtcUI.activePerms.get(id);
        if (!map) {
          // This windowId has no active device
          return false;
        }
        // Let's see if one of the devices is a camera or a microphone
        let types = [...map.values()];
        return types.includes("microphone") || types.includes("camera");
      })
  );
}

function removePrompt(aBrowser, aCallId) {
  let chromeWin = aBrowser.ownerGlobal;
  let notification = chromeWin.PopupNotifications.getNotification(
    "webRTC-shareDevices",
    aBrowser
  );
  if (notification && notification.callID == aCallId) {
    notification.remove();
  }
}

/**
 * Clears temporary permission grants used for WebRTC device grace periods.
 * @param browser - Browser element to clear permissions for.
 * @param {boolean} clearCamera - Clear camera grants.
 * @param {boolean} clearMicrophone - Clear microphone grants.
 */
function clearTemporaryGrants(browser, clearCamera, clearMicrophone) {
  if (!clearCamera && !clearMicrophone) {
    // Nothing to clear.
    return;
  }
  let perms = lazy.SitePermissions.getAllForBrowser(browser);
  perms
    .filter(perm => {
      let [id, key] = perm.id.split(lazy.SitePermissions.PERM_KEY_DELIMITER);
      // We only want to clear WebRTC grace periods. These are temporary, device
      // specifc (double-keyed) microphone or camera permissions.
      return (
        key &&
        perm.state == lazy.SitePermissions.ALLOW &&
        perm.scope == lazy.SitePermissions.SCOPE_TEMPORARY &&
        ((clearCamera && id == "camera") ||
          (clearMicrophone && id == "microphone"))
      );
    })
    .forEach(perm =>
      lazy.SitePermissions.removeFromPrincipal(null, perm.id, browser)
    );
}

/**
 * Persist an ALLOW state if the remember option is true.
 * Otherwise, persist PROMPT so that we can later tell the site
 * that permission was granted once before.
 * This makes Firefox seem much more like Chrome to sites that
 * expect a one-off, persistent permission grant for cam/mic.
 *
 * @param principal - Principal to add permission to.
 * @param {string} permissionName - name of permission.
 * @param remember - whether the grant should be persisted.
 */
function persistGrantOrPromptPermission(principal, permissionName, remember) {
  // There are cases like unsafe delegation where a prompt appears
  // even in ALLOW state, so make sure to not overwrite it (there's
  // no remember checkbox in those cases)
  if (
    lazy.SitePermissions.getForPrincipal(principal, permissionName).state ==
    lazy.SitePermissions.ALLOW
  ) {
    return;
  }

  lazy.SitePermissions.setForPrincipal(
    principal,
    permissionName,
    remember ? lazy.SitePermissions.ALLOW : lazy.SitePermissions.PROMPT
  );
}

/**
 * Clears any persisted PROMPT (aka Always Ask) permission.
 * @param principal - Principal to remove permission from.
 * @param {string} permissionName - name of permission.
 * @param browser - Browser element to clear permission for.
 */
function maybeClearAlwaysAsk(principal, permissionName, browser) {
  // For the "Always Ask" user choice, only persisted PROMPT is used,
  // so no need to scan through temporary permissions.
  if (
    lazy.SitePermissions.getForPrincipal(principal, permissionName).state ==
    lazy.SitePermissions.PROMPT
  ) {
    lazy.SitePermissions.removeFromPrincipal(
      principal,
      permissionName,
      browser
    );
  }
}
