diff options
Diffstat (limited to 'browser/components/preferences/dialogs/sitePermissions.js')
-rw-r--r-- | browser/components/preferences/dialogs/sitePermissions.js | 679 |
1 files changed, 679 insertions, 0 deletions
diff --git a/browser/components/preferences/dialogs/sitePermissions.js b/browser/components/preferences/dialogs/sitePermissions.js new file mode 100644 index 0000000000..e23706a01d --- /dev/null +++ b/browser/components/preferences/dialogs/sitePermissions.js @@ -0,0 +1,679 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../extensionControlled.js */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { SitePermissions } = ChromeUtils.importESModule( + "resource:///modules/SitePermissions.sys.mjs" +); + +const sitePermissionsL10n = { + "desktop-notification": { + window: "permissions-site-notification-window2", + description: "permissions-site-notification-desc", + disableLabel: "permissions-site-notification-disable-label", + disableDescription: "permissions-site-notification-disable-desc", + }, + geo: { + window: "permissions-site-location-window2", + description: "permissions-site-location-desc", + disableLabel: "permissions-site-location-disable-label", + disableDescription: "permissions-site-location-disable-desc", + }, + xr: { + window: "permissions-site-xr-window2", + description: "permissions-site-xr-desc", + disableLabel: "permissions-site-xr-disable-label", + disableDescription: "permissions-site-xr-disable-desc", + }, + camera: { + window: "permissions-site-camera-window2", + description: "permissions-site-camera-desc", + disableLabel: "permissions-site-camera-disable-label", + disableDescription: "permissions-site-camera-disable-desc", + }, + microphone: { + window: "permissions-site-microphone-window2", + description: "permissions-site-microphone-desc", + disableLabel: "permissions-site-microphone-disable-label", + disableDescription: "permissions-site-microphone-disable-desc", + }, + speaker: { + window: "permissions-site-speaker-window", + description: "permissions-site-speaker-desc", + }, + "autoplay-media": { + window: "permissions-site-autoplay-window2", + description: "permissions-site-autoplay-desc", + }, +}; + +const sitePermissionsConfig = { + "autoplay-media": { + _getCapabilityString(capability) { + switch (capability) { + case SitePermissions.ALLOW: + return "permissions-capabilities-autoplay-allow"; + case SitePermissions.BLOCK: + return "permissions-capabilities-autoplay-block"; + case SitePermissions.AUTOPLAY_BLOCKED_ALL: + return "permissions-capabilities-autoplay-blockall"; + } + throw new Error(`Unknown capability: ${capability}`); + }, + }, +}; + +// A set of permissions for a single origin. One PermissionGroup instance +// corresponds to one row in the gSitePermissionsManager._list richlistbox. +// Permissions may be single or double keyed, but the primary key of all +// permissions matches the permission type of the dialog. +class PermissionGroup { + #changedCapability; + + constructor(perm) { + this.principal = perm.principal; + this.origin = perm.principal.origin; + this.perms = [perm]; + } + addPermission(perm) { + this.perms.push(perm); + } + removePermission(perm) { + this.perms = this.perms.filter(p => p.type != perm.type); + } + set capability(cap) { + this.#changedCapability = cap; + } + get capability() { + if (this.#changedCapability) { + return this.#changedCapability; + } + return this.savedCapability; + } + revert() { + this.#changedCapability = null; + } + get savedCapability() { + // This logic to present a single capability for permissions of different + // keys and capabilities caters for speaker-selection, where a block + // permission may be set for all devices with no second key, which would + // override any device-specific double-keyed allow permissions. + let cap; + for (let perm of this.perms) { + let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER); + if (type == perm.type) { + // No second key. This overrides double-keyed perms. + return perm.capability; + } + // Double-keyed perms are not expected to have different capabilities. + cap = perm.capability; + } + return cap; + } +} + +const PERMISSION_STATES = [ + SitePermissions.ALLOW, + SitePermissions.BLOCK, + SitePermissions.PROMPT, + SitePermissions.AUTOPLAY_BLOCKED_ALL, +]; + +const NOTIFICATIONS_PERMISSION_OVERRIDE_KEY = "webNotificationsDisabled"; +const NOTIFICATIONS_PERMISSION_PREF = + "permissions.default.desktop-notification"; + +const AUTOPLAY_PREF = "media.autoplay.default"; + +var gSitePermissionsManager = { + _type: "", + _isObserving: false, + _permissionGroups: new Map(), + _permissionsToChange: new Map(), + _permissionsToDelete: new Map(), + _list: null, + _removeButton: null, + _removeAllButton: null, + _searchBox: null, + _checkbox: null, + _currentDefaultPermissionsState: null, + _defaultPermissionStatePrefName: null, + + onLoad() { + let params = window.arguments[0]; + document.mozSubdialogReady = this.init(params); + }, + + async init(params) { + if (!this._isObserving) { + Services.obs.addObserver(this, "perm-changed"); + this._isObserving = true; + } + + document.addEventListener("dialogaccept", () => this.onApplyChanges()); + + this._type = params.permissionType; + this._list = document.getElementById("permissionsBox"); + this._removeButton = document.getElementById("removePermission"); + this._removeAllButton = document.getElementById("removeAllPermissions"); + this._searchBox = document.getElementById("searchBox"); + this._checkbox = document.getElementById("permissionsDisableCheckbox"); + this._disableExtensionButton = document.getElementById( + "disableNotificationsPermissionExtension" + ); + this._permissionsDisableDescription = document.getElementById( + "permissionsDisableDescription" + ); + this._setAutoplayPref = document.getElementById("setAutoplayPref"); + + let permissionsText = document.getElementById("permissionsText"); + + document.l10n.pauseObserving(); + let l10n = sitePermissionsL10n[this._type]; + document.l10n.setAttributes(permissionsText, l10n.description); + if (l10n.disableLabel) { + document.l10n.setAttributes(this._checkbox, l10n.disableLabel); + } + if (l10n.disableDescription) { + document.l10n.setAttributes( + this._permissionsDisableDescription, + l10n.disableDescription + ); + } + document.l10n.setAttributes(document.documentElement, l10n.window); + + await document.l10n.translateElements([ + permissionsText, + this._checkbox, + this._permissionsDisableDescription, + document.documentElement, + ]); + document.l10n.resumeObserving(); + + // Initialize the checkbox state and handle showing notification permission UI + // when it is disabled by an extension. + this._defaultPermissionStatePrefName = "permissions.default." + this._type; + this._watchPermissionPrefChange(); + + this._loadPermissions(); + this.buildPermissionsList(); + + if (params.permissionType == "autoplay-media") { + await this.buildAutoplayMenulist(); + this._setAutoplayPref.hidden = false; + } + + this._searchBox.focus(); + }, + + uninit() { + if (this._isObserving) { + Services.obs.removeObserver(this, "perm-changed"); + this._isObserving = false; + } + if (this._setAutoplayPref) { + this._setAutoplayPref.hidden = true; + } + }, + + observe(subject, topic, data) { + if (topic !== "perm-changed") { + return; + } + + let permission = subject.QueryInterface(Ci.nsIPermission); + let [type] = permission.type.split(SitePermissions.PERM_KEY_DELIMITER); + + // Ignore unrelated permission types and permissions with unknown states. + if ( + type !== this._type || + !PERMISSION_STATES.includes(permission.capability) + ) { + return; + } + + if (data == "added") { + this._addPermissionToList(permission); + } else { + let group = this._permissionGroups.get(permission.principal.origin); + if (!group) { + // already moved to _permissionsToDelete + // or private browsing session permission + return; + } + if (data == "changed") { + group.removePermission(permission); + group.addPermission(permission); + } else if (data == "deleted") { + group.removePermission(permission); + if (!group.perms.length) { + this._removePermissionFromList(permission.principal.origin); + return; + } + } + } + this.buildPermissionsList(); + }, + + _handleCheckboxUIUpdates() { + let pref = Services.prefs.getPrefType(this._defaultPermissionStatePrefName); + if (pref != Services.prefs.PREF_INVALID) { + this._currentDefaultPermissionsState = Services.prefs.getIntPref( + this._defaultPermissionStatePrefName + ); + } + + if (this._currentDefaultPermissionsState === null) { + this._checkbox.hidden = true; + this._permissionsDisableDescription.hidden = true; + } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) { + this._checkbox.checked = true; + } else { + this._checkbox.checked = false; + } + + if (Services.prefs.prefIsLocked(this._defaultPermissionStatePrefName)) { + this._checkbox.disabled = true; + } + }, + + /** + * Listen for changes to the permissions.default.* pref and make + * necessary changes to the UI. + */ + _watchPermissionPrefChange() { + this._handleCheckboxUIUpdates(); + + if (this._type == "desktop-notification") { + this._handleWebNotificationsDisable(); + + this._disableExtensionButton.addEventListener( + "command", + makeDisableControllingExtension( + PREF_SETTING_TYPE, + NOTIFICATIONS_PERMISSION_OVERRIDE_KEY + ) + ); + } + + let observer = () => { + this._handleCheckboxUIUpdates(); + if (this._type == "desktop-notification") { + this._handleWebNotificationsDisable(); + } + }; + Services.prefs.addObserver(this._defaultPermissionStatePrefName, observer); + window.addEventListener("unload", () => { + Services.prefs.removeObserver( + this._defaultPermissionStatePrefName, + observer + ); + }); + }, + + /** + * Handles the UI update for web notifications disable by extensions. + */ + async _handleWebNotificationsDisable() { + let prefLocked = Services.prefs.prefIsLocked(NOTIFICATIONS_PERMISSION_PREF); + if (prefLocked) { + // An extension can't control these settings if they're locked. + hideControllingExtension(NOTIFICATIONS_PERMISSION_OVERRIDE_KEY); + } else { + let isControlled = await handleControllingExtension( + PREF_SETTING_TYPE, + NOTIFICATIONS_PERMISSION_OVERRIDE_KEY + ); + this._checkbox.disabled = isControlled; + } + }, + + _getCapabilityL10nId(element, type, capability) { + if ( + type in sitePermissionsConfig && + sitePermissionsConfig[type]._getCapabilityString + ) { + return sitePermissionsConfig[type]._getCapabilityString(capability); + } + switch (element.tagName) { + case "menuitem": + switch (capability) { + case Services.perms.ALLOW_ACTION: + return "permissions-capabilities-allow"; + case Services.perms.DENY_ACTION: + return "permissions-capabilities-block"; + case Services.perms.PROMPT_ACTION: + return "permissions-capabilities-prompt"; + default: + throw new Error(`Unknown capability: ${capability}`); + } + case "label": + switch (capability) { + case Services.perms.ALLOW_ACTION: + return "permissions-capabilities-listitem-allow"; + case Services.perms.DENY_ACTION: + return "permissions-capabilities-listitem-block"; + default: + throw new Error(`Unexpected capability: ${capability}`); + } + default: + throw new Error(`Unexpected tag: ${element.tagName}`); + } + }, + + _addPermissionToList(perm) { + let [type] = perm.type.split(SitePermissions.PERM_KEY_DELIMITER); + // Ignore unrelated permission types and permissions with unknown states. + if ( + type !== this._type || + !PERMISSION_STATES.includes(perm.capability) || + // Skip private browsing session permissions + (perm.principal.privateBrowsingId !== + Services.scriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID && + perm.expireType === Services.perms.EXPIRE_SESSION) + ) { + return; + } + let group = this._permissionGroups.get(perm.principal.origin); + if (group) { + group.addPermission(perm); + } else { + group = new PermissionGroup(perm); + this._permissionGroups.set(group.origin, group); + } + }, + + _removePermissionFromList(origin) { + this._permissionGroups.delete(origin); + this._permissionsToChange.delete(origin); + let permissionlistitem = document.getElementsByAttribute( + "origin", + origin + )[0]; + if (permissionlistitem) { + permissionlistitem.remove(); + } + }, + + _loadPermissions() { + // load permissions into a table. + for (let nextPermission of Services.perms.all) { + this._addPermissionToList(nextPermission); + } + }, + + _createPermissionListItem(permissionGroup) { + let richlistitem = document.createXULElement("richlistitem"); + richlistitem.setAttribute("origin", permissionGroup.origin); + let row = document.createXULElement("hbox"); + + let website = document.createXULElement("label"); + website.textContent = permissionGroup.origin; + website.className = "website-name"; + + let states = SitePermissions.getAvailableStates(this._type).filter( + state => state != SitePermissions.UNKNOWN + ); + // Handle the cases of a double-keyed ALLOW permission or a PROMPT + // permission after the default has been changed back to UNKNOWN. + if (!states.includes(permissionGroup.savedCapability)) { + states.unshift(permissionGroup.savedCapability); + } + let siteStatus; + if (states.length == 1) { + // Only a single state is available. Show a label. + siteStatus = document.createXULElement("label"); + document.l10n.setAttributes( + siteStatus, + this._getCapabilityL10nId( + siteStatus, + this._type, + permissionGroup.capability + ) + ); + } else { + // Multiple states are available. Show a menulist. + siteStatus = document.createXULElement("menulist"); + for (let state of states) { + let m = siteStatus.appendItem(undefined, state); + document.l10n.setAttributes( + m, + this._getCapabilityL10nId(m, this._type, state) + ); + } + siteStatus.addEventListener("select", () => { + this.onPermissionChange(permissionGroup, Number(siteStatus.value)); + }); + } + siteStatus.className = "website-status"; + siteStatus.value = permissionGroup.capability; + + row.appendChild(website); + row.appendChild(siteStatus); + richlistitem.appendChild(row); + return richlistitem; + }, + + onPermissionKeyPress(event) { + if (!this._list.selectedItem) { + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DELETE || + (AppConstants.platform == "macosx" && + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE) + ) { + this.onPermissionDelete(); + event.preventDefault(); + } + }, + + _setRemoveButtonState() { + if (!this._list) { + return; + } + + let hasSelection = this._list.selectedIndex >= 0; + let hasRows = this._list.itemCount > 0; + this._removeButton.disabled = !hasSelection; + this._removeAllButton.disabled = !hasRows; + }, + + onPermissionDelete() { + let richlistitem = this._list.selectedItem; + let origin = richlistitem.getAttribute("origin"); + let permissionGroup = this._permissionGroups.get(origin); + + this._removePermissionFromList(origin); + this._permissionsToDelete.set(permissionGroup.origin, permissionGroup); + + this._setRemoveButtonState(); + }, + + onAllPermissionsDelete() { + for (let permissionGroup of this._permissionGroups.values()) { + this._removePermissionFromList(permissionGroup.origin); + this._permissionsToDelete.set(permissionGroup.origin, permissionGroup); + } + + this._setRemoveButtonState(); + }, + + onPermissionSelect() { + this._setRemoveButtonState(); + }, + + onPermissionChange(perm, capability) { + let group = this._permissionGroups.get(perm.origin); + if (group.capability == capability) { + return; + } + if (capability == group.savedCapability) { + group.revert(); + this._permissionsToChange.delete(group.origin); + } else { + group.capability = capability; + this._permissionsToChange.set(group.origin, group); + } + + // enable "remove all" button as needed + this._setRemoveButtonState(); + }, + + onApplyChanges() { + // Stop observing permission changes since we are about + // to write out the pending adds/deletes and don't need + // to update the UI + this.uninit(); + + // Delete even _permissionsToChange to clear out double-keyed permissions + for (let group of [ + ...this._permissionsToDelete.values(), + ...this._permissionsToChange.values(), + ]) { + for (let perm of group.perms) { + SitePermissions.removeFromPrincipal(perm.principal, perm.type); + } + } + + for (let group of this._permissionsToChange.values()) { + SitePermissions.setForPrincipal( + group.principal, + this._type, + group.capability + ); + } + + if (this._checkbox.checked) { + Services.prefs.setIntPref( + this._defaultPermissionStatePrefName, + SitePermissions.BLOCK + ); + } else if (this._currentDefaultPermissionsState == SitePermissions.BLOCK) { + Services.prefs.setIntPref( + this._defaultPermissionStatePrefName, + SitePermissions.UNKNOWN + ); + } + }, + + buildPermissionsList(sortCol) { + // Clear old entries. + let oldItems = this._list.querySelectorAll("richlistitem"); + for (let item of oldItems) { + item.remove(); + } + let frag = document.createDocumentFragment(); + + let permissionGroups = Array.from(this._permissionGroups.values()); + + let keyword = this._searchBox.value.toLowerCase().trim(); + for (let permissionGroup of permissionGroups) { + if (keyword && !permissionGroup.origin.includes(keyword)) { + continue; + } + + let richlistitem = this._createPermissionListItem(permissionGroup); + frag.appendChild(richlistitem); + } + + // Sort permissions. + this._sortPermissions(this._list, frag, sortCol); + + this._list.appendChild(frag); + + this._setRemoveButtonState(); + }, + + async buildAutoplayMenulist() { + let menulist = document.createXULElement("menulist"); + let states = SitePermissions.getAvailableStates("autoplay-media"); + document.l10n.pauseObserving(); + for (let state of states) { + let m = menulist.appendItem(undefined, state); + document.l10n.setAttributes( + m, + this._getCapabilityL10nId(m, "autoplay-media", state) + ); + } + + menulist.value = SitePermissions.getDefault("autoplay-media"); + + menulist.addEventListener("select", () => { + SitePermissions.setDefault("autoplay-media", Number(menulist.value)); + }); + + menulist.menupopup.setAttribute("incontentshell", "false"); + + menulist.disabled = Services.prefs.prefIsLocked(AUTOPLAY_PREF); + + document.getElementById("setAutoplayPref").appendChild(menulist); + await document.l10n.translateFragment(menulist); + document.l10n.resumeObserving(); + }, + + _sortPermissions(list, frag, column) { + let sortDirection; + + if (!column) { + column = document.querySelector("treecol[data-isCurrentSortCol=true]"); + sortDirection = + column.getAttribute("data-last-sortDirection") || "ascending"; + } else { + sortDirection = column.getAttribute("data-last-sortDirection"); + sortDirection = + sortDirection === "ascending" ? "descending" : "ascending"; + } + + let sortFunc = null; + switch (column.id) { + case "siteCol": + sortFunc = (a, b) => { + return comp.compare( + a.getAttribute("origin"), + b.getAttribute("origin") + ); + }; + break; + + case "statusCol": + sortFunc = (a, b) => { + return ( + parseInt(a.querySelector(".website-status").value) > + parseInt(b.querySelector(".website-status").value) + ); + }; + break; + } + + let comp = new Services.intl.Collator(undefined, { + usage: "sort", + }); + + let items = Array.from(frag.querySelectorAll("richlistitem")); + + if (sortDirection === "descending") { + items.sort((a, b) => sortFunc(b, a)); + } else { + items.sort(sortFunc); + } + + // Re-append items in the correct order: + items.forEach(item => frag.appendChild(item)); + + let cols = list.previousElementSibling.querySelectorAll("treecol"); + cols.forEach(c => { + c.removeAttribute("data-isCurrentSortCol"); + c.removeAttribute("sortDirection"); + }); + column.setAttribute("data-isCurrentSortCol", "true"); + column.setAttribute("sortDirection", sortDirection); + column.setAttribute("data-last-sortDirection", sortDirection); + }, +}; |