webrtcIndicator.js (18763B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const { XPCOMUtils } = ChromeUtils.importESModule( 6 "resource://gre/modules/XPCOMUtils.sys.mjs" 7 ); 8 const { AppConstants } = ChromeUtils.importESModule( 9 "resource://gre/modules/AppConstants.sys.mjs" 10 ); 11 const { showStreamSharingMenu, webrtcUI } = ChromeUtils.importESModule( 12 "resource:///modules/webrtcUI.sys.mjs" 13 ); 14 15 XPCOMUtils.defineLazyServiceGetter( 16 this, 17 "gScreenManager", 18 "@mozilla.org/gfx/screenmanager;1", 19 Ci.nsIScreenManager 20 ); 21 22 /** 23 * Public function called by webrtcUI to update the indicator 24 * display when the active streams change. 25 */ 26 function updateIndicatorState() { 27 WebRTCIndicator.updateIndicatorState(); 28 } 29 30 /** 31 * Public function called by webrtcUI to indicate that webrtcUI 32 * is about to close the indicator. This is so that we can differentiate 33 * between closes that are caused by webrtcUI, and closes that are 34 * caused by other reasons (like the user closing the window via the 35 * OS window controls somehow). 36 * 37 * If the window is closed without having called this method first, the 38 * indicator will ask webrtcUI to shutdown any remaining streams and then 39 * select and focus the most recent browser tab that a stream was shared 40 * with. 41 */ 42 function closingInternally() { 43 WebRTCIndicator.closingInternally(); 44 } 45 46 /** 47 * Main control object for the WebRTC global indicator 48 */ 49 const WebRTCIndicator = { 50 init() { 51 addEventListener("load", this); 52 addEventListener("unload", this); 53 54 // If the user customizes the position of the indicator, we will 55 // not try to re-center it on the primary display after indicator 56 // state updates. 57 this.positionCustomized = false; 58 59 this.updatingIndicatorState = false; 60 this.loaded = false; 61 this.isClosingInternally = false; 62 63 this.statusBar = null; 64 this.statusBarMenus = new Set(); 65 66 this.showGlobalMuteToggles = Services.prefs.getBoolPref( 67 "privacy.webrtc.globalMuteToggles", 68 false 69 ); 70 71 this.hideGlobalIndicator = 72 Services.prefs.getBoolPref("privacy.webrtc.hideGlobalIndicator", false) || 73 Services.appinfo.isWayland; 74 75 if (this.hideGlobalIndicator) { 76 this.setVisibility(false); 77 } 78 }, 79 80 /** 81 * Controls the visibility of the global indicator. Also sets the value of 82 * a "visible" attribute on the document element to "true" or "false". 83 * 84 * @param isVisible (boolean) 85 * Whether or not the global indicator should be visible. 86 */ 87 setVisibility(isVisible) { 88 let baseWin = window.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow); 89 baseWin.visibility = isVisible; 90 // AppWindow::GetVisibility _always_ returns true (see 91 // https://bugzilla.mozilla.org/show_bug.cgi?id=306245), so we'll set an 92 // attribute on the document to make it easier for tests to know that the 93 // indicator is not visible. 94 document.documentElement.setAttribute("visible", isVisible); 95 }, 96 97 /** 98 * Exposed externally so that webrtcUI can alert the indicator to 99 * update itself when sharing states have changed. 100 */ 101 updateIndicatorState() { 102 // It's possible that we were called externally before the indicator 103 // finished loading. If so, then bail out - we're going to call 104 // updateIndicatorState ourselves automatically once the load 105 // event fires. 106 if (!this.loaded) { 107 return; 108 } 109 110 // We've started to update the indicator state. We set this flag so 111 // that the MozUpdateWindowPos event handler doesn't interpret indicator 112 // state updates as window movement caused by the user. 113 this.updatingIndicatorState = true; 114 115 let showCameraIndicator = webrtcUI.showCameraIndicator; 116 let showMicrophoneIndicator = webrtcUI.showMicrophoneIndicator; 117 let showScreenSharingIndicator = webrtcUI.showScreenSharingIndicator; 118 if (this.statusBar) { 119 let statusMenus = new Map([ 120 ["Camera", showCameraIndicator], 121 ["Microphone", showMicrophoneIndicator], 122 ["Screen", showScreenSharingIndicator], 123 ]); 124 125 for (let [name, shouldShow] of statusMenus) { 126 let menu = document.getElementById(`webRTC-sharing${name}-menu`); 127 if (shouldShow && !this.statusBarMenus.has(menu)) { 128 this.statusBar.addItem(menu); 129 this.statusBarMenus.add(menu); 130 } else if (!shouldShow && this.statusBarMenus.has(menu)) { 131 this.statusBar.removeItem(menu); 132 this.statusBarMenus.delete(menu); 133 } 134 } 135 } 136 137 if (!this.showGlobalMuteToggles && !webrtcUI.showScreenSharingIndicator) { 138 this.setVisibility(false); 139 } else if (!this.hideGlobalIndicator) { 140 this.setVisibility(true); 141 } 142 143 if (this.showGlobalMuteToggles) { 144 this.updateWindowAttr("sharingvideo", showCameraIndicator); 145 this.updateWindowAttr("sharingaudio", showMicrophoneIndicator); 146 } 147 148 let sharingScreen = showScreenSharingIndicator.startsWith("Screen"); 149 this.updateWindowAttr("sharingscreen", sharingScreen); 150 151 let sharingTab = showScreenSharingIndicator.startsWith("Browser"); 152 153 // We special-case sharing a window, because we want to have a slightly 154 // different UI if we're sharing a browser window. 155 let sharingWindow = showScreenSharingIndicator.startsWith("Window"); 156 this.updateWindowAttr("sharingwindow", sharingWindow); 157 158 let sharingBrowserWindow = false; 159 160 if (sharingWindow) { 161 // Get the active window streams and see if any of them are "scary". 162 // If so, then we're sharing a browser window. 163 let activeStreams = webrtcUI.getActiveStreams( 164 false /* camera */, 165 false /* microphone */, 166 false /* screen */, 167 false /* tab */, 168 true /* window */ 169 ); 170 sharingBrowserWindow = activeStreams.some(({ devices }) => 171 devices.some(({ scary }) => scary) 172 ); 173 } 174 this.updateWindowAttr("sharingtab", sharingTab || sharingBrowserWindow); 175 176 // The label that's displayed when sharing a display followed a priority. 177 // The more "risky" we deem the display is for sharing, the higher priority. 178 // This gives us the following priorities, from highest to lowest. 179 // 180 // 1. Screen 181 // 2. Browser window or tab 182 // 3. Other application window 183 // 184 // The CSS for the indicator does the work of showing or hiding these labels 185 // for us, but we need to update the aria-labelledby attribute on the container 186 // of those labels to make it clearer for screenreaders which one the user cares 187 // about. 188 let displayShare = document.getElementById("display-share"); 189 let labelledBy; 190 if (sharingScreen) { 191 labelledBy = "screen-share-info"; 192 } else if (sharingBrowserWindow || sharingTab) { 193 // TODO: Need to decide if we need different label for tab 194 labelledBy = "browser-window-share-info"; 195 } else if (sharingWindow) { 196 labelledBy = "window-share-info"; 197 } 198 displayShare.setAttribute("aria-labelledby", labelledBy); 199 200 if (window.windowState != window.STATE_MINIMIZED) { 201 // Resize and ensure the window position is correct 202 // (sizeToContent messes with our position). 203 let docElStyle = document.documentElement.style; 204 docElStyle.minWidth = docElStyle.maxWidth = "unset"; 205 docElStyle.minHeight = docElStyle.maxHeight = "unset"; 206 window.sizeToContent(); 207 208 // On Linux GTK, the style of window we're using by default is resizable. We 209 // workaround this by setting explicit limits on the height and width of the 210 // window. 211 if (AppConstants.platform == "linux") { 212 let { width, height } = window.windowUtils.getBoundsWithoutFlushing( 213 document.documentElement 214 ); 215 216 docElStyle.minWidth = docElStyle.maxWidth = `${width}px`; 217 docElStyle.minHeight = docElStyle.maxHeight = `${height}px`; 218 } 219 220 this.ensureOnScreen(); 221 222 if (!this.positionCustomized) { 223 this.centerOnLatestBrowser(); 224 } 225 } 226 227 this.updatingIndicatorState = false; 228 }, 229 230 /** 231 * After the indicator has been updated, checks to see if it has expanded 232 * such that part of the indicator is now outside of the screen. If so, 233 * it then adjusts the position to put the entire indicator on screen. 234 */ 235 ensureOnScreen() { 236 let desiredX = Math.max(window.screenX, screen.availLeft); 237 let maxX = 238 screen.availLeft + 239 screen.availWidth - 240 document.documentElement.clientWidth; 241 window.moveTo(Math.min(desiredX, maxX), window.screenY); 242 }, 243 244 /** 245 * If the indicator is first being opened, we'll find the browser window 246 * associated with the most recent share, and pin the indicator to the 247 * very top of the content area. 248 */ 249 centerOnLatestBrowser() { 250 let activeStreams = webrtcUI.getActiveStreams( 251 true /* camera */, 252 true /* microphone */, 253 true /* screen */, 254 true /* tab */, 255 true /* window */ 256 ); 257 258 if (!activeStreams.length) { 259 return; 260 } 261 262 let browser = activeStreams[activeStreams.length - 1].browser; 263 let browserWindow = browser.ownerGlobal; 264 let browserRect = 265 browserWindow.windowUtils.getBoundsWithoutFlushing(browser); 266 267 // This should be called in initialize right after we've just called 268 // updateIndicatorState. Since updateIndicatorState uses 269 // window.sizeToContent, the layout information should be up to date, 270 // and so the numbers that we get without flushing should be sufficient. 271 let { width: windowWidth } = window.windowUtils.getBoundsWithoutFlushing( 272 document.documentElement 273 ); 274 275 window.moveTo( 276 browserWindow.mozInnerScreenX + 277 browserRect.left + 278 (browserRect.width - windowWidth) / 2, 279 browserWindow.mozInnerScreenY + browserRect.top 280 ); 281 }, 282 283 handleEvent(event) { 284 switch (event.type) { 285 case "load": { 286 this.onLoad(); 287 break; 288 } 289 case "unload": { 290 this.onUnload(); 291 break; 292 } 293 case "click": { 294 this.onClick(event); 295 break; 296 } 297 case "change": { 298 this.onChange(event); 299 break; 300 } 301 case "MozUpdateWindowPos": { 302 if (!this.updatingIndicatorState) { 303 // The window moved while not updating the indicator state, 304 // so the user probably moved it. 305 this.positionCustomized = true; 306 } 307 break; 308 } 309 case "sizemodechange": { 310 if (window.windowState != window.STATE_MINIMIZED) { 311 this.updateIndicatorState(); 312 } 313 break; 314 } 315 case "popupshowing": { 316 this.onPopupShowing(event); 317 break; 318 } 319 case "popuphiding": { 320 this.onPopupHiding(event); 321 break; 322 } 323 case "command": { 324 this.onCommand(event); 325 break; 326 } 327 case "DOMWindowClose": 328 case "close": { 329 this.onClose(event); 330 break; 331 } 332 } 333 }, 334 335 onLoad() { 336 this.loaded = true; 337 338 if (AppConstants.platform == "macosx" || AppConstants.platform == "win") { 339 this.statusBar = Cc["@mozilla.org/widget/systemstatusbar;1"].getService( 340 Ci.nsISystemStatusBar 341 ); 342 } 343 344 this.updateIndicatorState(); 345 346 window.addEventListener("click", this); 347 window.addEventListener("change", this); 348 window.addEventListener("sizemodechange", this); 349 350 // There are two ways that the dialog can close - either via the 351 // .close() window method, or via the OS. We handle both of those 352 // cases here. 353 window.addEventListener("DOMWindowClose", this); 354 window.addEventListener("close", this); 355 356 if (this.statusBar) { 357 // We only want these events for the system status bar menus. 358 window.addEventListener("popupshowing", this); 359 window.addEventListener("popuphiding", this); 360 window.addEventListener("command", this); 361 } 362 363 window.windowRoot.addEventListener("MozUpdateWindowPos", this); 364 365 // Alert accessibility implementations stuff just changed. We only need to do 366 // this initially, because changes after this will automatically fire alert 367 // events if things change materially. 368 let ev = new CustomEvent("AlertActive", { 369 bubbles: true, 370 cancelable: true, 371 }); 372 document.documentElement.dispatchEvent(ev); 373 374 this.loaded = true; 375 }, 376 377 onClose(event) { 378 // This event is fired from when the indicator window tries to be closed. 379 // If we preventDefault() the event, we are able to cancel that close 380 // attempt. 381 // 382 // We want to do that if we're not showing the global mute toggles 383 // and we're still sharing a camera or a microphone so that we can 384 // keep the status bar indicators present (since those status bar 385 // indicators are bound to this window). 386 if ( 387 !this.showGlobalMuteToggles && 388 (webrtcUI.showCameraIndicator || webrtcUI.showMicrophoneIndicator) 389 ) { 390 event.preventDefault(); 391 this.setVisibility(false); 392 } 393 394 if (!this.isClosingInternally) { 395 // Something has tried to close the indicator, but it wasn't webrtcUI. 396 // This means we might still have some streams being shared. To protect 397 // the user from unknowingly sharing streams, we shut those streams 398 // down. 399 // 400 // This only includes the camera and microphone streams if the user 401 // has the global mute toggles enabled, since these toggles visually 402 // associate the indicator with those streams. 403 let activeStreams = webrtcUI.getActiveStreams( 404 this.showGlobalMuteToggles /* camera */, 405 this.showGlobalMuteToggles /* microphone */, 406 true /* screen */, 407 true /* tab */, 408 true /* window */ 409 ); 410 webrtcUI.stopSharingStreams( 411 activeStreams, 412 this.showGlobalMuteToggles /* camera */, 413 this.showGlobalMuteToggles /* microphone */, 414 true /* screen */, 415 true /* window */ 416 ); 417 } 418 }, 419 420 onUnload() { 421 Services.ppmm.sharedData.set("WebRTC:GlobalCameraMute", false); 422 Services.ppmm.sharedData.set("WebRTC:GlobalMicrophoneMute", false); 423 Services.ppmm.sharedData.flush(); 424 425 if (this.statusBar) { 426 for (let menu of this.statusBarMenus) { 427 this.statusBar.removeItem(menu); 428 } 429 } 430 }, 431 432 onClick(event) { 433 switch (event.target.id) { 434 case "stop-sharing": { 435 let activeStreams = webrtcUI.getActiveStreams( 436 false /* camera */, 437 false /* microphone */, 438 true /* screen */, 439 true /* tab */, 440 true /* window */ 441 ); 442 443 if (!activeStreams.length) { 444 return; 445 } 446 447 // getActiveStreams is filtering for streams that have screen 448 // sharing, but those streams might _also_ be sharing other 449 // devices like camera or microphone. This is why we need to 450 // tell stopSharingStreams explicitly which device type we want 451 // to stop. 452 webrtcUI.stopSharingStreams( 453 activeStreams, 454 false /* camera */, 455 false /* microphone */, 456 true /* screen */, 457 true /* window */ 458 ); 459 break; 460 } 461 case "minimize": { 462 window.minimize(); 463 break; 464 } 465 } 466 }, 467 468 onChange(event) { 469 switch (event.target.id) { 470 case "microphone-mute-toggle": { 471 this.toggleMicrophoneMute(event.target); 472 break; 473 } 474 case "camera-mute-toggle": { 475 this.toggleCameraMute(event.target); 476 break; 477 } 478 } 479 }, 480 481 onPopupShowing(event) { 482 if (this.eventIsForDeviceMenuPopup(event)) { 483 // When the indicator is hidden by default, opening the menu from the 484 // system tray _might_ cause the indicator to try to become visible again. 485 // We work around this by re-hiding it if it wasn't already visible. 486 if (document.documentElement.getAttribute("visible") != "true") { 487 let baseWin = window.docShell.treeOwner.QueryInterface( 488 Ci.nsIBaseWindow 489 ); 490 baseWin.visibility = false; 491 } 492 493 showStreamSharingMenu(window, event, true); 494 } 495 }, 496 497 onPopupHiding(event) { 498 if (!this.eventIsForDeviceMenuPopup(event)) { 499 return; 500 } 501 502 let menu = event.target; 503 while (menu.firstChild) { 504 menu.firstChild.remove(); 505 } 506 }, 507 508 onCommand(event) { 509 webrtcUI.showSharingDoorhanger(event.target.stream, event); 510 }, 511 512 /** 513 * Returns true if an event was fired for one of the shared device 514 * menupopups. 515 * 516 * @param event (Event) 517 * The event to check. 518 * @returns True if the event was for one of the shared device 519 * menupopups. 520 */ 521 eventIsForDeviceMenuPopup(event) { 522 let menupopup = event.target; 523 let type = menupopup.getAttribute("type"); 524 525 return ["Camera", "Microphone", "Screen"].includes(type); 526 }, 527 528 /** 529 * Mutes or unmutes the microphone globally based on the checked 530 * state of toggleEl. Also updates the tooltip of toggleEl once 531 * the state change is done. 532 * 533 * @param toggleEl (Element) 534 * The input[type="checkbox"] for toggling the microphone mute 535 * state. 536 */ 537 toggleMicrophoneMute(toggleEl) { 538 Services.ppmm.sharedData.set( 539 "WebRTC:GlobalMicrophoneMute", 540 toggleEl.checked 541 ); 542 Services.ppmm.sharedData.flush(); 543 let l10nId = toggleEl.checked 544 ? "webrtc-microphone-muted" 545 : "webrtc-microphone-unmuted"; 546 document.l10n.setAttributes(toggleEl, l10nId); 547 }, 548 549 /** 550 * Mutes or unmutes the camera globally based on the checked 551 * state of toggleEl. Also updates the tooltip of toggleEl once 552 * the state change is done. 553 * 554 * @param toggleEl (Element) 555 * The input[type="checkbox"] for toggling the camera mute 556 * state. 557 */ 558 toggleCameraMute(toggleEl) { 559 Services.ppmm.sharedData.set("WebRTC:GlobalCameraMute", toggleEl.checked); 560 Services.ppmm.sharedData.flush(); 561 let l10nId = toggleEl.checked 562 ? "webrtc-camera-muted" 563 : "webrtc-camera-unmuted"; 564 document.l10n.setAttributes(toggleEl, l10nId); 565 }, 566 567 /** 568 * Updates an attribute on the <window> element. 569 * 570 * @param attr (String) 571 * The name of the attribute to update. 572 * @param value (String?) 573 * A string to set the attribute to. If the value is false-y, 574 * the attribute is removed. 575 */ 576 updateWindowAttr(attr, value) { 577 let docEl = document.documentElement; 578 if (value) { 579 docEl.setAttribute(attr, "true"); 580 } else { 581 docEl.removeAttribute(attr); 582 } 583 }, 584 585 /** 586 * See the documentation on the script global closingInternally() function. 587 */ 588 closingInternally() { 589 this.isClosingInternally = true; 590 }, 591 }; 592 593 WebRTCIndicator.init();