diff --git a/src/main/desktop-pick.ts b/src/main/desktop-pick.ts index 13dbf5a..e184a41 100644 --- a/src/main/desktop-pick.ts +++ b/src/main/desktop-pick.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs"; @@ -714,6 +714,57 @@ function regionOverlayHtml(): string { ].join(""); } +interface MouseButtonPoller { + /** 就绪且至少读到一次按键状态后为 true;超时/启动失败为 false,调用方回退到渲染层事件 */ + ready: Promise; + /** 当前物理左键是否按下;未就绪时返回 null */ + isDown: () => boolean | null; + stop: () => void; +} + +// 覆盖层窗口可能被其他窗口盖住/失去焦点, 渲染层 mouseup/keydown 都可能收不到, +// 导致"画得出选区却永远完不成"。改用主进程轮询物理左键状态(GetAsyncKeyState), +// 与任何窗口的焦点、层级、事件路由完全解耦。 +function spawnMouseButtonPoller(): MouseButtonPoller { + const script = [ + "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern short GetAsyncKeyState(int vKey);' -Name K -Namespace WMouseButton | Out-Null", + "while ($true) {", + " $down = ([WMouseButton.K]::GetAsyncKeyState(1) -band 0x8000) -ne 0", + " [Console]::Out.Write($(if ($down) {'1'} else {'0'}) + [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 } + ); + let state: boolean | null = null; + let readyResolve: (value: boolean) => void = () => {}; + const ready = new Promise(resolve => { readyResolve = resolve; }); + let buffer = ""; + child.stdout?.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line === "1" || line === "0") { + state = line === "1"; + readyResolve(true); + } + } + }); + child.on("error", () => readyResolve(false)); + return { + ready, + isDown: () => state, + stop: () => { + try { child.kill(); } catch {} + } + }; +} + export async function pickScreenRegion(): Promise<{ x: number; y: number; width: number; height: number; scale: number; imagePath: string }> { const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed()); for (const w of appWindows) w.minimize(); @@ -730,93 +781,136 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width: }); void win.loadURL(url); win.setBounds({ x: display.bounds.x, y: display.bounds.y, width: display.bounds.width, height: display.bounds.height }); + // 默认 alwaysOnTop 层级太低, 会被其他置顶/普通窗口盖住; + // screen-saver 层级压过任务栏和普通应用窗口 + win.setAlwaysOnTop(true, "screen-saver"); win.show(); return { win, display }; }); - const deadline = Date.now() + 60000; + // 把焦点给光标所在屏的覆盖层, 保证键盘事件(Esc)优先送达 + const cursorDisplay = displayForPoint(screen.getCursorScreenPoint().x, screen.getCursorScreenPoint().y); + for (const { win, display } of overlays) { + if (!win.isDestroyed() && display.id === cursorDisplay.id) win.focus(); + } + + const poller = spawnMouseButtonPoller(); + const pollerReady = await Promise.race([ + poller.ready, + new Promise(resolve => setTimeout(() => resolve(false), 2500)) + ]); + if (!pollerReady) console.warn("[region-pick] 按键轮询未就绪, 回退渲染层事件"); + + const deadline = Date.now() + 120000; let cancelled = false; + // Esc 全局兜底: 覆盖层失去键盘焦点时也能取消 + const hotkeys: string[] = []; + for (const accelerator of ["Escape", "F9"]) { + try { + if (globalShortcut.register(accelerator, () => { cancelled = true; })) hotkeys.push(accelerator); + } catch (error) { + console.warn("[region-pick] hotkey register failed", accelerator, error); + } + } + // 选区坐标不取自窗口 clientX/clientY(混合 DPI 下窗口坐标映射不可靠), - // 渲染层只上报按下/松开状态, 主进程用 getCursorScreenPoint 采样鼠标屏幕绝对 DIP 坐标 + // 主进程用 getCursorScreenPoint 采样鼠标屏幕绝对 DIP 坐标; + // 按下/松开用主进程物理按键轮询判定, 渲染层状态只作回退 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; - while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, 100)); - // 聚合所有覆盖层的状态: 任一屏按 Esc 取消, 任一屏的按下/松开都要识别 - let down = false; - let wasDown = false; - let anyAlive = false; - for (const { win } of overlays) { - if (win.isDestroyed()) continue; - try { - const raw = await win.webContents.executeJavaScript("JSON.stringify({ down: !!window.__down, wasDown: !!window.__wasDown, cancelled: !!window.__cancelled })", true); - const s = JSON.parse(raw) as { down: boolean; wasDown: boolean; cancelled: boolean }; - anyAlive = true; - down = down || s.down; - wasDown = wasDown || s.wasDown; - if (s.cancelled) { cancelled = true; } - } catch {} + try { + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 80)); + if (cancelled) break; + // 聚合所有覆盖层状态: 任一屏按 Esc 取消; 按下/松开仅作轮询失败时的回退 + let rendererDown = false; + let anyAlive = false; + for (const { win } of overlays) { + if (win.isDestroyed()) continue; + try { + const raw = await win.webContents.executeJavaScript("JSON.stringify({ down: !!window.__down, cancelled: !!window.__cancelled })", true); + const s = JSON.parse(raw) as { down: boolean; cancelled: boolean }; + anyAlive = true; + rendererDown = rendererDown || s.down; + if (s.cancelled) cancelled = true; + } catch {} + } + if (cancelled) break; + if (!anyAlive) continue; + const cursor = screen.getCursorScreenPoint(); + const polled = pollerReady ? poller.isDown() : null; + const down = polled !== null ? polled : rendererDown; + if (down && !downPoint) { + downPoint = { x: cursor.x, y: cursor.y }; + sawDown = true; + console.log("[region-pick] down", downPoint); + } + if (!down && sawDown && downPoint) { + const display = displayForPoint(downPoint.x, downPoint.y); + const rx0 = Math.min(downPoint.x, cursor.x); + const ry0 = Math.min(downPoint.y, cursor.y); + 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); + break; + } } - if (cancelled) break; - if (!anyAlive) continue; - const cursor = screen.getCursorScreenPoint(); - if (down && !downPoint) { downPoint = { x: cursor.x, y: cursor.y }; sawDown = true; } - if (!down && sawDown && downPoint) { - const display = displayForPoint(downPoint.x, downPoint.y); - const rx0 = Math.min(downPoint.x, cursor.x); - const ry0 = Math.min(downPoint.y, cursor.y); - 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 }; - break; + } finally { + // 所有退出路径(取消/超时/异常)都要清理, 否则覆盖层永远留在屏幕上 + poller.stop(); + for (const accelerator of [...hotkeys].reverse()) { + try { globalShortcut.unregister(accelerator); } catch {} } + for (const { win } of overlays) if (!win.isDestroyed()) win.destroy(); } if (cancelled) throw new RuntimeError("pick_cancelled", "已取消框选"); if (!picked || picked.rect.width <= 2 || picked.rect.height <= 2) throw new RuntimeError("pick_cancelled", "未完成框选"); const { rect, display } = picked; - // 关键顺序: 先销毁覆盖层、完成截图, 最后才恢复应用窗口—— - // 之前是先恢复窗口再截图, 应用弹回盖住选区, 截到的全是应用窗口内容 - for (const { win } of overlays) if (!win.isDestroyed()) win.destroy(); - await new Promise(r => setTimeout(r, 300)); - const outDir = path.join(app.getPath("userData"), "region-picks"); - fs.mkdirSync(outDir, { recursive: true }); - const imagePath = path.join(outDir, `pick-region-${Date.now()}.png`); - // GDI CopyFromScreen 截不到 GPU 合成窗口(Electron 自身界面), 会截出全黑; - // 改用 desktopCapturer 走系统合成器截屏。rect 是屏幕绝对 DIP, 裁剪时换算到该屏相对坐标再乘 scaleFactor - const scale = display.scaleFactor || 1; - const sources = await desktopCapturer.getSources({ - types: ["screen"], - thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) } - }); - const physicalW = Math.round(display.bounds.width * scale); - // display_id 匹配 → 缩略图尺寸匹配 → 显示器数量一致时按下→右排序对位 - const displaysByX = [...displays].sort((a, b) => a.bounds.x - b.bounds.x); - const source = sources.find(item => item.display_id === String(display.id)) - || sources.find(item => item.thumbnail.getSize().width === physicalW) - || (sources.length === displays.length ? sources[displaysByX.findIndex(d => d.id === display.id)] : sources[0]); - if (!source || source.thumbnail.isEmpty()) { - throw new RuntimeError("pick_capture_failed", "框选截图失败:未获取到屏幕画面"); + try { + // 关键顺序: 先销毁覆盖层、完成截图, 最后才恢复应用窗口—— + // 之前是先恢复窗口再截图, 应用弹回盖住选区, 截到的全是应用窗口内容 + await new Promise(r => setTimeout(r, 300)); + const outDir = path.join(app.getPath("userData"), "region-picks"); + fs.mkdirSync(outDir, { recursive: true }); + const imagePath = path.join(outDir, `pick-region-${Date.now()}.png`); + // GDI CopyFromScreen 截不到 GPU 合成窗口(Electron 自身界面), 会截出全黑; + // 改用 desktopCapturer 走系统合成器截屏。rect 是屏幕绝对 DIP, 裁剪时换算到该屏相对坐标再乘 scaleFactor + const scale = display.scaleFactor || 1; + const sources = await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) } + }); + const physicalW = Math.round(display.bounds.width * scale); + // display_id 匹配 → 缩略图尺寸匹配 → 显示器数量一致时按下→右排序对位 + const displaysByX = [...displays].sort((a, b) => a.bounds.x - b.bounds.x); + const source = sources.find(item => item.display_id === String(display.id)) + || sources.find(item => item.thumbnail.getSize().width === physicalW) + || (sources.length === displays.length ? sources[displaysByX.findIndex(d => d.id === display.id)] : sources[0]); + if (!source || source.thumbnail.isEmpty()) { + throw new RuntimeError("pick_capture_failed", "框选截图失败:未获取到屏幕画面"); + } + const thumbnail = source.thumbnail; + const actualScale = thumbnail.getSize().width / Math.max(1, display.bounds.width); + const relX = rect.x - display.bounds.x; + const relY = rect.y - display.bounds.y; + const cropX = Math.max(0, Math.min(thumbnail.getSize().width - 1, Math.round(relX * actualScale))); + const cropY = Math.max(0, Math.min(thumbnail.getSize().height - 1, Math.round(relY * actualScale))); + const cropW = Math.max(1, Math.min(thumbnail.getSize().width - cropX, Math.round(rect.width * actualScale))); + const cropH = Math.max(1, Math.min(thumbnail.getSize().height - cropY, Math.round(rect.height * actualScale))); + fs.writeFileSync(imagePath, thumbnail.crop({ x: cropX, y: cropY, width: cropW, height: cropH }).toPNG()); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + scale, + imagePath + }; + } finally { + // 截图完成后(无论成败)恢复应用窗口 + for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } } } - const thumbnail = source.thumbnail; - const actualScale = thumbnail.getSize().width / Math.max(1, display.bounds.width); - const relX = rect.x - display.bounds.x; - const relY = rect.y - display.bounds.y; - const cropX = Math.max(0, Math.min(thumbnail.getSize().width - 1, Math.round(relX * actualScale))); - const cropY = Math.max(0, Math.min(thumbnail.getSize().height - 1, Math.round(relY * actualScale))); - const cropW = Math.max(1, Math.min(thumbnail.getSize().width - cropX, Math.round(rect.width * actualScale))); - const cropH = Math.max(1, Math.min(thumbnail.getSize().height - cropY, Math.round(rect.height * actualScale))); - fs.writeFileSync(imagePath, thumbnail.crop({ x: cropX, y: cropY, width: cropW, height: cropH }).toPNG()); - // 截图完成后才恢复应用窗口 - for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } } - // 返回屏幕绝对 DIP 坐标(含负值/副屏偏移)+该屏缩放比, 运行时由此换算点击位置 - return { - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - scale, - imagePath - }; }