From 4f48223aa51b31e73707397ae53ce8e926853254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Qu=C3=A8ze?= Date: Tue, 25 Feb 2014 12:50:42 +0100 Subject: [PATCH] Bug 804611 - Add a way to grant/deny getUserMedia permissions persistently, r=jesup,dolske, ui-r=Boriss. --- browser/base/content/test/general/browser.ini | 2 + .../test/general/browser_get_user_media.js | 742 ++++++++++++++++++ .../content/test/general/get_user_media.html | 32 + .../preferences/aboutPermissions.js | 26 +- .../preferences/aboutPermissions.xul | 42 + .../preferences/tests/browser_permissions.js | 10 +- .../en-US/chrome/browser/browser.properties | 4 + .../browser/preferences/aboutPermissions.dtd | 2 + .../chrome/browser/sitePermissions.properties | 2 + browser/modules/SitePermissions.jsm | 3 + browser/modules/webrtcUI.jsm | 63 +- .../linux/preferences/aboutPermissions.css | 6 + .../osx/preferences/aboutPermissions.css | 6 + .../windows/preferences/aboutPermissions.css | 6 + dom/media/MediaManager.cpp | 65 +- dom/media/MediaManager.h | 9 +- 16 files changed, 987 insertions(+), 33 deletions(-) create mode 100644 browser/base/content/test/general/browser_get_user_media.js create mode 100644 browser/base/content/test/general/get_user_media.html diff --git a/browser/base/content/test/general/browser.ini b/browser/base/content/test/general/browser.ini index e834ac32bd9..0f193d6efa0 100644 --- a/browser/base/content/test/general/browser.ini +++ b/browser/base/content/test/general/browser.ini @@ -57,6 +57,7 @@ support-files = file_bug970276_favicon2.ico file_dom_notifications.html file_fullscreen-window-open.html + get_user_media.html head.js healthreport_testRemoteCommands.html moz.png @@ -262,6 +263,7 @@ skip-if = true # browser_drag.js is disabled, as it needs to be updated for the [browser_findbarClose.js] [browser_fullscreen-window-open.js] [browser_gestureSupport.js] +[browser_get_user_media.js] [browser_getshortcutoruri.js] [browser_hide_removing.js] [browser_homeDrop.js] diff --git a/browser/base/content/test/general/browser_get_user_media.js b/browser/base/content/test/general/browser_get_user_media.js new file mode 100644 index 00000000000..23806a24823 --- /dev/null +++ b/browser/base/content/test/general/browser_get_user_media.js @@ -0,0 +1,742 @@ +const kObservedTopics = [ + "getUserMedia:response:allow", + "getUserMedia:revoke", + "getUserMedia:response:deny", + "getUserMedia:request", + "recording-device-events", + "recording-window-ended" +]; + +const PREF_PERMISSION_FAKE = "media.navigator.permission.fake"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService", + "@mozilla.org/mediaManagerService;1", + "nsIMediaManagerService"); + +var gObservedTopics = {}; +function observer(aSubject, aTopic, aData) { + if (!(aTopic in gObservedTopics)) + gObservedTopics[aTopic] = 1; + else + ++gObservedTopics[aTopic]; +} + +function promiseNotification(aTopic, aAction) { + let deferred = Promise.defer(); + + Services.obs.addObserver(function observer() { + ok(true, "got " + aTopic + " notification"); + Services.obs.removeObserver(observer, aTopic); + + if (kObservedTopics.indexOf(aTopic) != -1) { + if (!(aTopic in gObservedTopics)) + gObservedTopics[aTopic] = -1; + else + --gObservedTopics[aTopic]; + } + + deferred.resolve(); + }, aTopic, false); + + if (aAction) + aAction(); + + return deferred.promise; +} + +function expectNotification(aTopic) { + is(gObservedTopics[aTopic], 1, "expected notification " + aTopic); + if (aTopic in gObservedTopics) + --gObservedTopics[aTopic]; +} + +function expectNoNotifications() { + for (let topic in gObservedTopics) { + if (gObservedTopics[topic]) + is(gObservedTopics[topic], 0, topic + " notification unexpected"); + } + gObservedTopics = {} +} + +function promiseMessage(aMessage, aAction) { + let deferred = Promise.defer(); + + content.addEventListener("message", function messageListener(event) { + content.removeEventListener("message", messageListener); + is(event.data, aMessage, "received " + aMessage); + if (event.data == aMessage) + deferred.resolve(); + else + deferred.reject(); + }); + + if (aAction) + aAction(); + + return deferred.promise; +} + +function promisePopupNotification(aName) { + let deferred = Promise.defer(); + + waitForCondition(() => PopupNotifications.getNotification(aName), + () => { + ok(!!PopupNotifications.getNotification(aName), + aName + " notification appeared"); + deferred.resolve(); + }, "timeout waiting for popup notification " + aName); + + return deferred.promise; +} + +function promiseNoPopupNotification(aName) { + let deferred = Promise.defer(); + + waitForCondition(() => !PopupNotifications.getNotification(aName), + () => { + ok(!PopupNotifications.getNotification(aName), + aName + " notification removed"); + deferred.resolve(); + }, "timeout waiting for popup notification " + aName + " to disappear"); + + return deferred.promise; +} + +const kActionAlways = 1; +const kActionDeny = 2; +const kActionNever = 3; + +function activateSecondaryAction(aAction) { + let notification = PopupNotifications.panel.firstChild; + notification.button.focus(); + let popup = notification.menupopup; + popup.addEventListener("popupshown", function () { + popup.removeEventListener("popupshown", arguments.callee, false); + + // Press 'down' as many time as needed to select the requested action. + while (aAction--) + EventUtils.synthesizeKey("VK_DOWN", {}); + + // Activate + EventUtils.synthesizeKey("VK_RETURN", {}); + }, false); + + // One down event to open the popup + EventUtils.synthesizeKey("VK_DOWN", + { altKey: !navigator.platform.contains("Mac") }); +} + +registerCleanupFunction(function() { + gBrowser.removeCurrentTab(); + kObservedTopics.forEach(topic => { + Services.obs.removeObserver(observer, topic); + }); + Services.prefs.clearUserPref(PREF_PERMISSION_FAKE); +}); + +function getMediaCaptureState() { + let hasVideo = {}; + let hasAudio = {}; + MediaManagerService.mediaCaptureWindowState(content, hasVideo, hasAudio); + if (hasVideo.value && hasAudio.value) + return "CameraAndMicrophone"; + if (hasVideo.value) + return "Camera"; + if (hasAudio.value) + return "Microphone"; + return "none"; +} + +function closeStream(aAlreadyClosed) { + expectNoNotifications(); + + info("closing the stream"); + content.wrappedJSObject.closeStream(); + + if (!aAlreadyClosed) + yield promiseNotification("recording-device-events"); + + yield promiseNoPopupNotification("webRTC-sharingDevices"); + if (!aAlreadyClosed) + expectNotification("recording-window-ended"); + + let statusButton = document.getElementById("webrtc-status-button"); + ok(statusButton.hidden, "WebRTC status button hidden"); +} + +function checkDeviceSelectors(aAudio, aVideo) { + let micSelector = document.getElementById("webRTC-selectMicrophone"); + if (aAudio) + ok(!micSelector.hidden, "microphone selector visible"); + else + ok(micSelector.hidden, "microphone selector hidden"); + + let cameraSelector = document.getElementById("webRTC-selectCamera"); + if (aVideo) + ok(!cameraSelector.hidden, "camera selector visible"); + else + ok(cameraSelector.hidden, "camera selector hidden"); +} + +function checkSharingUI() { + yield promisePopupNotification("webRTC-sharingDevices"); + let statusButton = document.getElementById("webrtc-status-button"); + ok(!statusButton.hidden, "WebRTC status button visible"); +} + +function checkNotSharing() { + is(getMediaCaptureState(), "none", "expected nothing to be shared"); + + ok(!PopupNotifications.getNotification("webRTC-sharingDevices"), + "no webRTC-sharingDevices popup notification"); + + let statusButton = document.getElementById("webrtc-status-button"); + ok(statusButton.hidden, "WebRTC status button hidden"); +} + +let gTests = [ + +{ + desc: "getUserMedia audio+video", + run: function checkAudioVideo() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true, true); + + yield promiseMessage("ok", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + is(getMediaCaptureState(), "CameraAndMicrophone", + "expected camera and microphone to be shared"); + + yield checkSharingUI(); + yield closeStream(); + } +}, + +{ + desc: "getUserMedia audio only", + run: function checkAudioOnly() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true); + + yield promiseMessage("ok", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + is(getMediaCaptureState(), "Microphone", "expected microphone to be shared"); + + yield checkSharingUI(); + yield closeStream(); + } +}, + +{ + desc: "getUserMedia video only", + run: function checkVideoOnly() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(false, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(false, true); + + yield promiseMessage("ok", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + is(getMediaCaptureState(), "Camera", "expected camera to be shared"); + + yield checkSharingUI(); + yield closeStream(); + } +}, + +{ + desc: "getUserMedia audio+video, user disables video", + run: function checkDisableVideo() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true, true); + + // disable the camera + document.getElementById("webRTC-selectCamera-menulist").value = -1; + + yield promiseMessage("ok", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + + // reset the menuitem to have no impact on the following tests. + document.getElementById("webRTC-selectCamera-menulist").value = 0; + + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + is(getMediaCaptureState(), "Microphone", + "expected microphone to be shared"); + + yield checkSharingUI(); + yield closeStream(); + } +}, + +{ + desc: "getUserMedia audio+video, user disables audio", + run: function checkDisableAudio() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true, true); + + // disable the microphone + document.getElementById("webRTC-selectMicrophone-menulist").value = -1; + + yield promiseMessage("ok", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + + // reset the menuitem to have no impact on the following tests. + document.getElementById("webRTC-selectMicrophone-menulist").value = 0; + + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + is(getMediaCaptureState(), "Camera", + "expected microphone to be shared"); + + yield checkSharingUI(); + yield closeStream(); + } +}, + +{ + desc: "getUserMedia audio+video, user disables both audio and video", + run: function checkDisableAudioVideo() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true, true); + + // disable the camera and microphone + document.getElementById("webRTC-selectCamera-menulist").value = -1; + document.getElementById("webRTC-selectMicrophone-menulist").value = -1; + + yield promiseMessage("error: PERMISSION_DENIED", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + + // reset the menuitems to have no impact on the following tests. + document.getElementById("webRTC-selectCamera-menulist").value = 0; + document.getElementById("webRTC-selectMicrophone-menulist").value = 0; + + expectNotification("getUserMedia:response:deny"); + expectNotification("recording-window-ended"); + checkNotSharing(); + } +}, + +{ + desc: "getUserMedia audio+video, user clicks \"Don't Share\"", + run: function checkDontShare() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true, true); + + yield promiseMessage("error: PERMISSION_DENIED", () => { + activateSecondaryAction(kActionDeny); + }); + + expectNotification("getUserMedia:response:deny"); + expectNotification("recording-window-ended"); + checkNotSharing(); + } +}, + +{ + desc: "getUserMedia audio+video: stop sharing", + run: function checkStopSharing() { + yield promiseNotification("getUserMedia:request", () => { + info("requesting devices"); + content.wrappedJSObject.requestDevice(true, true); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + checkDeviceSelectors(true, true); + + yield promiseMessage("ok", () => { + PopupNotifications.panel.firstChild.button.click(); + }); + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + is(getMediaCaptureState(), "CameraAndMicrophone", + "expected camera and microphone to be shared"); + + yield checkSharingUI(); + + PopupNotifications.getNotification("webRTC-sharingDevices").reshow(); + activateSecondaryAction(kActionDeny); + + yield promiseNotification("recording-device-events"); + expectNotification("getUserMedia:revoke"); + + yield promiseNoPopupNotification("webRTC-sharingDevices"); + + if (gObservedTopics["recording-device-events"] == 1) { + todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719"); + gObservedTopics["recording-device-events"] = 0; + } + + expectNoNotifications(); + checkNotSharing(); + + // the stream is already closed, but this will do some cleanup anyway + yield closeStream(true); + } +}, + +{ + desc: "getUserMedia prompt: Always/Never Share", + run: function checkRememberCheckbox() { + function checkPerm(aRequestAudio, aRequestVideo, aAllowAudio, aAllowVideo, + aExpectedAudioPerm, aExpectedVideoPerm, aNever) { + yield promiseNotification("getUserMedia:request", () => { + content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo); + }); + + yield promisePopupNotification("webRTC-shareDevices"); + + let elt = id => document.getElementById(id); + + let noAudio = aAllowAudio === undefined; + is(elt("webRTC-selectMicrophone").hidden, noAudio, + "microphone selector expected to be " + (noAudio ? "hidden" : "visible")); + if (!noAudio) + elt("webRTC-selectMicrophone-menulist").value = (aAllowAudio || aNever) ? 0 : -1; + + let noVideo = aAllowVideo === undefined; + is(elt("webRTC-selectCamera").hidden, noVideo, + "camera selector expected to be " + (noVideo ? "hidden" : "visible")); + if (!noVideo) + elt("webRTC-selectCamera-menulist").value = (aAllowVideo || aNever) ? 0 : -1; + + let expectedMessage = + (aAllowVideo || aAllowAudio) ? "ok" : "error: PERMISSION_DENIED"; + yield promiseMessage(expectedMessage, () => { + activateSecondaryAction(aNever ? kActionNever : kActionAlways); + }); + let expected = []; + if (expectedMessage == "ok") { + expectNotification("getUserMedia:response:allow"); + expectNotification("recording-device-events"); + if (aAllowVideo) + expected.push("Camera"); + if (aAllowAudio) + expected.push("Microphone"); + expected = expected.join("And"); + } + else { + expectNotification("getUserMedia:response:deny"); + expectNotification("recording-window-ended"); + expected = "none"; + } + is(getMediaCaptureState(), expected, + "expected " + expected + " to be shared"); + + function checkDevicePermissions(aDevice, aExpected) { + let Perms = Services.perms; + let uri = content.document.documentURIObject; + let devicePerms = Perms.testExactPermission(uri, aDevice); + if (aExpected === undefined) + is(devicePerms, Perms.UNKNOWN_ACTION, "no " + aDevice + " persistent permissions"); + else { + is(devicePerms, aExpected ? Perms.ALLOW_ACTION : Perms.DENY_ACTION, + aDevice + " persistently " + (aExpected ? "allowed" : "denied")); + } + Perms.remove(uri.host, aDevice); + } + checkDevicePermissions("microphone", aExpectedAudioPerm); + checkDevicePermissions("camera", aExpectedVideoPerm); + + if (expectedMessage == "ok") + yield closeStream(); + } + + // 3 cases where the user accepts the device prompt. + info("audio+video, user grants, expect both perms set to allow"); + yield checkPerm(true, true, true, true, true, true); + info("audio only, user grants, check audio perm set to allow, video perm not set"); + yield checkPerm(true, false, true, undefined, true, undefined); + info("video only, user grants, check video perm set to allow, audio perm not set"); + yield checkPerm(false, true, undefined, true, undefined, true); + + // 3 cases where the user rejects the device request. + // First test these cases by setting the device to 'No Audio'/'No Video' + info("audio+video, user denies, expect both perms set to deny"); + yield checkPerm(true, true, false, false, false, false); + info("audio only, user denies, expect audio perm set to deny, video not set"); + yield checkPerm(true, false, false, undefined, false, undefined); + info("video only, user denies, expect video perm set to deny, audio perm not set"); + yield checkPerm(false, true, undefined, false, undefined, false); + // Now test these 3 cases again by using the 'Never Share' action. + info("audio+video, user denies, expect both perms set to deny"); + yield checkPerm(true, true, false, false, false, false, true); + info("audio only, user denies, expect audio perm set to deny, video not set"); + yield checkPerm(true, false, false, undefined, false, undefined, true); + info("video only, user denies, expect video perm set to deny, audio perm not set"); + yield checkPerm(false, true, undefined, false, undefined, false, true); + + // 2 cases where the user allows half of what's requested. + info("audio+video, user denies video, grants audio, " + + "expect video perm set to deny, audio perm set to allow."); + yield checkPerm(true, true, true, false, true, false); + info("audio+video, user denies audio, grants video, " + + "expect video perm set to allow, audio perm set to deny."); + yield checkPerm(true, true, false, true, false, true); + } +}, + +{ + desc: "getUserMedia without prompt: use persistent permissions", + run: function checkUsePersistentPermissions() { + function usePerm(aAllowAudio, aAllowVideo, aRequestAudio, aRequestVideo, + aExpectStream) { + let Perms = Services.perms; + let uri = content.document.documentURIObject; + if (aAllowAudio !== undefined) { + Perms.add(uri, "microphone", aAllowAudio ? Perms.ALLOW_ACTION + : Perms.DENY_ACTION); + } + if (aAllowVideo !== undefined) { + Perms.add(uri, "camera", aAllowVideo ? Perms.ALLOW_ACTION + : Perms.DENY_ACTION); + } + + let gum = function() { + content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo); + }; + + if (aExpectStream === undefined) { + // Check that we get a prompt. + yield promiseNotification("getUserMedia:request", gum); + yield promisePopupNotification("webRTC-shareDevices"); + + // Deny the request to cleanup... + yield promiseMessage("error: PERMISSION_DENIED", () => { + activateSecondaryAction(kActionDeny); + }); + expectNotification("getUserMedia:response:deny"); + expectNotification("recording-window-ended"); + } + else { + let allow = (aAllowVideo && aRequestVideo) || (aAllowAudio && aRequestAudio); + let expectedMessage = allow ? "ok" : "error: PERMISSION_DENIED"; + yield promiseMessage(expectedMessage, gum); + + if (expectedMessage == "ok") { + expectNotification("recording-device-events"); + + // Check what's actually shared. + let expected = []; + if (aAllowVideo && aRequestVideo) + expected.push("Camera"); + if (aAllowAudio && aRequestAudio) + expected.push("Microphone"); + expected = expected.join("And"); + is(getMediaCaptureState(), expected, + "expected " + expected + " to be shared"); + + yield closeStream(); + } + else { + expectNotification("recording-window-ended"); + } + } + + Perms.remove(uri.host, "camera"); + Perms.remove(uri.host, "microphone"); + } + + // Set both permissions identically + info("allow audio+video, request audio+video, expect ok (audio+video)"); + yield usePerm(true, true, true, true, true); + info("deny audio+video, request audio+video, expect denied"); + yield usePerm(false, false, true, true, false); + + // Allow audio, deny video. + info("allow audio, deny video, request audio+video, expect ok (audio)"); + yield usePerm(true, false, true, true, true); + info("allow audio, deny video, request audio, expect ok (audio)"); + yield usePerm(true, false, true, false, true); + info("allow audio, deny video, request video, expect denied"); + yield usePerm(true, false, false, true, false); + + // Deny audio, allow video. + info("deny audio, allow video, request audio+video, expect ok (video)"); + yield usePerm(false, true, true, true, true); + info("deny audio, allow video, request audio, expect denied"); + yield usePerm(false, true, true, false, true); + info("deny audio, allow video, request video, expect ok (video)"); + yield usePerm(false, true, false, true, false); + + // Allow audio, video not set. + info("allow audio, request audio+video, expect prompt"); + yield usePerm(true, undefined, true, true, undefined); + info("allow audio, request audio, expect ok (audio)"); + yield usePerm(true, undefined, true, false, true); + info("allow audio, request video, expect prompt"); + yield usePerm(true, undefined, false, true, undefined); + + // Deny audio, video not set. + info("deny audio, request audio+video, expect prompt"); + yield usePerm(false, undefined, true, true, undefined); + info("deny audio, request audio, expect denied"); + yield usePerm(false, undefined, true, false, false); + info("deny audio, request video, expect prompt"); + yield usePerm(false, undefined, false, true, undefined); + + // Allow video, video not set. + info("allow video, request audio+video, expect prompt"); + yield usePerm(undefined, true, true, true, undefined); + info("allow video, request audio, expect prompt"); + yield usePerm(undefined, true, true, false, undefined); + info("allow video, request video, expect ok (video)"); + yield usePerm(undefined, true, false, true, true); + + // Deny video, video not set. + info("deny video, request audio+video, expect prompt"); + yield usePerm(undefined, false, true, true, undefined); + info("deny video, request audio, expect prompt"); + yield usePerm(undefined, false, true, false, undefined); + info("deny video, request video, expect denied"); + yield usePerm(undefined, false, false, true, false); + } +}, + +{ + desc: "Stop Sharing removes persistent permissions", + run: function checkStopSharingRemovesPersistentPermissions() { + function stopAndCheckPerm(aRequestAudio, aRequestVideo) { + let Perms = Services.perms; + let uri = content.document.documentURIObject; + + // Initially set both permissions to 'allow'. + Perms.add(uri, "microphone", Perms.ALLOW_ACTION); + Perms.add(uri, "camera", Perms.ALLOW_ACTION); + + // Start sharing what's been requested. + yield promiseMessage("ok", () => { + content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo); + }); + expectNotification("recording-device-events"); + yield checkSharingUI(); + + // Stop sharing. + PopupNotifications.getNotification("webRTC-sharingDevices").reshow(); + activateSecondaryAction(kActionDeny); + + yield promiseNotification("recording-device-events"); + expectNotification("getUserMedia:revoke"); + + yield promiseNoPopupNotification("webRTC-sharingDevices"); + + if (gObservedTopics["recording-device-events"] == 1) { + todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719"); + gObservedTopics["recording-device-events"] = 0; + } + + // Check that permissions have been removed as expected. + let audioPerm = Perms.testExactPermission(uri, "microphone"); + if (aRequestAudio) + is(audioPerm, Perms.UNKNOWN_ACTION, "microphone permissions removed"); + else + is(audioPerm, Perms.ALLOW_ACTION, "microphone permissions untouched"); + + let videoPerm = Perms.testExactPermission(uri, "camera"); + if (aRequestVideo) + is(videoPerm, Perms.UNKNOWN_ACTION, "camera permissions removed"); + else + is(videoPerm, Perms.ALLOW_ACTION, "camera permissions untouched"); + + // Cleanup. + yield closeStream(true); + + Perms.remove(uri.host, "camera"); + Perms.remove(uri.host, "microphone"); + } + + info("request audio+video, stop sharing resets both"); + yield stopAndCheckPerm(true, true); + info("request audio, stop sharing resets audio only"); + yield stopAndCheckPerm(true, false); + info("request video, stop sharing resets video only"); + yield stopAndCheckPerm(false, true); + } +} + +]; + +function test() { + waitForExplicitFinish(); + + let tab = gBrowser.addTab(); + gBrowser.selectedTab = tab; + tab.linkedBrowser.addEventListener("load", function onload() { + tab.linkedBrowser.removeEventListener("load", onload, true); + + kObservedTopics.forEach(topic => { + Services.obs.addObserver(observer, topic, false); + }); + Services.prefs.setBoolPref(PREF_PERMISSION_FAKE, true); + + Task.spawn(function () { + for (let test of gTests) { + info(test.desc); + yield test.run(); + + // Cleanup before the next test + expectNoNotifications(); + } + }).then(finish, ex => { + ok(false, "Unexpected Exception: " + ex); + finish(); + }); + }, true); + let rootDir = getRootDirectory(gTestPath) + rootDir = rootDir.replace("chrome://mochitests/content/", + "http://127.0.0.1:8888/"); + content.location = rootDir + "get_user_media.html"; +} + + +function wait(time) { + let deferred = Promise.defer(); + setTimeout(deferred.resolve, time); + return deferred.promise; +} diff --git a/browser/base/content/test/general/get_user_media.html b/browser/base/content/test/general/get_user_media.html new file mode 100644 index 00000000000..7c444f33388 --- /dev/null +++ b/browser/base/content/test/general/get_user_media.html @@ -0,0 +1,32 @@ + + + + +
+ + + diff --git a/browser/components/preferences/aboutPermissions.js b/browser/components/preferences/aboutPermissions.js index 86fc3224fdd..f506597df3f 100644 --- a/browser/components/preferences/aboutPermissions.js +++ b/browser/components/preferences/aboutPermissions.js @@ -38,7 +38,7 @@ let gVisitStmt = gPlacesDatabase.createAsyncStatement( * Permission types that should be tested with testExactPermission, as opposed * to testPermission. This is based on what consumers use to test these permissions. */ -let TEST_EXACT_PERM_TYPES = ["geo"]; +let TEST_EXACT_PERM_TYPES = ["geo", "camera", "microphone"]; /** * Site object represents a single site, uniquely identified by a host. @@ -330,8 +330,11 @@ let PermissionDefaults = { set fullscreen(aValue) { let value = (aValue != this.DENY); Services.prefs.setBoolPref("full-screen-api.enabled", value); - } -} + }, + + get camera() this.UNKNOWN, + get microphone() this.UNKNOWN +}; /** * AboutPermissions manages the about:permissions page. @@ -339,7 +342,7 @@ let PermissionDefaults = { let AboutPermissions = { /** * Number of sites to return from the places database. - */ + */ PLACES_SITES_LIMIT: 50, /** @@ -369,17 +372,18 @@ let AboutPermissions = { * * Potential future additions: "sts/use", "sts/subd" */ - _supportedPermissions: ["password", "cookie", "geo", "indexedDB", "popup", "fullscreen"], + _supportedPermissions: ["password", "cookie", "geo", "indexedDB", "popup", + "fullscreen", "camera", "microphone"], /** * Permissions that don't have a global "Allow" option. */ - _noGlobalAllow: ["geo", "indexedDB", "fullscreen"], + _noGlobalAllow: ["geo", "indexedDB", "fullscreen", "camera", "microphone"], /** * Permissions that don't have a global "Deny" option. */ - _noGlobalDeny: [], + _noGlobalDeny: ["camera", "microphone"], _stringBundle: Services.strings. createBundle("chrome://browser/locale/preferences/aboutPermissions.properties"), @@ -407,7 +411,7 @@ let AboutPermissions = { Services.obs.addObserver(this, "passwordmgr-storage-changed", false); Services.obs.addObserver(this, "cookie-changed", false); Services.obs.addObserver(this, "browser:purge-domain-data", false); - + this._observersInitialized = true; Services.obs.notifyObservers(null, "browser-permissions-preinit", null); }, @@ -542,7 +546,7 @@ let AboutPermissions = { let uri = NetUtil.newURI(aLogin.hostname); this.addHost(uri.host); } catch (e) { - // newURI will throw for add-ons logins stored in chrome:// URIs + // newURI will throw for add-ons logins stored in chrome:// URIs } itemCnt++; }, this); @@ -557,7 +561,7 @@ let AboutPermissions = { let uri = NetUtil.newURI(aHostname); this.addHost(uri.host); } catch (e) { - // newURI will throw for add-ons logins stored in chrome:// URIs + // newURI will throw for add-ons logins stored in chrome:// URIs } itemCnt++; }, this); @@ -778,7 +782,7 @@ let AboutPermissions = { let visitLabel = PluralForm.get(aCount, visitForm) .replace("#1", aCount); document.getElementById("site-visit-count").value = visitLabel; - }); + }); }, updatePasswordsCount: function() { diff --git a/browser/components/preferences/aboutPermissions.xul b/browser/components/preferences/aboutPermissions.xul index 95f3e9dfc44..6c83927003a 100644 --- a/browser/components/preferences/aboutPermissions.xul +++ b/browser/components/preferences/aboutPermissions.xul @@ -113,6 +113,48 @@ + + + + + + + + + + + + + + diff --git a/browser/components/preferences/tests/browser_permissions.js b/browser/components/preferences/tests/browser_permissions.js index 11a23ef6468..00524b3a4be 100644 --- a/browser/components/preferences/tests/browser_permissions.js +++ b/browser/components/preferences/tests/browser_permissions.js @@ -27,6 +27,8 @@ const TEST_PERMS = { "indexedDB": PERM_UNKNOWN, "popup": PERM_DENY, "fullscreen" : PERM_UNKNOWN, + "camera": PERM_UNKNOWN, + "microphone": PERM_UNKNOWN }; const NO_GLOBAL_ALLOW = [ @@ -36,7 +38,7 @@ const NO_GLOBAL_ALLOW = [ ]; // number of managed permissions in the interface -const TEST_PERMS_COUNT = 6; +const TEST_PERMS_COUNT = 8; function test() { waitForExplicitFinish(); @@ -164,7 +166,7 @@ var tests = [ function test_all_sites_permission() { // apply the old default of allowing all cookies Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); - + // there should be no user-set pref for cookie behavior is(Services.prefs.getIntPref("network.cookie.cookieBehavior"), PERM_UNKNOWN, "network.cookie.cookieBehavior is expected default"); @@ -189,12 +191,12 @@ var tests = [ // make sure "Manage All Passwords..." button opens the correct dialog addWindowListener("chrome://passwordmgr/content/passwordManager.xul", runNextTest); gBrowser.contentDocument.getElementById("passwords-manage-all-button").doCommand(); - + }, function test_manage_all_cookies() { // make sure "Manage All Cookies..." button opens the correct dialog - addWindowListener("chrome://browser/content/preferences/cookies.xul", runNextTest); + addWindowListener("chrome://browser/content/preferences/cookies.xul", runNextTest); gBrowser.contentDocument.getElementById("cookies-manage-all-button").doCommand(); }, diff --git a/browser/locales/en-US/chrome/browser/browser.properties b/browser/locales/en-US/chrome/browser/browser.properties index a9a3c5af55a..fcf7dedfbc3 100644 --- a/browser/locales/en-US/chrome/browser/browser.properties +++ b/browser/locales/en-US/chrome/browser/browser.properties @@ -497,8 +497,12 @@ getUserMedia.noVideo.label = No Video getUserMedia.noAudio.label = No Audio getUserMedia.shareSelectedDevices.label = Share Selected Device;Share Selected Devices getUserMedia.shareSelectedDevices.accesskey = S +getUserMedia.always.label = Always Share +getUserMedia.always.accesskey = A getUserMedia.denyRequest.label = Don't Share getUserMedia.denyRequest.accesskey = D +getUserMedia.never.label = Never Share +getUserMedia.never.accesskey = N getUserMedia.sharingCamera.message2 = You are currently sharing your camera with this page. getUserMedia.sharingMicrophone.message2 = You are currently sharing your microphone with this page. getUserMedia.sharingCameraAndMicrophone.message2 = You are currently sharing your camera and microphone with this page. diff --git a/browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd b/browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd index 5b13abf8322..de59997a379 100644 --- a/browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd +++ b/browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd @@ -42,3 +42,5 @@ + + diff --git a/browser/locales/en-US/chrome/browser/sitePermissions.properties b/browser/locales/en-US/chrome/browser/sitePermissions.properties index 461e7679251..c9031c2f03d 100644 --- a/browser/locales/en-US/chrome/browser/sitePermissions.properties +++ b/browser/locales/en-US/chrome/browser/sitePermissions.properties @@ -10,6 +10,8 @@ alwaysAsk = Always Ask permission.cookie.label = Set Cookies permission.desktop-notification.label = Show Notifications permission.image.label = Load Images +permission.camera.label = Use the Camera +permission.microphone.label = Use the Microphone permission.install.label = Install Add-ons permission.popup.label = Open Pop-up Windows permission.geo.label = Access Your Location diff --git a/browser/modules/SitePermissions.jsm b/browser/modules/SitePermissions.jsm index d26163a1405..6cc7b58dfb2 100644 --- a/browser/modules/SitePermissions.jsm +++ b/browser/modules/SitePermissions.jsm @@ -186,6 +186,9 @@ let gPermissionObject = { "desktop-notification": {}, + "camera": {}, + "microphone": {}, + "popup": { getDefault: function () { return Services.prefs.getBoolPref("dom.disable_open_during_load") ? diff --git a/browser/modules/webrtcUI.jsm b/browser/modules/webrtcUI.jsm index 8bb28f9d5ab..29e4c6f1e84 100644 --- a/browser/modules/webrtcUI.jsm +++ b/browser/modules/webrtcUI.jsm @@ -120,13 +120,13 @@ function prompt(aContentWindow, aCallID, aAudioRequested, aVideoRequested, aDevi return; } - let host = aContentWindow.document.documentURIObject.host; + let uri = aContentWindow.document.documentURIObject; let browser = getBrowserForWindow(aContentWindow); let chromeDoc = browser.ownerDocument; let chromeWin = chromeDoc.defaultView; let stringBundle = chromeWin.gNavigatorBundle; let message = stringBundle.getFormattedString("getUserMedia.share" + requestType + ".message", - [ host ]); + [ uri.host ]); let mainAction = { label: PluralForm.get(requestType == "CameraAndMicrophone" ? 2 : 1, @@ -138,13 +138,34 @@ function prompt(aContentWindow, aCallID, aAudioRequested, aVideoRequested, aDevi callback: function() {} }; - let secondaryActions = [{ - label: stringBundle.getString("getUserMedia.denyRequest.label"), - accessKey: stringBundle.getString("getUserMedia.denyRequest.accesskey"), - callback: function () { - denyRequest(aCallID); + let secondaryActions = [ + { + label: stringBundle.getString("getUserMedia.always.label"), + accessKey: stringBundle.getString("getUserMedia.always.accesskey"), + callback: function () { + mainAction.callback(true); + } + }, + { + label: stringBundle.getString("getUserMedia.denyRequest.label"), + accessKey: stringBundle.getString("getUserMedia.denyRequest.accesskey"), + callback: function () { + denyRequest(aCallID); + } + }, + { + label: stringBundle.getString("getUserMedia.never.label"), + accessKey: stringBundle.getString("getUserMedia.never.accesskey"), + callback: function () { + denyRequest(aCallID); + let perms = Services.perms; + if (audioDevices.length) + perms.add(uri, "microphone", perms.DENY_ACTION); + if (videoDevices.length) + perms.add(uri, "camera", perms.DENY_ACTION); + } } - }]; + ]; let options = { eventCallback: function(aTopic, aNewBrowser) { @@ -188,18 +209,29 @@ function prompt(aContentWindow, aCallID, aAudioRequested, aVideoRequested, aDevi addDeviceToList(micMenupopup, stringBundle.getString("getUserMedia.noAudio.label"), "-1"); } - this.mainAction.callback = function() { + this.mainAction.callback = function(aRemember) { let allowedDevices = Cc["@mozilla.org/supports-array;1"] .createInstance(Ci.nsISupportsArray); + let perms = Services.perms; if (videoDevices.length) { let videoDeviceIndex = chromeDoc.getElementById("webRTC-selectCamera-menulist").value; - if (videoDeviceIndex != "-1") + let allowCamera = videoDeviceIndex != "-1"; + if (allowCamera) allowedDevices.AppendElement(videoDevices[videoDeviceIndex]); + if (aRemember) { + perms.add(uri, "camera", + allowCamera ? perms.ALLOW_ACTION : perms.DENY_ACTION); + } } if (audioDevices.length) { let audioDeviceIndex = chromeDoc.getElementById("webRTC-selectMicrophone-menulist").value; - if (audioDeviceIndex != "-1") + let allowMic = audioDeviceIndex != "-1"; + if (allowMic) allowedDevices.AppendElement(audioDevices[audioDeviceIndex]); + if (aRemember) { + perms.add(uri, "microphone", + allowMic ? perms.ALLOW_ACTION : perms.DENY_ACTION); + } } if (allowedDevices.Count() == 0) { @@ -252,6 +284,7 @@ function showBrowserSpecificIndicator(aBrowser) { let message = stringBundle.getString("getUserMedia.sharing" + captureState + ".message2"); + let uri = aBrowser.contentWindow.document.documentURIObject; let windowId = aBrowser.contentWindow .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils) @@ -266,6 +299,14 @@ function showBrowserSpecificIndicator(aBrowser) { label: stringBundle.getString("getUserMedia.stopSharing.label"), accessKey: stringBundle.getString("getUserMedia.stopSharing.accesskey"), callback: function () { + let perms = Services.perms; + if (hasVideo.value && + perms.testExactPermission(uri, "camera") == perms.ALLOW_ACTION) + perms.remove(uri.host, "camera"); + if (hasAudio.value && + perms.testExactPermission(uri, "microphone") == perms.ALLOW_ACTION) + perms.remove(uri.host, "microphone"); + Services.obs.notifyObservers(null, "getUserMedia:revoke", windowId); } }]; diff --git a/browser/themes/linux/preferences/aboutPermissions.css b/browser/themes/linux/preferences/aboutPermissions.css index 3e5fc721e3e..94549d82d23 100644 --- a/browser/themes/linux/preferences/aboutPermissions.css +++ b/browser/themes/linux/preferences/aboutPermissions.css @@ -97,6 +97,12 @@ .pref-icon[type="fullscreen"] { list-style-image: url(chrome://global/skin/icons/question-64.png); } +.pref-icon[type="camera"] { + list-style-image: url(chrome://global/skin/icons/question-64.png); +} +.pref-icon[type="microphone"] { + list-style-image: url(chrome://global/skin/icons/question-64.png); +} .pref-title { font-size: 125%; diff --git a/browser/themes/osx/preferences/aboutPermissions.css b/browser/themes/osx/preferences/aboutPermissions.css index 21dd49a90a7..f233032fa94 100644 --- a/browser/themes/osx/preferences/aboutPermissions.css +++ b/browser/themes/osx/preferences/aboutPermissions.css @@ -107,6 +107,12 @@ .pref-icon[type="fullscreen"] { list-style-image: url(chrome://global/skin/icons/question-64.png); } +.pref-icon[type="camera"] { + list-style-image: url(chrome://global/skin/icons/question-64.png); +} +.pref-icon[type="microphone"] { + list-style-image: url(chrome://global/skin/icons/question-64.png); +} @media (min-resolution: 2dppx) { .pref-icon[type="geo"] { diff --git a/browser/themes/windows/preferences/aboutPermissions.css b/browser/themes/windows/preferences/aboutPermissions.css index 7a64c1f9bfd..ae617664e4c 100644 --- a/browser/themes/windows/preferences/aboutPermissions.css +++ b/browser/themes/windows/preferences/aboutPermissions.css @@ -100,6 +100,12 @@ .pref-icon[type="fullscreen"] { list-style-image: url(chrome://global/skin/icons/question-64.png); } +.pref-icon[type="camera"] { + list-style-image: url(chrome://global/skin/icons/question-64.png); +} +.pref-icon[type="microphone"] { + list-style-image: url(chrome://global/skin/icons/question-64.png); +} .pref-title { font-size: 125%; diff --git a/dom/media/MediaManager.cpp b/dom/media/MediaManager.cpp index e564e883707..79902f9ff38 100644 --- a/dom/media/MediaManager.cpp +++ b/dom/media/MediaManager.cpp @@ -14,6 +14,7 @@ #include "nsIEventTarget.h" #include "nsIUUIDGenerator.h" #include "nsIScriptGlobalObject.h" +#include "nsIPermissionManager.h" #include "nsIPopupWindowManager.h" #include "nsISupportsArray.h" #include "nsIDocShell.h" @@ -33,8 +34,6 @@ #include "nsDOMFile.h" #include "nsGlobalWindow.h" -#include "mozilla/Preferences.h" - /* Using WebRTC backend on Desktops (Mac, Windows, Linux), otherwise default */ #include "MediaEngineDefault.h" #if defined(MOZ_WEBRTC) @@ -907,6 +906,13 @@ public: return NS_OK; } + nsresult + SetContraints(const MediaStreamConstraintsInternal& aConstraints) + { + mConstraints = aConstraints; + return NS_OK; + } + nsresult SetAudioDevice(MediaDevice* aAudioDevice) { @@ -1075,7 +1081,11 @@ public: { NS_ASSERTION(!NS_IsMainThread(), "Don't call on main thread"); - MediaEngine *backend = mManager->GetBackend(mWindowId); + nsRefPtr backend; + if (mConstraints.mFake) + backend = new MediaEngineDefault(); + else + backend = mManager->GetBackend(mWindowId); ScopedDeletePtr final (GetSources(backend, mConstraints.mVideom, &MediaEngine::EnumerateVideoDevices, @@ -1415,14 +1425,59 @@ MediaManager::GetUserMedia(JSContext* aCx, bool aPrivileged, } #endif // XXX No full support for picture in Desktop yet (needs proper UI) - if (aPrivileged || c.mFake) { + if (aPrivileged || + (c.mFake && !Preferences::GetBool("media.navigator.permission.fake"))) { runnable->Arm(); mMediaThread->Dispatch(runnable, NS_DISPATCH_NORMAL); } else { + // Check if this site has persistent permissions. + nsresult rv; + nsCOMPtr permManager = + do_GetService(NS_PERMISSIONMANAGER_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + uint32_t audioPerm = nsIPermissionManager::UNKNOWN_ACTION; + if (c.mAudio) { + rv = permManager->TestExactPermissionFromPrincipal( + aWindow->GetExtantDoc()->NodePrincipal(), "microphone", &audioPerm); + NS_ENSURE_SUCCESS(rv, rv); + if (audioPerm == nsIPermissionManager::PROMPT_ACTION) { + audioPerm = nsIPermissionManager::UNKNOWN_ACTION; + } + } + + uint32_t videoPerm = nsIPermissionManager::UNKNOWN_ACTION; + if (c.mVideo) { + rv = permManager->TestExactPermissionFromPrincipal( + aWindow->GetExtantDoc()->NodePrincipal(), "camera", &videoPerm); + NS_ENSURE_SUCCESS(rv, rv); + if (videoPerm == nsIPermissionManager::PROMPT_ACTION) { + videoPerm = nsIPermissionManager::UNKNOWN_ACTION; + } + } + + if ((!c.mAudio || audioPerm) && (!c.mVideo || videoPerm)) { + // All permissions we were about to request already have a saved value. + if (c.mAudio && audioPerm == nsIPermissionManager::DENY_ACTION) { + c.mAudio = false; + runnable->SetContraints(c); + } + if (c.mVideo && videoPerm == nsIPermissionManager::DENY_ACTION) { + c.mVideo = false; + runnable->SetContraints(c); + } + + runnable->Arm(); + if (!c.mAudio && !c.mVideo) { + return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED")); + } + + return mMediaThread->Dispatch(runnable, NS_DISPATCH_NORMAL); + } + // Ask for user permission, and dispatch runnable (or not) when a response // is received via an observer notification. Each call is paired with its // runnable by a GUID. - nsresult rv; nsCOMPtr uuidgen = do_GetService("@mozilla.org/uuid-generator;1", &rv); NS_ENSURE_SUCCESS(rv, rv); diff --git a/dom/media/MediaManager.h b/dom/media/MediaManager.h index 531381dc816..d187f86a431 100644 --- a/dom/media/MediaManager.h +++ b/dom/media/MediaManager.h @@ -19,6 +19,7 @@ #include "nsIDOMNavigatorUserMedia.h" #include "nsXULAppAPI.h" #include "mozilla/Attributes.h" +#include "mozilla/Preferences.h" #include "mozilla/StaticPtr.h" #include "mozilla/dom/MediaStreamTrackBinding.h" #include "prlog.h" @@ -102,12 +103,16 @@ public: bool CapturingVideo() { NS_ASSERTION(NS_IsMainThread(), "Only call on main thread"); - return mVideoSource && !mVideoSource->IsFake() && !mStopped; + return mVideoSource && !mStopped && + (!mVideoSource->IsFake() || + Preferences::GetBool("media.navigator.permission.fake")); } bool CapturingAudio() { NS_ASSERTION(NS_IsMainThread(), "Only call on main thread"); - return mAudioSource && !mAudioSource->IsFake() && !mStopped; + return mAudioSource && !mStopped && + (!mAudioSource->IsFake() || + Preferences::GetBool("media.navigator.permission.fake")); } void SetStopped()