diff --git a/Source/NETworkManager/Controls/DragablzTabHostWindow.xaml.cs b/Source/NETworkManager/Controls/DragablzTabHostWindow.xaml.cs index 28f715d080..c17732bc69 100644 --- a/Source/NETworkManager/Controls/DragablzTabHostWindow.xaml.cs +++ b/Source/NETworkManager/Controls/DragablzTabHostWindow.xaml.cs @@ -212,7 +212,7 @@ private void RemoteDesktop_FullscreenAction(object view) private void RemoteDesktop_AdjustScreenAction(object view) { if (view is RemoteDesktopControl control) - control.AdjustScreen(); + control.AdjustScreen(force:true); } public ICommand RemoteDesktop_SendCtrlAltDelCommand => diff --git a/Source/NETworkManager/Controls/IDragablzTabItem.cs b/Source/NETworkManager/Controls/IDragablzTabItem.cs index 51756a8592..bfc824c170 100644 --- a/Source/NETworkManager/Controls/IDragablzTabItem.cs +++ b/Source/NETworkManager/Controls/IDragablzTabItem.cs @@ -13,5 +13,6 @@ public interface IDragablzTabItem /// public void CloseTab() { + } } \ No newline at end of file diff --git a/Source/NETworkManager/Controls/RemoteDesktopControl.xaml b/Source/NETworkManager/Controls/RemoteDesktopControl.xaml index 8b457bad50..3418e5932e 100644 --- a/Source/NETworkManager/Controls/RemoteDesktopControl.xaml +++ b/Source/NETworkManager/Controls/RemoteDesktopControl.xaml @@ -9,7 +9,7 @@ xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:local="clr-namespace:NETworkManager.Controls" xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" - mc:Ignorable="d" Loaded="UserControl_Loaded" + mc:Ignorable="d" Loaded="UserControl_Loaded" d:DataContext="{d:DesignInstance local:RemoteDesktopControl}"> @@ -17,10 +17,11 @@ - - + + - + @@ -65,18 +63,6 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/Source/NETworkManager/Controls/RemoteDesktopControl.xaml.cs b/Source/NETworkManager/Controls/RemoteDesktopControl.xaml.cs index 7c7e1fda2c..c2ff6f4ed4 100644 --- a/Source/NETworkManager/Controls/RemoteDesktopControl.xaml.cs +++ b/Source/NETworkManager/Controls/RemoteDesktopControl.xaml.cs @@ -1,21 +1,24 @@ // Documenation: https://docs.microsoft.com/en-us/windows/desktop/termserv/remote-desktop-web-connection-reference -using System; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Input; using AxMSTSCLib; +using log4net; using MSTSCLib; using NETworkManager.Localization.Resources; using NETworkManager.Models.RemoteDesktop; using NETworkManager.Settings; using NETworkManager.Utilities; +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; namespace NETworkManager.Controls; public partial class RemoteDesktopControl : UserControlBase, IDragablzTabItem { #region Variables + private static readonly ILog Log = LogManager.GetLogger(typeof(RemoteDesktopControl)); private bool _initialized; private bool _closed; @@ -23,34 +26,32 @@ public partial class RemoteDesktopControl : UserControlBase, IDragablzTabItem private readonly Guid _tabId; private readonly RemoteDesktopSessionInfo _sessionInfo; - // Fix WindowsFormsHost width - private double _rdpClientWidth; + private double _windowsFormsHostMaxWidth; - public double RdpClientWidth + public double WindowsFormsHostMaxWidth { - get => _rdpClientWidth; + get => _windowsFormsHostMaxWidth; private set { - if (Math.Abs(value - _rdpClientWidth) < double.Epsilon) + if (Math.Abs(value - _windowsFormsHostMaxWidth) < double.Epsilon) return; - _rdpClientWidth = value; + _windowsFormsHostMaxWidth = value; OnPropertyChanged(); } } - // Fix WindowsFormsHost height - private double _rdpClientHeight; + private double _windowsFormsHostMaxHeight; - public double RdpClientHeight + public double WindowsFormsHostMaxHeight { - get => _rdpClientHeight; + get => _windowsFormsHostMaxHeight; private set { - if (Math.Abs(value - _rdpClientHeight) < double.Epsilon) + if (Math.Abs(value - _windowsFormsHostMaxHeight) < double.Epsilon) return; - _rdpClientHeight = value; + _windowsFormsHostMaxHeight = value; OnPropertyChanged(); } } @@ -100,25 +101,9 @@ private set } } - private bool _isReconnecting; - - public bool IsReconnecting - { - get => _isReconnecting; - set - { - if (value == _isReconnecting) - return; - - _isReconnecting = value; - OnPropertyChanged(); - } - } - #endregion #region Constructor, load - public RemoteDesktopControl(Guid tabId, RemoteDesktopSessionInfo sessionInfo) { InitializeComponent(); @@ -132,6 +117,7 @@ public RemoteDesktopControl(Guid tabId, RemoteDesktopSessionInfo sessionInfo) Dispatcher.ShutdownStarted += Dispatcher_ShutdownStarted; } + private void UserControl_Loaded(object sender, RoutedEventArgs e) { // Connect after the control is drawn and only on the first init @@ -139,6 +125,7 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) return; Connect(); + _initialized = true; } @@ -175,6 +162,38 @@ private void DisconnectAction() #region Methods + private Tuple GetDesktopSize() + { + // Get the screen size + double desktopWidth, desktopHeight; + + if (_sessionInfo.AdjustScreenAutomatically || _sessionInfo.UseCurrentViewSize) + { + desktopWidth = RdpGrid.ActualWidth; + desktopHeight = RdpGrid.ActualHeight; + } + else + { + desktopWidth = _sessionInfo.DesktopWidth; + desktopHeight = _sessionInfo.DesktopHeight; + } + + // Scale the screen size based on the DPI + var scaleFactor = GetDpiScaleFactor(); + + desktopWidth = desktopWidth * scaleFactor / 100; + desktopHeight = desktopHeight * scaleFactor / 100; + + // Round the screen size to an even number + desktopWidth = Math.Floor(desktopWidth / 2) * 2; + desktopHeight = Math.Floor(desktopHeight / 2) * 2; + + return new Tuple(desktopWidth, desktopHeight); + } + + /// + /// Connect to the remote session with the given session info. + /// private void Connect() { IsConnecting = true; @@ -201,16 +220,16 @@ private void Connect() // Display RdpClient.ColorDepth = _sessionInfo.ColorDepth; // 8, 15, 16, 24 - if (_sessionInfo.AdjustScreenAutomatically || _sessionInfo.UseCurrentViewSize) - { - RdpClient.DesktopWidth = (int)RdpGrid.ActualWidth; - RdpClient.DesktopHeight = (int)RdpGrid.ActualHeight; - } - else - { - RdpClient.DesktopWidth = _sessionInfo.DesktopWidth; - RdpClient.DesktopHeight = _sessionInfo.DesktopHeight; - } + var desktopSize = GetDesktopSize(); + + RdpClient.DesktopWidth = (int)desktopSize.Item1; + RdpClient.DesktopHeight = (int)desktopSize.Item2; + + FixWindowsFormsHostSize(desktopSize.Item1, desktopSize.Item2); + + // Initial scaling before connecting + ((IMsRdpExtendedSettings)RdpClient.GetOcx()).set_Property("DesktopScaleFactor", GetDesktopScaleFactor()); + ((IMsRdpExtendedSettings)RdpClient.GetOcx()).set_Property("DeviceScaleFactor", GetDeviceScaleFactor()); // Authentication RdpClient.AdvancedSettings9.AuthenticationLevel = _sessionInfo.AuthenticationLevel; @@ -306,8 +325,6 @@ private void Connect() // Connect RdpClient.Connect(); - - FixWindowsFormsHostSize(); } private void Reconnect() @@ -317,16 +334,14 @@ private void Reconnect() IsConnecting = true; - // Update screen size - if (_sessionInfo.AdjustScreenAutomatically || _sessionInfo.UseCurrentViewSize) - { - RdpClient.DesktopWidth = (int)RdpGrid.ActualWidth; - RdpClient.DesktopHeight = (int)RdpGrid.ActualHeight; - } + var desktopSize = GetDesktopSize(); - RdpClient.Connect(); + RdpClient.DesktopWidth = (int)desktopSize.Item1; + RdpClient.DesktopHeight = (int)desktopSize.Item2; + + FixWindowsFormsHostSize(desktopSize.Item1, desktopSize.Item2); - FixWindowsFormsHostSize(); + RdpClient.Connect(); } public void FullScreen() @@ -337,24 +352,134 @@ public void FullScreen() RdpClient.FullScreen = true; } - public void AdjustScreen() + public async void AdjustScreen(bool force = false) { - if (!IsConnected) - return; + try + { + // Check preconditions + if (IsConnecting) + { + Log.Debug("AdjustScreen - RDP session is connecting... We can't adjust the screen, yet."); + return; + } - // Adjust screen size - if (_sessionInfo.AdjustScreenAutomatically || _sessionInfo.UseCurrentViewSize) - RdpClient.Reconnect((uint)RdpGrid.ActualWidth, (uint)RdpGrid.ActualHeight); + if (!IsConnected) + { + Log.Debug("AdjustScreen - RDP session is not connected! We can't adjust the screen."); + return; + } + + // Wait for the control to be drawn (if window is resized or the DPI changes) + await Task.Delay(250); + + var desktopSize = GetDesktopSize(); + + Log.Debug($"AdjustScreen - Desktop size: {desktopSize.Item1}x{desktopSize.Item2}"); + + // Check if we need to adjust the screen (always on DPI changes or manual) + if (force == false) + { + var needUpdate = false; + + var windowsFormsHostSize = GetWindowsFormsHostSize(desktopSize.Item1, desktopSize.Item2); + + Log.Debug($"AdjustScreen - WindowsFormsHost size: {windowsFormsHostSize.Item1}x{windowsFormsHostSize.Item2}"); + + if (!(Math.Abs(WindowsFormsHostMaxWidth - windowsFormsHostSize.Item1) < double.Epsilon) || + !(Math.Abs(WindowsFormsHostMaxHeight - windowsFormsHostSize.Item2) < double.Epsilon)) + { + Log.Debug("AdjustScreen - WindowsFormsHost size is not adjusted!" + + $" Old size: {WindowsFormsHostMaxWidth}x{WindowsFormsHostMaxHeight}, new size: {windowsFormsHostSize.Item1}x{windowsFormsHostSize.Item2}"); + needUpdate = true; + } + + + if (!(Math.Abs(RdpClient.Width - desktopSize.Item1) < double.Epsilon) || + !(Math.Abs(RdpClient.Height - desktopSize.Item2) < double.Epsilon)) + { + Log.Debug("AdjustScreen - RDP control size is not adjusted!" + + $" Old size: {RdpClient.Width}x{RdpClient.Height}, new size: {desktopSize.Item1}x{desktopSize.Item2}"); + needUpdate = true; + } + + if (!(Math.Abs(RdpClient.DesktopWidth - desktopSize.Item1) < double.Epsilon) || + !(Math.Abs(RdpClient.DesktopHeight - desktopSize.Item2) < double.Epsilon)) + { + Log.Debug("AdjustScreen - RDP session size is not adjusted!" + + $" Old size: {RdpClient.DesktopWidth}x{RdpClient.DesktopHeight}, new size: {desktopSize.Item1}x{desktopSize.Item2}"); + needUpdate = true; + } + + if (needUpdate) + { + Log.Debug("AdjustScreen - Adjusting screen size..."); + } + else + { + Log.Debug("AdjustScreen - Screen size is already adjusted!"); + return; + } + } + else + { + Log.Debug("AdjustScreen - Screen size adjustment is forced..."); + } - FixWindowsFormsHostSize(); + // Fix the size of the WindowsFormsHost and the RDP control + FixWindowsFormsHostSize(desktopSize.Item1, desktopSize.Item2); + + try + { + // This may fail if the RDP session was connected recently + RdpClient.UpdateSessionDisplaySettings((uint)desktopSize.Item1, (uint)desktopSize.Item2, (uint)desktopSize.Item1, (uint)desktopSize.Item2, 0, GetDesktopScaleFactor(), GetDeviceScaleFactor()); + } + catch (Exception ex) + { + Log.Error("Error while updating the session display settings of the RDP control!", ex); + } + } + catch (Exception e) + { + Log.Error("Could not adjust screen!", e); + } } - private void FixWindowsFormsHostSize() + /// + /// Fix the size of the WindowsFormsHost and the RDP control after the size + /// or DPI of the control has changed. + /// + /// Width of the RDP session. + /// Height of the RDP session. + private void FixWindowsFormsHostSize(double width, double height) { - RdpClientWidth = RdpClient.DesktopWidth; - RdpClientHeight = RdpClient.DesktopHeight; + var windowsFormsHostSize = GetWindowsFormsHostSize(width, height); + + // Set the max width and height for the WindowsFormsHost + WindowsFormsHostMaxWidth = windowsFormsHostSize.Item1; + WindowsFormsHostMaxHeight = windowsFormsHostSize.Item2; + + // Update the size of the RDP control + RdpClient.Width = (int)width; + RdpClient.Height = (int)height; } + private Tuple GetWindowsFormsHostSize(double width, double height) + { + var scaleFactor = GetDpiScaleFactor(); + + var widthScaled = width / scaleFactor * 100; + var heightScaled = height / scaleFactor * 100; + + widthScaled = Math.Ceiling(widthScaled / 2) * 2; + heightScaled = Math.Ceiling(heightScaled / 2) * 2; + + return new Tuple(widthScaled, heightScaled); + } + + /// + /// Send a keystroke to the remote session. + /// + /// Keystroke to send. public void SendKey(Keystroke keystroke) { if (!IsConnected) @@ -369,6 +494,9 @@ public void SendKey(Keystroke keystroke) ocx.SendKeys(info.KeyData.Length, info.ArrayKeyUp, info.KeyData); } + /// + /// Disconnect the RDP session. + /// private void Disconnect() { if (!IsConnected) @@ -377,6 +505,9 @@ private void Disconnect() RdpClient.Disconnect(); } + /// + /// Close the tab. + /// public void CloseTab() { // Prevent multiple calls @@ -487,6 +618,65 @@ private static string GetDisconnectReason(int reason) }; } + /// + /// Get the desktop scale factor based on the DPI scale factor. + /// Supported values are 100, 125, 150, 175, 200. + /// See docs: + /// https://learn.microsoft.com/en-us/windows/win32/termserv/imsrdpextendedsettings-property --> DesktopScaleFactor + /// https://cdnweb.devolutions.net/blog/pdf/smart-resizing-and-high-dpi-issues-in-remote-desktop-manager.pdf + /// + /// + private uint GetDesktopScaleFactor() + { + var scaleFactor = GetDpiScaleFactor(); + + return scaleFactor switch + { + 125 => 125, + 150 or 175 => 150, + 200 => 200, + _ => (uint)(scaleFactor > 200 ? 200 : 100) + }; + } + + /// + /// Get the device scale factor based on the DPI scale factor. + /// Supported values are 100, 140, 180. + /// See docs: + /// https://learn.microsoft.com/en-us/windows/win32/termserv/imsrdpextendedsettings-property --> DeviceScaleFactor + /// https://cdnweb.devolutions.net/blog/pdf/smart-resizing-and-high-dpi-issues-in-remote-desktop-manager.pdf + /// + /// Device scale factor. + private uint GetDeviceScaleFactor() + { + var scaleFactor = GetDpiScaleFactor(); + + switch (scaleFactor) + { + case 125: + case 150: + case 175: + return 140; + case 200: + return 180; + } + + if (scaleFactor > 200) + return 180; + + return 100; + } + + /// + /// Get the current DPI scale factor like 100, 125, 150, 175, 200, 225, etc. + /// + /// Returns the DPI scale factor. + private int GetDpiScaleFactor() + { + var x = System.Windows.Media.VisualTreeHelper.GetDpi(this); + + return (int)(x.PixelsPerDip * 100); + } #endregion #region Events @@ -504,30 +694,16 @@ private void RdpClient_OnDisconnected(object sender, IMsTscAxEvents_OnDisconnect DisconnectReason = GetDisconnectReason(e.discReason); } + #endregion - private void RdpGrid_SizeChanged(object sender, SizeChangedEventArgs e) + public void UpdateOnWindowResize() { - // Resize the RDP screen size when the window size changes - if (IsConnected && _sessionInfo.AdjustScreenAutomatically && !IsReconnecting) - ReconnectOnSizeChanged().ConfigureAwait(false); + if (_sessionInfo.AdjustScreenAutomatically) + AdjustScreen(); } - private async Task ReconnectOnSizeChanged() + private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e) { - IsReconnecting = true; - - do // Prevent to many requests - { - await Task.Delay(250); - } while (Mouse.LeftButton == MouseButtonState.Pressed); - - // Reconnect with the new screen size - RdpClient.Reconnect((uint)RdpGrid.ActualWidth, (uint)RdpGrid.ActualHeight); - - FixWindowsFormsHostSize(); - - IsReconnecting = false; + AdjustScreen(force: true); } - - #endregion -} \ No newline at end of file +} diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index fb2f2dcb6a..846e21c74c 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1555,10 +1555,15 @@ private void Updater_UpdateAvailable(object sender, UpdateAvailableArgs e) #endregion - #region Handle WndProc messages (Single instance, handle HotKeys) + #region Handle WndProc messages (Single instance, handle HotKeys, handle window size events) private HwndSource _hwndSource; + private const int WmExitSizeMove = 0x232; + private const int WmSysCommand = 0x0112; + private const int ScMaximize = 0xF030; + private const int ScRestore = 0xF120; + // This is called after MainWindow() and before OnContentRendered() --> to register hotkeys... protected override void OnSourceInitialized(EventArgs e) { @@ -1578,8 +1583,32 @@ private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref { ShowWindow(); handled = true; + + return IntPtr.Zero; } + // Window size events + switch (msg) + { + case WmExitSizeMove: + _remoteDesktopHostView?.UpdateOnWindowResize(); + break; + + case WmSysCommand: + // Handle system commands (like maximize and restore) + if (wParam.ToInt32() == ScMaximize) + // Window is maximized + _remoteDesktopHostView?.UpdateOnWindowResize(); + + if (wParam.ToInt32() == ScRestore) + // Window is restored (back to normal size from maximized state) + _remoteDesktopHostView?.UpdateOnWindowResize(); + + break; + } + + handled = false; + return IntPtr.Zero; } @@ -1601,7 +1630,7 @@ private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref * 1 | ShowWindow() */ - private readonly List _registeredHotKeys = new(); + private readonly List _registeredHotKeys = []; private void RegisterHotKeys() { diff --git a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs index 50e8c9d81b..0d097bdc2c 100644 --- a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs @@ -1,4 +1,14 @@ -using System; +using Dragablz; +using MahApps.Metro.Controls.Dialogs; +using NETworkManager.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Models; +using NETworkManager.Models.RemoteDesktop; +using NETworkManager.Profiles; +using NETworkManager.Settings; +using NETworkManager.Utilities; +using NETworkManager.Views; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; @@ -9,16 +19,6 @@ using System.Windows.Data; using System.Windows.Input; using System.Windows.Threading; -using Dragablz; -using MahApps.Metro.Controls.Dialogs; -using NETworkManager.Controls; -using NETworkManager.Localization.Resources; -using NETworkManager.Models; -using NETworkManager.Models.RemoteDesktop; -using NETworkManager.Profiles; -using NETworkManager.Settings; -using NETworkManager.Utilities; -using NETworkManager.Views; using RemoteDesktop = NETworkManager.Profiles.Application.RemoteDesktop; namespace NETworkManager.ViewModels; @@ -281,7 +281,7 @@ private void FullscreenAction(object view) private void AdjustScreenAction(object view) { if (view is RemoteDesktopControl control) - control.AdjustScreen(); + control.AdjustScreen(force:true); } public ICommand SendCtrlAltDelCommand => new RelayCommand(SendCtrlAltDelAction, IsConnected_CanExecute); @@ -574,6 +574,12 @@ public void OnViewHide() _isViewActive = false; } + public void UpdateOnWindowResize() + { + foreach (var tab in TabItems) + (tab.View as RemoteDesktopControl)?.UpdateOnWindowResize(); + } + private void SetProfilesView(ProfileInfo profile = null) { Profiles = new CollectionViewSource @@ -652,4 +658,6 @@ private void SearchDispatcherTimer_Tick(object sender, EventArgs e) } #endregion + + } \ No newline at end of file diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs index 1aa2cd384e..45140ed077 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs @@ -1,9 +1,9 @@ -using System.Threading.Tasks; +using MahApps.Metro.Controls.Dialogs; +using NETworkManager.ViewModels; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using MahApps.Metro.Controls.Dialogs; -using NETworkManager.ViewModels; namespace NETworkManager.Views; @@ -54,4 +54,9 @@ public void OnViewVisible() { _viewModel.OnViewVisible(); } + + public void UpdateOnWindowResize() + { + _viewModel.UpdateOnWindowResize(); + } } \ No newline at end of file diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 771ba31bd8..4e7d1c8b94 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -17,7 +17,9 @@ Release date: **xx.xx.2024** ## Breaking Changes -- Minimum supported Windows version increased to `22H2`. [#2912](https://github.com/BornToBeRoot/NETworkManager/pull/2912) +- Minimum supported Windows version increased to `22H2` to support: + - WiFi 6 GHz, WPA3, 802.11be [#2912](https://github.com/BornToBeRoot/NETworkManager/pull/2912) + - Remote Desktop high DPI, scaling and fast resizing [#2968](https://github.com/BornToBeRoot/NETworkManager/pull/2968) ## What's new? @@ -27,6 +29,17 @@ Release date: **xx.xx.2024** - `WPA3 Personal (SAE)`, `WPA3 Enterprise` and `WPA3 Enterprise (192-bit)` are now supported. [#2912](https://github.com/BornToBeRoot/NETworkManager/pull/2912) - `802.11be` (`EHT`) is now supported. [#2912](https://github.com/BornToBeRoot/NETworkManager/pull/2912) +- **Remote Desktop** + + - Scale rdp session and control to support high DPI (e.g. per Monitor DPI like 125%, 150%, etc.). [#2968](https://github.com/BornToBeRoot/NETworkManager/pull/2968) + - Resizing now uses [`IMsRdpClient9::UpdateSessionDisplaySettings`]() instead of [`IMsRdpClient::Reconnect`](https://learn.microsoft.com/en-us/windows/win32/termserv/imsrdpclient8-reconnect) to support scaling and faster resizing (without the need of reconnecting). [#2968](https://github.com/BornToBeRoot/NETworkManager/pull/2968). + + :::warning + + The new features for high DPI, scaling and resizing may cause issues or doesn't work with legacy servers/clients. Please report any issues you find here: [#2911](https://github.com/BornToBeRoot/NETworkManager/issues/2911) + + ::: + ## Improvements - Improve ToolTips (e.g. migrate from Twitter to X, etc.), Buttons, etc. [#2955](https://github.com/BornToBeRoot/NETworkManager/pull/2955)