macro parser

pull/14/head
sezanzeb 4 years ago
parent 7134083e5b
commit 8d23593c89

@ -29,9 +29,9 @@ key-mapper-service
## Macros ## Macros
It is possible to write timed macros into the center column: It is possible to write timed macros into the center column:
- `r(3, k("a").w(10))` aaa - `r(3, k(a).w(10))` aaa
- `r(2, k("a").k("-")).k("b")` a-a-b - `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 - `w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)` AAb
Documentation: Documentation:
- `r` repeats - `r` repeats

@ -24,7 +24,6 @@
import sys import sys
import atexit import atexit
import getpass
from argparse import ArgumentParser from argparse import ArgumentParser
import gi import gi
@ -38,6 +37,13 @@ from keymapper.daemon import Daemon
from keymapper.dev.permissions import can_read_devices from keymapper.dev.permissions import can_read_devices
try:
from rich.traceback import install
install(show_locals=True)
except ImportError:
pass
if __name__ == '__main__': if __name__ == '__main__':
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument( parser.add_argument(

@ -38,6 +38,13 @@ from keymapper.daemon import Daemon
from keymapper.dev.permissions import can_read_devices from keymapper.dev.permissions import can_read_devices
try:
from rich.traceback import install
install(show_locals=True)
except ImportError:
pass
if __name__ == '__main__': if __name__ == '__main__':
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument( parser.add_argument(

@ -22,6 +22,7 @@
"""Keeps injecting keycodes in the background based on the mapping.""" """Keeps injecting keycodes in the background based on the mapping."""
import os
import re import re
import asyncio import asyncio
import time import time
@ -225,8 +226,6 @@ class KeycodeInjector:
where to read keycodes from where to read keycodes from
keymapper_device : evdev.UInput keymapper_device : evdev.UInput
where to write keycodes to where to write keycodes to
mapping : Mapping
to figure out which keycodes to write
""" """
logger.debug( logger.debug(
'Started injecting into %s, fd %s', 'Started injecting into %s, fd %s',
@ -250,6 +249,17 @@ class KeycodeInjector:
if character is None: if character is None:
# unknown keycode, forward it # unknown keycode, forward it
target_keycode = input_keycode 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: else:
target_keycode = system_mapping.get_keycode(character) target_keycode = system_mapping.get_keycode(character)
if target_keycode is None: if target_keycode is None:
@ -274,6 +284,14 @@ class KeycodeInjector:
) )
keymapper_device.syn() 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 @ensure_numlock
def stop_injecting(self): def stop_injecting(self):
"""Stop injecting keycodes.""" """Stop injecting keycodes."""

@ -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 time
import re
import random import random
try:
from rich.traceback import install
install(show_locals=True)
except ImportError:
pass
# TODO parse securely from keymapper.logger import logger
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)
logger.setLevel(5)
class Macro: class Macro:
"""Supports chaining and preparing actions.""" """Supports chaining and preparing actions."""
@ -76,7 +67,7 @@ class Macro:
"""Stop the macro.""" """Stop the macro."""
# TODO # TODO
def m(self, modifier, macro): def modify(self, modifier, macro):
"""Do stuff while a modifier is activated. """Do stuff while a modifier is activated.
Parameters Parameters
@ -89,7 +80,7 @@ class Macro:
# TODO release modifier # TODO release modifier
return self return self
def r(self, repeats, macro): def repeat(self, repeats, macro):
"""Repeat actions. """Repeat actions.
Parameters Parameters
@ -101,30 +92,160 @@ class Macro:
self.tasks.append(macro.run) self.tasks.append(macro.run)
return self return self
def k(self, character): def keycode(self, character):
"""Write the character.""" """Write the character."""
# TODO write character # TODO write character
self.tasks.append(lambda: print(character)) self.tasks.append(lambda: print(character))
return self return self
def w(self, min, max=None): def wait(self, min, max=None):
"""Wait a random time in milliseconds""" """Wait a random time in milliseconds"""
# TODO random # TODO random
self.tasks.append(lambda: time.sleep(min / 1000)) self.tasks.append(lambda: time.sleep(min / 1000))
return self return self
# TODO make these into tests def parse(macro):
"""parse and generate a Macro that can be run as often as you want.
print()
r(3, k('a').w(200)).run() Parameters
----------
print() macro : string
r(2, k('a').k('-')).k('b').run() "r(3, k(a).w(10))"
"r(2, k(a).k(-)).k(b)"
print() "w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)"
w(400).m('SHIFT_L', r(2, k('a'))).w(10).k('b').run() """
try:
print() return parse_recurse(macro)
# prints nothing yet except Exception as e:
k('a').r(3, k('b')) 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()

@ -0,0 +1,44 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
# 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 <https://www.gnu.org/licenses/>.
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()
Loading…
Cancel
Save