WebRTCParent.sys.mjs (57646B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 11 SitePermissions: "resource:///modules/SitePermissions.sys.mjs", 12 webrtcUI: "resource:///modules/webrtcUI.sys.mjs", 13 }); 14 15 XPCOMUtils.defineLazyServiceGetter( 16 lazy, 17 "OSPermissions", 18 "@mozilla.org/ospermissionrequest;1", 19 Ci.nsIOSPermissionRequest 20 ); 21 22 export class WebRTCParent extends JSWindowActorParent { 23 didDestroy() { 24 // Media stream tracks end on unload, so call stopRecording() on them early 25 // *before* we go away, to ensure we're working with the right principal. 26 this.stopRecording(this.manager.outerWindowId); 27 lazy.webrtcUI.forgetStreamsFromBrowserContext(this.browsingContext); 28 // Must clear activePerms here to prevent them from being read by laggard 29 // stopRecording() calls, which due to IPC, may come in *after* navigation. 30 // This is to prevent granting temporary grace periods to the wrong page. 31 lazy.webrtcUI.activePerms.delete(this.manager.outerWindowId); 32 } 33 34 getBrowser() { 35 return this.browsingContext.top.embedderElement; 36 } 37 38 receiveMessage(aMessage) { 39 switch (aMessage.name) { 40 case "rtcpeer:Request": { 41 let params = Object.freeze( 42 Object.assign( 43 { 44 origin: this.manager.documentPrincipal.origin, 45 }, 46 aMessage.data 47 ) 48 ); 49 50 let blockers = Array.from(lazy.webrtcUI.peerConnectionBlockers); 51 52 (async function () { 53 for (let blocker of blockers) { 54 try { 55 let result = await blocker(params); 56 if (result == "deny") { 57 return false; 58 } 59 } catch (err) { 60 console.error(`error in PeerConnection blocker: ${err.message}`); 61 } 62 } 63 return true; 64 })().then(decision => { 65 let message; 66 if (decision) { 67 lazy.webrtcUI.emitter.emit("peer-request-allowed", params); 68 message = "rtcpeer:Allow"; 69 } else { 70 lazy.webrtcUI.emitter.emit("peer-request-blocked", params); 71 message = "rtcpeer:Deny"; 72 } 73 74 this.sendAsyncMessage(message, { 75 callID: params.callID, 76 windowID: params.windowID, 77 }); 78 }); 79 break; 80 } 81 case "rtcpeer:CancelRequest": { 82 let params = Object.freeze({ 83 origin: this.manager.documentPrincipal.origin, 84 callID: aMessage.data, 85 }); 86 lazy.webrtcUI.emitter.emit("peer-request-cancel", params); 87 break; 88 } 89 case "webrtc:Request": { 90 let data = aMessage.data; 91 92 // Record third party origins for telemetry. 93 let isThirdPartyOrigin = 94 this.manager.documentPrincipal.origin != 95 this.manager.topWindowContext.documentPrincipal.origin; 96 data.isThirdPartyOrigin = isThirdPartyOrigin; 97 98 data.origin = this.manager.topWindowContext.documentPrincipal.origin; 99 100 let browser = this.getBrowser(); 101 if (browser.fxrPermissionPrompt) { 102 // For Firefox Reality on Desktop, switch to a different mechanism to 103 // prompt the user since fewer permissions are available and since many 104 // UI dependencies are not available. 105 browser.fxrPermissionPrompt(data); 106 } else { 107 prompt(this, this.getBrowser(), data); 108 } 109 break; 110 } 111 case "webrtc:StopRecording": 112 this.stopRecording( 113 aMessage.data.windowID, 114 aMessage.data.mediaSource, 115 aMessage.data.rawID 116 ); 117 break; 118 case "webrtc:CancelRequest": { 119 let browser = this.getBrowser(); 120 // browser can be null when closing the window 121 if (browser) { 122 removePrompt(browser, aMessage.data); 123 } 124 break; 125 } 126 case "webrtc:UpdateIndicators": { 127 let { data } = aMessage; 128 data.documentURI = this.manager.documentURI?.spec; 129 if (data.windowId) { 130 if (!data.remove) { 131 data.principal = this.manager.topWindowContext.documentPrincipal; 132 } 133 lazy.webrtcUI.streamAddedOrRemoved(this.browsingContext, data); 134 } 135 this.updateIndicators(data); 136 break; 137 } 138 } 139 } 140 141 updateIndicators(aData) { 142 let browsingContext = this.browsingContext; 143 let state = lazy.webrtcUI.updateIndicators(browsingContext.top); 144 145 let browser = this.getBrowser(); 146 if (!browser) { 147 return; 148 } 149 150 state.browsingContext = browsingContext; 151 state.windowId = aData.windowId; 152 153 let tabbrowser = browser.ownerGlobal.gBrowser; 154 if (tabbrowser) { 155 tabbrowser.updateBrowserSharing(browser, { 156 webRTC: state, 157 }); 158 } 159 } 160 161 denyRequest(aRequest) { 162 this.sendAsyncMessage("webrtc:Deny", { 163 callID: aRequest.callID, 164 windowID: aRequest.windowID, 165 }); 166 } 167 168 // 169 // Deny the request because the browser does not have access to the 170 // camera or microphone due to OS security restrictions. The user may 171 // have granted camera/microphone access to the site, but not have 172 // allowed the browser access in OS settings. 173 // 174 denyRequestNoPermission(aRequest) { 175 this.sendAsyncMessage("webrtc:Deny", { 176 callID: aRequest.callID, 177 windowID: aRequest.windowID, 178 noOSPermission: true, 179 }); 180 } 181 182 // 183 // Check if we have permission to access the camera or screen-sharing and/or 184 // microphone at the OS level. Triggers a request to access the device if access 185 // is needed and the permission state has not yet been determined. 186 // 187 async checkOSPermission(camNeeded, micNeeded, scrNeeded) { 188 // Don't trigger OS permission requests for fake devices. Fake devices don't 189 // require OS permission and the dialogs are problematic in automated testing 190 // (where fake devices are used) because they require user interaction. 191 if ( 192 !scrNeeded && 193 Services.prefs.getBoolPref("media.navigator.streams.fake", false) 194 ) { 195 return true; 196 } 197 let camStatus = {}, 198 micStatus = {}; 199 if (camNeeded || micNeeded) { 200 lazy.OSPermissions.getMediaCapturePermissionState(camStatus, micStatus); 201 } 202 if (camNeeded) { 203 let camPermission = camStatus.value; 204 let camAccessible = await this.checkAndGetOSPermission( 205 camPermission, 206 lazy.OSPermissions.requestVideoCapturePermission 207 ); 208 if (!camAccessible) { 209 return false; 210 } 211 } 212 if (micNeeded) { 213 let micPermission = micStatus.value; 214 let micAccessible = await this.checkAndGetOSPermission( 215 micPermission, 216 lazy.OSPermissions.requestAudioCapturePermission 217 ); 218 if (!micAccessible) { 219 return false; 220 } 221 } 222 let scrStatus = {}; 223 if (scrNeeded) { 224 lazy.OSPermissions.getScreenCapturePermissionState(scrStatus); 225 if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) { 226 lazy.OSPermissions.maybeRequestScreenCapturePermission(); 227 return false; 228 } 229 } 230 return true; 231 } 232 233 // 234 // Given a device's permission, return true if the device is accessible. If 235 // the device's permission is not yet determined, request access to the device. 236 // |requestPermissionFunc| must return a promise that resolves with true 237 // if the device is accessible and false otherwise. 238 // 239 async checkAndGetOSPermission(devicePermission, requestPermissionFunc) { 240 if ( 241 devicePermission == lazy.OSPermissions.PERMISSION_STATE_DENIED || 242 devicePermission == lazy.OSPermissions.PERMISSION_STATE_RESTRICTED 243 ) { 244 return false; 245 } 246 if (devicePermission == lazy.OSPermissions.PERMISSION_STATE_NOTDETERMINED) { 247 let deviceAllowed = await requestPermissionFunc(); 248 if (!deviceAllowed) { 249 return false; 250 } 251 } 252 return true; 253 } 254 255 stopRecording(aOuterWindowId, aMediaSource, aRawId) { 256 for (let { browsingContext, state } of lazy.webrtcUI._streams) { 257 if (browsingContext == this.browsingContext) { 258 let { principal } = state; 259 for (let { mediaSource, rawId } of state.devices) { 260 if (aRawId && (aRawId != rawId || aMediaSource != mediaSource)) { 261 continue; 262 } 263 // Deactivate this device (no aRawId means all devices). 264 this.deactivateDevicePerm( 265 aOuterWindowId, 266 mediaSource, 267 rawId, 268 principal 269 ); 270 } 271 } 272 } 273 } 274 275 /** 276 * Add a device record to webrtcUI.activePerms, denoting a device as in use. 277 * Important to call for permission grace periods to work correctly. 278 */ 279 activateDevicePerm(aOuterWindowId, aMediaSource, aId) { 280 if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) { 281 lazy.webrtcUI.activePerms.set(this.manager.outerWindowId, new Map()); 282 } 283 lazy.webrtcUI.activePerms 284 .get(this.manager.outerWindowId) 285 .set(aOuterWindowId + aMediaSource + aId, aMediaSource); 286 } 287 288 /** 289 * Remove a device record from webrtcUI.activePerms, denoting a device as 290 * no longer in use by the site. Meaning: gUM requests for this device will 291 * no longer be implicitly granted through the webrtcUI.activePerms mechanism. 292 * 293 * However, if webrtcUI.deviceGracePeriodTimeoutMs is defined, the implicit 294 * grant is extended for an additional period of time through SitePermissions. 295 */ 296 deactivateDevicePerm( 297 aOuterWindowId, 298 aMediaSource, 299 aId, 300 aPermissionPrincipal 301 ) { 302 // If we don't have active permissions for the given window anymore don't 303 // set a grace period. This happens if there has been a user revoke and 304 // webrtcUI clears the permissions. 305 if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) { 306 return; 307 } 308 let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId); 309 map.delete(aOuterWindowId + aMediaSource + aId); 310 311 // Add a permission grace period for camera and microphone only 312 if ( 313 (aMediaSource != "camera" && aMediaSource != "microphone") || 314 !this.browsingContext.top.embedderElement 315 ) { 316 return; 317 } 318 let gracePeriodMs = lazy.webrtcUI.deviceGracePeriodTimeoutMs; 319 if (gracePeriodMs > 0) { 320 // A grace period is extended (even past navigation) to this outer window 321 // + origin + deviceId only. This avoids re-prompting without the user 322 // having to persist permission to the site, in a common case of a web 323 // conference asking them for the camera in a lobby page, before 324 // navigating to the actual meeting room page. Does not survive tab close. 325 // 326 // Caution: since navigation causes deactivation, we may be in the middle 327 // of one. We must pass in a principal & URI for SitePermissions to use 328 // instead of browser.currentURI, because the latter may point to a new 329 // page already, and we must not leak permission to unrelated pages. 330 // 331 let permissionName = [aMediaSource, aId].join("^"); 332 lazy.SitePermissions.setForPrincipal( 333 aPermissionPrincipal, 334 permissionName, 335 lazy.SitePermissions.ALLOW, 336 lazy.SitePermissions.SCOPE_TEMPORARY, 337 this.browsingContext.top.embedderElement, 338 gracePeriodMs 339 ); 340 } 341 } 342 343 /** 344 * Checks if the principal has sufficient permissions 345 * to fulfill the given request. If the request can be 346 * fulfilled, a message is sent to the child 347 * signaling that WebRTC permissions were given and 348 * this function will return true. 349 */ 350 checkRequestAllowed(aRequest, aPrincipal) { 351 if (!aRequest.secure) { 352 return false; 353 } 354 // Always prompt for screen sharing 355 if (aRequest.sharingScreen) { 356 return false; 357 } 358 let { 359 callID, 360 windowID, 361 audioInputDevices, 362 videoInputDevices, 363 audioOutputDevices, 364 hasInherentAudioConstraints, 365 hasInherentVideoConstraints, 366 audioOutputId, 367 } = aRequest; 368 369 if (audioOutputDevices?.length) { 370 // Prompt if a specific device is not requested, available and allowed. 371 let device = audioOutputDevices.find(({ id }) => id == audioOutputId); 372 if ( 373 !device || 374 !lazy.SitePermissions.getForPrincipal( 375 aPrincipal, 376 ["speaker", device.id].join("^"), 377 this.getBrowser() 378 ).state == lazy.SitePermissions.ALLOW 379 ) { 380 return false; 381 } 382 this.sendAsyncMessage("webrtc:Allow", { 383 callID, 384 windowID, 385 devices: [device.deviceIndex], 386 }); 387 return true; 388 } 389 390 let { perms } = Services; 391 if ( 392 perms.testExactPermissionFromPrincipal(aPrincipal, "MediaManagerVideo") 393 ) { 394 perms.removeFromPrincipal(aPrincipal, "MediaManagerVideo"); 395 } 396 397 // Don't use persistent permissions from the top-level principal 398 // if we're handling a potentially insecure third party 399 // through a wildcard ("*") allow attribute. 400 let limited = aRequest.secondOrigin; 401 402 let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId); 403 // We consider a camera or mic active if it is active or was active within a 404 // grace period of milliseconds ago. 405 const isAllowed = ({ mediaSource, rawId }, permissionID) => 406 map?.get(windowID + mediaSource + rawId) || 407 (!limited && 408 (lazy.SitePermissions.getForPrincipal(aPrincipal, permissionID).state == 409 lazy.SitePermissions.ALLOW || 410 lazy.SitePermissions.getForPrincipal( 411 aPrincipal, 412 [mediaSource, rawId].join("^"), 413 this.getBrowser() 414 ).state == lazy.SitePermissions.ALLOW)); 415 416 let microphone; 417 if (audioInputDevices.length) { 418 for (let device of audioInputDevices) { 419 if (isAllowed(device, "microphone")) { 420 microphone = device; 421 break; 422 } 423 if (hasInherentAudioConstraints) { 424 // Inherent constraints suggest site is looking for a specific mic 425 break; 426 } 427 // Some sites don't look too hard at what they get, and spam gUM without 428 // adjusting what they ask for to match what they got last time. To keep 429 // users in charge and reduce prompts, ignore other constraints by 430 // returning the most-fit microphone a site already has access to. 431 } 432 if (!microphone) { 433 return false; 434 } 435 } 436 let camera; 437 if (videoInputDevices.length) { 438 for (let device of videoInputDevices) { 439 if (isAllowed(device, "camera")) { 440 camera = device; 441 break; 442 } 443 if (hasInherentVideoConstraints) { 444 // Inherent constraints suggest site is looking for a specific camera 445 break; 446 } 447 // Some sites don't look too hard at what they get, and spam gUM without 448 // adjusting what they ask for to match what they got last time. To keep 449 // users in charge and reduce prompts, ignore other constraints by 450 // returning the most-fit camera a site already has access to. 451 } 452 if (!camera) { 453 return false; 454 } 455 } 456 let devices = []; 457 if (camera) { 458 perms.addFromPrincipal( 459 aPrincipal, 460 "MediaManagerVideo", 461 perms.ALLOW_ACTION, 462 perms.EXPIRE_SESSION 463 ); 464 devices.push(camera.deviceIndex); 465 this.activateDevicePerm(windowID, camera.mediaSource, camera.rawId); 466 } 467 if (microphone) { 468 devices.push(microphone.deviceIndex); 469 this.activateDevicePerm( 470 windowID, 471 microphone.mediaSource, 472 microphone.rawId 473 ); 474 } 475 this.checkOSPermission(!!camera, !!microphone, false).then( 476 havePermission => { 477 if (havePermission) { 478 this.sendAsyncMessage("webrtc:Allow", { callID, windowID, devices }); 479 } else { 480 this.denyRequestNoPermission(aRequest); 481 } 482 } 483 ); 484 return true; 485 } 486 } 487 488 function prompt(aActor, aBrowser, aRequest) { 489 let { 490 audioInputDevices, 491 videoInputDevices, 492 audioOutputDevices, 493 sharingScreen, 494 sharingAudio, 495 requestTypes, 496 isHandlingUserInput, 497 } = aRequest; 498 499 let principal = 500 Services.scriptSecurityManager.createContentPrincipalFromOrigin( 501 aRequest.origin 502 ); 503 504 // For add-on principals, we immediately check for permission instead 505 // of waiting for the notification to focus. This allows for supporting 506 // cases such as browserAction popups where no prompt is shown. 507 if (principal.addonPolicy) { 508 let isPopup = false; 509 let isBackground = false; 510 511 for (let view of principal.addonPolicy.extension.views) { 512 if (view.viewType == "popup" && view.xulBrowser == aBrowser) { 513 isPopup = true; 514 } 515 if (view.viewType == "background" && view.xulBrowser == aBrowser) { 516 isBackground = true; 517 } 518 } 519 520 // Recording from background pages is considered too sensitive and will 521 // always be denied. 522 if (isBackground) { 523 aActor.denyRequest(aRequest); 524 return; 525 } 526 527 // If the request comes from a popup, we don't want to show the prompt, 528 // but we do want to allow the request if the user previously gave permission. 529 if (isPopup) { 530 if (!aActor.checkRequestAllowed(aRequest, principal, aBrowser)) { 531 aActor.denyRequest(aRequest); 532 } 533 return; 534 } 535 } 536 537 // If the request comes from a sidebar, 538 // the user already gave a persistent permission, skip showing a notification 539 // otherwise deny request. 540 if (isSidebar(aBrowser)) { 541 if (!aActor.checkRequestAllowed(aRequest, principal, aBrowser)) { 542 aActor.denyRequest(aRequest); 543 return; 544 } 545 546 return; 547 } 548 549 // If the user has already denied access once in this tab, 550 // deny again without even showing the notification icon. 551 for (const type of requestTypes) { 552 const permissionID = 553 type == "AudioCapture" ? "microphone" : type.toLowerCase(); 554 if ( 555 lazy.SitePermissions.getForPrincipal(principal, permissionID, aBrowser) 556 .state == lazy.SitePermissions.BLOCK 557 ) { 558 aActor.denyRequest(aRequest); 559 return; 560 } 561 } 562 563 let chromeDoc = aBrowser.ownerDocument; 564 const localization = new Localization( 565 ["browser/webrtcIndicator.ftl", "branding/brand.ftl"], 566 true 567 ); 568 569 /** @type {"Screen" | "Camera" | null} */ 570 let reqVideoInput = null; 571 if (videoInputDevices.length) { 572 reqVideoInput = sharingScreen ? "Screen" : "Camera"; 573 } 574 /** @type {"AudioCapture" | "Microphone" | null} */ 575 let reqAudioInput = null; 576 if (audioInputDevices.length) { 577 reqAudioInput = sharingAudio ? "AudioCapture" : "Microphone"; 578 } 579 const reqAudioOutput = !!audioOutputDevices.length; 580 581 const isFile = principal.schemeIs("file"); 582 const stringId = getPromptMessageId( 583 reqVideoInput, 584 reqAudioInput, 585 reqAudioOutput, 586 !!aRequest.secondOrigin, 587 isFile 588 ); 589 let message; 590 let originToShow; 591 if (isFile) { 592 message = localization.formatValueSync(stringId); 593 originToShow = null; 594 } else { 595 message = localization.formatValueSync(stringId, { 596 origin: "<>", 597 thirdParty: "{}", 598 }); 599 originToShow = lazy.webrtcUI.getHostOrExtensionName(principal.URI); 600 } 601 let notification; // Used by action callbacks. 602 const actionL10nIds = [{ id: "webrtc-action-allow" }]; 603 604 let notificationSilencingEnabled = Services.prefs.getBoolPref( 605 "privacy.webrtc.allowSilencingNotifications" 606 ); 607 608 const isNotNowLabelEnabled = 609 reqAudioOutput || allowedOrActiveCameraOrMicrophone(aBrowser); 610 let secondaryActions = []; 611 if (reqAudioOutput || (notificationSilencingEnabled && sharingScreen)) { 612 // We want to free up the checkbox at the bottom of the permission 613 // panel for the notification silencing option, so we use a 614 // different configuration for the permissions panel when 615 // notification silencing is enabled. 616 617 let permissionName = reqAudioOutput ? "speaker" : "screen"; 618 // When selecting speakers, we always offer 'Not now' instead of 'Block'. 619 // When selecting screens, we offer 'Not now' if and only if we have a 620 // (temporary) allow permission for some mic/cam device. 621 const id = isNotNowLabelEnabled 622 ? "webrtc-action-not-now" 623 : "webrtc-action-block"; 624 actionL10nIds.push({ id }, { id: "webrtc-action-always-block" }); 625 secondaryActions = [ 626 { 627 callback() { 628 aActor.denyRequest(aRequest); 629 if (!isNotNowLabelEnabled) { 630 lazy.SitePermissions.setForPrincipal( 631 principal, 632 permissionName, 633 lazy.SitePermissions.BLOCK, 634 lazy.SitePermissions.SCOPE_TEMPORARY, 635 notification.browser 636 ); 637 } 638 }, 639 }, 640 { 641 callback() { 642 aActor.denyRequest(aRequest); 643 lazy.SitePermissions.setForPrincipal( 644 principal, 645 permissionName, 646 lazy.SitePermissions.BLOCK, 647 lazy.SitePermissions.SCOPE_PERSISTENT, 648 notification.browser 649 ); 650 }, 651 }, 652 ]; 653 } else { 654 // We have a (temporary) allow permission for some device 655 // hence we offer a 'Not now' label instead of 'Block'. 656 const id = isNotNowLabelEnabled 657 ? "webrtc-action-not-now" 658 : "webrtc-action-block"; 659 actionL10nIds.push({ id }); 660 secondaryActions = [ 661 { 662 callback(aState) { 663 aActor.denyRequest(aRequest); 664 665 const isPersistent = aState?.checkboxChecked; 666 667 // Choosing 'Not now' will not set a block permission 668 // we just deny the request. This enables certain use cases 669 // where sites want to switch devices, but users back out of the permission request 670 // (See Bug 1609578). 671 // Selecting 'Remember this decision' and clicking 'Not now' will set a persistent block 672 if (!isPersistent && isNotNowLabelEnabled) { 673 return; 674 } 675 676 // Denying a camera / microphone prompt means we set a temporary or 677 // persistent permission block. There may still be active grace period 678 // permissions at this point. We need to remove them. 679 clearTemporaryGrants( 680 notification.browser, 681 reqVideoInput === "Camera", 682 !!reqAudioInput 683 ); 684 685 const scope = isPersistent 686 ? lazy.SitePermissions.SCOPE_PERSISTENT 687 : lazy.SitePermissions.SCOPE_TEMPORARY; 688 if (reqAudioInput) { 689 if (!isPersistent) { 690 // After a temporary block, having permissions.query() calls 691 // persistently report "granted" would be misleading 692 maybeClearAlwaysAsk( 693 principal, 694 "microphone", 695 notification.browser 696 ); 697 } 698 lazy.SitePermissions.setForPrincipal( 699 principal, 700 "microphone", 701 lazy.SitePermissions.BLOCK, 702 scope, 703 notification.browser 704 ); 705 } 706 if (reqVideoInput) { 707 if (!isPersistent && !sharingScreen) { 708 // After a temporary block, having permissions.query() calls 709 // persistently report "granted" would be misleading 710 maybeClearAlwaysAsk(principal, "camera", notification.browser); 711 } 712 lazy.SitePermissions.setForPrincipal( 713 principal, 714 sharingScreen ? "screen" : "camera", 715 lazy.SitePermissions.BLOCK, 716 scope, 717 notification.browser 718 ); 719 } 720 }, 721 }, 722 ]; 723 } 724 725 // The formatMessagesSync method returns an array of results 726 // for each message that was requested, and for the ones with 727 // attributes, returns an attributes array with objects like: 728 // { name: "label", value: "somevalue" } 729 const [mainMessage, ...secondaryMessages] = localization 730 .formatMessagesSync(actionL10nIds) 731 .map(msg => 732 msg.attributes.reduce( 733 (acc, { name, value }) => ({ ...acc, [name]: value }), 734 {} 735 ) 736 ); 737 738 const mainAction = { 739 label: mainMessage.label, 740 accessKey: mainMessage.accesskey, 741 // The real callback will be set during the "showing" event. The 742 // empty function here is so that PopupNotifications.show doesn't 743 // reject the action. 744 callback() {}, 745 }; 746 747 for (let i = 0; i < secondaryActions.length; ++i) { 748 secondaryActions[i].label = secondaryMessages[i].label; 749 secondaryActions[i].accessKey = secondaryMessages[i].accesskey; 750 } 751 752 let options = { 753 name: originToShow, 754 persistent: true, 755 hideClose: true, 756 eventCallback(aTopic, aNewBrowser, isCancel) { 757 if (aTopic == "swapping") { 758 return true; 759 } 760 761 let doc = this.browser.ownerDocument; 762 763 // Clean-up video streams of screensharing and camera previews. 764 if ( 765 (reqVideoInput != "Screen" && reqVideoInput != "Camera") || 766 aTopic == "dismissed" || 767 aTopic == "removed" 768 ) { 769 // Hide the webrtc preview section. 770 let webrtcPreviewSection = doc.getElementById("webRTC-preview-section"); 771 webrtcPreviewSection.hidden = true; 772 773 // Remove event listeners for camera and window selection menus. 774 for (const id of [ 775 "webRTC-selectWindow-menupopup", 776 "webRTC-selectCamera-menupopup", 777 ]) { 778 let menuPopup = doc.getElementById(id); 779 if (menuPopup?._commandEventListener) { 780 menuPopup.removeEventListener( 781 "command", 782 menuPopup._commandEventListener 783 ); 784 menuPopup._commandEventListener = null; 785 } 786 } 787 } 788 789 if (aTopic == "removed" && notification && isCancel) { 790 // The notification has been cancelled (e.g. due to entering 791 // full-screen). Also cancel the webRTC request. 792 aActor.denyRequest(aRequest); 793 } else if ( 794 aTopic == "shown" && 795 !notification.wasDismissed && 796 reqAudioOutput 797 ) { 798 let focusElement = 799 audioOutputDevices.length > 1 800 ? doc.getElementById("webRTC-selectSpeaker-richlistbox") // Focus the list on first show so that arrow keys select the speaker. 801 : doc.querySelector("button.popup-notification-primary-button"); // Or if the list is hidden (only 1 device), focus the primary button. 802 focusElement.focus(); 803 } 804 805 const isRequestingCamera = reqVideoInput === "Camera"; 806 807 if (aTopic == "shown") { 808 if (!notification.wasDismissed && isRequestingCamera) { 809 onCameraPromptShown(doc, isHandlingUserInput); 810 } 811 } 812 813 if (aTopic != "showing") { 814 return false; 815 } 816 817 // If BLOCK has been set persistently in the permission manager or has 818 // been set on the tab, then it is handled synchronously before we add 819 // the notification. 820 // Handling of ALLOW is delayed until the popupshowing event, 821 // to avoid granting permissions automatically to background tabs. 822 if (aActor.checkRequestAllowed(aRequest, principal, aBrowser)) { 823 this.remove(); 824 return true; 825 } 826 827 /** 828 * Prepare the device selector for one kind of device. 829 * 830 * @param {object[]} devices - available devices of this kind. 831 * @param {string} IDPrefix - indicating kind of device and so 832 * associated UI elements. 833 * @param {string[]} describedByIDs - an array to which might be 834 * appended ids of elements that describe the panel, for the caller to 835 * use in the aria-describedby attribute. 836 */ 837 function listDevices(devices, IDPrefix, describedByIDs) { 838 let labelID = `${IDPrefix}-single-device-label`; 839 let list; 840 let itemParent; 841 if (IDPrefix == "webRTC-selectSpeaker") { 842 list = doc.getElementById(`${IDPrefix}-richlistbox`); 843 itemParent = list; 844 } else { 845 itemParent = doc.getElementById(`${IDPrefix}-menupopup`); 846 list = itemParent.parentNode; // menulist 847 } 848 while (itemParent.lastChild) { 849 itemParent.removeChild(itemParent.lastChild); 850 } 851 852 // Removing the child nodes of a menupopup doesn't clear the value 853 // attribute of its menulist. Similary for richlistbox state. This can 854 // have unfortunate side effects when the list is rebuilt with a 855 // different content, so we set the selectedIndex explicitly to reset 856 // state. 857 let defaultIndex = 0; 858 859 for (let device of devices) { 860 let item = addDeviceToList(list, device.name, device.deviceIndex); 861 // Add deviceId property for camera devices so that we can preview them on selection. 862 if (IDPrefix == "webRTC-selectCamera") { 863 item.deviceId = device.rawId; 864 } 865 if (IDPrefix == "webRTC-selectSpeaker") { 866 item.addEventListener("dblclick", event => { 867 // Allow the chosen speakers via 868 // .popup-notification-primary-button so that 869 // "security.notification_enable_delay" is checked. 870 event.target.closest("popupnotification").button.doCommand(); 871 }); 872 if (device.id == aRequest.audioOutputId) { 873 defaultIndex = device.deviceIndex; 874 } 875 } 876 } 877 list.selectedIndex = defaultIndex; 878 879 let label = doc.getElementById(labelID); 880 if (devices.length == 1) { 881 describedByIDs.push(`${IDPrefix}-icon`, labelID); 882 label.value = devices[0].name; 883 label.hidden = false; 884 list.hidden = true; 885 } else { 886 label.hidden = true; 887 list.hidden = false; 888 } 889 } 890 891 let notificationElement = doc.getElementById( 892 "webRTC-shareDevices-notification" 893 ); 894 895 function checkDisabledWindowMenuItem() { 896 let list = doc.getElementById("webRTC-selectWindow-menulist"); 897 let item = list.selectedItem; 898 if (!item || item.hasAttribute("disabled")) { 899 notificationElement.setAttribute("invalidselection", "true"); 900 } else { 901 notificationElement.removeAttribute("invalidselection"); 902 } 903 } 904 905 function listScreenShareDevices(menupopup, devices) { 906 while (menupopup.lastChild) { 907 menupopup.removeChild(menupopup.lastChild); 908 } 909 910 // Removing the child nodes of the menupopup doesn't clear the value 911 // attribute of the menulist. This can have unfortunate side effects 912 // when the list is rebuilt with a different content, so we remove 913 // the value attribute and unset the selectedItem explicitly. 914 menupopup.parentNode.removeAttribute("value"); 915 menupopup.parentNode.selectedItem = null; 916 917 // "Select a Window or Screen" is the default because we can't and don't 918 // want to pick a 'default' window to share (Full screen is "scary"). 919 addDeviceToList( 920 menupopup.parentNode, 921 localization.formatValueSync("webrtc-pick-window-or-screen"), 922 "-1" 923 ); 924 menupopup.appendChild(doc.createXULElement("menuseparator")); 925 926 let isPipeWireDetected = false; 927 928 // Build the list of 'devices'. 929 let monitorIndex = 1; 930 for (let i = 0; i < devices.length; ++i) { 931 let device = devices[i]; 932 let type = device.mediaSource; 933 let name; 934 if (device.canRequestOsLevelPrompt) { 935 // When we share content by PipeWire add only one item to the device 936 // list. When it's selected PipeWire portal dialog is opened and 937 // user confirms actual window/screen sharing there. 938 // Don't mark it as scary as there's an extra confirmation step by 939 // PipeWire portal dialog. 940 941 isPipeWireDetected = true; 942 let item = addDeviceToList( 943 menupopup.parentNode, 944 localization.formatValueSync("webrtc-share-pipe-wire-portal"), 945 i, 946 type 947 ); 948 item.deviceId = device.rawId; 949 item.mediaSource = type; 950 951 // In this case the OS sharing dialog will be the only option and 952 // can be safely pre-selected. 953 menupopup.parentNode.selectedItem = item; 954 continue; 955 } else if (type == "screen") { 956 // Building screen list from available screens. 957 if (device.name == "Primary Monitor") { 958 name = localization.formatValueSync("webrtc-share-entire-screen"); 959 } else { 960 name = localization.formatValueSync("webrtc-share-monitor", { 961 monitorIndex, 962 }); 963 ++monitorIndex; 964 } 965 } else { 966 name = device.name; 967 968 if (type == "application") { 969 // The application names returned by the platform are of the form: 970 // <window count>\x1e<application name> 971 const [count, appName] = name.split("\x1e"); 972 name = localization.formatValueSync("webrtc-share-application", { 973 appName, 974 windowCount: parseInt(count), 975 }); 976 } 977 } 978 let item = addDeviceToList(menupopup.parentNode, name, i, type); 979 item.deviceId = device.rawId; 980 item.mediaSource = type; 981 if (device.scary) { 982 item.scary = true; 983 } 984 } 985 986 // Always re-select the "No <type>" item. 987 doc 988 .getElementById("webRTC-selectWindow-menulist") 989 .removeAttribute("value"); 990 doc.getElementById("webRTC-all-windows-shared").hidden = true; 991 992 const webrtcPreview = getOrCreateWebRTCPreviewEl(doc); 993 webrtcPreview.showPreviewControlButtons = false; 994 995 menupopup._commandEventListener = event => { 996 checkDisabledWindowMenuItem(); 997 998 const { deviceId, mediaSource, scary } = event.target; 999 if (deviceId == undefined) { 1000 webrtcPreviewSection.hidden = true; 1001 return; 1002 } 1003 // We have a valid device 1004 webrtcPreviewSection.hidden = false; 1005 1006 let warning = doc.getElementById("webRTC-previewWarning"); 1007 let warningBox = doc.getElementById("webRTC-previewWarningBox"); 1008 warningBox.hidden = !scary; 1009 if (scary) { 1010 const warnId = 1011 mediaSource == "screen" 1012 ? "webrtc-share-screen-warning" 1013 : "webrtc-share-browser-warning"; 1014 doc.l10n.setAttributes(warning, warnId); 1015 1016 // On Catalina, we don't want to blow our chance to show the 1017 // OS-level helper prompt to enable screen recording if the user 1018 // intends to reject anyway. OTOH showing it when they click Allow 1019 // is too late. A happy middle is to show it when the user makes a 1020 // choice in the picker. This already happens implicitly if the 1021 // user chooses "Entire desktop", as a side-effect of our preview, 1022 // we just need to also do it if they choose "Firefox". These are 1023 // the lone two options when permission is absent on Catalina. 1024 // Ironically, these are the two sources marked "scary" from a 1025 // web-sharing perspective, which is why this code resides here. 1026 // A restart doesn't appear to be necessary in spite of OS wording. 1027 let scrStatus = {}; 1028 lazy.OSPermissions.getScreenCapturePermissionState(scrStatus); 1029 if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) { 1030 lazy.OSPermissions.maybeRequestScreenCapturePermission(); 1031 } 1032 } 1033 1034 let perms = Services.perms; 1035 let chromePrincipal = 1036 Services.scriptSecurityManager.getSystemPrincipal(); 1037 perms.addFromPrincipal( 1038 chromePrincipal, 1039 "MediaManagerVideo", 1040 perms.ALLOW_ACTION, 1041 perms.EXPIRE_SESSION 1042 ); 1043 1044 // We don't have access to any screen content besides our browser tabs 1045 // on Wayland, therefore there are no previews we can show. 1046 if ( 1047 (!isPipeWireDetected || mediaSource == "browser") && 1048 Services.prefs.getBoolPref( 1049 "media.getdisplaymedia.previews.enabled", 1050 true 1051 ) 1052 ) { 1053 webrtcPreview.startPreview({ 1054 deviceId, 1055 mediaSource, 1056 showPreviewControlButtons: false, 1057 }); 1058 } 1059 }; 1060 menupopup.addEventListener("command", menupopup._commandEventListener); 1061 } 1062 1063 function addDeviceToList(list, deviceName, deviceIndex, type) { 1064 let item = list.appendItem(deviceName, deviceIndex); 1065 item.setAttribute("tooltiptext", deviceName); 1066 if (type) { 1067 item.setAttribute("devicetype", type); 1068 } 1069 1070 if (deviceIndex == "-1") { 1071 item.setAttribute("disabled", true); 1072 } 1073 1074 return item; 1075 } 1076 1077 doc.getElementById("webRTC-selectCamera").hidden = !isRequestingCamera; 1078 doc.getElementById("webRTC-selectWindowOrScreen").hidden = 1079 reqVideoInput !== "Screen"; 1080 doc.getElementById("webRTC-selectMicrophone").hidden = 1081 reqAudioInput !== "Microphone"; 1082 doc.getElementById("webRTC-selectSpeaker").hidden = !reqAudioOutput; 1083 1084 let describedByIDs = ["webRTC-shareDevices-notification-description"]; 1085 1086 // Hide the webrtc preview section. 1087 let webrtcPreviewSection = doc.getElementById("webRTC-preview-section"); 1088 webrtcPreviewSection.hidden = true; 1089 1090 if (sharingScreen) { 1091 let windowMenupopup = doc.getElementById( 1092 "webRTC-selectWindow-menupopup" 1093 ); 1094 listScreenShareDevices(windowMenupopup, videoInputDevices); 1095 checkDisabledWindowMenuItem(); 1096 } else { 1097 notificationElement.removeAttribute("invalidselection"); 1098 1099 // Make sure the screen share warning is hidden before showing the permission panel. 1100 let warningBox = doc.getElementById("webRTC-previewWarningBox"); 1101 warningBox.hidden = true; 1102 1103 if (isRequestingCamera) { 1104 listDevices(videoInputDevices, "webRTC-selectCamera", describedByIDs); 1105 1106 let cameraMenuPopup = doc.getElementById( 1107 "webRTC-selectCamera-menupopup" 1108 ); 1109 1110 // Set up initial camera preview. 1111 let webrtcPreview = getOrCreateWebRTCPreviewEl(doc); 1112 webrtcPreview.showPreviewControlButtons = true; 1113 // Apply the initial selection. 1114 webrtcPreview.deviceId = 1115 cameraMenuPopup.querySelector("[selected]")?.deviceId; 1116 webrtcPreview.mediaSource = "camera"; 1117 1118 // Show the preview section. 1119 webrtcPreviewSection.hidden = false; 1120 1121 if (cameraMenuPopup) { 1122 cameraMenuPopup._commandEventListener = event => { 1123 let { deviceId } = event.target; 1124 if (deviceId == undefined) { 1125 webrtcPreviewSection.hidden = true; 1126 return; 1127 } 1128 // Start preview on selection change. 1129 webrtcPreview.startPreview({ deviceId, mediaSource: "camera" }); 1130 }; 1131 cameraMenuPopup.addEventListener( 1132 "command", 1133 cameraMenuPopup._commandEventListener 1134 ); 1135 } 1136 } 1137 } 1138 if (!sharingAudio) { 1139 listDevices( 1140 audioInputDevices, 1141 "webRTC-selectMicrophone", 1142 describedByIDs 1143 ); 1144 } 1145 listDevices(audioOutputDevices, "webRTC-selectSpeaker", describedByIDs); 1146 1147 // PopupNotifications knows to clear the aria-describedby attribute 1148 // when hiding, so we don't have to worry about cleaning it up ourselves. 1149 chromeDoc.defaultView.PopupNotifications.panel.setAttribute( 1150 "aria-describedby", 1151 describedByIDs.join(" ") 1152 ); 1153 1154 this.mainAction.callback = async function (aState) { 1155 let remember = false; 1156 let silenceNotifications = false; 1157 1158 if (notificationSilencingEnabled && sharingScreen) { 1159 silenceNotifications = aState && aState.checkboxChecked; 1160 } else { 1161 remember = aState && aState.checkboxChecked; 1162 } 1163 1164 let allowedDevices = []; 1165 let perms = Services.perms; 1166 if (reqVideoInput) { 1167 let listId = sharingScreen 1168 ? "webRTC-selectWindow-menulist" 1169 : "webRTC-selectCamera-menulist"; 1170 let videoDeviceIndex = doc.getElementById(listId).value; 1171 let allowVideoDevice = videoDeviceIndex != "-1"; 1172 if (allowVideoDevice) { 1173 allowedDevices.push(videoDeviceIndex); 1174 // Session permission will be removed after use 1175 // (it's really one-shot, not for the entire session) 1176 perms.addFromPrincipal( 1177 principal, 1178 "MediaManagerVideo", 1179 perms.ALLOW_ACTION, 1180 perms.EXPIRE_SESSION 1181 ); 1182 let { mediaSource, rawId } = videoInputDevices.find( 1183 ({ deviceIndex }) => deviceIndex == videoDeviceIndex 1184 ); 1185 aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId); 1186 if (!sharingScreen) { 1187 persistGrantOrPromptPermission(principal, "camera", remember); 1188 } 1189 } 1190 } 1191 1192 if (reqAudioInput === "Microphone") { 1193 let audioDeviceIndex = doc.getElementById( 1194 "webRTC-selectMicrophone-menulist" 1195 ).value; 1196 let allowMic = audioDeviceIndex != "-1"; 1197 if (allowMic) { 1198 allowedDevices.push(audioDeviceIndex); 1199 let { mediaSource, rawId } = audioInputDevices.find( 1200 ({ deviceIndex }) => deviceIndex == audioDeviceIndex 1201 ); 1202 aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId); 1203 persistGrantOrPromptPermission(principal, "microphone", remember); 1204 } 1205 } else if (reqAudioInput === "AudioCapture") { 1206 // Only one device possible for audio capture. 1207 allowedDevices.push(0); 1208 } 1209 1210 if (reqAudioOutput) { 1211 let audioDeviceIndex = doc.getElementById( 1212 "webRTC-selectSpeaker-richlistbox" 1213 ).value; 1214 let allowSpeaker = audioDeviceIndex != "-1"; 1215 if (allowSpeaker) { 1216 allowedDevices.push(audioDeviceIndex); 1217 let { id } = audioOutputDevices.find( 1218 ({ deviceIndex }) => deviceIndex == audioDeviceIndex 1219 ); 1220 lazy.SitePermissions.setForPrincipal( 1221 principal, 1222 ["speaker", id].join("^"), 1223 lazy.SitePermissions.ALLOW 1224 ); 1225 } 1226 } 1227 1228 if (!allowedDevices.length) { 1229 aActor.denyRequest(aRequest); 1230 return; 1231 } 1232 1233 const camNeeded = reqVideoInput === "Camera"; 1234 const micNeeded = !!reqAudioInput; 1235 const scrNeeded = reqVideoInput === "Screen"; 1236 const havePermission = await aActor.checkOSPermission( 1237 camNeeded, 1238 micNeeded, 1239 scrNeeded 1240 ); 1241 if (!havePermission) { 1242 aActor.denyRequestNoPermission(aRequest); 1243 return; 1244 } 1245 1246 aActor.sendAsyncMessage("webrtc:Allow", { 1247 callID: aRequest.callID, 1248 windowID: aRequest.windowID, 1249 devices: allowedDevices, 1250 suppressNotifications: silenceNotifications, 1251 }); 1252 }; 1253 1254 // If we haven't handled the permission yet, we want to show the doorhanger. 1255 return false; 1256 }, 1257 queue: true, 1258 }; 1259 1260 function shouldShowAlwaysRemember() { 1261 // Don't offer "always remember" action in PB mode 1262 if (lazy.PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) { 1263 return false; 1264 } 1265 1266 // Don't offer "always remember" action in maybe unsafe permission 1267 // delegation 1268 if (aRequest.secondOrigin) { 1269 return false; 1270 } 1271 1272 // Speaker grants are always remembered, so no checkbox is required. 1273 if (reqAudioOutput) { 1274 return false; 1275 } 1276 1277 return true; 1278 } 1279 1280 function getRememberCheckboxLabel() { 1281 if (reqVideoInput == "Camera") { 1282 if (reqAudioInput == "Microphone") { 1283 return "webrtc-remember-allow-checkbox-camera-and-microphone"; 1284 } 1285 return "webrtc-remember-allow-checkbox-camera"; 1286 } 1287 1288 if (reqAudioInput == "Microphone") { 1289 return "webrtc-remember-allow-checkbox-microphone"; 1290 } 1291 1292 return "webrtc-remember-allow-checkbox"; 1293 } 1294 1295 if (shouldShowAlwaysRemember()) { 1296 // Disable the permanent 'Allow' action if the connection isn't secure, or for 1297 // screen/audio sharing (because we can't guess which window the user wants to 1298 // share without prompting). Note that we never enter this block for private 1299 // browsing windows. 1300 let reason = ""; 1301 if (sharingScreen) { 1302 reason = "webrtc-reason-for-no-permanent-allow-screen"; 1303 } else if (sharingAudio) { 1304 reason = "webrtc-reason-for-no-permanent-allow-audio"; 1305 } else if (!aRequest.secure) { 1306 reason = "webrtc-reason-for-no-permanent-allow-insecure"; 1307 } 1308 1309 options.checkbox = { 1310 label: localization.formatValueSync(getRememberCheckboxLabel()), 1311 checked: principal.isAddonOrExpandedAddonPrincipal, 1312 checkedState: reason 1313 ? { 1314 disableMainAction: true, 1315 warningLabel: localization.formatValueSync(reason), 1316 } 1317 : undefined, 1318 }; 1319 } 1320 1321 // If the notification silencing feature is enabled and we're sharing a 1322 // screen, then the checkbox for the permission panel is what controls 1323 // notification silencing. 1324 if (notificationSilencingEnabled && sharingScreen) { 1325 options.checkbox = { 1326 label: localization.formatValueSync("webrtc-mute-notifications-checkbox"), 1327 checked: false, 1328 checkedState: { 1329 disableMainAction: false, 1330 }, 1331 }; 1332 } 1333 1334 let anchorId = "webRTC-shareDevices-notification-icon"; 1335 if (reqVideoInput === "Screen") { 1336 anchorId = "webRTC-shareScreen-notification-icon"; 1337 } else if (!reqVideoInput) { 1338 if (reqAudioInput && !reqAudioOutput) { 1339 anchorId = "webRTC-shareMicrophone-notification-icon"; 1340 } else if (!reqAudioInput && reqAudioOutput) { 1341 anchorId = "webRTC-shareSpeaker-notification-icon"; 1342 } 1343 } 1344 1345 if (aRequest.secondOrigin) { 1346 options.secondName = lazy.webrtcUI.getHostOrExtensionName( 1347 null, 1348 aRequest.secondOrigin 1349 ); 1350 } 1351 1352 notification = chromeDoc.defaultView.PopupNotifications.show( 1353 aBrowser, 1354 "webRTC-shareDevices", 1355 message, 1356 anchorId, 1357 mainAction, 1358 secondaryActions, 1359 options 1360 ); 1361 notification.callID = aRequest.callID; 1362 } 1363 1364 /** 1365 * @param {"Screen" | "Camera" | null} reqVideoInput 1366 * @param {"AudioCapture" | "Microphone" | null} reqAudioInput 1367 * @param {boolean} reqAudioOutput 1368 * @param {boolean} delegation - Is the access delegated to a third party? 1369 * @param {boolean} isFile - Is the request coming from a file? 1370 * @returns {string} Localization message identifier 1371 */ 1372 function getPromptMessageId( 1373 reqVideoInput, 1374 reqAudioInput, 1375 reqAudioOutput, 1376 delegation, 1377 isFile 1378 ) { 1379 switch (reqVideoInput) { 1380 case "Camera": 1381 switch (reqAudioInput) { 1382 case "Microphone": 1383 if (isFile) { 1384 return "webrtc-allow-share-camera-and-microphone-with-file"; 1385 } 1386 if (delegation) { 1387 return "webrtc-allow-share-camera-and-microphone-unsafe-delegation"; 1388 } 1389 return "webrtc-allow-share-camera-and-microphone"; 1390 case "AudioCapture": 1391 if (isFile) { 1392 return "webrtc-allow-share-camera-and-audio-capture-with-file"; 1393 } 1394 if (delegation) { 1395 return "webrtc-allow-share-camera-and-audio-capture-unsafe-delegation"; 1396 } 1397 return "webrtc-allow-share-camera-and-audio-capture"; 1398 default: 1399 if (isFile) { 1400 return "webrtc-allow-share-camera-with-file"; 1401 } 1402 if (delegation) { 1403 return "webrtc-allow-share-camera-unsafe-delegation"; 1404 } 1405 return "webrtc-allow-share-camera"; 1406 } 1407 1408 case "Screen": 1409 switch (reqAudioInput) { 1410 case "Microphone": 1411 if (isFile) { 1412 return "webrtc-allow-share-screen-and-microphone-with-file"; 1413 } 1414 if (delegation) { 1415 return "webrtc-allow-share-screen-and-microphone-unsafe-delegation"; 1416 } 1417 return "webrtc-allow-share-screen-and-microphone"; 1418 case "AudioCapture": 1419 if (isFile) { 1420 return "webrtc-allow-share-screen-and-audio-capture-with-file"; 1421 } 1422 if (delegation) { 1423 return "webrtc-allow-share-screen-and-audio-capture-unsafe-delegation"; 1424 } 1425 return "webrtc-allow-share-screen-and-audio-capture"; 1426 default: 1427 if (isFile) { 1428 return "webrtc-allow-share-screen-with-file"; 1429 } 1430 if (delegation) { 1431 return "webrtc-allow-share-screen-unsafe-delegation"; 1432 } 1433 return "webrtc-allow-share-screen"; 1434 } 1435 1436 default: 1437 switch (reqAudioInput) { 1438 case "Microphone": 1439 if (isFile) { 1440 return "webrtc-allow-share-microphone-with-file"; 1441 } 1442 if (delegation) { 1443 return "webrtc-allow-share-microphone-unsafe-delegation"; 1444 } 1445 return "webrtc-allow-share-microphone"; 1446 case "AudioCapture": 1447 if (isFile) { 1448 return "webrtc-allow-share-audio-capture-with-file"; 1449 } 1450 if (delegation) { 1451 return "webrtc-allow-share-audio-capture-unsafe-delegation"; 1452 } 1453 return "webrtc-allow-share-audio-capture"; 1454 default: 1455 // This should be always true, if we've reached this far. 1456 if (reqAudioOutput) { 1457 if (isFile) { 1458 return "webrtc-allow-share-speaker-with-file"; 1459 } 1460 if (delegation) { 1461 return "webrtc-allow-share-speaker-unsafe-delegation"; 1462 } 1463 return "webrtc-allow-share-speaker"; 1464 } 1465 return undefined; 1466 } 1467 } 1468 } 1469 1470 /** 1471 * Checks whether we have a microphone/camera in use by checking the activePerms map 1472 * or if we have an allow permission for a microphone/camera in sitePermissions 1473 * 1474 * @param {Browser} browser - Browser to find all active and allowed microphone and camera devices for 1475 * @return true if one of the above conditions is met 1476 */ 1477 function allowedOrActiveCameraOrMicrophone(browser) { 1478 // Do we have an allow permission for cam/mic in the permissions manager? 1479 if ( 1480 lazy.SitePermissions.getAllForBrowser(browser).some(perm => { 1481 return ( 1482 perm.state == lazy.SitePermissions.ALLOW && 1483 (perm.id.startsWith("camera") || perm.id.startsWith("microphone")) 1484 ); 1485 }) 1486 ) { 1487 // Return early, no need to check for active devices 1488 return true; 1489 } 1490 1491 // Do we have an active device? 1492 return ( 1493 // Find all windowIDs that belong to our browsing contexts 1494 browser.browsingContext 1495 .getAllBrowsingContextsInSubtree() 1496 // Only keep the outerWindowIds 1497 .map(bc => bc.currentWindowGlobal?.outerWindowId) 1498 .filter(id => id != null) 1499 // We have an active device if one of our windowIds has a non empty map in the activePerms map 1500 // that includes one device of type "camera" or "microphone" 1501 .some(id => { 1502 let map = lazy.webrtcUI.activePerms.get(id); 1503 if (!map) { 1504 // This windowId has no active device 1505 return false; 1506 } 1507 // Let's see if one of the devices is a camera or a microphone 1508 let types = [...map.values()]; 1509 return types.includes("microphone") || types.includes("camera"); 1510 }) 1511 ); 1512 } 1513 1514 function removePrompt(aBrowser, aCallId) { 1515 let chromeWin = aBrowser.ownerGlobal; 1516 let notification = chromeWin.PopupNotifications.getNotification( 1517 "webRTC-shareDevices", 1518 aBrowser 1519 ); 1520 if (notification && notification.callID == aCallId) { 1521 notification.remove(); 1522 } 1523 } 1524 1525 /** 1526 * Clears temporary permission grants used for WebRTC device grace periods. 1527 * 1528 * @param browser - Browser element to clear permissions for. 1529 * @param {boolean} clearCamera - Clear camera grants. 1530 * @param {boolean} clearMicrophone - Clear microphone grants. 1531 */ 1532 function clearTemporaryGrants(browser, clearCamera, clearMicrophone) { 1533 if (!clearCamera && !clearMicrophone) { 1534 // Nothing to clear. 1535 return; 1536 } 1537 let perms = lazy.SitePermissions.getAllForBrowser(browser); 1538 perms 1539 .filter(perm => { 1540 let [id, key] = perm.id.split(lazy.SitePermissions.PERM_KEY_DELIMITER); 1541 // We only want to clear WebRTC grace periods. These are temporary, device 1542 // specifc (double-keyed) microphone or camera permissions. 1543 return ( 1544 key && 1545 perm.state == lazy.SitePermissions.ALLOW && 1546 perm.scope == lazy.SitePermissions.SCOPE_TEMPORARY && 1547 ((clearCamera && id == "camera") || 1548 (clearMicrophone && id == "microphone")) 1549 ); 1550 }) 1551 .forEach(perm => 1552 lazy.SitePermissions.removeFromPrincipal(null, perm.id, browser) 1553 ); 1554 } 1555 1556 /** 1557 * Persist an ALLOW state if the remember option is true. 1558 * Otherwise, persist PROMPT so that we can later tell the site 1559 * that permission was granted once before. 1560 * This makes Firefox seem much more like Chrome to sites that 1561 * expect a one-off, persistent permission grant for cam/mic. 1562 * 1563 * @param principal - Principal to add permission to. 1564 * @param {string} permissionName - name of permission. 1565 * @param remember - whether the grant should be persisted. 1566 */ 1567 function persistGrantOrPromptPermission(principal, permissionName, remember) { 1568 // There are cases like unsafe delegation where a prompt appears 1569 // even in ALLOW state, so make sure to not overwrite it (there's 1570 // no remember checkbox in those cases) 1571 if ( 1572 lazy.SitePermissions.getForPrincipal(principal, permissionName).state == 1573 lazy.SitePermissions.ALLOW 1574 ) { 1575 return; 1576 } 1577 1578 lazy.SitePermissions.setForPrincipal( 1579 principal, 1580 permissionName, 1581 remember ? lazy.SitePermissions.ALLOW : lazy.SitePermissions.PROMPT 1582 ); 1583 } 1584 1585 /** 1586 * Clears any persisted PROMPT (aka Always Ask) permission. 1587 * 1588 * @param principal - Principal to remove permission from. 1589 * @param {string} permissionName - name of permission. 1590 * @param browser - Browser element to clear permission for. 1591 */ 1592 function maybeClearAlwaysAsk(principal, permissionName, browser) { 1593 // For the "Always Ask" user choice, only persisted PROMPT is used, 1594 // so no need to scan through temporary permissions. 1595 if ( 1596 lazy.SitePermissions.getForPrincipal(principal, permissionName).state == 1597 lazy.SitePermissions.PROMPT 1598 ) { 1599 lazy.SitePermissions.removeFromPrincipal( 1600 principal, 1601 permissionName, 1602 browser 1603 ); 1604 } 1605 } 1606 1607 /** 1608 * Helper for lazily creating the webrtc-preview element. 1609 * 1610 * @param {Document} chromeDoc - The chrome document to create the webrtc-preview element in. 1611 * @returns {HTMLElement} The webrtc-preview element which has been inserted into the DOM. 1612 */ 1613 function getOrCreateWebRTCPreviewEl(chromeDoc) { 1614 let previewSection = chromeDoc.getElementById("webRTC-preview-section"); 1615 let previewEl = previewSection.querySelector("#webRTC-preview"); 1616 if (!previewEl) { 1617 previewEl = chromeDoc.createElement("webrtc-preview"); 1618 previewEl.id = "webRTC-preview"; 1619 previewSection.insertBefore(previewEl, previewSection.firstChild); 1620 } 1621 return previewEl; 1622 } 1623 1624 /** 1625 * On prompt "shown", if a camera permission request was made as the result of 1626 * user interaction start the camera preview automatically. 1627 * While websites don't have access to the camera preview, giving websites the 1628 * ability to turn on the users camera without any user interaction can be 1629 * scary. If there is no user input we offer the user to start the preview 1630 * manually. 1631 * 1632 * @param {Document} doc - The chrome document containing the prompt. 1633 * @param {boolean} isHandlingUserInput - Whether the prompt is shown as a 1634 * result of user interaction. 1635 */ 1636 function onCameraPromptShown(doc, isHandlingUserInput) { 1637 // Skip if the request was made without user input. 1638 if (!isHandlingUserInput) { 1639 return; 1640 } 1641 1642 // Skip if the entire preview section is hidden. 1643 if (doc.getElementById("webRTC-preview-section").hidden) { 1644 return; 1645 } 1646 1647 // Skip if no device is selected. 1648 let cameraMenuPopup = doc.getElementById("webRTC-selectCamera-menupopup"); 1649 let deviceId = cameraMenuPopup?.querySelector("[selected]")?.deviceId; 1650 if (!deviceId) { 1651 return; 1652 } 1653 1654 let webrtcPreview = doc.getElementById("webRTC-preview"); 1655 // Pass deviceId and mediaSource to make sure they're up to date, 1656 // matching the user selection. 1657 webrtcPreview?.startPreview({ deviceId, mediaSource: "camera" }); 1658 } 1659 1660 function isSidebar(browser) { 1661 const sidebarBrowser = 1662 browser.browsingContext?.topChromeWindow?.SidebarController?.browser; 1663 if (!sidebarBrowser) { 1664 return false; 1665 } 1666 1667 const nestedBrowsers = 1668 sidebarBrowser.contentDocument.querySelectorAll("browser"); 1669 return Array.from(nestedBrowsers).some(b => b === browser); 1670 }