/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const ADDONS_BUTTONS_HIDDEN = Services.prefs.getBoolPref( "zen.theme.hide-unified-extensions-button", true ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", }); export class nsZenSiteDataPanel { #iconMap = { install: "extension", "site-protection": "shield", "3rdPartyStorage": "cookie", }; constructor(window) { this.window = window; this.document = window.document; this.unifiedPanel = this.#initUnifiedPanel(); this.unifiedPanelView = "unified-extensions-view"; this.extensionsPanelView = "original-unified-extensions-view"; if (ADDONS_BUTTONS_HIDDEN) { this.window.gUnifiedExtensions._panel = this.unifiedPanel; // Remove the old permissions dialog this.document.getElementById("unified-extensions-panel-template")?.remove(); } else { this.extensionsPanel = this.#initExtensionsPanel(); } this.#init(); } #init() { // Add a new button to the urlbar popup const button = this.window.MozXULElement.parseXULToFragment(` `); this.anchor = button.querySelector("#zen-site-data-icon-button"); this.document.getElementById("identity-icon-box").before(button); this.extensionsPanelButton = this.document.getElementById("unified-extensions-button"); this.window.gUnifiedExtensions._button = ADDONS_BUTTONS_HIDDEN ? this.anchor : this.extensionsPanelButton; this.document .getElementById("nav-bar") .setAttribute("addon-webext-overflowbutton", "zen-site-data-icon-button"); this.#initCopyUrlButton(); this.#initEventListeners(); this.#initUnifiedExtensionsManageHook(); this.#maybeShowFeatureCallout(); } #initEventListeners() { this.unifiedPanel.addEventListener("popupshowing", this); this.document.getElementById("zen-site-data-manage-addons").addEventListener("click", this); this.document.getElementById("zen-site-data-settings-more").addEventListener("click", this); this.anchor.addEventListener("click", this); const kCommandIDs = [ "zen-site-data-header-share", "zen-site-data-header-bookmark", "zen-site-data-security-info", "zen-site-data-actions", "zen-site-data-new-addon-button", ]; for (let id of kCommandIDs) { this.document.getElementById(id).addEventListener("command", this); } this.#initContextMenuEventListener(); } #initCopyUrlButton() { // This function is a bit out of place, but it's related enough to the panel // that it's easier to do it here than in a separate module. const container = this.document.getElementById("page-action-buttons"); const fragment = this.window.MozXULElement.parseXULToFragment(` `); container.after(fragment); const aElement = this.document.getElementById("zen-copy-url-button"); aElement.addEventListener("click", () => { if (aElement.hasAttribute("disabled")) { return; } this.document.getElementById("cmd_zenCopyCurrentURL").doCommand(); }); this.window.gBrowser.addProgressListener({ onLocationChange: (aWebProgress, aRequest, aLocation) => { if (aWebProgress.isTopLevel) { const disabled = !this.#canCopyUrl(aLocation); if (disabled) { aElement.setAttribute("disabled", true); } else { aElement.removeAttribute("disabled"); } } }, }); } #initContextMenuEventListener() { const kCommands = { context_zenClearSiteData: (event) => { this.window.gIdentityHandler.clearSiteData(event); }, context_zenOpenGetAddons: () => { this.#openGetAddons(); }, context_zenOpenSiteSettings: () => { const { BrowserCommands } = this.window; BrowserCommands.pageInfo(null, "permTab"); }, }; for (let [id, handler] of Object.entries(kCommands)) { this.document.getElementById(id).addEventListener("command", handler); } } #initUnifiedExtensionsManageHook() { const manageExtensionItem = this.document.getElementById( "unified-extensions-context-menu-manage-extension" ); manageExtensionItem.addEventListener("command", () => { this.unifiedPanel.hidePopup(); }); } #initExtensionsPanel() { const panel = this.window.gUnifiedExtensions.panel; const extensionsView = panel?.querySelector("#unified-extensions-view"); extensionsView.setAttribute("id", this.extensionsPanelView); const panelMultiView = panel?.querySelector("panelmultiview"); panelMultiView.setAttribute("mainViewId", this.extensionsPanelView); return panel; } #initUnifiedPanel() { const panel = this.document.getElementById("zen-unified-site-data-panel"); this.window.gUnifiedExtensions.initializePanel(panel); return panel; } #preparePanel() { this.#setSitePermissions(); this.#setSiteSecurityInfo(); this.#setSiteHeader(); this.#setAddonsOverflow(); } #setAddonsOverflow() { const addons = this.document.getElementById("zen-site-data-addons"); if (addons.getBoundingClientRect().height > 420) { addons.setAttribute("overflowing", "true"); } else { addons.removeAttribute("overflowing"); } } get #currentPageIsBookmarked() { // A hacky way to check if the current page is bookmarked, but // it works for our purposes. return this.window.BookmarkingUI.star?.hasAttribute("starred"); } #setSiteHeader() { { const button = this.document.getElementById("zen-site-data-header-reader-mode"); const urlbarButton = this.window.document.getElementById("reader-mode-button"); const isActive = urlbarButton?.hasAttribute("readeractive"); const isVisible = !urlbarButton?.hidden || isActive; button.disabled = !isVisible; if (isActive) { button.classList.add("active"); } else { button.classList.remove("active"); } this.document.l10n.setAttributes(button, urlbarButton?.getAttribute("data-l10n-id")); } { const button = this.document.getElementById("zen-site-data-header-bookmark"); const isPageBookmarked = this.#currentPageIsBookmarked; if (isPageBookmarked) { button.classList.add("active"); } else { button.classList.remove("active"); } } { const button = this.document.getElementById("zen-site-data-header-share"); if (this.#canCopyUrl(this.window.gBrowser.currentURI)) { button.removeAttribute("disabled"); } else { button.setAttribute("disabled", "true"); } } } /** * Determines whether the copy URL button should be hidden for the given URI. * * @param {nsIURI} uri - The URI to check. * @returns {boolean} True if the button should be hidden, false otherwise. */ #canCopyUrl(uri) { if (!uri) { return false; } return uri.scheme.startsWith("http"); } #setSiteSecurityInfo() { const { gIdentityHandler } = this.window; const button = this.document.getElementById("zen-site-data-security-info"); if (gIdentityHandler._isSecureInternalUI) { button.parentNode.hidden = true; return; } let identity; if (gIdentityHandler._pageExtensionPolicy) { this.document.l10n.setAttributes(button, "zen-site-data-security-info-extension"); identity = "extension"; } else if ( gIdentityHandler._uriHasHost && gIdentityHandler._isSecureConnection && !gIdentityHandler._isCertUserOverridden && !gIdentityHandler._isCertErrorPage && !gIdentityHandler._isAboutHttpsOnlyErrorPage ) { this.document.l10n.setAttributes(button, "zen-site-data-security-info-secure"); identity = "secure"; } else { this.document.l10n.setAttributes(button, "zen-site-data-security-info-not-secure"); identity = "not-secure"; } button.parentNode.hidden = false; button.setAttribute("identity", identity); } #setSitePermissions() { const { gBrowser, SitePermissions } = this.window; const list = this.document.getElementById("zen-site-data-settings-list"); const section = list.closest(".zen-site-data-section"); // show permission icons let permissions = SitePermissions.getAllPermissionDetailsForBrowser(gBrowser.selectedBrowser); // Don't display origin-keyed 3rdPartyStorage permissions that are covered by // site-keyed 3rdPartyFrameStorage permissions. let thirdPartyStorageSites = new Set( permissions .map(function (permission) { let [id, key] = permission.id.split(SitePermissions.PERM_KEY_DELIMITER); if (id == "3rdPartyFrameStorage" || id == "3rdPartyStorage") { return key; } return null; }) .filter(function (key) { return key != null; }) ); permissions = permissions.filter(function (permission) { let [id, key] = permission.id.split(SitePermissions.PERM_KEY_DELIMITER); if (id != "3rdPartyStorage") { return true; } try { let origin = Services.io.newURI(key); let site = Services.eTLD.getSite(origin); return !thirdPartyStorageSites.has(site); } catch { return false; } }); this._sharingState = gBrowser.selectedTab._sharingState; if (this._sharingState?.geo) { let geoPermission = permissions.find((perm) => perm.id === "geo"); if (!geoPermission) { permissions.push({ id: "geo", state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_REQUEST, sharingState: true, }); } } if (this._sharingState?.xr) { let xrPermission = permissions.find((perm) => perm.id === "xr"); if (!xrPermission) { permissions.push({ id: "xr", state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_REQUEST, sharingState: true, }); } } if (this._sharingState?.webRTC) { let webrtcState = this._sharingState.webRTC; // If WebRTC device or screen are in use, we need to find // the associated ALLOW permission item to set the sharingState field. for (let id of ["camera", "microphone", "screen"]) { if (webrtcState[id]) { let found = false; for (let permission of permissions) { let [permId] = permission.id.split(SitePermissions.PERM_KEY_DELIMITER); if (permId != id || permission.state != SitePermissions.ALLOW) { continue; } found = true; } if (!found) { // If the ALLOW permission item we were looking for doesn't exist, // the user has temporarily allowed sharing and we need to add // an item in the permissions array to reflect this. permissions.push({ id, state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_REQUEST, sharingState: webrtcState[id], }); } } } } // Add site protection permissions if needed. const { gProtectionsHandler } = this.window; if ( gBrowser.currentURI.schemeIs("http") || gBrowser.currentURI.schemeIs("https") || gBrowser.currentURI.schemeIs("ftp") ) { permissions.push({ id: "site-protection", state: gProtectionsHandler.hasException ? SitePermissions.BLOCK : SitePermissions.ALLOW, scope: SitePermissions.SCOPE_PERSISTENT, }); } const separator = this.document.createXULElement("toolbarseparator"); list.innerHTML = ""; list.appendChild(separator); const settingElements = []; const crossSiteCookieElements = []; for (let permission of permissions) { let [id, key] = permission.id.split(SitePermissions.PERM_KEY_DELIMITER); if (id == "storage-access") { // Ignore storage access permissions here, they are made visible inside // the Content Blocking UI. continue; } if (permission.state == SitePermissions.PROMPT) { // We don't display "ask" permissions in the site data panel. continue; } let [item, isCrossSiteCookie] = this.#createPermissionItem(id, key, permission); if (item) { if (isCrossSiteCookie) { crossSiteCookieElements.push(item); } else { settingElements.push(item); } } } for (let elem of settingElements) { separator.before(elem); } for (let elem of crossSiteCookieElements) { separator.after(elem); } separator.hidden = !settingElements.length || !crossSiteCookieElements.length; section.hidden = list.childElementCount < 2; // only the separator } #getPermissionStateLabelId(permission) { const { SitePermissions } = this.window; switch (permission.state) { // There should only be these types being displayed in the panel. case SitePermissions.ALLOW: if (permission.id === "site-protection") { return "zen-site-data-protections-enabled"; } return "zen-site-data-setting-allow"; case SitePermissions.BLOCK: case SitePermissions.AUTOPLAY_BLOCKED_ALL: if (permission.id === "site-protection") { return "zen-site-data-protections-disabled"; } return "zen-site-data-setting-block"; default: return null; } } #createPermissionItem(id, key, permission) { const { SitePermissions } = this.window; const isCrossSiteCookie = id === "3rdPartyStorage"; // Create a permission item for the site data panel. let container = this.document.createXULElement("hbox"); const idNoSuffix = permission.id; container.classList.add( "permission-popup-permission-item", `permission-popup-permission-item-${idNoSuffix}` ); container.setAttribute("align", "center"); container.setAttribute("role", "group"); container.setAttribute("state", permission.state == SitePermissions.ALLOW ? "allow" : "block"); let img = this.document.createXULElement("toolbarbutton"); img.classList.add("permission-popup-permission-icon", "zen-site-data-permission-icon"); img.setAttribute("closemenu", "none"); if (this.#iconMap[id]) { img.classList.add(`zen-permission-${this.#iconMap[id]}-icon`); } let labelContainer = this.document.createXULElement("vbox"); labelContainer.setAttribute("flex", "1"); labelContainer.setAttribute("align", "start"); labelContainer.classList.add("permission-popup-permission-label-container"); labelContainer._permission = permission; let nameLabel = this.document.createXULElement("label"); nameLabel.setAttribute("flex", "1"); nameLabel.setAttribute("class", "permission-popup-permission-label"); if (isCrossSiteCookie) { this.document.l10n.setAttributes(nameLabel, "zen-site-data-setting-cross-site"); } else { let label = SitePermissions.getPermissionLabel(permission.id); if (label) { nameLabel.textContent = label; } else { this.document.l10n.setAttributes(nameLabel, "zen-site-data-setting-" + idNoSuffix); } } labelContainer.appendChild(nameLabel); let stateLabel = this.document.createXULElement("label"); stateLabel.setAttribute("class", "zen-permission-popup-permission-state-label"); if (isCrossSiteCookie) { // The key should be the site for cross-site cookies. stateLabel.textContent = key; } else { stateLabel.setAttribute("data-l10n-id", this.#getPermissionStateLabelId(permission)); } labelContainer.appendChild(stateLabel); container.appendChild(img); container.appendChild(labelContainer); container.addEventListener("click", this); return [container, isCrossSiteCookie]; } #openGetAddons() { const { switchToTabHavingURI } = this.window; let amoUrl = Services.urlFormatter.formatURLPref("extensions.getAddons.link.url"); switchToTabHavingURI(amoUrl, true); } #onCommandEvent(event) { const id = event.target.id; switch (id) { case "zen-site-data-new-addon-button": { this.#openGetAddons(); break; } case "zen-site-data-security-info": { this.window.gIdentityHandler._openPopup(event); break; } case "zen-site-data-actions": { const button = this.document.getElementById("zen-site-data-actions"); const popup = this.document.getElementById("zenSiteDataActions"); popup.openPopup( button, "after_start", 0, 0, /* context menu */ true, false, this.window.event ); break; } case "zen-site-data-header-bookmark": { this.window.BookmarkingUI.onStarCommand(event); break; } case "zen-site-data-header-share": { /* eslint-disable mozilla/valid-services */ if (Services.zen.canShare()) { const buttonRect = event.target.getBoundingClientRect(); const currentUrl = this.window.gBrowser.currentURI; /* eslint-disable mozilla/valid-services */ Services.zen.share( currentUrl, "", "", buttonRect.left, this.window.innerHeight - buttonRect.bottom, buttonRect.width, buttonRect.height ); } else { this.window.gZenCommonActions.copyCurrentURLToClipboard(); } if (AppConstants.platform !== "macosx") { this.unifiedPanel.hidePopup(); } } } } #onPermissionClick(label) { const { SitePermissions, gBrowser } = this.window; const permission = label._permission; let newState; switch (permission.state) { case SitePermissions.ALLOW: newState = SitePermissions.BLOCK; break; case SitePermissions.BLOCK: case SitePermissions.AUTOPLAY_BLOCKED_ALL: newState = SitePermissions.ALLOW; break; default: return; } if (permission.id === "site-protection") { const { gProtectionsHandler } = this.window; if (newState === SitePermissions.BLOCK) { gProtectionsHandler.disableForCurrentPage(); } else { gProtectionsHandler.enableForCurrentPage(); } } else { SitePermissions.setForPrincipal(gBrowser.contentPrincipal, permission.id, newState); } const isCrossSiteCookie = permission.id.startsWith("3rdPartyStorage"); label.parentNode.setAttribute("state", newState == SitePermissions.ALLOW ? "allow" : "block"); label._permission.state = newState; if (!isCrossSiteCookie) { label .querySelector(".zen-permission-popup-permission-state-label") .setAttribute("data-l10n-id", this.#getPermissionStateLabelId(label._permission)); } } #onClickEvent(event) { const id = event.target.id; switch (id) { case "zen-site-data-manage-addons": { const { BrowserAddonUI } = this.window; BrowserAddonUI.openAddonsMgr("addons://list/extension"); this.unifiedPanel.hidePopup(); break; } case "zen-site-data-settings-more": { const { BrowserCommands } = this.window; BrowserCommands.pageInfo(null, "permTab"); break; } case "zen-site-data-icon-button": { this.window.gUnifiedExtensions.togglePanel( event, null, this.unifiedPanel, this.unifiedPanelView, this.anchor ); break; } default: { const item = event.target.closest(".permission-popup-permission-item"); if (!item) { break; } const label = item.querySelector(".permission-popup-permission-label-container"); if (label?._permission) { this.#onPermissionClick(label); } break; } } } handleEvent(event) { const type = event.type; switch (type) { case "click": this.#onClickEvent(event); break; case "command": this.#onCommandEvent(event); break; case "popupshowing": this.#preparePanel(); break; } } async #maybeShowFeatureCallout() { const kPref = "zen.site-data-panel.show-callout"; if (!Services.prefs.getBoolPref(kPref, false)) { return; } Services.prefs.setBoolPref(kPref, false); const { gBrowser, gZenWorkspaces } = this.window; await gZenWorkspaces.promiseInitialized; await new Promise((resolve) => { const checkEmptyTab = () => { if (!gBrowser.selectedTab.hasAttribute("zen-empty-tab")) { resolve(); return; } this.window.addEventListener("TabSelect", checkEmptyTab, { once: true }); }; checkEmptyTab(); }); const callout = new lazy.FeatureCallout({ win: this.window, location: "chrome", context: "chrome", browser: gBrowser.selectedBrowser, theme: { preset: "chrome" }, }); this.window.setTimeout(() => { callout.showFeatureCallout({ id: "ZEN_EXTENSIONS_PANEL_MOVE_CALLOUT", template: "feature_callout", groups: ["cfr"], content: { id: "ZEN_EXTENSIONS_PANEL_MOVE_CALLOUT", template: "multistage", backdrop: "transparent", transitions: true, screens: [ { id: "ZEN_EXTENSIONS_PANEL_MOVE_CALLOUT", anchors: [ { selector: "#zen-site-data-icon-button", panel_position: { anchor_attachment: "bottomcenter", callout_attachment: "topleft", }, }, ], content: { position: "callout", width: "355px", title: { string_id: "zen-site-data-panel-feature-callout-title", }, subtitle: { string_id: "zen-site-data-panel-feature-callout-subtitle", }, dismiss_button: { action: { dismiss: true, }, background: true, size: "small", }, }, }, ], }, }); }, 1000); } }