diff --git a/.gitignore b/.gitignore index 3e759b7..c0a96dc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.user *.userosscache *.sln.docstates +test # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -328,3 +329,131 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/README.md b/README.md index 6dbeb3a..f125078 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,85 @@ -# interception_py -This is a port (not a [wrapper][wrp]) of [interception][c_ception] dll to python, it communicates directly with interception's driver +# pyinterception +This is a greatly reworked version of [interception_py][wrp], a python port for [interception][c_ception], which is now obsolete and points here instead. -### why not using the wrapper? -* it's very slow and some strokes are lost -* fast strokes made python crash (some heap allocation errors) +The Interception API aims to build a portable programming interface that allows one to intercept and control a range of input devices. -To make it run you should install the driver from [c-interception][c_ception] +## How to install +Pyinterception is now available on PyPi under the name `interception-python`! So simply `pip install interception-python`. + + +## Why use interception? +Did you ever try to send inputs to an application or game and, well, nothing happened? Sure, in alot of cases this is resolved by running your +code with administrative privileges, but this is not always the case. + +Take parsec as example, you are entirely unable to send any simulated inputs to parsec, but it has no problems with real inputs, so why is this? + +Long story short, injected inputs actually have a `LowLevelKeyHookInjected` (0x10) flag. This flag will **always** be set when sending an Input with `SendInput` or the even older `mouse_event` and `keyb_event`. This flag is not at all hard to pick up for programs, if you have python3.7 and pyHook installed, you can try it yourself using this code: -### example ```py +import pyHook +import pythoncom + +def keyb_event(event): + + if event.flags & 0x10: + print("Injected input sent") + else: + print("Real input sent") + + return True + +hm = pyHook.HookManager() +hm.KeyDown = keyb_event + +hm.HookKeyboard() + +pythoncom.PumpMessages() +``` +You will quickly see that, no matter what conventional python library you try, all of them will be flagged as injected. Thats because in the end, they all either rely on `SendInput` or `keyb_event` | `mouse_event`. + +Why is this bad? Well, it's not always bad. If whatever you're sending inputs to currently works fine, and you are not worried about getting flagged by some sort of anti-cheat, then by all means its totally fine to stick to pyautogui / pydirectinput. + +Alright, enough about that, onto the important shit. + +## Why use this fork? +- Extremely simple interface, comparable to pyautogui / pydirectinput +- Dynamic keyboard adjustment for all kinds of layouts +- Refactored in a much more readable and documented fashion +- I work with loads of automation first hand so there is alot of QoL features. + +## How to use? +First of all, you absolutely need to install the [interception-driver][c_ception], otherwise none of this will work. It's a very simple install. + +Now, once you have all of that set up, you can go ahead and import `interception`. +The first thing you need to understand is that you have 10 different numbers for keyboard / mouse, and any of them could be the device you are +using. You can observe this by running the following program: +```py +import interception + +interception.capture_keyboard() +interception.capture_mouse() +``` +You can cancel the capture by pressing the ESC key, but every time you press a key or click with the mouse, you can see the intercepted event in the terminal. +The event consists of different kinds of flags and states, but also of the number of your device we just talked about. + +To make sure that interception can actively send inputs from the correct devices, you have to set the correct devices. You can do this by manually checking the output, +but that gets pretty annoying as they can and will change sometimes. To make this easier, pyinterception has a method that will automatically capture a working device: +```py +import interception + +interception.auto_capture_devices(keyboard=True, mouse=True) +``` +So, now you can begin to send inputs, just like you are used to it from pyautogui or pydirectinput! +```py +interception.move_to(960, 540) + +with interception.hold_key("shift"): + interception.press("a") -from interception import * - -if __name__ == "__main__": - c = interception() - c.set_filter(interception.is_keyboard,interception_filter_key_state.INTERCEPTION_FILTER_KEY_UP.value) - while True: - device = c.wait() - stroke = c.receive(device) - if type(stroke) is key_stroke: - print(stroke.code) - c.send(device,stroke) +interception.click(120, 160, button="right", delay=1) ``` +Have fun :D -[wrp]: https://github.com/cobrce/interception_wrapper +[wrp]: https://github.com/cobrce/interception_py [c_ception]: https://github.com/oblitum/Interception diff --git a/_example_.py b/_example_.py deleted file mode 100644 index ba7b3df..0000000 --- a/_example_.py +++ /dev/null @@ -1,15 +0,0 @@ -from interception import * -from consts import * - -if __name__ == "__main__": - c = interception() - c.set_filter(interception.is_keyboard,interception_filter_key_state.INTERCEPTION_FILTER_KEY_UP.value) - while True: - device = c.wait() - stroke = c.receive(device) - if type(stroke) is key_stroke: - print(stroke.code) - c.send(device,stroke) - # hwid = c.get_HWID(device) - # print(u"%s" % hwid) - \ No newline at end of file diff --git a/_example_hardwareid.py b/_example_hardwareid.py deleted file mode 100644 index 4b14750..0000000 --- a/_example_hardwareid.py +++ /dev/null @@ -1,17 +0,0 @@ -from interception import * -from consts import * - -SCANCODE_ESC = 0x01 - -if __name__ == "__main__": - c = interception() - c.set_filter(interception.is_keyboard,interception_filter_key_state.INTERCEPTION_FILTER_KEY_UP.value | interception_filter_key_state.INTERCEPTION_FILTER_KEY_DOWN.value) - c.set_filter(interception.is_mouse,interception_filter_mouse_state.INTERCEPTION_FILTER_MOUSE_LEFT_BUTTON_DOWN.value) - while True: - device = c.wait() - stroke = c.receive(device) - c.send(device,stroke) - if stroke is None or (interception.is_keyboard(device) and stroke.code == SCANCODE_ESC): - break - print(c.get_HWID(device)) - c._destroy_context() diff --git a/_example_mathpointer.py b/_example_mathpointer.py deleted file mode 100644 index 0993278..0000000 --- a/_example_mathpointer.py +++ /dev/null @@ -1,256 +0,0 @@ -from interception import * -from consts import * -from math import * -from win32api import GetSystemMetrics -from datetime import datetime -from time import sleep - -esc = 0x01 -num_0 = 0x0B -num_1 = 0x02 -num_2 = 0x03 -num_3 = 0x04 -num_4 = 0x05 -num_5 = 0x06 -num_6 = 0x07 -num_7 = 0x08 -num_8 = 0x09 -num_9 = 0x0A -scale = 15 -screen_width = GetSystemMetrics(0) -screen_height = GetSystemMetrics(1) - -def delay(): - sleep(0.001) - -class point(): - x = 0 - y = 0 - def __init__(self,x,y): - self.x = x - self.y = y - -def circle(t): - f = 10 - return point(scale * f * cos(t), scale * f *sin(t)) - -def mirabilis(t): - f= 1 / 2 - k = 1 / (2 * pi) - - return point(scale * f * (exp(k * t) * cos(t)), - scale * f * (exp(k * t) * sin(t))) - -def epitrochoid(t): - f = 1 - R = 6 - r = 2 - d = 1 - c = R + r - - return point(scale * f * (c * cos(t) - d * cos((c * t) / r)), - scale * f * (c * sin(t) - d * sin((c * t) / r))) - -def hypotrochoid(t): - f = 10 / 7 - R = 5 - r = 3 - d = 5 - c = R - r - - return point(scale * f * (c * cos(t) + d * cos((c * t) / r)), - scale * f * (c * sin(t) - d * sin((c * t) / r))) - -def hypocycloid(t): - f = 10 / 3 - R = 3 - r = 1 - c = R - r - - return point(scale * f * (c * cos(t) + r * cos((c * t) / r)), - scale * f * (c * sin(t) - r * sin((c * t) / r))) - -def bean(t): - f = 10 - c = cos(t) - s = sin(t) - - return point(scale * f * ((pow(c, 3) + pow(s, 3)) * c), - scale * f * ((pow(c, 3) + pow(s, 3)) * s)) - -def Lissajous(t): - f = 10 - a = 2 - b = 3 - - return point(scale * f * (sin(a * t)), scale * f * (sin(b * t))) - -def epicycloid(t): - f = 10 / 42 - R = 21 - r = 10 - c = R + r - - return point(scale * f * (c * cos(t) - r * cos((c * t) / r)), - scale * f * (c * sin(t) - r * sin((c * t) / r))) - -def rose(t): - f = 10 - R = 1 - k = 2 / 7 - - return point(scale * f * (R * cos(k * t) * cos(t)), - scale * f * (R * cos(k * t) * sin(t))) - -def butterfly(t): - f = 10 / 4 - c = exp(cos(t)) - 2 * cos(4 * t) + pow(sin(t / 12), 5) - - return point(scale * f * (sin(t) * c), scale * f * (cos(t) * c)) - -def math_track(context:interception, mouse : int, - center,curve, t1, t2, # changed params order - partitioning): - delta = t2 - t1 - position = curve(t1) - mstroke = mouse_stroke(interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_UP.value, - interception_mouse_flag.INTERCEPTION_MOUSE_MOVE_ABSOLUTE.value, - 0, - int((0xFFFF * center.x) / screen_width), - int((0xFFFF * center.y) / screen_height), - 0) - - context.send(mouse,mstroke) - - mstroke.state = 0 - mstroke.x = int((0xFFFF * (center.x + position.x)) / screen_width) - mstroke.y = int((0xFFFF * (center.y - position.y)) / screen_height) - - context.send(mouse,mstroke) - - j = 0 - for i in range(partitioning+2): - if (j % 250 == 0): - delay() - mstroke.state = interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_UP.value - context.send(mouse,mstroke) - - delay() - mstroke.state = interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_DOWN.value - context.send(mouse,mstroke) - if i > 0: - i = i-2 - - position = curve(t1 + (i * delta)/partitioning) - mstroke.x = int((0xFFFF * (center.x + position.x)) / screen_width) - mstroke.y = int((0xFFFF * (center.y - position.y)) / screen_height) - context.send(mouse,mstroke) - delay() - j = j + 1 - - delay() - mstroke.state = interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_DOWN.value - context.send(mouse,mstroke) - - delay() - mstroke.state = interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_UP.value - context.send(mouse,mstroke) - - delay() - mstroke.state = 0 - mstroke.x = int((0xFFFF * center.x) / screen_width) - mstroke.y = int((0xFFFF * center.y) / screen_height) - context.send(mouse,mstroke) - -curves = { num_0 : (circle,0,2*pi,200), - num_1 : (mirabilis,-6*pi,6*pi,200), - num_2 : (epitrochoid,0, 2 * pi, 200), - num_3 : (hypotrochoid, 0, 6 * pi, 200), - num_4 : (hypocycloid,0, 2 * pi, 200), - num_5 : (bean, 0, pi, 200), - num_6 : (Lissajous, 0, 2 * pi, 200), - num_7 : (epicycloid, 0, 20 * pi, 1000), - num_8 : (rose,0, 14 * pi, 500), - num_9 : (butterfly, 0, 21 * pi, 2000), - } - - -notice = '''NOTICE: This example works on real machines. -Virtual machines generally work with absolute mouse -positioning over the screen, which this samples isn't\n" -prepared to handle. - -Now please, first move the mouse that's going to be impersonated. -''' - -steps = '''Impersonating mouse %d -Now: - - Go to Paint (or whatever place you want to draw) - - Select your pencil - - Position your mouse in the drawing board - - Press any digit (not numpad) on your keyboard to draw an equation - - Press ESC to exit.''' - -def main(): - - mouse = 0 - position = point(screen_width // 2, screen_height // 2) - context = interception() - context.set_filter(interception.is_keyboard, - interception_filter_key_state.INTERCEPTION_FILTER_KEY_DOWN.value | - interception_filter_key_state.INTERCEPTION_FILTER_KEY_UP.value) - context.set_filter(interception.is_mouse, - interception_filter_mouse_state.INTERCEPTION_FILTER_MOUSE_MOVE.value ) - - print(notice) - - while True: - - device = context.wait() - if interception.is_mouse(device): - if mouse == 0: - mouse = device - print( steps % (device - 10)) - - mstroke = context.receive(device) - - position.x += mstroke.x - position.y += mstroke.y - - if position.x < 0: - position.x = 0 - if position.x > screen_width - 1: - position.x = screen_width -1 - - if position.y <0 : - position.y = 0 - if position.y > screen_height - 1: - position.y = screen_height -1 - - mstroke.flags = interception_mouse_flag.INTERCEPTION_MOUSE_MOVE_ABSOLUTE.value - mstroke.x = int((0xFFFF * position.x) / screen_width) - mstroke.y = int((0xFFFF * position.y) / screen_height) - - context.send(device,mstroke) - - if mouse and interception.is_keyboard(device): - kstroke = context.receive(device) - - if kstroke.code == esc: - return - - if kstroke.state == interception_key_state.INTERCEPTION_KEY_DOWN.value: - if kstroke.code in curves: - math_track(context,mouse,position,*curves[kstroke.code]) - else: - context.send(device,kstroke) - - elif kstroke.state == interception_key_state.INTERCEPTION_KEY_UP.value: - if not kstroke.code in curves: - context.send(device,kstroke) - else: - context.send(device,kstroke) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/_right_click.py b/_right_click.py deleted file mode 100644 index 8987355..0000000 --- a/_right_click.py +++ /dev/null @@ -1,37 +0,0 @@ -from interception import * -from win32api import GetSystemMetrics - -# get screen size -screen_width = GetSystemMetrics(0) -screen_height = GetSystemMetrics(1) - -# create a context for interception to use to send strokes, in this case -# we won't use filters, we will manually search for the first found mouse -context = interception() - -# loop through all devices and check if they correspond to a mouse -mouse = 0 -for i in range(MAX_DEVICES): - if interception.is_mouse(i): - mouse = i - break - -# no mouse we quit -if (mouse == 0): - print("No mouse found") - exit(0) - - -# we create a new mouse stroke, initially we use set right button down, we also use absolute move, -# and for the coordinate (x and y) we use center screen -mstroke = mouse_stroke(interception_mouse_state.INTERCEPTION_MOUSE_RIGHT_BUTTON_DOWN.value, - interception_mouse_flag.INTERCEPTION_MOUSE_MOVE_ABSOLUTE.value, - 0, - int((0xFFFF * screen_width/2) / screen_width), - int((0xFFFF * screen_height/2) / screen_height), - 0) - -context.send(mouse,mstroke) # we send the key stroke, now the right button is down - -mstroke.state = interception_mouse_state.INTERCEPTION_MOUSE_RIGHT_BUTTON_UP.value # update the stroke to release the button -context.send(mouse,mstroke) #button right is up \ No newline at end of file diff --git a/consts.py b/consts.py deleted file mode 100644 index 5ad70b8..0000000 --- a/consts.py +++ /dev/null @@ -1,79 +0,0 @@ -from enum import Enum - -class interception_key_state(Enum): - INTERCEPTION_KEY_DOWN = 0x00 - INTERCEPTION_KEY_UP = 0x01 - INTERCEPTION_KEY_E0 = 0x02 - INTERCEPTION_KEY_E1 = 0x04 - INTERCEPTION_KEY_TERMSRV_SET_LED = 0x08 - INTERCEPTION_KEY_TERMSRV_SHADOW = 0x10 - INTERCEPTION_KEY_TERMSRV_VKPACKET = 0x20 - -class interception_filter_key_state(Enum): - INTERCEPTION_FILTER_KEY_NONE = 0x0000 - INTERCEPTION_FILTER_KEY_ALL = 0xFFFF - INTERCEPTION_FILTER_KEY_DOWN = interception_key_state.INTERCEPTION_KEY_UP.value - INTERCEPTION_FILTER_KEY_UP = interception_key_state.INTERCEPTION_KEY_UP.value << 1 - INTERCEPTION_FILTER_KEY_E0 = interception_key_state.INTERCEPTION_KEY_E0.value << 1 - INTERCEPTION_FILTER_KEY_E1 = interception_key_state.INTERCEPTION_KEY_E1.value << 1 - INTERCEPTION_FILTER_KEY_TERMSRV_SET_LED = interception_key_state.INTERCEPTION_KEY_TERMSRV_SET_LED.value << 1 - INTERCEPTION_FILTER_KEY_TERMSRV_SHADOW = interception_key_state.INTERCEPTION_KEY_TERMSRV_SHADOW.value << 1 - INTERCEPTION_FILTER_KEY_TERMSRV_VKPACKET = interception_key_state.INTERCEPTION_KEY_TERMSRV_VKPACKET.value << 1 - -class interception_mouse_state (Enum): - INTERCEPTION_MOUSE_LEFT_BUTTON_DOWN = 0x001 - INTERCEPTION_MOUSE_LEFT_BUTTON_UP = 0x002 - INTERCEPTION_MOUSE_RIGHT_BUTTON_DOWN = 0x004 - INTERCEPTION_MOUSE_RIGHT_BUTTON_UP = 0x008 - INTERCEPTION_MOUSE_MIDDLE_BUTTON_DOWN = 0x010 - INTERCEPTION_MOUSE_MIDDLE_BUTTON_UP = 0x020 - - INTERCEPTION_MOUSE_BUTTON_1_DOWN = INTERCEPTION_MOUSE_LEFT_BUTTON_DOWN - INTERCEPTION_MOUSE_BUTTON_1_UP = INTERCEPTION_MOUSE_LEFT_BUTTON_UP - INTERCEPTION_MOUSE_BUTTON_2_DOWN = INTERCEPTION_MOUSE_RIGHT_BUTTON_DOWN - INTERCEPTION_MOUSE_BUTTON_2_UP = INTERCEPTION_MOUSE_RIGHT_BUTTON_UP - INTERCEPTION_MOUSE_BUTTON_3_DOWN = INTERCEPTION_MOUSE_MIDDLE_BUTTON_DOWN - INTERCEPTION_MOUSE_BUTTON_3_UP = INTERCEPTION_MOUSE_MIDDLE_BUTTON_UP - - INTERCEPTION_MOUSE_BUTTON_4_DOWN = 0x040 - INTERCEPTION_MOUSE_BUTTON_4_UP = 0x080 - INTERCEPTION_MOUSE_BUTTON_5_DOWN = 0x100 - INTERCEPTION_MOUSE_BUTTON_5_UP = 0x200 - - INTERCEPTION_MOUSE_WHEEL = 0x400 - INTERCEPTION_MOUSE_HWHEEL = 0x800 - -class interception_filter_mouse_state(Enum): - INTERCEPTION_FILTER_MOUSE_NONE = 0x0000 - INTERCEPTION_FILTER_MOUSE_ALL = 0xFFFF - - INTERCEPTION_FILTER_MOUSE_LEFT_BUTTON_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_DOWN.value - INTERCEPTION_FILTER_MOUSE_LEFT_BUTTON_UP = interception_mouse_state.INTERCEPTION_MOUSE_LEFT_BUTTON_UP.value - INTERCEPTION_FILTER_MOUSE_RIGHT_BUTTON_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_RIGHT_BUTTON_DOWN.value - INTERCEPTION_FILTER_MOUSE_RIGHT_BUTTON_UP = interception_mouse_state.INTERCEPTION_MOUSE_RIGHT_BUTTON_UP.value - INTERCEPTION_FILTER_MOUSE_MIDDLE_BUTTON_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_MIDDLE_BUTTON_DOWN.value - INTERCEPTION_FILTER_MOUSE_MIDDLE_BUTTON_UP = interception_mouse_state.INTERCEPTION_MOUSE_MIDDLE_BUTTON_UP.value - - INTERCEPTION_FILTER_MOUSE_BUTTON_1_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_1_DOWN.value - INTERCEPTION_FILTER_MOUSE_BUTTON_1_UP = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_1_UP.value - INTERCEPTION_FILTER_MOUSE_BUTTON_2_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_2_DOWN.value - INTERCEPTION_FILTER_MOUSE_BUTTON_2_UP = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_2_UP.value - INTERCEPTION_FILTER_MOUSE_BUTTON_3_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_3_DOWN.value - INTERCEPTION_FILTER_MOUSE_BUTTON_3_UP = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_3_UP.value - - INTERCEPTION_FILTER_MOUSE_BUTTON_4_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_4_DOWN.value - INTERCEPTION_FILTER_MOUSE_BUTTON_4_UP = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_4_UP.value - INTERCEPTION_FILTER_MOUSE_BUTTON_5_DOWN = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_5_DOWN.value - INTERCEPTION_FILTER_MOUSE_BUTTON_5_UP = interception_mouse_state.INTERCEPTION_MOUSE_BUTTON_5_UP.value - - INTERCEPTION_FILTER_MOUSE_WHEEL = interception_mouse_state.INTERCEPTION_MOUSE_WHEEL.value - INTERCEPTION_FILTER_MOUSE_HWHEEL = interception_mouse_state.INTERCEPTION_MOUSE_HWHEEL.value - INTERCEPTION_FILTER_MOUSE_MOVE = 0x1000 - -class interception_mouse_flag(Enum): - INTERCEPTION_MOUSE_MOVE_RELATIVE = 0x000 - INTERCEPTION_MOUSE_MOVE_ABSOLUTE = 0x001 - INTERCEPTION_MOUSE_VIRTUAL_DESKTOP = 0x002 - INTERCEPTION_MOUSE_ATTRIBUTES_CHANGED = 0x004 - INTERCEPTION_MOUSE_MOVE_NOCOALESCE = 0x008 - INTERCEPTION_MOUSE_TERMSRV_SRC_SHADOW = 0x100 \ No newline at end of file diff --git a/interception.py b/interception.py deleted file mode 100644 index 0209fb2..0000000 --- a/interception.py +++ /dev/null @@ -1,182 +0,0 @@ -from ctypes import * -from stroke import * -from consts import * - -MAX_DEVICES = 20 -MAX_KEYBOARD = 10 -MAX_MOUSE = 10 - -k32 = windll.LoadLibrary('kernel32') - -class interception(): - _context = [] - k32 = None - _c_events = (c_void_p * MAX_DEVICES)() - - def __init__(self): - try: - for i in range(MAX_DEVICES): - _device = device(k32.CreateFileA(b'\\\\.\\interception%02d' % i, - 0x80000000,0,0,3,0,0), - k32.CreateEventA(0, 1, 0, 0), - interception.is_keyboard(i)) - self._context.append(_device) - self._c_events[i] = _device.event - - except Exception as e: - self._destroy_context() - raise e - - def wait(self,milliseconds =-1): - - result = k32.WaitForMultipleObjects(MAX_DEVICES,self._c_events,0,milliseconds) - if result == -1 or result == 0x102: - return 0 - else: - return result - - def set_filter(self,predicate,filter): - for i in range(MAX_DEVICES): - if predicate(i): - result = self._context[i].set_filter(filter) - - def get_HWID(self,device:int): - if not interception.is_invalid(device): - try: - return self._context[device].get_HWID().decode("utf-16") - except: - pass - return "" - - def receive(self,device:int): - if not interception.is_invalid(device): - return self._context[device].receive() - - def send(self,device: int,stroke : stroke): - if not interception.is_invalid(device): - self._context[device].send(stroke) - - @staticmethod - def is_keyboard(device): - return device+1 > 0 and device+1 <= MAX_KEYBOARD - - @staticmethod - def is_mouse(device): - return device+1 > MAX_KEYBOARD and device+1 <= MAX_KEYBOARD + MAX_MOUSE - - @staticmethod - def is_invalid(device): - return device+1 <= 0 or device+1 > (MAX_KEYBOARD + MAX_MOUSE) - - def _destroy_context(self): - for device in self._context: - device.destroy() - -class device_io_result: - result = 0 - data = None - data_bytes = None - def __init__(self,result,data): - self.result = result - if data!=None: - self.data = list(data) - self.data_bytes = bytes(data) - - -def device_io_call(decorated): - def decorator(device,*args,**kwargs): - command,inbuffer,outbuffer = decorated(device,*args,**kwargs) - return device._device_io_control(command,inbuffer,outbuffer) - return decorator - -class device(): - handle=0 - event=0 - is_keyboard = False - _parser = None - _bytes_returned = (c_int * 1)(0) - _c_byte_500 = (c_byte * 500)() - _c_int_2 = (c_int * 2)() - _c_ushort_1 = (c_ushort * 1)() - _c_int_1 = (c_int * 1)() - _c_recv_buffer = None - - def __init__(self, handle, event,is_keyboard:bool): - self.is_keyboard = is_keyboard - if is_keyboard: - self._c_recv_buffer = (c_byte * 12)() - self._parser = key_stroke - else: - self._c_recv_buffer = (c_byte * 24)() - self._parser = mouse_stroke - - if handle == -1 or event == 0: - raise Exception("Can't create device") - self.handle=handle - self.event =event - - if self._device_set_event().result == 0: - raise Exception("Can't communicate with driver") - - def destroy(self): - if self.handle != -1: - k32.CloseHandle(self.handle) - if self.event!=0: - k32.CloseHandle(self.event) - - @device_io_call - def get_precedence(self): - return 0x222008,0,self._c_int_1 - - @device_io_call - def set_precedence(self,precedence : int): - self._c_int_1[0] = precedence - return 0x222004,self._c_int_1,0 - - @device_io_call - def get_filter(self): - return 0x222020,0,self._c_ushort_1 - - @device_io_call - def set_filter(self,filter): - self._c_ushort_1[0] = filter - return 0x222010,self._c_ushort_1,0 - - @device_io_call - def _get_HWID(self): - return 0x222200,0,self._c_byte_500 - - def get_HWID(self): - data = self._get_HWID().data_bytes - return data[:self._bytes_returned[0]] - - @device_io_call - def _receive(self): - return 0x222100,0,self._c_recv_buffer - - def receive(self): - data = self._receive().data_bytes - return self._parser.parse_raw(data) - - def send(self,stroke:stroke): - if type(stroke) == self._parser: - self._send(stroke) - - @device_io_call - def _send(self,stroke:stroke): - memmove(self._c_recv_buffer,stroke.data_raw,len(self._c_recv_buffer)) - return 0x222080,self._c_recv_buffer,0 - - @device_io_call - def _device_set_event(self): - self._c_int_2[0] = self.event - return 0x222040,self._c_int_2,0 - - def _device_io_control(self,command,inbuffer,outbuffer)->device_io_result: - res = k32.DeviceIoControl(self.handle,command,inbuffer, - len(bytes(inbuffer)) if inbuffer != 0 else 0, - outbuffer, - len(bytes(outbuffer)) if outbuffer !=0 else 0, - self._bytes_returned,0) - - return device_io_result(res,outbuffer if outbuffer !=0 else None) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..06f3e34 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "interception-python" +version = "0.0.0" +authors = [ + { name="Kenny Hommel", email="kennyhommel36@gmail.com" }, +] +description = "A python port of interception, which hooks into the input event handling mechanisms to simulate inputs without injected flags" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/kennyhml/pyinterception" +"Bug Tracker" = "https://github.com/kennyhml/pyinterception/issues" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c19d646 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[metadata] +name = "interception-python" +license_files = LICENSE + +[options] +package_dir= + =src +packages = find: +zip_safe = False +python_requires = >= 3 +install_requires = + pynput + +[options.packages.find] +where = src +exclude = + tests* + .gitignore \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f4aeaa1 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup # type: ignore[import] + +setup() \ No newline at end of file diff --git a/src/interception/__init__.py b/src/interception/__init__.py new file mode 100644 index 0000000..1358888 --- /dev/null +++ b/src/interception/__init__.py @@ -0,0 +1,4 @@ +from .interception import Interception +from .device import Device +from .strokes import KeyStroke, MouseStroke, Stroke +from .inputs import * \ No newline at end of file diff --git a/src/interception/_consts.py b/src/interception/_consts.py new file mode 100644 index 0000000..5bb6715 --- /dev/null +++ b/src/interception/_consts.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from enum import IntEnum + + +class KeyState(IntEnum): + KEY_DOWN = 0x00 + KEY_UP = 0x01 + KEY_E0 = 0x02 + KEY_E1 = 0x04 + KEY_TERMSRV_SET_LED = 0x08 + KEY_TERMSRV_SHADOW = 0x10 + KEY_TERMSRV_VKPACKET = 0x20 + + +class MouseState(IntEnum): + MOUSE_LEFT_BUTTON_DOWN = 0x001 + MOUSE_LEFT_BUTTON_UP = 0x002 + MOUSE_RIGHT_BUTTON_DOWN = 0x004 + MOUSE_RIGHT_BUTTON_UP = 0x008 + MOUSE_MIDDLE_BUTTON_DOWN = 0x010 + MOUSE_MIDDLE_BUTTON_UP = 0x020 + + MOUSE_BUTTON_4_DOWN = 0x040 + MOUSE_BUTTON_4_UP = 0x080 + MOUSE_BUTTON_5_DOWN = 0x100 + MOUSE_BUTTON_5_UP = 0x200 + + MOUSE_WHEEL = 0x400 + MOUSE_HWHEEL = 0x800 + + @staticmethod + def from_string(button: str) -> tuple[MouseState, MouseState]: + return _MAPPED_MOUSE_BUTTONS[button] + + +class MouseFlag(IntEnum): + MOUSE_MOVE_RELATIVE = 0x000 + MOUSE_MOVE_ABSOLUTE = 0x001 + MOUSE_VIRTUAL_DESKTOP = 0x002 + MOUSE_ATTRIBUTES_CHANGED = 0x004 + MOUSE_MOVE_NOCOALESCE = 0x008 + MOUSE_TERMSRV_SRC_SHADOW = 0x100 + + +class MouseRolling(IntEnum): + MOUSE_WHEEL_UP = 0x78 + MOUSE_WHEEL_DOWN = 0xFF88 + + +class FilterMouseState(IntEnum): + FILTER_MOUSE_NONE = 0x0000 + FILTER_MOUSE_ALL = 0xFFFF + + FILTER_MOUSE_LEFT_BUTTON_DOWN = MouseState.MOUSE_LEFT_BUTTON_DOWN + FILTER_MOUSE_LEFT_BUTTON_UP = MouseState.MOUSE_LEFT_BUTTON_UP + FILTER_MOUSE_RIGHT_BUTTON_DOWN = MouseState.MOUSE_RIGHT_BUTTON_DOWN + FILTER_MOUSE_RIGHT_BUTTON_UP = MouseState.MOUSE_RIGHT_BUTTON_UP + FILTER_MOUSE_MIDDLE_BUTTON_DOWN = MouseState.MOUSE_MIDDLE_BUTTON_DOWN + FILTER_MOUSE_MIDDLE_BUTTON_UP = MouseState.MOUSE_MIDDLE_BUTTON_UP + + FILTER_MOUSE_BUTTON_4_DOWN = MouseState.MOUSE_BUTTON_4_DOWN + FILTER_MOUSE_BUTTON_4_UP = MouseState.MOUSE_BUTTON_4_UP + FILTER_MOUSE_BUTTON_5_DOWN = MouseState.MOUSE_BUTTON_5_DOWN + FILTER_MOUSE_BUTTON_5_UP = MouseState.MOUSE_BUTTON_5_UP + + FILTER_MOUSE_WHEEL = MouseState.MOUSE_WHEEL + FILTER_MOUSE_HWHEEL = MouseState.MOUSE_HWHEEL + FILTER_MOUSE_MOVE = 0x1000 + + +class FilterKeyState(IntEnum): + FILTER_KEY_NONE = 0x0000 + FILTER_KEY_ALL = 0xFFFF + FILTER_KEY_DOWN = KeyState.KEY_UP + FILTER_KEY_UP = KeyState.KEY_UP << 1 + FILTER_KEY_E0 = KeyState.KEY_E0 << 1 + FILTER_KEY_E1 = KeyState.KEY_E1 << 1 + FILTER_KEY_TERMSRV_SET_LED = KeyState.KEY_TERMSRV_SET_LED << 1 + FILTER_KEY_TERMSRV_SHADOW = KeyState.KEY_TERMSRV_SHADOW << 1 + FILTER_KEY_TERMSRV_VKPACKET = KeyState.KEY_TERMSRV_VKPACKET << 1 + + +_MAPPED_MOUSE_BUTTONS = { + "left": (MouseState.MOUSE_LEFT_BUTTON_DOWN, MouseState.MOUSE_LEFT_BUTTON_UP), + "right": (MouseState.MOUSE_RIGHT_BUTTON_DOWN, MouseState.MOUSE_RIGHT_BUTTON_UP), + "middle": (MouseState.MOUSE_MIDDLE_BUTTON_DOWN, MouseState.MOUSE_MIDDLE_BUTTON_UP), + "mouse4": (MouseState.MOUSE_BUTTON_4_DOWN, MouseState.MOUSE_BUTTON_4_UP), + "mouse5": (MouseState.MOUSE_BUTTON_5_DOWN, MouseState.MOUSE_BUTTON_5_UP), +} diff --git a/src/interception/_keycodes.py b/src/interception/_keycodes.py new file mode 100644 index 0000000..33a4a0d --- /dev/null +++ b/src/interception/_keycodes.py @@ -0,0 +1,141 @@ +from ctypes import windll, wintypes + +KEYBOARD_MAPPING = { + "f1": 0x70, + "f2": 0x71, + "f3": 0x72, + "f4": 0x73, + "f5": 0x74, + "f6": 0x75, + "f7": 0x76, + "f8": 0x77, + "f9": 0x78, + "f10": 0x79, + "f11": 0x7A, + "f12": 0x7B, + "f13": 0x7C, + "f14": 0x7D, + "f15": 0x7E, + "f16": 0x7F, + "f17": 0x80, + "f18": 0x81, + "f19": 0x82, + "f20": 0x83, + "f21": 0x84, + "f22": 0x85, + "f23": 0x86, + "f24": 0x87, + "backspace": 0x08, + "\b": 0x08, # same as backspace + "tab": 0x09, + "\t": 0x09, # same as tab + "super": 0x5B, + "clear": 0x0C, + "enter": 0x0D, + "\n": 0x0D, # same as enter key (newline) + "return": 0x0D, + "shift": 0x10, + "ctrl": 0x11, + "alt": 0x12, + "pause": 0x13, + "capslock": 0x14, + "kana": 0x15, + "hanguel": 0x15, + "hangul": 0x15, + "junja": 0x17, + "final": 0x18, + "hanja": 0x19, + "kanji": 0x19, + "esc": 0x1B, + "escape": 0x1B, + "convert": 0x1C, + "nonconvert": 0x1D, + "accept": 0x1E, + "modechange": 0x1F, + " ": 0x20, + "space": 0x20, + "pgup": 0x21, + "pgdn": 0x22, + "pageup": 0x21, + "pagedown": 0x22, + "end": 0x23, + "home": 0x24, + "left": 0x25, + "up": 0x26, + "right": 0x27, + "down": 0x28, + "select": 0x29, + "print": 0x2A, + "execute": 0x2B, + "prtsc": 0x2C, + "prtscr": 0x2C, + "prntscrn": 0x2C, + "printscreen": 0x2C, + "insert": 0x2D, + "del": 0x2E, + "delete": 0x2E, + "help": 0x2F, + "win": 0x5B, + "winleft": 0x5B, + "winright": 0x5C, + "apps": 0x5D, + "sleep": 0x5F, + "num0": 0x60, + "num1": 0x61, + "num2": 0x62, + "num3": 0x63, + "num4": 0x64, + "num5": 0x65, + "num6": 0x66, + "num7": 0x67, + "num8": 0x68, + "num9": 0x69, + "multiply": 0x6A, + "*": 0x6A, + "add": 0x6B, + "plus": 0x6B, + "+": 0x6B, + "separator": 0x6C, + "subtract": 0x6D, + "minus": 0x6D, + "dash": 0x6D, + "decimal": 0x6E, + "divide": 0x6F, + "numlock": 0x90, + "scrolllock": 0x91, + "shiftleft": 0xA0, + "shiftright": 0xA1, + "ctrlleft": 0xA2, + "ctrlright": 0xA3, + "altleft": 0xA4, + "altright": 0xA5, + "browserback": 0xA6, + "browserforward": 0xA7, + "browserrefresh": 0xA8, + "browserstop": 0xA9, + "browsersearch": 0xAA, + "browserfavorites": 0xAB, + "browserhome": 0xAC, + "volumemute": 0xAD, + "volumedown": 0xAE, + "volumeup": 0xAF, + "nexttrack": 0xB0, + "prevtrack": 0xB1, + "stop": 0xB2, + "playpause": 0xB3, + "launchmail": 0xB4, + "launchmediaselect": 0xB5, + "launchapp1": 0xB6, + "launchapp2": 0xB7, +} + + +for c in range(32, 128): + KEYBOARD_MAPPING[chr(c).lower()] = windll.user32.VkKeyScanA(wintypes.WCHAR(chr(c))) + +for k, v in KEYBOARD_MAPPING.items(): + KEYBOARD_MAPPING[k] = windll.user32.MapVirtualKeyA(v, 0) + + + + diff --git a/src/interception/_utils.py b/src/interception/_utils.py new file mode 100644 index 0000000..88d6fb4 --- /dev/null +++ b/src/interception/_utils.py @@ -0,0 +1,73 @@ +from typing import Optional +import functools +from threading import Thread +import win32api # type: ignore + + +def normalize(x: int | tuple[int, int], y: Optional[int] = None) -> tuple[int, int]: + """Normalizes an x, y position to allow passing them seperately or as tuple.""" + if isinstance(x, tuple): + if len(x) == 2: + x, y = x + elif len(x) == 4: + x, y, *_ = x + else: + raise ValueError(f"Cant normalize tuple of length {len(x)}: {x}") + else: + assert y is not None + + return int(x), int(y) + + +def to_interception_coordinate(x: int, y: int) -> tuple[int, int]: + """Scales a "normal" coordinate to the respective point in the interception + coordinate system. + + The interception coordinate system covers all 16-bit unsigned integers, + ranging from `0x0` to `0xFFFF (65535)`. + + To arrive at the formula, we first have to realize the following: + - The maximum value in the 16-bit system is so `0xFFFF (~65535)` + - The maximum value, depending on your monitor, would for example be `1920` + - To calculate the factor, we can calculate `65535 / 1920 = ~34.13`. + - Thus we found out, that `scaled x = factor * original x` and `factor = 0xFFFF / axis` + + So, to bring it to code: + ```py + xfactor = 0xFFFF / screen_width + yfactor = 0xFFFF / screen_height + ``` + + Now, using that factor, we can calculate the position of our coordinate as such: + ```py + interception_x = round(xfactor * x) + interception_y = round(yfactor * y) + """ + + def scale(dimension: int, point: int) -> int: + return int((0xFFFF / dimension) * point) + 1 + + screen = win32api.GetSystemMetrics(0), win32api.GetSystemMetrics(1) + return scale(screen[0], x), scale(screen[1], y) + + +def get_cursor_pos() -> tuple[int, int]: + """Gets the current position of the cursor using `GetCursorPos`""" + return win32api.GetCursorPos() + + +def threaded(name: str): + """Threads a function, beware that it will lose its return values""" + + def outer(func): + @functools.wraps(func) + def inner(*args, **kwargs): + def run(): + func(*args, **kwargs) + + thread = Thread(target=run, name=name) + thread.start() + + return inner + + return outer diff --git a/src/interception/device.py b/src/interception/device.py new file mode 100644 index 0000000..b74f30e --- /dev/null +++ b/src/interception/device.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from ctypes import c_byte, c_int, c_ubyte, c_ushort, memmove, windll +from dataclasses import dataclass, field +from typing import Any, Type + +from .strokes import KeyStroke, MouseStroke, Stroke + +k32 = windll.LoadLibrary("kernel32") + + +def device_io_call(decorated): + def decorator(device: Device, *args, **kwargs): + command, inbuffer, outbuffer = decorated(device, *args, **kwargs) + return device._device_io_control(command, inbuffer, outbuffer) + + return decorator + + +@dataclass +class DeviceIOResult: + """Represents the result of an IO operation on a `Device`.""" + result: int + data: Any + data_bytes: bytes = field(init=False, repr=False) + + def __post_init__(self): + if self.data is not None: + self.data = list(self.data) + self.data_bytes = bytes(self.data) + + +class Device: + _bytes_returned = (c_int * 1)(0) + _c_byte_500 = (c_byte * 500)() + _c_int_2 = (c_int * 2)() + _c_ushort_1 = (c_ushort * 1)() + _c_int_1 = (c_int * 1)() + + def __init__(self, handle, event, *, is_keyboard: bool): + self.is_keyboard = is_keyboard + self._parser: Type[KeyStroke] | Type[MouseStroke] + if is_keyboard: + self._c_recv_buffer = (c_ubyte * 12)() + self._parser = KeyStroke + else: + self._c_recv_buffer = (c_ubyte * 24)() + self._parser = MouseStroke + + if handle == -1 or event == 0: + raise Exception("Can't create device!") + + self.handle = handle + self.event = event + + if self._device_set_event().result == 0: + raise Exception("Can't communicate with driver") + + def __str__(self) -> str: + return f"Device(handle={self.handle}, event={self.event}, is_keyboard: {self.is_keyboard})" + + def __repr__(self) -> str: + return f"Device(handle={self.handle}, event={self.event}, is_keyboard: {self.is_keyboard})" + + def destroy(self): + if self.handle != -1: + k32.CloseHandle(self.handle) + + if self.event: + k32.CloseHandle(self.event) + + @device_io_call + def get_precedence(self): + return 0x222008, 0, self._c_int_1 + + @device_io_call + def set_precedence(self, precedence: int): + self._c_int_1[0] = precedence + return 0x222004, self._c_int_1, 0 + + @device_io_call + def get_filter(self): + return 0x222020, 0, self._c_ushort_1 + + @device_io_call + def set_filter(self, filter): + self._c_ushort_1[0] = filter + return 0x222010, self._c_ushort_1, 0 + + @device_io_call + def _get_HWID(self): + return 0x222200, 0, self._c_byte_500 + + def get_HWID(self): + data = self._get_HWID().data_bytes + return data[:self._bytes_returned[0]] + + @device_io_call + def _receive(self): + return 0x222100, 0, self._c_recv_buffer + + def receive(self): + data = self._receive().data_bytes + return self._parser.parse_raw(data) + + def send(self, stroke: Stroke): + if not isinstance(stroke, self._parser): + raise ValueError( + f"Can't parse {stroke} with {self._parser.__name__} parser!" + ) + self._send(stroke) + + @device_io_call + def _send(self, stroke: Stroke): + memmove(self._c_recv_buffer, stroke.raw_data, len(self._c_recv_buffer)) + return 0x222080, self._c_recv_buffer, 0 + + @device_io_call + def _device_set_event(self): + self._c_int_2[0] = self.event + return 0x222040, self._c_int_2, 0 + + def _device_io_control(self, command, inbuffer, outbuffer) -> DeviceIOResult: + res = k32.DeviceIoControl( + self.handle, + command, + inbuffer, + len(bytes(inbuffer)) if inbuffer else 0, + outbuffer, + len(bytes(outbuffer)) if outbuffer else 0, + self._bytes_returned, + 0, + ) + + return DeviceIOResult(res, outbuffer if outbuffer else None) diff --git a/src/interception/exceptions.py b/src/interception/exceptions.py new file mode 100644 index 0000000..9fa75b5 --- /dev/null +++ b/src/interception/exceptions.py @@ -0,0 +1,34 @@ +class DriverNotFoundError(Exception): + """Raised when the interception driver is not installed / found.""" + + def __str__(self) -> str: + return ( + "Interception driver was not found or is not installed.\n" + "Please confirm that it has been installed properly and is added to PATH." + ) + + +class UnknownKeyError(LookupError): + """Raised when attemping to press a key that doesnt exist""" + + def __init__(self, key: str) -> None: + self.key = key + + def __str__(self) -> str: + return ( + f"Unknown key requested: {self.key}.\n" + "Consider running 'pyinterception show_supported_keys' for a list of all supported keys." + ) + + +class UnknownButtonError(LookupError): + """Raised when attemping to press a mouse button that doesnt exist""" + + def __init__(self, button: str) -> None: + self.button = button + + def __str__(self) -> str: + return ( + f"Unknown button requested: {self.button}.\n" + "Consider running 'pyinterception show_supported_buttons' for a list of all supported buttons." + ) diff --git a/src/interception/inputs.py b/src/interception/inputs.py new file mode 100644 index 0000000..7cfe1a9 --- /dev/null +++ b/src/interception/inputs.py @@ -0,0 +1,438 @@ +import functools +import random +import time +from contextlib import contextmanager +from typing import Literal, Optional + +from pynput.keyboard import Listener as KeyListener # type: ignore[import] +from pynput.mouse import Listener as MouseListener # type: ignore[import] + +from . import _utils, exceptions +from ._consts import (FilterKeyState, FilterMouseState, KeyState, MouseFlag, + MouseRolling, MouseState) +from ._keycodes import KEYBOARD_MAPPING +from .interception import Interception +from .strokes import KeyStroke, MouseStroke, Stroke +from .types import MouseButton + +# try to initialize interception, if it fails simply remember that it failed to initalize. +# I want to avoid raising the error on import and instead raise it when attempting to call +# functionality that relies on the driver, this also still allows access to non driver stuff +try: + interception = Interception() + INTERCEPTION_INSTALLED = True +except Exception: + INTERCEPTION_INSTALLED = False + + +MOUSE_BUTTON_DELAY = 0.03 +KEY_PRESS_DELAY = 0.025 + + +_TEST_MOUSE_STROKE = MouseStroke(MouseState.MOUSE_MIDDLE_BUTTON_UP, 0, 0, 0, 0, 0) +_TEST_KEY_STROKE = KeyStroke(KEYBOARD_MAPPING["space"], KeyState.KEY_UP, 0) + + +def requires_driver(func): + """Wraps any function that requires the interception driver to be installed + such that, if it is not installed, a `DriverNotFoundError` is raised""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if not INTERCEPTION_INSTALLED: + raise exceptions.DriverNotFoundError + return func(*args, **kwargs) + + return wrapper + + +@requires_driver +def move_to(x: int | tuple[int, int], y: Optional[int] = None) -> None: + """Moves to a given absolute (x, y) location on the screen. + + The paramters can be passed as a tuple-like `(x, y)` coordinate or + seperately as `x` and `y` coordinates, it will be parsed accordingly. + + Due to conversion to the coordinate system the interception driver + uses, an offset of 1 pixel in either x or y axis may occur or not. + + ### Examples: + ```py + # passing x and y seperately, typical when manually calling the function + interception.move_to(800, 1200) + + # passing a tuple-like coordinate, typical for dynamic operations. + # simply avoids having to unpack the arguments. + target_location = (1200, 300) + interception.move_to(target_location) + ``` + """ + x, y = _utils.normalize(x, y) + x, y = _utils.to_interception_coordinate(x, y) + + stroke = MouseStroke(0, MouseFlag.MOUSE_MOVE_ABSOLUTE, 0, x, y, 0) + interception.send_mouse(stroke) + + +@requires_driver +def move_relative(x: int = 0, y: int = 0) -> None: + """Moves relatively from the current cursor position by the given amounts. + + Due to conversion to the coordinate system the interception driver + uses, an offset of 1 pixel in either x or y axis may occur or not. + + ### Example: + ```py + interception.mouse_position() + >>> 300, 400 + + # move the mouse by 100 pixels on the x-axis and 0 in y-axis + interception.move_relative(100, 0) + interception.mouse_position() + >>> 400, 400 + """ + stroke = MouseStroke(0, MouseFlag.MOUSE_MOVE_RELATIVE, 0, x, y, 0) + interception.send_mouse(stroke) + + +def mouse_position() -> tuple[int, int]: + """Returns the current position of the cursor as `(x, y)` coordinate. + + This does nothing special like other conventional mouse position functions. + """ + return _utils.get_cursor_pos() + + +@requires_driver +def click( + x: Optional[int | tuple[int, int]] = None, + y: Optional[int] = None, + button: MouseButton | str = "left", + clicks: int = 1, + interval: int | float = 0.1, + delay: int | float = 0.3, +) -> None: + """Presses a mouse button at a specific location (if given). + + Parameters + ---------- + button :class:`Literal["left", "right", "middle", "mouse4", "mouse5"] | str`: + The button to click once moved to the location (if passed), default "left". + + clicks :class:`int`: + The amount of mouse clicks to perform with the given button, default 1. + + interval :class:`int | float`: + The interval between multiple clicks, only applies if clicks > 1, default 0.1. + + delay :class:`int | float`: + The delay between moving and clicking, default 0.3. + """ + if x is not None: + move_to(x, y) + time.sleep(delay) + + for _ in range(clicks): + mouse_down(button) + mouse_up(button) + + if clicks > 1: + time.sleep(interval) + + +# decided against using functools.partial for left_click and right_click +# because it makes it less clear that the method attribute is a function +# and might be misunderstood. It also still allows changing the button +# argument afterall - just adds the correct default. +@requires_driver +def left_click(clicks: int = 1, interval: int | float = 0.1) -> None: + """Thin wrapper for the `click` function with the left mouse button.""" + click(button="left", clicks=clicks, interval=interval) + + +@requires_driver +def right_click(clicks: int = 1, interval: int | float = 0.1) -> None: + """Thin wrapper for the `click` function with the right mouse button.""" + click(button="right", clicks=clicks, interval=interval) + + +@requires_driver +def press(key: str, presses: int = 1, interval: int | float = 0.1) -> None: + """Presses a given key, for mouse buttons use the`click` function. + + Parameters + ---------- + key :class:`str`: + The key to press, not case sensitive. + + presses :class:`int`: + The amount of presses to perform with the given key, default 1. + + interval :class:`int | float`: + The interval between multiple presses, only applies if presses > 1, defaul 0.1. + """ + for _ in range(presses): + key_down(key) + key_up(key) + if presses > 1: + time.sleep(interval) + + +@requires_driver +def write(term: str, interval: int | float = 0.05) -> None: + """Writes a term by sending each key one after another. + + Uppercase characters are not currently supported, the term will + come out as lowercase. + + Parameters + ---------- + term :class:`str`: + The term to write. + + interval :class:`int | float`: + The interval between the different characters, default 0.05. + """ + for c in term.lower(): + press(c) + time.sleep(interval) + + +@requires_driver +def scroll(direction: Literal["up", "down"]) -> None: + """Scrolls the mouse wheel one unit in a given direction.""" + if direction == "up": + rolling = MouseRolling.MOUSE_WHEEL_UP + else: + rolling = MouseRolling.MOUSE_WHEEL_DOWN + + stroke = MouseStroke(MouseState.MOUSE_WHEEL, 0, rolling, 0, 0, 0) + interception.send_mouse(stroke) + time.sleep(0.025) + + +@requires_driver +def key_down(key: str, delay: Optional[float | int] = None) -> None: + """Updates the state of the given key to be `down`. + + To release the key automatically, consider using the `hold_key` contextmanager. + + ### Parameters: + ---------- + key :class: `str`: + The key to hold down. + + delay :class: `Optional[float | int]`: + The amount of time to wait after updating the key state. + + ### Raises: + `UnknownKeyError` if the given key is not supported. + """ + keycode = _get_keycode(key) + stroke = KeyStroke(keycode, KeyState.KEY_DOWN, 0) + interception.send_key(stroke) + time.sleep(delay or KEY_PRESS_DELAY) + + +@requires_driver +def key_up(key: str, delay: Optional[float | int] = None) -> None: + """Updates the state of the given key to be `up`. + + ### Parameters: + ---------- + key :class: `str`: + The key to release. + + delay :class: `Optional[float | int]`: + The amount of time to wait after updating the key state. + + ### Raises: + `UnknownKeyError` if the given key is not supported. + """ + keycode = _get_keycode(key) + stroke = KeyStroke(keycode, KeyState.KEY_UP, 0) + interception.send_key(stroke) + time.sleep(delay or KEY_PRESS_DELAY) + + +@requires_driver +def mouse_down(button: MouseButton, delay: Optional[float] = None) -> None: + """Holds a mouse button down, will not be released automatically. + + If you want to hold a mouse button while performing an action, please use + `hold_mouse`, which offers a context manager. + """ + button_state = _get_button_states(button, down=True) + stroke = MouseStroke(button_state, MouseFlag.MOUSE_MOVE_ABSOLUTE, 0, 0, 0, 0) + interception.send_mouse(stroke) + time.sleep(delay or MOUSE_BUTTON_DELAY) + + +@requires_driver +def mouse_up(button: MouseButton, delay: Optional[float] = None) -> None: + """Releases a mouse button.""" + button_state = _get_button_states(button, down=False) + stroke = MouseStroke(button_state, MouseFlag.MOUSE_MOVE_ABSOLUTE, 0, 0, 0, 0) + interception.send_mouse(stroke) + time.sleep(delay or MOUSE_BUTTON_DELAY) + + +@requires_driver +@contextmanager +def hold_mouse(button: MouseButton): + """Holds a mouse button down while performing another action. + + ### Example: + ```py + with interception.hold_mouse("left"): + interception.move_to(300, 300) + """ + mouse_down(button=button) + try: + yield + finally: + mouse_up(button=button) + + +@requires_driver +@contextmanager +def hold_key(key: str): + """Hold a key down while performing another action. + + ### Example: + ```py + with interception.hold_key("ctrl"): + interception.press("c") + """ + key_down(key) + try: + yield + finally: + key_up(key) + + +@requires_driver +def capture_keyboard() -> None: + """Captures keyboard keypresses until the `Escape` key is pressed. + + Filters out non `KEY_DOWN` events to not post the same capture twice. + """ + context = Interception() + context.set_filter(context.is_keyboard, FilterKeyState.FILTER_KEY_DOWN) + print("Capturing keyboard presses, press ESC to quit.") + + _listen_to_events(context, "esc") + print("No longer intercepting mouse events.") + + +@requires_driver +def capture_mouse() -> None: + """Captures mouse left clicks until the `Escape` key is pressed. + + Filters out non `LEFT_BUTTON_DOWN` events to not post the same capture twice. + """ + context = Interception() + context.set_filter(context.is_mouse, FilterMouseState.FILTER_MOUSE_LEFT_BUTTON_DOWN) + context.set_filter(context.is_keyboard, FilterKeyState.FILTER_KEY_DOWN) + print("Intercepting mouse left clicks, press ESC to quit.") + + _listen_to_events(context, "esc") + print("No longer intercepting mouse events.") + + +@requires_driver +def auto_capture_devices( + *, keyboard: bool = True, mouse: bool = True, verbose: bool = False +) -> None: + """Uses pynputs keyboard and mouse listener to check whether a device + number will send a valid input. During this process, each possible number + for the device is tried - once a working number is found, it is assigned + to the context and the it moves to the next device. + + ### Parameters: + -------------- + keyboard :class:`bool`: + Capture the keyboard number. + + mouse :class:`bool`: + Capture the mouse number. + + verbose :class:`bool`: + Provide output regarding the tested numbers. + """ + + def log(info: str) -> None: + if verbose: + print(info) + + mouse_listener = MouseListener(on_click=lambda *args: False) + key_listener = KeyListener(on_release=lambda *args: False) + + for device in ("keyboard", "mouse"): + if (device == "keyboard" and not keyboard) or (device == "mouse" and not mouse): + continue + log(f"Trying {device} device numbers...") + stroke: Stroke + if device == "mouse": + listener, stroke, nums = mouse_listener, _TEST_MOUSE_STROKE, range(10, 20) + else: + listener, stroke, nums = key_listener, _TEST_KEY_STROKE, range(10) + + listener.start() + for num in nums: + interception.send(num, stroke) + time.sleep(random.uniform(0.1, 0.3)) + if listener.is_alive(): + log(f"No success on {device} {num}...") + continue + log(f"Success on {device} {num}!") + set_devices(**{device: num}) + break + + log("Devices set.") + +@requires_driver +def set_devices(keyboard: Optional[int] = None, mouse: Optional[int] = None) -> None: + """Sets the devices on the current context. Keyboard devices should be from 0 to 10 + and mouse devices from 10 to 20 (both non-inclusive). + + If a device out of range is passed, the context will raise a `ValueError`. + """ + interception.keyboard = keyboard or interception.keyboard + interception.mouse = mouse or interception.mouse + + +def _listen_to_events(context: Interception, stop_button: str) -> None: + """Listens to a given interception context. Stops when the `stop_button` is + the event key. + + Remember to destroy the context in any case afterwards. Otherwise events + will continue to be intercepted!""" + stop = _get_keycode(stop_button) + try: + while True: + device = context.wait() + stroke = context.receive(device) + + if context.is_keyboard(device) and stroke.code == stop: + return + + print(f"Received stroke {stroke} on mouse device {device}") + context.send(device, stroke) + finally: + context.destroy() + + +def _get_keycode(key: str) -> int: + try: + return KEYBOARD_MAPPING[key] + except KeyError: + raise exceptions.UnknownKeyError(key) + + +def _get_button_states(button: str, *, down: bool) -> int: + try: + states = MouseState.from_string(button) + return states[not down] # first state is down, second state is up + except KeyError: + raise exceptions.UnknownButtonError(button) diff --git a/src/interception/interception.py b/src/interception/interception.py new file mode 100644 index 0000000..c961b76 --- /dev/null +++ b/src/interception/interception.py @@ -0,0 +1,163 @@ +from ctypes import Array, c_void_p, windll +from typing import Final + +from .device import Device +from .strokes import Stroke + +MAX_DEVICES: Final = 20 +MAX_KEYBOARD: Final = 10 +MAX_MOUSE: Final = 10 + +k32 = windll.LoadLibrary("kernel32") + + +class Interception: + def __init__(self) -> None: + self._context: list[Device] = [] + self._c_events: Array[c_void_p] = (c_void_p * MAX_DEVICES)() + self._mouse = 11 + self._keyboard = 1 + + try: + self.build_handles() + except Exception as e: + self.destroy() + raise e + + @property + def mouse(self) -> int: + return self._mouse + + @mouse.setter + def mouse(self, num: int) -> None: + if self.is_invalid(num) or not self.is_mouse(num): + raise ValueError(f"{num} is not a valid mouse number (10 <= mouse <= 19).") + self._mouse = num + + @property + def keyboard(self) -> int: + return self._keyboard + + @keyboard.setter + def keyboard(self, num: int) -> None: + if self.is_invalid(num) or not self.is_keyboard(num): + raise ValueError( + f"{num} is not a valid keyboard number (0 <= keyboard <= 9)." + ) + self._keyboard = num + + def build_handles(self) -> None: + """Creates handles and events for all interception devices. + + Iterates over all interception devices and creates a `Device` object for each one. + A `Device` object represents an interception device and includes a handle to the device, + an event that can be used to wait for input on the device, and a flag indicating whether + the device is a keyboard or a mouse. + + The handle is created using the `create_device_handle()` method, which calls the Windows API + function `CreateFileA()` with the appropriate parameters. + + The event is created using the Windows API function `CreateEventA()`, which creates a + synchronization event that can be signaled when input is available on the device. + + The `is_keyboard()` method is called to determine whether the device is a keyboard or a mouse. + This is used to set the `is_keyboard` flag on the Device object. + + The created Device objects are added to the context list and the corresponding event + handle is added to the c_events dictionary. + + Raises: + IOError: If a device handle cannot be created. + """ + for device_num in range(MAX_DEVICES): + device = Device( + self.create_device_handle(device_num), + k32.CreateEventA(0, 1, 0, 0), + is_keyboard=self.is_keyboard(device_num), + ) + self._context.append(device) + self._c_events[device_num] = device.event + + def wait(self, milliseconds: int = -1): + """Waits for input on any interception device. + + Calls the `WaitForMultipleObjects()` Windows API function to wait for input on any of the + interception devices. The function will block until input is available on one of the devices + or until the specified timeout period (in milliseconds) has elapsed. If `milliseconds` is + not specified or is negative, the function will block indefinitely until input is available. + + If input is available on a device, the function will return the index of the device in the + `_c_events` dictionary, indicating which device received input. If the timeout period + elapses before input is available, the function will return 0. If an error occurs, the function + will raise an OSError. + + Raises: + OSError: If an error occurs while waiting for input. + """ + result = k32.WaitForMultipleObjects( + MAX_DEVICES, self._c_events, 0, milliseconds + ) + if result in [-1, 0x102]: + return 0 + return result + + def get_HWID(self, device: int): + """Returns the HWID of a device in the context""" + if self.is_invalid(device): + return "" + try: + return self._context[device].get_HWID().decode("utf-16") + except: + return "" + + def receive(self, device: int): + if not self.is_invalid(device): + return self._context[device].receive() + + def send(self, device: int, stroke: Stroke) -> None: + self._context[device].send(stroke) + + def send_key(self, stroke: Stroke) -> None: + self._context[self._keyboard].send(stroke) + + def send_mouse(self, stroke: Stroke) -> None: + self._context[self._mouse].send(stroke) + + def set_filter(self, predicate, filter): + for i in range(MAX_DEVICES): + if predicate(i): + result = self._context[i].set_filter(filter) + + @staticmethod + def is_keyboard(device): + return device + 1 > 0 and device + 1 <= MAX_KEYBOARD + + @staticmethod + def is_mouse(device): + return device + 1 > MAX_KEYBOARD and device + 1 <= MAX_KEYBOARD + MAX_MOUSE + + @staticmethod + def is_invalid(device): + return device + 1 <= 0 or device + 1 > (MAX_KEYBOARD + MAX_MOUSE) + + @staticmethod + def create_device_handle(device_num: int): + """Creates a handle to a specified device. + + Access mode for the device is `GENERIC_READ | GENERIC_WRITE`, allows the + handle to read and write to the device. + + Sharing mode for the device is `FILE_SHARE_READ | FILE_SHARE_WRITE`, which + allows other processes to read from and write to the device while it is open. + + Creation disposition for the device is `OPEN_EXISTING`, indicating that the device + should be opened if it already exists. + + Flags and attributes for the device are not used in this case. + """ + device_name = f"\\\\.\\interception{device_num:02d}".encode() + return k32.CreateFileA(device_name, 0x80000000, 0, 0, 3, 0, 0) + + def destroy(self) -> None: + for device in self._context: + device.destroy() diff --git a/src/interception/py.typed b/src/interception/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/interception/strokes.py b/src/interception/strokes.py new file mode 100644 index 0000000..acd0a19 --- /dev/null +++ b/src/interception/strokes.py @@ -0,0 +1,102 @@ +import struct +from typing import Protocol, ClassVar +from dataclasses import dataclass + + +@dataclass +class Stroke(Protocol): + fmt: ClassVar + fmt_raw: ClassVar + + @classmethod + def parse(cls, self): + ... + + @classmethod + def parse_raw(cls, self): + ... + + @property + def data(self): + ... + + @property + def raw_data(self): + ... + + +@dataclass +class MouseStroke: + fmt: ClassVar = "HHhiiI" + fmt_raw: ClassVar = "HHHHIiiI" + + state: int + flags: int + rolling: int + x: int + y: int + information: int + + @classmethod + def parse(cls, data): + return cls(*struct.unpack(cls.fmt, data)) + + @classmethod + def parse_raw(cls, data): + unpacked = struct.unpack(cls.fmt_raw, data) + return cls(*(unpacked[i] for i in (2, 1, 3, 5, 6, 7))) + + @property + def data(self) -> bytes: + return struct.pack( + self.fmt, + self.state, + self.flags, + self.rolling, + self.x, + self.y, + self.information, + ) + + @property + def raw_data(self) -> bytes: + return struct.pack( + self.fmt_raw, + 0, + self.flags, + self.state, + self.rolling, + 0, + self.x, + self.y, + self.information, + ) + + +@dataclass +class KeyStroke: + fmt: ClassVar = "HHI" + fmt_raw: ClassVar = "HHHHI" + + code: int + state: int + information: int + + @classmethod + def parse(cls, data): + return cls(*struct.unpack(cls.fmt, data)) + + @classmethod + def parse_raw(cls, data): + unpacked = struct.unpack(cls.fmt_raw, data) + return cls(unpacked[1], unpacked[2], unpacked[4]) + + @property + def data(self): + data = struct.pack(self.fmt, self.code, self.state, self.information) + return data + + @property + def raw_data(self): + data = struct.pack(self.fmt_raw, 0, self.code, self.state, 0, self.information) + return data diff --git a/src/interception/types.py b/src/interception/types.py new file mode 100644 index 0000000..dae3a75 --- /dev/null +++ b/src/interception/types.py @@ -0,0 +1,3 @@ +from typing import Literal + +MouseButton = str | Literal["left", "right", "middle", "mouse4", "mouse5"] diff --git a/stroke.py b/stroke.py deleted file mode 100644 index d1da17f..0000000 --- a/stroke.py +++ /dev/null @@ -1,105 +0,0 @@ -import struct - -class stroke(): - - @property - def data(self): - raise NotImplementedError - - @property - def data_raw(self): - raise NotImplementedError - - -class mouse_stroke(stroke): - - fmt = 'HHhiiI' - fmt_raw = 'HHHHIiiI' - state = 0 - flags = 0 - rolling = 0 - x = 0 - y = 0 - information = 0 - - def __init__(self,state,flags,rolling,x,y,information): - super().__init__() - self.state =state - self.flags = flags - self.rolling = rolling - self.x = x - self.y = y - self.information = information - - @staticmethod - def parse(data): - return mouse_stroke(*struct.unpack(mouse_stroke.fmt,data)) - - @staticmethod - def parse_raw(data): - unpacked= struct.unpack(mouse_stroke.fmt_raw,data) - return mouse_stroke( - unpacked[2], - unpacked[1], - unpacked[3], - unpacked[5], - unpacked[6], - unpacked[7]) - - @property - def data(self): - data = struct.pack(self.fmt, - self.state, - self.flags, - self.rolling, - self.x, - self.y, - self.information) - return data - - @property - def data_raw(self): - data = struct.pack(self.fmt_raw, - 0, - self.flags, - self.state, - self.rolling, - 0, - self.x, - self.y, - self.information) - - return data - -class key_stroke(stroke): - - fmt = 'HHI' - fmt_raw = 'HHHHI' - code = 0 - state = 0 - information = 0 - - def __init__(self,code,state,information): - super().__init__() - self.code = code - self.state = state - self.information = information - - - @staticmethod - def parse(data): - return key_stroke(*struct.unpack(key_stroke.fmt,data)) - - @staticmethod - def parse_raw(data): - unpacked= struct.unpack(key_stroke.fmt_raw,data) - return key_stroke(unpacked[1],unpacked[2],unpacked[4]) - - @property - def data(self): - data = struct.pack(self.fmt,self.code,self.state,self.information) - return data - @property - def data_raw(self): - data = struct.pack(self.fmt_raw,0,self.code,self.state,0,self.information) - return data \ No newline at end of file