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 file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
11 SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
12 webrtcUI: "resource:///modules/webrtcUI.sys.mjs",
15 XPCOMUtils.defineLazyServiceGetter(
18 "@mozilla.org/ospermissionrequest;1",
19 "nsIOSPermissionRequest"
22 export class WebRTCParent extends JSWindowActorParent {
24 // Media stream tracks end on unload, so call stopRecording() on them early
25 // *before* we go away, to ensure we're working with the right principal.
26 this.stopRecording(this.manager.outerWindowId);
27 lazy.webrtcUI.forgetStreamsFromBrowserContext(this.browsingContext);
28 // Must clear activePerms here to prevent them from being read by laggard
29 // stopRecording() calls, which due to IPC, may come in *after* navigation.
30 // This is to prevent granting temporary grace periods to the wrong page.
31 lazy.webrtcUI.activePerms.delete(this.manager.outerWindowId);
35 return this.browsingContext.top.embedderElement;
38 receiveMessage(aMessage) {
39 switch (aMessage.name) {
40 case "rtcpeer:Request": {
41 let params = Object.freeze(
44 origin: this.manager.documentPrincipal.origin,
50 let blockers = Array.from(lazy.webrtcUI.peerConnectionBlockers);
53 for (let blocker of blockers) {
55 let result = await blocker(params);
56 if (result == "deny") {
60 console.error(`error in PeerConnection blocker: ${err.message}`);
64 })().then(decision => {
67 lazy.webrtcUI.emitter.emit("peer-request-allowed", params);
68 message = "rtcpeer:Allow";
70 lazy.webrtcUI.emitter.emit("peer-request-blocked", params);
71 message = "rtcpeer:Deny";
74 this.sendAsyncMessage(message, {
75 callID: params.callID,
76 windowID: params.windowID,
81 case "rtcpeer:CancelRequest": {
82 let params = Object.freeze({
83 origin: this.manager.documentPrincipal.origin,
84 callID: aMessage.data,
86 lazy.webrtcUI.emitter.emit("peer-request-cancel", params);
89 case "webrtc:Request": {
90 let data = aMessage.data;
92 // Record third party origins for telemetry.
93 let isThirdPartyOrigin =
94 this.manager.documentPrincipal.origin !=
95 this.manager.topWindowContext.documentPrincipal.origin;
96 data.isThirdPartyOrigin = isThirdPartyOrigin;
98 data.origin = this.manager.topWindowContext.documentPrincipal.origin;
100 let browser = this.getBrowser();
101 if (browser.fxrPermissionPrompt) {
102 // For Firefox Reality on Desktop, switch to a different mechanism to
103 // prompt the user since fewer permissions are available and since many
104 // UI dependencies are not available.
105 browser.fxrPermissionPrompt(data);
107 prompt(this, this.getBrowser(), data);
111 case "webrtc:StopRecording":
113 aMessage.data.windowID,
114 aMessage.data.mediaSource,
118 case "webrtc:CancelRequest": {
119 let browser = this.getBrowser();
120 // browser can be null when closing the window
122 removePrompt(browser, aMessage.data);
126 case "webrtc:UpdateIndicators": {
127 let { data } = aMessage;
128 data.documentURI = this.manager.documentURI?.spec;
131 data.principal = this.manager.topWindowContext.documentPrincipal;
133 lazy.webrtcUI.streamAddedOrRemoved(this.browsingContext, data);
135 this.updateIndicators(data);
141 updateIndicators(aData) {
142 let browsingContext = this.browsingContext;
143 let state = lazy.webrtcUI.updateIndicators(browsingContext.top);
145 let browser = this.getBrowser();
150 state.browsingContext = browsingContext;
151 state.windowId = aData.windowId;
153 let tabbrowser = browser.ownerGlobal.gBrowser;
155 tabbrowser.updateBrowserSharing(browser, {
161 denyRequest(aRequest) {
162 this.sendAsyncMessage("webrtc:Deny", {
163 callID: aRequest.callID,
164 windowID: aRequest.windowID,
169 // Deny the request because the browser does not have access to the
170 // camera or microphone due to OS security restrictions. The user may
171 // have granted camera/microphone access to the site, but not have
172 // allowed the browser access in OS settings.
174 denyRequestNoPermission(aRequest) {
175 this.sendAsyncMessage("webrtc:Deny", {
176 callID: aRequest.callID,
177 windowID: aRequest.windowID,
178 noOSPermission: true,
183 // Check if we have permission to access the camera or screen-sharing and/or
184 // microphone at the OS level. Triggers a request to access the device if access
185 // is needed and the permission state has not yet been determined.
187 async checkOSPermission(camNeeded, micNeeded, scrNeeded) {
188 // Don't trigger OS permission requests for fake devices. Fake devices don't
189 // require OS permission and the dialogs are problematic in automated testing
190 // (where fake devices are used) because they require user interaction.
193 Services.prefs.getBoolPref("media.navigator.streams.fake", false)
199 if (camNeeded || micNeeded) {
200 lazy.OSPermissions.getMediaCapturePermissionState(camStatus, micStatus);
203 let camPermission = camStatus.value;
204 let camAccessible = await this.checkAndGetOSPermission(
206 lazy.OSPermissions.requestVideoCapturePermission
208 if (!camAccessible) {
213 let micPermission = micStatus.value;
214 let micAccessible = await this.checkAndGetOSPermission(
216 lazy.OSPermissions.requestAudioCapturePermission
218 if (!micAccessible) {
224 lazy.OSPermissions.getScreenCapturePermissionState(scrStatus);
225 if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) {
226 lazy.OSPermissions.maybeRequestScreenCapturePermission();
234 // Given a device's permission, return true if the device is accessible. If
235 // the device's permission is not yet determined, request access to the device.
236 // |requestPermissionFunc| must return a promise that resolves with true
237 // if the device is accessible and false otherwise.
239 async checkAndGetOSPermission(devicePermission, requestPermissionFunc) {
241 devicePermission == lazy.OSPermissions.PERMISSION_STATE_DENIED ||
242 devicePermission == lazy.OSPermissions.PERMISSION_STATE_RESTRICTED
246 if (devicePermission == lazy.OSPermissions.PERMISSION_STATE_NOTDETERMINED) {
247 let deviceAllowed = await requestPermissionFunc();
248 if (!deviceAllowed) {
255 stopRecording(aOuterWindowId, aMediaSource, aRawId) {
256 for (let { browsingContext, state } of lazy.webrtcUI._streams) {
257 if (browsingContext == this.browsingContext) {
258 let { principal } = state;
259 for (let { mediaSource, rawId } of state.devices) {
260 if (aRawId && (aRawId != rawId || aMediaSource != mediaSource)) {
263 // Deactivate this device (no aRawId means all devices).
264 this.deactivateDevicePerm(
276 * Add a device record to webrtcUI.activePerms, denoting a device as in use.
277 * Important to call for permission grace periods to work correctly.
279 activateDevicePerm(aOuterWindowId, aMediaSource, aId) {
280 if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) {
281 lazy.webrtcUI.activePerms.set(this.manager.outerWindowId, new Map());
283 lazy.webrtcUI.activePerms
284 .get(this.manager.outerWindowId)
285 .set(aOuterWindowId + aMediaSource + aId, aMediaSource);
289 * Remove a device record from webrtcUI.activePerms, denoting a device as
290 * no longer in use by the site. Meaning: gUM requests for this device will
291 * no longer be implicitly granted through the webrtcUI.activePerms mechanism.
293 * However, if webrtcUI.deviceGracePeriodTimeoutMs is defined, the implicit
294 * grant is extended for an additional period of time through SitePermissions.
296 deactivateDevicePerm(
302 // If we don't have active permissions for the given window anymore don't
303 // set a grace period. This happens if there has been a user revoke and
304 // webrtcUI clears the permissions.
305 if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) {
308 let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId);
309 map.delete(aOuterWindowId + aMediaSource + aId);
311 // Add a permission grace period for camera and microphone only
313 (aMediaSource != "camera" && aMediaSource != "microphone") ||
314 !this.browsingContext.top.embedderElement
318 let gracePeriodMs = lazy.webrtcUI.deviceGracePeriodTimeoutMs;
319 if (gracePeriodMs > 0) {
320 // A grace period is extended (even past navigation) to this outer window
321 // + origin + deviceId only. This avoids re-prompting without the user
322 // having to persist permission to the site, in a common case of a web
323 // conference asking them for the camera in a lobby page, before
324 // navigating to the actual meeting room page. Does not survive tab close.
326 // Caution: since navigation causes deactivation, we may be in the middle
327 // of one. We must pass in a principal & URI for SitePermissions to use
328 // instead of browser.currentURI, because the latter may point to a new
329 // page already, and we must not leak permission to unrelated pages.
331 let permissionName = [aMediaSource, aId].join("^");
332 lazy.SitePermissions.setForPrincipal(
333 aPermissionPrincipal,
335 lazy.SitePermissions.ALLOW,
336 lazy.SitePermissions.SCOPE_TEMPORARY,
337 this.browsingContext.top.embedderElement,
344 * Checks if the principal has sufficient permissions
345 * to fulfill the given request. If the request can be
346 * fulfilled, a message is sent to the child
347 * signaling that WebRTC permissions were given and
348 * this function will return true.
350 checkRequestAllowed(aRequest, aPrincipal) {
351 if (!aRequest.secure) {
354 // Always prompt for screen sharing
355 if (aRequest.sharingScreen) {
364 hasInherentAudioConstraints,
365 hasInherentVideoConstraints,
369 if (audioOutputDevices?.length) {
370 // Prompt if a specific device is not requested, available and allowed.
371 let device = audioOutputDevices.find(({ id }) => id == audioOutputId);
374 !lazy.SitePermissions.getForPrincipal(
376 ["speaker", device.id].join("^"),
378 ).state == lazy.SitePermissions.ALLOW
382 this.sendAsyncMessage("webrtc:Allow", {
385 devices: [device.deviceIndex],
390 let { perms } = Services;
392 perms.testExactPermissionFromPrincipal(aPrincipal, "MediaManagerVideo")
394 perms.removeFromPrincipal(aPrincipal, "MediaManagerVideo");
397 // Don't use persistent permissions from the top-level principal
398 // if we're handling a potentially insecure third party
399 // through a wildcard ("*") allow attribute.
400 let limited = aRequest.secondOrigin;
402 let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId);
403 // We consider a camera or mic active if it is active or was active within a
404 // grace period of milliseconds ago.
405 const isAllowed = ({ mediaSource, rawId }, permissionID) =>
406 map?.get(windowID + mediaSource + rawId) ||
408 (lazy.SitePermissions.getForPrincipal(aPrincipal, permissionID).state ==
409 lazy.SitePermissions.ALLOW ||
410 lazy.SitePermissions.getForPrincipal(
412 [mediaSource, rawId].join("^"),
414 ).state == lazy.SitePermissions.ALLOW));
417 if (audioInputDevices.length) {
418 for (let device of audioInputDevices) {
419 if (isAllowed(device, "microphone")) {
423 if (hasInherentAudioConstraints) {
424 // Inherent constraints suggest site is looking for a specific mic
427 // Some sites don't look too hard at what they get, and spam gUM without
428 // adjusting what they ask for to match what they got last time. To keep
429 // users in charge and reduce prompts, ignore other constraints by
430 // returning the most-fit microphone a site already has access to.
437 if (videoInputDevices.length) {
438 for (let device of videoInputDevices) {
439 if (isAllowed(device, "camera")) {
443 if (hasInherentVideoConstraints) {
444 // Inherent constraints suggest site is looking for a specific camera
447 // Some sites don't look too hard at what they get, and spam gUM without
448 // adjusting what they ask for to match what they got last time. To keep
449 // users in charge and reduce prompts, ignore other constraints by
450 // returning the most-fit camera a site already has access to.
458 perms.addFromPrincipal(
464 devices.push(camera.deviceIndex);
465 this.activateDevicePerm(windowID, camera.mediaSource, camera.rawId);
468 devices.push(microphone.deviceIndex);
469 this.activateDevicePerm(
471 microphone.mediaSource,
475 this.checkOSPermission(!!camera, !!microphone, false).then(
477 if (havePermission) {
478 this.sendAsyncMessage("webrtc:Allow", { callID, windowID, devices });
480 this.denyRequestNoPermission(aRequest);
488 function prompt(aActor, aBrowser, aRequest) {
499 Services.scriptSecurityManager.createContentPrincipalFromOrigin(
503 // For add-on principals, we immediately check for permission instead
504 // of waiting for the notification to focus. This allows for supporting
505 // cases such as browserAction popups where no prompt is shown.
506 if (principal.addonPolicy) {
508 let isBackground = false;
510 for (let view of principal.addonPolicy.extension.views) {
511 if (view.viewType == "popup" && view.xulBrowser == aBrowser) {
514 if (view.viewType == "background" && view.xulBrowser == aBrowser) {
519 // Recording from background pages is considered too sensitive and will
522 aActor.denyRequest(aRequest);
526 // If the request comes from a popup, we don't want to show the prompt,
527 // but we do want to allow the request if the user previously gave permission.
529 if (!aActor.checkRequestAllowed(aRequest, principal, aBrowser)) {
530 aActor.denyRequest(aRequest);
536 // If the user has already denied access once in this tab,
537 // deny again without even showing the notification icon.
538 for (const type of requestTypes) {
540 type == "AudioCapture" ? "microphone" : type.toLowerCase();
542 lazy.SitePermissions.getForPrincipal(principal, permissionID, aBrowser)
543 .state == lazy.SitePermissions.BLOCK
545 aActor.denyRequest(aRequest);
550 let chromeDoc = aBrowser.ownerDocument;
551 const localization = new Localization(
552 ["browser/webrtcIndicator.ftl", "branding/brand.ftl"],
556 /** @type {"Screen" | "Camera" | null} */
557 let reqVideoInput = null;
558 if (videoInputDevices.length) {
559 reqVideoInput = sharingScreen ? "Screen" : "Camera";
561 /** @type {"AudioCapture" | "Microphone" | null} */
562 let reqAudioInput = null;
563 if (audioInputDevices.length) {
564 reqAudioInput = sharingAudio ? "AudioCapture" : "Microphone";
566 const reqAudioOutput = !!audioOutputDevices.length;
568 const stringId = getPromptMessageId(
572 !!aRequest.secondOrigin
576 if (principal.schemeIs("file")) {
577 message = localization.formatValueSync(stringId + "-with-file");
580 message = localization.formatValueSync(stringId, {
584 originToShow = lazy.webrtcUI.getHostOrExtensionName(principal.URI);
586 let notification; // Used by action callbacks.
587 const actionL10nIds = [{ id: "webrtc-action-allow" }];
589 let notificationSilencingEnabled = Services.prefs.getBoolPref(
590 "privacy.webrtc.allowSilencingNotifications"
593 const isNotNowLabelEnabled =
594 reqAudioOutput || allowedOrActiveCameraOrMicrophone(aBrowser);
595 let secondaryActions = [];
596 if (reqAudioOutput || (notificationSilencingEnabled && sharingScreen)) {
597 // We want to free up the checkbox at the bottom of the permission
598 // panel for the notification silencing option, so we use a
599 // different configuration for the permissions panel when
600 // notification silencing is enabled.
602 let permissionName = reqAudioOutput ? "speaker" : "screen";
603 // When selecting speakers, we always offer 'Not now' instead of 'Block'.
604 // When selecting screens, we offer 'Not now' if and only if we have a
605 // (temporary) allow permission for some mic/cam device.
606 const id = isNotNowLabelEnabled
607 ? "webrtc-action-not-now"
608 : "webrtc-action-block";
609 actionL10nIds.push({ id }, { id: "webrtc-action-always-block" });
613 aActor.denyRequest(aRequest);
614 if (!isNotNowLabelEnabled) {
615 lazy.SitePermissions.setForPrincipal(
618 lazy.SitePermissions.BLOCK,
619 lazy.SitePermissions.SCOPE_TEMPORARY,
627 aActor.denyRequest(aRequest);
628 lazy.SitePermissions.setForPrincipal(
631 lazy.SitePermissions.BLOCK,
632 lazy.SitePermissions.SCOPE_PERSISTENT,
639 // We have a (temporary) allow permission for some device
640 // hence we offer a 'Not now' label instead of 'Block'.
641 const id = isNotNowLabelEnabled
642 ? "webrtc-action-not-now"
643 : "webrtc-action-block";
644 actionL10nIds.push({ id });
648 aActor.denyRequest(aRequest);
650 const isPersistent = aState?.checkboxChecked;
652 // Choosing 'Not now' will not set a block permission
653 // we just deny the request. This enables certain use cases
654 // where sites want to switch devices, but users back out of the permission request
655 // (See Bug 1609578).
656 // Selecting 'Remember this decision' and clicking 'Not now' will set a persistent block
657 if (!isPersistent && isNotNowLabelEnabled) {
661 // Denying a camera / microphone prompt means we set a temporary or
662 // persistent permission block. There may still be active grace period
663 // permissions at this point. We need to remove them.
664 clearTemporaryGrants(
665 notification.browser,
666 reqVideoInput === "Camera",
670 const scope = isPersistent
671 ? lazy.SitePermissions.SCOPE_PERSISTENT
672 : lazy.SitePermissions.SCOPE_TEMPORARY;
675 // After a temporary block, having permissions.query() calls
676 // persistently report "granted" would be misleading
683 lazy.SitePermissions.setForPrincipal(
686 lazy.SitePermissions.BLOCK,
692 if (!isPersistent && !sharingScreen) {
693 // After a temporary block, having permissions.query() calls
694 // persistently report "granted" would be misleading
695 maybeClearAlwaysAsk(principal, "camera", notification.browser);
697 lazy.SitePermissions.setForPrincipal(
699 sharingScreen ? "screen" : "camera",
700 lazy.SitePermissions.BLOCK,
710 // The formatMessagesSync method returns an array of results
711 // for each message that was requested, and for the ones with
712 // attributes, returns an attributes array with objects like:
713 // { name: "label", value: "somevalue" }
714 const [mainMessage, ...secondaryMessages] = localization
715 .formatMessagesSync(actionL10nIds)
717 msg.attributes.reduce(
718 (acc, { name, value }) => ({ ...acc, [name]: value }),
724 label: mainMessage.label,
725 accessKey: mainMessage.accesskey,
726 // The real callback will be set during the "showing" event. The
727 // empty function here is so that PopupNotifications.show doesn't
728 // reject the action.
732 for (let i = 0; i < secondaryActions.length; ++i) {
733 secondaryActions[i].label = secondaryMessages[i].label;
734 secondaryActions[i].accessKey = secondaryMessages[i].accesskey;
741 eventCallback(aTopic, aNewBrowser, isCancel) {
742 if (aTopic == "swapping") {
746 let doc = this.browser.ownerDocument;
748 // Clean-up video streams of screensharing previews.
750 reqVideoInput !== "Screen" ||
751 aTopic == "dismissed" ||
754 let video = doc.getElementById("webRTC-previewVideo");
755 video.deviceId = null; // Abort previews still being started.
757 video.stream.getTracks().forEach(t => t.stop());
760 doc.getElementById("webRTC-preview").hidden = true;
762 let menupopup = doc.getElementById("webRTC-selectWindow-menupopup");
763 if (menupopup._commandEventListener) {
764 menupopup.removeEventListener(
766 menupopup._commandEventListener
768 menupopup._commandEventListener = null;
772 if (aTopic == "removed" && notification && isCancel) {
773 // The notification has been cancelled (e.g. due to entering
774 // full-screen). Also cancel the webRTC request.
775 aActor.denyRequest(aRequest);
778 audioOutputDevices.length > 1 &&
779 !notification.wasDismissed
781 // Focus the list on first show so that arrow keys select the speaker.
782 doc.getElementById("webRTC-selectSpeaker-richlistbox").focus();
785 if (aTopic != "showing") {
789 // If BLOCK has been set persistently in the permission manager or has
790 // been set on the tab, then it is handled synchronously before we add
792 // Handling of ALLOW is delayed until the popupshowing event,
793 // to avoid granting permissions automatically to background tabs.
794 if (aActor.checkRequestAllowed(aRequest, principal, aBrowser)) {
800 * Prepare the device selector for one kind of device.
801 * @param {Object[]} devices - available devices of this kind.
802 * @param {string} IDPrefix - indicating kind of device and so
803 * associated UI elements.
804 * @param {string[]} describedByIDs - an array to which might be
805 * appended ids of elements that describe the panel, for the caller to
806 * use in the aria-describedby attribute.
808 function listDevices(devices, IDPrefix, describedByIDs) {
809 let labelID = `${IDPrefix}-single-device-label`;
812 if (IDPrefix == "webRTC-selectSpeaker") {
813 list = doc.getElementById(`${IDPrefix}-richlistbox`);
816 itemParent = doc.getElementById(`${IDPrefix}-menupopup`);
817 list = itemParent.parentNode; // menulist
819 while (itemParent.lastChild) {
820 itemParent.removeChild(itemParent.lastChild);
823 // Removing the child nodes of a menupopup doesn't clear the value
824 // attribute of its menulist. Similary for richlistbox state. This can
825 // have unfortunate side effects when the list is rebuilt with a
826 // different content, so we set the selectedIndex explicitly to reset
828 let defaultIndex = 0;
830 for (let device of devices) {
831 let item = addDeviceToList(list, device.name, device.deviceIndex);
832 if (IDPrefix == "webRTC-selectSpeaker") {
833 item.addEventListener("dblclick", event => {
834 // Allow the chosen speakers via
835 // .popup-notification-primary-button so that
836 // "security.notification_enable_delay" is checked.
837 event.target.closest("popupnotification").button.doCommand();
839 if (device.id == aRequest.audioOutputId) {
840 defaultIndex = device.deviceIndex;
844 list.selectedIndex = defaultIndex;
846 let label = doc.getElementById(labelID);
847 if (devices.length == 1) {
848 describedByIDs.push(`${IDPrefix}-icon`, labelID);
849 label.value = devices[0].name;
850 label.hidden = false;
858 let notificationElement = doc.getElementById(
859 "webRTC-shareDevices-notification"
862 function checkDisabledWindowMenuItem() {
863 let list = doc.getElementById("webRTC-selectWindow-menulist");
864 let item = list.selectedItem;
865 if (!item || item.hasAttribute("disabled")) {
866 notificationElement.setAttribute("invalidselection", "true");
868 notificationElement.removeAttribute("invalidselection");
872 function listScreenShareDevices(menupopup, devices) {
873 while (menupopup.lastChild) {
874 menupopup.removeChild(menupopup.lastChild);
877 // Removing the child nodes of the menupopup doesn't clear the value
878 // attribute of the menulist. This can have unfortunate side effects
879 // when the list is rebuilt with a different content, so we remove
880 // the value attribute and unset the selectedItem explicitly.
881 menupopup.parentNode.removeAttribute("value");
882 menupopup.parentNode.selectedItem = null;
884 // "Select a Window or Screen" is the default because we can't and don't
885 // want to pick a 'default' window to share (Full screen is "scary").
887 menupopup.parentNode,
888 localization.formatValueSync("webrtc-pick-window-or-screen"),
891 menupopup.appendChild(doc.createXULElement("menuseparator"));
893 let isPipeWireDetected = false;
895 // Build the list of 'devices'.
896 let monitorIndex = 1;
897 for (let i = 0; i < devices.length; ++i) {
898 let device = devices[i];
899 let type = device.mediaSource;
901 if (device.canRequestOsLevelPrompt) {
902 // When we share content by PipeWire add only one item to the device
903 // list. When it's selected PipeWire portal dialog is opened and
904 // user confirms actual window/screen sharing there.
905 // Don't mark it as scary as there's an extra confirmation step by
906 // PipeWire portal dialog.
908 isPipeWireDetected = true;
909 let item = addDeviceToList(
910 menupopup.parentNode,
911 localization.formatValueSync("webrtc-share-pipe-wire-portal"),
915 item.deviceId = device.rawId;
916 item.mediaSource = type;
918 // In this case the OS sharing dialog will be the only option and
919 // can be safely pre-selected.
920 menupopup.parentNode.selectedItem = item;
922 } else if (type == "screen") {
923 // Building screen list from available screens.
924 if (device.name == "Primary Monitor") {
925 name = localization.formatValueSync("webrtc-share-entire-screen");
927 name = localization.formatValueSync("webrtc-share-monitor", {
935 if (type == "application") {
936 // The application names returned by the platform are of the form:
937 // <window count>\x1e<application name>
938 const [count, appName] = name.split("\x1e");
939 name = localization.formatValueSync("webrtc-share-application", {
941 windowCount: parseInt(count),
945 let item = addDeviceToList(menupopup.parentNode, name, i, type);
946 item.deviceId = device.rawId;
947 item.mediaSource = type;
953 // Always re-select the "No <type>" item.
955 .getElementById("webRTC-selectWindow-menulist")
956 .removeAttribute("value");
957 doc.getElementById("webRTC-all-windows-shared").hidden = true;
959 menupopup._commandEventListener = event => {
960 checkDisabledWindowMenuItem();
961 let video = doc.getElementById("webRTC-previewVideo");
963 video.stream.getTracks().forEach(t => t.stop());
967 const { deviceId, mediaSource, scary } = event.target;
968 if (deviceId == undefined) {
969 doc.getElementById("webRTC-preview").hidden = true;
974 let warning = doc.getElementById("webRTC-previewWarning");
975 let warningBox = doc.getElementById("webRTC-previewWarningBox");
976 warningBox.hidden = !scary;
977 let chromeWin = doc.defaultView;
980 mediaSource == "screen"
981 ? "webrtc-share-screen-warning"
982 : "webrtc-share-browser-warning";
983 doc.l10n.setAttributes(warning, warnId);
985 const learnMore = doc.getElementById(
986 "webRTC-previewWarning-learnMore"
988 const baseURL = Services.urlFormatter.formatURLPref(
989 "app.support.baseURL"
991 learnMore.setAttribute("href", baseURL + "screenshare-safety");
992 doc.l10n.setAttributes(learnMore, "webrtc-share-screen-learn-more");
994 // On Catalina, we don't want to blow our chance to show the
995 // OS-level helper prompt to enable screen recording if the user
996 // intends to reject anyway. OTOH showing it when they click Allow
997 // is too late. A happy middle is to show it when the user makes a
998 // choice in the picker. This already happens implicitly if the
999 // user chooses "Entire desktop", as a side-effect of our preview,
1000 // we just need to also do it if they choose "Firefox". These are
1001 // the lone two options when permission is absent on Catalina.
1002 // Ironically, these are the two sources marked "scary" from a
1003 // web-sharing perspective, which is why this code resides here.
1004 // A restart doesn't appear to be necessary in spite of OS wording.
1006 lazy.OSPermissions.getScreenCapturePermissionState(scrStatus);
1007 if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) {
1008 lazy.OSPermissions.maybeRequestScreenCapturePermission();
1012 let perms = Services.perms;
1013 let chromePrincipal =
1014 Services.scriptSecurityManager.getSystemPrincipal();
1015 perms.addFromPrincipal(
1017 "MediaManagerVideo",
1019 perms.EXPIRE_SESSION
1022 // We don't have access to any screen content besides our browser tabs
1023 // on Wayland, therefore there are no previews we can show.
1025 (!isPipeWireDetected || mediaSource == "browser") &&
1026 Services.prefs.getBoolPref(
1027 "media.getdisplaymedia.previews.enabled",
1031 video.deviceId = deviceId;
1033 video: { mediaSource, deviceId: { exact: deviceId } },
1035 chromeWin.navigator.mediaDevices.getUserMedia(constraints).then(
1037 if (video.deviceId != deviceId) {
1038 // The user has selected a different device or closed the panel
1039 // before getUserMedia finished.
1040 stream.getTracks().forEach(t => t.stop());
1043 video.srcObject = stream;
1044 video.stream = stream;
1045 doc.getElementById("webRTC-preview").hidden = false;
1046 video.onloadedmetadata = function () {
1052 err.name == "OverconstrainedError" &&
1053 err.constraint == "deviceId"
1055 // Window has disappeared since enumeration, which can happen.
1056 // No preview for you.
1060 `error in preview: ${err.message} ${err.constraint}`
1066 menupopup.addEventListener("command", menupopup._commandEventListener);
1069 function addDeviceToList(list, deviceName, deviceIndex, type) {
1070 let item = list.appendItem(deviceName, deviceIndex);
1071 item.setAttribute("tooltiptext", deviceName);
1073 item.setAttribute("devicetype", type);
1076 if (deviceIndex == "-1") {
1077 item.setAttribute("disabled", true);
1083 doc.getElementById("webRTC-selectCamera").hidden =
1084 reqVideoInput !== "Camera";
1085 doc.getElementById("webRTC-selectWindowOrScreen").hidden =
1086 reqVideoInput !== "Screen";
1087 doc.getElementById("webRTC-selectMicrophone").hidden =
1088 reqAudioInput !== "Microphone";
1089 doc.getElementById("webRTC-selectSpeaker").hidden = !reqAudioOutput;
1091 let describedByIDs = ["webRTC-shareDevices-notification-description"];
1093 if (sharingScreen) {
1094 let windowMenupopup = doc.getElementById(
1095 "webRTC-selectWindow-menupopup"
1097 listScreenShareDevices(windowMenupopup, videoInputDevices);
1098 checkDisabledWindowMenuItem();
1100 listDevices(videoInputDevices, "webRTC-selectCamera", describedByIDs);
1101 notificationElement.removeAttribute("invalidselection");
1103 if (!sharingAudio) {
1106 "webRTC-selectMicrophone",
1110 listDevices(audioOutputDevices, "webRTC-selectSpeaker", describedByIDs);
1112 // PopupNotifications knows to clear the aria-describedby attribute
1113 // when hiding, so we don't have to worry about cleaning it up ourselves.
1114 chromeDoc.defaultView.PopupNotifications.panel.setAttribute(
1116 describedByIDs.join(" ")
1119 this.mainAction.callback = async function (aState) {
1120 let remember = false;
1121 let silenceNotifications = false;
1123 if (notificationSilencingEnabled && sharingScreen) {
1124 silenceNotifications = aState && aState.checkboxChecked;
1126 remember = aState && aState.checkboxChecked;
1129 let allowedDevices = [];
1130 let perms = Services.perms;
1131 if (reqVideoInput) {
1132 let listId = sharingScreen
1133 ? "webRTC-selectWindow-menulist"
1134 : "webRTC-selectCamera-menulist";
1135 let videoDeviceIndex = doc.getElementById(listId).value;
1136 let allowVideoDevice = videoDeviceIndex != "-1";
1137 if (allowVideoDevice) {
1138 allowedDevices.push(videoDeviceIndex);
1139 // Session permission will be removed after use
1140 // (it's really one-shot, not for the entire session)
1141 perms.addFromPrincipal(
1143 "MediaManagerVideo",
1145 perms.EXPIRE_SESSION
1147 let { mediaSource, rawId } = videoInputDevices.find(
1148 ({ deviceIndex }) => deviceIndex == videoDeviceIndex
1150 aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId);
1151 if (!sharingScreen) {
1152 persistGrantOrPromptPermission(principal, "camera", remember);
1157 if (reqAudioInput === "Microphone") {
1158 let audioDeviceIndex = doc.getElementById(
1159 "webRTC-selectMicrophone-menulist"
1161 let allowMic = audioDeviceIndex != "-1";
1163 allowedDevices.push(audioDeviceIndex);
1164 let { mediaSource, rawId } = audioInputDevices.find(
1165 ({ deviceIndex }) => deviceIndex == audioDeviceIndex
1167 aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId);
1168 persistGrantOrPromptPermission(principal, "microphone", remember);
1170 } else if (reqAudioInput === "AudioCapture") {
1171 // Only one device possible for audio capture.
1172 allowedDevices.push(0);
1175 if (reqAudioOutput) {
1176 let audioDeviceIndex = doc.getElementById(
1177 "webRTC-selectSpeaker-richlistbox"
1179 let allowSpeaker = audioDeviceIndex != "-1";
1181 allowedDevices.push(audioDeviceIndex);
1182 let { id } = audioOutputDevices.find(
1183 ({ deviceIndex }) => deviceIndex == audioDeviceIndex
1185 lazy.SitePermissions.setForPrincipal(
1187 ["speaker", id].join("^"),
1188 lazy.SitePermissions.ALLOW
1193 if (!allowedDevices.length) {
1194 aActor.denyRequest(aRequest);
1198 const camNeeded = reqVideoInput === "Camera";
1199 const micNeeded = !!reqAudioInput;
1200 const scrNeeded = reqVideoInput === "Screen";
1201 const havePermission = await aActor.checkOSPermission(
1206 if (!havePermission) {
1207 aActor.denyRequestNoPermission(aRequest);
1211 aActor.sendAsyncMessage("webrtc:Allow", {
1212 callID: aRequest.callID,
1213 windowID: aRequest.windowID,
1214 devices: allowedDevices,
1215 suppressNotifications: silenceNotifications,
1219 // If we haven't handled the permission yet, we want to show the doorhanger.
1224 function shouldShowAlwaysRemember() {
1225 // Don't offer "always remember" action in PB mode
1226 if (lazy.PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) {
1230 // Don't offer "always remember" action in maybe unsafe permission
1232 if (aRequest.secondOrigin) {
1236 // Speaker grants are always remembered, so no checkbox is required.
1237 if (reqAudioOutput) {
1244 function getRememberCheckboxLabel() {
1245 if (reqVideoInput == "Camera") {
1246 if (reqAudioInput == "Microphone") {
1247 return "webrtc-remember-allow-checkbox-camera-and-microphone";
1249 return "webrtc-remember-allow-checkbox-camera";
1252 if (reqAudioInput == "Microphone") {
1253 return "webrtc-remember-allow-checkbox-microphone";
1256 return "webrtc-remember-allow-checkbox";
1259 if (shouldShowAlwaysRemember()) {
1260 // Disable the permanent 'Allow' action if the connection isn't secure, or for
1261 // screen/audio sharing (because we can't guess which window the user wants to
1262 // share without prompting). Note that we never enter this block for private
1263 // browsing windows.
1265 if (sharingScreen) {
1266 reason = "webrtc-reason-for-no-permanent-allow-screen";
1267 } else if (sharingAudio) {
1268 reason = "webrtc-reason-for-no-permanent-allow-audio";
1269 } else if (!aRequest.secure) {
1270 reason = "webrtc-reason-for-no-permanent-allow-insecure";
1273 options.checkbox = {
1274 label: localization.formatValueSync(getRememberCheckboxLabel()),
1275 checked: principal.isAddonOrExpandedAddonPrincipal,
1276 checkedState: reason
1278 disableMainAction: true,
1279 warningLabel: localization.formatValueSync(reason),
1285 // If the notification silencing feature is enabled and we're sharing a
1286 // screen, then the checkbox for the permission panel is what controls
1287 // notification silencing.
1288 if (notificationSilencingEnabled && sharingScreen) {
1289 options.checkbox = {
1290 label: localization.formatValueSync("webrtc-mute-notifications-checkbox"),
1293 disableMainAction: false,
1298 let anchorId = "webRTC-shareDevices-notification-icon";
1299 if (reqVideoInput === "Screen") {
1300 anchorId = "webRTC-shareScreen-notification-icon";
1301 } else if (!reqVideoInput) {
1302 if (reqAudioInput && !reqAudioOutput) {
1303 anchorId = "webRTC-shareMicrophone-notification-icon";
1304 } else if (!reqAudioInput && reqAudioOutput) {
1305 anchorId = "webRTC-shareSpeaker-notification-icon";
1309 if (aRequest.secondOrigin) {
1310 options.secondName = lazy.webrtcUI.getHostOrExtensionName(
1312 aRequest.secondOrigin
1316 notification = chromeDoc.defaultView.PopupNotifications.show(
1318 "webRTC-shareDevices",
1325 notification.callID = aRequest.callID;
1327 let schemeHistogram = Services.telemetry.getKeyedHistogramById(
1328 "PERMISSION_REQUEST_ORIGIN_SCHEME"
1330 let userInputHistogram = Services.telemetry.getKeyedHistogramById(
1331 "PERMISSION_REQUEST_HANDLING_USER_INPUT"
1334 let docURI = aRequest.documentURI;
1336 if (docURI.startsWith("https")) {
1338 } else if (docURI.startsWith("http")) {
1342 for (let requestType of requestTypes) {
1343 if (requestType == "AudioCapture") {
1344 requestType = "Microphone";
1346 requestType = requestType.toLowerCase();
1348 schemeHistogram.add(requestType, scheme);
1349 userInputHistogram.add(requestType, aRequest.isHandlingUserInput);
1354 * @param {"Screen" | "Camera" | null} reqVideoInput
1355 * @param {"AudioCapture" | "Microphone" | null} reqAudioInput
1356 * @param {boolean} reqAudioOutput
1357 * @param {boolean} delegation - Is the access delegated to a third party?
1358 * @returns {string} Localization message identifier
1360 function getPromptMessageId(
1366 switch (reqVideoInput) {
1368 switch (reqAudioInput) {
1371 ? "webrtc-allow-share-camera-and-microphone-unsafe-delegation"
1372 : "webrtc-allow-share-camera-and-microphone";
1373 case "AudioCapture":
1375 ? "webrtc-allow-share-camera-and-audio-capture-unsafe-delegation"
1376 : "webrtc-allow-share-camera-and-audio-capture";
1379 ? "webrtc-allow-share-camera-unsafe-delegation"
1380 : "webrtc-allow-share-camera";
1384 switch (reqAudioInput) {
1387 ? "webrtc-allow-share-screen-and-microphone-unsafe-delegation"
1388 : "webrtc-allow-share-screen-and-microphone";
1389 case "AudioCapture":
1391 ? "webrtc-allow-share-screen-and-audio-capture-unsafe-delegation"
1392 : "webrtc-allow-share-screen-and-audio-capture";
1395 ? "webrtc-allow-share-screen-unsafe-delegation"
1396 : "webrtc-allow-share-screen";
1400 switch (reqAudioInput) {
1403 ? "webrtc-allow-share-microphone-unsafe-delegation"
1404 : "webrtc-allow-share-microphone";
1405 case "AudioCapture":
1407 ? "webrtc-allow-share-audio-capture-unsafe-delegation"
1408 : "webrtc-allow-share-audio-capture";
1410 // This should be always true, if we've reached this far.
1411 if (reqAudioOutput) {
1413 ? "webrtc-allow-share-speaker-unsafe-delegation"
1414 : "webrtc-allow-share-speaker";
1422 * Checks whether we have a microphone/camera in use by checking the activePerms map
1423 * or if we have an allow permission for a microphone/camera in sitePermissions
1424 * @param {Browser} browser - Browser to find all active and allowed microphone and camera devices for
1425 * @return true if one of the above conditions is met
1427 function allowedOrActiveCameraOrMicrophone(browser) {
1428 // Do we have an allow permission for cam/mic in the permissions manager?
1430 lazy.SitePermissions.getAllForBrowser(browser).some(perm => {
1432 perm.state == lazy.SitePermissions.ALLOW &&
1433 (perm.id.startsWith("camera") || perm.id.startsWith("microphone"))
1437 // Return early, no need to check for active devices
1441 // Do we have an active device?
1443 // Find all windowIDs that belong to our browsing contexts
1444 browser.browsingContext
1445 .getAllBrowsingContextsInSubtree()
1446 // Only keep the outerWindowIds
1447 .map(bc => bc.currentWindowGlobal?.outerWindowId)
1448 .filter(id => id != null)
1449 // We have an active device if one of our windowIds has a non empty map in the activePerms map
1450 // that includes one device of type "camera" or "microphone"
1452 let map = lazy.webrtcUI.activePerms.get(id);
1454 // This windowId has no active device
1457 // Let's see if one of the devices is a camera or a microphone
1458 let types = [...map.values()];
1459 return types.includes("microphone") || types.includes("camera");
1464 function removePrompt(aBrowser, aCallId) {
1465 let chromeWin = aBrowser.ownerGlobal;
1466 let notification = chromeWin.PopupNotifications.getNotification(
1467 "webRTC-shareDevices",
1470 if (notification && notification.callID == aCallId) {
1471 notification.remove();
1476 * Clears temporary permission grants used for WebRTC device grace periods.
1477 * @param browser - Browser element to clear permissions for.
1478 * @param {boolean} clearCamera - Clear camera grants.
1479 * @param {boolean} clearMicrophone - Clear microphone grants.
1481 function clearTemporaryGrants(browser, clearCamera, clearMicrophone) {
1482 if (!clearCamera && !clearMicrophone) {
1483 // Nothing to clear.
1486 let perms = lazy.SitePermissions.getAllForBrowser(browser);
1489 let [id, key] = perm.id.split(lazy.SitePermissions.PERM_KEY_DELIMITER);
1490 // We only want to clear WebRTC grace periods. These are temporary, device
1491 // specifc (double-keyed) microphone or camera permissions.
1494 perm.state == lazy.SitePermissions.ALLOW &&
1495 perm.scope == lazy.SitePermissions.SCOPE_TEMPORARY &&
1496 ((clearCamera && id == "camera") ||
1497 (clearMicrophone && id == "microphone"))
1501 lazy.SitePermissions.removeFromPrincipal(null, perm.id, browser)
1506 * Persist an ALLOW state if the remember option is true.
1507 * Otherwise, persist PROMPT so that we can later tell the site
1508 * that permission was granted once before.
1509 * This makes Firefox seem much more like Chrome to sites that
1510 * expect a one-off, persistent permission grant for cam/mic.
1512 * @param principal - Principal to add permission to.
1513 * @param {string} permissionName - name of permission.
1514 * @param remember - whether the grant should be persisted.
1516 function persistGrantOrPromptPermission(principal, permissionName, remember) {
1517 // There are cases like unsafe delegation where a prompt appears
1518 // even in ALLOW state, so make sure to not overwrite it (there's
1519 // no remember checkbox in those cases)
1521 lazy.SitePermissions.getForPrincipal(principal, permissionName).state ==
1522 lazy.SitePermissions.ALLOW
1527 lazy.SitePermissions.setForPrincipal(
1530 remember ? lazy.SitePermissions.ALLOW : lazy.SitePermissions.PROMPT
1535 * Clears any persisted PROMPT (aka Always Ask) permission.
1536 * @param principal - Principal to remove permission from.
1537 * @param {string} permissionName - name of permission.
1538 * @param browser - Browser element to clear permission for.
1540 function maybeClearAlwaysAsk(principal, permissionName, browser) {
1541 // For the "Always Ask" user choice, only persisted PROMPT is used,
1542 // so no need to scan through temporary permissions.
1544 lazy.SitePermissions.getForPrincipal(principal, permissionName).state ==
1545 lazy.SitePermissions.PROMPT
1547 lazy.SitePermissions.removeFromPrincipal(