├── src └── interception │ ├── py.typed │ ├── __init__.py │ ├── beziercurve.py │ ├── _ioctl.py │ ├── exceptions.py │ ├── _utils.py │ ├── constants.py │ ├── interception.py │ ├── strokes.py │ ├── device.py │ ├── _keycodes.py │ └── inputs.py ├── setup.py ├── demo └── curves.gif ├── setup.cfg ├── pyproject.toml ├── LICENSE ├── README.md └── .gitignore /src/interception/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup # type: ignore[import] 2 | 3 | setup() -------------------------------------------------------------------------------- /demo/curves.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennyhml/pyinterception/HEAD/demo/curves.gif -------------------------------------------------------------------------------- /src/interception/__init__.py: -------------------------------------------------------------------------------- 1 | from .interception import Interception 2 | from .device import Device 3 | from .strokes import KeyStroke, MouseStroke, Stroke 4 | from .inputs import * -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = "interception-python" 3 | license_files = [LICENSE] 4 | 5 | [options] 6 | package_dir= 7 | =src 8 | packages = find: 9 | zip_safe = False 10 | python_requires = >= 3 11 | 12 | [options.packages.find] 13 | where = src 14 | exclude = 15 | tests* 16 | .gitignore -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "interception-python" 7 | version = "1.13.6" 8 | authors = [{ name = "Kenny Hommel", email = "kennyhommel36@gmail.com" }] 9 | description = "A python port of interception, which hooks into the input event handling mechanisms to simulate inputs without injected flags" 10 | readme = "README.md" 11 | requires-python = ">=3.10" 12 | classifiers = [ 13 | "Programming Language :: Python :: 3", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | ] 17 | 18 | [project.urls] 19 | "Homepage" = "https://github.com/kennyhml/pyinterception" 20 | "Bug Tracker" = "https://github.com/kennyhml/pyinterception/issues" 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 cob_258 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/interception/beziercurve.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Callable, Optional 3 | from . import exceptions 4 | 5 | try: 6 | from pyclick.humancurve import HumanCurve # type: ignore[import] 7 | except ImportError: 8 | 9 | class HumanCurve: # type: ignore[no-redef] 10 | 11 | def __init__(self, *args, **kwargs) -> None: 12 | # If pyclick isnt installed this dummy class will be initialized instead, 13 | # so just use that to throw the exception. 14 | self.points: list[tuple[int, int]] 15 | 16 | raise exceptions.PyClickNotInstalled 17 | 18 | 19 | @dataclass 20 | class BezierCurveParams: 21 | 22 | knots: int = 2 23 | distortion_mean: int = 1 24 | distortion_stdev: int = 1 25 | distortion_frequency: float = 0.5 26 | tween: Optional[Callable[[None], None]] = None 27 | target_points: int = 100 28 | 29 | 30 | _g_params: Optional[BezierCurveParams] = None 31 | 32 | 33 | def set_default_params(params: BezierCurveParams) -> None: 34 | global _g_params 35 | _g_params = params 36 | 37 | 38 | def get_default_params() -> Optional[BezierCurveParams]: 39 | return _g_params 40 | -------------------------------------------------------------------------------- /src/interception/_ioctl.py: -------------------------------------------------------------------------------- 1 | # Constants for the CTL_CODE macro 2 | # See: https://github.com/tpn/winsdk-10/blob/master/Include/10.0.16299.0/km/d4drvif.h 3 | IOCTL_DOT4_USER_BASE = 2049 4 | FILE_DEVICE_UNKNOWN = 0x00000022 5 | METHOD_BUFFERED = 0 6 | FILE_ANY_ACCESS = 0 7 | 8 | 9 | # Python equivalent of https://github.com/tpn/winsdk-10/blob/9b69fd26ac0c7d0b83d378dba01080e93349c2ed/Include/10.0.16299.0/km/d4drvif.h#L38 10 | def ctl(device_type, function_code, method, access): 11 | return (device_type << 16) | (access << 14) | (function_code << 2) | method 12 | 13 | 14 | # Create the IOCTL codes that we need to use 15 | IOCTL_SET_PRECEDENCE = ctl(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS) 16 | IOCTL_GET_PRECEDENCE = ctl(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS) 17 | IOCTL_SET_FILTER = ctl(FILE_DEVICE_UNKNOWN, 0x804, METHOD_BUFFERED, FILE_ANY_ACCESS) 18 | IOCTL_GET_FILTER = ctl(FILE_DEVICE_UNKNOWN, 0x808, METHOD_BUFFERED, FILE_ANY_ACCESS) 19 | IOCTL_SET_EVENT = ctl(FILE_DEVICE_UNKNOWN, 0x810, METHOD_BUFFERED, FILE_ANY_ACCESS) 20 | IOCTL_WRITE = ctl(FILE_DEVICE_UNKNOWN, 0x820, METHOD_BUFFERED, FILE_ANY_ACCESS) 21 | IOCTL_READ = ctl(FILE_DEVICE_UNKNOWN, 0x840, METHOD_BUFFERED, FILE_ANY_ACCESS) 22 | IOCTL_GET_HARDWARE_ID = ctl( 23 | FILE_DEVICE_UNKNOWN, 0x880, METHOD_BUFFERED, FILE_ANY_ACCESS 24 | ) 25 | -------------------------------------------------------------------------------- /src/interception/exceptions.py: -------------------------------------------------------------------------------- 1 | class DriverNotFoundError(Exception): 2 | """Raised when the interception driver is not installed / found.""" 3 | 4 | def __str__(self) -> str: 5 | return ( 6 | "Interception driver was not found or is not installed.\n" 7 | "Please confirm that it has been installed properly and is added to PATH." 8 | ) 9 | 10 | 11 | class PyClickNotInstalled(Exception): 12 | """Raised when attempting to use human curve functionality without pyclick.""" 13 | 14 | def __str__(self) -> str: 15 | return ( 16 | "PyClick must be installed to generate human curves (pip install pyclick)." 17 | ) 18 | 19 | 20 | class UnknownKeyError(LookupError): 21 | """Raised when attemping to press a key that doesnt exist""" 22 | 23 | def __init__(self, key: str) -> None: 24 | self.key = key 25 | 26 | def __str__(self) -> str: 27 | return f"Unknown key requested: {self.key}!" 28 | 29 | 30 | class UnknownButtonError(LookupError): 31 | """Raised when attemping to press a mouse button that doesnt exist""" 32 | 33 | def __init__(self, button: str) -> None: 34 | self.button = button 35 | 36 | def __str__(self) -> str: 37 | return ( 38 | f"Unknown button requested: {self.button}.\n" 39 | "Consider running 'pyinterception show_supported_buttons' for a list of all supported buttons." 40 | ) 41 | -------------------------------------------------------------------------------- /src/interception/_utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Optional 3 | import functools 4 | from threading import Thread 5 | import win32api # type: ignore 6 | import ctypes 7 | 8 | SPI_GETMOUSE = 0x003 9 | SPI_SETMOUSE = 0x004 10 | SPIF_SENDCHANGE = 0x002 11 | 12 | SystemParametersInfoA = ctypes.windll.user32.SystemParametersInfoA 13 | 14 | 15 | def normalize(x: int | tuple[int, int], y: Optional[int] = None) -> tuple[int, int]: 16 | """Normalizes an x, y position to allow passing them seperately or as tuple.""" 17 | if isinstance(x, tuple): 18 | if len(x) == 2: 19 | x, y = x 20 | elif len(x) == 4: 21 | x, y, *_ = x 22 | else: 23 | raise ValueError(f"Cant normalize tuple of length {len(x)}: {x}") 24 | else: 25 | assert y is not None 26 | 27 | return int(x), int(y) 28 | 29 | 30 | def to_interception_coordinate(x: int, y: int) -> tuple[int, int]: 31 | """Scales a "normal" coordinate to the respective point in the interception 32 | coordinate system. 33 | 34 | The interception coordinate system covers all 16-bit unsigned integers, 35 | ranging from `0x0` to `0xFFFF (65535)`. 36 | 37 | To arrive at the formula, we first have to realize the following: 38 | - The maximum value in the 16-bit system is so `0xFFFF (~65535)` 39 | - The maximum value, depending on your monitor, would for example be `1920` 40 | - To calculate the factor, we can calculate `65535 / 1920 = ~34.13`. 41 | - Thus we found out, that `scaled x = factor * original x` and `factor = 0xFFFF / axis` 42 | 43 | So, to bring it to code: 44 | ```py 45 | xfactor = 0xFFFF / screen_width 46 | yfactor = 0xFFFF / screen_height 47 | ``` 48 | 49 | Now, using that factor, we can calculate the position of our coordinate as such: 50 | ```py 51 | interception_x = round(xfactor * x) 52 | interception_y = round(yfactor * y) 53 | """ 54 | 55 | def scale(metric_index: int, point: int) -> int: 56 | scale: float = 0xFFFF / win32api.GetSystemMetrics(metric_index) 57 | return round(point * scale) 58 | 59 | return scale(0, x), scale(1, y) 60 | 61 | 62 | def get_cursor_pos() -> tuple[int, int]: 63 | """Gets the current position of the cursor using `GetCursorPos`""" 64 | return win32api.GetCursorPos() 65 | 66 | 67 | def threaded(name: str): 68 | """Threads a function, beware that it will lose its return values""" 69 | 70 | def outer(func): 71 | @functools.wraps(func) 72 | def inner(*args, **kwargs): 73 | def run(): 74 | func(*args, **kwargs) 75 | 76 | thread = Thread(target=run, name=name) 77 | thread.start() 78 | 79 | return inner 80 | 81 | return outer 82 | 83 | 84 | def set_win32_mouse_acceleration(enabled: bool): 85 | 86 | # buffer storing the mouse state. The last element is the acceleration 87 | mouse_params = (ctypes.c_int * 3)() 88 | 89 | SystemParametersInfoA(SPI_GETMOUSE, 0, mouse_params, 0) 90 | mouse_params[2] = int(enabled) 91 | SystemParametersInfoA(SPI_SETMOUSE, 0, mouse_params, SPIF_SENDCHANGE) 92 | 93 | 94 | @contextmanager 95 | def disable_mouse_acceleration(): 96 | set_win32_mouse_acceleration(False) 97 | try: 98 | yield 99 | finally: 100 | set_win32_mouse_acceleration(True) 101 | -------------------------------------------------------------------------------- /src/interception/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import IntEnum 3 | 4 | 5 | class KeyFlag(IntEnum): 6 | """ 7 | Interception uses the key flag enums as defined per win32. 8 | 9 | See `Flags` member: https://learn.microsoft.com/de-de/windows/win32/api/winuser/ns-winuser-rawkeyboard#members 10 | """ 11 | 12 | KEY_DOWN = 0x00 13 | KEY_UP = 0x01 14 | KEY_E0 = 0x02 15 | KEY_E1 = 0x04 16 | KEY_TERMSRV_SET_LED = 0x08 17 | KEY_TERMSRV_SHADOW = 0x10 18 | KEY_TERMSRV_VKPACKET = 0x20 19 | 20 | 21 | class MouseButtonFlag(IntEnum): 22 | """ 23 | Interception uses the mouse button flag enums as defined per win32. 24 | 25 | See usButtonFlags member: https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-rawmouse#members 26 | """ 27 | 28 | MOUSE_LEFT_BUTTON_DOWN = 0x001 29 | MOUSE_LEFT_BUTTON_UP = 0x002 30 | MOUSE_RIGHT_BUTTON_DOWN = 0x004 31 | MOUSE_RIGHT_BUTTON_UP = 0x008 32 | MOUSE_MIDDLE_BUTTON_DOWN = 0x010 33 | MOUSE_MIDDLE_BUTTON_UP = 0x020 34 | 35 | MOUSE_BUTTON_4_DOWN = 0x040 36 | MOUSE_BUTTON_4_UP = 0x080 37 | MOUSE_BUTTON_5_DOWN = 0x100 38 | MOUSE_BUTTON_5_UP = 0x200 39 | 40 | MOUSE_WHEEL = 0x400 41 | MOUSE_HWHEEL = 0x800 42 | 43 | @staticmethod 44 | def from_string(button: str) -> tuple[MouseButtonFlag, MouseButtonFlag]: 45 | return _MAPPED_MOUSE_BUTTONS[button] 46 | 47 | 48 | class MouseFlag(IntEnum): 49 | """ 50 | Interception uses the mouse state enums as defined per win32. 51 | 52 | See usFlags member: https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-rawmouse#members 53 | """ 54 | 55 | MOUSE_MOVE_RELATIVE = 0x000 56 | MOUSE_MOVE_ABSOLUTE = 0x001 57 | MOUSE_VIRTUAL_DESKTOP = 0x002 58 | MOUSE_ATTRIBUTES_CHANGED = 0x004 59 | MOUSE_MOVE_NOCOALESCE = 0x008 60 | MOUSE_TERMSRV_SRC_SHADOW = 0x100 61 | 62 | 63 | class MouseRolling(IntEnum): 64 | """ 65 | See: https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-rawmouse#remarks 66 | 67 | The wheel rotation will be a multiple of WHEEL_DELTA, which is set at 120. 68 | This is the threshold for action to be taken, and one such action should occur for each delta. 69 | """ 70 | 71 | MOUSE_WHEEL_UP = 0x78 72 | MOUSE_WHEEL_DOWN = 0xFF88 73 | 74 | 75 | class FilterMouseButtonFlag(IntEnum): 76 | FILTER_MOUSE_NONE = 0x0000 77 | FILTER_MOUSE_ALL = 0xFFFF 78 | 79 | FILTER_MOUSE_LEFT_BUTTON_DOWN = MouseButtonFlag.MOUSE_LEFT_BUTTON_DOWN 80 | FILTER_MOUSE_LEFT_BUTTON_UP = MouseButtonFlag.MOUSE_LEFT_BUTTON_UP 81 | FILTER_MOUSE_RIGHT_BUTTON_DOWN = MouseButtonFlag.MOUSE_RIGHT_BUTTON_DOWN 82 | FILTER_MOUSE_RIGHT_BUTTON_UP = MouseButtonFlag.MOUSE_RIGHT_BUTTON_UP 83 | FILTER_MOUSE_MIDDLE_BUTTON_DOWN = MouseButtonFlag.MOUSE_MIDDLE_BUTTON_DOWN 84 | FILTER_MOUSE_MIDDLE_BUTTON_UP = MouseButtonFlag.MOUSE_MIDDLE_BUTTON_UP 85 | 86 | FILTER_MOUSE_BUTTON_4_DOWN = MouseButtonFlag.MOUSE_BUTTON_4_DOWN 87 | FILTER_MOUSE_BUTTON_4_UP = MouseButtonFlag.MOUSE_BUTTON_4_UP 88 | FILTER_MOUSE_BUTTON_5_DOWN = MouseButtonFlag.MOUSE_BUTTON_5_DOWN 89 | FILTER_MOUSE_BUTTON_5_UP = MouseButtonFlag.MOUSE_BUTTON_5_UP 90 | 91 | FILTER_MOUSE_WHEEL = MouseButtonFlag.MOUSE_WHEEL 92 | FILTER_MOUSE_HWHEEL = MouseButtonFlag.MOUSE_HWHEEL 93 | FILTER_MOUSE_MOVE = 0x1000 94 | 95 | 96 | class FilterKeyFlag(IntEnum): 97 | FILTER_KEY_NONE = 0x0000 98 | FILTER_KEY_ALL = 0xFFFF 99 | FILTER_KEY_DOWN = KeyFlag.KEY_UP 100 | FILTER_KEY_UP = KeyFlag.KEY_UP << 1 101 | FILTER_KEY_E0 = KeyFlag.KEY_E0 << 1 102 | FILTER_KEY_E1 = KeyFlag.KEY_E1 << 1 103 | FILTER_KEY_TERMSRV_SET_LED = KeyFlag.KEY_TERMSRV_SET_LED << 1 104 | FILTER_KEY_TERMSRV_SHADOW = KeyFlag.KEY_TERMSRV_SHADOW << 1 105 | FILTER_KEY_TERMSRV_VKPACKET = KeyFlag.KEY_TERMSRV_VKPACKET << 1 106 | 107 | 108 | _MAPPED_MOUSE_BUTTONS = { 109 | "left": ( 110 | MouseButtonFlag.MOUSE_LEFT_BUTTON_DOWN, 111 | MouseButtonFlag.MOUSE_LEFT_BUTTON_UP, 112 | ), 113 | "right": ( 114 | MouseButtonFlag.MOUSE_RIGHT_BUTTON_DOWN, 115 | MouseButtonFlag.MOUSE_RIGHT_BUTTON_UP, 116 | ), 117 | "middle": ( 118 | MouseButtonFlag.MOUSE_MIDDLE_BUTTON_DOWN, 119 | MouseButtonFlag.MOUSE_MIDDLE_BUTTON_UP, 120 | ), 121 | "mouse4": (MouseButtonFlag.MOUSE_BUTTON_4_DOWN, MouseButtonFlag.MOUSE_BUTTON_4_UP), 122 | "mouse5": (MouseButtonFlag.MOUSE_BUTTON_5_DOWN, MouseButtonFlag.MOUSE_BUTTON_5_UP), 123 | } 124 | -------------------------------------------------------------------------------- /src/interception/interception.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | 3 | from typing import Callable, Final, Optional 4 | 5 | from .strokes import Stroke 6 | from .device import Device 7 | 8 | MAX_DEVICES: Final = 20 9 | MAX_KEYBOARD: Final = 10 10 | MAX_MOUSE: Final = 10 11 | 12 | GENERIC_READ: Final = 0x80000000 13 | OPEN_EXISTING: Final = 0x3 14 | WAIT_TIMEOUT: Final = 0x102 15 | WAIT_FAILED: Final = 0xFFFFFFFF 16 | 17 | 18 | class Interception: 19 | """Represents an interception context. 20 | 21 | Encapsulating the environment into a class context is useful in order to 22 | allow the quick creation of different contexts (e.g a filter context). 23 | 24 | Properties 25 | ---------- 26 | mouse :class:`int`: 27 | The mouse device number the context is currently using 28 | 29 | keyboard :class:`int`: 30 | The keyboard device number the context is currently using. 31 | 32 | devices :class:`list[Device]`: 33 | A list containing all 20 devices the context is managing 34 | """ 35 | 36 | def __init__(self) -> None: 37 | self._devices: list[Device] = [] 38 | self._event_handles = (ctypes.c_void_p * MAX_DEVICES)() 39 | self._using_mouse = 11 40 | self._using_keyboard = 1 41 | 42 | try: 43 | self.get_handles() 44 | except Exception: 45 | self.destroy() 46 | 47 | @property 48 | def mouse(self) -> int: 49 | return self._using_mouse 50 | 51 | @mouse.setter 52 | def mouse(self, num: int) -> None: 53 | if self.is_invalid(num) or not self.is_mouse(num): 54 | raise ValueError(f"{num} mouse number does not match (10 <= num <= 19).") 55 | self._using_mouse = num 56 | 57 | @property 58 | def keyboard(self) -> int: 59 | return self._using_keyboard 60 | 61 | @keyboard.setter 62 | def keyboard(self, num: int) -> None: 63 | if self.is_invalid(num) or not self.is_keyboard(num): 64 | raise ValueError(f"{num} keyboard number does not match (0 <= num <= 9).") 65 | self._using_keyboard = num 66 | 67 | @property 68 | def devices(self) -> list[Device]: 69 | return self._devices 70 | 71 | @property 72 | def valid(self) -> bool: 73 | return len(self._devices) > 0 74 | 75 | def destroy(self) -> None: 76 | for device in self._devices: 77 | device.destroy() 78 | 79 | def get_handles(self) -> None: 80 | """Opens handles to all 20 interception devices and events. 81 | 82 | See: 83 | https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea 84 | https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createeventa 85 | """ 86 | for num in range(MAX_DEVICES): 87 | device_name = f"\\\\.\\interception{num:02d}".encode() 88 | hdevice = ctypes.windll.kernel32.CreateFileA( 89 | device_name, GENERIC_READ, 0, 0, OPEN_EXISTING, 0, 0 90 | ) 91 | hevent = ctypes.windll.kernel32.CreateEventA(0, 1, 0, 0) 92 | 93 | device = Device(hdevice, hevent, is_keyboard=self.is_keyboard(num)) 94 | self._devices.append(device) 95 | self._event_handles[num] = hevent 96 | 97 | def await_input(self, timeout_milliseconds: int = -1) -> Optional[int]: 98 | """Waits until any of the devices received an input or the timeout has 99 | expired (if it is non-negative). 100 | 101 | Once an input was received, the number of the device that received the input 102 | is returned. 103 | """ 104 | result = ctypes.windll.kernel32.WaitForMultipleObjects( 105 | MAX_DEVICES, self._event_handles, 0, timeout_milliseconds 106 | ) 107 | return None if result in [-1, WAIT_TIMEOUT, WAIT_FAILED] else result 108 | 109 | def set_filter(self, condition: Callable[[int], bool], filter: int): 110 | for i in range(MAX_DEVICES): 111 | if condition(i): 112 | self._devices[i].set_filter(filter) 113 | 114 | def send(self, device: int, stroke: Stroke): 115 | return self._devices[device].send(stroke) 116 | 117 | @staticmethod 118 | def is_keyboard(device: int): 119 | """Determines whether a device is a keyboard based on it's index""" 120 | return 0 <= device <= MAX_KEYBOARD - 1 121 | 122 | @staticmethod 123 | def is_mouse(device: int): 124 | """Determines whether a device is a mouse based on it's index""" 125 | return MAX_KEYBOARD <= device <= (MAX_KEYBOARD + MAX_MOUSE) - 1 126 | 127 | @staticmethod 128 | def is_invalid(device: int): 129 | """Determines whether a device is invalid based on it's index""" 130 | return not 0 <= device <= 19 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyinterception 2 | This is a python **port and wrapper** for [interception][c_ception], a low level input device driver. 3 | 4 | > The Interception API aims to build a portable programming interface that allows one to intercept and control a range of input devices. 5 | 6 | If your're a C++ Developer, also check out my modern [C++ wrapper][interccption] for the interception c-api! 7 | 8 | ## Installing 9 | Pyinterception is available on PyPi as `interception-python`, so simply `pip install interception-python`. 10 | 11 | ## Why use the interception device driver? 12 | Some people are under the impression that windows doesnt differentiate between *fake* inputs and *real* inputs, but that is **wrong**! 13 | 14 | Take a look at [KBDLLHOOKSTRUCT][kbdllhook], specifically the `flags` field: 15 | > Testing LLKHF_INJECTED (bit 4) will tell you whether the event was injected. If it was, then testing LLKHF_LOWER_IL_INJECTED (bit 1) will tell you whether or not the event was injected from a process running at lower integrity level. 16 | 17 | This flag will **always** be set when sending an input through the windows API and there is nothing you can do about it. Programs may not pick up on this flag through the `KBDLLHOOKSTRUCT`, but it certainly proves that the OS marks these inputs and it is something that could always be considered in such an analysis. 18 | 19 | > [!NOTE] 20 | > Some versions of more advanced anti cheats, including vanguard and some versions of EAC, **will not boot** with the interception driver loaded. 21 | > You usually won't get banned for having it - you simply wont be able to launch the game. It is a well known piece of software that is not rarely abused. 22 | > Theres really no way around this except writing your own driver (which comes with it's own set of challenges). 23 | 24 | ## Why use this port? 25 | - Very simple interface inspired by pyautogui / pydirectinput, the low-level communication is abstracted away. 26 | - Dynamically obtains scancodes, thus doesnt depend on the order of your keyboard layout. 27 | - Well documented for anyone who is interested in implementing any functionality themselves. 28 | - Completely self-contained, no dependencies are required to use the library other than the driver itself! 29 | - Supports keys that are extended or require a shift / alt / ctrl modifier to work. 30 | - Supports 'human' movement by generating configurable [Bezier Curves][curve] - requires [PyClick][pyclick] to be installed. 31 | 32 | ## How is it used? 33 | The [interception-driver][c_ception] must be installed on your system, otherwise none of this will work. 34 | 35 | From your code, simply call `interception.auto_capture_devices()` in order for the library to get the correct device handles. 36 | Explaining why would blow the scope of this introduction and you shouldn't have to worry about, just call the function and let it do it's thing! 37 | 38 | Now you can begin to send inputs, just like you are used to it from pyautogui or pydirectinput! 39 | ```py 40 | interception.move_to(960, 540) 41 | 42 | with interception.hold_key("ctrl"): 43 | interception.press("v") 44 | 45 | interception.click(120, 160, button="right", delay=1) 46 | ``` 47 | 48 | ## Human Mouse Movement 49 | Some people may need the library to move the mouse in a more 'human' fashion to be less vulnerable to heuristic input detection. 50 | 51 | [PyClick][pyclick] already offers a great way to create custom Bezier Curves, so the library just makes use of that. To avoid bloat for the people who 52 | do not care about this functionality, PyClick must be installed manually if you want to use it. 53 | 54 | First create your Bezier Curve parameters container. You can either pass the params to `move_to` calls individually, or set them globally. 55 | ```py 56 | from interception import beziercurve 57 | 58 | curve_params = beziercurve.BezierCurveParams() 59 | 60 | # Uses a bezier curve created with the specified parameters 61 | interception.move_to(960, 540, params) 62 | 63 | # Does not use a bezier curve, instead 'warps' to the location 64 | interception.move_to(960, 540) 65 | 66 | beziercurve.set_default_params(params) 67 | 68 | # Uses the bezier curve parameters we just declared as default 69 | interception.move_to(960, 540) 70 | 71 | # Overrules the default bezier curve parameters and 'warps' instead 72 | interception.move_to(960, 540, allow_global_params=False) 73 | ``` 74 | 75 | The resulting mouse movements look something like this (with the default curve parameters): 76 |

