/* 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/. */ "use strict"; var EXPORTED_SYMBOLS = ["webrtcUI"]; const { EventEmitter } = ChromeUtils.import( "resource:///modules/syncedtabs/EventEmitter.jsm" ); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "AppConstants", "resource://gre/modules/AppConstants.jsm" ); ChromeUtils.defineModuleGetter( this, "PluralForm", "resource://gre/modules/PluralForm.jsm" ); ChromeUtils.defineModuleGetter( this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "SitePermissions", "resource:///modules/SitePermissions.jsm" ); XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() { return Services.strings.createBundle( "chrome://branding/locale/brand.properties" ); }); XPCOMUtils.defineLazyServiceGetter( this, "OSPermissions", "@mozilla.org/ospermissionrequest;1", "nsIOSPermissionRequest" ); var webrtcUI = { peerConnectionBlockers: new Set(), emitter: new EventEmitter(), init() { Services.obs.addObserver( maybeAddMenuIndicator, "browser-delayed-startup-finished" ); Services.ppmm.addMessageListener("child-process-shutdown", this); }, uninit() { Services.obs.removeObserver( maybeAddMenuIndicator, "browser-delayed-startup-finished" ); if (gIndicatorWindow) { gIndicatorWindow.close(); gIndicatorWindow = null; } }, processIndicators: new Map(), activePerms: new Map(), get showGlobalIndicator() { for (let [, indicators] of this.processIndicators) { if (indicators.showGlobalIndicator) { return true; } } return false; }, get showCameraIndicator() { for (let [, indicators] of this.processIndicators) { if (indicators.showCameraIndicator) { return true; } } return false; }, get showMicrophoneIndicator() { for (let [, indicators] of this.processIndicators) { if (indicators.showMicrophoneIndicator) { return true; } } return false; }, get showScreenSharingIndicator() { let list = [""]; for (let [, indicators] of this.processIndicators) { if (indicators.showScreenSharingIndicator) { list.push(indicators.showScreenSharingIndicator); } } let precedence = ["Screen", "Window", "Application", "Browser", ""]; list.sort((a, b) => { return precedence.indexOf(a) - precedence.indexOf(b); }); return list[0]; }, _streams: [], // The boolean parameters indicate which streams should be included in the result. getActiveStreams(aCamera, aMicrophone, aScreen) { return webrtcUI._streams .filter(aStream => { let state = aStream.state; return ( (aCamera && state.camera) || (aMicrophone && state.microphone) || (aScreen && state.screen) ); }) .map(aStream => { let state = aStream.state; let types = { camera: state.camera, microphone: state.microphone, screen: state.screen, }; let browser = aStream.browser; let browserWindow = browser.ownerGlobal; let tab = browserWindow.gBrowser && browserWindow.gBrowser.getTabForBrowser(browser); return { uri: state.documentURI, tab, browser, types }; }); }, swapBrowserForNotification(aOldBrowser, aNewBrowser) { for (let stream of this._streams) { if (stream.browser == aOldBrowser) { stream.browser = aNewBrowser; } } }, forgetActivePermissionsFromBrowser(aBrowser) { webrtcUI.activePerms.delete(aBrowser.outerWindowID); }, forgetStreamsFromBrowser(aBrowser) { this._streams = this._streams.filter(stream => stream.browser != aBrowser); webrtcUI.forgetActivePermissionsFromBrowser(aBrowser); }, forgetStreamsFromProcess(aProcessMM) { // stream.processMM is null when e10s is disabled. this._streams = this._streams.filter( stream => stream.processMM && stream.processMM != aProcessMM ); }, showSharingDoorhanger(aActiveStream) { let browserWindow = aActiveStream.browser.ownerGlobal; if (aActiveStream.tab) { browserWindow.gBrowser.selectedTab = aActiveStream.tab; } else { aActiveStream.browser.focus(); } browserWindow.focus(); let identityBox = browserWindow.document.getElementById("identity-box"); if (AppConstants.platform == "macosx" && !Services.focus.activeWindow) { browserWindow.addEventListener( "activate", function() { Services.tm.dispatchToMainThread(function() { identityBox.click(); }); }, { once: true } ); Cc["@mozilla.org/widget/macdocksupport;1"] .getService(Ci.nsIMacDockSupport) .activateApplication(true); return; } identityBox.click(); }, updateWarningLabel(aMenuList) { let type = aMenuList.selectedItem.getAttribute("devicetype"); let document = aMenuList.ownerDocument; document.getElementById("webRTC-all-windows-shared").hidden = type != "screen"; }, // Add-ons can override stock permission behavior by doing: // // webrtcUI.addPeerConnectionBlocker(function(aParams) { // // new permission checking logic // })); // // The blocking function receives an object with origin, callID, and windowID // parameters. If it returns the string "deny" or a Promise that resolves // to "deny", the connection is immediately blocked. With any other return // value (though the string "allow" is suggested for consistency), control // is passed to other registered blockers. If no registered blockers block // the connection (or of course if there are no registered blockers), then // the connection is allowed. // // Add-ons may also use webrtcUI.on/off to listen to events without // blocking anything: // peer-request-allowed is emitted when a new peer connection is // established (and not blocked). // peer-request-blocked is emitted when a peer connection request is // blocked by some blocking connection handler. // peer-request-cancel is emitted when a peer-request connection request // is canceled. (This would typically be used in // conjunction with a blocking handler to cancel // a user prompt or other work done by the handler) addPeerConnectionBlocker(aCallback) { this.peerConnectionBlockers.add(aCallback); }, removePeerConnectionBlocker(aCallback) { this.peerConnectionBlockers.delete(aCallback); }, on(...args) { return this.emitter.on(...args); }, off(...args) { return this.emitter.off(...args); }, // Listeners and observers are registered in BrowserGlue.jsm receiveMessage(aMessage) { switch (aMessage.name) { case "rtcpeer:Request": { let params = Object.freeze( Object.assign( { origin: aMessage.target.contentPrincipal.origin, }, aMessage.data ) ); let blockers = Array.from(this.peerConnectionBlockers); (async function() { for (let blocker of blockers) { try { let result = await blocker(params); if (result == "deny") { return false; } } catch (err) { Cu.reportError(`error in PeerConnection blocker: ${err.message}`); } } return true; })().then(decision => { let message; if (decision) { this.emitter.emit("peer-request-allowed", params); message = "rtcpeer:Allow"; } else { this.emitter.emit("peer-request-blocked", params); message = "rtcpeer:Deny"; } aMessage.target.messageManager.sendAsyncMessage(message, { callID: params.callID, windowID: params.windowID, }); }); break; } case "rtcpeer:CancelRequest": { let params = Object.freeze({ origin: aMessage.target.contentPrincipal.origin, callID: aMessage.data, }); this.emitter.emit("peer-request-cancel", params); break; } case "webrtc:Request": prompt(aMessage.target, aMessage.data); break; case "webrtc:StopRecording": stopRecording(aMessage.target, aMessage.data); break; case "webrtc:CancelRequest": removePrompt(aMessage.target, aMessage.data); break; case "webrtc:UpdatingIndicators": webrtcUI.forgetStreamsFromProcess(aMessage.target); break; case "webrtc:UpdateGlobalIndicators": updateIndicators(aMessage.data, aMessage.target); break; case "webrtc:UpdateBrowserIndicators": let id = aMessage.data.windowId; let processMM = aMessage.targetFrameLoader.messageManager.processMessageManager; let index; for (index = 0; index < webrtcUI._streams.length; ++index) { let stream = webrtcUI._streams[index]; if (stream.state.windowId == id && stream.processMM == processMM) { break; } } // If there's no documentURI, the update is actually a removal of the // stream, triggered by the recording-window-ended notification. if (!aMessage.data.documentURI && index < webrtcUI._streams.length) { webrtcUI._streams.splice(index, 1); } else { webrtcUI._streams[index] = { browser: aMessage.target, processMM, state: aMessage.data, }; } let tabbrowser = aMessage.target.ownerGlobal.gBrowser; if (tabbrowser) { tabbrowser.setBrowserSharing(aMessage.target, aMessage.data); } break; case "child-process-shutdown": webrtcUI.processIndicators.delete(aMessage.target); webrtcUI.forgetStreamsFromProcess(aMessage.target); updateIndicators(null, null); break; } }, }; function denyRequest(aBrowser, aRequest) { aBrowser.messageManager.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. // function denyRequestNoPermission(aBrowser, aRequest) { aBrowser.messageManager.sendAsyncMessage("webrtc:Deny", { callID: aRequest.callID, windowID: aRequest.windowID, noOSPermission: true, }); } // // Check if we have permission to access the camera 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 function checkOSPermission(camNeeded, micNeeded) { let camStatus = {}, micStatus = {}; OSPermissions.getMediaCapturePermissionState(camStatus, micStatus); if (camNeeded) { let camPermission = camStatus.value; let camAccessible = await checkAndGetOSPermission( camPermission, OSPermissions.requestVideoCapturePermission ); if (!camAccessible) { return false; } } if (micNeeded) { let micPermission = micStatus.value; let micAccessible = await checkAndGetOSPermission( micPermission, OSPermissions.requestAudioCapturePermission ); if (!micAccessible) { 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 function checkAndGetOSPermission( devicePermission, requestPermissionFunc ) { if ( devicePermission == OSPermissions.PERMISSION_STATE_DENIED || devicePermission == OSPermissions.PERMISSION_STATE_RESTRICTED ) { return false; } if (devicePermission == OSPermissions.PERMISSION_STATE_NOTDETERMINED) { let deviceAllowed = await requestPermissionFunc(); if (!deviceAllowed) { return false; } } return true; } function getHostOrExtensionName(uri, href) { let host; try { if (!uri) { uri = Services.io.newURI(href); } let addonPolicy = WebExtensionPolicy.getByURI(uri); host = addonPolicy ? addonPolicy.name : uri.host; } catch (ex) {} if (!host) { if (uri && uri.scheme.toLowerCase() == "about") { // For about URIs, just use the full spec, without any #hash parts. host = uri.specIgnoringRef; } else { // This is unfortunate, but we should display *something*... const kBundleURI = "chrome://browser/locale/browser.properties"; let bundle = Services.strings.createBundle(kBundleURI); host = bundle.GetStringFromName("getUserMedia.sharingMenuUnknownHost"); } } return host; } function stopRecording(aBrowser, aRequest) { let outerWindowID = aBrowser.outerWindowID; if (!webrtcUI.activePerms.has(outerWindowID)) { return; } if (!aRequest.rawID) { webrtcUI.activePerms.delete(outerWindowID); } else { let set = webrtcUI.activePerms.get(outerWindowID); set.delete(aRequest.windowID + aRequest.mediaSource + aRequest.rawID); } } function prompt(aBrowser, aRequest) { let { audioDevices, videoDevices, sharingScreen, sharingAudio, requestTypes, } = aRequest; let principal = Services.scriptSecurityManager.createCodebasePrincipalFromOrigin( aRequest.origin ); // If the user has already denied access once in this tab, // deny again without even showing the notification icon. if ( (audioDevices.length && SitePermissions.getForPrincipal(principal, "microphone", aBrowser) .state == SitePermissions.BLOCK) || (videoDevices.length && SitePermissions.getForPrincipal( principal, sharingScreen ? "screen" : "camera", aBrowser ).state == SitePermissions.BLOCK) ) { denyRequest(aBrowser, aRequest); return; } // Tell the browser to refresh the identity block display in case there // are expired permission states. aBrowser.dispatchEvent( new aBrowser.ownerGlobal.CustomEvent("PermissionStateChange") ); let chromeDoc = aBrowser.ownerDocument; let stringBundle = chromeDoc.defaultView.gNavigatorBundle; // Mind the order, because for simplicity we're iterating over the list using // "includes()". This allows the rotation of string identifiers. We list the // full identifiers here so they can be cross-referenced more easily. let joinedRequestTypes = requestTypes.join("And"); let stringId = [ // Individual request types first. "getUserMedia.shareCamera2.message", "getUserMedia.shareMicrophone2.message", "getUserMedia.shareScreen3.message", "getUserMedia.shareAudioCapture2.message", // Combinations of the above request types last. "getUserMedia.shareCameraAndMicrophone2.message", "getUserMedia.shareCameraAndAudioCapture2.message", "getUserMedia.shareScreenAndMicrophone3.message", "getUserMedia.shareScreenAndAudioCapture3.message", ].find(id => id.includes(joinedRequestTypes)); let message = stringBundle.getFormattedString(stringId, ["<>"], 1); let notification; // Used by action callbacks. let mainAction = { label: stringBundle.getString("getUserMedia.allow.label"), accessKey: stringBundle.getString("getUserMedia.allow.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() {}, }; let secondaryActions = [ { label: stringBundle.getString("getUserMedia.dontAllow.label"), accessKey: stringBundle.getString("getUserMedia.dontAllow.accesskey"), callback(aState) { denyRequest(notification.browser, aRequest); let scope = SitePermissions.SCOPE_TEMPORARY; if (aState && aState.checkboxChecked) { scope = SitePermissions.SCOPE_PERSISTENT; } if (audioDevices.length) { SitePermissions.setForPrincipal( principal, "microphone", SitePermissions.BLOCK, scope, notification.browser ); } if (videoDevices.length) { SitePermissions.setForPrincipal( principal, sharingScreen ? "screen" : "camera", SitePermissions.BLOCK, scope, notification.browser ); } }, }, ]; let productName = gBrandBundle.GetStringFromName("brandShortName"); let options = { name: getHostOrExtensionName(principal.URI), persistent: true, hideClose: true, eventCallback(aTopic, aNewBrowser) { if (aTopic == "swapping") { return true; } let doc = this.browser.ownerDocument; // Clean-up video streams of screensharing previews. if ( ((aTopic == "dismissed" || aTopic == "removed") && requestTypes.includes("Screen")) || !requestTypes.includes("Screen") ) { let video = doc.getElementById("webRTC-previewVideo"); 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 != "showing") { return false; } // BLOCK is handled immediately by MediaManager if it has been set // persistently in the permission manager. If it has been set on the tab, // 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 (aRequest.secure) { let micAllowed = SitePermissions.getForPrincipal(principal, "microphone").state == SitePermissions.ALLOW; let camAllowed = SitePermissions.getForPrincipal(principal, "camera").state == SitePermissions.ALLOW; let perms = Services.perms; let mediaManagerPerm = perms.testExactPermissionFromPrincipal( principal, "MediaManagerVideo" ); if (mediaManagerPerm) { perms.removeFromPrincipal(principal, "MediaManagerVideo"); } // Screen sharing shouldn't follow the camera permissions. if (videoDevices.length && sharingScreen) { camAllowed = false; } if (aRequest.isThirdPartyOrigin) { camAllowed = false; micAllowed = false; } let activeCamera; let activeMic; // Always prompt for screen sharing if (!sharingScreen) { for (let device of videoDevices) { let set = webrtcUI.activePerms.get(aBrowser.outerWindowID); if ( set && set.has(aRequest.windowID + device.mediaSource + device.id) ) { activeCamera = device; break; } } for (let device of audioDevices) { let set = webrtcUI.activePerms.get(aBrowser.outerWindowID); if ( set && set.has(aRequest.windowID + device.mediaSource + device.id) ) { activeMic = device; break; } } } if ( (!audioDevices.length || micAllowed || activeMic) && (!videoDevices.length || camAllowed || activeCamera) ) { let allowedDevices = []; if (videoDevices.length) { allowedDevices.push((activeCamera || videoDevices[0]).deviceIndex); Services.perms.addFromPrincipal( principal, "MediaManagerVideo", Services.perms.ALLOW_ACTION, Services.perms.EXPIRE_SESSION ); } if (audioDevices.length) { allowedDevices.push((activeMic || audioDevices[0]).deviceIndex); } // Remember on which URIs we found persistent permissions so that we // can remove them if the user clicks 'Stop Sharing'. There's no // other way for the stop sharing code to know the hostnames of frames // using devices until bug 1066082 is fixed. let browser = this.browser; browser._devicePermissionPrincipals = browser._devicePermissionPrincipals || []; browser._devicePermissionPrincipals.push(principal); let camNeeded = videoDevices.length > 0; let micNeeded = audioDevices.length > 0; checkOSPermission(camNeeded, micNeeded).then(havePermission => { if (havePermission) { let mm = browser.messageManager; mm.sendAsyncMessage("webrtc:Allow", { callID: aRequest.callID, windowID: aRequest.windowID, devices: allowedDevices, }); } else { denyRequestNoPermission(browser, aRequest); } }); this.remove(); return true; } } function listDevices(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; for (let device of devices) { addDeviceToList(menupopup, device.name, device.deviceIndex); } } function checkDisabledWindowMenuItem() { let list = doc.getElementById("webRTC-selectWindow-menulist"); let item = list.selectedItem; let notificationElement = doc.getElementById( "webRTC-shareDevices-notification" ); 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; let label = doc.getElementById("webRTC-selectWindow-label"); const gumStringId = "getUserMedia.selectWindowOrScreen"; label.setAttribute( "value", stringBundle.getString(gumStringId + ".label") ); label.setAttribute( "accesskey", stringBundle.getString(gumStringId + ".accesskey") ); // "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, stringBundle.getString("getUserMedia.pickWindowOrScreen.label"), "-1" ); menupopup.appendChild(doc.createXULElement("menuseparator")); // 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; // Building screen list from available screens. if (type == "screen") { if (device.name == "Primary Monitor") { name = stringBundle.getString( "getUserMedia.shareEntireScreen.label" ); } else { name = stringBundle.getFormattedString( "getUserMedia.shareMonitor.label", [monitorIndex] ); ++monitorIndex; } } else { name = device.name; if (type == "application") { // The application names returned by the platform are of the form: // \x1e let sepIndex = name.indexOf("\x1e"); let count = name.slice(0, sepIndex); let sawcStringId = "getUserMedia.shareApplicationWindowCount.label"; name = PluralForm.get( parseInt(count), stringBundle.getString(sawcStringId) ) .replace("#1", name.slice(sepIndex + 1)) .replace("#2", count); } } let item = addDeviceToList(menupopup, name, i, type); item.deviceId = device.id; item.mediaSource = type; if (device.scary) { item.scary = true; } } // Always re-select the "No " 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; } let type = event.target.mediaSource; let deviceId = event.target.deviceId; if (deviceId == undefined) { doc.getElementById("webRTC-preview").hidden = true; video.src = null; return; } let scary = event.target.scary; let warning = doc.getElementById("webRTC-previewWarning"); warning.hidden = !scary; let chromeWin = doc.defaultView; if (scary) { warning.hidden = false; let string; let bundle = chromeWin.gNavigatorBundle; let learnMoreText = bundle.getString( "getUserMedia.shareScreen.learnMoreLabel" ); let baseURL = Services.urlFormatter.formatURLPref( "app.support.baseURL" ); let learnMore = chromeWin.document.createXULElement("label", { is: "text-link", }); learnMore.setAttribute("href", baseURL + "screenshare-safety"); learnMore.textContent = learnMoreText; if (type == "screen") { string = bundle.getFormattedString( "getUserMedia.shareScreenWarning.message", ["<>"] ); } else { let brand = doc .getElementById("bundle_brand") .getString("brandShortName"); string = bundle.getFormattedString( "getUserMedia.shareFirefoxWarning.message", [brand, "<>"] ); } let [pre, post] = string.split("<>"); warning.textContent = pre; warning.appendChild(learnMore); warning.appendChild(chromeWin.document.createTextNode(post)); } let perms = Services.perms; let chromePrincipal = Services.scriptSecurityManager.getSystemPrincipal(); perms.addFromPrincipal( chromePrincipal, "MediaManagerVideo", perms.ALLOW_ACTION, perms.EXPIRE_SESSION ); video.deviceId = deviceId; let constraints = { video: { mediaSource: type, 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(e) { video.play(); }; }, err => { if ( err.name == "OverconstrainedError" && err.constraint == "deviceId" ) { // Window has disappeared since enumeration, which can happen. // No preview for you. return; } Cu.reportError( `error in preview: ${err.message} ${err.constraint}` ); } ); }; menupopup.addEventListener("command", menupopup._commandEventListener); } function addDeviceToList(menupopup, deviceName, deviceIndex, type) { let menuitem = doc.createXULElement("menuitem"); menuitem.setAttribute("value", deviceIndex); menuitem.setAttribute("label", deviceName); menuitem.setAttribute("tooltiptext", deviceName); if (type) { menuitem.setAttribute("devicetype", type); } if (deviceIndex == "-1") { menuitem.setAttribute("disabled", true); } menupopup.appendChild(menuitem); return menuitem; } doc.getElementById("webRTC-selectCamera").hidden = !videoDevices.length || sharingScreen; doc.getElementById("webRTC-selectWindowOrScreen").hidden = !sharingScreen || !videoDevices.length; doc.getElementById("webRTC-selectMicrophone").hidden = !audioDevices.length || sharingAudio; let camMenupopup = doc.getElementById("webRTC-selectCamera-menupopup"); let windowMenupopup = doc.getElementById("webRTC-selectWindow-menupopup"); let micMenupopup = doc.getElementById( "webRTC-selectMicrophone-menupopup" ); if (sharingScreen) { listScreenShareDevices(windowMenupopup, videoDevices); checkDisabledWindowMenuItem(); } else { listDevices(camMenupopup, videoDevices); doc .getElementById("webRTC-shareDevices-notification") .removeAttribute("invalidselection"); } if (!sharingAudio) { listDevices(micMenupopup, audioDevices); } this.mainAction.callback = async function(aState) { let remember = aState && aState.checkboxChecked; let allowedDevices = []; let perms = Services.perms; if (videoDevices.length) { let listId = "webRTC-select" + (sharingScreen ? "Window" : "Camera") + "-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 ); if (!webrtcUI.activePerms.has(aBrowser.outerWindowID)) { webrtcUI.activePerms.set(aBrowser.outerWindowID, new Set()); } for (let device of videoDevices) { if (device.deviceIndex == videoDeviceIndex) { webrtcUI.activePerms .get(aBrowser.outerWindowID) .add(aRequest.windowID + device.mediaSource + device.id); break; } } if (remember) { SitePermissions.setForPrincipal( principal, "camera", SitePermissions.ALLOW ); } } } if (audioDevices.length) { if (!sharingAudio) { let audioDeviceIndex = doc.getElementById( "webRTC-selectMicrophone-menulist" ).value; let allowMic = audioDeviceIndex != "-1"; if (allowMic) { allowedDevices.push(audioDeviceIndex); if (!webrtcUI.activePerms.has(aBrowser.outerWindowID)) { webrtcUI.activePerms.set(aBrowser.outerWindowID, new Set()); } for (let device of audioDevices) { if (device.deviceIndex == audioDeviceIndex) { webrtcUI.activePerms .get(aBrowser.outerWindowID) .add(aRequest.windowID + device.mediaSource + device.id); break; } } if (remember) { SitePermissions.setForPrincipal( principal, "microphone", SitePermissions.ALLOW ); } } } else { // Only one device possible for audio capture. allowedDevices.push(0); } } if (!allowedDevices.length) { denyRequest(notification.browser, aRequest); return; } if (remember) { // Remember on which URIs we set persistent permissions so that we // can remove them if the user clicks 'Stop Sharing'. aBrowser._devicePermissionPrincipals = aBrowser._devicePermissionPrincipals || []; aBrowser._devicePermissionPrincipals.push(principal); } let camNeeded = videoDevices.length > 0; let micNeeded = audioDevices.length > 0; let havePermission = await checkOSPermission(camNeeded, micNeeded); if (!havePermission) { denyRequestNoPermission(notification.browser, aRequest); return; } let mm = notification.browser.messageManager; mm.sendAsyncMessage("webrtc:Allow", { callID: aRequest.callID, windowID: aRequest.windowID, devices: allowedDevices, }); }; return false; }, }; // Don't offer "always remember" action in PB mode or from third party if ( !PrivateBrowsingUtils.isBrowserPrivate(aBrowser) && !aRequest.isThirdPartyOrigin ) { // 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). let reasonForNoPermanentAllow = ""; if (sharingScreen) { reasonForNoPermanentAllow = "getUserMedia.reasonForNoPermanentAllow.screen3"; } else if (sharingAudio) { reasonForNoPermanentAllow = "getUserMedia.reasonForNoPermanentAllow.audio"; } else if (!aRequest.secure) { reasonForNoPermanentAllow = "getUserMedia.reasonForNoPermanentAllow.insecure"; } options.checkbox = { label: stringBundle.getString("getUserMedia.remember"), checkedState: reasonForNoPermanentAllow ? { disableMainAction: true, warningLabel: stringBundle.getFormattedString( reasonForNoPermanentAllow, [productName] ), } : undefined, }; } let iconType = "Devices"; if ( requestTypes.length == 1 && (requestTypes[0] == "Microphone" || requestTypes[0] == "AudioCapture") ) { iconType = "Microphone"; } if (requestTypes.includes("Screen")) { iconType = "Screen"; } let anchorId = "webRTC-share" + iconType + "-notification-icon"; let iconClass = iconType.toLowerCase(); if (iconClass == "devices") { iconClass = "camera"; } options.popupIconClass = iconClass + "-icon"; notification = chromeDoc.defaultView.PopupNotifications.show( aBrowser, "webRTC-shareDevices", message, anchorId, mainAction, secondaryActions, options ); notification.callID = aRequest.callID; let schemeHistogram = Services.telemetry.getKeyedHistogramById( "PERMISSION_REQUEST_ORIGIN_SCHEME" ); let userInputHistogram = Services.telemetry.getKeyedHistogramById( "PERMISSION_REQUEST_HANDLING_USER_INPUT" ); let docURI = aRequest.documentURI; let scheme = 0; if (docURI.startsWith("https")) { scheme = 2; } else if (docURI.startsWith("http")) { scheme = 1; } for (let requestType of requestTypes) { if (requestType == "AudioCapture") { requestType = "Microphone"; } requestType = requestType.toLowerCase(); schemeHistogram.add(requestType, scheme); userInputHistogram.add(requestType, aRequest.isHandlingUserInput); } } function removePrompt(aBrowser, aCallId) { let chromeWin = aBrowser.ownerGlobal; let notification = chromeWin.PopupNotifications.getNotification( "webRTC-shareDevices", aBrowser ); if (notification && notification.callID == aCallId) { notification.remove(); } } function getGlobalIndicator() { if (AppConstants.platform != "macosx") { const INDICATOR_CHROME_URI = "chrome://browser/content/webrtcIndicator.xhtml"; const features = "chrome,dialog=yes,titlebar=no,popup=yes"; return Services.ww.openWindow( null, INDICATOR_CHROME_URI, "_blank", features, [] ); } let indicator = { _camera: null, _microphone: null, _screen: null, _hiddenDoc: Services.appShell.hiddenDOMWindow.document, _statusBar: Cc["@mozilla.org/widget/macsystemstatusbar;1"].getService( Ci.nsISystemStatusBar ), _command(aEvent) { webrtcUI.showSharingDoorhanger(aEvent.target.stream); }, _popupShowing(aEvent) { let type = this.getAttribute("type"); let activeStreams; if (type == "Camera") { activeStreams = webrtcUI.getActiveStreams(true, false, false); } else if (type == "Microphone") { activeStreams = webrtcUI.getActiveStreams(false, true, false); } else if (type == "Screen") { activeStreams = webrtcUI.getActiveStreams(false, false, true); type = webrtcUI.showScreenSharingIndicator; } let bundle = Services.strings.createBundle( "chrome://browser/locale/webrtcIndicator.properties" ); if (activeStreams.length == 1) { let stream = activeStreams[0]; let menuitem = this.ownerDocument.createXULElement("menuitem"); let labelId = "webrtcIndicator.sharing" + type + "With.menuitem"; let label = stream.browser.contentTitle || stream.uri; menuitem.setAttribute( "label", bundle.formatStringFromName(labelId, [label]) ); menuitem.setAttribute("disabled", "true"); this.appendChild(menuitem); menuitem = this.ownerDocument.createXULElement("menuitem"); menuitem.setAttribute( "label", bundle.GetStringFromName("webrtcIndicator.controlSharing.menuitem") ); menuitem.stream = stream; menuitem.addEventListener("command", indicator._command); this.appendChild(menuitem); return true; } // We show a different menu when there are several active streams. let menuitem = this.ownerDocument.createXULElement("menuitem"); let labelId = "webrtcIndicator.sharing" + type + "WithNTabs.menuitem"; let count = activeStreams.length; let label = PluralForm.get( count, bundle.GetStringFromName(labelId) ).replace("#1", count); menuitem.setAttribute("label", label); menuitem.setAttribute("disabled", "true"); this.appendChild(menuitem); for (let stream of activeStreams) { let item = this.ownerDocument.createXULElement("menuitem"); labelId = "webrtcIndicator.controlSharingOn.menuitem"; label = stream.browser.contentTitle || stream.uri; item.setAttribute( "label", bundle.formatStringFromName(labelId, [label]) ); item.stream = stream; item.addEventListener("command", indicator._command); this.appendChild(item); } return true; }, _popupHiding(aEvent) { while (this.firstChild) { this.firstChild.remove(); } }, _setIndicatorState(aName, aState) { let field = "_" + aName.toLowerCase(); if (aState && !this[field]) { let menu = this._hiddenDoc.createXULElement("menu"); menu.setAttribute("id", "webRTC-sharing" + aName + "-menu"); // The CSS will only be applied if the menu is actually inserted in the DOM. this._hiddenDoc.documentElement.appendChild(menu); this._statusBar.addItem(menu); let menupopup = this._hiddenDoc.createXULElement("menupopup"); menupopup.setAttribute("type", aName); menupopup.addEventListener("popupshowing", this._popupShowing); menupopup.addEventListener("popuphiding", this._popupHiding); menupopup.addEventListener("command", this._command); menu.appendChild(menupopup); this[field] = menu; } else if (this[field] && !aState) { this._statusBar.removeItem(this[field]); this[field].remove(); this[field] = null; } }, updateIndicatorState() { this._setIndicatorState("Camera", webrtcUI.showCameraIndicator); this._setIndicatorState("Microphone", webrtcUI.showMicrophoneIndicator); this._setIndicatorState("Screen", webrtcUI.showScreenSharingIndicator); }, close() { this._setIndicatorState("Camera", false); this._setIndicatorState("Microphone", false); this._setIndicatorState("Screen", false); }, }; indicator.updateIndicatorState(); return indicator; } function onTabSharingMenuPopupShowing(e) { let streams = webrtcUI.getActiveStreams(true, true, true); for (let streamInfo of streams) { let stringName = "getUserMedia.sharingMenu"; let types = streamInfo.types; if (types.camera) { stringName += "Camera"; } if (types.microphone) { stringName += "Microphone"; } if (types.screen) { stringName += types.screen; } let doc = e.target.ownerDocument; let bundle = doc.defaultView.gNavigatorBundle; let origin = getHostOrExtensionName(null, streamInfo.uri); let menuitem = doc.createXULElement("menuitem"); menuitem.setAttribute( "label", bundle.getFormattedString(stringName, [origin]) ); menuitem.stream = streamInfo; menuitem.addEventListener("command", onTabSharingMenuPopupCommand); e.target.appendChild(menuitem); } } function onTabSharingMenuPopupHiding(e) { while (this.lastChild) { this.lastChild.remove(); } } function onTabSharingMenuPopupCommand(e) { webrtcUI.showSharingDoorhanger(e.target.stream); } function showOrCreateMenuForWindow(aWindow) { let document = aWindow.document; let menu = document.getElementById("tabSharingMenu"); if (!menu) { let stringBundle = aWindow.gNavigatorBundle; menu = document.createXULElement("menu"); menu.id = "tabSharingMenu"; let labelStringId = "getUserMedia.sharingMenu.label"; menu.setAttribute("label", stringBundle.getString(labelStringId)); let container, insertionPoint; if (AppConstants.platform == "macosx") { container = document.getElementById("windowPopup"); insertionPoint = document.getElementById("sep-window-list"); let separator = document.createXULElement("menuseparator"); separator.id = "tabSharingSeparator"; container.insertBefore(separator, insertionPoint); } else { let accesskeyStringId = "getUserMedia.sharingMenu.accesskey"; menu.setAttribute("accesskey", stringBundle.getString(accesskeyStringId)); container = document.getElementById("main-menubar"); insertionPoint = document.getElementById("helpMenu"); } let popup = document.createXULElement("menupopup"); popup.id = "tabSharingMenuPopup"; popup.addEventListener("popupshowing", onTabSharingMenuPopupShowing); popup.addEventListener("popuphiding", onTabSharingMenuPopupHiding); menu.appendChild(popup); container.insertBefore(menu, insertionPoint); } else { menu.hidden = false; if (AppConstants.platform == "macosx") { document.getElementById("tabSharingSeparator").hidden = false; } } } function maybeAddMenuIndicator(window) { if (webrtcUI.showGlobalIndicator) { showOrCreateMenuForWindow(window); } } var gIndicatorWindow = null; function updateIndicators(data, target) { if (data) { // the global indicators specific to this process let indicators; if (webrtcUI.processIndicators.has(target)) { indicators = webrtcUI.processIndicators.get(target); } else { indicators = {}; webrtcUI.processIndicators.set(target, indicators); } indicators.showGlobalIndicator = data.showGlobalIndicator; indicators.showCameraIndicator = data.showCameraIndicator; indicators.showMicrophoneIndicator = data.showMicrophoneIndicator; indicators.showScreenSharingIndicator = data.showScreenSharingIndicator; } for (let chromeWin of Services.wm.getEnumerator("navigator:browser")) { if (webrtcUI.showGlobalIndicator) { showOrCreateMenuForWindow(chromeWin); } else { let doc = chromeWin.document; let existingMenu = doc.getElementById("tabSharingMenu"); if (existingMenu) { existingMenu.hidden = true; } if (AppConstants.platform == "macosx") { let separator = doc.getElementById("tabSharingSeparator"); if (separator) { separator.hidden = true; } } } } if (webrtcUI.showGlobalIndicator) { if (!gIndicatorWindow) { gIndicatorWindow = getGlobalIndicator(); } else { try { gIndicatorWindow.updateIndicatorState(); } catch (err) { Cu.reportError( `error in gIndicatorWindow.updateIndicatorState(): ${err.message}` ); } } } else if (gIndicatorWindow) { gIndicatorWindow.close(); gIndicatorWindow = null; } }