# -*- coding: utf-8 -*- r'''被控端的**输入注入**:模拟鼠标和键盘。 == 用来干什么 == 1. 远程鼠标控制:移动 / 左键 / 右键 / 中键 / 滚轮 2. 「一键显示桌面」:Win+D == 为什么用 SendInput 而不是 mouse_event == `mouse_event` / `keybd_event` 是 Win95 时代的旧 API, 微软文档明确说"新程序请用 SendInput": * 旧的会被 UIPI 拦(不同完整性级别的窗口之间) * 旧的没有 `dwExtraInfo`,多个注入源会互相干扰 * SendInput 一次可以打包多个事件,**原子性好** —— 比如"按下+抬起"中间不会插进别的输入 == 坐标怎么算 == SendInput 的绝对坐标是 **0..65535 归一化**的,不是像素。 而且它是**按整个虚拟屏幕**(多显示器算成一个大方块)算的。 所以: 归一化 x = 像素 x * 65535 / (虚拟屏宽 - 1) 归一化 y = 像素 y * 65535 / (虚拟屏高 - 1) ⚠ 那个 `-1` 不能省。微软文档写得很清楚:0 和 65535 分别对应 屏幕最左/最右**像素**,所以要除以 `宽-1`。少了这个 -1, 最右边和最下边几像素点不到 —— 表现为"鼠标到不了屏幕边缘", 任务栏在底下的机器上尤其明显。 == 权限 == 被控端跑在普通用户下也能注入(同一会话内)。 但如果目标窗口是**管理员权限**的(比如 UAC 提升过的程序), 普通权限的 SendInput 会被 UIPI 拦掉,鼠标点不动。 这不是 bug,是 Windows 的安全设计(防止低权限程序操控高权限窗口)。 被控端如果以管理员运行就都能点。README 里说清楚这一点。 ''' from __future__ import annotations import ctypes import ctypes.wintypes as wt user32 = ctypes.WinDLL("user32", use_last_error=True) # ---- SendInput 的结构体 ---- # # ⚠ INPUT 结构在 32 位和 64 位下**大小不一样**: # 64 位下 union 前面有 4 字节的对齐填充,sizeof(INPUT) == 40; # 32 位下是 28。 # 所以必须让 ctypes 按本进程的位数算,不能写死。 # (我们用 Python 3.8 **32 位**打包,所以实际是 28 —— # 但代码要两边都对,免得以后换 64 位打包时静默出错。) ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 \ else ctypes.c_ulong class MOUSEINPUT(ctypes.Structure): _fields_ = [("dx", wt.LONG), ("dy", wt.LONG), ("mouseData", wt.DWORD), ("dwFlags", wt.DWORD), ("time", wt.DWORD), ("dwExtraInfo", ULONG_PTR)] class KEYBDINPUT(ctypes.Structure): _fields_ = [("wVk", wt.WORD), ("wScan", wt.WORD), ("dwFlags", wt.DWORD), ("time", wt.DWORD), ("dwExtraInfo", ULONG_PTR)] class HARDWAREINPUT(ctypes.Structure): _fields_ = [("uMsg", wt.DWORD), ("wParamL", wt.WORD), ("wParamH", wt.WORD)] class _INPUTunion(ctypes.Union): _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)] class INPUT(ctypes.Structure): _fields_ = [("type", wt.DWORD), ("union", _INPUTunion)] INPUT_MOUSE = 0 INPUT_KEYBOARD = 1 # 鼠标标志 MOUSEEVENTF_MOVE = 0x0001 MOUSEEVENTF_LEFTDOWN = 0x0002 MOUSEEVENTF_LEFTUP = 0x0004 MOUSEEVENTF_RIGHTDOWN = 0x0008 MOUSEEVENTF_RIGHTUP = 0x0010 MOUSEEVENTF_MIDDLEDOWN = 0x0020 MOUSEEVENTF_MIDDLEUP = 0x0040 MOUSEEVENTF_WHEEL = 0x0800 MOUSEEVENTF_HWHEEL = 0x1000 MOUSEEVENTF_ABSOLUTE = 0x8000 MOUSEEVENTF_VIRTUALDESK = 0x4000 # 键盘标志 KEYEVENTF_KEYUP = 0x0002 KEYEVENTF_EXTENDEDKEY = 0x0001 VK_LWIN = 0x5B VK_D = 0x44 VK_ESCAPE = 0x1B WHEEL_DELTA = 120 # 屏幕指标 SM_XVIRTUALSCREEN = 76 SM_YVIRTUALSCREEN = 77 SM_CXVIRTUALSCREEN = 78 SM_CYVIRTUALSCREEN = 79 # 屏幕宽高在进程生命周期里基本不变,缓存一下 _screen_cache: dict = {} class InputError(Exception): """注入失败(被系统拦了、参数不对等)。""" def _screen() -> tuple: """整个虚拟屏幕的 (左, 上, 宽, 高)。多显示器时是包住所有屏的矩形。""" if not _screen_cache: _screen_cache["v"] = ( user32.GetSystemMetrics(SM_XVIRTUALSCREEN), user32.GetSystemMetrics(SM_YVIRTUALSCREEN), user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), user32.GetSystemMetrics(SM_CYVIRTUALSCREEN), ) return _screen_cache["v"] def refresh_screen() -> None: """显示器分辨率变了之后调一下,让缓存重新读。""" _screen_cache.clear() def _send(*inputs: INPUT) -> None: """把一批事件一次性发出去。""" if not inputs: return n = len(inputs) arr = (INPUT * n)(*inputs) sent = user32.SendInput(n, ctypes.byref(arr), ctypes.sizeof(INPUT)) if sent != n: err = ctypes.get_last_error() # 0x57 = ERROR_INVALID_PARAMETER,多半是被 UIPI 拦了 if err == 87: raise InputError("系统拒绝了这次输入(目标窗口权限比被控端高?" "把被控端用管理员身份运行就能解决)") raise InputError("注入输入失败(SendInput 返回 %d/%d,错误码 %d)" % (sent, n, err)) def _mouse(dx: int, dy: int, data: int, flags: int) -> INPUT: return INPUT(type=INPUT_MOUSE, union=_INPUTunion(mi=MOUSEINPUT(dx, dy, data, flags, 0, 0))) def _key(vk: int, up: bool = False) -> INPUT: flags = KEYEVENTF_KEYUP if up else 0 # 左右 Win / 方向键这些是扩展键(不是必须,但更准确) if vk in (VK_LWIN, 0x5C, 0x25, 0x26, 0x27, 0x28): flags |= KEYEVENTF_EXTENDEDKEY return INPUT(type=INPUT_KEYBOARD, union=_INPUTunion(ki=KEYBDINPUT(vk, 0, flags, 0, 0))) # ---------------------------------------------------------------- 鼠标 def move_to(x: int, y: int) -> None: """把鼠标移到**屏幕像素坐标** (x, y)。 坐标是相对虚拟屏幕左上角的像素值;超出范围会被夹到边界内。 """ vx, vy, vw, vh = _screen() if vw <= 1 or vh <= 1: raise InputError("拿不到屏幕尺寸") # 夹到边界,免得归一化后溢出 px = max(vx, min(vx + vw - 1, int(x))) py = max(vy, min(vy + vh - 1, int(y))) # ⚠ 除以 (宽-1),见文件头说明 nx = int(round((px - vx) * 65535 / (vw - 1))) ny = int(round((py - vy) * 65535 / (vh - 1))) _send(_mouse(nx, ny, 0, MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK)) def move_by(dx: int, dy: int) -> None: """相对移动(有些场景比绝对坐标好用,比如拖拽)。""" _send(_mouse(int(dx), int(dy), 0, MOUSEEVENTF_MOVE)) _BUTTONS = { "left": (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), "right": (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), "middle": (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), } def click(button: str = "left", double: bool = False) -> None: """点一下(按下+抬起一次发出去,中间不会插进别的输入)。""" b = _BUTTONS.get(str(button).lower()) if not b: raise InputError("不认识的鼠标键:%s(只能 left/right/middle)" % button) down, up = b seq = [_mouse(0, 0, 0, down), _mouse(0, 0, 0, up)] if double: seq = seq + seq _send(*seq) def down(button: str = "left") -> None: b = _BUTTONS.get(str(button).lower()) if not b: raise InputError("不认识的鼠标键:%s" % button) _send(_mouse(0, 0, 0, b[0])) def up(button: str = "left") -> None: b = _BUTTONS.get(str(button).lower()) if not b: raise InputError("不认识的鼠标键:%s" % button) _send(_mouse(0, 0, 0, b[1])) def scroll(amount: int, horizontal: bool = False) -> None: """滚轮。amount 正数向上/向右,负数向下/向左。 一个"格"按 120 算(Windows 的 WHEEL_DELTA)。 手表那边传 1/-1 这种格子数就行,这里乘 120。 """ try: n = int(amount) except (TypeError, ValueError): raise InputError("滚轮量必须是整数") if n == 0: return # 夹一下,防止一次滚几百格把页面甩飞 n = max(-50, min(50, n)) flag = MOUSEEVENTF_HWHEEL if horizontal else MOUSEEVENTF_WHEEL _send(_mouse(0, 0, (n * WHEEL_DELTA) & 0xFFFFFFFF, flag)) # ---------------------------------------------------------------- 键盘 def key(vk: int) -> None: """按一下某个虚拟键码。""" _send(_key(int(vk)), _key(int(vk), up=True)) def combo(*vks: int) -> None: """组合键,比如 combo(VK_LWIN, VK_D) 就是 Win+D。 顺序:全部按下 -> 逆序抬起。这样修饰键不会卡住。 """ if not vks: return seq = [_key(v) for v in vks] seq += [_key(v, up=True) for v in reversed(vks)] _send(*seq) def show_desktop() -> None: """显示桌面(等于 Win+D)。 用户要求快捷栏里那个不能改的按钮就是这个功能。 """ combo(VK_LWIN, VK_D)