├── 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 |
--------------------------------------------------------------------------------