Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PopupNotifications: "resource://gre/modules/PopupNotifications.sys.mjs",
SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
});
const { SidebarPermissionsUI } = ChromeUtils.importESModule(
"chrome://browser/content/sidebar/sidebar-permissions-ui.mjs"
);
/**
* Get count of queued tab notifications
*/
function getTabNotificationCount(win) {
const tabBrowser = win.gBrowser?.selectedBrowser;
if (!tabBrowser || !win.PopupNotifications) {
return 0;
}
const notifications =
win.PopupNotifications.getNotificationsForBrowser(tabBrowser);
return notifications.length ?? 0;
}
/**
* Cancel all tab notifications within same window.
* When sidebar takes over, we cancel all pending tab notifications,
* not just the one matching the current permission type.
*/
function cancelAllTabNotifications(win) {
const tabBrowser = win.gBrowser?.selectedBrowser;
if (tabBrowser && win.PopupNotifications) {
removeNotificationsForBrowser(win.PopupNotifications, tabBrowser);
}
}
/**
* Remove all pending notifications for a browser from a notification system.
*
* @param {PopupNotifications} popupNotifications - The notification system (SidebarPopupNotifications)
* @param {Browser} browser - The browser to remove notifications
*/
function removeNotificationsForBrowser(popupNotifications, browser) {
const notifications = popupNotifications.getNotificationsForBrowser(browser);
if (notifications?.length) {
for (const notification of [...notifications]) {
popupNotifications.remove(notification, true);
}
}
}
/**
* Per window Sidebar Permissions handles:
* - webrtc request UI
* - webrtc sharing UI
* - blocked webrtc UI
* - sidebar PopupNotification placement
* - PopupNotification coordination between sidebar and tabs (same window and cross window)
*/
export class SidebarPermissions {
#initialized = false;
#window;
#browser = null;
#contentBrowser = null;
#sidebarPermissionUI = null;
#observerBound = false;
#popupshowingHandler;
#panelOpenHandler;
#popuphiddenHandler;
#currentPopupNotificationBrowser;
#onPermissionStateChanged;
#onSidebarBrowserChanged;
#onSidebarHideEvent;
#securityChangeListener;
constructor(win) {
this.#window = win;
}
/**
* Getter for contentBrowser
*/
get contentBrowser() {
return this.#contentBrowser;
}
/**
* Called by SidebarController after the sidebar nested browser is ready.
* Sets up all the permission UI and observers.
*/
init(browser) {
const win = this.#window;
this.#browser = browser;
if (!this.#browser?.contentDocument) {
console.warn("Sidebar browser is not ready");
return;
}
// Window level setup - per window
if (!this.#initialized) {
this.#bindObservers();
// Also listen to custom event as a hint, but verify with actual DOM check
// this allows chat.js or other tools to trigger changes
this.#onSidebarBrowserChanged = this.onContentBrowserChanged.bind(this);
win.addEventListener(
"sidebarbrowserchanged",
this.#onSidebarBrowserChanged
);
win.addEventListener("unload", () => this.#uninit(), {
once: true,
});
this.#initialized = true;
}
// Ensure SidebarPopupNotifications exists (may need to recreate after sidebar close/reopen)
if (!win.SidebarPopupNotifications) {
this.#setupSidebarPopupNotifications();
}
// Content level setup - runs every eligible permission sidebar tool opens
// When sidebar is closed or change tool
this.#sidebarPermissionUI = new SidebarPermissionsUI(win, this.#browser);
this.#sidebarPermissionUI.build();
this.#setupSidebarPopupNotificationWrapper();
this.#onSidebarHideEvent = () => this.onSidebarHidden();
this.#browser.contentWindow?.addEventListener(
"SidebarWillHide",
this.#onSidebarHideEvent
);
if (this.#contentBrowser && this.#onPermissionStateChanged) {
this.#contentBrowser.removeEventListener(
"PermissionStateChange",
this.#onPermissionStateChanged,
true
);
}
this.#contentBrowser =
this.#browser.contentDocument.querySelector("browser");
this.#sidebarPermissionUI.setContentBrowser(this.#contentBrowser);
this.#setupPermissionStateListener();
}
/**
* Remove SidebarPopupNotification.show wrappers when sidebar is hidden
*/
onSidebarHidden() {
const win = this.#window;
if (this.#observerBound) {
Services.obs.removeObserver(this, "perm-changed");
this.#observerBound = false;
}
if (this.#contentBrowser?._sharingState) {
this.#contentBrowser._sharingState = {};
}
// Cancel any active SidebarPopupNotification before unwrapping
this.#cancelSidebarNotifications();
const panel = win.document?.getElementById("notification-popup");
if (this.#popupshowingHandler) {
panel?.removeEventListener(
"popupshowing",
this.#popupshowingHandler,
true
);
this.#popupshowingHandler = null;
}
if (this.#panelOpenHandler) {
panel?.removeEventListener("popupshown", this.#panelOpenHandler, true);
panel?.removeEventListener("PanelUpdated", this.#panelOpenHandler, true);
this.#panelOpenHandler = null;
}
if (this.#popuphiddenHandler) {
panel?.removeEventListener("popuphidden", this.#popuphiddenHandler, true);
this.#popuphiddenHandler = null;
}
this.#currentPopupNotificationBrowser = null;
if (this.#onSidebarBrowserChanged) {
win.removeEventListener(
"sidebarbrowserchanged",
this.#onSidebarBrowserChanged
);
this.#onSidebarBrowserChanged = null;
}
// Remove wrapper when sidebar is closed/hidden
if (win.SidebarPopupNotifications) {
win.SidebarPopupNotifications._currentAnchorElement = null;
if (win.SidebarPopupNotifications._wrappedBySidebarPermissions) {
win.SidebarPopupNotifications.show =
win.SidebarPopupNotifications._originalShow;
}
delete win.SidebarPopupNotifications;
}
if (this.#contentBrowser && this.#securityChangeListener) {
try {
this.#contentBrowser.removeProgressListener(
this.#securityChangeListener
);
} catch (e) {}
this.#securityChangeListener = null;
}
if (this.#contentBrowser && this.#onPermissionStateChanged) {
this.#contentBrowser.removeEventListener(
"PermissionStateChange",
this.#onPermissionStateChanged,
true
);
this.#onPermissionStateChanged = null;
}
this.#browser?.contentWindow?.removeEventListener(
"SidebarWillHide",
this.#onSidebarHideEvent
);
this.#onSidebarHideEvent = null;
// Clear references to prevent leaks
this.#browser = null;
this.#contentBrowser = null;
this.#sidebarPermissionUI?.destroy();
this.#sidebarPermissionUI = null;
}
/**
* Clean up observers and listeners on unload.
*/
#uninit() {
const win = this.#window;
if (this.#contentBrowser && this.#securityChangeListener) {
try {
this.#contentBrowser.removeProgressListener(
this.#securityChangeListener
);
} catch (e) {
// Listener wasn't on this browser
console.warn("Failed to remove progress listener:", e.message);
}
this.#securityChangeListener = null;
}
if (this.#contentBrowser && this.#onPermissionStateChanged) {
this.#contentBrowser.removeEventListener(
"PermissionStateChange",
this.#onPermissionStateChanged,
true
);
this.#onPermissionStateChanged = null;
}
if (this.#browser?.contentWindow && this.#onSidebarHideEvent) {
this.#browser.contentWindow.removeEventListener(
"SidebarWillHide",
this.#onSidebarHideEvent
);
this.#onSidebarHideEvent = null;
}
// This makes sure the wrappers are removed even if hide event didn't fire
this.onSidebarHidden();
const panel = win.document?.getElementById("notification-popup");
if (this.#popupshowingHandler) {
panel?.removeEventListener(
"popupshowing",
this.#popupshowingHandler,
true
);
this.#popupshowingHandler = null;
}
if (this.#panelOpenHandler) {
panel?.removeEventListener("popupshown", this.#panelOpenHandler, true);
panel?.removeEventListener("PanelUpdated", this.#panelOpenHandler, true);
this.#panelOpenHandler = null;
}
if (this.#popuphiddenHandler) {
panel?.removeEventListener("popuphidden", this.#popuphiddenHandler, true);
this.#popuphiddenHandler = null;
}
this.#currentPopupNotificationBrowser = null;
if (this.#observerBound) {
Services.obs.removeObserver(this, "perm-changed");
this.#observerBound = false;
}
if (this.#onSidebarBrowserChanged) {
this.#window.removeEventListener(
"sidebarbrowserchanged",
this.#onSidebarBrowserChanged
);
this.#onSidebarBrowserChanged = null;
}
if (win.SidebarPopupNotifications) {
delete win.SidebarPopupNotifications;
}
// Reset browser override
if (win.gPermissionPanel) {
win.gPermissionPanel.clearBrowserOverride();
}
this.#sidebarPermissionUI?.destroy();
this.#sidebarPermissionUI = null;
this.#contentBrowser = null;
this.#browser = null;
}
/**
* Create SidebarPopupNotifications instance for this window.
* This redirects PopupNotification to use sidebar anchors instead of browser chrome.
* Because both sidebar and tab PopupNotifications use the same panel element
* this function wraps SidebarPopupNotification.show() methods to cancel the others before showing to ensure only
* one permission popup is visible at a time.
*/
#setupSidebarPopupNotifications() {
const win = this.#window;
const chromeDoc = win.document;
let panel = chromeDoc.getElementById("notification-popup");
const iconBox = chromeDoc.getElementById("notification-popup-box");
win.SidebarPopupNotifications = new lazy.PopupNotifications(
this.#browser,
panel,
iconBox,
{
getVisibleAnchorElement(anchorElement) {
if (anchorElement?.checkVisibility()) {
return anchorElement;
}
const sidebarDocument =
win.SidebarController?.browser?.contentDocument;
const micAnchor = sidebarDocument?.getElementById(
"sidebar-webrtc-microphone-notification-icon"
);
if (micAnchor?.checkVisibility()) {
return micAnchor;
}
return iconBox;
},
}
);
// Only when a sidebar is open, hook SidebarPopupNotification show()
// Cancel pending all other PopupNotifications
this.#wrapSidebarShow(win.SidebarPopupNotifications);
// Track which browser notification is showing
this.#currentPopupNotificationBrowser = null;
this.#popupshowingHandler = () => {
const firstChild = panel.firstElementChild;
const browser = firstChild?.notification?.browser;
const isSidebarNotification = browser === this.#contentBrowser;
if (isSidebarNotification) {
this.#currentPopupNotificationBrowser = browser;
this.#sidebarPermissionUI.showMicRequestUI();
}
};
this.#panelOpenHandler = () => {
this.#handlePopupChange(panel);
};
this.#popuphiddenHandler = () => {
// Ignore if we're in the middle of showing sidebar
if (win.SidebarPopupNotifications?._isShowing) {
return;
}
// Only handle sidebar PopupNotifications
if (this.#currentPopupNotificationBrowser === this.#contentBrowser) {
this.#onSidebarPopupNotificationHidden();
}
};
// Store current PopupNotification Browser to check with this.#contentBrowser.
panel.addEventListener("popupshowing", this.#popupshowingHandler, true);
panel.addEventListener("popupshown", this.#panelOpenHandler, true);
panel.addEventListener("PanelUpdated", this.#panelOpenHandler, true);
// When the active SidebarPopupNotification is hidden just cancel the notification
panel.addEventListener("popuphidden", this.#popuphiddenHandler, true);
}
#handlePopupChange(panel) {
if (panel.state !== "open") {
return;
}
const firstChild = panel.firstElementChild;
const browser = firstChild?.notification?.browser;
this.#currentPopupNotificationBrowser = browser;
const isSidebarNotification = browser === this.#contentBrowser;
if (!isSidebarNotification) {
this.#cancelSidebarNotifications();
}
}
/**
* Setup SidebarPopupNotification wrapper for sidebar.
* Called on every sidebar open or changed provider
*/
#setupSidebarPopupNotificationWrapper() {
const win = this.#window;
if (win.SidebarPopupNotifications) {
// if it is wrapped already it won't be rewrapped
this.#wrapSidebarShow(win.SidebarPopupNotifications);
}
}
/**
* Called when sidebar PopupNotification is hidden
*/
#onSidebarPopupNotificationHidden() {
this.#cancelSidebarNotifications();
this.updatePermissionIcons();
}
/**
* Cancel all SidebarPopupNotification in current window.
*/
#cancelSidebarNotifications() {
const win = this.#window;
if (this.#contentBrowser && win.SidebarPopupNotifications) {
removeNotificationsForBrowser(
win.SidebarPopupNotifications,
this.#contentBrowser
);
}
}
/**
* Wrap SidebarPopupNotifications.show() to cancel tab notifications
* if SidebarPopupNotification is requested.
*
* @param {PopupNotifications} sidebarInstance - The SidebarPopupNotifications instance
*/
#wrapSidebarShow(sidebarInstance) {
// TODO - Bug 2009301: This approach is hacky to cancel the active tab Notification.
// Should revisit and find a better solution.
if (sidebarInstance._wrappedBySidebarPermissions) {
return;
}
const originalShow = sidebarInstance.show;
const win = this.#window;
sidebarInstance._isShowing = false;
sidebarInstance.show = function (browser, id, ...args) {
const panel = win.document.getElementById("notification-popup");
sidebarInstance._isShowing = true;
// Check how many tab notifications exist
const tabNotificationCount = getTabNotificationCount(win);
if (tabNotificationCount >= 1) {
// Cancel all tab notifications
cancelAllTabNotifications(win);
}
// When multiple tab notifications are queued, canceling them triggers
// multiple popuphidden events. wait for the panels to fully closed before
// showing the sidebar notification to avoid UI conflicts.
if (tabNotificationCount > 1 && panel) {
const showAfterHidden = async () => {
await new Promise(resolve => {
if (panel && panel.state === "closed") {
panel.addEventListener("popuphidden", resolve, { once: true });
} else {
resolve();
}
});
const result = originalShow.call(
sidebarInstance,
browser,
id,
...args
);
sidebarInstance._isShowing = false;
return result;
};
return showAfterHidden();
}
// No queued notifications in tab, show SidebarNotification immediately.
const result = originalShow.call(sidebarInstance, browser, id, ...args);
sidebarInstance._isShowing = false;
return result;
};
sidebarInstance._wrappedBySidebarPermissions = true;
sidebarInstance._originalShow = originalShow;
}
#bindObservers() {
if (this.#observerBound) {
return;
}
Services.obs.addObserver(this, "perm-changed");
this.#observerBound = true;
}
observe(subject, topic) {
switch (topic) {
case "perm-changed":
this.#onPermissionChanged(subject);
break;
}
}
onContentBrowserChanged() {
const win = this.#window;
// Remove listeners from old browser
if (this.#contentBrowser && this.#onPermissionStateChanged) {
this.#contentBrowser.removeEventListener(
"PermissionStateChange",
this.#onPermissionStateChanged,
true
);
}
if (this.#contentBrowser && this.#securityChangeListener) {
try {
this.#contentBrowser.removeProgressListener(
this.#securityChangeListener
);
} catch (e) {
// Listener wasn't on this browser
console.warn("Failed to remove progress listener:", e.message);
}
}
// Cancel any active sidebar notification for older browser
this.#cancelSidebarNotifications();
// Reset UI
this.#sidebarPermissionUI?.clearUI();
// Get the new provider browser
this.#contentBrowser =
this.#browser?.contentDocument?.querySelector("browser");
this.#sidebarPermissionUI.setContentBrowser(this.#contentBrowser);
if (this.#contentBrowser) {
const _self = this;
this.#securityChangeListener = {
_previousOrigin: null,
onSecurityChange() {
const currentOrigin = _self.#contentBrowser?.contentPrincipal?.origin;
if (this._previousOrigin === currentOrigin) {
return;
}
this._previousOrigin = currentOrigin;
_self.updatePermissionIcons();
},
QueryInterface: ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
};
this.#contentBrowser.addProgressListener(
this.#securityChangeListener,
Ci.nsIWebProgress.NOTIFY_SECURITY
);
this.#setupPermissionStateListener();
}
// Reset browser override to gPanel
if (win.gPermissionPanel) {
win.gPermissionPanel.clearBrowserOverride();
}
}
#onPermissionChanged(subject) {
// Only care about microphone for now
const permission = subject.QueryInterface(Ci.nsIPermission);
if (permission.type !== "microphone") {
return;
}
if (!this.#sidebarPermissionUI?.isReady()) {
return;
}
if (this.#sidebarPermissionUI.isIdentityBoxOpen()) {
return;
}
const sidebarPrincipal = this.#contentBrowser?.contentPrincipal;
// Only care if permission is the same as sidebar's origin
if (permission.principal?.origin !== sidebarPrincipal?.origin) {
return;
}
this.updatePermissionIcons();
}
#setupPermissionStateListener() {
if (!this.#contentBrowser) {
return;
}
this.#onPermissionStateChanged = event => {
const currentBrowser = event.target;
// Sidebar browser changed
if (currentBrowser === this.#contentBrowser) {
this.updatePermissionIcons();
}
};
// Listen on contentBrowser for sidebar permission changes
this.#contentBrowser.addEventListener(
"PermissionStateChange",
this.#onPermissionStateChanged,
true
);
}
/**
* Update permission icons based on current permission state.
* Supporting only microphone for now
*/
updatePermissionIcons() {
if (!this.#contentBrowser) {
return;
}
let permissions = lazy.SitePermissions.getAllForBrowser(
this.#contentBrowser
);
// If no permissions, clear UI
if (!permissions.length) {
this.#sidebarPermissionUI.clearUI();
return;
}
// Filter permission matching type (e.g. "microphone" or "microphone^DeviceName")
const matchingMicPermissions = permissions.filter(
p => p.id === "microphone" || p.id.startsWith("microphone^")
);
// Check if any matching permission is blocked
if (
matchingMicPermissions.some(p => p.state === lazy.SitePermissions.BLOCK)
) {
this.#sidebarPermissionUI.showBlockedUI(true);
return;
}
// Check if any matching permission is allowed
if (
matchingMicPermissions.some(p => p.state === lazy.SitePermissions.ALLOW)
) {
this.#sidebarPermissionUI.showGrantedUI("microphone");
return;
}
this.#sidebarPermissionUI.clearUI();
}
showMicRequestUI() {
this.#sidebarPermissionUI.showMicRequestUI();
}
/**
* Show the granted permission UI indicator.
*
* @param {string} type - Permission type (e.g. "microphone")
*/
showGrantedUI(type) {
this.#sidebarPermissionUI.showGrantedUI(type);
}
/**
* Update sharing icon separately from browser-sitePermissionPanel.
*
* @param {object} sharingState - Browser sharing state object
*/
updateFromBrowserState(sharingState) {
this.#sidebarPermissionUI?.updateFromBrowserState(sharingState);
}
}