Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/ # 轮盘切削形态渲染器策略族
Expand Down Expand Up @@ -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. 🧩 插件系统:契约、边界与运行时实现
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 判据、或把进程名换回取前台窗口,都会红掉对应断言。

### 🧩 兼容性与升级要求

Expand Down
107 changes: 72 additions & 35 deletions WinPieGestures/ActiveWindowHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
Expand All @@ -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();
Expand All @@ -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";
}
}

/// <summary>
/// 指定窗口所在进程的可执行文件名(小写,与 <see cref="GetActiveWindowProcessName"/> 同一格式)。
///
/// <para>存在的理由:轮盘方案是按进程名取的,而「用鼠标呼出轮盘时该属于哪个程序」必须看
/// <b>呼出点所在的窗口</b>,不是前台窗口 —— 多显示器上把光标移到另一块屏幕时,前台窗口还在原屏幕上,
/// 于是轮盘会错用另一个程序的方案(用户得呼出第二次才正确)。</para>
/// </summary>
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";
}
}
37 changes: 30 additions & 7 deletions WinPieGestures/GestureController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -2175,7 +2186,12 @@ private void ProcessMove(Point currentPoint)
QueueHighlightUpdate(num4, num5, flag, flag2, GetCurrentGestureVersion());
}

private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersion)
/// <param name="switchFocusToWheelPoint">
/// 是否把前台焦点切到轮盘呼出点所在的窗口。只有「鼠标呼出轮盘」的路径传 true:
/// 触发键的点击已被本程序的鼠标钩子吞掉,宿主窗口收不到激活,多显示器下会让动作落到另一块屏幕的窗口上。
/// 键盘触发与插件粘滞轮盘一律传 false,前台语义保持原样。
/// </param>
private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersion, bool switchFocusToWheelPoint)
{
profile.EnsureLayers();
profile.ActiveLayerIndex = 0;
Expand Down Expand Up @@ -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)
{
Expand Down
Loading