Files
sousa-gecko/browser/components/ipprotection/IPProtectionToolbarButton.sys.mjs
Rebecca King 5ac9e990eb Bug 2068276 - Add new toolbar icon state for applied inclusion - r=ip-protection-reviewers,desktop-theme-reviewers,tgiles,sstreich
Adds the new toolbar button state and icons for the included state in the toolbar and for the overflow menu.

Differential Revision: https://phabricator.services.mozilla.com/D323384
2026-09-09 19:01:59 +00:00

555 lines
16 KiB
JavaScript

/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CustomizableUI:
"moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
IPPExceptionsManager:
"moz-src:///toolkit/components/ipprotection/IPPExceptionsManager.sys.mjs",
IPPPrincipalRules:
"moz-src:///toolkit/components/ipprotection/IPPExceptionsManager.sys.mjs",
IPPProxyManager:
"moz-src:///toolkit/components/ipprotection/IPPProxyManager.sys.mjs",
IPProtectionService:
"moz-src:///toolkit/components/ipprotection/IPProtectionService.sys.mjs",
IPPProxyStates:
"moz-src:///toolkit/components/ipprotection/IPPProxyManager.sys.mjs",
ERRORS: "moz-src:///toolkit/components/ipprotection/IPPProxyManager.sys.mjs",
});
import { getSitePrincipal } from "chrome://browser/content/ipprotection/ipprotection-utils.mjs";
const OPENED_WITH_LOCATION_PREF =
"browser.ipProtection.openedPanelWithLocation";
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"siteExceptionsFeaturePref",
"browser.ipProtection.features.siteExceptions",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"siteInclusionsFeaturePref",
"browser.ipProtection.features.siteInclusions",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"siteExceptionsHintsPref",
"browser.ipProtection.siteExceptionsHintsEnabled",
true
);
/**
* IPProtectionToolbarButton manages the IP Protection toolbar button
* for a single browser window.
*
* Each instance:
* - Tracks location changes via a progress listener
* - Updates the button icon according to the proxy state, proxy errors,
* offline status, and site exclusions
* - Handles the visual state of the toolbar button
*/
export class IPProtectionToolbarButton {
#window = null;
#progressListener = null;
#widgetId = null;
#previousIsExcluded = null;
#prefObserver = null;
#visitedExcludedSites = new Set();
static CONFIRMATION_HINT_MESSAGE_ID =
"confirmation-hint-ipprotection-navigated-to-excluded-site";
// Non-default icon states rendered as always-painted overlay layers in the
// toolbar so switching states never triggers a fresh image decode
// (bug 2034698). The default "off" state is the base .toolbarbutton-icon.
static ICON_LAYER_STATES = [
"on",
"network-error",
"error",
"excluded",
"included",
"paused",
];
/**
* Gets the gBrowser from the weak reference to the window.
*
* @returns {object|undefined}
* The gBrowser object, or undefined if the window has been garbage collected.
*/
get gBrowser() {
const win = this.#window.get();
return win?.gBrowser;
}
/**
* Gets the value of the pref
* browser.ipProtection.features.siteExceptions.
*
* @returns {boolean}
* True if site exceptions support is enabled, false otherwise.
*/
get isExceptionsFeatureEnabled() {
return lazy.siteExceptionsFeaturePref;
}
/**
* Gets the value of the pref
* browser.ipProtection.features.siteInclusions.
*
* @returns {boolean}
* True if site inclusions support is enabled, false otherwise.
*/
get isInclusionsFeatureEnabled() {
return lazy.siteInclusionsFeaturePref;
}
/**
* Gets the value of the pref
* browser.ipProtection.siteExceptionsHintsEnabled.
*
* @returns {boolean}
* True if confirmation hints for site exceptions are enabled, false otherwise.
*/
get isExceptionsHintsEnabled() {
return lazy.siteExceptionsHintsPref;
}
/**
* Gets the toolbaritem for this window.
*
* @returns {XULElement|null}
* The toolbaritem element, or null if not available.
*/
get toolbaritem() {
const win = this.#window.get();
if (!win) {
return null;
}
return lazy.CustomizableUI.getWidget(this.#widgetId)?.forWindow(win).node;
}
constructor(window, widgetId, toolbaritem = null) {
this.#window = Cu.getWeakReference(window);
this.#widgetId = widgetId;
this.handleEvent = this.#handleEvent.bind(this);
this.#addProgressListener();
lazy.IPProtectionService.addEventListener(
"IPProtectionService:StateChanged",
this.handleEvent
);
lazy.IPPProxyManager.addEventListener(
"IPPProxyManager:StateChanged",
this.handleEvent
);
lazy.IPPExceptionsManager.addEventListener(
"IPPExceptionsManager:ExclusionChanged",
this.handleEvent
);
if (this.gBrowser?.tabContainer) {
this.gBrowser.tabContainer.addEventListener("TabSelect", this);
}
this.#prefObserver = { observe: () => this.#updateBadge() };
Services.prefs.addObserver(OPENED_WITH_LOCATION_PREF, this.#prefObserver);
if (toolbaritem) {
toolbaritem.classList.add("subviewbutton-nav"); // adds the right arrow in overflow menu
this.updateState(toolbaritem);
}
}
/**
* Creates and registers a progress listener for the window.
*/
#addProgressListener() {
if (!this.gBrowser) {
return;
}
this.#progressListener = {
onLocationChange: (
aBrowser,
aWebProgress,
_aRequest,
aLocationURI,
aFlags
) => {
if (!aWebProgress.isTopLevel) {
return;
}
// Only update if on the currently selected tab
if (aBrowser !== this.gBrowser?.selectedBrowser) {
return;
}
if (!aLocationURI) {
return;
}
const isReload =
aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD;
this.updateState(null, { showConfirmationHint: !isReload });
},
};
this.gBrowser.addTabsProgressListener(this.#progressListener);
}
/**
* Event handler for document-level events.
*
* @param {Event} event
* The event to handle.
*/
#handleEvent(event) {
if (
event.type !== "IPProtectionService:StateChanged" &&
event.type !== "IPPProxyManager:StateChanged" &&
event.type !== "IPPExceptionsManager:ExclusionChanged" &&
event.type !== "TabSelect"
) {
return;
}
let exclusionChanged =
event.type === "IPPExceptionsManager:ExclusionChanged";
if (
event.type === "IPPProxyManager:StateChanged" &&
lazy.IPPProxyManager.state !== lazy.IPPProxyStates.ACTIVE
) {
this.#visitedExcludedSites.clear();
}
this.updateState(null, { showConfirmationHint: !exclusionChanged });
}
/**
* Updates the button to reflect the current state.
*
* This method is called under these circumstances:
* 1. After creating the toolbar button, to set up the initial icon
* 2. After an IPProtectionService or IPPProxyManager state change
* 3. After pressing the site exclusion toggle on the panel and the
* exclusion state for a site has changed in ipp-vpn
* 4. After a location change / page navigation
* 5. After tab switching
* 7. After an IPPProxyManager error event occurs
* 8. The panel opens or closes.
*
* @param {XULElement|null} [toolbaritem]
* Optional toolbaritem to update directly.
* If not provided, looks up the toolbaritem via CustomizableUI.
* If provided, but toolbaritem is null, this means the toolbaritem isn't available yet.
* @param {object} [options]
* Optional options object
* @param {boolean} [options.showConfirmationHint=true]
* Whether to show confirmation hints for navigation to excluded sites
* @param {string} [options.error=undefined]
* Error type to show.
*/
updateState(
toolbaritem = null,
options = { showConfirmationHint: true, error: undefined }
) {
const win = this.#window.get();
if (!win) {
return;
}
toolbaritem ??= this.toolbaritem;
if (!toolbaritem) {
return;
}
let principal = getSitePrincipal(this.gBrowser);
// Only surface an exclusion for pages the user can manage (normal content
// pages), matching the panel: about:/chrome:/system pages are never shown
// excluded.
let isExcluded =
!!principal &&
lazy.IPPExceptionsManager.canManage(principal) &&
lazy.IPPExceptionsManager.getPrincipalRule(principal) ===
lazy.IPPPrincipalRules.EXCLUDED;
//TODO: Add hasInclusion function to exceptions manager, replace false with commented out call to hasInclusion - Bug 2066802
let isIncluded =
!!principal && lazy.IPPExceptionsManager.canManage(principal) && false;
// lazy.IPPExceptionsManager.hasInclusion(principal);
let isActive = lazy.IPPProxyManager.state === lazy.IPPProxyStates.ACTIVE;
let isPaused = lazy.IPPProxyManager.state === lazy.IPPProxyStates.PAUSED;
// Show error icon when proxy manager is in ERROR state.
let hasProxyError =
lazy.IPPProxyManager.state === lazy.IPPProxyStates.ERROR;
let isNetworkError =
options?.error === lazy.ERRORS.NETWORK ||
(hasProxyError && lazy.IPPProxyManager.errorType === lazy.ERRORS.NETWORK);
let isError = hasProxyError || !!options.error;
const showConfirmationHint = options.showConfirmationHint ?? true;
if (showConfirmationHint) {
this.updateConfirmationHint(win.ConfirmationHint, toolbaritem, {
isActive,
isError,
isExcluded,
});
}
// Null principals reset the previous state to false if
// the state was initially true. To avoid this, only set
// the previous state if not a null principal.
if (principal && !principal.isNullPrincipal) {
this.#previousIsExcluded = isExcluded;
}
this.updateIconStatus(toolbaritem, {
isActive,
isError,
isNetworkError,
isExcluded,
isIncluded,
isPaused,
});
this.#updateBadge(toolbaritem);
}
/**
* Updates the badge on the toolbar button based on whether the user has
* opened the panel since location controls were introduced.
* The badge is not shown when the button is in the customize toolbar palette.
*
* @param {XULElement|null} [toolbaritem]
*/
#updateBadge(toolbaritem = null) {
toolbaritem ??= this.toolbaritem;
if (!toolbaritem) {
return;
}
let everOpenedPanel = Services.prefs.getBoolPref(
OPENED_WITH_LOCATION_PREF,
false
);
let inPalette = !lazy.CustomizableUI.getPlacementOfWidget(this.#widgetId);
let badge = toolbaritem.querySelector(".toolbarbutton-badge");
if (everOpenedPanel || inPalette) {
toolbaritem.removeAttribute("badged");
badge?.classList.remove("feature-callout");
} else {
toolbaritem.setAttribute("badged", "true");
badge?.classList.add("feature-callout");
}
}
/**
* Shows a confirmation hint after navigating from a
* protected site to an excluded site while the VPN is on.
* Ignore the message if there is an error, if the VPN is off,
* or if we already showed the message for a site during the
* VPN session.
*
* @param {object} confirmationHint
* The current window's confirmation hint instance
* @param {XULElement} toolbaritem
* The toolbaritem to anchor the confirmation hint to
* @param {object} status
* VPN connection status
*/
updateConfirmationHint(
confirmationHint,
toolbaritem,
status = { isActive: false, isError: false, isExcluded: false }
) {
if (!confirmationHint) {
return;
}
let exceptionsPrefsEnabled =
this.isExceptionsFeatureEnabled && this.isExceptionsHintsEnabled;
const canShowConfirmationHint =
exceptionsPrefsEnabled &&
!status.isError &&
status.isActive &&
status.isExcluded &&
!this.#previousIsExcluded;
if (!canShowConfirmationHint) {
return;
}
let siteOrigin = getSitePrincipal(this.gBrowser)?.origin;
if (!siteOrigin || this.#visitedExcludedSites.has(siteOrigin)) {
return;
}
this.#visitedExcludedSites.add(siteOrigin);
confirmationHint.show(
toolbaritem,
IPProtectionToolbarButton.CONFIRMATION_HINT_MESSAGE_ID,
{
position: "bottomright topright", // panel anchor, message anchor
hideCheckmark: true,
}
);
}
/**
* Updates the toolbar button icon to reflect the VPN connection status
*
* @param {XULElement} toolbaritem
* The toolbaritem to update
* @param {object} status
* VPN connection status
*/
updateIconStatus(
toolbaritem,
status = {
isActive: false,
isError: false,
isExcluded: false,
isIncluded: false,
isPaused: false,
isNetworkError: false,
}
) {
if (!toolbaritem) {
return;
}
this.#buildIconLayers(toolbaritem);
let isActive = status.isActive;
let isNetworkError = status.isNetworkError;
let isError = status.isError && !isNetworkError;
let isExcluded = status.isExcluded && this.isExceptionsFeatureEnabled;
let isIncluded = status.isIncluded && this.isInclusionsFeatureEnabled;
let isPaused = status.isPaused;
let l10nId =
isError || isNetworkError
? "ipprotection-button-error"
: "ipprotection-button";
toolbaritem.classList.remove(
"ipprotection-on",
"ipprotection-network-error",
"ipprotection-error",
"ipprotection-excluded",
"ipprotection-included",
"ipprotection-paused"
);
if (isNetworkError) {
toolbaritem.classList.add("ipprotection-network-error");
} else if (isError) {
toolbaritem.classList.add("ipprotection-error");
} else if (isPaused) {
toolbaritem.classList.add("ipprotection-paused");
} else if (isExcluded && isActive) {
toolbaritem.classList.add("ipprotection-excluded");
} else if (isIncluded && !isActive) {
toolbaritem.classList.add("ipprotection-included");
} else if (isActive) {
toolbaritem.classList.add("ipprotection-on");
}
toolbaritem.setAttribute("data-l10n-id", l10nId);
}
/**
* Wraps the toolbar button's icon in a <stack> and renders one overlay
* <image> layer per non-default icon state on top of it. Keeps every
* state's artwork painted and updates opacity via CSS.
* This approach prevents flickers between initial state changes since
* there is no fresh image decode - Bug 2034698.
*
* @param {XULElement} toolbaritem
* The toolbaritem to add the icon layers to.
*/
#buildIconLayers(toolbaritem) {
if (toolbaritem.querySelector(".ipprotection-icon-stack")) {
return;
}
let icon = toolbaritem.querySelector(".toolbarbutton-icon");
if (!icon) {
// The button hasn't rendered its DOM yet; try again on the next update.
return;
}
let doc = toolbaritem.ownerDocument;
let stack = doc.createXULElement("stack");
stack.classList.add("ipprotection-icon-stack", "toolbarbutton-badge-stack");
// Move the existing icon into the stack as the base (off) layer, then
// stack the remaining states on top of it.
icon.replaceWith(stack);
stack.appendChild(icon);
for (let state of IPProtectionToolbarButton.ICON_LAYER_STATES) {
let layer = doc.createXULElement("image");
layer.classList.add("ipprotection-icon-layer");
layer.setAttribute("data-state", state);
// Purely presentational; the button itself carries the accessible name.
layer.setAttribute("aria-hidden", "true");
stack.appendChild(layer);
}
}
/**
* Cleans up listeners and observers when the button is destroyed.
*/
uninit() {
if (this.gBrowser && this.#progressListener) {
this.gBrowser.removeTabsProgressListener(this.#progressListener);
}
this.#progressListener = null;
Services.prefs.removeObserver(
OPENED_WITH_LOCATION_PREF,
this.#prefObserver
);
this.#prefObserver = null;
if (this.gBrowser?.tabContainer) {
this.gBrowser.tabContainer.removeEventListener("TabSelect", this);
}
lazy.IPProtectionService.removeEventListener(
"IPProtectionService:StateChanged",
this.handleEvent
);
lazy.IPPProxyManager.removeEventListener(
"IPPProxyManager:StateChanged",
this.handleEvent
);
lazy.IPPExceptionsManager.removeEventListener(
"IPPExceptionsManager:ExclusionChanged",
this.handleEvent
);
}
}