这两天鼠标有点怪:用着用着,光标突然只能在屏幕左上角一块矩形里活动,怎么也划不出去,过几秒又自己恢复,然后再来一次。
第一反应是蓝牙鼠标出了毛病。不过"困在一个矩形里"这个症状太具体了,不太像硬件故障:鼠标坏了一般是断连、乱飘、掉帧,不会规规矩矩地画个框。
一、先确认:是不是 ClipCursor
Windows 有个 API 叫 ClipCursor,作用就是把系统光标限制在一个矩形里。游戏用它防止鼠标在多屏之间跑出窗口,远程桌面用它把鼠标锁进远程画面。它锁的是整个系统的光标,不管你在用哪个程序、用的是鼠标还是触控板。
对应地有个 GetClipCursor 可以读出当前的限制范围。没被限制时,读出来就是整个虚拟桌面(所有显示器拼起来的范围);被限制时就是那个小矩形。
用 PowerShell 调一下,第一次就踩了个坑:读出来的范围明显比桌面小,看起来"正在被锁"。但其实我的笔记本屏幕是 2880×1800、200% 缩放,PowerShell 默认不是 DPI 感知的进程,系统给它返回的坐标被缩放过,和真实值对不上。加一句:
# Per-Monitor V2 DPI 感知(-4 即 DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
[Clip]::SetProcessDpiAwarenessContext([IntPtr]-4)
再读,限制范围和虚拟桌面完全一致,也就是当时其实没被锁。
高 DPI 屏上调 Win32 API
凡是涉及屏幕坐标的调用(光标、窗口矩形、显示器范围),先把进程设成 DPI 感知,否则读到的数字可能是系统"好心"缩放过的。
二、挂个监控,等它发作
问题是间歇性的,只能等它发作时抓现场。思路很简单:每 100ms 读一次 GetClipCursor,一旦范围小于虚拟桌面,就把当时的前台窗口记下来,再遍历所有窗口(包括隐藏窗口和子窗口),看谁的矩形和锁定范围对得上——锁鼠标的程序,通常就是把光标锁进自己的窗口。
Add-Type @"
using System; using System.Runtime.InteropServices; using System.Text; using System.Collections.Generic;
public struct RECT { public int L, T, R, B; }
public class Clip {
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v);
[DllImport("user32.dll")] public static extern bool GetClipCursor(out RECT r);
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n);
public delegate bool WndEnum(IntPtr h, IntPtr l);
[DllImport("user32.dll")] public static extern bool EnumWindows(WndEnum f, IntPtr l);
[DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p, WndEnum f, IntPtr l);
// 所有窗口,含隐藏窗口和子窗口
public static List<IntPtr> All() {
var l = new List<IntPtr>();
EnumWindows((h, x) => { l.Add(h); EnumChildWindows(h, (c, y) => { l.Add(c); return true; }, IntPtr.Zero); return true; }, IntPtr.Zero);
return l;
}
}
"@
[Clip]::SetProcessDpiAwarenessContext([IntPtr]-4) | Out-Null
function Who($h) {
$p = [uint32]0; [void][Clip]::GetWindowThreadProcessId($h, [ref]$p)
$c = New-Object Text.StringBuilder 256; [void][Clip]::GetClassName($h, $c, 256)
"$((Get-Process -Id $p -ErrorAction SilentlyContinue).ProcessName)[$p] class=$c"
}
$clipped = $false
while ($true) {
$r = New-Object RECT; [void][Clip]::GetClipCursor([ref]$r)
$vx = [Clip]::GetSystemMetrics(76); $vy = [Clip]::GetSystemMetrics(77) # 虚拟桌面左上角
$vw = [Clip]::GetSystemMetrics(78); $vh = [Clip]::GetSystemMetrics(79) # 虚拟桌面宽高
$full = $r.L -le $vx -and $r.T -le $vy -and $r.R -ge $vx + $vw -and $r.B -ge $vy + $vh
if (-not $full -and -not $clipped) {
$clipped = $true
Write-Host "$(Get-Date -f HH:mm:ss.fff) CLIPPED ($($r.L),$($r.T))-($($r.R),$($r.B))"
Write-Host " 前台: $(Who ([Clip]::GetForegroundWindow()))"
foreach ($h in [Clip]::All()) {
$w = New-Object RECT; [void][Clip]::GetWindowRect($h, [ref]$w)
$d = [math]::Abs($w.L - $r.L) + [math]::Abs($w.T - $r.T) + [math]::Abs($w.R - $r.R) + [math]::Abs($w.B - $r.B)
if ($d -le 60) { Write-Host " 矩形吻合: $(Who $h) visible=$([Clip]::IsWindowVisible($h)) ($($w.L),$($w.T))-($($w.R),$($w.B))" }
}
} elseif ($full -and $clipped) {
$clipped = $false
Write-Host "$(Get-Date -f HH:mm:ss.fff) released"
}
Start-Sleep -Milliseconds 100
}
挂上没几秒就抓到了,两分钟里锁了 8 次,每次 3 到 9 秒:
17:01:47.708 CLIPPED (32,95)-(1302,805) ← 1270×710
17:01:51.387 released
17:01:55.729 CLIPPED (32,95)-(1302,805)
17:02:03.203 released
...
17:03:21.034 CLIPPED (32,95)-(1942,1165) ← 1910×1070
17:03:26.945 released
确实是 ClipCursor。而且锁定范围几乎每次都一样,左上角固定在 (32,95);锁的时候前台程序五花八门,浏览器、编辑器都有。说明不是我正在用的程序在锁,是某个后台程序在反复上锁。
三、第一个嫌疑人:远程控制软件
翻了一下后台进程,一眼就看到一个远程控制软件(网易 UU 远程)开着。嫌疑很大:
-
远控软件本来就有"锁定鼠标"功能,用
ClipCursor把鼠标锁进远程画面窗口; - 它还装了一个自己的虚拟 HID 鼠标驱动;
- 锁定范围是 16:9 的,很像一个远程画面窗口。
于是把它从托盘里彻底退出。退出之后,监控日志就没再出现过 CLIPPED。
结案。还顺手上网搜了一圈,找到别人被远控软件、Steam Link 之类串流工具锁住鼠标的案例,觉得八九不离十。
四、翻案:换触控板也被锁
过了一阵,又锁了,还是一模一样的症状。把蓝牙鼠标拔掉改用触控板,照样被锁在框里。远控软件的界面早已退出,所以凶手另有其人。
回头看,"退出它之后就好了"只是个巧合:真凶恰好在那个时间点停了。只观察了半分钟就下结论,太草率。
这次把监控加强了一点,被锁时额外记录光标位置、光标下的窗口、最近启动的进程。重新挂上,很快又抓到了(日志整理过):
CLIPPED (32,95)-(1942,1165) size 1910x1070
前台: <聊天软件>
矩形吻合: msedge[29784] class=Chrome_RenderWidgetHostHWND visible=False (40,90)-(1960,1170)
最近 3 分钟启动: 一批 msedge 进程(大多在同一秒启动), node, bash, ...
这里有几个关键信息:
-
和锁定范围吻合的,是一个 Edge 的网页渲染窗口,大小 1920×1080,但
visible=False,我根本看不见它; -
这个 Edge 进程是刚刚启动的,和它一起启动的还有一批 Edge 子进程和
node、bash; - 几秒之后再去查,这个进程已经退出了。
一个看不见的、临时启动又很快退出的浏览器,加上 node——这是自动化测试的样子。
五、真凶:headless 浏览器测试里的 requestPointerLock
那段时间我正让 AI 编程助手在后台帮我写一个网页 3D 小游戏,它会反复跑 Playwright 测试来验证效果:起一个 headless 的 Edge,打开游戏,点击标题画面进入游戏,截图、测帧率。
而游戏代码里,点击进入游戏时会做这件事:
requestLock() {
// Pointer lock can be refused (headless browsers, iframes); the game still runs.
try {
const result = target.requestPointerLock() as unknown;
if (result instanceof Promise) result.catch(() => {});
} catch {
/* ignore */
}
},
requestPointerLock() 是网页游戏常用的 Pointer Lock API,用来隐藏光标、捕获鼠标移动做第一人称视角。注释里写着"headless 浏览器会拒绝",这个假设是错的。
再对一下测试脚本里的视口尺寸:
| 测试脚本 | 视口 | 实际锁定范围 |
|---|---|---|
| 冒烟测试 | 1280×720 | 1270×710 |
| 帧率测试 | 1920×1080 | 1910×1070 |
两个尺寸都对得上,都只差十个像素。帧率测试脚本最后修改的时间,和那个隐形 Edge 启动的时间是同一秒。每次锁几秒就自动解开,也符合一次测试跑完、浏览器关闭的节奏。
整条链路是这样的:
flowchart LR A[AI 助手跑 Playwright 测试] --> B[启动 headless Edge<br/>窗口存在但不可见] B --> C[测试点击标题画面] C --> D[游戏调用 requestPointerLock] D --> E[Edge 调用 ClipCursor] E --> F[系统真实光标<br/>被锁进隐形窗口]
为什么 headless 也会影响真实光标?新版 Chromium 的 headless 模式跑的就是完整浏览器,在 Windows 上同样会创建原生窗口,只是不显示。从现象看,Chromium 在 Windows 上实现 Pointer Lock 时会用 ClipCursor 把光标限制在窗口范围内,而 ClipCursor 作用于整个系统,它分不清这个窗口是不是 headless 的。
六、修法
让游戏在被自动化工具控制时不去请求 Pointer Lock。Playwright、Puppeteer、Selenium 控制的浏览器里,navigator.webdriver 都是 true,正常玩家打开时是 false:
requestLock() {
if (navigator.webdriver) return; // 自动化测试下不锁指针,否则会锁住宿主机的真实光标
// ...
},
改在游戏里,所有测试脚本都能覆盖到。如果不想动业务代码,也可以在测试里注入脚本把它替换成空操作:
await page.addInitScript(() => {
Element.prototype.requestPointerLock = function () { return Promise.resolve(); };
});
另外还有个应急办法:被锁住时按 Ctrl+Alt+Del 再按 Esc,切换一次桌面,一般能解开当前的光标限制。不过如果后台还在反复上锁,过一会儿还会被锁回去。
小结
-
鼠标被困在一个规整的矩形里,先怀疑
ClipCursor,不是硬件。 换一个指针设备也被锁,基本就能确认。 - 别凭进程名猜凶手。 去找矩形对得上的窗口,而且要把隐藏窗口和子窗口都算上,真凶可能根本看不见。
- "关掉它就好了"可能只是巧合。 间歇性问题要多观察一段时间再下结论。
- 在自己电脑上跑 headless 浏览器测试,它并不是完全隔离的。 看不见窗口,不代表它碰不到光标这类系统级的状态。让 AI 助手在后台跑浏览器测试时,值得留意这一点。