sitePermissions.js (22902B)
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 /* import-globals-from ../extensionControlled.js */ 6 7 var { AppConstants } = ChromeUtils.importESModule( 8 "resource://gre/modules/AppConstants.sys.mjs" 9 ); 10 const { SitePermissions } = ChromeUtils.importESModule( 11 "resource:///modules/SitePermissions.sys.mjs" 12 ); 13 14 const sitePermissionsL10n = { 15 "desktop-notification": { 16 window: "permissions-site-notification-window2", 17 description: "permissions-site-notification-desc", 18 disableLabel: "permissions-site-notification-disable-label", 19 disableDescription: "permissions-site-notification-disable-desc", 20 }, 21 geo: { 22 window: "permissions-site-location-window2", 23 description: "permissions-site-location-desc", 24 disableLabel: "permissions-site-location-disable-label", 25 disableDescription: "permissions-site-location-disable-desc", 26 }, 27 xr: { 28 window: "permissions-site-xr-window2", 29 description: "permissions-site-xr-desc", 30 disableLabel: "permissions-site-xr-disable-label", 31 disableDescription: "permissions-site-xr-disable-desc", 32 }, 33 camera: { 34 window: "permissions-site-camera-window2", 35 description: "permissions-site-camera-desc", 36 disableLabel: "permissions-site-camera-disable-label", 37 disableDescription: "permissions-site-camera-disable-desc", 38 }, 39 microphone: { 40 window: "permissions-site-microphone-window2", 41 description: "permissions-site-microphone-desc", 42 disableLabel: "permissions-site-microphone-disable-label", 43 disableDescription: "permissions-site-microphone-disable-desc", 44 }, 45 speaker: { 46 window: "permissions-site-speaker-window", 47 description: "permissions-site-speaker-desc", 48 }, 49 "autoplay-media": { 50 window: "permissions-site-autoplay-window2", 51 description: "permissions-site-autoplay-desc", 52 }, 53 localhost: { 54 window: "permissions-site-localhost-window", 55 description: "permissions-site-localhost-desc", 56 disableLabel: "permissions-site-localhost-disable-label", 57 disableDescription: "permissions-site-localhost-disable-desc", 58 }, 59 "local-network": { 60 window: "permissions-site-local-network-window", 61 description: "permissions-site-local-network-desc", 62 disableLabel: "permissions-site-local-network-disable-label", 63 disableDescription: "permissions-site-local-network-disable-desc", 64 }, 65 }; 66 67 const sitePermissionsConfig = { 68 "autoplay-media": { 69 _getCapabilityString(capability) { 70 switch (capability) { 71 case SitePermissions.ALLOW: 72 return "permissions-capabilities-autoplay-allow"; 73 case SitePermissions.BLOCK: 74 return "permissions-capabilities-autoplay-block"; 75 case SitePermissions.AUTOPLAY_BLOCKED_ALL: 76 return "permissions-capabilities-autoplay-blockall"; 77 } 78 throw new Error(`Unknown capability: ${capability}`); 79 }, 80 }, 81 }; 82 83 // A set of permissions for a single origin. One PermissionGroup instance 84 // corresponds to one row in the gSitePermissionsManager._list richlistbox. 85 // Permissions may be single or double keyed, but the primary key of all 86 // permissions matches the permission type of the dialog. 87 class PermissionGroup { 88 #changedCapability; 89 90 constructor(perm) { 91 this.principal = perm.principal; 92 this.origin = perm.principal.origin; 93 this.perms = [perm]; 94 } 95 addPermission(perm) { 96 this.perms.push(perm); 97 } 98 removePermission(perm) { 99 this.perms = this.perms.filter(p => p.type != perm.type); 100 } 101 set capability(cap) { 102 this.#changedCapability = cap; 103 } 104 get capability() { 105 if (this.#changedCapability) { 106 return this.#changedCapability; 107 } 108 return this.savedCapability; 109 } 110 revert() { 111 this.#changedCapability = null; 112 } 113 get savedCapability() { 114 // This logic to present a single capability for permissions of different 115 // keys and capabilities caters for speaker-selection, where a block 116 // permission may be set for all devices with no second key, which would 117 // override any device-specific double-keyed allow permissions. 118 let cap; 119 for (let perm of this.perms) { 120 let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER); 121 if (type == perm.type) { 122 // No second key. This overrides double-keyed perms. 123 return perm.capability; 124 } 125 // Double-keyed perms are not expected to have different capabilities. 126 cap = perm.capability; 127 } 128 return cap; 129 } 130 } 131 132 const PERMISSION_STATES = [ 133 SitePermissions.ALLOW, 134 SitePermissions.BLOCK, 135 SitePermissions.PROMPT, 136 SitePermissions.AUTOPLAY_BLOCKED_ALL, 137 ]; 138 139 const NOTIFICATIONS_PERMISSION_OVERRIDE_KEY = "webNotificationsDisabled"; 140 const NOTIFICATIONS_PERMISSION_PREF = 141 "permissions.default.desktop-notification"; 142 143 const AUTOPLAY_PREF = "media.autoplay.default"; 144 145 var gSitePermissionsManager = { 146 _type: "", 147 _isObserving: false, 148 _permissionGroups: new Map(), 149 _permissionsToChange: new Map(), 150 _permissionsToDelete: new Map(), 151 _list: null, 152 _removeButton: null, 153 _removeAllButton: null, 154 _searchBox: null, 155 _checkbox: null, 156 _currentDefaultPermissionsState: null, 157 _defaultPermissionStatePrefName: null, 158 159 onLoad() { 160 let params = window.arguments[0]; 161 document.mozSubdialogReady = this.init(params); 162 }, 163 164 async init(params) { 165 if (!this._isObserving) { 166 Services.obs.addObserver(this, "perm-changed"); 167 this._isObserving = true; 168 } 169 170 document.addEventListener("command", this); 171 document.addEventListener("dialogaccept", this); 172 window.addEventListener("unload", this); 173 174 document 175 .getElementById("siteCol") 176 .addEventListener("click", event => 177 this.buildPermissionsList(event.target) 178 ); 179 document 180 .getElementById("statusCol") 181 .addEventListener("click", event => 182 this.buildPermissionsList(event.target) 183 ); 184 185 this._type = params.permissionType; 186 this._list = document.getElementById("permissionsBox"); 187 this._removeButton = document.getElementById("removePermission"); 188 this._removeAllButton = document.getElementById("removeAllPermissions"); 189 this._searchBox = document.getElementById("searchBox"); 190 this._searchBox.addEventListener("MozInputSearch:search", () => 191 this.buildPermissionsList() 192 ); 193 this._checkbox = document.getElementById("permissionsDisableCheckbox"); 194 this._disableExtensionButton = document.getElementById( 195 "disableNotificationsPermissionExtension" 196 ); 197 this._permissionsDisableDescription = document.getElementById( 198 "permissionsDisableDescription" 199 ); 200 this._setAutoplayPref = document.getElementById("setAutoplayPref"); 201 202 this._list.addEventListener("keypress", event => 203 this.onPermissionKeyPress(event) 204 ); 205 this._list.addEventListener("select", () => this.onPermissionSelect()); 206 207 let permissionsText = document.getElementById("permissionsText"); 208 209 document.l10n.pauseObserving(); 210 let l10n = sitePermissionsL10n[this._type]; 211 document.l10n.setAttributes(permissionsText, l10n.description); 212 if (l10n.disableLabel) { 213 document.l10n.setAttributes(this._checkbox, l10n.disableLabel); 214 } 215 if (l10n.disableDescription) { 216 document.l10n.setAttributes( 217 this._permissionsDisableDescription, 218 l10n.disableDescription 219 ); 220 } 221 document.l10n.setAttributes(document.documentElement, l10n.window); 222 223 await document.l10n.translateElements([ 224 permissionsText, 225 this._checkbox, 226 this._permissionsDisableDescription, 227 document.documentElement, 228 ]); 229 document.l10n.resumeObserving(); 230 231 // Initialize the checkbox state and handle showing notification permission UI 232 // when it is disabled by an extension. 233 this._defaultPermissionStatePrefName = "permissions.default." + this._type; 234 this._watchPermissionPrefChange(); 235 236 this._loadPermissions(); 237 this.buildPermissionsList(); 238 239 if (params.permissionType == "autoplay-media") { 240 await this.buildAutoplayMenulist(); 241 this._setAutoplayPref.hidden = false; 242 } 243 244 this._searchBox.focus(); 245 }, 246 247 uninit() { 248 if (this._isObserving) { 249 Services.obs.removeObserver(this, "perm-changed"); 250 this._isObserving = false; 251 } 252 if (this._setAutoplayPref) { 253 this._setAutoplayPref.hidden = true; 254 } 255 }, 256 257 observe(subject, topic, data) { 258 if (topic !== "perm-changed") { 259 return; 260 } 261 262 let permission = subject.QueryInterface(Ci.nsIPermission); 263 let [type] = permission.type.split(SitePermissions.PERM_KEY_DELIMITER); 264 265 // Ignore unrelated permission types and permissions with unknown states. 266 if ( 267 type !== this._type || 268 !PERMISSION_STATES.includes(permission.capability) 269 ) { 270 return; 271 } 272 273 if (data == "added") { 274 this._addPermissionToList(permission); 275 } else { 276 let group = this._permissionGroups.get(permission.principal.origin); 277 if (!group) { 278 // already moved to _permissionsToDelete 279 // or private browsing session permission 280 return; 281 } 282 if (data == "changed") { 283 group.removePermission(permission); 284 group.addPermission(permission); 285 } else if (data == "deleted") { 286 group.removePermission(permission); 287 if (!group.perms.length) { 288 this._removePermissionFromList(permission.principal.origin); 289 return; 290 } 291 } 292 } 293 this.buildPermissionsList(); 294 }, 295 296 handleEvent(event) { 297 switch (event.type) { 298 case "command": 299 switch (event.target.id) { 300 case "key_close": 301 window.close(); 302 break; 303 case "removePermission": 304 this.onPermissionDelete(); 305 break; 306 case "removeAllPermissions": 307 this.onAllPermissionsDelete(); 308 break; 309 } 310 break; 311 case "dialogaccept": 312 this.onApplyChanges(); 313 break; 314 case "unload": 315 this.uninit(); 316 break; 317 } 318 }, 319 320 _handleCheckboxUIUpdates() { 321 let pref = Services.prefs.getPrefType(this._defaultPermissionStatePrefName); 322 if (pref != Services.prefs.PREF_INVALID) { 323 this._currentDefaultPermissionsState = Services.prefs.getIntPref( 324 this._defaultPermissionStatePrefName 325 ); 326 } 327 328 if (this._currentDefaultPermissionsState === null) { 329 this._checkbox.hidden = true; 330 this._permissionsDisableDescription.hidden = true; 331 } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) { 332 this._checkbox.checked = true; 333 } else { 334 this._checkbox.checked = false; 335 } 336 337 if (Services.prefs.prefIsLocked(this._defaultPermissionStatePrefName)) { 338 this._checkbox.disabled = true; 339 } 340 }, 341 342 /** 343 * Listen for changes to the permissions.default.* pref and make 344 * necessary changes to the UI. 345 */ 346 _watchPermissionPrefChange() { 347 this._handleCheckboxUIUpdates(); 348 349 if (this._type == "desktop-notification") { 350 this._handleWebNotificationsDisable(); 351 352 this._disableExtensionButton.addEventListener( 353 "command", 354 makeDisableControllingExtension( 355 PREF_SETTING_TYPE, 356 NOTIFICATIONS_PERMISSION_OVERRIDE_KEY 357 ) 358 ); 359 } 360 361 let observer = () => { 362 this._handleCheckboxUIUpdates(); 363 if (this._type == "desktop-notification") { 364 this._handleWebNotificationsDisable(); 365 } 366 }; 367 Services.prefs.addObserver(this._defaultPermissionStatePrefName, observer); 368 window.addEventListener("unload", () => { 369 Services.prefs.removeObserver( 370 this._defaultPermissionStatePrefName, 371 observer 372 ); 373 }); 374 }, 375 376 /** 377 * Handles the UI update for web notifications disable by extensions. 378 */ 379 async _handleWebNotificationsDisable() { 380 let prefLocked = Services.prefs.prefIsLocked(NOTIFICATIONS_PERMISSION_PREF); 381 if (prefLocked) { 382 // An extension can't control these settings if they're locked. 383 hideControllingExtension(NOTIFICATIONS_PERMISSION_OVERRIDE_KEY); 384 } else { 385 let isControlled = await handleControllingExtension( 386 PREF_SETTING_TYPE, 387 NOTIFICATIONS_PERMISSION_OVERRIDE_KEY 388 ); 389 this._checkbox.disabled = isControlled; 390 } 391 }, 392 393 _getCapabilityL10nId(element, type, capability) { 394 if ( 395 type in sitePermissionsConfig && 396 sitePermissionsConfig[type]._getCapabilityString 397 ) { 398 return sitePermissionsConfig[type]._getCapabilityString(capability); 399 } 400 switch (element.tagName) { 401 case "menuitem": 402 switch (capability) { 403 case Services.perms.ALLOW_ACTION: 404 return "permissions-capabilities-allow"; 405 case Services.perms.DENY_ACTION: 406 return "permissions-capabilities-block"; 407 case Services.perms.PROMPT_ACTION: 408 return "permissions-capabilities-prompt"; 409 default: 410 throw new Error(`Unknown capability: ${capability}`); 411 } 412 case "label": 413 switch (capability) { 414 case Services.perms.ALLOW_ACTION: 415 return "permissions-capabilities-listitem-allow"; 416 case Services.perms.DENY_ACTION: 417 return "permissions-capabilities-listitem-block"; 418 default: 419 throw new Error(`Unexpected capability: ${capability}`); 420 } 421 default: 422 throw new Error(`Unexpected tag: ${element.tagName}`); 423 } 424 }, 425 426 _addPermissionToList(perm) { 427 let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER); 428 // Ignore unrelated permission types and permissions with unknown states. 429 if ( 430 type !== this._type || 431 !PERMISSION_STATES.includes(perm.capability) || 432 // Skip private browsing session permissions 433 (perm.principal.privateBrowsingId !== 434 Services.scriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID && 435 perm.expireType === Services.perms.EXPIRE_SESSION) 436 ) { 437 return; 438 } 439 let group = this._permissionGroups.get(perm.principal.origin); 440 if (group) { 441 group.addPermission(perm); 442 } else { 443 group = new PermissionGroup(perm); 444 this._permissionGroups.set(group.origin, group); 445 } 446 }, 447 448 _removePermissionFromList(origin) { 449 this._permissionGroups.delete(origin); 450 this._permissionsToChange.delete(origin); 451 let permissionlistitem = document.getElementsByAttribute( 452 "origin", 453 origin 454 )[0]; 455 if (permissionlistitem) { 456 permissionlistitem.remove(); 457 } 458 }, 459 460 _loadPermissions() { 461 // load permissions into a table. 462 for (let nextPermission of Services.perms.all) { 463 this._addPermissionToList(nextPermission); 464 } 465 }, 466 467 _createPermissionListItem(permissionGroup) { 468 let richlistitem = document.createXULElement("richlistitem"); 469 richlistitem.setAttribute("origin", permissionGroup.origin); 470 let row = document.createXULElement("hbox"); 471 472 let website = document.createXULElement("label"); 473 website.textContent = permissionGroup.origin; 474 website.className = "website-name"; 475 476 let states = SitePermissions.getAvailableStates(this._type).filter( 477 state => state != SitePermissions.UNKNOWN 478 ); 479 // Handle the cases of a double-keyed ALLOW permission or a PROMPT 480 // permission after the default has been changed back to UNKNOWN. 481 if (!states.includes(permissionGroup.savedCapability)) { 482 states.unshift(permissionGroup.savedCapability); 483 } 484 let siteStatus; 485 if (states.length == 1) { 486 // Only a single state is available. Show a label. 487 siteStatus = document.createXULElement("label"); 488 document.l10n.setAttributes( 489 siteStatus, 490 this._getCapabilityL10nId( 491 siteStatus, 492 this._type, 493 permissionGroup.capability 494 ) 495 ); 496 } else { 497 // Multiple states are available. Show a menulist. 498 siteStatus = document.createXULElement("menulist"); 499 for (let state of states) { 500 let m = siteStatus.appendItem(undefined, state); 501 document.l10n.setAttributes( 502 m, 503 this._getCapabilityL10nId(m, this._type, state) 504 ); 505 } 506 siteStatus.addEventListener("select", () => { 507 this.onPermissionChange(permissionGroup, Number(siteStatus.value)); 508 }); 509 } 510 siteStatus.className = "website-status"; 511 siteStatus.value = permissionGroup.capability; 512 513 row.appendChild(website); 514 row.appendChild(siteStatus); 515 richlistitem.appendChild(row); 516 return richlistitem; 517 }, 518 519 onPermissionKeyPress(event) { 520 if (!this._list.selectedItem) { 521 return; 522 } 523 524 if ( 525 event.keyCode == KeyEvent.DOM_VK_DELETE || 526 (AppConstants.platform == "macosx" && 527 event.keyCode == KeyEvent.DOM_VK_BACK_SPACE) 528 ) { 529 this.onPermissionDelete(); 530 event.preventDefault(); 531 } 532 }, 533 534 _setRemoveButtonState() { 535 if (!this._list) { 536 return; 537 } 538 539 let hasSelection = this._list.selectedIndex >= 0; 540 let hasRows = this._list.itemCount > 0; 541 this._removeButton.disabled = !hasSelection; 542 this._removeAllButton.disabled = !hasRows; 543 }, 544 545 onPermissionDelete() { 546 let richlistitem = this._list.selectedItem; 547 let origin = richlistitem.getAttribute("origin"); 548 let permissionGroup = this._permissionGroups.get(origin); 549 550 this._removePermissionFromList(origin); 551 this._permissionsToDelete.set(permissionGroup.origin, permissionGroup); 552 553 this._setRemoveButtonState(); 554 }, 555 556 onAllPermissionsDelete() { 557 for (let permissionGroup of this._permissionGroups.values()) { 558 this._removePermissionFromList(permissionGroup.origin); 559 this._permissionsToDelete.set(permissionGroup.origin, permissionGroup); 560 } 561 562 this._setRemoveButtonState(); 563 }, 564 565 onPermissionSelect() { 566 this._setRemoveButtonState(); 567 }, 568 569 onPermissionChange(perm, capability) { 570 let group = this._permissionGroups.get(perm.origin); 571 if (group.capability == capability) { 572 return; 573 } 574 if (capability == group.savedCapability) { 575 group.revert(); 576 this._permissionsToChange.delete(group.origin); 577 } else { 578 group.capability = capability; 579 this._permissionsToChange.set(group.origin, group); 580 } 581 582 // enable "remove all" button as needed 583 this._setRemoveButtonState(); 584 }, 585 586 onApplyChanges() { 587 // Stop observing permission changes since we are about 588 // to write out the pending adds/deletes and don't need 589 // to update the UI 590 this.uninit(); 591 592 // Delete even _permissionsToChange to clear out double-keyed permissions 593 for (let group of [ 594 ...this._permissionsToDelete.values(), 595 ...this._permissionsToChange.values(), 596 ]) { 597 for (let perm of group.perms) { 598 SitePermissions.removeFromPrincipal(perm.principal, perm.type); 599 } 600 } 601 602 for (let group of this._permissionsToChange.values()) { 603 SitePermissions.setForPrincipal( 604 group.principal, 605 this._type, 606 group.capability 607 ); 608 } 609 610 if (this._checkbox.checked) { 611 Services.prefs.setIntPref( 612 this._defaultPermissionStatePrefName, 613 SitePermissions.BLOCK 614 ); 615 } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) { 616 Services.prefs.setIntPref( 617 this._defaultPermissionStatePrefName, 618 SitePermissions.UNKNOWN 619 ); 620 } 621 }, 622 623 buildPermissionsList(sortCol) { 624 // Clear old entries. 625 let oldItems = this._list.querySelectorAll("richlistitem"); 626 for (let item of oldItems) { 627 item.remove(); 628 } 629 let frag = document.createDocumentFragment(); 630 631 let permissionGroups = Array.from(this._permissionGroups.values()); 632 633 let keyword = this._searchBox.value.toLowerCase().trim(); 634 for (let permissionGroup of permissionGroups) { 635 if (keyword && !permissionGroup.origin.includes(keyword)) { 636 continue; 637 } 638 639 let richlistitem = this._createPermissionListItem(permissionGroup); 640 frag.appendChild(richlistitem); 641 } 642 643 // Sort permissions. 644 this._sortPermissions(this._list, frag, sortCol); 645 646 this._list.appendChild(frag); 647 648 this._setRemoveButtonState(); 649 }, 650 651 async buildAutoplayMenulist() { 652 let menulist = document.createXULElement("menulist"); 653 let states = SitePermissions.getAvailableStates("autoplay-media"); 654 document.l10n.pauseObserving(); 655 for (let state of states) { 656 let m = menulist.appendItem(undefined, state); 657 document.l10n.setAttributes( 658 m, 659 this._getCapabilityL10nId(m, "autoplay-media", state) 660 ); 661 } 662 663 menulist.value = SitePermissions.getDefault("autoplay-media"); 664 665 menulist.addEventListener("select", () => { 666 SitePermissions.setDefault("autoplay-media", Number(menulist.value)); 667 }); 668 669 menulist.menupopup.setAttribute("incontentshell", "false"); 670 671 menulist.disabled = Services.prefs.prefIsLocked(AUTOPLAY_PREF); 672 673 document.getElementById("setAutoplayPref").appendChild(menulist); 674 await document.l10n.translateFragment(menulist); 675 document.l10n.resumeObserving(); 676 }, 677 678 _sortPermissions(list, frag, column) { 679 let sortDirection; 680 681 if (!column) { 682 column = document.querySelector("treecol[data-isCurrentSortCol=true]"); 683 sortDirection = 684 column.getAttribute("data-last-sortDirection") || "ascending"; 685 } else { 686 sortDirection = column.getAttribute("data-last-sortDirection"); 687 sortDirection = 688 sortDirection === "ascending" ? "descending" : "ascending"; 689 } 690 691 let sortFunc = null; 692 switch (column.id) { 693 case "siteCol": 694 sortFunc = (a, b) => { 695 return comp.compare( 696 a.getAttribute("origin"), 697 b.getAttribute("origin") 698 ); 699 }; 700 break; 701 702 case "statusCol": 703 sortFunc = (a, b) => { 704 return ( 705 parseInt(a.querySelector(".website-status").value) > 706 parseInt(b.querySelector(".website-status").value) 707 ); 708 }; 709 break; 710 } 711 712 let comp = new Services.intl.Collator(undefined, { 713 usage: "sort", 714 }); 715 716 let items = Array.from(frag.querySelectorAll("richlistitem")); 717 718 if (sortDirection === "descending") { 719 items.sort((a, b) => sortFunc(b, a)); 720 } else { 721 items.sort(sortFunc); 722 } 723 724 // Re-append items in the correct order: 725 items.forEach(item => frag.appendChild(item)); 726 727 let cols = list.previousElementSibling.querySelectorAll("treecol"); 728 cols.forEach(c => { 729 c.removeAttribute("data-isCurrentSortCol"); 730 c.removeAttribute("sortDirection"); 731 }); 732 column.setAttribute("data-isCurrentSortCol", "true"); 733 column.setAttribute("sortDirection", sortDirection); 734 column.setAttribute("data-last-sortDirection", sortDirection); 735 }, 736 }; 737 738 window.addEventListener("load", () => gSitePermissionsManager.onLoad());