From d90575b8da842233c5b6bb3b606fbee339dc1d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tib=C3=A8re=20B=2E?= Date: Fri, 22 Aug 2025 14:42:36 +0200 Subject: [PATCH] feat(MouseWithoutBorders): Prevent Easy Mouse from moving to another machine when an application is running in fullscreen mode. (#39854) ## Summary of the Pull Request This PR adds a new feature to Easy Mouse, it is now possible to toggle a setting that will prevent Easy Mouse to switch away from the host machine when the foreground application is running in full screen mode, requiring the user to first alt tab out of the application before performing the switch, this also comes with a way to allow the switch on specific apps. ![image](https://github.com/user-attachments/assets/e45bbfa7-89c9-4051-8f1a-f2ac2648a6ca) ## PR Checklist - [x] **Closes:** #32197 - [x] **Communication:** I've discussed this with core contributors already. If work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [x] **Localization:** All end user facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [x] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: MicrosoftDocs/windows-dev-docs#5470 ## Detailed Description of the Pull Request / Additional comments This PR changes the way Easy Mouse checks wherever it should move to another machine, after checking that the corresponding setting is enabled and that we are trying to move away from the host machine, it will run a test using native WinAPI methods to get the foreground window and check if it is running in full screen. If it is, it will then check the name of the executable against a list of ignored app configured by the user, if the executable is found in that list, the switch will be allowed despite the application running in full screen. These new settings were moved along with the original Easy Mouse toggle to a new "Easy Mouse" setting group to avoid cluttering the Keyboard shortcuts group. This feature will only work when used from the controller machine, as I didn't find a way to easily check for running application on a remote machine that didn't involved touching the sockets, I felt like such a change would be out of scope for this issue. ## Validation Steps Performed I had a hard time writing tests and didn't achieve anything meaningful enough to be included, I may require some guidance on how to properly write tests for this project. I tested my changes by running my modified version of MouseWithoutBorders on my machines, which I did for a few days now, It allowed me to catch a few bugs, but it has been running smoothly otherwise. My changes didn't seemed to have caused any automated tests to fail. It may require some additional testing for setups including more than two machines. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Kai Tao (from Dev Box) Co-authored-by: Niels Laute Co-authored-by: Gordon Lam (SH) --- .../MouseWithoutBorders/App/Class/Common.cs | 94 +++++++++++++++++++ .../App/Class/NativeMethods.cs | 78 +++++++++++---- .../MouseWithoutBorders/App/Class/Setting.cs | 38 ++++++++ .../MouseWithoutBorders/App/Core/Event.cs | 13 ++- .../MouseWithoutBordersProperties.cs | 11 +++ .../Views/MouseWithoutBordersPage.xaml | 42 ++++++++- .../Settings.UI/Strings/en-us/Resources.resw | 24 +++++ .../MouseWithoutBordersViewModel.cs | 61 ++++++++++++ 8 files changed, 338 insertions(+), 23 deletions(-) diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.cs b/src/modules/MouseWithoutBorders/App/Class/Common.cs index 8d4ff7f326..0494a952fd 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Common.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Common.cs @@ -13,6 +13,7 @@ using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -1560,5 +1561,98 @@ namespace MouseWithoutBorders } } } + + private static bool DisableEasyMouseWhenForegroundWindowIsFullscreenSetting() + { + return Setting.Values.DisableEasyMouseWhenForegroundWindowIsFullscreen; + } + + private static bool IsAppIgnoredByEasyMouseFullscreenCheck(IntPtr foregroundWindowHandle) + { + if (NativeMethods.GetWindowThreadProcessId(foregroundWindowHandle, out var processId) == 0) + { + Logger.LogDebug($"GetWindowThreadProcessId failed with error : {Marshal.GetLastWin32Error()}"); + return false; + } + + var processHandle = NativeMethods.OpenProcess(0x1000, false, processId); + if (processHandle == IntPtr.Zero) + { + return false; + } + + uint maxPath = 260; + var nameBuffer = new char[maxPath]; + if (!NativeMethods.QueryFullProcessImageName( + processHandle, NativeMethods.QUERY_FULL_PROCESS_NAME_FLAGS.DEFAULT, nameBuffer, ref maxPath)) + { + Logger.LogDebug($"QueryFullProcessImageName failed with error : {Marshal.GetLastWin32Error()}"); + NativeMethods.CloseHandle(processHandle); + return false; + } + + NativeMethods.CloseHandle(processHandle); + + var name = new string(nameBuffer, 0, (int)maxPath); + + var excludedApps = Setting.Values.EasyMouseFullscreenSwitchBlockExcludedApps; + + return excludedApps.Contains(Path.GetFileNameWithoutExtension(name), StringComparer.OrdinalIgnoreCase) + || excludedApps.Contains(Path.GetFileName(name), StringComparer.OrdinalIgnoreCase); + } + + internal static bool IsEasyMouseBlockedByFullscreenWindow() + { + var shellHandle = NativeMethods.GetShellWindow(); + var desktopHandle = NativeMethods.GetDesktopWindow(); + var foregroundHandle = NativeMethods.GetForegroundWindow(); + + // If the foreground window is either the desktop or the Windows shell, we are not in fullscreen mode. + if (foregroundHandle.Equals(shellHandle) || foregroundHandle.Equals(desktopHandle)) + { + return false; + } + + if (NativeMethods.SHQueryUserNotificationState(out var userNotificationState) != 0) + { + Logger.LogDebug($"SHQueryUserNotificationState failed with error : {Marshal.GetLastWin32Error()}"); + return false; + } + + switch (userNotificationState) + { + // An application running in full screen mode, check if the foreground window is + // listed as ignored in the settings. + case NativeMethods.USER_NOTIFICATION_STATE.BUSY: + case NativeMethods.USER_NOTIFICATION_STATE.RUNNING_D3D_FULL_SCREEN: + case NativeMethods.USER_NOTIFICATION_STATE.PRESENTATION_MODE: + return !IsAppIgnoredByEasyMouseFullscreenCheck(foregroundHandle); + + // No full screen app running. + case NativeMethods.USER_NOTIFICATION_STATE.NOT_PRESENT: + case NativeMethods.USER_NOTIFICATION_STATE.ACCEPTS_NOTIFICATIONS: + case NativeMethods.USER_NOTIFICATION_STATE.QUIET_TIME: + // Cannot determine + case NativeMethods.USER_NOTIFICATION_STATE.APP: + default: + return false; + } + } + + /// + /// Check if a machine switch triggered by EasyMouse would be allowed to proceed due to other settings. + /// + /// A boolean that tells us if the switch isn't blocked by any other settings + internal static bool IsEasyMouseSwitchAllowed() + { + // Never prevent a switch if we are not moving out of the host machine. + if (!DisableEasyMouseWhenForegroundWindowIsFullscreenSetting() || DesMachineID != MachineID) + { + return true; + } + + // Check if the switch is blocked by a full-screen window running in the foreground + return !IsEasyMouseBlockedByFullscreenWindow(); + } } } diff --git a/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs b/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs index 1b045a61a0..539e0267bd 100644 --- a/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs +++ b/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs @@ -122,9 +122,16 @@ namespace MouseWithoutBorders.Class [DllImport("user32.dll", SetLastError = false)] internal static extern IntPtr GetDesktopWindow(); + [LibraryImport("user32.dll")] + internal static partial IntPtr GetShellWindow(); + [DllImport("user32.dll")] internal static extern IntPtr GetWindowDC(IntPtr hWnd); + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetWindowRect(IntPtr hWnd, out RECT rect); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern int DrawText(IntPtr hDC, string lpString, int nCount, ref RECT lpRect, uint uFormat); @@ -291,6 +298,17 @@ namespace MouseWithoutBorders.Class [DllImport("user32.dll", SetLastError = true)] internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [LibraryImport("kernel32.dll", + EntryPoint = "QueryFullProcessImageNameW", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool QueryFullProcessImageName( + IntPtr hProcess, QUERY_FULL_PROCESS_NAME_FLAGS dwFlags, [Out] char[] lpExeName, ref uint lpdwSize); + + [LibraryImport("shell32.dll", SetLastError = true)] + internal static partial int SHQueryUserNotificationState(out USER_NOTIFICATION_STATE state); + [StructLayout(LayoutKind.Sequential)] internal struct POINT { @@ -333,11 +351,11 @@ namespace MouseWithoutBorders.Class [DllImport("ntdll.dll")] internal static extern int NtQueryInformationProcess( - IntPtr hProcess, - int processInformationClass /* 0 */, - ref PROCESS_BASIC_INFORMATION processBasicInformation, - uint processInformationLength, - out uint returnLength); + IntPtr hProcess, + int processInformationClass /* 0 */, + ref PROCESS_BASIC_INFORMATION processBasicInformation, + uint processInformationLength, + out uint returnLength); #endif #if USE_GetSecurityDescriptorSacl @@ -632,14 +650,14 @@ namespace MouseWithoutBorders.Class { internal int LowPart; internal int HighPart; - }// end struct + } // end struct [StructLayout(LayoutKind.Sequential)] internal struct LUID_AND_ATTRIBUTES { internal LUID Luid; internal int Attributes; - }// end struct + } // end struct [StructLayout(LayoutKind.Sequential)] internal struct TOKEN_PRIVILEGES @@ -670,23 +688,23 @@ namespace MouseWithoutBorders.Class internal const int TOKEN_ADJUST_SESSIONID = 0x0100; internal const int TOKEN_ALL_ACCESS_P = STANDARD_RIGHTS_REQUIRED | - TOKEN_ASSIGN_PRIMARY | - TOKEN_DUPLICATE | - TOKEN_IMPERSONATE | - TOKEN_QUERY | - TOKEN_QUERY_SOURCE | - TOKEN_ADJUST_PRIVILEGES | - TOKEN_ADJUST_GROUPS | - TOKEN_ADJUST_DEFAULT; + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_IMPERSONATE | + TOKEN_QUERY | + TOKEN_QUERY_SOURCE | + TOKEN_ADJUST_PRIVILEGES | + TOKEN_ADJUST_GROUPS | + TOKEN_ADJUST_DEFAULT; internal const int TOKEN_ALL_ACCESS = TOKEN_ALL_ACCESS_P | TOKEN_ADJUST_SESSIONID; internal const int TOKEN_READ = STANDARD_RIGHTS_READ | TOKEN_QUERY; internal const int TOKEN_WRITE = STANDARD_RIGHTS_WRITE | - TOKEN_ADJUST_PRIVILEGES | - TOKEN_ADJUST_GROUPS | - TOKEN_ADJUST_DEFAULT; + TOKEN_ADJUST_PRIVILEGES | + TOKEN_ADJUST_GROUPS | + TOKEN_ADJUST_DEFAULT; internal const int TOKEN_EXECUTE = STANDARD_RIGHTS_EXECUTE; @@ -940,6 +958,30 @@ namespace MouseWithoutBorders.Class NameDnsDomain = 12, } + internal enum MONITOR_FROM_WINDOW_FLAGS : uint + { + DEFAULT_TO_NULL = 0x00000000, + DEFAULT_TO_PRIMARY = 0x00000001, + DEFAULT_TO_NEAREST = 0x00000002, + } + + internal enum QUERY_FULL_PROCESS_NAME_FLAGS : uint + { + DEFAULT = 0x00000000, + PROCESS_NAME_NATIVE = 0x00000001, + } + + internal enum USER_NOTIFICATION_STATE + { + NOT_PRESENT = 1, + BUSY = 2, + RUNNING_D3D_FULL_SCREEN = 3, + PRESENTATION_MODE = 4, + ACCEPTS_NOTIFICATIONS = 5, + QUIET_TIME = 6, + APP = 7, + } + [DllImport("secur32.dll", CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.I1)] internal static extern bool GetUserNameEx(int nameFormat, StringBuilder userName, ref uint userNameSize); diff --git a/src/modules/MouseWithoutBorders/App/Class/Setting.cs b/src/modules/MouseWithoutBorders/App/Class/Setting.cs index 72365fe478..30b99a97d0 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Setting.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Setting.cs @@ -414,6 +414,44 @@ namespace MouseWithoutBorders.Class } } + internal bool DisableEasyMouseWhenForegroundWindowIsFullscreen + { + get + { + lock (_loadingSettingsLock) + { + return _properties.DisableEasyMouseWhenForegroundWindowIsFullscreen; + } + } + + set + { + lock (_loadingSettingsLock) + { + _properties.DisableEasyMouseWhenForegroundWindowIsFullscreen = value; + } + } + } + + internal HashSet EasyMouseFullscreenSwitchBlockExcludedApps + { + get + { + lock (_loadingSettingsLock) + { + return _properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value; + } + } + + set + { + lock (_loadingSettingsLock) + { + _properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value = value; + } + } + } + internal string Enc(string st, bool dec, DataProtectionScope protectionScope) { if (st == null || st.Length < 1) diff --git a/src/modules/MouseWithoutBorders/App/Core/Event.cs b/src/modules/MouseWithoutBorders/App/Core/Event.cs index 7856a64d87..c30c59e547 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Event.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Event.cs @@ -66,13 +66,17 @@ internal static class Event try { Common.PaintCount = 0; - bool switchByMouseEnabled = IsSwitchingByMouseEnabled(); - if (switchByMouseEnabled && Common.Sk != null && (Common.DesMachineID == Common.MachineID || !Setting.Values.MoveMouseRelatively) && e.dwFlags == Common.WM_MOUSEMOVE) + // Check if easy mouse setting is enabled. + bool isEasyMouseEnabled = IsSwitchingByMouseEnabled(); + + if (isEasyMouseEnabled && Common.Sk != null && (Common.DesMachineID == Common.MachineID || !Setting.Values.MoveMouseRelatively) && e.dwFlags == Common.WM_MOUSEMOVE) { Point p = MachineStuff.MoveToMyNeighbourIfNeeded(e.X, e.Y, MachineStuff.desMachineID); - if (!p.IsEmpty) + // Check if easy mouse switches are disabled when an application is running in fullscreen mode, + // if they are, check that there is no application running in fullscreen mode before switching. + if (!p.IsEmpty && Common.IsEasyMouseSwitchAllowed()) { Common.HasSwitchedMachineSinceLastCopy = true; @@ -165,7 +169,8 @@ internal static class Event string newDesMachineName = MachineStuff.NameFromID(newDesMachineID); if (!Common.IsConnectedTo(newDesMachineID)) - {// Connection lost, cancel switching + { + // Connection lost, cancel switching Logger.LogDebug("No active connection found for " + newDesMachineName); // ShowToolTip("No active connection found for [" + newDesMachineName + "]!", 500); diff --git a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs index 265b8a1e2d..e234ee6b2f 100644 --- a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs @@ -92,6 +92,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library public IntProperty EasyMouse { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool DisableEasyMouseWhenForegroundWindowIsFullscreen { get; set; } + + // Apps that are to be excluded when using DisableEasyMouseWhenForegroundWindowIsFullscreen + // meaning that it is possible to switch screen when these apps are running in fullscreen. + [CmdConfigureIgnore] + public GenericProperty> EasyMouseFullscreenSwitchBlockExcludedApps { get; set; } + [CmdConfigureIgnore] public IntProperty MachineID { get; set; } @@ -173,6 +181,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowOriginalUI = false; UseService = false; + DisableEasyMouseWhenForegroundWindowIsFullscreen = true; + EasyMouseFullscreenSwitchBlockExcludedApps = new GenericProperty>(new HashSet(StringComparer.OrdinalIgnoreCase)); + HotKeySwitchMachine = new IntProperty(0x70); // VK.F1 ToggleEasyMouseShortcut = DefaultHotKeyToggleEasyMouse; LockMachineShortcut = DefaultHotKeyLockMachine; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml index 32f4e79d8b..c95bb47dee 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml @@ -267,7 +267,8 @@ - + + @@ -276,7 +277,46 @@ + + + + + + + + + + + + + + + Advanced Settings + + Easy Mouse + Easy Mouse: move between machines by moving the mouse pointer to the screen edges. @@ -419,6 +422,27 @@ Shift This is the Shift keyboard key + + Disable Easy Mouse when an application is running in full screen. + + + Prevent Easy Mouse from moving to another machine when an application is in full-screen mode. + + + Disabling Easy Mouse in full-screen mode only affects the host PC. It won’t stop the mouse from moving away from remote machines. + + + msedge.exe +firefox.exe +opera.exe + Allow easy mouse when chrome is in fullscreen mode. + + + Ignored fullscreen applications + + + Allow Easy Mouse to move between machines even if one of these applications is running in full screen, separate each executable with a new line. + Shortcut to lock all machines. diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs index 3d81acd81d..6e0bcaa444 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs @@ -904,6 +904,43 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + private string _easyMouseIgnoredFullscreenAppsString; + + public string EasyMouseFullscreenSwitchBlockExcludedApps + { + // Convert the list of excluded apps retrieved from the settings + // to a single string that can be displayed in the bound textbox + get + { + if (_easyMouseIgnoredFullscreenAppsString == null) + { + var excludedApps = Settings.Properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value; + _easyMouseIgnoredFullscreenAppsString = excludedApps.Count == 0 ? string.Empty : string.Join('\r', excludedApps); + } + + return _easyMouseIgnoredFullscreenAppsString; + } + + set + { + if (EasyMouseFullscreenSwitchBlockExcludedApps == value) + { + return; + } + + _easyMouseIgnoredFullscreenAppsString = value; + + var ignoredAppsSet = new HashSet(StringComparer.OrdinalIgnoreCase); + if (value != string.Empty) + { + ignoredAppsSet.UnionWith(value.Split('\r', StringSplitOptions.RemoveEmptyEntries)); + } + + Settings.Properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value = ignoredAppsSet; + NotifyPropertyChanged(); + } + } + public bool CardForName2IpSettingIsEnabled => _disableUserDefinedIpMappingRulesIsGPOConfigured == false; public bool ShowPolicyConfiguredInfoForName2IPSetting => _disableUserDefinedIpMappingRulesIsGPOConfigured && IsEnabled; @@ -1005,6 +1042,30 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EasyMouseEnabled => (EasyMouseOption)EasyMouseOptionIndex != EasyMouseOption.Disable; + + public bool IsEasyMouseBlockingOnFullscreenEnabled => + EasyMouseEnabled && DisableEasyMouseWhenForegroundWindowIsFullscreen; + + public bool DisableEasyMouseWhenForegroundWindowIsFullscreen + { + get + { + return Settings.Properties.DisableEasyMouseWhenForegroundWindowIsFullscreen; + } + + set + { + if (Settings.Properties.DisableEasyMouseWhenForegroundWindowIsFullscreen == value) + { + return; + } + + Settings.Properties.DisableEasyMouseWhenForegroundWindowIsFullscreen = value; + NotifyPropertyChanged(); + } + } + public HotkeySettings ToggleEasyMouseShortcut { get => Settings.Properties.ToggleEasyMouseShortcut;