From 8d23593c89f8e1b359e08b7af75d8c305339f268 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sat, 28 Nov 2020 17:49:32 +0100 Subject: [PATCH] macro parser --- README.md | 6 +- bin/key-mapper-gtk | 8 +- bin/key-mapper-service | 7 ++ keymapper/dev/injector.py | 22 ++++- keymapper/dev/macros.py | 191 +++++++++++++++++++++++++++++++------- tests/testcases/macros.py | 44 +++++++++ 6 files changed, 237 insertions(+), 41 deletions(-) create mode 100644 tests/testcases/macros.py diff --git a/README.md b/README.md index 9138bc27..1a92751f 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ key-mapper-service ## Macros It is possible to write timed macros into the center column: -- `r(3, k("a").w(10))` aaa -- `r(2, k("a").k("-")).k("b")` a-a-b -- `w(1000).m("SHIFT_L", r(2, k("a"))).w(10, 20).k("b")` AAb +- `r(3, k(a).w(10))` aaa +- `r(2, k(a).k(-)).k(b)` a-a-b +- `w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)` AAb Documentation: - `r` repeats diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index 5c845280..04da1ce4 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -24,7 +24,6 @@ import sys import atexit -import getpass from argparse import ArgumentParser import gi @@ -38,6 +37,13 @@ from keymapper.daemon import Daemon from keymapper.dev.permissions import can_read_devices +try: + from rich.traceback import install + install(show_locals=True) +except ImportError: + pass + + if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( diff --git a/bin/key-mapper-service b/bin/key-mapper-service index 7d96a5dd..7df401c8 100755 --- a/bin/key-mapper-service +++ b/bin/key-mapper-service @@ -38,6 +38,13 @@ from keymapper.daemon import Daemon from keymapper.dev.permissions import can_read_devices +try: + from rich.traceback import install + install(show_locals=True) +except ImportError: + pass + + if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( diff --git a/keymapper/dev/injector.py b/keymapper/dev/injector.py index 7626e1fe..7f9469a5 100644 --- a/keymapper/dev/injector.py +++ b/keymapper/dev/injector.py @@ -22,6 +22,7 @@ """Keeps injecting keycodes in the background based on the mapping.""" +import os import re import asyncio import time @@ -225,8 +226,6 @@ class KeycodeInjector: where to read keycodes from keymapper_device : evdev.UInput where to write keycodes to - mapping : Mapping - to figure out which keycodes to write """ logger.debug( 'Started injecting into %s, fd %s', @@ -250,6 +249,17 @@ class KeycodeInjector: if character is None: # unknown keycode, forward it target_keycode = input_keycode + elif '(' in character: + # must be a macro. Only allow it if the injector is not + # running as root. + if os.geteuid() == 0: + logger.error( + 'Cannot allow running macros as root to avoid ' + 'injecting arbitrary code' + ) + continue + + Macro() else: target_keycode = system_mapping.get_keycode(character) if target_keycode is None: @@ -274,6 +284,14 @@ class KeycodeInjector: ) keymapper_device.syn() + # this should only ever happen in tests to avoid blocking them + # forever, as soon as all events are consumed. In normal operation + # there is no end to the events. + logger.error( + 'The injector for "%s" stopped early', + keymapper_device.device.path + ) + @ensure_numlock def stop_injecting(self): """Stop injecting keycodes.""" diff --git a/keymapper/dev/macros.py b/keymapper/dev/macros.py index 4c8cd279..b9d1fc25 100644 --- a/keymapper/dev/macros.py +++ b/keymapper/dev/macros.py @@ -39,27 +39,18 @@ w(1000).m('SHIFT_L', r(2, k('a'))).w(10).k('b'): <1s> 'A' 'A' <10ms> 'b' import time +import re import random +try: + from rich.traceback import install + install(show_locals=True) +except ImportError: + pass -# TODO parse securely - - -def m(*args): - return Macro().m(*args) - - -def r(*args): - return Macro().r(*args) - - -def k(*args): - return Macro().k(*args) - - -def w(*args): - return Macro().w(*args) +from keymapper.logger import logger +logger.setLevel(5) class Macro: """Supports chaining and preparing actions.""" @@ -76,7 +67,7 @@ class Macro: """Stop the macro.""" # TODO - def m(self, modifier, macro): + def modify(self, modifier, macro): """Do stuff while a modifier is activated. Parameters @@ -89,7 +80,7 @@ class Macro: # TODO release modifier return self - def r(self, repeats, macro): + def repeat(self, repeats, macro): """Repeat actions. Parameters @@ -101,30 +92,160 @@ class Macro: self.tasks.append(macro.run) return self - def k(self, character): + def keycode(self, character): """Write the character.""" # TODO write character self.tasks.append(lambda: print(character)) return self - def w(self, min, max=None): + def wait(self, min, max=None): """Wait a random time in milliseconds""" # TODO random self.tasks.append(lambda: time.sleep(min / 1000)) return self -# TODO make these into tests - -print() -r(3, k('a').w(200)).run() - -print() -r(2, k('a').k('-')).k('b').run() - -print() -w(400).m('SHIFT_L', r(2, k('a'))).w(10).k('b').run() - -print() -# prints nothing yet -k('a').r(3, k('b')) +def parse(macro): + """parse and generate a Macro that can be run as often as you want. + + Parameters + ---------- + macro : string + "r(3, k(a).w(10))" + "r(2, k(a).k(-)).k(b)" + "w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)" + """ + try: + return parse_recurse(macro) + except Exception as e: + logger.error(e) + # parsing unsuccessful + return None + + +def extract_params(inner): + """Extract parameters from the inner contents of a call. + + Parameters + ---------- + inner : string + for example 'r, r(2, k(a))' should result in ['r', 'r(2, k(a)'] + """ + inner = inner.strip() + brackets = 0 + params = [] + start = 0 + for position, char in enumerate(inner): + if char == '(': + brackets += 1 + if char == ')': + brackets -= 1 + if (char == ',') and brackets == 0: + # , potentially starts another parameter, but only if + # the current brackets are all closed. + params.append(inner[start:position].strip()) + # skip the comma + start = position + 1 + + if brackets == 0 and start != len(inner): + # one last parameter + params.append(inner[start:].strip()) + + return params + + +def parse_recurse(macro, macro_instance=None, depth=0): + """Handle a subset of the macro, e.g. one parameter or function call. + + Parameters + ---------- + macro : string + Just like parse + macro_instance : Macro or None + A macro instance to add tasks to + depth : int + For logging and debugging purposes + """ + # to anyone who knows better about compilers and thinks this is horrible: + # please make a pull request. Because it probably is. + # not using eval for security reasons ofc. And this syntax doesn't need + # string quotes for its params. + if macro_instance is None: + macro_instance = Macro() + + macro = macro.strip() + logger.spam('%sinput %s', ' ' * depth, macro) + space = ' ' * depth + + # is it another macro? + call_match = re.match(r'^(\w+)\(.+?', macro) + call = call_match[1] if call_match else None + if call is not None: + # available functions in the macro + functions = { + 'm': macro_instance.modify, + 'r': macro_instance.repeat, + 'k': macro_instance.keycode, + 'w': macro_instance.wait + } + + if functions.get(call) is None: + logger.error(f'Unknown function %s', call) + + # get all the stuff inbetween + brackets = 0 + position = 0 + for char in macro: + position += 1 + + if char == '(': + brackets += 1 + continue + + if char == ')': + brackets -= 1 + if brackets < 0: + logger.error(f'There is one ")" too much at %s', position) + return + if brackets == 0: + # the closing bracket of the call + break + + if brackets != 0: + logger.error(f'There are %s closing brackets missing', brackets) + + inner = macro[2:position - 1] + + # split "3, k(a).w(10)" into parameters + string_params = extract_params(inner) + logger.spam('%scalls %s with %s', space, call, string_params) + # evaluate the params + params = [ + parse_recurse(param.strip(), None, depth + 1) + for param in string_params + ] + + logger.spam('%scalling %s with %s', space, call, params) + functions[call](*params) + + # is after this another call? Chain it to the macro_instance + if len(macro) > position and macro[position] == '.': + chain = macro[position + 1:] + logger.spam('%sfollowed by %s', space, chain) + parse_recurse(chain, macro_instance, depth) + + return macro_instance + else: + # probably a parameter for an outer function + try: + macro = int(macro) + except ValueError: + pass + return macro + + +parse("k(1).k(2).k(3)").run() +parse("r(1, k(2))").run() +parse("r(3, k(a).w(10))").run() +parse("r(2, k(a).k(-)).k(b)").run() +parse("w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)").run() diff --git a/tests/testcases/macros.py b/tests/testcases/macros.py new file mode 100644 index 00000000..6769d272 --- /dev/null +++ b/tests/testcases/macros.py @@ -0,0 +1,44 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2020 sezanzeb +# +# This file is part of key-mapper. +# +# key-mapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# key-mapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with key-mapper. If not, see . + + +import unittest + +from keymapper.dev.macros import Macro, k, m, r, w + + +class TestMacros(unittest.TestCase): + def test_1(self): + r(3, k('a').w(200)).run() + + def test_2(self): + r(2, k('a').k('-')).k('b').run() + + def test_3(self): + w(400).m('SHIFT_L', r(2, k('a'))).w(10).k('b').run() + + def test_4(self): + # prints nothing without .run + k('a').r(3, k('b')) + + + +if __name__ == "__main__": + unittest.main()