diff --git a/AGENTS.md b/AGENTS.md index 7f8924fc..179ad35e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ g:\Users\2 Better\Desktop\design\ │ ├── AppThemeManager.cs # 窗口深浅色主题画刷注入管理器 │ ├── FullScreenHelper.cs # 独占全屏检测与 Windows Explorer 穿透识别 │ ├── ActiveWindowHelper.cs # 前台活动窗口探测器 +│ ├── WheelFocusSwitcher.cs # 鼠标呼出轮盘时的前台焦点对齐(解析呼出点所在窗口并激活) │ ├── MemoryOptimizer.cs # 内存整理与工作集压缩工具 │ ├── I18n.cs # 多语言国际化字典 (zh-CN, zh-TW, en-US, ja-JP) │ ├── Renderers/ # 轮盘切削形态渲染器策略族 @@ -218,6 +219,15 @@ g:\Users\2 Better\Desktop\design\ - **初始化不得产生系统副作用**:WPF 给 `CheckBox.IsChecked` 赋值时也可能触发 `Checked/Unchecked`。加载自启动状态时必须同时使用 `_isUpdatingUi`、`_isUiInitializing` 与 `_isLoadingAutoStartState` 防护,并比较已加载状态;只有用户实际修改开关时才能调用 `ConfigManager.SetAutoStart()`,严禁打开控制台时创建或删除计划任务。 - **显式退出模式**:`App.xaml` 必须保持 `ShutdownMode="OnExplicitShutdown"`,关闭最后一个设置窗口不能结束后台 Hook 与托盘进程;只有托盘退出、提权重启或明确的应用退出流程可以调用 `Shutdown()`。 +### 3.7 鼠标呼出轮盘时的前台焦点对齐 +- **触发键的点击被自己吞掉了,宿主窗口就不会被激活**。轮盘触发键(右键/中键/侧键)的 DOWN/UP 都由 `MouseHook` 拦截,被点中的那个窗口收不到任何鼠标消息,Windows 自然不会把它切到前台 —— 前台仍留在按下之前那个窗口上。多显示器场景下这条缺陷最刺眼:光标在显示器 2、轮盘也画在显示器 2,但松手后执行的动作(`SendInput` 注入的快捷键、窗口操作)全部落进显示器 1 上原来那个前台窗口。单屏上"右键点在背景窗口上"同样成立,只是不容易被注意到。 +- **修法**:`WheelFocusSwitcher.SwitchFocusToPoint(呼出点)` 在轮盘真正上屏之前调用一次(`GestureController.ShowRadialUI` 的最前面),把前台切到呼出点所在的顶层窗口;激活复用 `WindowTaskbarHelper.ActivateWindow`(前台锁的 `AttachThreadInput` / `Alt` 兜底都在那里),本类只负责"解析出该切给谁",不注入点击、不改窗口样式、不移动窗口。 +- **只服务「鼠标呼出轮盘」这一条路径**:`ShowRadialUI` 的 `switchFocusToWheelPoint` 参数只有鼠标触发传 `true`(拖动阈值路径看 `_kbTriggerWaiting`,长按路径看 `isMouseWaiting`)。键盘触发(穿透模式)与插件经 `IHostWheelService` 呼出的粘滞轮盘一律不动前台 —— 前者用户正在某个窗口里打字,后者连触发都不在钩子上,抢前台都是错的。 +- **⚠️ 光切前台还不够:轮盘方案也必须一起换过去。** 方案是按进程名取的(`ConfigManager.GetProfileForProcess`),而进程名过去一律取 `ActiveWindowHelper.GetActiveWindowProcessName()`(**前台**窗口)。跨显示器呼出时前台还在另一块屏幕上,于是第一次呼出拿到的是**另一个程序**的扇区配置,前台虽然被切过去了、用户却得**再呼出一次**才看到该有的选项。所以鼠标路径改用 `WheelFocusSwitcher.TryGetProcessNameAtPoint(呼出点)`(解析同一个目标窗口 → `ActiveWindowHelper.GetProcessNameForWindow`),键盘路径与解析失败时回落到前台窗口,行为不变。 + - **解析必须在钩子线程上做、激活必须留到 UI 线程**:`ProcessMove(初始位置)` 在 `BeginInvoke` 之前就按 `_activeProfile` 算好了初始扇区并写进手势状态,松手执行的动作也读这一份 —— 方案若等到 UI 线程才换,初判扇区会按**另一个方案**的扇区数算(12 扇区方案算出 10,套到 8 扇区方案上),用户松手可能落到错的扇区或空扇区。所以 `TryGetProcessNameAtPoint` 刻意只读(`WindowFromPoint`/`GetWindowRect`/`GetWindowLongPtr` 之类,钩子已有同类调用先例),前台切换仍留在 `ShowRadialUI`(UI 线程)里。 +- **解析规则(判据一律保守,宁可不切也不切错)**:先 `WindowFromPoint` + `GetAncestor(GA_ROOT)`;命中的是自己人的窗口(轮盘 HWND 隐藏后仍占着原位置、轨迹浮层)或 no-activate 悬浮层时,沿 z 序自上而下兜底找真正压在下面的窗口。**兜底只对这两类成立**:命中的是 Shell 表面(桌面、任务栏)时绝不下钻 —— 桌面铺满整块屏幕、任务栏上方还压着最大化的程序,往下找到的第一个「矩形盖住该点」的窗口就是那个无关程序,在桌面/任务栏空白处呼出轮盘会把用户当前软件抢走;此时宁可不动前台。被排除的目标:StarPie 自身进程的窗口、`WS_EX_NOACTIVATE` / `WS_EX_TRANSPARENT` 窗口、最小化/不可见窗口、桌面与任务栏等 Shell 表面(类名清单见 `ShellWindowClasses`)。目标已是前台时直接返回,不做任何多余的置顶/还原动作。 +- **机器护栏**:`scratch/test_wheel_focus.ps1`(真摆几层窗口做端到端验证,含"探针自己那层确实压在最上面"的前置条件断言,另有一条拉真记事本断言"进程名取自呼出点而不是前台窗口",以及一条"Shell 表面上不做 z 序兜底、宁可不动前台")。这段逻辑够不着单测与 UI 套件 —— 它们要的是屏幕上真的压着几层窗口;变异测试验过:去掉"自己的窗口"判据、去掉 no-activate 判据、把进程名换回取前台窗口,各自会红掉对应的断言。 + --- ## 4. 🧩 插件系统:契约、边界与运行时实现 diff --git a/CHANGELOG.md b/CHANGELOG.md index cb32b2af..86a32d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ - **旧配置兼容**:Launch 插件在新参数缺失时兼容 RunAsStandardUser;显式的新值优先。参数回填不会自动写盘。 - **冷启动编辑支持**:编辑认领动作时仅按需激活当前已启用插件以取得声明,不改变启用或预加载偏好;参数校验本身仍不触发加载。 - **环境失败提示**:取消授权、能力不足或不支持所选模式时提供可见提示,避免将环境条件误计为插件缺陷。 +- **鼠标呼出轮盘时自动对齐前台焦点,并同步切到该窗口的轮盘方案**:触发键的按下/抬起一直被 StarPie 的低级鼠标钩子吞掉,被点中的窗口收不到点击、也就不会被 Windows 激活,于是前台留在原窗口上。多显示器场景下表现为:光标已经移到显示器 2、轮盘也画在显示器 2,松手后执行的动作(快捷键注入、窗口操作等)却全部落进显示器 1 上原来那个前台窗口。现在由新增的 `WheelFocusSwitcher` 在轮盘上屏之前把前台切到呼出点所在的顶层窗口。 + - **轮盘方案(扇区选项)也一起跟过去**:方案按进程名取,而进程名原先一律取**前台**窗口 —— 跨屏呼出时前台还在另一块屏幕上,第一次呼出拿到的是**另一个程序**的配置,用户得再呼出一次才看到该有的选项。现改为鼠标路径按呼出点所在窗口取进程名(`WheelFocusSwitcher.TryGetProcessNameAtPoint` + `ActiveWindowHelper.GetProcessNameForWindow`),键盘路径与解析失败时仍回落到前台窗口。 + - **只影响「用鼠标呼出轮盘」这一条路径**:键盘触发(穿透模式)与插件经 `IHostWheelService` 呼出的粘滞轮盘不改前台、也不改方案来源,其它既有行为一律不动。 + - **判据保守**:StarPie 自身进程的窗口、`WS_EX_NOACTIVATE`/`WS_EX_TRANSPARENT` 悬浮层、最小化/不可见窗口、桌面与任务栏等 Shell 表面都不是切换目标(命中的是自己人的那层时,沿 z 序向下找真正压在下面的窗口);**命中的是桌面/任务栏等 Shell 表面时不做这层下钻**(桌面铺满屏幕、任务栏上方还压着最大化程序,下钻会把无关程序抢到前台),宁可不动前台。目标已是前台时不做多余动作。进程名解析刻意只读,前台切换仍留在 UI 线程(钩子线程不做激活)。 + - **验证**:新增 `scratch/test_wheel_focus.ps1` 端到端探针(真摆几层窗口 + 前置条件断言 + 拉真记事本断言"进程名取自呼出点而非前台窗口"),变异测试确认去掉"自己的窗口"判据、去掉 no-activate 判据、或把进程名换回取前台窗口,都会红掉对应断言。 ### 🧩 兼容性与升级要求 diff --git a/WinPieGestures/ActiveWindowHelper.cs b/WinPieGestures/ActiveWindowHelper.cs index a9a7a524..f9ce3833 100644 --- a/WinPieGestures/ActiveWindowHelper.cs +++ b/WinPieGestures/ActiveWindowHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Runtime.InteropServices; using System.Text; @@ -25,7 +25,7 @@ public static class ActiveWindowHelper [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(nint hObject); - private static nint _cachedForegroundHwnd = IntPtr.Zero; + private static nint _cachedWindow = IntPtr.Zero; private static string _cachedProcessName = "unknown.exe"; private static long _cachedTick = 0; private static readonly object _cacheLock = new object(); @@ -49,59 +49,96 @@ public static string GetActiveWindowInfo(out nint foregroundWindow) long now = Environment.TickCount64; lock (_cacheLock) { - if (foregroundWindow == _cachedForegroundHwnd && (now - _cachedTick) < 150) + if (foregroundWindow == _cachedWindow && (now - _cachedTick) < 150) { return _cachedProcessName; } } - GetWindowThreadProcessId(foregroundWindow, out var lpdwProcessId); - if (lpdwProcessId == 0) + return ResolveProcessName(foregroundWindow); + } + catch + { + return "unknown.exe"; + } + } + + /// + /// 指定窗口所在进程的可执行文件名(小写,与 同一格式)。 + /// + /// 存在的理由:轮盘方案是按进程名取的,而「用鼠标呼出轮盘时该属于哪个程序」必须看 + /// 呼出点所在的窗口,不是前台窗口 —— 多显示器上把光标移到另一块屏幕时,前台窗口还在原屏幕上, + /// 于是轮盘会错用另一个程序的方案(用户得呼出第二次才正确)。 + /// + public static string GetProcessNameForWindow(nint window) + { + if (window == IntPtr.Zero) + { + return "unknown.exe"; + } + try + { + long now = Environment.TickCount64; + lock (_cacheLock) { - return "unknown.exe"; + // 与前台路径共用同一格缓存:键就是 hwnd,查的是别的窗口只会未命中并重算,不会读到错的名字。 + if (window == _cachedWindow && (now - _cachedTick) < 150) + { + return _cachedProcessName; + } } + return ResolveProcessName(window); + } + catch + { + return "unknown.exe"; + } + } + + private static string ResolveProcessName(nint window) + { + GetWindowThreadProcessId(window, out var lpdwProcessId); + if (lpdwProcessId == 0) + { + return "unknown.exe"; + } - nint hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, lpdwProcessId); - if (hProcess != IntPtr.Zero) + nint hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, lpdwProcessId); + if (hProcess != IntPtr.Zero) + { + try { - try + uint size = 1024; + StringBuilder sb = new StringBuilder((int)size); + if (QueryFullProcessImageName(hProcess, 0, sb, ref size)) { - uint size = 1024; - StringBuilder sb = new StringBuilder((int)size); - if (QueryFullProcessImageName(hProcess, 0, sb, ref size)) + string fullPath = sb.ToString(); + string fileName = Path.GetFileName(fullPath); + if (!string.IsNullOrEmpty(fileName)) { - string fullPath = sb.ToString(); - string fileName = Path.GetFileName(fullPath); - if (!string.IsNullOrEmpty(fileName)) + string procName = fileName.ToLowerInvariant(); + lock (_cacheLock) { - string procName = fileName.ToLowerInvariant(); - lock (_cacheLock) - { - _cachedForegroundHwnd = foregroundWindow; - _cachedProcessName = procName; - _cachedTick = Environment.TickCount64; - } - return procName; + _cachedWindow = window; + _cachedProcessName = procName; + _cachedTick = Environment.TickCount64; } + return procName; } } - finally - { - CloseHandle(hProcess); - } } - - lock (_cacheLock) + finally { - _cachedForegroundHwnd = foregroundWindow; - _cachedProcessName = "unknown.exe"; - _cachedTick = Environment.TickCount64; + CloseHandle(hProcess); } - return "unknown.exe"; } - catch + + lock (_cacheLock) { - return "unknown.exe"; + _cachedWindow = window; + _cachedProcessName = "unknown.exe"; + _cachedTick = Environment.TickCount64; } + return "unknown.exe"; } } diff --git a/WinPieGestures/GestureController.cs b/WinPieGestures/GestureController.cs index d7a9ac8f..17d76bd1 100644 --- a/WinPieGestures/GestureController.cs +++ b/WinPieGestures/GestureController.cs @@ -1421,8 +1421,11 @@ private void LongPressTimerCallback(int generation) _currentDpiScaleX = scaleX; _currentDpiScaleY = scaleY; } - string processName = ActiveWindowHelper.GetActiveWindowProcessName(); - WheelProfile profile = ConfigManager.GetProfileForProcess(processName); + // 同拖动路径:鼠标呼出时轮盘方案必须按呼出点所在的窗口取(键盘触发保持原样,只读解析不切前台)。 + string? processAtWheelPoint = isMouseWaiting + ? WheelFocusSwitcher.TryGetProcessNameAtPoint(_startPoint) + : null; + WheelProfile profile = ConfigManager.GetProfileForProcess(processAtWheelPoint ?? ActiveWindowHelper.GetActiveWindowProcessName()); long gestureVersion = GetCurrentGestureVersion(); Point startPoint = _startPoint; ((DispatcherObject)Application.Current).Dispatcher.BeginInvoke((Delegate)(Action)delegate @@ -1433,7 +1436,7 @@ private void LongPressTimerCallback(int generation) { return; } - if (ShowRadialUI(startPoint, profile, gestureVersion)) + if (ShowRadialUI(startPoint, profile, gestureVersion, switchFocusToWheelPoint: isMouseWaiting)) { ProcessMove(startPoint); ApplyPendingHighlight(); @@ -2005,13 +2008,21 @@ private void Hook_OnMouseMove(object? sender, MouseEventArgs e) { return; } + // 本帧越过拖动阈值的呼出来源:键盘触发(穿透模式)时 _kbTriggerWaiting 为 true。 + // 只有鼠标触发才允许抢前台焦点(见 ShowRadialUI 的 switchFocusToWheelPoint)。 + bool switchFocusToWheelPoint = !_kbTriggerWaiting; _kbTriggerWaiting = false; _isWaitingForThreshold = false; _isGestureActive = true; _soundSessionId = SoundEffectManager.BeginSession(SoundSessionSource.NormalGesture); CancelLongPressTimer(); // 拖动先于长按触发 - string activeWindowProcessName = ActiveWindowHelper.GetActiveWindowProcessName(); - _activeProfile = ConfigManager.GetProfileForProcess(activeWindowProcessName); + // 轮盘方案按「呼出点所在的窗口」取,而不是前台窗口:跨显示器呼出时前台窗口还在另一块屏幕上, + // 沿用它的进程会拿到另一个程序的方案,用户得呼出第二次才对(前台这时才被切过去)。 + // 这里只做只读解析 —— 钩子线程绝不做前台切换,激活留在 ShowRadialUI 里。 + string? processAtWheelPoint = switchFocusToWheelPoint + ? WheelFocusSwitcher.TryGetProcessNameAtPoint(_startPoint) + : null; + _activeProfile = ConfigManager.GetProfileForProcess(processAtWheelPoint ?? ActiveWindowHelper.GetActiveWindowProcessName()); Point center = _startPoint; WheelProfile profile = _activeProfile; Point initialPos = e.Position; @@ -2027,7 +2038,7 @@ private void Hook_OnMouseMove(object? sender, MouseEventArgs e) { return; } - if (ShowRadialUI(center, profile, gestureVersion)) + if (ShowRadialUI(center, profile, gestureVersion, switchFocusToWheelPoint)) { ApplyPendingHighlight(); ApplyVolumePreview(); @@ -2175,7 +2186,12 @@ private void ProcessMove(Point currentPoint) QueueHighlightUpdate(num4, num5, flag, flag2, GetCurrentGestureVersion()); } - private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersion) + /// + /// 是否把前台焦点切到轮盘呼出点所在的窗口。只有「鼠标呼出轮盘」的路径传 true: + /// 触发键的点击已被本程序的鼠标钩子吞掉,宿主窗口收不到激活,多显示器下会让动作落到另一块屏幕的窗口上。 + /// 键盘触发与插件粘滞轮盘一律传 false,前台语义保持原样。 + /// + private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersion, bool switchFocusToWheelPoint) { profile.EnsureLayers(); profile.ActiveLayerIndex = 0; @@ -2205,6 +2221,13 @@ private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersio try { + // 鼠标呼出轮盘:先把前台焦点对齐到呼出点所在的窗口,再上屏。 + // 必须在 Present 之前完成 —— 轮盘一旦呈现,用户随时可能松手执行动作, + // 而动作(快捷键注入、窗口操作)作用的对象就是此刻的前台窗口。 + if (switchFocusToWheelPoint) + { + WheelFocusSwitcher.SwitchFocusToPoint(center); + } window.Present(center, profile, ConfigManager.ConfigurationRevision, gestureVersion); lock (_uiUpdateSync) { diff --git a/WinPieGestures/WheelFocusSwitcher.cs b/WinPieGestures/WheelFocusSwitcher.cs new file mode 100644 index 00000000..cc04d96e --- /dev/null +++ b/WinPieGestures/WheelFocusSwitcher.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows; + +namespace WinPieGestures; + +/// +/// 鼠标呼出轮盘时的前台焦点对齐。 +/// +/// 要解决的问题:触发键的按下/抬起被 StarPie 的低级鼠标钩子吞掉,宿主窗口因此收不到这次点击, +/// Windows 也就不会把它激活 —— 前台焦点仍留在原窗口上。多显示器场景下表现为:光标已移到显示器 2 +/// 并在那里呼出轮盘,轮盘也画在显示器 2,但随后执行的动作(快捷键注入、窗口操作等)全部落到了 +/// 显示器 1 上原来那个前台窗口里,形成误操作。 +/// +/// 本类的边界:只服务于「鼠标呼出轮盘」这一条路径 —— 由 +/// GestureController.ShowRadialUI 在真正呈现轮盘之前调用一次。键盘触发路径、 +/// 插件经 IHostWheelService 呼出的粘滞轮盘、以及任何不是「用鼠标呼出轮盘」的路径都不经过这里, +/// 它们的前台语义保持原样。 +/// +/// 行为:解析呼出点所在的顶层窗口,若它可被安全激活且尚不是前台窗口,就把它切到前台 +/// (复用 处理 Windows 前台锁)。 +/// 不做任何其它副作用:不注入点击、不改窗口样式、不移动窗口。 +/// +internal static class WheelFocusSwitcher +{ + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + private const uint GA_ROOT = 2u; + private const uint GW_HWNDNEXT = 2u; + private const int GWL_EXSTYLE = -20; + + // 竞态上限:z 序链理论上有限,这里只是防守异常情况下 GetWindow 返回自环。 + private const int MaxZOrderWalk = 4096; + + // 不接受激活的悬浮层(轮盘、轨迹浮层、HUD、OSD、输入法候选窗)与点击穿透窗口: + // 它们不是用户想聚焦的「界面」,真正的目标在其下方,因此跳过并继续向下找。 + private const long WS_EX_NOACTIVATE = 0x08000000L; + private const long WS_EX_TRANSPARENT = 0x00000020L; + + // 桌面、任务栏与 Shell 表面:把前台交给它们等于让用户当前软件失去焦点,没有任何收益。 + private static readonly HashSet ShellWindowClasses = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Progman", + "WorkerW", + "SHELLDLL_DefView", + "SysListView32", + "Shell_TrayWnd", + "Shell_SecondaryTrayWnd", + "NotifyIconOverflowWindow", + "TopLevelWindowForOverflowXamlIsland", + "Windows.UI.Core.CoreWindow", + "Shell_InputSwitchTopLevelWindow", + "MultitaskingViewFrame", + "ForegroundStaging" + }; + + [DllImport("user32.dll")] + private static extern nint WindowFromPoint(POINT point); + + [DllImport("user32.dll")] + private static extern nint GetAncestor(nint hWnd, uint gaFlags); + + [DllImport("user32.dll")] + private static extern nint GetTopWindow(nint hWnd); + + [DllImport("user32.dll")] + private static extern nint GetWindow(nint hWnd, uint uCmd); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern nint GetShellWindow(); + + [DllImport("user32.dll")] + private static extern nint GetDesktopWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindow(nint hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(nint hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsIconic(nint hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowRect(nint hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(nint hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] + private static extern nint GetWindowLongPtr64(nint hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "GetWindowLong")] + private static extern nint GetWindowLong32(nint hWnd, int nIndex); + + private static nint GetWindowLongPtr(nint hWnd, int nIndex) + { + return (IntPtr.Size == 8) ? GetWindowLongPtr64(hWnd, nIndex) : GetWindowLong32(hWnd, nIndex); + } + + /// + /// 把前台焦点对齐到指定物理坐标所在的顶层窗口。 + /// 返回该坐标解析出的目标窗口句柄( 表示没有可切换的目标); + /// 调用方可以据此判断「这次轮盘属于哪个窗口/哪个程序」。 + /// 目标已经是前台时不做任何动作。 + /// + public static nint SwitchFocusToPoint(Point physicalPoint) + { + try + { + POINT pt = new POINT + { + X = (int)Math.Round(physicalPoint.X), + Y = (int)Math.Round(physicalPoint.Y) + }; + nint target = ResolveTargetWindow(pt); + if (target == IntPtr.Zero) + { + return IntPtr.Zero; + } + if (target == GetForegroundWindow()) + { + // 前台已经落在轮盘呼出点所在的窗口上:无需切换,也不做任何置顶/还原动作。 + return target; + } + bool activated = WindowTaskbarHelper.ActivateWindow(target); + if (activated) + { + AppLogger.LogDebug($"WheelFocusSwitcher: wheel focus moved to window 0x{target.ToInt64():X} at ({pt.X},{pt.Y}), process '{ActiveWindowHelper.GetProcessNameForWindow(target)}'."); + } + else + { + AppLogger.LogDebug($"WheelFocusSwitcher: failed to activate window 0x{target.ToInt64():X} at ({pt.X},{pt.Y})."); + } + return target; + } + catch (Exception ex) + { + AppLogger.LogDebug($"WheelFocusSwitcher: {ex.GetType().Name}: {ex.Message}"); + return IntPtr.Zero; + } + } + + /// + /// 呼出点所在窗口的进程名(小写 exe 名,与 同一格式); + /// 解析不到合适目标、或进程名取不出来时返回 null(调用方按原路径回落到前台窗口)。 + /// + /// 只读、不激活:鼠标钩子线程可以安全调用。轮盘方案按进程名取, + /// 所以这一步必须和前台切换看同一个窗口 —— 否则跨屏呼出时方案会取到另一块屏幕上那个旧前台窗口的进程, + /// 用户得呼出第二次才能拿到该有的选项。 + /// + public static string? TryGetProcessNameAtPoint(Point physicalPoint) + { + try + { + POINT pt = new POINT + { + X = (int)Math.Round(physicalPoint.X), + Y = (int)Math.Round(physicalPoint.Y) + }; + nint target = ResolveTargetWindow(pt); + if (target == IntPtr.Zero) + { + return null; + } + // 「取不出来」与「解析失败」对调用方是同一件事:都该回落到前台窗口的原行为。 + // 顶层的 UWP 进程经常 OpenProcess 不到(Suspended/受保护),那时 GetProcessNameForWindow + // 只会给 "unknown.exe" —— 拿它去取方案等于把用户已经配好的方案换成默认方案。 + string processName = ActiveWindowHelper.GetProcessNameForWindow(target); + return string.Equals(processName, "unknown.exe", StringComparison.OrdinalIgnoreCase) ? null : processName; + } + catch (Exception ex) + { + AppLogger.LogDebug($"WheelFocusSwitcher: {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + /// + /// 解析呼出点下应当接管前台的顶层窗口;没有合适目标时返回 。 + /// + private static nint ResolveTargetWindow(POINT pt) + { + // 首选 WindowFromPoint:与系统的命中判定完全一致(自动排除隐藏、禁用、逐像素透明的窗口, + // 也能正确处理 UWP 托管窗口与子窗口)。命中的是自己人的窗口时再走下面的兜底。 + nint hit = WindowFromPoint(pt); + nint hitRoot = (hit != IntPtr.Zero) ? GetAncestor(hit, GA_ROOT) : IntPtr.Zero; + if (IsEligibleTarget(hitRoot, pt)) + { + return hitRoot; + } + + // 兜底只对「命中的那层本身不该接管前台」成立 —— 也就是 StarPie 自己的窗口(轮盘 HWND 隐藏后 + // 仍占着原位置、轨迹浮层等)与 no-activate / 点击穿透的悬浮层,真正压在下面的那个窗口才是 + // 用户手指底下的界面。EnumWindows/GetTopWindow 的顺序即 z 序。 + // + // ⚠️ 命中的是 Shell 表面(桌面、任务栏)时绝不能兜底:桌面铺满整块屏幕,沿 z 序往下找到的 + // 第一个「矩形盖住该点」的窗口就是压在桌面底下的那个最大化程序 —— 在桌面空白处或任务栏空白处 + // 呼出轮盘,会把那个程序的窗口抢到前台,用户点的是桌面,前台却跳给了别的软件。 + if (ShouldSearchBelow(hitRoot)) + { + int steps = 0; + for (nint h = GetTopWindow(IntPtr.Zero); h != IntPtr.Zero && steps < MaxZOrderWalk; h = GetWindow(h, GW_HWNDNEXT)) + { + steps++; + if (IsEligibleTarget(h, pt)) + { + return h; + } + } + } + return IntPtr.Zero; + } + + /// + /// 命中点被拒绝之后,是否还有必要沿 z 序往下找:只有当命中的那层是「不接管前台的覆盖层」时才找。 + /// 桌面与任务栏是用户真正点在的东西(MouseHook 里任务栏那几下会放行原生点击), + /// 在它们上面往下钻只会钻到压在底下的无关程序。 + /// + private static bool ShouldSearchBelow(nint hitRoot) + { + if (hitRoot == IntPtr.Zero) + { + return true; + } + if (IsShellSurface(hitRoot)) + { + return false; + } + GetWindowThreadProcessId(hitRoot, out uint pid); + if (pid == (uint)Environment.ProcessId) + { + return true; + } + long exStyle = GetWindowLongPtr(hitRoot, GWL_EXSTYLE).ToInt64(); + return (exStyle & WS_EX_NOACTIVATE) != 0L || (exStyle & WS_EX_TRANSPARENT) != 0L; + } + + /// + /// 该顶层窗口是否可以作为「呼出轮盘的界面」接管前台。 + /// 判据刻意保守:任何一条不满足都只是放弃切换(前台保持原样),绝不会切到错误窗口上。 + /// + private static bool IsEligibleTarget(nint hWnd, POINT pt) + { + if (hWnd == IntPtr.Zero || !IsWindow(hWnd)) + { + return false; + } + if (!IsWindowVisible(hWnd) || IsIconic(hWnd)) + { + return false; + } + if (!ContainsPoint(hWnd, pt)) + { + return false; + } + GetWindowThreadProcessId(hWnd, out uint pid); + if (pid == 0u || pid == (uint)Environment.ProcessId) + { + // StarPie 自身的窗口(轮盘、轨迹浮层、控制台)绝不作为焦点目标: + // 把前台切给自己没有任何意义,还会让随后注入的按键打回自己身上。 + return false; + } + long exStyle = GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64(); + if ((exStyle & WS_EX_NOACTIVATE) != 0L || (exStyle & WS_EX_TRANSPARENT) != 0L) + { + return false; + } + return !IsShellSurface(hWnd); + } + + private static bool ContainsPoint(nint hWnd, POINT pt) + { + if (!GetWindowRect(hWnd, out RECT rect)) + { + return false; + } + return pt.X >= rect.Left && pt.X < rect.Right && pt.Y >= rect.Top && pt.Y < rect.Bottom; + } + + private static bool IsShellSurface(nint hWnd) + { + if (hWnd == GetShellWindow() || hWnd == GetDesktopWindow()) + { + return true; + } + StringBuilder sb = new StringBuilder(128); + if (GetClassName(hWnd, sb, sb.Capacity) <= 0) + { + return false; + } + return ShellWindowClasses.Contains(sb.ToString()); + } + + #region 诊断与测试切缝 (Diagnostics & Test Seams) + /// + /// 仅供 scratch/test_wheel_focus 无界面地断言「这个呼出点解析到了哪个窗口」。 + /// 只解析、不激活,因此探针不会改动任何窗口的前台状态或 z 序。 + /// + internal static nint TestResolveTargetWindow(double physicalX, double physicalY) + { + return ResolveTargetWindow(new POINT + { + X = (int)Math.Round(physicalX), + Y = (int)Math.Round(physicalY) + }); + } + #endregion +} diff --git a/WinPieGestures/WinPieGestures.csproj b/WinPieGestures/WinPieGestures.csproj index 674fbd70..61ed565b 100644 --- a/WinPieGestures/WinPieGestures.csproj +++ b/WinPieGestures/WinPieGestures.csproj @@ -49,6 +49,7 @@ + diff --git a/scratch/Directory.Build.props b/scratch/Directory.Build.props index 1018fbba..86a904c1 100644 --- a/scratch/Directory.Build.props +++ b/scratch/Directory.Build.props @@ -1,5 +1,5 @@ - + obj\$(MSBuildProjectName)\ bin\$(MSBuildProjectName)\ diff --git a/scratch/test_wheel_focus.cs b/scratch/test_wheel_focus.cs new file mode 100644 index 00000000..ea3af726 --- /dev/null +++ b/scratch/test_wheel_focus.cs @@ -0,0 +1,820 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows.Forms; +using WinPieGestures; + +/// +/// StarPie「鼠标呼出轮盘时前台焦点自动对齐」()的端到端验证。 +/// +/// 一次性工具,不参与主程序构建(scratch 目录性质)。用法: +/// powershell -ExecutionPolicy Bypass -File scratch\test_wheel_focus.ps1 +/// +/// 为什么必须有它:这段逻辑的三条判据(只认别人的窗口、跳过 no-activate 悬浮层、沿 z 序兜底) +/// 全部够不着单元测试与 UI 回归套件 —— 它们要的是「屏幕上真的压着几层窗口」。 +/// 这里用子进程拉起几个真窗口,把轮盘窗口那种「压在最上面又不是目标」的处境原地复现出来。 +/// +/// 每条用例都自带前置条件断言(见 ):探针摆的那层窗口 +/// 必须真的是该点上最上面的窗口,否则用例会因为「下面那层被解析出来」而假绿。 +/// +/// 注意:运行期间会短暂改动前台窗口(几秒),结束后恢复运行前的那个前台窗口。 +/// +internal static class WheelFocusProbe +{ + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + private delegate bool EnumWindowsProc(nint hWnd, nint lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern nint WindowFromPoint(POINT point); + + [DllImport("user32.dll")] + private static extern nint GetAncestor(nint hWnd, uint gaFlags); + + [DllImport("user32.dll")] + private static extern nint GetTopWindow(nint hWnd); + + [DllImport("user32.dll")] + private static extern nint GetWindow(nint hWnd, uint uCmd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(nint hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(nint hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindow(nint hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowRect(nint hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(nint hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern nint FindWindow(string? lpClassName, string? lpWindowName); + + [DllImport("user32.dll")] + private static extern nint GetShellWindow(); + + [DllImport("user32.dll")] + private static extern nint GetDesktopWindow(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetWindowPos(nint hWnd, nint hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] + private static extern nint GetWindowLongPtr64(nint hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "GetWindowLong")] + private static extern nint GetWindowLong32(nint hWnd, int nIndex); + + private static nint GetWindowLongPtr(nint hWnd, int nIndex) + { + return (IntPtr.Size == 8) ? GetWindowLongPtr64(hWnd, nIndex) : GetWindowLong32(hWnd, nIndex); + } + + private const int GWL_EXSTYLE = -20; + private const int WS_EX_NOACTIVATE = 0x08000000; + private const int WS_EX_TOOLWINDOW = 0x00000080; + private static readonly nint HWND_TOPMOST = new nint(-1); + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOZORDER = 0x0004; + private const uint SWP_NOACTIVATE = 0x0010; + + // 轮盘呼出点:A 窗口 (60,80,420,300) 的中心。 + private const int PointX = 270; + private const int PointY = 230; + + private static int _failures; + private static int _checks; + + private static bool Trace => Environment.GetEnvironmentVariable("TFW_TRACE") == "1"; + + [STAThread] + private static int Main(string[] args) + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + if (args.Length > 0 && string.Equals(args[0], "--child", StringComparison.OrdinalIgnoreCase)) + { + return RunChildWindow(args); + } + if (args.Length > 0 && string.Equals(args[0], "--diag", StringComparison.OrdinalIgnoreCase)) + { + return RunDiag(args); + } + if (args.Length > 0 && string.Equals(args[0], "--dump", StringComparison.OrdinalIgnoreCase)) + { + // 不建窗口的坐标扫描:只回答「这个点上命中的是谁、解析成了谁」。 + foreach (string pair in (args.Length > 1 ? args[1] : string.Empty).Split(';')) + { + string[] xy = pair.Split(','); + int dx = int.Parse(xy[0]); + int dy = int.Parse(xy[1]); + nint dh = WindowFromPoint(new POINT { X = dx, Y = dy }); + nint dr = GetAncestor(dh, 2u); + StringBuilder dc = new StringBuilder(128); + GetClassName(dr, dc, dc.Capacity); + GetWindowThreadProcessId(dr, out uint dpid); + long dex = GetWindowLongPtr(dr, GWL_EXSTYLE).ToInt64(); + Console.WriteLine($"({dx},{dy}) hit=0x{dh.ToInt64():X} root=0x{dr.ToInt64():X} 类={dc} pid={dpid} ex=0x{dex:X} 解析=0x{WheelFocusSwitcher.TestResolveTargetWindow(dx, dy).ToInt64():X}"); + } + return 0; + } + return RunProbe(); + } + + // ------------------------------------------------------------------ + // 子进程模式:在指定矩形上摆一个真实窗口,供父进程当作「别的程序的窗口 / 悬浮层」。 + // ------------------------------------------------------------------ + private static int RunChildWindow(string[] args) + { + string title = ArgValue(args, "--title") ?? "TFW_CHILD"; + Rectangle bounds = ParseRect(ArgValue(args, "--rect") ?? "0,0,200,150"); + bool noActivate = HasFlag(args, "--noactivate"); + bool topmost = HasFlag(args, "--topmost"); + + ProbeForm form = new ProbeForm(noActivate) + { + Text = title, + FormBorderStyle = FormBorderStyle.FixedSingle, + StartPosition = FormStartPosition.Manual, + // ShowInTaskbar 一律为 true:WinForms 把它设成 false 时会把窗体挂到隐藏的 + // "parking window" 之下,于是它不再是 z 序里的顶层窗口 —— 那样就复现不出 + // 「轮盘 HWND 压在目标上面」的处境了(no-activate 变体靠 WS_EX_TOOLWINDOW 不进任务栏)。 + ShowInTaskbar = true, + TopMost = topmost, + BackColor = noActivate ? Color.FromArgb(255, 240, 200) : Color.FromArgb(200, 225, 255) + }; + form.Bounds = bounds; + Application.Run(form); + return 0; + } + + /// + /// 诊断模式:在本进程里摆一个窗口,然后打印指定点上系统的真实命中结果与解析结果, + /// 用来回答「探针摆的这个窗口到底有没有进 z 序 / 有没有被窗口解析器选中」。 + /// test_wheel_focus.exe --diag --rect 170,130,200,150 --topmost --at 270,230 + /// + private static int RunDiag(string[] args) + { + Rectangle bounds = ParseRect(ArgValue(args, "--rect") ?? "170,130,200,150"); + string[] at = (ArgValue(args, "--at") ?? $"{PointX},{PointY}").Split(','); + + ProbeForm form = new ProbeForm(HasFlag(args, "--noactivate")) + { + Text = "TFW_DIAG", + FormBorderStyle = FormBorderStyle.FixedSingle, + StartPosition = FormStartPosition.Manual, + ShowInTaskbar = true, + TopMost = HasFlag(args, "--topmost") + }; + form.Bounds = bounds; + form.Show(); + Pump(); + form.EnsureTopmost(); + Thread.Sleep(200); + Pump(); + + int x = int.Parse(at[0]); + int y = int.Parse(at[1]); + Console.WriteLine($"本进程 PID: {Environment.ProcessId}"); + Console.WriteLine($"本进程窗口: 0x{form.Handle.ToInt64():X} {DescribeWindow(form.Handle, new POINT { X = x, Y = y })}"); + Console.WriteLine(DescribePoint(x, y)); + Console.WriteLine($"WheelFocusSwitcher 解析结果 = 0x{WheelFocusSwitcher.TestResolveTargetWindow(x, y).ToInt64():X}"); + form.Close(); + return 0; + } + + private sealed class ProbeForm : Form + { + private readonly bool _noActivate; + + public ProbeForm(bool noActivate) + { + _noActivate = noActivate; + } + + protected override CreateParams CreateParams + { + get + { + CreateParams cp = base.CreateParams; + if (_noActivate) + { + cp.ExStyle |= WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW; + } + return cp; + } + } + + /// 显示后强制一次 HWND_TOPMOST(仅在真正有消息循环的进程里有效,见 EnsureTopmost 注释)。 + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + EnsureTopmost(); + } + + /// + /// 强制置顶。注意:在没有消息循环的进程里(本探针的父进程)SetWindowPos(HWND_TOPMOST) + /// 会返回 true 却落不到扩展样式上(实测 ex 仍无 WS_EX_TOPMOST),所以父进程一侧改用 + /// 「把窗口激活到最前」来保证它真的压在目标窗口上面。 + /// + public void EnsureTopmost() + { + if (TopMost && Handle != IntPtr.Zero) + { + bool ok = SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + if (Trace) + { + Console.WriteLine($" [trace] SetWindowPos(TOPMOST) ok={ok} err={Marshal.GetLastWin32Error()} ex=0x{GetWindowLongPtr(Handle, GWL_EXSTYLE).ToInt64():X}"); + } + } + } + } + + // ------------------------------------------------------------------ + // 探针 + // ------------------------------------------------------------------ + private static int RunProbe() + { + Console.WriteLine("== StarPie 鼠标呼出轮盘 · 前台焦点对齐验证 =="); + Console.WriteLine($"屏幕: {Screen.PrimaryScreen!.Bounds} 工作区: {Screen.PrimaryScreen.WorkingArea}"); + Console.WriteLine($"呼出点: ({PointX},{PointY})(A 窗口的中心)"); + Console.WriteLine("提示:若桌面上恰好有置顶窗口压在该点上,本套用例会以前置条件失败的方式报出来,请先关掉。"); + Console.WriteLine(); + + nint originalForeground = GetForegroundWindow(); + List children = new List(); + List
selfWindows = new List(); + + try + { + children.Add(SpawnChild("TFW_A", new Rectangle(60, 80, 420, 300), noActivate: false, topmost: false)); + children.Add(SpawnChild("TFW_B", new Rectangle(560, 80, 420, 300), noActivate: false, topmost: false)); + nint hA = WaitForWindow("TFW_A", 20000); + nint hB = WaitForWindow("TFW_B", 20000); + if (hA == IntPtr.Zero || hB == IntPtr.Zero) + { + Check("准备:两个「别的程序」的窗口已出现", false, $"A=0x{hA.ToInt64():X} B=0x{hB.ToInt64():X}"); + return Finish(originalForeground, children, selfWindows); + } + Check("准备:两个「别的程序」的窗口已出现", true, $"A=0x{hA.ToInt64():X} B=0x{hB.ToInt64():X}"); + + // [1] 呼出点解析:压在下面的是 A,就应当解析到 A。 + ActivateWindow(hA); + CheckResolve("解析:呼出点落在 A 上 → 目标就是 A", + WheelFocusSwitcher.TestResolveTargetWindow(PointX, PointY), hA, PointX, PointY); + + // [2] 核心行为:前台在 B(跨显示器时"前台在另一块屏"的那种处境),鼠标呼出轮盘后前台切到 A。 + ActivateWindow(hB); + nint switched = WheelFocusSwitcher.SwitchFocusToPoint(new System.Windows.Point(PointX, PointY)); + bool switchedForeground = WaitForForeground(hA); + Check("切换:前台切到呼出点所在的 A", + switched == hA && switchedForeground, + $"返回=0x{switched.ToInt64():X} 前台=0x{GetForegroundWindow().ToInt64():X} 期望=0x{hA.ToInt64():X}"); + + // [3] 幂等:前台已经是 A 时,不应再做任何置顶/还原动作。 + nint again = WheelFocusSwitcher.SwitchFocusToPoint(new System.Windows.Point(PointX, PointY)); + Check("幂等:前台已经是 A 时不产生额外切换", + again == hA && WaitForForeground(hA), + $"返回=0x{again.ToInt64():X} 前台=0x{GetForegroundWindow().ToInt64():X}"); + + // [4][5] StarPie 自身进程的窗口压在 A 上(轮盘 HWND 的处境):不能当目标,必须沿 z 序兜底到 A。 + ActivateWindow(hA); + Form selfTopmost = ShowSelfWindow("TFW_SELF", new Rectangle(170, 130, 200, 150), topmost: true); + selfWindows.Add(selfTopmost); + CheckOverlayIsTopHit("自身窗口:探针自身进程的窗口确实压在 A 上(前置条件)", selfTopmost.Handle, PointX, PointY); + CheckResolve("自身窗口:压在 A 上的 StarPie 自己的窗口不是目标,兜底解析到 A", + WheelFocusSwitcher.TestResolveTargetWindow(PointX, PointY), hA, PointX, PointY); + ActivateWindow(hB); + nint switchedUnderSelf = WheelFocusSwitcher.SwitchFocusToPoint(new System.Windows.Point(PointX, PointY)); + bool underSelfForeground = WaitForForeground(hA); + Check("自身窗口:前台不会被切给自己,仍切到 A", + switchedUnderSelf == hA && underSelfForeground, + $"返回=0x{switchedUnderSelf.ToInt64():X} 前台=0x{GetForegroundWindow().ToInt64():X} 期望=0x{hA.ToInt64():X}"); + selfTopmost.Close(); + selfWindows.Remove(selfTopmost); + Pump(); + + // [6][7] 别的进程的 WS_EX_NOACTIVATE 悬浮层(HUD / OSD / 桌面挂件):同样跳过,继续找下面那层。 + children.Add(SpawnChild("TFW_NA", new Rectangle(170, 130, 200, 150), noActivate: true, topmost: true)); + nint hNa = WaitForWindow("TFW_NA", 20000); + if (hNa == IntPtr.Zero) + { + Check("悬浮层:其它进程的 no-activate 置顶窗口已出现", false, "未找到 TFW_NA"); + } + else + { + ActivateWindow(hA); + CheckOverlayIsTopHit("悬浮层:探针的 no-activate 悬浮层确实压在最上面(前置条件)", hNa, PointX, PointY); + CheckResolve("悬浮层:其它进程的 no-activate 置顶窗口不是目标,兜底解析到 A", + WheelFocusSwitcher.TestResolveTargetWindow(PointX, PointY), hA, PointX, PointY); + ActivateWindow(hB); + nint switchedUnderNa = WheelFocusSwitcher.SwitchFocusToPoint(new System.Windows.Point(PointX, PointY)); + bool underNaForeground = WaitForForeground(hA); + Check("悬浮层:前台不会被切给 no-activate 悬浮层,仍切到 A", + switchedUnderNa == hA && underNaForeground, + $"返回=0x{switchedUnderNa.ToInt64():X} 前台=0x{GetForegroundWindow().ToInt64():X} 期望=0x{hA.ToInt64():X}"); + } + + // [8] 别的进程的普通置顶窗口:它就是用户手指底下的那个界面,该切给它。 + children.Add(SpawnChild("TFW_TOP", new Rectangle(170, 130, 200, 150), noActivate: false, topmost: true)); + nint hTop = WaitForWindow("TFW_TOP", 20000); + if (hTop == IntPtr.Zero) + { + Check("普通置顶窗口:已出现", false, "未找到 TFW_TOP"); + } + else + { + ActivateWindow(hB); + CheckOverlayIsTopHit("普通置顶窗口:探针的置顶窗口确实压在最上面(前置条件)", hTop, PointX, PointY); + CheckResolve("普通置顶窗口:解析目标就是压在最上面的它", + WheelFocusSwitcher.TestResolveTargetWindow(PointX, PointY), hTop, PointX, PointY); + nint switchedToTop = WheelFocusSwitcher.SwitchFocusToPoint(new System.Windows.Point(PointX, PointY)); + bool topForeground = WaitForForeground(hTop); + Check("普通置顶窗口:前台切到它", + switchedToTop == hTop && topForeground, + $"返回=0x{switchedToTop.ToInt64():X} 前台=0x{GetForegroundWindow().ToInt64():X} 期望=0x{hTop.ToInt64():X}"); + } + + // [10] 方案归属:轮盘方案是按进程名取的,而"这次轮盘属于哪个程序"必须看呼出点所在的窗口, + // 不是前台窗口 —— 否则跨屏呼出时第一次拿到的是另一块屏幕上那个程序的方案(用户得呼出第二次)。 + RunProfileOwnershipCheck(children); + + // [9] Shell 表面(任务栏 / 桌面):永远不作为目标,也绝不让前台从用户软件上跑掉。 + // 这里同时守住「命中 Shell 表面时不许沿 z 序往下钻」:任务栏正下方压着的那一层 + // (Win11 的 MSTaskListWClass 等任务栏子窗口,以及延伸到任务栏底下的最大化程序) + // 矩形同样盖住该点,一旦往下钻就会把用户当前软件抢走。 + nint taskbar = FindWindow("Shell_TrayWnd", null); + if (taskbar != IntPtr.Zero && GetWindowRect(taskbar, out RECT tb) && tb.Right > tb.Left && tb.Bottom > tb.Top) + { + int tx = (tb.Left + tb.Right) / 2; + int ty = (tb.Top + tb.Bottom) / 2; + nint resolvedOnTaskbar = WheelFocusSwitcher.TestResolveTargetWindow(tx, ty); + Console.WriteLine($" 任务栏空白处 ({tx},{ty}):{DescribePoint(tx, ty)}"); + Check("Shell 表面:任务栏/桌面不会被当作焦点目标", + NeverShellSurface(resolvedOnTaskbar, taskbar), + $"任务栏=0x{taskbar.ToInt64():X} 解析结果=0x{resolvedOnTaskbar.ToInt64():X}" + + (NeverShellSurface(resolvedOnTaskbar, taskbar) ? string.Empty : Environment.NewLine + " " + DescribePoint(tx, ty))); + // 任务栏是「命中层本身就不是目标」的一种兜底场景:解析结果必须为空, + // 不能退而求其次切给压在任务栏底下的那个窗口(旧实现会这么做)。 + Check("Shell 表面:任务栏上不做 z 序兜底,宁可不动前台", + resolvedOnTaskbar == IntPtr.Zero, + $"解析结果=0x{resolvedOnTaskbar.ToInt64():X}(期望 0x0)" + + (resolvedOnTaskbar == IntPtr.Zero ? string.Empty : Environment.NewLine + " " + DescribePoint(tx, ty))); + } + else + { + Console.WriteLine("SKIP Shell 表面:未找到任务栏窗口"); + } + } + catch (Exception ex) + { + Check("探针未抛异常", false, ex.ToString()); + } + finally + { + Finish(originalForeground, children, selfWindows); + } + + Console.WriteLine(); + Console.WriteLine(_failures == 0 + ? $"全部通过({_checks} 项断言)。" + : $"失败 {_failures} / {_checks} 项。"); + return _failures == 0 ? 0 : 1; + } + + /// + /// 方案归属:拉起另一个可执行文件的窗口(记事本,失败则退回 cmd),把它压到指定呼出点上, + /// 前台却停在探针自己的窗口上 —— 正是「光标在这块屏幕、前台在另一块屏幕」的缺陷现场。 + /// 断言 给出的进程名取自呼出点, + /// 而不是前台窗口(旧实现会让轮盘用错方案,用户得呼出第二次)。 + /// + private static void RunProfileOwnershipCheck(List children) + { + const int OtherX = 270; + const int OtherY = 600; + + Process? other = TrySpawnForeignApp(children); + if (other == null) + { + Console.WriteLine("SKIP 方案归属:未能拉起另一个可执行文件的窗口"); + return; + } + nint hOther = WaitForProcessMainWindow(other, 20000); + if (hOther == IntPtr.Zero) + { + Console.WriteLine($"SKIP 方案归属:{other.ProcessName}.exe 的窗口未出现"); + return; + } + + SetWindowPos(hOther, IntPtr.Zero, 60, 450, 420, 300, SWP_NOZORDER | SWP_NOACTIVATE); + Pump(); + // 先把这个窗口抬到最上面(它才该是呼出点上的那个窗口),再把前台交给探针自己的另一个窗口。 + ActivateWindow(hOther); + nint probeWindow = WaitForWindow("TFW_B", 5000); + if (probeWindow != IntPtr.Zero) + { + ActivateWindow(probeWindow); + } + Pump(); + + nint hit = WindowFromPoint(new POINT { X = OtherX, Y = OtherY }); + nint hitRoot = GetAncestor(hit, 2u); + Check("方案归属:另一个进程的窗口确实压在呼出点上(前置条件)", + hitRoot == hOther, + $"窗口=0x{hOther.ToInt64():X} WindowFromPoint=0x{hit.ToInt64():X} GA_ROOT=0x{hitRoot.ToInt64():X}" + + (hitRoot == hOther ? string.Empty : Environment.NewLine + " " + DescribePoint(OtherX, OtherY))); + + GetWindowThreadProcessId(hOther, out uint otherPid); + string expectedProc = TryDescribeProcess(otherPid); + string foregroundProc = ActiveWindowHelper.GetActiveWindowProcessName(); + string? pointProc = WheelFocusSwitcher.TryGetProcessNameAtPoint(new System.Windows.Point(OtherX, OtherY)); + + bool matchesPoint = expectedProc.Length == 0 + ? !string.IsNullOrEmpty(pointProc) + : string.Equals(pointProc, expectedProc, StringComparison.OrdinalIgnoreCase); + bool differsFromForeground = !string.Equals(pointProc, foregroundProc, StringComparison.OrdinalIgnoreCase); + + Check("方案归属:进程名取的是呼出点所在的窗口,而不是前台窗口", + matchesPoint && differsFromForeground, + $"呼出点进程={pointProc ?? "(空)"} 期望={expectedProc} 前台进程={foregroundProc}" + + (matchesPoint && differsFromForeground ? string.Empty : Environment.NewLine + " " + DescribePoint(OtherX, OtherY))); + } + + /// 拉起另一个可执行文件的窗口(记事本优先,失败退回 cmd)。返回 null 表示无法拉起。 + private static Process? TrySpawnForeignApp(List children) + { + Process? notepad = TryStart("notepad.exe", string.Empty); + if (notepad != null) + { + children.Add(notepad); + if (WaitForProcessMainWindow(notepad, 8000) != IntPtr.Zero) + { + return notepad; + } + } + Process? cmd = TryStart("cmd.exe", "/k title TFW_CMD"); + if (cmd != null) + { + children.Add(cmd); + if (WaitForProcessMainWindow(cmd, 8000) != IntPtr.Zero) + { + return cmd; + } + } + return null; + } + + private static Process? TryStart(string fileName, string arguments) + { + try + { + ProcessStartInfo psi = new ProcessStartInfo(fileName, arguments) + { + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Normal + }; + return Process.Start(psi); + } + catch + { + return null; + } + } + + private static nint WaitForProcessMainWindow(Process process, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + try + { + process.Refresh(); + if (process.HasExited) + { + return IntPtr.Zero; + } + if (process.MainWindowHandle != IntPtr.Zero) + { + return process.MainWindowHandle; + } + } + catch + { + return IntPtr.Zero; + } + Thread.Sleep(120); + } + return IntPtr.Zero; + } + + /// 用 BCL(与宿主实现相互独立的一条路径)拿窗口所在进程的 exe 名,作为期望值。 + private static string TryDescribeProcess(uint pid) + { + try + { + return (Process.GetProcessById((int)pid).ProcessName + ".exe").ToLowerInvariant(); + } + catch + { + return string.Empty; + } + } + + private static int Finish(nint originalForeground, List children, List selfWindows) + { + foreach (Form form in selfWindows) + { + try { form.Close(); } catch { } + } + foreach (Process child in children) + { + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + child.Dispose(); + } + catch { } + } + Pump(); + if (originalForeground != IntPtr.Zero && IsWindow(originalForeground)) + { + WindowTaskbarHelper.ActivateWindow(originalForeground); + } + return _failures == 0 ? 0 : 1; + } + + private static void Check(string name, bool ok, string detail) + { + _checks++; + if (!ok) + { + _failures++; + } + Console.WriteLine($"{(ok ? "PASS" : "FAIL")} {name}"); + if (!string.IsNullOrEmpty(detail)) + { + Console.WriteLine($" {detail}"); + } + } + + /// 解析类断言:失败时把「这个点上系统真实命中了谁」一并打印出来,避免只看到一个句柄对不上。 + private static void CheckResolve(string name, nint actual, nint expected, int x, int y) + { + if (actual == expected) + { + Check(name, true, Describe(actual, expected) + (Trace ? Environment.NewLine + " " + DescribePoint(x, y) : string.Empty)); + return; + } + Check(name, false, Describe(actual, expected) + Environment.NewLine + " " + DescribePoint(x, y)); + } + + /// + /// 前置条件断言:探针自己摆的那层窗口必须真的是该点上最上面的窗口(WindowFromPoint 命中它)。 + /// 没有这条,窗口没摆上去时用例会「因为下面那层被解析出来」而假绿 —— 这条断言存在的全部理由。 + /// + private static void CheckOverlayIsTopHit(string name, nint overlay, int x, int y) + { + POINT pt = new POINT { X = x, Y = y }; + nint hit = WindowFromPoint(pt); + // 命中点可能是目标窗口里的子控件(记事本的 Edit、WinForms 的客户区), + // 所以和产品代码一样按 GA_ROOT 比对顶层窗口。 + nint hitRoot = GetAncestor(hit, 2u); + long ex = overlay != IntPtr.Zero ? GetWindowLongPtr(overlay, GWL_EXSTYLE).ToInt64() : 0L; + bool ok = overlay != IntPtr.Zero && hitRoot == overlay; + Check(name, ok, + $"窗口=0x{overlay.ToInt64():X} WindowFromPoint=0x{hit.ToInt64():X} GA_ROOT=0x{hitRoot.ToInt64():X} ex=0x{ex:X} Visible={(overlay != IntPtr.Zero && IsWindowVisible(overlay) ? "是" : "否")}" + + (ok && !Trace ? string.Empty : Environment.NewLine + " " + DescribePoint(x, y))); + } + + private static string Describe(nint actual, nint expected) + { + return $"解析结果=0x{actual.ToInt64():X} 期望=0x{expected.ToInt64():X}"; + } + + private static bool NeverShellSurface(nint resolved, nint taskbar) + { + return resolved != taskbar && resolved != GetShellWindow() && resolved != GetDesktopWindow(); + } + + /// + /// 失败时自解释:把该点上系统的真实命中结果与 z 序候选逐条打印出来 + /// (句柄 / 类名 / 进程 / 可见性 / 扩展样式 / 矩形 / 是否覆盖该点)。 + /// + private static string DescribePoint(int x, int y) + { + POINT pt = new POINT { X = x, Y = y }; + StringBuilder sb = new StringBuilder(); + nint hit = WindowFromPoint(pt); + sb.Append($"命中点({x},{y}):WindowFromPoint=0x{hit.ToInt64():X}{DescribeWindow(hit, pt)}"); + nint root = GetAncestor(hit, 2u); + sb.Append($" GA_ROOT=0x{root.ToInt64():X}{DescribeWindow(root, pt)}"); + sb.Append(" | z序含该点的顶层窗口:"); + int seen = 0; + for (nint h = GetTopWindow(IntPtr.Zero); h != IntPtr.Zero && seen < 12; h = GetWindow(h, 2u)) + { + if (GetWindowRect(h, out RECT rect) && x >= rect.Left && x < rect.Right && y >= rect.Top && y < rect.Bottom) + { + seen++; + sb.Append($"[{seen}]0x{h.ToInt64():X}{DescribeWindow(h, pt)} "); + } + } + return sb.ToString(); + } + + private static string DescribeWindow(nint hWnd, POINT pt) + { + if (hWnd == IntPtr.Zero) + { + return "(空)"; + } + StringBuilder cls = new StringBuilder(128); + GetClassName(hWnd, cls, cls.Capacity); + GetWindowThreadProcessId(hWnd, out uint pid); + long ex = GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64(); + bool covers = GetWindowRect(hWnd, out RECT rect) && pt.X >= rect.Left && pt.X < rect.Right && pt.Y >= rect.Top && pt.Y < rect.Bottom; + return $"(类={cls} pid={pid} 可见={(IsWindowVisible(hWnd) ? "是" : "否")} ex=0x{ex:X} 覆盖={covers} 矩形={rect.Left},{rect.Top},{rect.Right - rect.Left}x{rect.Bottom - rect.Top})"; + } + + private static void ActivateWindow(nint hWnd) + { + WindowTaskbarHelper.ActivateWindow(hWnd); + Pump(); + Thread.Sleep(120); + Pump(); + } + + /// + /// 断言前的确定性等待:SetForegroundWindow 的生效是异步的(前台锁、跨线程 + /// AttachThreadInput 都要走完),系统繁忙时 GetForegroundWindow() 会晚一拍才反映出来。 + /// 不等待就会得到「探针偶发 FAIL、重跑又全绿」这种假红 —— 断言判的是最终状态,不是瞬时状态。 + /// + private static bool WaitForForeground(nint expected, int timeoutMs = 2000) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (GetForegroundWindow() == expected) + { + return true; + } + Thread.Sleep(50); + Pump(); + } + return GetForegroundWindow() == expected; + } + + private static void Pump() + { + try + { + Application.DoEvents(); + } + catch { } + } + + /// + /// 在探针自己进程里摆一个压在呼出点上的窗口(模拟常驻的轮盘 HWND)。 + /// 靠「激活到最前」而不是 TopMost 把它排到 A 前面:本进程没有消息循环, + /// SetWindowPos(HWND_TOPMOST) 会返回 true 却不落到扩展样式上(见 ProbeForm.EnsureTopmost)。 + /// + private static Form ShowSelfWindow(string title, Rectangle bounds, bool topmost) + { + ProbeForm form = new ProbeForm(noActivate: false) + { + Text = title, + FormBorderStyle = FormBorderStyle.FixedSingle, + StartPosition = FormStartPosition.Manual, + ShowInTaskbar = true, + TopMost = topmost, + BackColor = Color.FromArgb(210, 255, 210) + }; + form.Bounds = bounds; + form.Show(); + Pump(); + WindowTaskbarHelper.ActivateWindow(form.Handle); + form.EnsureTopmost(); + Pump(); + Thread.Sleep(200); + Pump(); + return form; + } + + private static Process SpawnChild(string title, Rectangle bounds, bool noActivate, bool topmost) + { + string exe = Environment.ProcessPath ?? throw new InvalidOperationException("ProcessPath is null."); + string arguments = $"--child --title {title} --rect {bounds.Left},{bounds.Top},{bounds.Width},{bounds.Height}" + + (noActivate ? " --noactivate" : string.Empty) + + (topmost ? " --topmost" : string.Empty); + ProcessStartInfo psi = new ProcessStartInfo(exe, arguments) + { + UseShellExecute = false, + WorkingDirectory = AppContext.BaseDirectory + }; + Process? process = Process.Start(psi); + if (process == null) + { + throw new InvalidOperationException($"Failed to start child window '{title}'."); + } + return process; + } + + private static nint WaitForWindow(string title, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + nint found = FindWindowByTitle(title); + if (found != IntPtr.Zero) + { + return found; + } + Thread.Sleep(80); + } + return IntPtr.Zero; + } + + private static nint FindWindowByTitle(string title) + { + nint result = IntPtr.Zero; + EnumWindows(delegate (nint hWnd, nint _) + { + StringBuilder sb = new StringBuilder(256); + if (GetWindowText(hWnd, sb, sb.Capacity) > 0 && string.Equals(sb.ToString(), title, StringComparison.Ordinal)) + { + result = hWnd; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + + private static Rectangle ParseRect(string text) + { + string[] parts = text.Split(','); + return new Rectangle(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]), int.Parse(parts[3])); + } + + private static string? ArgValue(string[] args, string name) + { + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; + } + + private static bool HasFlag(string[] args, string name) + { + foreach (string arg in args) + { + if (string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } +} diff --git a/scratch/test_wheel_focus.csproj b/scratch/test_wheel_focus.csproj new file mode 100644 index 00000000..c02e04d0 --- /dev/null +++ b/scratch/test_wheel_focus.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0-windows10.0.19041.0 + enable + true + true + false + test_wheel_focus + test_wheel_focus + $(NoWarn);CS8600;CS8601;CS8602;CS8604;CS8618;CS8625 + + + + + + + + diff --git a/scratch/test_wheel_focus.ps1 b/scratch/test_wheel_focus.ps1 new file mode 100644 index 00000000..2cb90e88 --- /dev/null +++ b/scratch/test_wheel_focus.ps1 @@ -0,0 +1,20 @@ +# StarPie 鼠标呼出轮盘 · 前台焦点对齐端到端验证(scratch 一次性工具) +# 用法: powershell -ExecutionPolicy Bypass -File scratch\test_wheel_focus.ps1 +# +# 会短暂改动前台窗口并弹出几个临时窗口,跑完自动清理并恢复运行前的那个前台窗口。 +# +# -m:1 是刻意的:本仓库 scratch 下的测试工程引用 WinPieGestures 时,多节点并行 MSBuild +# 在受限环境里会于 _GetProjectReferenceTargetFrameworkProperties 静默失败(0 错误但生成失败)。 +# 单节点构建与运行结果完全一致,只是慢一点。 + +$ErrorActionPreference = 'Stop' +$project = Join-Path $PSScriptRoot 'test_wheel_focus.csproj' +$exe = Join-Path $PSScriptRoot 'bin\test_wheel_focus\Release\net8.0-windows10.0.19041.0\test_wheel_focus.exe' + +Write-Host "==> dotnet build $project -c Release -m:1" -ForegroundColor Cyan +dotnet build $project -c Release -m:1 -v m +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "==> $exe" -ForegroundColor Cyan +& $exe +exit $LASTEXITCODE