77 | 78 |

79 | 80 | [c_ception]: https://github.com/oblitum/Interception 81 | [pyclick]: https://github.com/patrikoss/pyclick 82 | [curve]: https://en.wikipedia.org/wiki/B%C3%A9zier_curve 83 | [kbdllhook]: https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-kbdllhookstruct?redirectedfrom=MSDN 84 | [interccption]: https://github.com/kennyhml/intercpption 85 | -------------------------------------------------------------------------------- /src/interception/strokes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import struct 4 | 5 | from typing import Protocol, ClassVar, TypeVar, Type 6 | from dataclasses import dataclass, field 7 | 8 | T = TypeVar("T", bound="Stroke") 9 | 10 | 11 | class Stroke(Protocol): 12 | """The Protocol any stroke (input) must implement. 13 | 14 | This essentially requires the strokes to deal with conversions of the stroke 15 | between the c-struct that interception expects and the python object we use. 16 | 17 | Another option would be to use ctypes.Structure together with _fields_ 18 | attributes to get rid of the conversion through struct.pack / struct.unpack, 19 | but doing things that way we would lose alot of the type-checking and IDE 20 | support for variable access. 21 | 22 | To pack the struct, we need a format for the packer to use that knows the 23 | size of each fields in bytes, you can read more about the format here: 24 | https://docs.python.org/3/library/struct.html#format-characters 25 | """ 26 | 27 | format: ClassVar[bytes] 28 | 29 | @classmethod 30 | def parse(cls: Type[T], data: bytes) -> T: ... 31 | 32 | @property 33 | def data(self) -> bytes: ... 34 | 35 | 36 | @dataclass 37 | class MouseStroke: 38 | """The data of a single interception mouse stroke. 39 | 40 | Reference: https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-rawmouse#members 41 | 42 | Attributes 43 | ---------- 44 | flags :class:`int`: 45 | Combinations of `MouseState`, equivalent to referenced `usFlags` of `RAWMOUSE` 46 | 47 | button_flags :class:`int`: 48 | Combination of `MouseButtonFlag`, equivalent to referenced `usButtonFlags` of `RAWMOUSE` 49 | 50 | button_data :class:`int`: 51 | If button_flags named a MOUSE_WHEEL, specifies the distance the wheel is rotated. 52 | 53 | x :class:`int`: 54 | Signed relative motion or absolute motion in x direction, depending on the value of `flags`. 55 | 56 | y :class:`int`: 57 | Signed relative motion or absolute motion in y direction, depending on the value of `flags`. 58 | 59 | information :class:`int`: 60 | Additional device-specific information for the event. See `ulExtraInformation`\n 61 | This field is receive-only, setting this yourself doesnt change anything. 62 | """ 63 | 64 | format: ClassVar[bytes] = b"HHHHIiiI" 65 | 66 | flags: int 67 | button_flags: int 68 | button_data: int 69 | x: int 70 | y: int 71 | 72 | information: int = field(init=False, default=0) 73 | _unit_id: int = field(init=False, default=0, repr=False) 74 | _raw_buttons: int = field(init=False, default=0, repr=False) 75 | 76 | @classmethod 77 | def parse(cls: Type[MouseStroke], data: bytes) -> MouseStroke: 78 | unpacked: tuple[int, ...] = struct.unpack(cls.format, data) 79 | 80 | # The order of the MouseStroke initializer doesnt match the values 81 | # arrangement in the bytes struct, so we need to 'pick' them in order. 82 | # This is because many of the fields are useless to initialize ourselves 83 | # and only report information of a received event. 84 | instance = cls(*(unpacked[i] for i in (1, 2, 3, 5, 6))) 85 | 86 | instance.information = unpacked[7] 87 | instance._unit_id = unpacked[0] 88 | instance._raw_buttons = unpacked[4] 89 | 90 | return instance 91 | 92 | @property 93 | def data(self) -> bytes: 94 | return struct.pack( 95 | self.format, 96 | self._unit_id, 97 | self.flags, 98 | self.button_flags, 99 | self.button_data, 100 | self._raw_buttons, 101 | self.x, 102 | self.y, 103 | self.information, 104 | ) 105 | 106 | 107 | @dataclass 108 | class KeyStroke: 109 | """The data of a single interception key stroke. 110 | 111 | Reference: https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-rawkeyboard#members 112 | 113 | Attributes 114 | ---------- 115 | format :class:`bytes`: 116 | The format this struct is stored in, 4x `USHORT` and one `ULONG` (12 bytes total) 117 | 118 | code :class:`int`: 119 | Specifies the scan code, equivalent to referenced `MakeCode` of `RAWKEYBOARD` 120 | 121 | flags :class:`int`: 122 | Flags for scan code information, equivalent to referenced `Flags` of `RAWKEYBOARD` 123 | """ 124 | 125 | format: ClassVar[bytes] = b"HHHHI" 126 | 127 | code: int 128 | flags: int 129 | 130 | information: int = field(init=False, default=0) 131 | _unit_id: int = field(init=False, default=0, repr=False) 132 | _reserved: int = field(init=False, default=0, repr=False) 133 | 134 | @classmethod 135 | def parse(cls: Type[KeyStroke], data: bytes) -> KeyStroke: 136 | unpacked: tuple[int, ...] = struct.unpack(cls.format, data) 137 | 138 | # The order of the KeyStroke initializer doesnt match the values 139 | # arrangement in the bytes struct, so we need to 'pick' them in order. 140 | # This is because when we initialize it ourselves, we dont care about 141 | # 'information' or 'unit_id', they are receive-only fields. 142 | instance = cls(*unpacked[1:3]) 143 | instance._unit_id, instance.information = data[0], data[4] 144 | return instance 145 | 146 | @property 147 | def data(self) -> bytes: 148 | data = struct.pack( 149 | self.format, 150 | self._unit_id, 151 | self.code, 152 | self.flags, 153 | self._reserved, 154 | self.information, 155 | ) 156 | return data 157 | -------------------------------------------------------------------------------- /src/interception/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ctypes 4 | from ctypes.wintypes import HANDLE 5 | 6 | from dataclasses import dataclass, field 7 | from typing import Optional, Type 8 | from .strokes import KeyStroke, MouseStroke, Stroke 9 | from . import _ioctl 10 | 11 | 12 | @dataclass 13 | class DeviceIOResult: 14 | """Represents the result of a `DeviceIoControl` call. 15 | 16 | Parameters 17 | ---------- 18 | succeeded :class:`bool`: 19 | Whether the DeviceIoControl call completed successfully 20 | 21 | outbuffer :class:`Optional[Array]`: 22 | The outbuffer passed to the DeviceIoControl call, or `None` 23 | 24 | Attributes 25 | ---------- 26 | data_bytes :class:`Optional[bytes]`: 27 | The data as bytes if data is not `None` 28 | """ 29 | 30 | succeeded: bool 31 | outbuffer: Optional[ctypes.Array] 32 | data: Optional[bytes] = field(init=False, repr=False) 33 | 34 | def __post_init__(self): 35 | self.data = None if self.outbuffer is None else bytes(self.outbuffer) 36 | 37 | 38 | class Device: 39 | """Represents a windows IO mouse / keyboard device. 40 | 41 | Parameters 42 | ---------- 43 | handle :class:`HANDLE`: 44 | The handle to the I/O device obtained through creation with `CreateFileA` 45 | 46 | event :class:`HANDLE`: 47 | The handle to the event object responsible for synchronization 48 | 49 | is_keyboard :class:`bool`: 50 | Whether the device is a keyboard device (otherwise mouse) 51 | 52 | To communicate with `DeviceIoControl`, buffers for the respective operations 53 | are created - but meant for internal use only. 54 | 55 | Raises 56 | ------ 57 | `Exception`: 58 | If the device or event handle are invalid or the event could not synchronize. 59 | """ 60 | 61 | def __init__(self, handle: HANDLE, event: HANDLE, *, is_keyboard: bool): 62 | if handle == -1 or event == 0: 63 | raise Exception("Handle and event must be valid to create device!") 64 | 65 | self.is_keyboard = is_keyboard 66 | self._parser: Type[KeyStroke] | Type[MouseStroke] 67 | if is_keyboard: 68 | self._stroke_buffer = (ctypes.c_ubyte * 12)() 69 | self._parser = KeyStroke 70 | else: 71 | self._stroke_buffer = (ctypes.c_ubyte * 24)() 72 | self._parser = MouseStroke 73 | 74 | self.handle = handle 75 | self.event = event 76 | 77 | # preferring personal buffers over shared buffers for thread safety 78 | self._bytes_returned = (ctypes.c_uint32 * 1)(0) 79 | self._hwid_buffer = (ctypes.c_byte * 500)() 80 | self._event_buffer = (ctypes.c_void_p * 2)() 81 | self._filter_buffer = (ctypes.c_ushort * 1)() 82 | self._prdc_buffer = (ctypes.c_int * 1)() 83 | 84 | if not self._device_set_event().succeeded: 85 | raise Exception("Can't communicate with driver") 86 | 87 | def __str__(self) -> str: 88 | return f"Device(handle={self.handle}, event={self.event}, is_keyboard: {self.is_keyboard})" 89 | 90 | def __repr__(self) -> str: 91 | return f"Device(handle={self.handle}, event={self.event}, is_keyboard: {self.is_keyboard})" 92 | 93 | def __del__(self) -> None: 94 | self.destroy() 95 | 96 | def destroy(self): 97 | """Closes the handles to the device, must be called before destruction 98 | in order to prevent handle leakage. 99 | """ 100 | if getattr(self, "handle", -1) != -1: 101 | ctypes.windll.kernel32.CloseHandle(self.handle) 102 | self.handle = -1 103 | 104 | if getattr(self, "event", 0): 105 | ctypes.windll.kernel32.CloseHandle(self.event) 106 | self.handle = 0 107 | 108 | def receive(self) -> Optional[KeyStroke | MouseStroke]: 109 | """Receives the keystroke sent from this device. 110 | 111 | Must be the resulting device of a kernel32 `WaitForMultipleObjects` call to 112 | ensure that there is a valid input to be received. 113 | 114 | If no stroke could be received, `None` is returned instead. 115 | """ 116 | data = self._receive().data 117 | return None if data is None else self._parser.parse(data) 118 | 119 | def send(self, stroke: Stroke) -> DeviceIOResult: 120 | """Sends the given stroke from this device. 121 | 122 | The `Stroke` must be compatible with the device type parser, i.e a mouse device 123 | is unable to send a `KeyStroke`. 124 | 125 | Raises 126 | ------ 127 | `ValueError`: 128 | If the provided stroke is of an incompatible type for this device. 129 | """ 130 | if not isinstance(stroke, self._parser): 131 | raise ValueError( 132 | f"Unable to send {type(stroke).__name__} with '{self._parser.__name__}' parser!" 133 | ) 134 | return self._send(stroke) 135 | 136 | def get_precedence(self) -> DeviceIOResult: 137 | """Gets the device precedence""" 138 | return self._device_io_control( 139 | _ioctl.IOCTL_GET_PRECEDENCE, None, self._prdc_buffer 140 | ) 141 | 142 | def set_precedence(self, precedence: int) -> DeviceIOResult: 143 | """Sets the device precedence""" 144 | self._prdc_buffer[0] = precedence 145 | return self._device_io_control( 146 | _ioctl.IOCTL_SET_PRECEDENCE, self._prdc_buffer, None 147 | ) 148 | 149 | def get_filter(self) -> DeviceIOResult: 150 | """Retrieves the input filter for this device. 151 | 152 | TODO: Automatically parse it from the `DeviceIOResult` 153 | """ 154 | return self._device_io_control( 155 | _ioctl.IOCTL_GET_FILTER, None, self._filter_buffer 156 | ) 157 | 158 | def set_filter(self, filter: int) -> DeviceIOResult: 159 | """Sets the input filter for this device. 160 | 161 | The filter is a bitfield of Filter flags found in `_consts.py` 162 | """ 163 | self._filter_buffer[0] = filter 164 | return self._device_io_control( 165 | _ioctl.IOCTL_SET_FILTER, self._filter_buffer, None 166 | ) 167 | 168 | def get_HWID(self) -> Optional[str]: 169 | """Gets the Hardware ID of this device as a string. 170 | 171 | If the device is invalid, `None` is returned instead. 172 | """ 173 | data = self._get_HWID().data 174 | size: int = self._bytes_returned[0] 175 | return None if data is None or not size else data[:size].decode("utf-16") 176 | 177 | def _get_HWID(self) -> DeviceIOResult: 178 | """Makes a low-level call to `DeviceIoControl` to get the hardware ID""" 179 | return self._device_io_control( 180 | _ioctl.IOCTL_GET_HARDWARE_ID, None, self._hwid_buffer 181 | ) 182 | 183 | def _receive(self) -> DeviceIOResult: 184 | """Makes a low-level call to `DeviceIoControl` to read the device input""" 185 | return self._device_io_control(_ioctl.IOCTL_READ, None, self._stroke_buffer) 186 | 187 | def _send(self, stroke: Stroke) -> DeviceIOResult: 188 | """Makes a low-level call to `DeviceIoControl` to write to the device output.""" 189 | ctypes.memmove(self._stroke_buffer, stroke.data, len(self._stroke_buffer)) 190 | return self._device_io_control(_ioctl.IOCTL_WRITE, self._stroke_buffer, None) 191 | 192 | def _device_set_event(self) -> DeviceIOResult: 193 | """Makes a low-level call to `DeviceIoControl` to synchronize the event object""" 194 | self._event_buffer[0] = self.event 195 | return self._device_io_control(_ioctl.IOCTL_SET_EVENT, self._event_buffer, None) 196 | 197 | def _device_io_control( 198 | self, 199 | command: int, 200 | inbuffer: Optional[ctypes.Array] = None, 201 | outbuffer: Optional[ctypes.Array] = None, 202 | ) -> DeviceIOResult: 203 | """The heart of the device operations, makes a call to `DeviceIoControl` with 204 | the provided arguments. 205 | 206 | Parameters 207 | ---------- 208 | command :class:`int`: 209 | An IOCTL (I/O Control) command value that specifies the operation, see `_ioctl.py` 210 | 211 | inbuffer :class:`Optional[Array]`: 212 | A buffer containing the data to send to the operation, should it require input data. 213 | 214 | outbuffer :class:`Optional[Array]`: 215 | A buffer to hold the data of the operation, should it require an output buffer. 216 | 217 | See: https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol 218 | """ 219 | res = ctypes.windll.kernel32.DeviceIoControl( 220 | self.handle, 221 | command, 222 | inbuffer, 223 | len(bytes(inbuffer)) if inbuffer is not None else 0, 224 | outbuffer, 225 | len(bytes(outbuffer)) if outbuffer is not None else 0, 226 | self._bytes_returned, 227 | 0, 228 | ) 229 | return DeviceIOResult(res, outbuffer) 230 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | test 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015/2017 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # Visual Studio 2017 auto generated files 34 | Generated\ Files/ 35 | 36 | # MSTest test Results 37 | [Tt]est[Rr]esult*/ 38 | [Bb]uild[Ll]og.* 39 | 40 | # NUNIT 41 | *.VisualState.xml 42 | TestResult.xml 43 | 44 | # Build Results of an ATL Project 45 | [Dd]ebugPS/ 46 | [Rr]eleasePS/ 47 | dlldata.c 48 | 49 | # Benchmark Results 50 | BenchmarkDotNet.Artifacts/ 51 | 52 | # .NET Core 53 | project.lock.json 54 | project.fragment.lock.json 55 | artifacts/ 56 | **/Properties/launchSettings.json 57 | 58 | # StyleCop 59 | StyleCopReport.xml 60 | 61 | # Files built by Visual Studio 62 | *_i.c 63 | *_p.c 64 | *_i.h 65 | *.ilk 66 | *.meta 67 | *.obj 68 | *.iobj 69 | *.pch 70 | *.pdb 71 | *.ipdb 72 | *.pgc 73 | *.pgd 74 | *.rsp 75 | *.sbr 76 | *.tlb 77 | *.tli 78 | *.tlh 79 | *.tmp 80 | *.tmp_proj 81 | *.log 82 | *.vspscc 83 | *.vssscc 84 | .builds 85 | *.pidb 86 | *.svclog 87 | *.scc 88 | 89 | # Chutzpah Test files 90 | _Chutzpah* 91 | 92 | # Visual C++ cache files 93 | ipch/ 94 | *.aps 95 | *.ncb 96 | *.opendb 97 | *.opensdf 98 | *.sdf 99 | *.cachefile 100 | *.VC.db 101 | *.VC.VC.opendb 102 | 103 | # Visual Studio profiler 104 | *.psess 105 | *.vsp 106 | *.vspx 107 | *.sap 108 | 109 | # Visual Studio Trace Files 110 | *.e2e 111 | 112 | # TFS 2012 Local Workspace 113 | $tf/ 114 | 115 | # Guidance Automation Toolkit 116 | *.gpState 117 | 118 | # ReSharper is a .NET coding add-in 119 | _ReSharper*/ 120 | *.[Rr]e[Ss]harper 121 | *.DotSettings.user 122 | 123 | # JustCode is a .NET coding add-in 124 | .JustCode 125 | 126 | # TeamCity is a build add-in 127 | _TeamCity* 128 | 129 | # DotCover is a Code Coverage Tool 130 | *.dotCover 131 | 132 | # AxoCover is a Code Coverage Tool 133 | .axoCover/* 134 | !.axoCover/settings.json 135 | 136 | # Visual Studio code coverage results 137 | *.coverage 138 | *.coveragexml 139 | 140 | # NCrunch 141 | _NCrunch_* 142 | .*crunch*.local.xml 143 | nCrunchTemp_* 144 | 145 | # MightyMoose 146 | *.mm.* 147 | AutoTest.Net/ 148 | 149 | # Web workbench (sass) 150 | .sass-cache/ 151 | 152 | # Installshield output folder 153 | [Ee]xpress/ 154 | 155 | # DocProject is a documentation generator add-in 156 | DocProject/buildhelp/ 157 | DocProject/Help/*.HxT 158 | DocProject/Help/*.HxC 159 | DocProject/Help/*.hhc 160 | DocProject/Help/*.hhk 161 | DocProject/Help/*.hhp 162 | DocProject/Help/Html2 163 | DocProject/Help/html 164 | 165 | # Click-Once directory 166 | publish/ 167 | 168 | # Publish Web Output 169 | *.[Pp]ublish.xml 170 | *.azurePubxml 171 | # Note: Comment the next line if you want to checkin your web deploy settings, 172 | # but database connection strings (with potential passwords) will be unencrypted 173 | *.pubxml 174 | *.publishproj 175 | 176 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 177 | # checkin your Azure Web App publish settings, but sensitive information contained 178 | # in these scripts will be unencrypted 179 | PublishScripts/ 180 | 181 | # NuGet Packages 182 | *.nupkg 183 | # The packages folder can be ignored because of Package Restore 184 | **/[Pp]ackages/* 185 | # except build/, which is used as an MSBuild target. 186 | !**/[Pp]ackages/build/ 187 | # Uncomment if necessary however generally it will be regenerated when needed 188 | #!**/[Pp]ackages/repositories.config 189 | # NuGet v3's project.json files produces more ignorable files 190 | *.nuget.props 191 | *.nuget.targets 192 | 193 | # Microsoft Azure Build Output 194 | csx/ 195 | *.build.csdef 196 | 197 | # Microsoft Azure Emulator 198 | ecf/ 199 | rcf/ 200 | 201 | # Windows Store app package directories and files 202 | AppPackages/ 203 | BundleArtifacts/ 204 | Package.StoreAssociation.xml 205 | _pkginfo.txt 206 | *.appx 207 | 208 | # Visual Studio cache files 209 | # files ending in .cache can be ignored 210 | *.[Cc]ache 211 | # but keep track of directories ending in .cache 212 | !*.[Cc]ache/ 213 | 214 | # Others 215 | ClientBin/ 216 | ~$* 217 | *~ 218 | *.dbmdl 219 | *.dbproj.schemaview 220 | *.jfm 221 | *.pfx 222 | *.publishsettings 223 | orleans.codegen.cs 224 | 225 | # Including strong name files can present a security risk 226 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 227 | #*.snk 228 | 229 | # Since there are multiple workflows, uncomment next line to ignore bower_components 230 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 231 | #bower_components/ 232 | 233 | # RIA/Silverlight projects 234 | Generated_Code/ 235 | 236 | # Backup & report files from converting an old project file 237 | # to a newer Visual Studio version. Backup files are not needed, 238 | # because we have git ;-) 239 | _UpgradeReport_Files/ 240 | Backup*/ 241 | UpgradeLog*.XML 242 | UpgradeLog*.htm 243 | ServiceFabricBackup/ 244 | *.rptproj.bak 245 | 246 | # SQL Server files 247 | *.mdf 248 | *.ldf 249 | *.ndf 250 | 251 | # Business Intelligence projects 252 | *.rdl.data 253 | *.bim.layout 254 | *.bim_*.settings 255 | *.rptproj.rsuser 256 | 257 | # Microsoft Fakes 258 | FakesAssemblies/ 259 | 260 | # GhostDoc plugin setting file 261 | *.GhostDoc.xml 262 | 263 | # Node.js Tools for Visual Studio 264 | .ntvs_analysis.dat 265 | node_modules/ 266 | 267 | # Visual Studio 6 build log 268 | *.plg 269 | 270 | # Visual Studio 6 workspace options file 271 | *.opt 272 | 273 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 274 | *.vbw 275 | 276 | # Visual Studio LightSwitch build output 277 | **/*.HTMLClient/GeneratedArtifacts 278 | **/*.DesktopClient/GeneratedArtifacts 279 | **/*.DesktopClient/ModelManifest.xml 280 | **/*.Server/GeneratedArtifacts 281 | **/*.Server/ModelManifest.xml 282 | _Pvt_Extensions 283 | 284 | # Paket dependency manager 285 | .paket/paket.exe 286 | paket-files/ 287 | 288 | # FAKE - F# Make 289 | .fake/ 290 | 291 | # JetBrains Rider 292 | .idea/ 293 | *.sln.iml 294 | 295 | # CodeRush 296 | .cr/ 297 | 298 | # Python Tools for Visual Studio (PTVS) 299 | __pycache__/ 300 | *.pyc 301 | 302 | # Cake - Uncomment if you are using it 303 | # tools/** 304 | # !tools/packages.config 305 | 306 | # Tabs Studio 307 | *.tss 308 | 309 | # Telerik's JustMock configuration file 310 | *.jmconfig 311 | 312 | # BizTalk build output 313 | *.btp.cs 314 | *.btm.cs 315 | *.odx.cs 316 | *.xsd.cs 317 | 318 | # OpenCover UI analysis results 319 | OpenCover/ 320 | 321 | # Azure Stream Analytics local run output 322 | ASALocalRun/ 323 | 324 | # MSBuild Binary and Structured Log 325 | *.binlog 326 | 327 | # NVidia Nsight GPU debugger configuration file 328 | *.nvuser 329 | 330 | # MFractors (Xamarin productivity tool) working folder 331 | .mfractor/ 332 | # Byte-compiled / optimized / DLL files 333 | __pycache__/ 334 | *.py[cod] 335 | *$py.class 336 | 337 | # C extensions 338 | *.so 339 | 340 | # Distribution / packaging 341 | .Python 342 | build/ 343 | develop-eggs/ 344 | dist/ 345 | downloads/ 346 | eggs/ 347 | .eggs/ 348 | lib/ 349 | lib64/ 350 | parts/ 351 | sdist/ 352 | var/ 353 | pip-wheel-metadata/ 354 | share/python-wheels/ 355 | *.egg-info/ 356 | .installed.cfg 357 | *.egg 358 | MANIFEST 359 | 360 | # PyInstaller 361 | # Usually these files are written by a python script from a template 362 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 363 | *.manifest 364 | *.spec 365 | 366 | # Installer logs 367 | pip-log.txt 368 | pip-delete-this-directory.txt 369 | 370 | # Unit test / coverage reports 371 | htmlcov/ 372 | .tox/ 373 | .nox/ 374 | .coverage 375 | .coverage.* 376 | .cache 377 | nosetests.xml 378 | coverage.xml 379 | *.cover 380 | *.py,cover 381 | .hypothesis/ 382 | .pytest_cache/ 383 | 384 | # Translations 385 | *.mo 386 | *.pot 387 | 388 | # Django stuff: 389 | *.log 390 | local_settings.py 391 | db.sqlite3 392 | db.sqlite3-journal 393 | 394 | # Flask stuff: 395 | instance/ 396 | .webassets-cache 397 | 398 | # Scrapy stuff: 399 | .scrapy 400 | 401 | # Sphinx documentation 402 | docs/_build/ 403 | 404 | # PyBuilder 405 | target/ 406 | 407 | # Jupyter Notebook 408 | .ipynb_checkpoints 409 | 410 | # IPython 411 | profile_default/ 412 | ipython_config.py 413 | 414 | # pyenv 415 | .python-version 416 | 417 | # pipenv 418 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 419 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 420 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 421 | # install all needed dependencies. 422 | #Pipfile.lock 423 | 424 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 425 | __pypackages__/ 426 | 427 | # Celery stuff 428 | celerybeat-schedule 429 | celerybeat.pid 430 | 431 | # SageMath parsed files 432 | *.sage.py 433 | 434 | # Environments 435 | .env 436 | .venv 437 | env/ 438 | venv/ 439 | ENV/ 440 | env.bak/ 441 | venv.bak/ 442 | 443 | # Spyder project settings 444 | .spyderproject 445 | .spyproject 446 | 447 | # Rope project settings 448 | .ropeproject 449 | 450 | # mkdocs documentation 451 | /site 452 | 453 | # mypy 454 | .mypy_cache/ 455 | .dmypy.json 456 | dmypy.json 457 | 458 | # Pyre type checker 459 | .pyre/ 460 | -------------------------------------------------------------------------------- /src/interception/_keycodes.py: -------------------------------------------------------------------------------- 1 | from ctypes import windll, wintypes 2 | from dataclasses import dataclass 3 | import functools 4 | 5 | from .exceptions import UnknownKeyError 6 | 7 | 8 | @dataclass 9 | class KeyData: 10 | vk_code: int = -1 11 | scan_code: int = -1 12 | 13 | shift: bool = False 14 | ctrl: bool = False 15 | alt: bool = False 16 | is_extended: bool = False 17 | 18 | 19 | _KEYBOARD_KEYS: list[str] = [ 20 | "\t", 21 | "\n", 22 | "\r", 23 | " ", 24 | "!", 25 | '"', 26 | "#", 27 | "$", 28 | "%", 29 | "&", 30 | "'", 31 | "(", 32 | ")", 33 | "*", 34 | "+", 35 | ",", 36 | "-", 37 | ".", 38 | "/", 39 | "0", 40 | "1", 41 | "2", 42 | "3", 43 | "4", 44 | "5", 45 | "6", 46 | "7", 47 | "8", 48 | "9", 49 | ":", 50 | ";", 51 | "<", 52 | "=", 53 | ">", 54 | "?", 55 | "@", 56 | "[", 57 | "\\", 58 | "]", 59 | "^", 60 | "_", 61 | "`", 62 | "a", 63 | "b", 64 | "c", 65 | "d", 66 | "e", 67 | "f", 68 | "g", 69 | "h", 70 | "i", 71 | "j", 72 | "k", 73 | "l", 74 | "m", 75 | "n", 76 | "o", 77 | "p", 78 | "q", 79 | "r", 80 | "s", 81 | "t", 82 | "u", 83 | "v", 84 | "w", 85 | "x", 86 | "y", 87 | "z", 88 | "{", 89 | "|", 90 | "}", 91 | "~", 92 | "accept", 93 | "add", 94 | "alt", 95 | "altleft", 96 | "altright", 97 | "apps", 98 | "backspace", 99 | "browserback", 100 | "browserfavorites", 101 | "browserforward", 102 | "browserhome", 103 | "browserrefresh", 104 | "browsersearch", 105 | "browserstop", 106 | "capslock", 107 | "clear", 108 | "convert", 109 | "ctrl", 110 | "ctrlleft", 111 | "ctrlright", 112 | "decimal", 113 | "del", 114 | "delete", 115 | "divide", 116 | "down", 117 | "end", 118 | "enter", 119 | "esc", 120 | "escape", 121 | "execute", 122 | "f1", 123 | "f10", 124 | "f11", 125 | "f12", 126 | "f13", 127 | "f14", 128 | "f15", 129 | "f16", 130 | "f17", 131 | "f18", 132 | "f19", 133 | "f2", 134 | "f20", 135 | "f21", 136 | "f22", 137 | "f23", 138 | "f24", 139 | "f3", 140 | "f4", 141 | "f5", 142 | "f6", 143 | "f7", 144 | "f8", 145 | "f9", 146 | "final", 147 | "fn", 148 | "hanguel", 149 | "hangul", 150 | "hanja", 151 | "help", 152 | "home", 153 | "insert", 154 | "junja", 155 | "kana", 156 | "kanji", 157 | "launchapp1", 158 | "launchapp2", 159 | "launchmail", 160 | "launchmediaselect", 161 | "left", 162 | "modechange", 163 | "multiply", 164 | "nexttrack", 165 | "nonconvert", 166 | "num0", 167 | "num1", 168 | "num2", 169 | "num3", 170 | "num4", 171 | "num5", 172 | "num6", 173 | "num7", 174 | "num8", 175 | "num9", 176 | "numlock", 177 | "pagedown", 178 | "pageup", 179 | "pause", 180 | "pgdn", 181 | "pgup", 182 | "playpause", 183 | "prevtrack", 184 | "printscreen", 185 | "prntscrn", 186 | "prtsc", 187 | "prtscr", 188 | "return", 189 | "right", 190 | "scrolllock", 191 | "select", 192 | "separator", 193 | "shift", 194 | "shiftleft", 195 | "shiftright", 196 | "sleep", 197 | "space", 198 | "stop", 199 | "subtract", 200 | "tab", 201 | "up", 202 | "volumedown", 203 | "volumemute", 204 | "volumeup", 205 | "win", 206 | "winleft", 207 | "winright", 208 | "yen", 209 | "command", 210 | "option", 211 | "optionleft", 212 | "optionright", 213 | ] 214 | 215 | _MAPPING: dict[str, int] = {key: -1 for key in _KEYBOARD_KEYS} 216 | _MAPPING.update( 217 | { 218 | "backspace": 0x08, # VK_BACK 219 | "\b": 0x08, # VK_BACK 220 | "super": 0x5B, # VK_LWIN 221 | "tab": 0x09, # VK_TAB 222 | "\t": 0x09, # VK_TAB 223 | "clear": 0x0C, # VK_CLEAR 224 | "enter": 0x0D, # VK_RETURN 225 | "\n": 0x0D, # VK_RETURN 226 | "return": 0x0D, # VK_RETURN 227 | "shift": 0x10, # VK_SHIFT 228 | "ctrl": 0x11, # VK_CONTROL 229 | "alt": 0x12, # VK_MENU 230 | "pause": 0x13, # VK_PAUSE 231 | "capslock": 0x14, # VK_CAPITAL 232 | "kana": 0x15, # VK_KANA 233 | "hanguel": 0x15, # VK_HANGUEL 234 | "hangul": 0x15, # VK_HANGUL 235 | "junja": 0x17, # VK_JUNJA 236 | "final": 0x18, # VK_FINAL 237 | "hanja": 0x19, # VK_HANJA 238 | "kanji": 0x19, # VK_KANJI 239 | "esc": 0x1B, # VK_ESCAPE 240 | "escape": 0x1B, # VK_ESCAPE 241 | "convert": 0x1C, # VK_CONVERT 242 | "nonconvert": 0x1D, # VK_NONCONVERT 243 | "accept": 0x1E, # VK_ACCEPT 244 | "modechange": 0x1F, # VK_MODECHANGE 245 | " ": 0x20, # VK_SPACE 246 | "space": 0x20, # VK_SPACE 247 | "pgup": 0x21, # VK_PRIOR 248 | "pgdn": 0x22, # VK_NEXT 249 | "pageup": 0x21, # VK_PRIOR 250 | "pagedown": 0x22, # VK_NEXT 251 | "end": 0x23, # VK_END 252 | "home": 0x24, # VK_HOME 253 | "left": 0x25, # VK_LEFT 254 | "up": 0x26, # VK_UP 255 | "right": 0x27, # VK_RIGHT 256 | "down": 0x28, # VK_DOWN 257 | "select": 0x29, # VK_SELECT 258 | "print": 0x2A, # VK_PRINT 259 | "execute": 0x2B, # VK_EXECUTE 260 | "prtsc": 0x2C, # VK_SNAPSHOT 261 | "prtscr": 0x2C, # VK_SNAPSHOT 262 | "prntscrn": 0x2C, # VK_SNAPSHOT 263 | "printscreen": 0x2C, # VK_SNAPSHOT 264 | "insert": 0x2D, # VK_INSERT 265 | "del": 0x2E, # VK_DELETE 266 | "delete": 0x2E, # VK_DELETE 267 | "help": 0x2F, # VK_HELP 268 | "win": 0x5B, # VK_LWIN 269 | "winleft": 0x5B, # VK_LWIN 270 | "winright": 0x5C, # VK_RWIN 271 | "apps": 0x5D, # VK_APPS 272 | "sleep": 0x5F, # VK_SLEEP 273 | "num0": 0x60, # VK_NUMPAD0 274 | "num1": 0x61, # VK_NUMPAD1 275 | "num2": 0x62, # VK_NUMPAD2 276 | "num3": 0x63, # VK_NUMPAD3 277 | "num4": 0x64, # VK_NUMPAD4 278 | "num5": 0x65, # VK_NUMPAD5 279 | "num6": 0x66, # VK_NUMPAD6 280 | "num7": 0x67, # VK_NUMPAD7 281 | "num8": 0x68, # VK_NUMPAD8 282 | "num9": 0x69, # VK_NUMPAD9 283 | "multiply": 0x6A, # VK_MULTIPLY ??? Is this the numpad *? 284 | "add": 0x6B, # VK_ADD ??? Is this the numpad +? 285 | "separator": 0x6C, # VK_SEPARATOR ??? Is this the numpad enter? 286 | "subtract": 0x6D, # VK_SUBTRACT ??? Is this the numpad -? 287 | "decimal": 0x6E, # VK_DECIMAL 288 | "divide": 0x6F, # VK_DIVIDE 289 | "f1": 0x70, # VK_F1 290 | "f2": 0x71, # VK_F2 291 | "f3": 0x72, # VK_F3 292 | "f4": 0x73, # VK_F4 293 | "f5": 0x74, # VK_F5 294 | "f6": 0x75, # VK_F6 295 | "f7": 0x76, # VK_F7 296 | "f8": 0x77, # VK_F8 297 | "f9": 0x78, # VK_F9 298 | "f10": 0x79, # VK_F10 299 | "f11": 0x7A, # VK_F11 300 | "f12": 0x7B, # VK_F12 301 | "f13": 0x7C, # VK_F13 302 | "f14": 0x7D, # VK_F14 303 | "f15": 0x7E, # VK_F15 304 | "f16": 0x7F, # VK_F16 305 | "f17": 0x80, # VK_F17 306 | "f18": 0x81, # VK_F18 307 | "f19": 0x82, # VK_F19 308 | "f20": 0x83, # VK_F20 309 | "f21": 0x84, # VK_F21 310 | "f22": 0x85, # VK_F22 311 | "f23": 0x86, # VK_F23 312 | "f24": 0x87, # VK_F24 313 | "numlock": 0x90, # VK_NUMLOCK 314 | "scrolllock": 0x91, # VK_SCROLL 315 | "shiftleft": 0xA0, # VK_LSHIFT 316 | "shiftright": 0xA1, # VK_RSHIFT 317 | "ctrlleft": 0xA2, # VK_LCONTROL 318 | "ctrlright": 0xA3, # VK_RCONTROL 319 | "altleft": 0xA4, # VK_LMENU 320 | "altright": 0xA5, # VK_RMENU 321 | "browserback": 0xA6, # VK_BROWSER_BACK 322 | "browserforward": 0xA7, # VK_BROWSER_FORWARD 323 | "browserrefresh": 0xA8, # VK_BROWSER_REFRESH 324 | "browserstop": 0xA9, # VK_BROWSER_STOP 325 | "browsersearch": 0xAA, # VK_BROWSER_SEARCH 326 | "browserfavorites": 0xAB, # VK_BROWSER_FAVORITES 327 | "browserhome": 0xAC, # VK_BROWSER_HOME 328 | "volumemute": 0xAD, # VK_VOLUME_MUTE 329 | "volumedown": 0xAE, # VK_VOLUME_DOWN 330 | "volumeup": 0xAF, # VK_VOLUME_UP 331 | "nexttrack": 0xB0, # VK_MEDIA_NEXT_TRACK 332 | "prevtrack": 0xB1, # VK_MEDIA_PREV_TRACK 333 | "stop": 0xB2, # VK_MEDIA_STOP 334 | "playpause": 0xB3, # VK_MEDIA_PLAY_PAUSE 335 | "launchmail": 0xB4, # VK_LAUNCH_MAIL 336 | "launchmediaselect": 0xB5, # VK_LAUNCH_MEDIA_SELECT 337 | "launchapp1": 0xB6, # VK_LAUNCH_APP1 338 | "launchapp2": 0xB7, # VK_LAUNCH_APP2 339 | } 340 | ) 341 | 342 | # Certain keys that are considered extended yet to not use the E0 indication prefix 343 | # See https://github.com/kennyhml/pyinterception/pull/36#issue-2600389258 344 | extended_keys = { 345 | 0xA5, # VK_RMENU 346 | 0x2E, # VK_DELETE 347 | 0x2D, # VK_INSERT 348 | 0x22, # VK_NEXT 349 | 0x21, # VK_PRIOR 350 | 0x24, # VK_HOME 351 | 0x23, # VK_END 352 | 0x25, # VK_LEFT 353 | 0x27, # VK_RIGHT 354 | 0x26, # VK_UP 355 | 0x28, # VK_DOWN 356 | } 357 | 358 | SHIFT_FLAG = 1 359 | ALT_FLAG = 2 360 | CTRL_FLAG = 4 361 | 362 | MAPVK_VK_TO_VSC_EX = 4 363 | 364 | # ascii characters from 32 (space) to 126 (~), see https://www.asciitable.com/ 365 | # these are constants, but its easier to just map them like this than maintaining 366 | # an even bigger table. 367 | for c in range(32, 127): 368 | _MAPPING[chr(c)] = windll.user32.VkKeyScanA(wintypes.WCHAR(chr(c))) 369 | 370 | 371 | @functools.cache 372 | def get_key_information(key: str) -> KeyData: 373 | if key not in _MAPPING: 374 | raise UnknownKeyError(key) 375 | 376 | res = KeyData() 377 | 378 | # Split the modifiers (high byte) from the virtual code (low byte) 379 | # That way we can check whether we have to include shift / ctrl / alt for this key 380 | vk = _MAPPING[key] 381 | modifiers, res.vk_code = divmod(vk, 0x100) 382 | 383 | res.shift |= bool(modifiers & SHIFT_FLAG) 384 | res.ctrl |= bool(modifiers & CTRL_FLAG) 385 | res.alt |= bool(modifiers & ALT_FLAG) 386 | 387 | # Use MAPVK_VK_TO_VSC_EX, see https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mapvirtualkeya 388 | # If it is a virtual-key code that does not distinguish between left- and right-hand keys, 389 | # the left-hand scan code is returned. If the scan code is an extended scan code, the high 390 | # byte of the returned value will contain either 0xe0 or 0xe1 to specify the extended scan 391 | # code. If there is no translation, the function returns 0. 392 | scan_code = windll.user32.MapVirtualKeyA(res.vk_code, MAPVK_VK_TO_VSC_EX) 393 | res.scan_code = scan_code & 0xFF 394 | res.is_extended = ( 395 | bool(((scan_code >> 8) & 0xFF) & 0xE0) or res.vk_code in extended_keys 396 | ) 397 | 398 | return res 399 | -------------------------------------------------------------------------------- /src/interception/inputs.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import time 3 | import random 4 | from contextlib import contextmanager 5 | from typing import Literal, Optional, TypeAlias 6 | 7 | from . import _keycodes, _utils, beziercurve, exceptions 8 | from .constants import ( 9 | FilterKeyFlag, 10 | FilterMouseButtonFlag, 11 | KeyFlag, 12 | MouseButtonFlag, 13 | MouseFlag, 14 | MouseRolling, 15 | ) 16 | from .interception import Interception 17 | from .strokes import KeyStroke, MouseStroke 18 | 19 | _g_context = Interception() 20 | MouseButton: TypeAlias = Literal["left", "right", "middle", "mouse4", "mouse5"] 21 | 22 | MOUSE_BUTTON_DELAY = 0.03 23 | KEY_PRESS_DELAY = 0.025 24 | 25 | 26 | def requires_driver(func): 27 | """Wraps any function that requires the interception driver to be installed 28 | such that, if it is not installed, a `DriverNotFoundError` is raised""" 29 | 30 | @functools.wraps(func) 31 | def wrapper(*args, **kwargs): 32 | if not _g_context.valid: 33 | raise exceptions.DriverNotFoundError 34 | return func(*args, **kwargs) 35 | 36 | return wrapper 37 | 38 | 39 | @requires_driver 40 | def move_to( 41 | x: int | tuple[int, int], 42 | y: Optional[int] = None, 43 | curve_params: Optional[beziercurve.BezierCurveParams] = None, 44 | *, 45 | allow_global_params: bool = True, 46 | ) -> None: 47 | """Moves to a given absolute (x, y) location on the screen. 48 | 49 | Parameters 50 | ---------- 51 | curve_params :class:`Optional[HumanCurve]`: 52 | An optional container to define the curve parameters, pyclick is required. 53 | 54 | allow_global_params :class:`bool`: 55 | Whether the global curve params should be used when set. True by default. 56 | 57 | The coordinates can be passed as a tuple-like `(x, y)` coordinate or 58 | seperately as `x` and `y` coordinates, it will be parsed accordingly. 59 | 60 | ### Examples: 61 | ```py 62 | # passing x and y seperately, typical when manually calling the function 63 | interception.move_to(800, 1200) 64 | 65 | # passing a tuple-like coordinate, typical for dynamic operations. 66 | # simply avoids having to unpack the arguments. 67 | target_location = (1200, 300) 68 | interception.move_to(target_location) 69 | ``` 70 | """ 71 | 72 | if curve_params is None: 73 | if allow_global_params and (params := beziercurve.get_default_params()): 74 | return move_to(x, y, params) 75 | 76 | x, y = _utils.to_interception_coordinate(*_utils.normalize(x, y)) 77 | stroke = MouseStroke(MouseFlag.MOUSE_MOVE_ABSOLUTE, 0, 0, x, y) 78 | _g_context.send(_g_context.mouse, stroke) 79 | return 80 | 81 | curve = beziercurve.HumanCurve(mouse_position(), _utils.normalize(x, y)) 82 | 83 | # Track where we currently are since we need to round the points generated 84 | # which will, especially on longer curves, offset us if we dont adjust. 85 | curr_x, curr_y = curve.points[0] 86 | 87 | # Mouse acceleration must be disabled to preserve precision on relative movements 88 | with _utils.disable_mouse_acceleration(): 89 | for point in curve.points: 90 | rel_x: int = round(point[0] - curr_x) 91 | rel_y: int = round(point[1] - curr_y) 92 | curr_x, curr_y = curr_x + rel_x, curr_y + rel_y 93 | 94 | stroke = MouseStroke(MouseFlag.MOUSE_MOVE_RELATIVE, 0, 0, rel_x, rel_y) 95 | _g_context.send(_g_context.mouse, stroke) 96 | 97 | if random.uniform(0, 1) > 0.75: 98 | time.sleep(random.uniform(0.005, 0.010)) 99 | 100 | 101 | @requires_driver 102 | def move_relative(x: int = 0, y: int = 0) -> None: 103 | """Moves relatively from the current cursor position by the given amounts. 104 | 105 | Due to conversion to the coordinate system the interception driver 106 | uses, an offset of 1 pixel in either x or y axis may occur or not. 107 | 108 | ### Example: 109 | ```py 110 | interception.mouse_position() 111 | >>> 300, 400 112 | 113 | # move the mouse by 100 pixels on the x-axis and 0 in y-axis 114 | interception.move_relative(100, 0) 115 | interception.mouse_position() 116 | >>> 400, 400 117 | """ 118 | with _utils.disable_mouse_acceleration(): 119 | stroke = MouseStroke(MouseFlag.MOUSE_MOVE_RELATIVE, 0, 0, x, y) 120 | _g_context.send(_g_context.mouse, stroke) 121 | 122 | 123 | def mouse_position() -> tuple[int, int]: 124 | """Returns the current position of the cursor as `(x, y)` coordinate. 125 | 126 | This does nothing special like other conventional mouse position functions. 127 | """ 128 | return _utils.get_cursor_pos() 129 | 130 | 131 | @requires_driver 132 | def click( 133 | x: Optional[int | tuple[int, int]] = None, 134 | y: Optional[int] = None, 135 | button: MouseButton = "left", 136 | clicks: int = 1, 137 | interval: int | float = 0.1, 138 | delay: int | float = 0.3, 139 | ) -> None: 140 | """Presses a mouse button at a specific location (if given). 141 | 142 | Parameters 143 | ---------- 144 | button :class:`Literal["left", "right", "middle", "mouse4", "mouse5"] | str`: 145 | The button to click once moved to the location (if passed), default "left". 146 | 147 | clicks :class:`int`: 148 | The amount of mouse clicks to perform with the given button, default 1. 149 | 150 | interval :class:`int | float`: 151 | The interval between multiple clicks, only applies if clicks > 1, default 0.1. 152 | 153 | delay :class:`int | float`: 154 | The delay between moving and clicking, default 0.3. 155 | """ 156 | if x is not None: 157 | move_to(x, y) 158 | time.sleep(delay) 159 | 160 | for _ in range(clicks): 161 | mouse_down(button) 162 | mouse_up(button) 163 | 164 | if clicks > 1: 165 | time.sleep(interval) 166 | 167 | 168 | # decided against using functools.partial for left_click and right_click 169 | # because it makes it less clear that the method attribute is a function 170 | # and might be misunderstood. It also still allows changing the button 171 | # argument afterall - just adds the correct default. 172 | @requires_driver 173 | def left_click(clicks: int = 1, interval: int | float = 0.1) -> None: 174 | """Thin wrapper for the `click` function with the left mouse button.""" 175 | click(button="left", clicks=clicks, interval=interval) 176 | 177 | 178 | @requires_driver 179 | def right_click(clicks: int = 1, interval: int | float = 0.1) -> None: 180 | """Thin wrapper for the `click` function with the right mouse button.""" 181 | click(button="right", clicks=clicks, interval=interval) 182 | 183 | 184 | @requires_driver 185 | def press(key: str, presses: int = 1, interval: int | float = 0.1) -> None: 186 | """Presses a given key, for mouse buttons use the`click` function. 187 | 188 | Parameters 189 | ---------- 190 | key :class:`str`: 191 | The key to press, not case sensitive. 192 | 193 | presses :class:`int`: 194 | The amount of presses to perform with the given key, default 1. 195 | 196 | interval :class:`int | float`: 197 | The interval between multiple presses, only applies if presses > 1, defaul 0.1. 198 | """ 199 | for _ in range(presses): 200 | key_down(key) 201 | key_up(key) 202 | if presses > 1: 203 | time.sleep(interval) 204 | 205 | 206 | @requires_driver 207 | def write(term: str, interval: int | float = 0.05) -> None: 208 | """Writes a term by sending each key one after another. 209 | 210 | Uppercase characters are not currently supported, the term will 211 | come out as lowercase. 212 | 213 | Parameters 214 | ---------- 215 | term :class:`str`: 216 | The term to write. 217 | 218 | interval :class:`int | float`: 219 | The interval between the different characters, default 0.05. 220 | """ 221 | for c in term: 222 | press(c) 223 | time.sleep(interval) 224 | 225 | 226 | @requires_driver 227 | def scroll(direction: Literal["up", "down"]) -> None: 228 | """Scrolls the mouse wheel one unit in a given direction.""" 229 | if direction == "up": 230 | button_data = MouseRolling.MOUSE_WHEEL_UP 231 | else: 232 | button_data = MouseRolling.MOUSE_WHEEL_DOWN 233 | 234 | stroke = MouseStroke( 235 | MouseFlag.MOUSE_MOVE_RELATIVE, MouseButtonFlag.MOUSE_WHEEL, button_data, 0, 0 236 | ) 237 | _g_context.send(_g_context.mouse, stroke) 238 | time.sleep(0.025) 239 | 240 | 241 | def _send_with_mods(stroke: KeyStroke, **kwarg_mods) -> None: 242 | mods: list[str] = [key for key, v in kwarg_mods.items() if v] 243 | 244 | for mod in mods: 245 | key_down(mod, 0) 246 | 247 | _g_context.send(_g_context.keyboard, stroke) 248 | 249 | for mod in mods: 250 | key_up(mod, 0) 251 | 252 | 253 | @requires_driver 254 | def key_down(key: str, delay: Optional[float | int] = None) -> None: 255 | """Updates the state of the given key to be `down`. 256 | 257 | To release the key automatically, consider using the `hold_key` contextmanager. 258 | 259 | ### Parameters: 260 | ---------- 261 | key :class: `str`: 262 | The key to hold down. 263 | 264 | delay :class: `Optional[float | int]`: 265 | The amount of time to wait after updating the key state. 266 | 267 | ### Raises: 268 | `UnknownKeyError` if the given key is not supported. 269 | """ 270 | data = _keycodes.get_key_information(key) 271 | 272 | stroke = KeyStroke(data.scan_code, KeyFlag.KEY_DOWN) 273 | if data.is_extended: 274 | stroke.flags |= KeyFlag.KEY_E0 275 | 276 | _send_with_mods(stroke, ctrl=data.ctrl, alt=data.alt, shift=data.shift) 277 | time.sleep(delay if delay is not None else KEY_PRESS_DELAY) 278 | 279 | 280 | @requires_driver 281 | def key_up(key: str, delay: Optional[float | int] = None) -> None: 282 | """Updates the state of the given key to be `up`. 283 | 284 | ### Parameters: 285 | ---------- 286 | key :class: `str`: 287 | The key to release. 288 | 289 | delay :class: `Optional[float | int]`: 290 | The amount of time to wait after updating the key state. 291 | 292 | ### Raises: 293 | `UnknownKeyError` if the given key is not supported. 294 | """ 295 | data = _keycodes.get_key_information(key) 296 | 297 | stroke = KeyStroke(data.scan_code, KeyFlag.KEY_UP) 298 | if data.is_extended: 299 | stroke.flags |= KeyFlag.KEY_E0 300 | 301 | _send_with_mods(stroke, ctrl=data.ctrl, alt=data.alt, shift=data.shift) 302 | time.sleep(delay if delay is not None else KEY_PRESS_DELAY) 303 | 304 | 305 | @requires_driver 306 | def mouse_down(button: MouseButton, delay: Optional[float] = None) -> None: 307 | """Holds a mouse button down, will not be released automatically. 308 | 309 | If you want to hold a mouse button while performing an action, please use 310 | `hold_mouse`, which offers a context manager. 311 | """ 312 | button_state = _get_button_states(button, down=True) 313 | stroke = MouseStroke(MouseFlag.MOUSE_MOVE_ABSOLUTE, button_state, 0, 0, 0) 314 | _g_context.send(_g_context.mouse, stroke) 315 | time.sleep(delay or MOUSE_BUTTON_DELAY) 316 | 317 | 318 | @requires_driver 319 | def mouse_up(button: MouseButton, delay: Optional[float] = None) -> None: 320 | """Releases a mouse button.""" 321 | button_state = _get_button_states(button, down=False) 322 | stroke = MouseStroke(MouseFlag.MOUSE_MOVE_ABSOLUTE, button_state, 0, 0, 0) 323 | _g_context.send(_g_context.mouse, stroke) 324 | time.sleep(delay or MOUSE_BUTTON_DELAY) 325 | 326 | 327 | @requires_driver 328 | @contextmanager 329 | def hold_mouse(button: MouseButton): 330 | """Holds a mouse button down while performing another action. 331 | 332 | ### Example: 333 | ```py 334 | with interception.hold_mouse("left"): 335 | interception.move_to(300, 300) 336 | """ 337 | mouse_down(button=button) 338 | try: 339 | yield 340 | finally: 341 | mouse_up(button=button) 342 | 343 | 344 | @requires_driver 345 | @contextmanager 346 | def hold_key(key: str): 347 | """Hold a key down while performing another action. 348 | 349 | ### Example: 350 | ```py 351 | with interception.hold_key("ctrl"): 352 | interception.press("c") 353 | """ 354 | key_down(key) 355 | try: 356 | yield 357 | finally: 358 | key_up(key) 359 | 360 | 361 | @requires_driver 362 | def capture_keyboard() -> None: 363 | """Captures keyboard keypresses until the `Escape` key is pressed. 364 | 365 | Filters out non `KEY_DOWN` events to not post the same capture twice. 366 | """ 367 | context = Interception() 368 | context.set_filter(context.is_keyboard, FilterKeyFlag.FILTER_KEY_DOWN) 369 | print("Capturing keyboard presses, press ESC to quit.") 370 | 371 | _listen_to_events(context, "esc") 372 | print("No longer intercepting keyboard events.") 373 | 374 | 375 | @requires_driver 376 | def capture_mouse() -> None: 377 | """Captures mouse left clicks until the `Escape` key is pressed. 378 | 379 | Filters out non `LEFT_BUTTON_DOWN` events to not post the same capture twice. 380 | """ 381 | context = Interception() 382 | context.set_filter( 383 | context.is_mouse, FilterMouseButtonFlag.FILTER_MOUSE_LEFT_BUTTON_DOWN 384 | ) 385 | context.set_filter(context.is_keyboard, FilterKeyFlag.FILTER_KEY_DOWN) 386 | print("Intercepting mouse left clicks, press ESC to quit.") 387 | 388 | _listen_to_events(context, "esc") 389 | print("No longer intercepting mouse events.") 390 | 391 | 392 | @requires_driver 393 | def auto_capture_devices( 394 | *, keyboard: bool = True, mouse: bool = True, verbose: bool = False 395 | ) -> None: 396 | """Uses pynputs keyboard and mouse listener to check whether a device 397 | number will send a valid input. During this process, each possible number 398 | for the device is tried - once a working number is found, it is assigned 399 | to the context and the it moves to the next device. 400 | 401 | ### Parameters: 402 | -------------- 403 | keyboard :class:`bool`: 404 | Capture the keyboard number. 405 | 406 | mouse :class:`bool`: 407 | Capture the mouse number. 408 | 409 | verbose :class:`bool`: 410 | Provide output regarding the tested numbers. 411 | """ 412 | 413 | def log(info: str) -> None: 414 | if verbose: 415 | print(info) 416 | 417 | num = 0 if keyboard else 10 418 | while num < 20: 419 | hwid: Optional[str] = _g_context.devices[num].get_HWID() 420 | if hwid is None: 421 | log(f"{num}: None") 422 | num += 1 423 | continue 424 | 425 | log(f"{num}: {hwid[:60]}...") 426 | if _g_context.is_keyboard(num): 427 | _g_context.keyboard = num 428 | num += 1 429 | if not mouse: 430 | break 431 | continue 432 | _g_context.mouse = num 433 | num += 1 434 | 435 | log(f"Devices set. Mouse: {_g_context.mouse}, keyboard: {_g_context.keyboard}") 436 | 437 | 438 | @requires_driver 439 | def set_devices(keyboard: Optional[int] = None, mouse: Optional[int] = None) -> None: 440 | """Sets the devices on the current context. Keyboard devices should be from 0 to 10 441 | and mouse devices from 10 to 20 (both non-inclusive). 442 | 443 | If a device out of range is passed, the context will raise a `ValueError`. 444 | """ 445 | if keyboard is not None: 446 | _g_context.keyboard = keyboard 447 | 448 | if mouse is not None: 449 | _g_context.mouse = mouse 450 | 451 | 452 | @requires_driver 453 | def get_mouse() -> int: 454 | return _g_context.mouse 455 | 456 | 457 | @requires_driver 458 | def get_keyboard() -> int: 459 | return _g_context.keyboard 460 | 461 | 462 | def _listen_to_events(context: Interception, stop_button: str) -> None: 463 | """Listens to a given interception context. Stops when the `stop_button` is 464 | the event key. 465 | 466 | Remember to destroy the context in any case afterwards. Otherwise events 467 | will continue to be intercepted!""" 468 | stop = _keycodes.get_key_information(stop_button) 469 | try: 470 | while True: 471 | device = context.await_input() 472 | if device is None: 473 | continue 474 | 475 | stroke = context.devices[device].receive() 476 | if stroke is None: 477 | continue 478 | 479 | if isinstance(stroke, KeyStroke) and stroke.code == stop.scan_code: 480 | return 481 | 482 | # Only print the stroke if it is a key down stroke, that way we dont 483 | # have to filter the context for the strokes which would lead to issues 484 | # in passing the intercepted stroke on. 485 | # See https://github.com/kennyhml/pyinterception/issues/32#issuecomment-2332565307 486 | if (isinstance(stroke, KeyStroke) and stroke.flags == KeyFlag.KEY_DOWN) or ( 487 | isinstance(stroke, MouseStroke) 488 | and stroke.flags == MouseButtonFlag.MOUSE_LEFT_BUTTON_DOWN 489 | ): 490 | print(f"Received stroke {stroke} on device {device}") 491 | context.send(device, stroke) 492 | finally: 493 | context.destroy() 494 | 495 | 496 | def _get_button_states(button: str, *, down: bool) -> int: 497 | try: 498 | states = MouseButtonFlag.from_string(button) 499 | return states[not down] # first state is down, second state is up 500 | except KeyError: 501 | raise exceptions.UnknownButtonError(button) 502 | --------------------------------------------------------------------------------