summaryrefslogtreecommitdiffstats
path: root/browser/actors/WebRTCChild.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/actors/WebRTCChild.sys.mjs')
-rw-r--r--browser/actors/WebRTCChild.sys.mjs585
1 files changed, 585 insertions, 0 deletions
diff --git a/browser/actors/WebRTCChild.sys.mjs b/browser/actors/WebRTCChild.sys.mjs
new file mode 100644
index 0000000000..5fd372c705
--- /dev/null
+++ b/browser/actors/WebRTCChild.sys.mjs
@@ -0,0 +1,585 @@
+/* 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";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "MediaManagerService",
+ "@mozilla.org/mediaManagerService;1",
+ "nsIMediaManagerService"
+);
+
+const kBrowserURL = AppConstants.BROWSER_CHROME_URL;
+
+/**
+ * GlobalMuteListener is a process-global object that listens for changes to
+ * the global mute state of the camera and microphone. When it notices a
+ * change in that state, it tells the underlying platform code to mute or
+ * unmute those devices.
+ */
+const GlobalMuteListener = {
+ _initted: false,
+
+ /**
+ * Initializes the listener if it hasn't been already. This will also
+ * ensure that the microphone and camera are initially in the right
+ * muting state.
+ */
+ init() {
+ if (!this._initted) {
+ Services.cpmm.sharedData.addEventListener("change", this);
+ this._updateCameraMuteState();
+ this._updateMicrophoneMuteState();
+ this._initted = true;
+ }
+ },
+
+ handleEvent(event) {
+ if (event.changedKeys.includes("WebRTC:GlobalCameraMute")) {
+ this._updateCameraMuteState();
+ }
+ if (event.changedKeys.includes("WebRTC:GlobalMicrophoneMute")) {
+ this._updateMicrophoneMuteState();
+ }
+ },
+
+ _updateCameraMuteState() {
+ let shouldMute = Services.cpmm.sharedData.get("WebRTC:GlobalCameraMute");
+ let topic = shouldMute
+ ? "getUserMedia:muteVideo"
+ : "getUserMedia:unmuteVideo";
+ Services.obs.notifyObservers(null, topic);
+ },
+
+ _updateMicrophoneMuteState() {
+ let shouldMute = Services.cpmm.sharedData.get(
+ "WebRTC:GlobalMicrophoneMute"
+ );
+ let topic = shouldMute
+ ? "getUserMedia:muteAudio"
+ : "getUserMedia:unmuteAudio";
+
+ Services.obs.notifyObservers(null, topic);
+ },
+};
+
+export class WebRTCChild extends JSWindowActorChild {
+ actorCreated() {
+ // The user might request that DOM notifications be silenced
+ // when sharing the screen. There doesn't seem to be a great
+ // way of storing that state in any of the objects going into
+ // the WebRTC API or coming out via the observer notification
+ // service, so we store it here on the actor.
+ //
+ // If the user chooses to silence notifications during screen
+ // share, this will get set to true.
+ this.suppressNotifications = false;
+ }
+
+ // Called only for 'unload' to remove pending gUM prompts in reloaded frames.
+ static handleEvent(aEvent) {
+ let contentWindow = aEvent.target.defaultView;
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ for (let key of contentWindow.pendingGetUserMediaRequests.keys()) {
+ actor.sendAsyncMessage("webrtc:CancelRequest", key);
+ }
+ for (let key of contentWindow.pendingPeerConnectionRequests.keys()) {
+ actor.sendAsyncMessage("rtcpeer:CancelRequest", key);
+ }
+ }
+ }
+
+ // This observer is called from BrowserProcessChild to avoid
+ // loading this .jsm when WebRTC is not in use.
+ static observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "getUserMedia:request":
+ handleGUMRequest(aSubject, aTopic, aData);
+ break;
+ case "recording-device-stopped":
+ handleGUMStop(aSubject, aTopic, aData);
+ break;
+ case "PeerConnection:request":
+ handlePCRequest(aSubject, aTopic, aData);
+ break;
+ case "recording-device-events":
+ updateIndicators(aSubject, aTopic, aData);
+ break;
+ case "recording-window-ended":
+ removeBrowserSpecificIndicator(aSubject, aTopic, aData);
+ break;
+ }
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "rtcpeer:Allow":
+ case "rtcpeer:Deny": {
+ let callID = aMessage.data.callID;
+ let contentWindow = Services.wm.getOuterWindowWithId(
+ aMessage.data.windowID
+ );
+ forgetPCRequest(contentWindow, callID);
+ let topic =
+ aMessage.name == "rtcpeer:Allow"
+ ? "PeerConnection:response:allow"
+ : "PeerConnection:response:deny";
+ Services.obs.notifyObservers(null, topic, callID);
+ break;
+ }
+ case "webrtc:Allow": {
+ let callID = aMessage.data.callID;
+ let contentWindow = Services.wm.getOuterWindowWithId(
+ aMessage.data.windowID
+ );
+ let devices = contentWindow.pendingGetUserMediaRequests.get(callID);
+ forgetGUMRequest(contentWindow, callID);
+
+ let allowedDevices = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ for (let deviceIndex of aMessage.data.devices) {
+ allowedDevices.appendElement(devices[deviceIndex]);
+ }
+
+ Services.obs.notifyObservers(
+ allowedDevices,
+ "getUserMedia:response:allow",
+ callID
+ );
+
+ this.suppressNotifications = !!aMessage.data.suppressNotifications;
+
+ break;
+ }
+ case "webrtc:Deny":
+ denyGUMRequest(aMessage.data);
+ break;
+ case "webrtc:StopSharing":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:revoke",
+ aMessage.data
+ );
+ break;
+ case "webrtc:MuteCamera":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:muteVideo",
+ aMessage.data
+ );
+ break;
+ case "webrtc:UnmuteCamera":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:unmuteVideo",
+ aMessage.data
+ );
+ break;
+ case "webrtc:MuteMicrophone":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:muteAudio",
+ aMessage.data
+ );
+ break;
+ case "webrtc:UnmuteMicrophone":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:unmuteAudio",
+ aMessage.data
+ );
+ break;
+ }
+ }
+}
+
+function getActorForWindow(window) {
+ try {
+ let windowGlobal = window.windowGlobalChild;
+ if (windowGlobal) {
+ return windowGlobal.getActor("WebRTC");
+ }
+ } catch (ex) {
+ // There might not be an actor for a parent process chrome URL,
+ // and we may not even be allowed to access its windowGlobalChild.
+ }
+
+ return null;
+}
+
+function handlePCRequest(aSubject, aTopic, aData) {
+ let { windowID, innerWindowID, callID, isSecure } = aSubject;
+ let contentWindow = Services.wm.getOuterWindowWithId(windowID);
+ if (!contentWindow.pendingPeerConnectionRequests) {
+ setupPendingListsInitially(contentWindow);
+ }
+ contentWindow.pendingPeerConnectionRequests.add(callID);
+
+ let request = {
+ windowID,
+ innerWindowID,
+ callID,
+ documentURI: contentWindow.document.documentURI,
+ secure: isSecure,
+ };
+
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("rtcpeer:Request", request);
+ }
+}
+
+function handleGUMStop(aSubject, aTopic, aData) {
+ let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
+
+ let request = {
+ windowID: aSubject.windowID,
+ rawID: aSubject.rawID,
+ mediaSource: aSubject.mediaSource,
+ };
+
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("webrtc:StopRecording", request);
+ }
+}
+
+function handleGUMRequest(aSubject, aTopic, aData) {
+ // Now that a getUserMedia request has been created, we should check
+ // to see if we're supposed to have any devices muted. This needs
+ // to occur after the getUserMedia request is made, since the global
+ // mute state is associated with the GetUserMediaWindowListener, which
+ // is only created after a getUserMedia request.
+ GlobalMuteListener.init();
+
+ let constraints = aSubject.getConstraints();
+ let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
+
+ prompt(
+ aSubject.type,
+ contentWindow,
+ aSubject.windowID,
+ aSubject.callID,
+ constraints,
+ aSubject.getAudioOutputOptions(),
+ aSubject.devices,
+ aSubject.isSecure,
+ aSubject.isHandlingUserInput
+ );
+}
+
+function prompt(
+ aRequestType,
+ aContentWindow,
+ aWindowID,
+ aCallID,
+ aConstraints,
+ aAudioOutputOptions,
+ aDevices,
+ aSecure,
+ aIsHandlingUserInput
+) {
+ let audioInputDevices = [];
+ let videoInputDevices = [];
+ let audioOutputDevices = [];
+ let devices = [];
+
+ // MediaStreamConstraints defines video as 'boolean or MediaTrackConstraints'.
+ let video = aConstraints.video || aConstraints.picture;
+ let audio = aConstraints.audio;
+ let sharingScreen =
+ video && typeof video != "boolean" && video.mediaSource != "camera";
+ let sharingAudio =
+ audio && typeof audio != "boolean" && audio.mediaSource != "microphone";
+
+ const hasInherentConstraints = ({ facingMode, groupId, deviceId }) => {
+ const id = [deviceId].flat()[0];
+ return facingMode || groupId || (id && id != "default"); // flock workaround
+ };
+ let hasInherentAudioConstraints =
+ audio &&
+ !sharingAudio &&
+ [audio, ...(audio.advanced || [])].some(hasInherentConstraints);
+ let hasInherentVideoConstraints =
+ video &&
+ !sharingScreen &&
+ [video, ...(video.advanced || [])].some(hasInherentConstraints);
+
+ for (let device of aDevices) {
+ device = device.QueryInterface(Ci.nsIMediaDevice);
+ let deviceObject = {
+ name: device.rawName, // unfiltered device name to show to the user
+ deviceIndex: devices.length,
+ rawId: device.rawId,
+ id: device.id,
+ mediaSource: device.mediaSource,
+ canRequestOsLevelPrompt: device.canRequestOsLevelPrompt,
+ };
+ switch (device.type) {
+ case "audioinput":
+ // Check that if we got a microphone, we have not requested an audio
+ // capture, and if we have requested an audio capture, we are not
+ // getting a microphone instead.
+ if (audio && (device.mediaSource == "microphone") != sharingAudio) {
+ audioInputDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ case "videoinput":
+ // Verify that if we got a camera, we haven't requested a screen share,
+ // or that if we requested a screen share we aren't getting a camera.
+ if (video && (device.mediaSource == "camera") != sharingScreen) {
+ if (device.scary) {
+ deviceObject.scary = true;
+ }
+ videoInputDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ case "audiooutput":
+ if (aRequestType == "selectaudiooutput") {
+ audioOutputDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ }
+ }
+
+ let requestTypes = [];
+ if (videoInputDevices.length) {
+ requestTypes.push(sharingScreen ? "Screen" : "Camera");
+ }
+ if (audioInputDevices.length) {
+ requestTypes.push(sharingAudio ? "AudioCapture" : "Microphone");
+ }
+ if (audioOutputDevices.length) {
+ requestTypes.push("Speaker");
+ }
+
+ if (!requestTypes.length) {
+ // Device enumeration is done ahead of handleGUMRequest, so we're not
+ // responsible for handling the NotFoundError spec case.
+ denyGUMRequest({ callID: aCallID });
+ return;
+ }
+
+ if (!aContentWindow.pendingGetUserMediaRequests) {
+ setupPendingListsInitially(aContentWindow);
+ }
+ aContentWindow.pendingGetUserMediaRequests.set(aCallID, devices);
+
+ // WebRTC prompts have a bunch of special requirements, such as being able to
+ // grant two permissions (microphone and camera), selecting devices and showing
+ // a screen sharing preview. All this could have probably been baked into
+ // nsIContentPermissionRequest prompts, but the team that implemented this back
+ // then chose to just build their own prompting mechanism instead.
+ //
+ // So, what you are looking at here is not a real nsIContentPermissionRequest, but
+ // something that looks really similar and will be transmitted to webrtcUI.jsm
+ // for showing the prompt.
+ // Note that we basically do the permission delegate check in
+ // nsIContentPermissionRequest, but because webrtc uses their own prompting
+ // system, we should manually apply the delegate policy here. Permission
+ // should be delegated using Feature Policy and top principal
+ const permDelegateHandler =
+ aContentWindow.document.permDelegateHandler.QueryInterface(
+ Ci.nsIPermissionDelegateHandler
+ );
+
+ const shouldDelegatePermission =
+ permDelegateHandler.permissionDelegateFPEnabled;
+
+ let secondOrigin = undefined;
+ if (
+ shouldDelegatePermission &&
+ permDelegateHandler.maybeUnsafePermissionDelegate(requestTypes)
+ ) {
+ // We are going to prompt both first party and third party origin.
+ // SecondOrigin should be third party
+ secondOrigin = aContentWindow.document.nodePrincipal.origin;
+ }
+
+ let request = {
+ callID: aCallID,
+ windowID: aWindowID,
+ secondOrigin,
+ documentURI: aContentWindow.document.documentURI,
+ secure: aSecure,
+ isHandlingUserInput: aIsHandlingUserInput,
+ shouldDelegatePermission,
+ requestTypes,
+ sharingScreen,
+ sharingAudio,
+ audioInputDevices,
+ videoInputDevices,
+ audioOutputDevices,
+ hasInherentAudioConstraints,
+ hasInherentVideoConstraints,
+ audioOutputId: aAudioOutputOptions.deviceId,
+ };
+
+ let actor = getActorForWindow(aContentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("webrtc:Request", request);
+ }
+}
+
+function denyGUMRequest(aData) {
+ let subject;
+ if (aData.noOSPermission) {
+ subject = "getUserMedia:response:noOSPermission";
+ } else {
+ subject = "getUserMedia:response:deny";
+ }
+ Services.obs.notifyObservers(null, subject, aData.callID);
+
+ if (!aData.windowID) {
+ return;
+ }
+ let contentWindow = Services.wm.getOuterWindowWithId(aData.windowID);
+ if (contentWindow.pendingGetUserMediaRequests) {
+ forgetGUMRequest(contentWindow, aData.callID);
+ }
+}
+
+function forgetGUMRequest(aContentWindow, aCallID) {
+ aContentWindow.pendingGetUserMediaRequests.delete(aCallID);
+ forgetPendingListsEventually(aContentWindow);
+}
+
+function forgetPCRequest(aContentWindow, aCallID) {
+ aContentWindow.pendingPeerConnectionRequests.delete(aCallID);
+ forgetPendingListsEventually(aContentWindow);
+}
+
+function setupPendingListsInitially(aContentWindow) {
+ if (aContentWindow.pendingGetUserMediaRequests) {
+ return;
+ }
+ aContentWindow.pendingGetUserMediaRequests = new Map();
+ aContentWindow.pendingPeerConnectionRequests = new Set();
+ aContentWindow.addEventListener("unload", WebRTCChild.handleEvent);
+}
+
+function forgetPendingListsEventually(aContentWindow) {
+ if (
+ aContentWindow.pendingGetUserMediaRequests.size ||
+ aContentWindow.pendingPeerConnectionRequests.size
+ ) {
+ return;
+ }
+ aContentWindow.pendingGetUserMediaRequests = null;
+ aContentWindow.pendingPeerConnectionRequests = null;
+ aContentWindow.removeEventListener("unload", WebRTCChild.handleEvent);
+}
+
+function updateIndicators(aSubject, aTopic, aData) {
+ if (
+ aSubject instanceof Ci.nsIPropertyBag &&
+ aSubject.getProperty("requestURL") == kBrowserURL
+ ) {
+ // Ignore notifications caused by the browser UI showing previews.
+ return;
+ }
+
+ let contentWindow = aSubject.getProperty("window");
+
+ let actor = contentWindow ? getActorForWindow(contentWindow) : null;
+ if (actor) {
+ let tabState = getTabStateForContentWindow(contentWindow, false);
+ tabState.windowId = getInnerWindowIDForWindow(contentWindow);
+
+ // If we were silencing DOM notifications before, but we've updated
+ // state such that we're no longer sharing one of our displays, then
+ // reset the silencing state.
+ if (actor.suppressNotifications) {
+ if (!tabState.screen && !tabState.window && !tabState.browser) {
+ actor.suppressNotifications = false;
+ }
+ }
+
+ tabState.suppressNotifications = actor.suppressNotifications;
+
+ actor.sendAsyncMessage("webrtc:UpdateIndicators", tabState);
+ }
+}
+
+function removeBrowserSpecificIndicator(aSubject, aTopic, aData) {
+ let contentWindow = Services.wm.getOuterWindowWithId(aData);
+ if (contentWindow.document.documentURI == kBrowserURL) {
+ // Ignore notifications caused by the browser UI showing previews.
+ return;
+ }
+
+ let tabState = getTabStateForContentWindow(contentWindow, true);
+
+ tabState.windowId = aData;
+
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("webrtc:UpdateIndicators", tabState);
+ }
+}
+
+function getTabStateForContentWindow(aContentWindow, aForRemove = false) {
+ let camera = {},
+ microphone = {},
+ screen = {},
+ window = {},
+ browser = {},
+ devices = {};
+ lazy.MediaManagerService.mediaCaptureWindowState(
+ aContentWindow,
+ camera,
+ microphone,
+ screen,
+ window,
+ browser,
+ devices
+ );
+
+ if (
+ camera.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ microphone.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ screen.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ window.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ browser.value == lazy.MediaManagerService.STATE_NOCAPTURE
+ ) {
+ return { remove: true };
+ }
+
+ if (aForRemove) {
+ return { remove: true };
+ }
+
+ let serializedDevices = [];
+ if (Array.isArray(devices.value)) {
+ serializedDevices = devices.value.map(device => {
+ return {
+ type: device.type,
+ mediaSource: device.mediaSource,
+ rawId: device.rawId,
+ scary: device.scary,
+ };
+ });
+ }
+
+ return {
+ camera: camera.value,
+ microphone: microphone.value,
+ screen: screen.value,
+ window: window.value,
+ browser: browser.value,
+ devices: serializedDevices,
+ };
+}
+
+function getInnerWindowIDForWindow(aContentWindow) {
+ return aContentWindow.windowGlobalChild.innerWindowId;
+}