fix: 框选轮询脚本免 System.Drawing 引用, 采样队列化并取消阻塞等待

This commit is contained in:
yu 2026-11-03 10:35:00 +08:00
parent 82765a7f19
commit 0960be88e5

View File

@ -722,40 +722,41 @@ interface MouseSample {
}
interface MouseButtonPoller {
/** 至少读到一次采样后为 true;超时/启动失败为 false,调用方回退到渲染层事件 */
ready: Promise<boolean>;
/** 最近一次采样;轮询进程退出后返回 null,调用方回退渲染层事件 */
sample: () => MouseSample | null;
/** live=已产出采样; dead=进程退出(脚本编译失败/被杀); starting=启动中 */
status: () => "live" | "dead" | "starting";
/** 取走自上次调用以来的全部采样流(40ms 一条), 按下→松开的转移不会被主循环轮询间隔漏掉 */
drain: () => MouseSample[];
stop: () => void;
}
// 覆盖层窗口可能被其他窗口盖住/失去焦点, 渲染层 mouseup/keydown 都可能收不到,
// 导致"画得出选区却永远完不成"。改用主进程轮询物理左键状态+光标位置
// (GetAsyncKeyState/GetCursorPos), 与任何窗口的焦点、层级、事件路由完全解耦。
// 按键状态与坐标来自同一次采样, 按下点不会因轮询间隔/PowerShell 冷启动延迟而漂移。
// 采样进队列: 即使主循环在忙别的(或 PowerShell 冷启动后才就绪),
// 拖拽全程的按下点/松开点也都精确保留在采样流里。
// 注意: GetCursorPos 参数用 int[] 而非 System.Drawing.Point——
// Add-Type -MemberDefinition 编译期默认不引用 System.Drawing.dll, 用 Point 会导致编译失败、脚本直接退出
function spawnMouseButtonPoller(): MouseButtonPoller {
const script = [
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern short GetAsyncKeyState(int vKey); [DllImport(\"user32.dll\")] public static extern bool GetCursorPos(int[] pt);' -Name K -Namespace WMouseButton | Out-Null",
// 必须 DPI 感知, 否则 GetCursorPos 返回的是被系统虚拟化(96dpi)后的坐标
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern short GetAsyncKeyState(int vKey); [DllImport(\"user32.dll\")] public static extern bool GetCursorPos(out System.Drawing.Point p);' -Name K -Namespace WMouseButton | Out-Null",
"Add-Type -AssemblyName System.Drawing | Out-Null",
"try { [void][WMouseButton.K]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}",
"while ($true) {",
" $down = ([WMouseButton.K]::GetAsyncKeyState(1) -band 0x8000) -ne 0",
" $p = New-Object System.Drawing.Point",
" [void][WMouseButton.K]::GetCursorPos([ref]$p)",
" [Console]::Out.Write([string]([int]$down) + ' ' + $p.X + ' ' + $p.Y + [char]10)",
" [int[]]$p = @(0,0)",
" [void][WMouseButton.K]::GetCursorPos($p)",
" [Console]::Out.Write([string]([int]$down) + ' ' + $p[0] + ' ' + $p[1] + [char]10)",
" Start-Sleep -Milliseconds 40",
"}"
].join("\n");
const child = spawn(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
);
let sample: MouseSample | null = null;
const queue: MouseSample[] = [];
let live = false;
let dead = false;
let readyResolve: (value: boolean) => void = () => {};
const ready = new Promise<boolean>(resolve => { readyResolve = resolve; });
let buffer = "";
child.stdout?.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
@ -768,17 +769,30 @@ function spawnMouseButtonPoller(): MouseButtonPoller {
const x = Number(parts[1]);
const y = Number(parts[2]);
if (Number.isFinite(x) && Number.isFinite(y)) {
sample = { down: parts[0] === "1", x, y };
readyResolve(true);
queue.push({ down: parts[0] === "1", x, y });
if (!live) {
live = true;
console.log("[region-pick] poller live");
}
}
}
}
});
child.on("error", () => { dead = true; readyResolve(false); });
child.on("exit", () => { dead = true; });
child.on("error", error => {
dead = true;
console.warn("[region-pick] poller spawn failed", error);
});
child.on("exit", code => {
dead = true;
console.warn("[region-pick] poller exited, code=" + code);
});
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) console.warn("[region-pick] poller stderr:", text.slice(0, 500));
});
return {
ready,
sample: () => (dead ? null : sample),
status: () => (dead ? "dead" : live ? "live" : "starting"),
drain: () => queue.splice(0, queue.length),
stop: () => {
try { child.kill(); } catch {}
}
@ -817,12 +831,8 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
if (!win.isDestroyed() && display.id === cursorDisplay.id) win.focus();
}
const pollerReady = await Promise.race([
poller.ready,
new Promise<boolean>(resolve => setTimeout(() => resolve(false), 2500))
]);
if (!pollerReady) console.warn("[region-pick] 按键轮询未就绪, 回退渲染层事件");
// 不再阻塞等待轮询就绪: 采样进队列, 冷启动期间发生的拖拽也不会丢;
// 轮询死亡(starting/dead)时按 tick 回退渲染层事件 + getCursorScreenPoint
const deadline = Date.now() + 120000;
let cancelled = false;
// Esc 全局兜底: 覆盖层失去键盘焦点时也能取消
@ -836,14 +846,15 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
}
// 选区坐标不取自窗口 clientX/clientY(混合 DPI 下窗口坐标映射不可靠);
// 优先用轮询采样(物理坐标经 screenToDipPoint 换算), 轮询不可用时回退渲染层事件 + getCursorScreenPoint
// 轮询 live: 按采样流识别按下→松开(40ms 粒度, 精确到帧);
// 轮询 starting/dead: 回退渲染层事件 + getCursorScreenPoint
let downPoint: { x: number; y: number } | null = null;
let sawDown = false;
let picked: { rect: { x: number; y: number; width: number; height: number }; display: (typeof displays)[number] } | null = null;
try {
try {
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 80));
await new Promise(r => setTimeout(r, 60));
if (cancelled) break;
// 聚合所有覆盖层状态: 任一屏按 Esc 取消; 按下/松开仅作轮询失败时的回退
let rendererDown = false;
@ -859,17 +870,39 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
} catch {}
}
if (cancelled) break;
if (poller.status() === "live") {
// 主链路: 处理采样队列里的按下→松开转移
const samples = poller.drain();
for (const s of samples) {
const dip = screen.screenToDipPoint({ x: s.x, y: s.y });
if (!downPoint && s.down) {
downPoint = { x: dip.x, y: dip.y };
sawDown = true;
console.log("[region-pick] down", downPoint, "(polled)");
} else if (downPoint && !s.down) {
const display = displayForPoint(downPoint.x, downPoint.y);
const rx0 = Math.min(downPoint.x, dip.x);
const ry0 = Math.min(downPoint.y, dip.y);
const rx1 = Math.max(downPoint.x, dip.x);
const ry1 = Math.max(downPoint.y, dip.y);
picked = { rect: { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 }, display };
console.log("[region-pick] up", picked.rect, "(polled)");
break;
}
}
if (picked) break;
continue;
}
// 回退链路: 轮询不可用, 用渲染层事件 + getCursorScreenPoint
if (!anyAlive) continue;
const mouse = pollerReady ? poller.sample() : null;
// 按键与坐标同源采样; 轮询不可用时回退: 渲染层事件 + getCursorScreenPoint
const cursor = mouse
? screen.screenToDipPoint({ x: mouse.x, y: mouse.y })
: screen.getCursorScreenPoint();
const down = mouse ? mouse.down : rendererDown;
const cursor = screen.getCursorScreenPoint();
const down = rendererDown;
if (down && !downPoint) {
downPoint = { x: cursor.x, y: cursor.y };
sawDown = true;
console.log("[region-pick] down", downPoint, mouse ? "(polled)" : "(renderer)");
console.log("[region-pick] down", downPoint, "(renderer)");
}
if (!down && sawDown && downPoint) {
const display = displayForPoint(downPoint.x, downPoint.y);
@ -878,7 +911,7 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
const rx1 = Math.max(downPoint.x, cursor.x);
const ry1 = Math.max(downPoint.y, cursor.y);
picked = { rect: { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 }, display };
console.log("[region-pick] up", picked.rect);
console.log("[region-pick] up", picked.rect, "(renderer)");
break;
}
}