From 5e4ae668ddd6d1f6add2d56041bf8bb3fcc7b980 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 21 Mar 2021 19:15:20 +0100 Subject: [PATCH] gtk not running as root anymore --- .coveragerc | 3 + .github/ISSUE_TEMPLATE/bug-report.md | 2 +- bin/key-mapper-control | 77 +++- bin/key-mapper-gtk | 5 +- bin/key-mapper-gtk-pkexec | 4 - bin/key-mapper-helper | 58 +++ bin/key-mapper-service | 37 +- data/key-mapper.desktop | 2 +- data/key-mapper.policy | 8 +- keymapper/config.py | 5 +- keymapper/daemon.py | 128 +++--- keymapper/getdevices.py | 12 +- keymapper/gui/helper.py | 209 ++++++++++ keymapper/gui/reader.py | 359 +++++----------- keymapper/gui/row.py | 38 +- keymapper/gui/window.py | 113 ++--- keymapper/injection/keycode_mapper.py | 51 +-- keymapper/ipc/__init__.py | 0 keymapper/ipc/pipe.py | 144 +++++++ keymapper/ipc/readme.md | 7 + keymapper/ipc/socket.py | 297 ++++++++++++++ keymapper/logger.py | 58 +-- keymapper/paths.py | 53 +-- keymapper/permissions.py | 111 ----- keymapper/presets.py | 4 + keymapper/state.py | 16 +- keymapper/user.py | 65 +++ readme/development.md | 66 ++- readme/pylint.svg | 12 +- readme/usage.md | 12 +- setup.py | 26 +- tests/test.py | 232 +++++++---- tests/testcases/test_control.py | 50 ++- tests/testcases/test_daemon.py | 61 +-- tests/testcases/test_getdevices.py | 23 +- tests/testcases/test_injector.py | 79 ++-- tests/testcases/test_integration.py | 460 +++++++++++++++------ tests/testcases/test_ipc.py | 142 +++++++ tests/testcases/test_keycode_mapper.py | 8 +- tests/testcases/test_logger.py | 23 +- tests/testcases/test_paths.py | 34 +- tests/testcases/test_permissions.py | 166 -------- tests/testcases/test_reader.py | 548 +++++++++++++------------ tests/testcases/test_test.py | 52 ++- 44 files changed, 2440 insertions(+), 1420 deletions(-) delete mode 100755 bin/key-mapper-gtk-pkexec create mode 100755 bin/key-mapper-helper create mode 100644 keymapper/gui/helper.py create mode 100644 keymapper/ipc/__init__.py create mode 100644 keymapper/ipc/pipe.py create mode 100644 keymapper/ipc/readme.md create mode 100644 keymapper/ipc/socket.py delete mode 100644 keymapper/permissions.py create mode 100644 keymapper/user.py create mode 100644 tests/testcases/test_ipc.py delete mode 100644 tests/testcases/test_permissions.py diff --git a/.coveragerc b/.coveragerc index cb734e59..b0114cbc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,3 +3,6 @@ branch = True source = /usr/lib/python3.9/site-packages/keymapper concurrency = multiprocessing debug = multiproc +omit = + # not used currently due to problems + /usr/lib/python3.9/site-packages/keymapper/ipc/socket.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 20f10ef2..67785cfe 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -11,4 +11,4 @@ Please install the newest version from source to see if the problem has already If a button on your device doesn't show up in the gui, verify that the button is reporting an event via `sudo evtest`. If not, key-mapper won't be able to map that button. -If yes, please run `sudo systemctl stop key-mapper && sudo key-mapper-gtk -d`, reproduce the problem and then share the logs. +If yes, please run `sudo systemctl stop key-mapper && key-mapper-gtk -d`, reproduce the problem and then share the logs. diff --git a/bin/key-mapper-control b/bin/key-mapper-control index 2b0e08f5..c3d8d351 100755 --- a/bin/key-mapper-control +++ b/bin/key-mapper-control @@ -25,11 +25,12 @@ import os import grp import sys -from argparse import ArgumentParser +import argparse +import subprocess -from keymapper.logger import logger +from keymapper.logger import logger, update_verbosity, log_info from keymapper.config import config -from keymapper.daemon import get_dbus_interface +from keymapper.daemon import Daemon from keymapper.state import system_mapping from keymapper.getdevices import get_devices from keymapper.paths import USER @@ -41,6 +42,10 @@ STOP = 'stop' STOP_ALL = 'stop-all' HELLO = 'hello' +# internal stuff that the gui uses +START_DAEMON = 'start-daemon' +HELPER = 'helper' + def run(cmd): """Run and log a command.""" @@ -59,9 +64,16 @@ def group_exists(name): return False +COMMANDS = [AUTOLOAD, START, STOP, HELLO, STOP_ALL] + +INTERNALS = [START_DAEMON, HELPER] + + def main(options, daemon): - """Do the stuff that the executable is supposed to do.""" - # `main` is a function so that I can import it and test it + """Do the stuff that the executable is supposed to do. + + Is a function so that I can import it and test it + """ if options.config_dir is not None: path = os.path.abspath(os.path.expanduser(os.path.join( options.config_dir, @@ -85,7 +97,7 @@ def main(options, daemon): print('\n'.join(system_mapping.list_names())) sys.exit(0) - if options.command not in [AUTOLOAD, START, STOP, HELLO, STOP_ALL]: + if options.command not in COMMANDS: logger.error('Unknown command "%s"', options.command) if daemon is None: @@ -114,7 +126,10 @@ def main(options, daemon): logger.error('--preset missing') sys.exit(1) - logger.info('Starting injection: "%s", "%s"', options.device, options.preset) + logger.info( + 'Starting injection: "%s", "%s"', + options.device, options.preset + ) daemon.start_injecting(options.device, options.preset) if options.command == STOP: @@ -132,8 +147,28 @@ def main(options, daemon): logger.info('Daemon answered with "%s"', response) +def internals(options): + """Methods that are needed to get the gui to work and that require root. + + key-mapper-control should be started with sudo or pkexec for this. + """ + if options.command == HELPER: + cmd = ['key-mapper-helper'] + elif options.command == START_DAEMON: + cmd = ['key-mapper-service', '--hide-info'] + else: + return + + if options.debug: + cmd.append('-d') + + # Popen makes os.system of the main process continue with stuff while + # cmd runs in the background. + subprocess.Popen(cmd) + + if __name__ == '__main__': - parser = ArgumentParser() + parser = argparse.ArgumentParser() parser.add_argument( '--command', action='store', dest='command', help='start, stop, autoload, hello or stop-all', @@ -171,9 +206,31 @@ if __name__ == '__main__': help='Print all available names for the mapping', default=False ) + parser.add_argument( + '-d', '--debug', action='store_true', dest='debug', + help='Displays additional debug information', + default=False + ) + parser.add_argument( + '-v', '--version', action='store_true', dest='version', + help='Print the version and exit', default=False + ) options = parser.parse_args(sys.argv[1:]) - daemon = get_dbus_interface(fallback=False) + if options.version: + log_info() + sys.exit(0) + + update_verbosity(options.debug) + + logger.debug('Call for "%s"', sys.argv) + + config.load_config() - main(options, daemon) + if options.command in INTERNALS: + options.debug = True + internals(options) + else: + daemon = Daemon.connect(fallback=False) + main(options, daemon) diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index f699e845..33e8a5df 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -47,11 +47,14 @@ if __name__ == '__main__': options = parser.parse_args(sys.argv[1:]) update_verbosity(options.debug) - log_info() + log_info('key-mapper-gtk') # import key-mapper stuff after setting the log verbosity from keymapper.gui.window import Window from keymapper.daemon import Daemon + from keymapper.daemon import config + + config.load_config() window = Window() diff --git a/bin/key-mapper-gtk-pkexec b/bin/key-mapper-gtk-pkexec deleted file mode 100755 index 2c0c4a95..00000000 --- a/bin/key-mapper-gtk-pkexec +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# This binary exists only to make key-mapper.desktop compatible to -# environments that can't handle arguments in Exec. -pkexec key-mapper-gtk diff --git a/bin/key-mapper-helper b/bin/key-mapper-helper new file mode 100755 index 00000000..8d96e13f --- /dev/null +++ b/bin/key-mapper-helper @@ -0,0 +1,58 @@ +#!/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 . + + +"""Starts the root helper.""" + + +import os +import sys +import atexit +import signal +from argparse import ArgumentParser + +from keymapper.logger import update_verbosity + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument( + '-d', '--debug', action='store_true', dest='debug', + help='Displays additional debug information', default=False + ) + + options = parser.parse_args(sys.argv[1:]) + + update_verbosity(options.debug) + + # import key-mapper stuff after setting the log verbosity + from keymapper.gui.helper import RootHelper + + def on_exit(): + """Don't remain idle and alive when the GUI exits via ctrl+c.""" + # makes no sense to me, but after the keyboard interrupt it is still + # waiting for an event to complete (`S` in `ps ax`), even when using + # sys.exit + os.kill(os.getpid(), signal.SIGKILL) + + atexit.register(on_exit) + + helper = RootHelper() + helper.run() diff --git a/bin/key-mapper-service b/bin/key-mapper-service index e01a403e..5e230beb 100755 --- a/bin/key-mapper-service +++ b/bin/key-mapper-service @@ -23,25 +23,21 @@ import sys -import atexit from argparse import ArgumentParser -import gi -gi.require_version('GLib', '2.0') -from gi.repository import GLib -from pydbus import SystemBus - from keymapper.logger import update_verbosity, log_info, \ - add_filehandler, logger -from keymapper.permissions import can_read_devices + add_filehandler if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( '-d', '--debug', action='store_true', dest='debug', - help='Displays additional debug information', - default=False + help='Displays additional debug information', default=False + ) + parser.add_argument( + '--hide-info', action='store_true', dest='hide_info', + help='Don\'t display version information', default=False ) options = parser.parse_args(sys.argv[1:]) @@ -49,23 +45,12 @@ if __name__ == '__main__': update_verbosity(options.debug) # import key-mapper stuff after setting the log verbosity - from keymapper.daemon import Daemon, BUS_NAME + from keymapper.daemon import Daemon add_filehandler() - log_info() - - can_read_devices() + if not options.hide_info: + log_info('key-mapper-service') - bus = SystemBus() - loop = GLib.MainLoop() daemon = Daemon() - - try: - bus.publish(BUS_NAME, daemon) - except RuntimeError as error: - logger.error('Is the service is already running? %s', str(error)) - sys.exit(1) - - atexit.register(daemon.stop_all) - - loop.run() + daemon.publish() + daemon.run() diff --git a/data/key-mapper.desktop b/data/key-mapper.desktop index 011acdd4..47a326ac 100644 --- a/data/key-mapper.desktop +++ b/data/key-mapper.desktop @@ -2,7 +2,7 @@ Type=Application Name=key-mapper Icon=/usr/share/key-mapper/key-mapper.svg -Exec=key-mapper-gtk-pkexec +Exec=key-mapper-gtk Terminal=false Categories=Settings Comment=GUI for device specific key mappings diff --git a/data/key-mapper.policy b/data/key-mapper.policy index ec1effe8..20ec0820 100644 --- a/data/key-mapper.policy +++ b/data/key-mapper.policy @@ -9,10 +9,10 @@ Authentication is required to discover and read devices. no - auth_admin - auth_admin + auth_admin_keep + auth_admin_keep - /usr/bin/key-mapper-gtk - true + /usr/bin/key-mapper-control + false diff --git a/keymapper/config.py b/keymapper/config.py index 3c891c21..c1eedcf0 100644 --- a/keymapper/config.py +++ b/keymapper/config.py @@ -156,7 +156,9 @@ class ConfigBase: if resolved is None and self.fallback is not None: resolved = self.fallback._resolve(path, callback) if resolved is None: - resolved = self._resolve(path, callback, INITIAL_CONFIG) + # don't create new empty stuff in INITIAL_CONFIG with _resolve + initial_copy = copy.deepcopy(INITIAL_CONFIG) + resolved = self._resolve(path, callback, initial_copy) if resolved is None and log_unknown: logger.error('Unknown config key "%s"', path) @@ -187,7 +189,6 @@ class GlobalConfig(ConfigBase): os.rename(os.path.join(CONFIG_PATH, 'config'), self.path) super().__init__() - self.load_config() def set_autoload_preset(self, device, preset): """Set a preset to be automatically applied on start. diff --git a/keymapper/daemon.py b/keymapper/daemon.py index f970dc7b..2653172b 100644 --- a/keymapper/daemon.py +++ b/keymapper/daemon.py @@ -26,15 +26,18 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/ex import os -import subprocess +import sys import json import time +import atexit import evdev from pydbus import SystemBus +import gi +gi.require_version('GLib', '2.0') from gi.repository import GLib -from keymapper.logger import logger +from keymapper.logger import logger, is_debug from keymapper.injection.injector import Injector, UNKNOWN from keymapper.mapping import Mapping from keymapper.config import config @@ -45,54 +48,6 @@ from keymapper.getdevices import get_devices, refresh_devices BUS_NAME = 'keymapper.Control' -def is_service_running(): - """Check if the daemon is running.""" - try: - subprocess.check_output(['pgrep', '-f', 'key-mapper-service']) - except subprocess.CalledProcessError: - return False - return True - - -def get_dbus_interface(fallback=True): - """Get an interface to start and stop injecting keystrokes. - - Parameters - ---------- - fallback : bool - If true, returns an instance of the daemon instead if it cannot - connect - """ - msg = ( - 'The daemon "key-mapper-service" is not running, mapping keys ' - 'only works as long as the window is open. ' - 'Try `sudo systemctl start key-mapper`' - ) - - if not is_service_running(): - if not fallback: - logger.error('Service not running') - return None - - logger.warning(msg) - return Daemon() - - try: - bus = SystemBus() - interface = bus.get(BUS_NAME) - except GLib.GError as error: - logger.debug(error) - - if not fallback: - logger.error('Failed to connect to the running service') - return None - - logger.warning(msg) - return Daemon() - - return interface - - def path_to_device_name(path): """Find the name of the get_devices group this path belongs to. @@ -218,6 +173,75 @@ class Daemon: self.autoload_history = AutoloadHistory() self.refreshed_devices_at = 0 + atexit.register(self.stop_all) + + @classmethod + def connect(cls, fallback=True): + """Get an interface to start and stop injecting keystrokes. + + Parameters + ---------- + fallback : bool + If true, returns an instance of the daemon instead if it cannot + connect + """ + try: + bus = SystemBus() + interface = bus.get(BUS_NAME) + logger.info('Connected to the service') + except GLib.GError as error: + if not fallback: + logger.error('Service not running? %s', error) + return None + + logger.info('Starting the service') + # Blocks until pkexec is done asking for the password. + # Runs via key-mapper-control so that auth_admin_keep works + # for all pkexec calls of the gui + cmd = 'pkexec key-mapper-control --command start-daemon' + if is_debug(): + cmd += ' -d' + + # using pkexec will also cause the service to continue running in + # the background after the gui has been closed, which will keep + # the injections ongoing + + logger.debug('Running `%s`', cmd) + os.system(cmd) + time.sleep(0.2) + + # try a few times if the service was just started + for attempt in range(3): + try: + interface = bus.get(BUS_NAME) + break + except GLib.GError as error: + logger.debug( + 'Attempt %d to connect to the service failed: "%s"', + attempt + 1, error + ) + time.sleep(0.2) + else: + logger.error('Failed to connect to the service') + sys.exit(1) + + return interface + + def publish(self): + """Make the dbus interface available.""" + bus = SystemBus() + try: + bus.publish(BUS_NAME, self) + except RuntimeError as error: + logger.error('Is the service is already running? %s', str(error)) + sys.exit(1) + + def run(self): + """Start the daemons loop. Blocks until the daemon stops.""" + loop = GLib.MainLoop() + logger.debug('Running daemon') + loop.run() + def refresh_devices(self, device=None): """Keep the devices up to date.""" now = time.time() @@ -401,11 +425,11 @@ class Daemon: 'Tried to start an injection without configuring the daemon ' 'first via set_config_dir.' ) - return + return False if device not in get_devices(): logger.error('Could not find device "%s"', device) - return + return False preset_path = os.path.join( self.config_dir, diff --git a/keymapper/getdevices.py b/keymapper/getdevices.py index ac719f96..ebfa0572 100644 --- a/keymapper/getdevices.py +++ b/keymapper/getdevices.py @@ -175,7 +175,11 @@ class _GetDevices(threading.Thread): def refresh_devices(): - """This can be called to discover new devices.""" + """This can be called to discover new devices. + + Only call this if appropriate permissions are available, otherwise + the object may be empty afterwards. + """ # it may take a little bit of time until devices are visible after # changes time.sleep(0.1) @@ -184,6 +188,12 @@ def refresh_devices(): return get_devices() +def set_devices(devices): + """Overwrite the object containing the devices.""" + global _devices + _devices = devices + + def get_devices(include_keymapper=False): """Group devices and get relevant infos per group. diff --git a/keymapper/gui/helper.py b/keymapper/gui/helper.py new file mode 100644 index 00000000..ce6223d4 --- /dev/null +++ b/keymapper/gui/helper.py @@ -0,0 +1,209 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2021 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 . + + +"""Process that sends stuff to the GUI. + +It should be started via key-mapper-control and pkexec. + +GUIs should not run as root +https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root +""" + + +import sys +import select +import multiprocessing +import subprocess + +import evdev +from evdev.ecodes import EV_KEY + +from keymapper.ipc.pipe import Pipe +from keymapper.logger import logger +from keymapper.state import custom_mapping +from keymapper.getdevices import get_devices, is_gamepad +from keymapper import utils + + +TERMINATE = 'terminate' + + +def is_helper_running(): + """Check if the helper is running.""" + try: + subprocess.check_output(['pgrep', '-f', 'key-mapper-helper']) + except subprocess.CalledProcessError: + return False + return True + + +class RootHelper: + """Client that runs as root and works for the GUI. + + Sends device information and keycodes to the GUIs socket. + + Commands are either numbers for generic commands, + or strings to start listening on a specific device. + """ + def __init__(self): + """Construct the helper and initialize its sockets.""" + self._results = Pipe('/tmp/key-mapper/results') + self._commands = Pipe('/tmp/key-mapper/commands') + + # the ui needs the devices first + self._results.send({ + 'type': 'devices', + 'message': get_devices() + }) + + self.device_name = None + self._pipe = multiprocessing.Pipe() + + def run(self): + """Start doing stuff. Blocks.""" + while True: + self._handle_commands() + self._start_reading() + + def _handle_commands(self): + """Handle all unread commands.""" + # wait for something to do + select.select([self._commands], [], []) + + while self._commands.poll(): + cmd = self._commands.recv() + logger.debug('Received command "%s"', cmd) + if cmd == TERMINATE: + logger.debug('Helper terminates') + sys.exit(0) + elif cmd in get_devices(): + self.device_name = cmd + else: + logger.error('Received unknown command "%s"', cmd) + + def _start_reading(self): + """Tell the evdev lib to start looking for keycodes. + + If read is called without prior start_reading, no keycodes + will be available. + + This blocks forever until it discovers a new command on the socket. + """ + device_name = self.device_name + + rlist = {} + gamepad = {} + + if device_name is None: + logger.error('device_name is None') + return + + group = get_devices()[device_name] + virtual_devices = [] + # Watch over each one of the potentially multiple devices per + # hardware + for path in group['paths']: + try: + device = evdev.InputDevice(path) + except FileNotFoundError: + continue + + if evdev.ecodes.EV_KEY in device.capabilities(): + virtual_devices.append(device) + + if len(virtual_devices) == 0: + logger.debug('No interesting device for "%s"', device_name) + return + + for device in virtual_devices: + rlist[device.fd] = device + gamepad[device.fd] = is_gamepad(device) + + logger.debug( + 'Starting reading keycodes from "%s"', + '", "'.join([device.name for device in virtual_devices]) + ) + + rlist[self._commands] = self._commands + + while True: + ready_fds = select.select(rlist, [], []) + if len(ready_fds[0]) == 0: + # whatever, maybe the socket is closed and select + # has nothing to select from? + continue + + for fd in ready_fds[0]: + if rlist[fd] == self._commands: + # all commands will cause the reader to start over + # (possibly for a different device). + # _handle_commands will check what is going on + return + + device = rlist[fd] + + try: + event = device.read_one() + self._send_event(event, device, gamepad[device.fd]) + except OSError: + logger.debug('Device "%s" disappeared', device.path) + return + + def _send_event(self, event, device, gamepad): + """Write the event into the pipe to the main process. + + Parameters + ---------- + event : evdev.InputEvent + device : evdev.InputDevice + gamepad : bool + If true, ABS_X and ABS_Y might be mapped to buttons as well + depending on the purpose configuration + """ + # value: 1 for down, 0 for up, 2 for hold. + if event.type == EV_KEY and event.value == 2: + # ignore hold-down events + return + + click_events = [ + evdev.ecodes.BTN_LEFT, + evdev.ecodes.BTN_TOOL_DOUBLETAP + ] + + if event.type == EV_KEY and event.code in click_events: + # disable mapping the left mouse button because it would break + # the mouse. Also it is emitted right when focusing the row + # which breaks the current workflow. + return + + if not utils.should_map_as_btn(event, custom_mapping, gamepad): + return + + max_abs = utils.get_max_abs(device) + event.value = utils.normalize_value(event, max_abs) + + self._results.send({ + 'type': 'event', + 'message': ( + event.sec, event.usec, + event.type, event.code, event.value + ) + }) diff --git a/keymapper/gui/reader.py b/keymapper/gui/reader.py index a805e1fd..22ef8b4b 100644 --- a/keymapper/gui/reader.py +++ b/keymapper/gui/reader.py @@ -19,253 +19,81 @@ # along with key-mapper. If not, see . -"""Keeps reading keycodes in the background for the UI to use.""" +"""Talking to the GUI helper that has root permissions. +see gui.helper.helper +""" -import sys -import time -import select -import multiprocessing -import threading import evdev -from evdev.ecodes import EV_KEY, EV_ABS, ABS_MISC, EV_REL +from evdev.ecodes import EV_REL from keymapper.logger import logger from keymapper.key import Key -from keymapper.state import custom_mapping -from keymapper.getdevices import get_devices, is_gamepad -from keymapper import utils +from keymapper.getdevices import set_devices +from keymapper.ipc.pipe import Pipe +from keymapper.gui.helper import TERMINATE -CLOSE = 1 - -PRIORITIES = { - EV_KEY: 100, - EV_ABS: 50, -} - -FILTER_THRESHOLD = 0.01 DEBOUNCE_TICKS = 3 -def prioritize(events): - """Return the event that is most likely desired to be mapped. - - KEY over ABS and everything over ABS_MISC. - """ - events = [ - event for event in events - if event is not None - ] - return sorted(events, key=lambda e: ( - PRIORITIES.get(e.type, 0), - not (e.type == EV_ABS and e.code == ABS_MISC), - abs(e.value) - ))[-1] - - def will_report_up(ev_type): """Check if this event will ever report a key up (wheels).""" return ev_type != EV_REL -def event_unix_time(event): - """Get the unix timestamp of an event.""" - if event is None: - return 0 - return event.sec + event.usec / 1000000 - - -class _KeycodeReader: - """Keeps reading keycodes in the background for the UI to use. +class Reader: + """Processes events from the helper for the GUI to use. Does not serve any purpose for the injection service. When a button was pressed, the newest keycode can be obtained from this - object. GTK has get_key for keyboard keys, but KeycodeReader also + object. GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like the middle-mouse button. """ def __init__(self): - self.virtual_devices = [] - self._pipe = None - self._process = None - self.fail_counter = 0 self.previous_event = None self.previous_result = None self._unreleased = {} self._debounce_remove = {} + self._devices_updated = False + self._cleared_at = 0 + self.device_name = None - def __del__(self): - self.stop_reading() + self._results = None + self._commands = None + self.connect() - def stop_reading(self): - """Stop reading keycodes.""" - if self._pipe is not None: - logger.debug('Sending close msg to reader') - self._pipe[0].send(CLOSE) - self._pipe = None + def connect(self): + """Connect to the helper.""" + self._results = Pipe('/tmp/key-mapper/results') + self._commands = Pipe('/tmp/key-mapper/commands') - def clear(self): - """Next time when reading don't return the previous keycode.""" - # just call read to clear the pipe - self.read() - self._unreleased = {} - self.previous_event = None - self.previous_result = None + def are_new_devices_available(self): + """Check if get_devices contains new devices. - def start_reading(self, device_name): - """Tell the evdev lib to start looking for keycodes. - - If read is called without prior start_reading, no keycodes - will be available. - - Parameters - ---------- - device_name : string - As indexed in get_devices() - """ - if self._pipe is not None: - self.stop_reading() - time.sleep(0.1) - - self.virtual_devices = [] - - group = get_devices()[device_name] - - # Watch over each one of the potentially multiple devices per hardware - for path in group['paths']: - try: - device = evdev.InputDevice(path) - except FileNotFoundError: - continue - - if evdev.ecodes.EV_KEY in device.capabilities(): - self.virtual_devices.append(device) - - logger.debug( - 'Starting reading keycodes from "%s"', - '", "'.join([device.name for device in self.virtual_devices]) - ) - - pipe = multiprocessing.Pipe() - self._pipe = pipe - self._process = threading.Thread(target=self._read_worker) - self._process.start() - - def _pipe_event(self, event, device, gamepad): - """Write the event into the pipe to the main process. - - Parameters - ---------- - event : evdev.InputEvent - device : evdev.InputDevice - gamepad : bool - If true, ABS_X and ABS_Y might be mapped to buttons as well - depending on the purpose configuration + The ui should then update its list. """ - # value: 1 for down, 0 for up, 2 for hold. - if self._pipe is None or self._pipe[1].closed: - logger.debug('Pipe closed, reader stops.') - sys.exit(0) - - if event.type == EV_KEY and event.value == 2: - # ignore hold-down events - return - - click_events = [ - evdev.ecodes.BTN_LEFT, - evdev.ecodes.BTN_TOOL_DOUBLETAP - ] - - if event.type == EV_KEY and event.code in click_events: - # disable mapping the left mouse button because it would break - # the mouse. Also it is emitted right when focusing the row - # which breaks the current workflow. - return - - if not utils.should_map_as_btn(event, custom_mapping, gamepad): - return - - max_abs = utils.get_max_abs(device) - event.value = utils.normalize_value(event, max_abs) - - self._pipe[1].send(event) - - def _read_worker(self): - """Thread that reads keycodes and buffers them into a pipe.""" - # using a thread that blocks instead of read_one made it easier - # to debug via the logs, because the UI was not polling properly - # at some point which caused logs for events not to be written. - rlist = {} - gamepad = {} - for device in self.virtual_devices: - rlist[device.fd] = device - gamepad[device.fd] = is_gamepad(device) - - rlist[self._pipe[1]] = self._pipe[1] - - while True: - ready = select.select(rlist, [], [])[0] - for fd in ready: - readable = rlist[fd] # an InputDevice or a pipe - if isinstance(readable, multiprocessing.connection.Connection): - msg = readable.recv() - if msg == CLOSE: - logger.debug('Reader stopped') - return - continue - - try: - for event in rlist[fd].read(): - self._pipe_event( - event, - readable, - gamepad.get(fd, False) - ) - except OSError: - logger.debug( - 'Device "%s" disappeared from the reader', - rlist[fd].path - ) - del rlist[fd] - - def get_unreleased_keys(self): - """Get a Key object of the current keyboard state.""" - unreleased = list(self._unreleased.values()) - - if len(unreleased) == 0: + outdated = self._devices_updated + self._devices_updated = False # assume the ui will react accordingly + return outdated + + def _get_event(self, message): + """Return an InputEvent if the message contains one. None otherwise.""" + message_type = message['type'] + message_body = message['message'] + if message_type == 'devices': + # result of get_devices in the helper + logger.debug('Received %d devices', len(message_body)) + set_devices(message_body) + self._devices_updated = True return None + elif message_type == 'event': + return evdev.InputEvent(*message_body) - return Key(*unreleased) - - def _release(self, type_code): - """Modify the state to recognize the releasing of the key.""" - if type_code in self._unreleased: - del self._unreleased[type_code] - if type_code in self._debounce_remove: - del self._debounce_remove[type_code] - - def _debounce_start(self, event_tuple): - """Act like the key was released if no new event arrives in time.""" - if not will_report_up(event_tuple[0]): - self._debounce_remove[event_tuple[:2]] = DEBOUNCE_TICKS - - def _debounce_tick(self): - """If the counter reaches 0, the key is not considered held down.""" - for type_code in list(self._debounce_remove.keys()): - if type_code not in self._unreleased: - continue - - # clear wheel events from unreleased after some time - if self._debounce_remove[type_code] == 0: - logger.key_spam( - self._unreleased[type_code], - 'Considered as released' - ) - self._release(type_code) - else: - self._debounce_remove[type_code] -= 1 + logger.error('Received unknown message "%s"', message) + return None def read(self): """Get the newest key/combination as Key object. @@ -289,13 +117,8 @@ class _KeycodeReader: # have to trigger anything, manage any macros and only # reports key-down events. This function is called periodically # by the window. - if self._pipe is None: - self.fail_counter += 1 - if self.fail_counter % 10 == 0: # spam less - logger.debug('No pipe available to read from') - return None - # remember the prevous down-event from the pipe in order to + # remember the previous down-event from the pipe in order to # be able to prioritize events, and to be able to tell if the reader # should return the updated combination previous_event = self.previous_event @@ -303,10 +126,14 @@ class _KeycodeReader: self._debounce_tick() - while self._pipe[0].poll(): - # loop over all new and unhandled events - event = self._pipe[0].recv() + while self._results.poll(): + message = self._results.recv() + event = self._get_event(message) + if event is None: + continue + event_tuple = (event.type, event.code, event.value) + type_code = (event.type, event.code) if event.value == 0: @@ -319,29 +146,6 @@ class _KeycodeReader: self._debounce_start(event_tuple) continue - delta = event_unix_time(event) - event_unix_time(previous_event) - if delta < FILTER_THRESHOLD: - if prioritize([previous_event, event]) == previous_event: - # two events happened very close, probably some weird - # spam from the device. The wacom intuos 5 adds an - # ABS_MISC event to every button press, filter that out - logger.key_spam(event_tuple, 'ignoring new event') - continue - - # the previous event of the previous iteration is ignored. - # clean stuff up to remove its side effects - prev_tuple = ( - previous_event.type, - previous_event.code, - previous_event.value - ) - if prev_tuple[:2] in self._unreleased: - logger.key_spam( - event_tuple, - 'ignoring previous event %s', prev_tuple - ) - self._release(prev_tuple[:2]) - # to keep track of combinations. # "I have got this release event, what was this for?" A release # event for a D-Pad axis might be any direction, hence this maps @@ -374,5 +178,70 @@ class _KeycodeReader: return None + def start_reading(self, device_name): + """Start reading keycodes for a device.""" + logger.debug('Sending start msg to helper for "%s"', device_name) + self._commands.send(device_name) + self.device_name = device_name + self.clear() + + def terminate(self): + """Stop reading keycodes for good.""" + logger.debug('Sending close msg to helper') + self._commands.send(TERMINATE) + + def clear(self): + """Next time when reading don't return the previous keycode.""" + logger.debug('Clearing reader') + while self._results.poll(): + # clear the results pipe and handle any non-event messages, + # otherwise a get_devices message might get lost + message = self._results.recv() + self._get_event(message) + + self._unreleased = {} + self.previous_event = None + self.previous_result = None + + def get_unreleased_keys(self): + """Get a Key object of the current keyboard state.""" + unreleased = list(self._unreleased.values()) + + if len(unreleased) == 0: + return None + + return Key(*unreleased) + + def _release(self, type_code): + """Modify the state to recognize the releasing of the key.""" + if type_code in self._unreleased: + del self._unreleased[type_code] + if type_code in self._debounce_remove: + del self._debounce_remove[type_code] + + def _debounce_start(self, event_tuple): + """Act like the key was released if no new event arrives in time.""" + if not will_report_up(event_tuple[0]): + self._debounce_remove[event_tuple[:2]] = DEBOUNCE_TICKS + + def _debounce_tick(self): + """If the counter reaches 0, the key is not considered held down.""" + for type_code in list(self._debounce_remove.keys()): + if type_code not in self._unreleased: + continue + + # clear wheel events from unreleased after some time + if self._debounce_remove[type_code] == 0: + logger.key_spam( + self._unreleased[type_code], + 'Considered as released' + ) + self._release(type_code) + else: + self._debounce_remove[type_code] -= 1 + + def __del__(self): + self.terminate() + -keycode_reader = _KeycodeReader() +reader = Reader() diff --git a/keymapper/gui/row.py b/keymapper/gui/row.py index f1bdafe2..dce0bc33 100644 --- a/keymapper/gui/row.py +++ b/keymapper/gui/row.py @@ -28,7 +28,7 @@ from gi.repository import Gtk, GLib, Gdk from keymapper.state import custom_mapping, system_mapping from keymapper.logger import logger from keymapper.key import Key -from keymapper.gui.reader import keycode_reader +from keymapper.gui.reader import reader CTX_KEYCODE = 2 @@ -38,6 +38,19 @@ store = Gtk.ListStore(str) for name in system_mapping.list_names(): store.append([name]) +for key in [ + 'mouse(up, 1)', + 'mouse(down, 1)', + 'mouse(left, 1)', + 'mouse(right, 1)', + 'wheel(up, 1)', + 'wheel(down, 1)', + 'wheel(left, 1)', + 'wheel(right, 1)' +]: + # add some more keys to the dropdown list + store.append([key]) + def to_string(key): """A nice to show description of the pressed key.""" @@ -57,9 +70,20 @@ def to_string(key): logger.error('Unknown key code for %s', key) return 'unknown' - key_name = evdev.ecodes.bytype[ev_type][code] - if isinstance(key_name, list): - key_name = key_name[0] + key_name = None + + # first try to find the name in xmodmap to not display wrong + # names due to the keyboard layout + if ev_type == evdev.ecodes.EV_KEY: + key_name = system_mapping.get_name(code) + + if key_name is None: + # if no result, look in the linux key constants. On a german + # keyboard for example z and y are switched, which will therefore + # cause the wrong letter to be displayed. + key_name = evdev.ecodes.bytype[ev_type][code] + if isinstance(key_name, list): + key_name = key_name[0] if ev_type != evdev.ecodes.EV_KEY: direction = { @@ -141,7 +165,7 @@ class Row(Gtk.ListBoxRow): self._state = IDLE return - unreleased_keys = keycode_reader.get_unreleased_keys() + unreleased_keys = reader.get_unreleased_keys() if unreleased_keys is None and old_state == HOLDING and self.key: # A key was pressed and then released. # Switch to the character. idle_add this so that the @@ -259,7 +283,7 @@ class Row(Gtk.ListBoxRow): def on_keycode_input_focus(self, *_): """Refresh useful usage information.""" - keycode_reader.clear() + reader.clear() self.show_press_key() self.window.can_modify_mapping() @@ -268,7 +292,7 @@ class Row(Gtk.ListBoxRow): self.show_click_here() self.keycode_input.set_active(False) self._state = IDLE - keycode_reader.clear() + reader.clear() self.window.save_preset() def set_keycode_input_label(self, label): diff --git a/keymapper/gui/window.py b/keymapper/gui/window.py index e17abe56..fc06b3ad 100755 --- a/keymapper/gui/window.py +++ b/keymapper/gui/window.py @@ -23,6 +23,7 @@ import math +import os from gi.repository import Gtk, Gdk, GLib @@ -31,15 +32,16 @@ from keymapper.paths import get_config_path, get_preset_path from keymapper.state import custom_mapping, system_mapping from keymapper.presets import get_presets, find_newest_preset, \ delete_preset, rename_preset, get_available_preset_name -from keymapper.logger import logger, COMMIT_HASH, version, evdev_version +from keymapper.logger import logger, COMMIT_HASH, version, evdev_version, \ + is_debug from keymapper.getdevices import get_devices from keymapper.gui.row import Row, to_string -from keymapper.gui.reader import keycode_reader +from keymapper.gui.reader import reader +from keymapper.gui.helper import is_helper_running from keymapper.injection.injector import RUNNING, FAILED, NO_GRAB -from keymapper.daemon import get_dbus_interface +from keymapper.daemon import Daemon from keymapper.config import config from keymapper.injection.macros import is_this_a_macro, parse -from keymapper import permissions def gtk_iteration(): @@ -107,7 +109,7 @@ def on_close_about(about, _): class Window: """User Interface.""" def __init__(self): - self.dbus = get_dbus_interface() + self.dbus = None self.selected_device = None self.selected_preset = None @@ -128,6 +130,8 @@ class Window: builder.connect_signals(self) self.builder = builder + self.start_processes() + self.confirm_delete = builder.get_object('confirm-delete') self.about = builder.get_object('about-dialog') self.about.connect('delete-event', on_close_about) @@ -147,25 +151,12 @@ class Window: # already visible (without content) to make it look more responsive. gtk_iteration() - permission_errors = permissions.can_read_devices() - if len(permission_errors) > 0: - permission_errors = [( - 'Usually, key-mapper-gtk should be started with pkexec ' - 'or sudo.' - )] + permission_errors - self.show_status( - CTX_ERROR, - 'Permission error, hover for info', - '\n\n'.join(permission_errors) - ) - # this is not set to invisible in glade to give the ui a default # height that doesn't jump when a gamepad is selected self.get('gamepad_separator').hide() self.get('gamepad_config').hide() self.populate_devices() - self.select_newest_preset() self.timeouts = [ GLib.timeout_add(100, self.check_add_row), @@ -178,6 +169,21 @@ class Window: self.ctrl = False self.unreleased_warn = 0 + def start_processes(self): + """Start helper and daemon via pkexec to run in the background.""" + # this function is overwritten in tests + self.dbus = Daemon.connect() + + cmd = 'pkexec key-mapper-control --command helper' + if is_debug: + cmd += ' -d' + + logger.debug('Running `%s`', cmd) + os.system(cmd) + + if not is_helper_running(): + self.show_status(CTX_ERROR, 'The helper did not start') + def show_confirm_delete(self): """Blocks until the user decided about an action.""" self.confirm_delete.show() @@ -248,7 +254,7 @@ class Window: for timeout in self.timeouts: GLib.source_remove(timeout) self.timeouts = [] - keycode_reader.stop_reading() + reader.terminate() Gtk.main_quit() def check_add_row(self): @@ -275,8 +281,8 @@ class Window: ) # iterating over that 10 times per second is a bit wasteful, - # but there were problems with the old approach which involved - # just counting the number of mappings and rows. + # but the old approach which involved just counting the number of + # mappings and rows didn't seem very robust. for row in rows: if row.get_key() is None or row.get_character() is None: # unfinished row found @@ -287,19 +293,24 @@ class Window: return True def select_newest_preset(self): - """Find and select the newest preset.""" + """Find and select the newest preset (and its device).""" device, preset = find_newest_preset() if device is not None: self.get('device_selection').set_active_id(device) if preset is not None: - self.get('device_selection').set_active_id(preset) + self.get('preset_selection').set_active_id(preset) def populate_devices(self): """Make the devices selectable.""" devices = get_devices() device_selection = self.get('device_selection') - for device in devices: - device_selection.append(device, device) + + with HandlerDisabled(device_selection, self.on_select_device): + device_selection.remove_all() + for device in devices: + device_selection.append(device, device) + + self.select_newest_preset() def populate_presets(self): """Show the available presets for the selected device. @@ -320,10 +331,9 @@ class Window: preset_selection = self.get('preset_selection') - preset_selection.handler_block_by_func(self.on_select_preset) - # otherwise the handler is called with None for each removed preset - preset_selection.remove_all() - preset_selection.handler_unblock_by_func(self.on_select_preset) + with HandlerDisabled(preset_selection, self.on_select_preset): + # otherwise the handler is called with None for each preset + preset_selection.remove_all() for preset in presets: preset_selection.append(preset, preset) @@ -365,7 +375,6 @@ class Window: return row, focused - @with_selected_device def consume_newest_keycode(self): """To capture events from keyboards, mice and gamepads.""" # the "event" event of Gtk.Window wouldn't trigger on gamepad @@ -375,7 +384,10 @@ class Window: # letting go of one of the keys of a combination won't just make # it return the leftover key, it will continue to return None because # they have already been read. - key = keycode_reader.read() + key = reader.read() + + if reader.are_new_devices_available(): + self.populate_devices() # TODO highlight if a row for that key exists or something @@ -499,6 +511,8 @@ class Window: @with_selected_preset def on_apply_preset_clicked(self, _): """Apply a preset without saving changes.""" + self.save_preset() + if custom_mapping.num_saved_keys == 0: logger.error('Cannot apply empty preset file') # also helpful for first time use @@ -521,7 +535,7 @@ class Window: logger.info('Applying preset "%s" for "%s"', preset, device) if not self.unreleased_warn: - unreleased = keycode_reader.get_unreleased_keys() + unreleased = reader.get_unreleased_keys() if unreleased is not None: # it's super annoying if that happens and may break the user # input in such a way to prevent disabling the mapping @@ -571,13 +585,17 @@ class Window: device = dropdown.get_active_text() + if device is None: + return + logger.debug('Selecting device "%s"', device) self.selected_device = device self.selected_preset = None self.populate_presets() - GLib.idle_add(lambda: keycode_reader.start_reading(device)) + + reader.start_reading(device) self.show_device_mapping_status() @@ -586,17 +604,10 @@ class Window: state = self.dbus.get_state(self.selected_device) if state == RUNNING: - if custom_mapping.changed: - self.show_status( - CTX_WARNING, - 'Applied without unsaved changes. shift + del to stop', - 'Click "Save" first for changes to take effect' - ) - else: - self.show_status( - CTX_APPLY, - f'Applied preset "{self.selected_preset}"' - ) + self.show_status( + CTX_APPLY, + f'Applied preset "{self.selected_preset}"' + ) self.show_device_mapping_status() return False @@ -624,7 +635,8 @@ class Window: def show_device_mapping_status(self): """Figure out if this device is currently under keymappers control.""" device = self.selected_device - if self.dbus.get_state(device) == RUNNING: + state = self.dbus.get_state(device) + if state == RUNNING: logger.info('Device "%s" is currently mapped', device) self.get('apply_system_layout').set_opacity(1) else: @@ -668,12 +680,18 @@ class Window: def on_select_preset(self, dropdown): """Show the mappings of the preset.""" + # beware in tests that this function won't be called at all if the + # active_id stays the same + if dropdown.get_active_id() == self.selected_preset: return self.clear_mapping_table() preset = dropdown.get_active_text() + if preset is None: + return + logger.debug('Selecting preset "%s"', preset) self.selected_preset = preset @@ -723,10 +741,7 @@ class Window: def add_empty(self): """Add one empty row for a single mapped key.""" - empty = Row( - window=self, - delete_callback=self.on_row_removed - ) + empty = Row(window=self, delete_callback=self.on_row_removed) key_list = self.get('key_list') key_list.insert(empty, -1) diff --git a/keymapper/injection/keycode_mapper.py b/keymapper/injection/keycode_mapper.py index 8beffa7e..36c17396 100644 --- a/keymapper/injection/keycode_mapper.py +++ b/keymapper/injection/keycode_mapper.py @@ -103,11 +103,10 @@ class Unreleased: __slots__ = ( 'target_type_code', 'input_event_tuple', - 'key', - 'is_mapped' + 'triggered_key', ) - def __init__(self, target_type_code, input_event_tuple, key, is_mapped): + def __init__(self, target_type_code, input_event_tuple, triggered_key): """ Parameters ---------- @@ -115,18 +114,15 @@ class Unreleased: int type and int code of what was injected or forwarded input_event_tuple : 3-tuple the original event, int, int, int / type, code, value - key : tuple of 3-tuples - what was used to index key_to_code and macros when stuff - was triggered - is_mapped : bool - if true, target_type_code is supposed to be written to the - "... mapped" device and originated from the mapping. - cached result of context.is_mapped(key) + triggered_key : tuple of 3-tuples + What was used to index key_to_code or macros when stuff + was triggered. + If nothing was triggered and input_event_tuple forwarded, + insert None. """ self.target_type_code = target_type_code self.input_event_tuple = input_event_tuple - self.key = key - self.is_mapped = is_mapped + self.triggered_key = triggered_key if ( not isinstance(input_event_tuple[0], int) or @@ -139,12 +135,21 @@ class Unreleased: unreleased[input_event_tuple[:2]] = self + def is_mapped(self): + """If true, the key-down event was written to context.uinput. + + That means the release event should also be injected into that one. + If this returns false, just forward the release event instead. + """ + # This should end up being equal to context.is_mapped(key) + return self.triggered_key is not None + def __str__(self): return ( 'Unreleased(' f'target{self.target_type_code},' f'input{self.input_event_tuple},' - f'key{"(None)" if self.key is None else self.key}' + f'key{self.triggered_key or "(None)"}' ')' ) @@ -171,7 +176,7 @@ def find_by_key(key): """Find an unreleased entry by a combination of keys. If such an entry exist, it was created when a combination of keys - (which matches the parameter) (can also be of len 1 = single key) + (which matches the parameter, can also be of len 1 = single key) ended up triggering something. Parameters @@ -179,7 +184,7 @@ def find_by_key(key): key : tuple of 3-tuples """ unreleased_entry = unreleased.get(key[-1][:2]) - if unreleased_entry and unreleased_entry.key == key: + if unreleased_entry and unreleased_entry.triggered_key == key: return unreleased_entry return None @@ -264,10 +269,10 @@ class KeycodeMapper: value = key[2] key = (key,) - if unreleased_entry is not None and unreleased_entry.key is not None: + if unreleased_entry and unreleased_entry.triggered_key is not None: # seen before. If this key triggered a combination, # use the combination that was triggered by this as key. - return unreleased_entry.key + return unreleased_entry.triggered_key if is_key_down(value): # get the key/combination that the key-down would trigger @@ -356,16 +361,14 @@ class KeycodeMapper: if type_code in unreleased: # figure out what this release event was for unreleased_entry = unreleased[type_code] - target_type, target_code = ( - unreleased[type_code].target_type_code - ) + target_type, target_code = unreleased_entry.target_type_code del unreleased[type_code] if target_code == DISABLE_CODE: logger.key_spam(key, 'releasing disabled key') elif target_code is None: logger.key_spam(key, 'releasing key') - elif unreleased_entry.is_mapped: + elif unreleased_entry.is_mapped(): # release what the input is mapped to logger.key_spam(key, 'releasing %s', target_code) self.write((target_type, target_code, 0)) @@ -418,7 +421,7 @@ class KeycodeMapper: if key in self.context.macros: macro = self.context.macros[key] active_macros[type_code] = macro - Unreleased((None, None), event_tuple, key, is_mapped) + Unreleased((None, None), event_tuple, key) macro.press_key() logger.key_spam(key, 'maps to macro %s', macro.code) asyncio.ensure_future(macro.run(self.macro_write)) @@ -428,7 +431,7 @@ class KeycodeMapper: target_code = self.context.key_to_code[key] # remember the key that triggered this # (this combination or this single key) - Unreleased((EV_KEY, target_code), event_tuple, key, is_mapped) + Unreleased((EV_KEY, target_code), event_tuple, key) if target_code == DISABLE_CODE: logger.key_spam(key, 'disabled') @@ -446,7 +449,7 @@ class KeycodeMapper: # unhandled events may still be important for triggering # combinations later, so remember them as well. - Unreleased((event_tuple[:2]), event_tuple, None, is_mapped) + Unreleased((event_tuple[:2]), event_tuple, None) return logger.error('%s unhandled', key) diff --git a/keymapper/ipc/__init__.py b/keymapper/ipc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keymapper/ipc/pipe.py b/keymapper/ipc/pipe.py new file mode 100644 index 00000000..442a8d89 --- /dev/null +++ b/keymapper/ipc/pipe.py @@ -0,0 +1,144 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2021 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 . + + +"""Named bidirectional non-blocking pipes. + +>>> p1 = Pipe('foo') +>>> p2 = Pipe('foo') + +>>> p1.send(1) +>>> p2.poll() +>>> p2.recv() + +>>> p2.send(2) +>>> p1.poll() +>>> p1.recv() + +Beware that pipes read any available messages, +even those written by themselves. +""" + + +import os +import time +import json + +from keymapper.logger import logger +from keymapper.paths import mkdir, chown + + +class Pipe: + """Pipe object.""" + def __init__(self, path): + """Create a pipe, or open it if it already exists.""" + self._path = path + self._unread = [] + self._created_at = time.time() + + paths = ( + f'{path}r', + f'{path}w' + ) + + mkdir(os.path.dirname(path)) + + if not os.path.exists(paths[0]): + logger.spam('Creating new pipe for "%s"', path) + # The fd the link points to is closed, or none ever existed + # If there is a link, remove it. + if os.path.islink(paths[0]): + os.remove(paths[0]) + if os.path.islink(paths[1]): + os.remove(paths[1]) + + self._fds = os.pipe() + fds_dir = f'/proc/{os.getpid()}/fd/' + chown(f'{fds_dir}{self._fds[0]}') + chown(f'{fds_dir}{self._fds[1]}') + + # to make it accessible by path constants, create symlinks + os.symlink(f'{fds_dir}{self._fds[0]}', paths[0]) + os.symlink(f'{fds_dir}{self._fds[1]}', paths[1]) + else: + logger.spam('Using existing pipe for "%s"', path) + + # thanks to os.O_NONBLOCK, readline will return b'' when there + # is nothing to read + self._fds = ( + os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK), + os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK) + ) + + self._handles = ( + open(self._fds[0], 'r'), + open(self._fds[1], 'w') + ) + + def recv(self): + """Read an object from the pipe or None if nothing available. + + Doesn't transmit pickles, to avoid injection attacks on the + privileged helper. Only messages that can be converted to json + are allowed. + """ + if len(self._unread) > 0: + return self._unread.pop(0) + + line = self._handles[0].readline() + if len(line) == 0: + return None + + parsed = json.loads(line) + if parsed[0] < self._created_at and os.environ.get('UNITTEST'): + # important to avoid race conditions between multiple unittests, + # for example old terminate messages reaching a new instance of + # the helper. + logger.spam('Ignoring old message %s', parsed) + return None + + return parsed[1] + + def send(self, message): + """Write an object to the pipe.""" + dump = json.dumps((time.time(), message)) + # there aren't any newlines supposed to be, + # but if there are it breaks readline(). + self._handles[1].write(dump.replace('\n', '')) + self._handles[1].write('\n') + self._handles[1].flush() + + def poll(self): + """Check if there is anything that can be read.""" + if len(self._unread) > 0: + return True + + # using select.select apparently won't mark the pipe as ready + # anymore when there are multiple lines to read but only a single + # line is retreived. Using read instead. + msg = self.recv() + if msg is not None: + self._unread.append(msg) + + return len(self._unread) > 0 + + def fileno(self): + """Compatibility to select.select""" + return self._handles[0].fileno() diff --git a/keymapper/ipc/readme.md b/keymapper/ipc/readme.md new file mode 100644 index 00000000..e5fea4ac --- /dev/null +++ b/keymapper/ipc/readme.md @@ -0,0 +1,7 @@ +# IPC + +Since I'm not forking, I can't use the handy multiprocessing.Pipe +method anymore. + +Processes that need privileges are spawned with pkexec, which connect to +known pipe paths to communicate with the non-privileged parent process. diff --git a/keymapper/ipc/socket.py b/keymapper/ipc/socket.py new file mode 100644 index 00000000..dd63b7ef --- /dev/null +++ b/keymapper/ipc/socket.py @@ -0,0 +1,297 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2021 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 . + + +"""Non-blocking abstraction of unix domain sockets. + +>>> server = Server('foo') +>>> client = Client('foo') + +>>> server.send(1) +>>> client.poll() +>>> client.recv() + +>>> client.send(2) +>>> server.poll() +>>> server.recv() + +I seems harder to sniff on a socket than using pipes for other non-root +processes, but it doesn't guarantee security. As long as the GUI is open +and not running as root user, it is most likely possible to somehow log +keycodes by looking into the memory of the gui process (just like with most +other applications because they end up receiving keyboard input as well). +It still appears to be a bit overkill to use a socket considering pipes +are much easier to handle. +""" + + +# Issues: +# - Tests don't pass with Server (reader) and Client (helper) instead of Pipe +# - Had one case of a test that was blocking forever, seems very rare. +# - Hard to debug, generally very problematic compared to Pipes +# The tool works fine, it's just the tests. BrokenPipe errors reported +# by _Server all the time. + + +import select +import socket +import os +import time +import json + +from keymapper.logger import logger +from keymapper.paths import mkdir, chown + + +# something funny that most likely won't appear in messages. +# also add some ones so that 01 in the payload won't offset +# a match by 2 bits +END = b'\x55\x55\xff\x55' # should be 01010101 01010101 11111111 01010101 + +ENCODING = 'utf8' + + +# reusing existing objects makes tests easier, no headaches about closing +# and reopening anymore. The ui also only runs only one instance of each all +# the time. +existing_servers = {} +existing_clients = {} + + +class Base: + """Abstract base class for Socket and Client.""" + def __init__(self, path): + self._path = path + self._unread = [] + self.unsent = [] + mkdir(os.path.dirname(path)) + self.connection = None + self.socket = None + self._created_at = 0 + self.reset() + + def reset(self): + """Ignore older messages than now.""" + # ensure it is connected + self.connect() + self._created_at = time.time() + + def connect(self): + """Returns True if connected, and if not attempts to connect.""" + raise NotImplementedError + + def fileno(self): + """For compatibility with select.select.""" + raise NotImplementedError + + def reconnect(self): + """Try to make a new connection.""" + raise NotImplementedError + + def _receive_new_messages(self): + if not self.connect(): + logger.spam('Not connected') + return + + messages = b'' + attempts = 0 + while True: + try: + chunk = self.connection.recvmsg(4096)[0] + messages += chunk + + if len(chunk) == 0: + # select keeps telling me the socket has messages + # ready to be received, and I keep getting empty + # buffers. Happened during a test that ran two helper + # processes without stopping the first one. + attempts += 1 + if attempts == 2 or not self.reconnect(): + return + + except (socket.timeout, BlockingIOError): + break + + split = messages.split(END) + for message in split: + if len(message) > 0: + parsed = json.loads(message.decode(ENCODING)) + if parsed[0] < self._created_at: + # important to avoid race conditions between multiple + # unittests, for example old terminate messages reaching + # a new instance of the helper. + logger.spam('Ignoring old message %s', parsed) + continue + + self._unread.append(parsed[1]) + + def recv(self): + """Get the next message or None if nothing to read. + + Doesn't transmit pickles, to avoid injection attacks on the + privileged helper. Only messages that can be converted to json + are allowed. + """ + self._receive_new_messages() + + if len(self._unread) == 0: + return None + + return self._unread.pop(0) + + def poll(self): + """Check if a message to read is available.""" + if len(self._unread) > 0: + return True + + self._receive_new_messages() + return len(self._unread) > 0 + + def send(self, message): + """Send jsonable messages, like numbers, strings or objects.""" + dump = bytes(json.dumps((time.time(), message)), ENCODING) + self.unsent.append(dump) + + if not self.connect(): + logger.spam('Not connected') + return + + def send_all(): + while len(self.unsent) > 0: + unsent = self.unsent[0] + self.connection.sendall(unsent + END) + # sending worked, remove message + self.unsent.pop(0) + + # attempt sending twice in case it fails + try: + send_all() + except BrokenPipeError: + if not self.reconnect(): + logger.error( + '%s: The other side of "%s" disappeared', + type(self).__name__, self._path + ) + return + + try: + send_all() + except BrokenPipeError as error: + logger.error( + '%s: Failed to send via "%s": %s', + type(self).__name__, self._path, error + ) + + +class _Client(Base): + """A socket that can be written to and read from.""" + def connect(self): + if self.socket is not None: + return True + + try: + _socket = socket.socket(socket.AF_UNIX) + _socket.connect(self._path) + logger.spam('Connected to socket: "%s"', self._path) + _socket.setblocking(False) + except Exception as error: + logger.spam('Failed to connect to "%s": "%s"', self._path, error) + return False + + self.socket = _socket + self.connection = _socket + existing_clients[self._path] = self + return True + + def fileno(self): + """For compatibility with select.select""" + self.connect() + return self.socket.fileno() + + def reconnect(self): + self.connection = None + self.socket = None + return self.connect() + + +def Client(path): + if path in existing_clients: + # ensure it is running, might have been closed + existing_clients[path].reset() + return existing_clients[path] + else: + return _Client(path) + + +class _Server(Base): + """A socket that can be written to and read from. + + It accepts one connection at a time, and drops old connections if + a new one is in sight. + """ + def connect(self): + if self.socket is None: + if os.path.exists(self._path): + # leftover from the previous execution + os.remove(self._path) + + _socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + _socket.bind(self._path) + _socket.listen(1) + chown(self._path) + logger.spam('Created socket: "%s"', self._path) + self.socket = _socket + self.socket.setblocking(False) + existing_servers[self._path] = self + + incoming = len(select.select([self.socket], [], [], 0)[0]) != 0 + if not incoming and self.connection is None: + # no existing connection, no client attempting to connect + return False + + if not incoming and self.connection is not None: + # old connection + return True + + if incoming: + logger.spam('Incoming connection: "%s"', self._path) + connection = self.socket.accept()[0] + self.connection = connection + self.connection.setblocking(False) + + return True + + def fileno(self): + """For compatibility with select.select.""" + self.connect() + return self.connection.fileno() + + def reconnect(self): + self.connection = None + return self.connect() + + +def Server(path): + if path in existing_servers: + # ensure it is running, might have been closed + existing_servers[path].reset() + return existing_servers[path] + else: + return _Server(path) diff --git a/keymapper/logger.py b/keymapper/logger.py index 17e46d78..6124e85e 100644 --- a/keymapper/logger.py +++ b/keymapper/logger.py @@ -23,10 +23,13 @@ import os +import shutil import time import logging import pkg_resources +from keymapper.user import HOME + SPAM = 5 @@ -81,9 +84,10 @@ logging.addLevelName(SPAM, "SPAM") logging.Logger.spam = spam logging.Logger.key_spam = key_spam -start = time.time() - -LOG_PATH = os.path.expanduser('~/.log/key-mapper') +LOG_PATH = ( + '/var/log/key-mapper' if os.access('/var/log', os.W_OK) + else f'{HOME}/.log/key-mapper' +) class Formatter(logging.Formatter): @@ -107,22 +111,15 @@ class Formatter(logging.Formatter): logging.INFO: 32, }.get(record.levelno, 0) - # if this runs in a separate process, write down the pid - # to debug exit codes and such - pid = '' - if os.getpid() != logger.main_pid: - pid = f'pid {os.getpid()}, ' - if debug: delta = f'{str(time.time() - start)[:7]}' self._style._fmt = ( # noqa f'\033[{color}m' # color + f'{os.getpid()} ' f'{delta} ' - '\033[1m' # bold - f'%(levelname)s' - '\033[0m' # end style - f'\033[{color}m' # color - f': {pid}%(filename)s, line %(lineno)d, %(message)s' + f'%(levelname)s ' + f'%(filename)s:%(lineno)d: ' + '%(message)s' '\033[0m' # end style ) else: @@ -138,7 +135,6 @@ handler.setFormatter(Formatter()) logger.addHandler(handler) logger.setLevel(logging.INFO) logging.getLogger('asyncio').setLevel(logging.WARNING) -logger.main_pid = os.getpid() try: name = pkg_resources.require('key-mapper')[0].project_name @@ -157,9 +153,10 @@ def is_debug(): return logger.level <= logging.DEBUG -def log_info(): +def log_info(name='key-mapper'): """Log version and name to the console""" # read values from setup.py + logger.info( '%s %s %s https://github.com/sezanzeb/key-mapper', name, version, COMMIT_HASH @@ -168,6 +165,11 @@ def log_info(): if evdev_version: logger.info('python-evdev %s', evdev_version) + logger.info( + '%s %s %s https://github.com/sezanzeb/key-mapper', + name, version, COMMIT_HASH + ) + if is_debug(): logger.warning( 'Debug level will log all your keystrokes! Do not post this ' @@ -175,8 +177,6 @@ def log_info(): 'information with your device!' ) - logger.debug('pid %s', os.getpid()) - def update_verbosity(debug): """Set the logging verbosity according to the settings object. @@ -201,24 +201,24 @@ def update_verbosity(debug): logger.setLevel(logging.INFO) -def add_filehandler(path=LOG_PATH): +def add_filehandler(log_path=LOG_PATH): """Clear the existing logfile and start logging to it.""" logger.info('This output is also stored in "%s"', LOG_PATH) - log_path = os.path.expanduser(path) - log_file = os.path.join(log_path, 'log') - - os.makedirs(log_path, exist_ok=True) + log_path = os.path.expanduser(log_path) + os.makedirs(os.path.dirname(log_path), exist_ok=True) - if os.path.exists(log_file): + if os.path.exists(log_path): # keep the log path small, start from scratch each time - os.remove(log_file) + if os.path.isdir(log_path): + # used to be a folder < 0.8.0 + shutil.rmtree(log_path) + else: + os.remove(log_path) - file_handler = logging.FileHandler(log_file) + file_handler = logging.FileHandler(log_path) file_handler.setFormatter(Formatter()) - logger.info('Logging to "%s"', log_file) + logger.info('Logging to "%s"', log_path) logger.addHandler(file_handler) - - return os.path.join(log_path, log_file) diff --git a/keymapper/paths.py b/keymapper/paths.py index 9eb9cdae..9228c284 100644 --- a/keymapper/paths.py +++ b/keymapper/paths.py @@ -24,46 +24,9 @@ import os import shutil -import getpass -import pwd from keymapper.logger import logger - - -def get_user(): - """Try to find the user who called sudo/pkexec.""" - try: - return os.getlogin() - except OSError: - # failed in some ubuntu installations and in systemd services - pass - - try: - user = os.environ['USER'] - except KeyError: - # possibly the systemd service. no sudo was used - return getpass.getuser() - - if user == 'root': - try: - return os.environ['SUDO_USER'] - except KeyError: - # no sudo was used - pass - - try: - pkexec_uid = int(os.environ['PKEXEC_UID']) - return pwd.getpwuid(pkexec_uid).pw_name - except KeyError: - # no pkexec was used or the uid is unknown - pass - - return user - - -USER = get_user() - -CONFIG_PATH = os.path.join('/home', USER, '.config/key-mapper') +from keymapper.user import USER, CONFIG_PATH def chown(path): @@ -94,6 +57,9 @@ def touch(path, log=True): def mkdir(path, log=True): """Create a folder, give it to the user.""" + if path == '' or path is None: + return + if os.path.exists(path): return @@ -109,6 +75,17 @@ def mkdir(path, log=True): chown(path) +def remove(path): + """Remove whatever is at the path""" + if not os.path.exists(path): + return + + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + + def get_preset_path(device=None, preset=None): """Get a path to the stored preset, or to store a preset to.""" presets_base = os.path.join(CONFIG_PATH, 'presets') diff --git a/keymapper/permissions.py b/keymapper/permissions.py deleted file mode 100644 index f1dfd796..00000000 --- a/keymapper/permissions.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# key-mapper - GUI for device specific keyboard mappings -# Copyright (C) 2021 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 . - - -"""To check if access to devices in /dev is possible.""" - - -import grp -import glob -import getpass -import subprocess -import os - -from keymapper.logger import logger -from keymapper.paths import USER -from keymapper.daemon import is_service_running - - -def check_group(group): - """Check if the required group is active and log if not.""" - try: - in_group = USER in grp.getgrnam(group).gr_mem - except KeyError: - # group doesn't exist. Ignore - return None - - # check if files exist with that group in /dev. Even if plugdev - # exists, that doesn't mean that it is needed. - used_groups = [os.stat(path).st_gid for path in glob.glob('/dev/input/*')] - if grp.getgrnam(group).gr_gid not in used_groups: - return None - - if not in_group: - msg = ( - 'Some devices may not be accessible without being in the ' - f'"{group}" user group.' - ) - logger.warning(msg) - return msg - - try: - groups = subprocess.check_output('groups').decode().split() - group_active = group in groups - except FileNotFoundError: - # groups command missing. Idk if any distro doesn't have it - # but if so, cover the case. - return None - - if in_group and not group_active: - msg = ( - f'You are in the "{group}" group, but your session is not yet ' - 'using it. Some devices may not be accessible. Please log out and ' - 'back in or restart' - ) - logger.warning(msg) - return msg - - return None - - -def check_injection_rights(): - """Check if the user may write into /dev/uinput.""" - if not os.access('/dev/uinput', os.W_OK): - msg = ( - 'Rights to write to /dev/uinput are missing, keycodes cannot ' - 'be injected.' - ) - logger.error(msg) - return msg - - return None - - -def can_read_devices(): - """Get a list of problems before key-mapper can be used properly.""" - if getpass.getuser() == 'root': - return [] - - input_check = check_group('input') - plugdev_check = check_group('plugdev') - - # ubuntu. funnily, individual devices in /dev/input/ have write permitted. - if not is_service_running(): - can_write = check_injection_rights() - else: - can_write = None - - ret = [ - check for check - in [can_write, input_check, plugdev_check] - if check is not None - ] - - return ret diff --git a/keymapper/presets.py b/keymapper/presets.py index c7ae719c..58bc3e0a 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -56,6 +56,10 @@ migrate_path() def get_available_preset_name(device, preset='new preset', copy=False): """Increment the preset name until it is available.""" + if device is None: + # endless loop otherwise + raise ValueError('Device may not be None') + preset = preset.strip() if copy and not re.match(r'^.+\scopy( \d+)?$', preset): diff --git a/keymapper/state.py b/keymapper/state.py index 3913928a..77cde1d5 100644 --- a/keymapper/state.py +++ b/keymapper/state.py @@ -44,6 +44,7 @@ class SystemMapping: def __init__(self): """Construct the system_mapping.""" self._mapping = {} + self.xmodmap = {} self.populate() def list_names(self): @@ -61,8 +62,9 @@ class SystemMapping: stderr=subprocess.STDOUT ).decode() xmodmap = xmodmap.lower() - mappings = re.findall(r'(\d+) = (.+)\n', xmodmap + '\n') - for keycode, names in mappings: + self.xmodmap = re.findall(r'(\d+) = (.+)\n', xmodmap + '\n') + + for keycode, names in self.xmodmap: # there might be multiple, like: # keycode 64 = Alt_L Meta_L Alt_L Meta_L # keycode 204 = NoSymbol Alt_L NoSymbol Alt_L @@ -72,7 +74,7 @@ class SystemMapping: name = names.split()[0] xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET - for keycode, names in mappings: + for keycode, names in self.xmodmap: # but since KP may be mapped like KP_Home KP_7 KP_Home KP_7, # make another pass and add all of them if they don't already # exist. don't overwrite any keycodes. @@ -124,6 +126,14 @@ class SystemMapping: for key in keys: del self._mapping[key] + def get_name(self, code): + """Get the first matching name for the code.""" + for entry in self.xmodmap: + if int(entry[0]) - XKB_KEYCODE_OFFSET == code: + return entry[1].split()[0] + + return None + # one mapping object for the GUI application custom_mapping = Mapping() diff --git a/keymapper/user.py b/keymapper/user.py new file mode 100644 index 00000000..40023009 --- /dev/null +++ b/keymapper/user.py @@ -0,0 +1,65 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2021 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 . + + +"""Figure out the user.""" + + +import os +import getpass +import pwd + + +def get_user(): + """Try to find the user who called sudo/pkexec.""" + try: + return os.getlogin() + except OSError: + # failed in some ubuntu installations and in systemd services + pass + + try: + user = os.environ['USER'] + except KeyError: + # possibly the systemd service. no sudo was used + return getpass.getuser() + + if user == 'root': + try: + return os.environ['SUDO_USER'] + except KeyError: + # no sudo was used + pass + + try: + pkexec_uid = int(os.environ['PKEXEC_UID']) + return pwd.getpwuid(pkexec_uid).pw_name + except KeyError: + # no pkexec was used or the uid is unknown + pass + + return user + + +USER = get_user() + +HOME = '/root' if USER == 'root' else f'/home/{USER}' + +CONFIG_PATH = os.path.join(HOME, '.config/key-mapper') diff --git a/readme/development.md b/readme/development.md index 89ec4495..c49a521d 100644 --- a/readme/development.md +++ b/readme/development.md @@ -1,8 +1,9 @@ # Development Contributions are very welcome, I will gladly review and discuss any merge -requests. This file should give an overview about some notable internals of -key-mapper. +requests. If you have questions about the code and architecture, feel free +to [open an issue](https://github.com/sezanzeb/key-mapper/issues). This +file should give an overview about some internals of key-mapper. ## Roadmap @@ -33,9 +34,9 @@ key-mapper. - [x] automatically load presets when devices get plugged in after login (udev) - [x] map keys using a `modifier + modifier + ... + key` syntax - [x] inject in an additional device instead to avoid clashing capabilities -- [ ] don't run any GTK code as root for wayland compatibility +- [x] don't run any GUI code as root for improved wayland compatibility - [ ] injecting keys that aren't available in the systems keyboard layout -- [ ] add comprehensive tabbed help popup +- [ ] getting it into the official debian repo ## Tests @@ -81,15 +82,21 @@ just need to be commited. - `bin/key-mapper-gtk` the executable that starts the gui. It also sends messages to the service via dbus if certain buttons are clicked. -- `bin/key-mapper-gtk-pkexec` opens a password promt to grant root rights - to the GUI, so that it can read from devices -- `data/key-mapper.policy` configures pkexec +- `bin/key-mapper-helper` provides information to the gui that requires + root rights. Is stopped when the gui closes. +- `data/key-mapper.policy` configures pkexec. By using auth_admin_keep + the user is not asked multiple times for each task that needs elevated + rights. This is done instead of granting the whole application root rights + because it is [considered problematic](https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root). - `data/key-mapper.desktop` is the entry in the start menu **cli** - `bin/key-mapper-control` is an executable to send messages to the service via dbus. It can be used to start and stop injection without a GUI. + The gui also uses it to run the service (if not already running) and + helper, because by using one single command for both the polkit rules file + remembers not to ask for a password again. **service** @@ -125,6 +132,26 @@ just need to be commited. Communication to the service always happens via `key-mapper-control` +## Permissions + +**gui** + +The gui process starts without root rights. It makes sure the daemon and +helper are running via pkexec. + +**daemon** + +The daemon exists to keep injections alive beyond the lifetime of the +user interface. Runs via root. Communicates via dbus. Either started +via systemd or pkexec. + +**helper** + +The helper provides information to the user interface like events and +devices. Communicates via pipes. It should not exceed the lifetime of +the user interface because it exposes all the input events. Starts via +pkexec. + ## Unsupported Devices Either open up an issue or debug it yourself and make a pull request. @@ -147,18 +174,30 @@ readme/capabilities.md **It won't offer mapping a button** -Modify `should_map_as_btn` +If `sudo evtest` shows an event for the button, try to +modify `should_map_as_btn`. If not, the button cannot be mapped. ## How it works It uses evdev. The links below point to older code in 0.7.0 so that their line numbers remain valid. -1. It grabs a device (e.g. /dev/input/event3), so that the key events won't reach X11/Wayland anymore [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L182) -2. Reads the events from it (`evtest` can do it, you can also do `cat /dev/input/event3` which yields binary stuff) [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L413) -3. Looks up the mapping if that event maps to anything [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/keycode_mapper.py#L421) -4. Injects the output event in a new device that key-mapper created (another new path in /dev/input, device name is suffixed by "mapped") [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/keycode_mapper.py#L227), [new device](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L324) -5. Forwards any events that should not be mapped to anything in another new device (device name is suffixed by "forwarded") [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/keycode_mapper.py#L232), [new device](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L342) +1. It grabs a device (e.g. /dev/input/event3), so that the key events won't + reach X11/Wayland anymore + [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L182) +2. Reads the events from it (`evtest` can do it, you can also do + `cat /dev/input/event3` which yields binary stuff) + [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L413) +3. Looks up the mapping if that event maps to anything + [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/keycode_mapper.py#L421) +4. Injects the output event in a new device that key-mapper created (another + new path in /dev/input, device name is suffixed by "mapped") + [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/keycode_mapper.py#L227), + [new device](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L324) +5. Forwards any events that should not be mapped to anything in another new + device (device name is suffixed by "forwarded") + [source](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/keycode_mapper.py#L232), + [new device](https://github.com/sezanzeb/key-mapper/blob/0.7.0/keymapper/injection/injector.py#L342) This stuff is going on as a daemon in the background @@ -167,3 +206,4 @@ This stuff is going on as a daemon in the background - [Guidelines for device capabilities](https://www.kernel.org/doc/Documentation/input/event-codes.txt) - [PyGObject API Reference](https://lazka.github.io/pgi-docs/) - [python-evdev](https://python-evdev.readthedocs.io/en/stable/) +- [Python Unix Domain Sockets](https://pymotw.com/2/socket/uds.html) diff --git a/readme/pylint.svg b/readme/pylint.svg index 5ff69b25..6771fc7a 100644 --- a/readme/pylint.svg +++ b/readme/pylint.svg @@ -1,23 +1,23 @@ - + - + - - + + pylint pylint - 9.84 - 9.84 + 9.8 + 9.8 \ No newline at end of file diff --git a/readme/usage.md b/readme/usage.md index 002cc3c4..75feceaa 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -3,7 +3,7 @@ To open the UI to modify the mappings, look into your applications menu and search for 'Key Mapper'. You should be prompted for your sudo password as special permissions are needed to read events from `/dev/input/` files. -You can also start it via `sudo key-mapper-gtk`. +You can also start it via `key-mapper-gtk`.

@@ -23,17 +23,14 @@ invisible since the daemon maps it independently of the GUI. ## Troubleshooting -If stuff doesn't work, check the output of `sudo key-mapper-gtk -d` and feel free +If stuff doesn't work, check the output of `key-mapper-gtk -d` and feel free to [open up an issue here](https://github.com/sezanzeb/key-mapper/issues/new). Make sure to not post any debug logs that were generated while you entered private information with your device. Debug logs are quite verbose. -If injecting stops after closing the window, the service is not running. -Try `sudo systemctl start key-mapper` in a terminal. - If key-mapper or your presets prevents your input device from working -at all due to autoload, please try to replug your device, wait 3 seconds -and replug it again. No injection should be running anymore. +at all due to autoload, please try to unplug and plug it in twice. +No injection should be running anymore. ## Combinations @@ -212,6 +209,7 @@ running (or without sudo if your user has the appropriate permissions). Examples: ```bash +key-mapper-control --version key-mapper-control --command autoload # if you are running as root user, provide information about the whereabouts of the key-mapper config: key-mapper-control --command autoload --config-dir "/home/user/.config/key-mapper/" diff --git a/setup.py b/setup.py index 7b762ac0..594bf802 100644 --- a/setup.py +++ b/setup.py @@ -45,6 +45,24 @@ class Install(install): install.run(self) +def get_packages(): + """Return all modules used in key-mapper. + + For example 'keymapper.gui'. + """ + result = ['keymapper'] + for name in os.listdir('keymapper'): + if not os.path.isdir(f'keymapper/{name}'): + continue + + if name == '__pycache__': + continue + + result.append(f'keymapper.{name}') + + return result + + setup( name='key-mapper', version='0.7.1', @@ -53,11 +71,7 @@ setup( author_email='proxima@sezanzeb.de', url='https://github.com/sezanzeb/key-mapper', license='GPL-3.0', - packages=[ - 'keymapper', - 'keymapper.gui', - 'keymapper.injection' - ], + packages=get_packages(), data_files=[ # see development.md#files ('/usr/share/key-mapper/', glob.glob('data/*')), @@ -68,9 +82,9 @@ setup( ('/etc/xdg/autostart/', ['data/key-mapper-autoload.desktop']), ('/etc/udev/rules.d', ['data/key-mapper.rules']), ('/usr/bin/', ['bin/key-mapper-gtk']), - ('/usr/bin/', ['bin/key-mapper-gtk-pkexec']), ('/usr/bin/', ['bin/key-mapper-service']), ('/usr/bin/', ['bin/key-mapper-control']), + ('/usr/bin/', ['bin/key-mapper-helper']), ], install_requires=[ 'setuptools', diff --git a/tests/test.py b/tests/test.py index 4823ff16..27446621 100644 --- a/tests/test.py +++ b/tests/test.py @@ -31,6 +31,7 @@ import unittest import subprocess import multiprocessing import asyncio +import psutil import evdev import gi @@ -41,6 +42,9 @@ gi.require_version('GLib', '2.0') assert not os.getcwd().endswith('tests') +os.environ['UNITTEST'] = '1' + + def is_service_running(): """Check if the daemon is running.""" try: @@ -50,6 +54,28 @@ def is_service_running(): return False +def join_children(): + """Wait for child processes to exit. Stop them if it takes too long.""" + this = psutil.Process(os.getpid()) + + i = 0 + time.sleep(EVENT_READ_TIMEOUT) + children = this.children(recursive=True) + while len([c for c in children if c.status() != 'zombie']) > 0: + for child in children: + if i > 10: + child.kill() + print( + f'\033[90m' # color + f'Killed pid {child.pid} because it didn\'t finish in time' + '\033[0m' # end style + ) + + children = this.children(recursive=True) + time.sleep(EVENT_READ_TIMEOUT) + i += 1 + + if is_service_running(): # let tests control daemon existance raise Exception('Expected the service not to be running already.') @@ -62,6 +88,11 @@ sys.path.append(os.getcwd()) # is still running EVENT_READ_TIMEOUT = 0.01 +# based on experience how much time passes at most until +# the helper starts receiving previously pushed events after a +# call to start_reading +START_READING_DELAY = 0.05 + MAX_ABS = 2 ** 15 @@ -170,13 +201,31 @@ fixtures = { } +def setup_pipe(device): + """Create a pipe that can be used to send events to the helper, + which in turn will be sent to the reader + """ + if pending_events.get(device) is None: + pending_events[device] = multiprocessing.Pipe() + + +# make sure those pipes exist before any process (the helper) gets forked, +# so that events can be pushed after the fork. +for fixture in fixtures.values(): + if 'group' in fixture: + setup_pipe(fixture['group']) + + def get_events(): """Get all events written by the injector.""" return uinput_write_history def push_event(device, event): - """Emit a fake event for a device. + """Make a device act like it is reading events from evdev. + + push_event is like hitting a key on a keyboard for stuff that reads from + evdev.InputDevice (which is patched in test.py to work that way) Parameters ---------- @@ -184,15 +233,20 @@ def push_event(device, event): For example 'device 1' event : InputEvent """ - if pending_events.get(device) is None: - pending_events[device] = [] - pending_events[device].append(event) + setup_pipe(device) + pending_events[device][0].send(event) -def new_event(type, code, value, timestamp=None): +def push_events(device, events): + """Push multiple events""" + for event in events: + push_event(device, event) + + +def new_event(type, code, value, timestamp=None, offset=0): """Create a new input_event.""" if timestamp is None: - timestamp = time.time() + timestamp = time.time() + offset sec = int(timestamp) usec = timestamp % 1 * 1000000 @@ -205,33 +259,6 @@ def patch_paths(): paths.CONFIG_PATH = '/tmp/key-mapper-test' -def patch_select(): - # goes hand in hand with patch_evdev, which makes InputDevices return - # their names for `.fd`. - # rlist contains device names therefore, so select.select returns the - # name of the device for which events are pending. - import select - - def new_select(rlist, *args): - ret = [] - for thing in rlist: - if hasattr(thing, 'poll') and thing.poll(): - # the reader receives msgs through pipes. If there is one - # ready, provide the pipe - ret.append(thing) - continue - - if len(pending_events.get(thing, [])) > 0: - ret.append(thing) - - # avoid a fast iterating infinite loop in the reader - time.sleep(0.01) - - return [ret, [], []] - - select.select = new_select - - class InputDevice: # expose as existing attribute, otherwise the patch for # evdev < 1.0.0 will crash the test @@ -246,12 +273,21 @@ class InputDevice: self.phys = fixture.get('phys', 'unset') self.info = fixture.get('info', evdev.device.DeviceInfo(None, None, None, None)) self.name = fixture.get('name', 'unset') - self.fd = self.name # properties that exists for test purposes and are not part of # the original object self.group = fixture.get('group', self.name) + # ensure a pipe exists to make this object act like + # it is reading events from a device + setup_pipe(self.group) + + self.fd = pending_events[self.group][1].fileno() + + def fileno(self): + """Compatibility to select.select.""" + return self.fd + def log(self, key, msg): print( f'\033[90m' # color @@ -265,51 +301,59 @@ class InputDevice: def grab(self): pass + async def async_read_loop(self): + if pending_events.get(self.group) is None: + self.log('no events to read', self.group) + return + + # consume all of them + while pending_events[self.group][1].poll(): + result = pending_events[self.group][1].recv() + self.log(result, 'async_read_loop') + yield result + await asyncio.sleep(0.01) + + # doesn't loop endlessly in order to run tests for the injector in + # the main process + def read(self): # the patched fake InputDevice objects read anything pending from - # that group, to be realistic it would have to check if the provided + # that group. + # To be realistic it would have to check if the provided # element is in its capabilities. - ret = [e.copy() for e in pending_events.get(self.group, [])] - if ret is not None: - # consume all of them - self.log('read all', self.group) - pending_events[self.group] = [] + if self.group not in pending_events: + self.log('no events to read', self.group) + return - return ret + # consume all of them + while pending_events[self.group][1].poll(): + event = pending_events[self.group][1].recv() + self.log(event, 'read') + yield event + time.sleep(EVENT_READ_TIMEOUT) + + def read_loop(self): + """Endless loop that yields events.""" + while True: + event = pending_events[self.group][1].recv() + if event is not None: + self.log(event, 'read_loop') + yield event + time.sleep(EVENT_READ_TIMEOUT) def read_one(self): + """Read one event or none if nothing available.""" if pending_events.get(self.group) is None: return None if len(pending_events[self.group]) == 0: return None - event = pending_events[self.group].pop(0).copy() + time.sleep(EVENT_READ_TIMEOUT) + event = pending_events[self.group][1].recv() self.log(event, 'read_one') return event - def read_loop(self): - """Read all prepared events at once.""" - if pending_events.get(self.group) is None: - return - - while len(pending_events[self.group]) > 0: - result = pending_events[self.group].pop(0).copy() - self.log(result, 'read_loop') - yield result - time.sleep(EVENT_READ_TIMEOUT) - - async def async_read_loop(self): - """Read all prepared events at once.""" - if pending_events.get(self.group) is None: - return - - while len(pending_events[self.group]) > 0: - result = pending_events[self.group].pop(0).copy() - self.log(result, 'async_read_loop') - yield result - await asyncio.sleep(0.01) - def capabilities(self, absinfo=True, verbose=False): result = copy.deepcopy(fixtures[self.path]['capabilities']) @@ -391,6 +435,21 @@ def patch_events(): ) +def patch_os_system(): + """Avoid running pkexec.""" + original_system = os.system + + def system(command): + if 'pkexec' in command: + # because it + # - will open a window for user input + # - has no knowledge of the fixtures and patches + raise Exception('Write patches to avoid running pkexec stuff') + return original_system(command) + + os.system = system + + def clear_write_history(): """Empty the history in preparation for the next test.""" while len(uinput_write_history) > 0: @@ -403,13 +462,16 @@ def clear_write_history(): # the original versions patch_paths() patch_evdev() -patch_select() patch_events() +patch_os_system() from keymapper.logger import update_verbosity + +update_verbosity(True) + from keymapper.injection.injector import Injector from keymapper.config import config -from keymapper.gui.reader import keycode_reader +from keymapper.gui.reader import reader from keymapper.getdevices import refresh_devices from keymapper.state import system_mapping, custom_mapping from keymapper.paths import get_config_path @@ -423,13 +485,30 @@ _fixture_copy = copy.deepcopy(fixtures) environ_copy = copy.deepcopy(os.environ) +def send_event_to_reader(event): + """Act like the helper and send input events to the reader.""" + reader._results._unread.append({ + 'type': 'event', + 'message': ( + event.sec, event.usec, + event.type, event.code, event.value + ) + }) + + def quick_cleanup(log=True): """Reset the applications state.""" if log: print('quick cleanup') - keycode_reader.stop_reading() - keycode_reader.__init__() + for key in list(pending_events.keys()): + while pending_events[key][1].poll(): + pending_events[key][1].recv() + + try: + reader.terminate() + except (BrokenPipeError, OSError): + pass if asyncio.get_event_loop().is_running(): for task in asyncio.all_tasks(): @@ -457,9 +536,6 @@ def quick_cleanup(log=True): for key in list(unreleased.keys()): del unreleased[key] - for key in list(pending_events.keys()): - del pending_events[key] - for path in list(fixtures.keys()): if path not in _fixture_copy: del fixtures[path] @@ -472,20 +548,26 @@ def quick_cleanup(log=True): if key not in environ_copy: del os.environ[key] + join_children() + + reader.clear() + + for _, pipe in pending_events.values(): + assert not pipe.poll() + def cleanup(): """Reset the applications state. - Using this is very slow, usually quick_cleanup() is sufficient. + Using this is slower, usually quick_cleanup() is sufficient. """ print('cleanup') os.system('pkill -f key-mapper-service') - + os.system('pkill -f key-mapper-control') time.sleep(0.05) quick_cleanup(log=False) - refresh_devices() @@ -507,8 +589,6 @@ def spy(obj, name): def main(): - update_verbosity(True) - cleanup() modules = sys.argv[1:] diff --git a/tests/testcases/test_control.py b/tests/testcases/test_control.py index 7f63e147..c990dd82 100644 --- a/tests/testcases/test_control.py +++ b/tests/testcases/test_control.py @@ -25,6 +25,7 @@ import os import time import unittest +from unittest import mock import collections from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader @@ -49,15 +50,17 @@ def import_control(): module = module_from_spec(spec) spec.loader.exec_module(module) - return module.main + return module.main, module.internals -control = import_control() +control, internals = import_control() options = collections.namedtuple( - 'options', - ['command', 'config_dir', 'preset', 'device', 'list_devices', 'key_names'] + 'options', [ + 'command', 'config_dir', 'preset', 'device', 'list_devices', + 'key_names', 'debug' + ] ) @@ -97,7 +100,7 @@ class TestControl(unittest.TestCase): config.set_autoload_preset(devices[1], presets[1]) config.save_config() - control(options('autoload', None, None, None, False, False), daemon) + control(options('autoload', None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (devices[0], presets[0])) self.assertEqual(start_history[1], (devices[1], presets[1])) @@ -107,31 +110,31 @@ class TestControl(unittest.TestCase): self.assertFalse(daemon.autoload_history.may_autoload(devices[1], presets[1])) # calling autoload again doesn't load redundantly - control(options('autoload', None, None, None, False, False), daemon) + control(options('autoload', None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 2) self.assertEqual(stop_counter, 0) self.assertFalse(daemon.autoload_history.may_autoload(devices[0], presets[0])) self.assertFalse(daemon.autoload_history.may_autoload(devices[1], presets[1])) # unless the injection in question ist stopped - control(options('stop', None, None, devices[0], False, False), daemon) + control(options('stop', None, None, devices[0], False, False, False), daemon) self.assertEqual(stop_counter, 1) self.assertTrue(daemon.autoload_history.may_autoload(devices[0], presets[0])) self.assertFalse(daemon.autoload_history.may_autoload(devices[1], presets[1])) - control(options('autoload', None, None, None, False, False), daemon) + control(options('autoload', None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 3) self.assertEqual(start_history[2], (devices[0], presets[0])) self.assertFalse(daemon.autoload_history.may_autoload(devices[0], presets[0])) self.assertFalse(daemon.autoload_history.may_autoload(devices[1], presets[1])) # if a device name is passed, will only start injecting for that one - control(options('stop-all', None, None, None, False, False), daemon) + control(options('stop-all', None, None, None, False, False, False), daemon) self.assertTrue(daemon.autoload_history.may_autoload(devices[0], presets[0])) self.assertTrue(daemon.autoload_history.may_autoload(devices[1], presets[1])) self.assertEqual(stop_counter, 3) config.set_autoload_preset(devices[1], presets[2]) config.save_config() - control(options('autoload', None, None, devices[1], False, False), daemon) + control(options('autoload', None, None, devices[1], False, False, False), daemon) self.assertEqual(len(start_history), 4) self.assertEqual(start_history[3], (devices[1], presets[2])) self.assertTrue(daemon.autoload_history.may_autoload(devices[0], presets[0])) @@ -139,7 +142,7 @@ class TestControl(unittest.TestCase): # autoloading for the same device again redundantly will not autoload # again - control(options('autoload', None, None, devices[1], False, False), daemon) + control(options('autoload', None, None, devices[1], False, False, False), daemon) self.assertEqual(len(start_history), 4) self.assertEqual(stop_counter, 3) self.assertFalse(daemon.autoload_history.may_autoload(devices[1], presets[2])) @@ -174,7 +177,7 @@ class TestControl(unittest.TestCase): config.set_autoload_preset(devices[1], presets[1]) config.save_config() - control(options('autoload', config_dir, None, None, False, False), daemon) + control(options('autoload', config_dir, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (devices[0], presets[0])) @@ -193,15 +196,15 @@ class TestControl(unittest.TestCase): daemon.stop_injecting = lambda *args: stop_history.append(args) daemon.stop_all = lambda *args: stop_all_history.append(args) - control(options('start', None, preset, device, False, False), daemon) + control(options('start', None, preset, device, False, False, False), daemon) self.assertEqual(len(start_history), 1) self.assertEqual(start_history[0], (device, preset)) - control(options('stop', None, None, device, False, False), daemon) + control(options('stop', None, None, device, False, False, False), daemon) self.assertEqual(len(stop_history), 1) self.assertEqual(stop_history[0], (device,)) - control(options('stop-all', None, None, None, False, False), daemon) + control(options('stop-all', None, None, None, False, False, False), daemon) self.assertEqual(len(stop_all_history), 1) self.assertEqual(stop_all_history[0], ()) @@ -217,10 +220,10 @@ class TestControl(unittest.TestCase): daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) - options_1 = options('start', config_dir, path, device, False, False) + options_1 = options('start', config_dir, path, device, False, False, False) self.assertRaises(SystemExit, lambda: control(options_1, daemon)) - options_2 = options('stop', config_dir, None, device, False, False) + options_2 = options('stop', config_dir, None, device, False, False, False) self.assertRaises(SystemExit, lambda: control(options_2, daemon)) def test_autoload_config_dir(self): @@ -245,6 +248,19 @@ class TestControl(unittest.TestCase): daemon.set_config_dir(os.path.join(tmp, 'qux')) self.assertEqual(config.get('foo'), 'bar') + def test_internals(self): + with mock.patch('subprocess.Popen') as popen_patch: + internals(options('helper', None, None, None, False, False, False)) + popen_patch.assert_called_once() + self.assertIn('key-mapper-helper', popen_patch.call_args.args[0]) + self.assertNotIn('-d', popen_patch.call_args.args[0]) + + with mock.patch('subprocess.Popen') as popen_patch: + internals(options('start-daemon', None, None, None, False, False, True)) + popen_patch.assert_called_once() + self.assertIn('key-mapper-service', popen_patch.call_args.args[0]) + self.assertIn('-d', popen_patch.call_args.args[0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/testcases/test_daemon.py b/tests/testcases/test_daemon.py index 86528400..1f03777d 100644 --- a/tests/testcases/test_daemon.py +++ b/tests/testcases/test_daemon.py @@ -34,15 +34,15 @@ from pydbus import SystemBus from keymapper.state import custom_mapping, system_mapping from keymapper.config import config from keymapper.getdevices import get_devices -from keymapper.paths import get_preset_path, get_config_path +from keymapper.paths import get_preset_path, get_config_path, mkdir from keymapper.key import Key from keymapper.mapping import Mapping from keymapper.injection.injector import STARTING, RUNNING, STOPPED, UNKNOWN -from keymapper.daemon import Daemon, get_dbus_interface, BUS_NAME, \ +from keymapper.daemon import Daemon, BUS_NAME, \ path_to_device_name from tests.test import cleanup, uinput_write_history_pipe, new_event, \ - pending_events, is_service_running, fixtures, tmp + push_events, is_service_running, fixtures, tmp def gtk_iteration(): @@ -59,7 +59,10 @@ class TestDBusDaemon(unittest.TestCase): ) self.process.start() time.sleep(0.5) - self.interface = get_dbus_interface() + + # should not use pkexec, but rather connect to the previously + # spawned process + self.interface = Daemon.connect() def tearDown(self): self.interface.stop_all() @@ -80,6 +83,7 @@ class TestDBusDaemon(unittest.TestCase): check_output = subprocess.check_output +os_system = os.system dbus_get = type(SystemBus()).get @@ -89,6 +93,8 @@ class TestDaemon(unittest.TestCase): def setUp(self): self.grab = evdev.InputDevice.grab self.daemon = None + mkdir(get_config_path()) + config.save_config() def tearDown(self): # avoid race conditions with other tests, daemon may run processes @@ -98,6 +104,7 @@ class TestDaemon(unittest.TestCase): evdev.InputDevice.grab = self.grab subprocess.check_output = check_output + os.system = os_system type(SystemBus()).get = dbus_get cleanup() @@ -108,29 +115,28 @@ class TestDaemon(unittest.TestCase): self.assertEqual(path_to_device_name('/dev/input/event1234'), None) self.assertEqual(path_to_device_name('asdf'), 'asdf') - def test_get_dbus_interface(self): - # no daemon runs, should return an instance of the object instead - self.assertFalse(is_service_running()) - self.assertIsInstance(get_dbus_interface(), Daemon) - self.assertIsNone(get_dbus_interface(False)) + def test_connect(self): + os_system_history = [] + os.system = os_system_history.append - subprocess.check_output = lambda *args: None - self.assertTrue(is_service_running()) - # now it actually tries to use the dbus, but it fails - # because none exists, so it returns an instance again - self.assertIsInstance(get_dbus_interface(), Daemon) - self.assertIsNone(get_dbus_interface(False)) + self.assertFalse(is_service_running()) + # no daemon runs, should try to run it via pkexec instead. + # It fails due to the patch and therefore exits the process + self.assertRaises(SystemExit, Daemon.connect) + self.assertEqual(len(os_system_history), 1) + self.assertIsNone(Daemon.connect(False)) class FakeConnection: pass type(SystemBus()).get = lambda *args: FakeConnection() - self.assertIsInstance(get_dbus_interface(), FakeConnection) - self.assertIsInstance(get_dbus_interface(False), FakeConnection) + self.assertIsInstance(Daemon.connect(), FakeConnection) + self.assertIsInstance(Daemon.connect(False), FakeConnection) def test_daemon(self): # remove the existing system mapping to force our own into it - os.remove(get_config_path('xmodmap.json')) + if os.path.exists(get_config_path('xmodmap.json')): + os.remove(get_config_path('xmodmap.json')) ev_1 = (EV_KEY, 9) ev_2 = (EV_ABS, 12) @@ -156,9 +162,9 @@ class TestDaemon(unittest.TestCase): """injection 1""" # should forward the event unchanged - pending_events[device] = [ + push_events(device, [ new_event(EV_KEY, 13, 1) - ] + ]) self.daemon = Daemon() self.daemon.set_config_dir(get_config_path()) @@ -189,9 +195,9 @@ class TestDaemon(unittest.TestCase): """injection 2""" # -1234 will be normalized to -1 by the injector - pending_events[device] = [ + push_events(device, [ new_event(*ev_2, -1234) - ] + ]) self.daemon.start_injecting(device, preset) @@ -207,7 +213,8 @@ class TestDaemon(unittest.TestCase): self.assertEqual(event.value, 1) def test_refresh_devices_on_start(self): - os.remove(get_config_path('xmodmap.json')) + if os.path.exists(get_config_path('xmodmap.json')): + os.remove(get_config_path('xmodmap.json')) ev = (EV_KEY, 9) keycode_to = 100 @@ -226,9 +233,9 @@ class TestDaemon(unittest.TestCase): preset = 'foo' custom_mapping.save(get_preset_path(device, preset)) config.set_autoload_preset(device, preset) - pending_events[device] = [ + push_events(device, [ new_event(*ev, 1) - ] + ]) self.daemon = Daemon() # make sure the devices are populated @@ -297,9 +304,9 @@ class TestDaemon(unittest.TestCase): system_mapping.clear() - pending_events[device] = [ + push_events(device, [ new_event(*event) - ] + ]) # an existing config file is needed otherwise set_config_dir refuses # to use the directory diff --git a/tests/testcases/test_getdevices.py b/tests/testcases/test_getdevices.py index aa34b490..17fe6c67 100644 --- a/tests/testcases/test_getdevices.py +++ b/tests/testcases/test_getdevices.py @@ -20,6 +20,7 @@ import unittest +from unittest import mock import evdev @@ -37,11 +38,7 @@ class FakePipe: class TestGetDevices(unittest.TestCase): - def setUp(self): - self.original_list_devices = evdev.list_devices - def tearDown(self): - evdev.list_devices = self.original_list_devices cleanup() def test_get_devices(self): @@ -120,11 +117,10 @@ class TestGetDevices(unittest.TestCase): } } - evdev.list_devices = list_devices - - refresh_devices() - self.assertNotIn('camera', get_devices()) - self.assertIn('gamepad', get_devices()) + with mock.patch('evdev.list_devices', list_devices): + refresh_devices() + self.assertNotIn('camera', get_devices()) + self.assertIn('gamepad', get_devices()) def test_device_with_only_ev_abs(self): def list_devices(): @@ -143,11 +139,10 @@ class TestGetDevices(unittest.TestCase): } } - evdev.list_devices = list_devices - - refresh_devices() - self.assertIn('gamepad', get_devices()) - self.assertNotIn('qux', get_devices()) + with mock.patch('evdev.list_devices', list_devices): + refresh_devices() + self.assertIn('gamepad', get_devices()) + self.assertNotIn('qux', get_devices()) def test_is_gamepad(self): # properly detects if the device is a gamepad diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index e383838e..bd09918b 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -20,12 +20,13 @@ import unittest +from unittest import mock import time import copy import evdev -from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, BTN_LEFT, KEY_A, \ - REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, BTN_A, ABS_X, ABS_Y, \ +from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, BTN_LEFT, \ + KEY_A, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, BTN_A, ABS_X, ABS_Y, \ ABS_Z, ABS_RZ, ABS_VOLUME, KEY_B, KEY_C from keymapper.injection.injector import Injector, is_in_capabilities, \ @@ -38,17 +39,13 @@ from keymapper.config import config, NONE, MOUSE, WHEEL, BUTTONS from keymapper.key import Key from keymapper.injection.macros import parse from keymapper.injection.context import Context -from keymapper import utils from keymapper.getdevices import get_devices, is_gamepad -from tests.test import new_event, pending_events, fixtures, \ +from tests.test import new_event, push_events, fixtures, \ EVENT_READ_TIMEOUT, uinput_write_history_pipe, \ MAX_ABS, quick_cleanup, read_write_history_pipe, InputDevice, uinputs -original_smeab = utils.should_map_as_btn - - class TestInjector(unittest.TestCase): new_gamepad = '/dev/input/event100' @@ -69,8 +66,6 @@ class TestInjector(unittest.TestCase): evdev.InputDevice.grab = grab_fail_twice def tearDown(self): - utils.should_map_as_btn = original_smeab - if self.injector is not None: self.injector.stop_injecting() self.assertEqual(self.injector.get_state(), STOPPED) @@ -313,12 +308,12 @@ class TestInjector(unittest.TestCase): divisor = 10 x = MAX_ABS / pointer_speed / divisor y = MAX_ABS / pointer_speed / divisor - pending_events['gamepad'] = [ + push_events('gamepad', [ new_event(EV_ABS, ABS_X, x), new_event(EV_ABS, ABS_Y, y), new_event(EV_ABS, ABS_X, -x), new_event(EV_ABS, ABS_Y, -y), - ] + ]) self.injector = Injector('gamepad', custom_mapping) self.injector.start() @@ -353,7 +348,7 @@ class TestInjector(unittest.TestCase): self.assertEqual(len(history), count_x + count_y) def test_gamepad_forward_joysticks(self): - pending_events['gamepad'] = [ + push_events('gamepad', [ # should forward them unmodified new_event(EV_ABS, ABS_X, 10), new_event(EV_ABS, ABS_Y, 20), @@ -361,7 +356,7 @@ class TestInjector(unittest.TestCase): new_event(EV_ABS, ABS_Y, -40), new_event(EV_KEY, BTN_A, 1), new_event(EV_KEY, BTN_A, 0) - ] * 2 + ] * 2) custom_mapping.set('gamepad.joystick.left_purpose', NONE) custom_mapping.set('gamepad.joystick.right_purpose', NONE) @@ -389,10 +384,10 @@ class TestInjector(unittest.TestCase): # map one of the triggers to BTN_NORTH, while the other one # should be forwarded unchanged value = MAX_ABS // 2 - pending_events['gamepad'] = [ + push_events('gamepad', [ new_event(EV_ABS, ABS_Z, value), new_event(EV_ABS, ABS_RZ, value), - ] + ]) # ABS_Z -> 77 # ABS_RZ is not mapped @@ -546,7 +541,7 @@ class TestInjector(unittest.TestCase): system_mapping._set('key_q', code_q) system_mapping._set('w', code_w) - pending_events['device 2'] = [ + push_events('device 2', [ # should execute a macro... new_event(EV_KEY, 8, 1), new_event(EV_KEY, 9, 1), # ...now @@ -559,7 +554,7 @@ class TestInjector(unittest.TestCase): new_event(EV_KEY, 10, 1), new_event(EV_KEY, 10, 0), new_event(3124, 3564, 6542), - ] + ]) self.injector = Injector('device 2', custom_mapping) self.assertEqual(self.injector.get_state(), UNKNOWN) @@ -658,12 +653,12 @@ class TestInjector(unittest.TestCase): while uinput_write_history_pipe[0].poll(): uinput_write_history_pipe[0].recv() - pending_events['gamepad'] = [ + push_events('gamepad', [ new_event(*w_down), new_event(*d_down), new_event(*w_up), new_event(*d_up), - ] + ]) self.injector = Injector('gamepad', custom_mapping) @@ -687,12 +682,12 @@ class TestInjector(unittest.TestCase): """yes""" - utils.should_map_as_btn = lambda *args: True - history = do_stuff() - self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) - self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) - self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) - self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) + with mock.patch('keymapper.utils.should_map_as_btn', lambda *_: True): + history = do_stuff() + self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) def test_wheel(self): # this tests both keycode_mapper and event_producer, and it seems @@ -718,14 +713,14 @@ class TestInjector(unittest.TestCase): system_mapping._set('c', code_c) device_name = 'device 1' - pending_events[device_name] = [ + push_events(device_name, [ new_event(*w_up), ] * 10 + [ new_event(*hw_right), new_event(*w_up), ] * 5 + [ new_event(*hw_left) - ] + ]) self.injector = Injector(device_name, custom_mapping) @@ -791,19 +786,23 @@ class TestInjector(unittest.TestCase): # avoid going into any mainloop raise Stop() - self.injector._construct_capabilities = _construct_capabilities - try: - self.injector.run() - except Stop: - pass - - # one call - self.assertEqual(len(history), 1) - # first argument of the first call - macros = self.injector.context.macros - self.assertEqual(len(macros), 2) - self.assertEqual(macros[(ev_1, ev_2, ev_3)].code, 'k(a)') - self.assertEqual(macros[(ev_2, ev_1, ev_3)].code, 'k(a)') + with mock.patch.object( + self.injector, + '_construct_capabilities', + _construct_capabilities + ): + try: + self.injector.run() + except Stop: + pass + + # one call + self.assertEqual(len(history), 1) + # first argument of the first call + macros = self.injector.context.macros + self.assertEqual(len(macros), 2) + self.assertEqual(macros[(ev_1, ev_2, ev_3)].code, 'k(a)') + self.assertEqual(macros[(ev_2, ev_1, ev_3)].code, 'k(a)') def test_key_to_code(self): mapping = Mapping() diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index ebccd6c6..2d35b850 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -21,9 +21,10 @@ import sys import time -import grp +import atexit import os import unittest +import multiprocessing import evdev from evdev.ecodes import EV_KEY, EV_ABS, KEY_LEFTSHIFT, KEY_A, ABS_RX, \ EV_REL, REL_X, ABS_X @@ -33,21 +34,24 @@ from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader import gi -import shutil gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gdk from keymapper.state import custom_mapping, system_mapping, XMODMAP_FILENAME from keymapper.paths import CONFIG_PATH, get_preset_path, get_config_path from keymapper.config import config, WHEEL, MOUSE, BUTTONS -from keymapper.gui.reader import keycode_reader, FILTER_THRESHOLD -from keymapper.injection.injector import RUNNING -from keymapper.gui.row import to_string, HOLDING, IDLE -from keymapper import permissions +from keymapper.gui.reader import reader +from keymapper.injection.injector import RUNNING, FAILED +from keymapper.gui.row import Row, to_string, HOLDING, IDLE +from keymapper.gui.window import Window from keymapper.key import Key +from keymapper.daemon import Daemon +from keymapper.getdevices import get_devices, set_devices +from keymapper.gui.helper import RootHelper -from tests.test import tmp, pending_events, new_event, spy, cleanup, \ - uinput_write_history_pipe, MAX_ABS +from tests.test import tmp, push_events, new_event, spy, cleanup, \ + uinput_write_history_pipe, MAX_ABS, EVENT_READ_TIMEOUT, \ + send_event_to_reader def gtk_iteration(): @@ -80,6 +84,10 @@ def launch(argv=None): gtk_iteration() + # otherwise a new handler is added with each call to launch, which + # spams tons of garbage when all tests finish + atexit.unregister(module.stop) + return module.window @@ -93,6 +101,93 @@ class FakeDropdown(Gtk.ComboBoxText): def get_active_id(self): return self.name + def set_active_id(self, name): + self.name = name + + +def clean_up_integration(test): + if hasattr(test, 'original_on_close'): + test.window.on_close = test.original_on_close + + test.window.on_apply_system_layout_clicked(None) + gtk_iteration() + test.window.on_close() + test.window.window.destroy() + gtk_iteration() + cleanup() + + # do this now, not when all tests are finished + test.window.dbus.stop_all() + if isinstance(test.window.dbus, Daemon): + atexit.unregister(test.window.dbus.stop_all) + + +original_on_select_preset = Window.on_select_preset + + +class TestGetDevicesFromHelper(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.injector = None + cls.grab = evdev.InputDevice.grab + + # don't try to connect, return an object instance of it instead + cls.original_connect = Daemon.connect + Daemon.connect = Daemon + + cls.original_os_system = os.system + + def os_system(cmd): + # instead of running pkexec, fork instead. This will make + # the helper aware of all the test patches + if 'pkexec key-mapper-control --command helper' in cmd: + # the process will have the initial value of None + set_devices(None) + multiprocessing.Process(target=RootHelper).start() + # the gui an empty dict, because it doesn't know any devices + # without the help of the privileged helper + set_devices({}) + else: + cls.original_os_system(cmd) + + os.system = os_system + + def setUp(self): + self.window = launch() + # verify that the ui doesn't have knowledge of any device yet + self.assertIsNone(self.window.selected_device) + self.assertEqual(len(get_devices()), 0) + + def tearDown(self): + clean_up_integration(self) + + @classmethod + def tearDownClass(cls): + os.system = cls.original_os_system + Daemon.connect = cls.original_connect + + @patch('keymapper.gui.window.Window.on_select_preset') + def test_knows_devices(self, on_select_preset_patch): + # verify that it is working as expected + gtk_iteration() + self.assertIsNone(self.window.selected_device) + self.assertIsNone(self.window.selected_preset) + self.assertEqual(len(get_devices()), 0) + on_select_preset_patch.assert_not_called() + + # perform some iterations so that the gui ends up running + # consume_newest_keycode, which will make it receive devices. + # Restore patch, otherwise gtk complains when disabling handlers + Window.on_select_preset = original_on_select_preset + for _ in range(10): + time.sleep(0.01) + gtk_iteration() + + self.assertIn('device 1', get_devices()) + self.assertIn('device 2', get_devices()) + self.assertIn('gamepad', get_devices()) + self.assertEqual(self.window.selected_device, 'device 1') + class TestIntegration(unittest.TestCase): """For tests that use the window. @@ -104,6 +199,15 @@ class TestIntegration(unittest.TestCase): cls.injector = None cls.grab = evdev.InputDevice.grab + def start_processes(self): + """Avoid running pkexec which requires user input, and fork in + order to pass the fixtures to the helper and daemon process. + """ + multiprocessing.Process(target=RootHelper).start() + self.dbus = Daemon() + + Window.start_processes = start_processes + def setUp(self): self.window = launch() self.original_on_close = self.window.on_close @@ -114,14 +218,10 @@ class TestIntegration(unittest.TestCase): raise OSError() evdev.InputDevice.grab = grab + config.save_config() + def tearDown(self): - self.window.on_close = self.original_on_close - self.window.on_apply_system_layout_clicked(None) - gtk_iteration() - self.window.on_close() - self.window.window.destroy() - gtk_iteration() - cleanup() + clean_up_integration(self) def get_rows(self): return self.window.get('key_list').get_children() @@ -246,10 +346,26 @@ class TestIntegration(unittest.TestCase): preset = json.load(file) self.assertEqual(len(preset['mapping']), 0) + def test_permission_error_on_create_preset_clicked(self): + def save(_=None): + raise PermissionError + with patch.object(custom_mapping, 'save', save): + self.window.on_create_preset_clicked(None) + status = self.get_status_text() + self.assertIn('Permission denied', status) + + def test_show_injection_result_failure(self): + def get_state(_=None): + return FAILED + + with patch.object(self.window.dbus, 'get_state', get_state): + self.window.show_injection_result() + text = self.get_status_text() + self.assertIn('Failed', text) + def test_row_keycode_to_string(self): # not an integration test, but I have all the row tests here already - self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.KEY_9, 1)), '9') - self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.KEY_SEMICOLON, 1)), 'SEMICOLON') + self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.KEY_A, 1)), 'a') self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1)), 'ABS_HAT0X L') self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1)), 'ABS_HAT0Y U') self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.BTN_A, 1)), 'BTN_A') @@ -280,7 +396,7 @@ class TestIntegration(unittest.TestCase): self.assertEqual(row.get_key(), (EV_KEY, 30, 1)) # this is KEY_A in linux/input-event-codes.h, # but KEY_ is removed from the text - self.assertEqual(row.keycode_input.get_label(), 'A') + self.assertEqual(row.keycode_input.get_label(), 'a') row.set_new_key(Key(EV_KEY, 30, 1)) self.assertEqual(len(custom_mapping), 0) @@ -301,15 +417,42 @@ class TestIntegration(unittest.TestCase): self.assertEqual(row.get_character(), 'Shift_L') self.assertEqual(row.get_key(), (EV_KEY, 30, 1)) - def wait_until_reader_pipe_clear(self): - for i in range(100): - if keycode_reader._pipe[0].poll(): - time.sleep(0.01) - gtk_iteration() - else: - break - else: - raise Exception('Expected the event to be read at some point') + def sleep(self, num_events): + for _ in range(num_events * 2): + time.sleep(EVENT_READ_TIMEOUT) + gtk_iteration() + + time.sleep(1 / 30) # one window iteration + gtk_iteration() + + def test_row_not_focused(self): + self.window.window.set_focus(self.window.get('preset_name_input')) + send_event_to_reader(new_event(1, 61, 1)) + self.window.consume_newest_keycode() + + rows = self.get_rows() + self.assertEqual(len(rows), 1) + row = rows[0] + + # the empty row has this key not set + self.assertIsNone(row.get_key()) + + # focus the text input instead + self.window.window.set_focus(row.character_input) + send_event_to_reader(new_event(1, 61, 1)) + self.window.consume_newest_keycode() + + # still nothing set + self.assertIsNone(row.get_key()) + + def test_show_status(self): + self.window.show_status(0, 'a' * 100) + text = self.get_status_text() + self.assertIn('...', text) + + self.window.show_status(0, 'b') + text = self.get_status_text() + self.assertNotIn('...', text) def change_empty_row(self, key, char, code_first=True, expect_success=True): """Modify the one empty row that always exists. @@ -327,7 +470,7 @@ class TestIntegration(unittest.TestCase): in the mapping eventually. False if this change is going to cause a duplicate. """ - self.assertIsNone(keycode_reader.get_unreleased_keys()) + self.assertIsNone(reader.get_unreleased_keys()) # wait for the window to create a new empty row if needed time.sleep(0.1) @@ -361,36 +504,36 @@ class TestIntegration(unittest.TestCase): # modifies the keycode in the row not by writing into the input, # but by sending an event. press down all the keys of a combination for sub_key in key: - keycode_reader._pipe[1].send(new_event(*sub_key)) - time.sleep(FILTER_THRESHOLD * 1.1) + + send_event_to_reader(new_event(*sub_key)) # this will be consumed all at once, since no gt_iteration # is done # make the window consume the keycode - self.wait_until_reader_pipe_clear() + self.sleep(len(key)) # holding down - self.assertIsNotNone(keycode_reader.get_unreleased_keys()) - self.assertGreater(len(keycode_reader.get_unreleased_keys()), 0) + self.assertIsNotNone(reader.get_unreleased_keys()) + self.assertGreater(len(reader.get_unreleased_keys()), 0) self.assertEqual(row._state, HOLDING) self.assertTrue(row.keycode_input.is_focus()) # release all the keys for sub_key in key: - keycode_reader._pipe[1].send(new_event(*sub_key[:2], 0)) + send_event_to_reader(new_event(*sub_key[:2], 0)) # wait for the window to consume the keycode - self.wait_until_reader_pipe_clear() + self.sleep(len(key)) # released - self.assertIsNone(keycode_reader.get_unreleased_keys()) + self.assertIsNone(reader.get_unreleased_keys()) self.assertEqual(row._state, IDLE) if expect_success: self.assertEqual(row.get_key(), key) self.assertEqual(row.keycode_input.get_label(), to_string(key)) self.assertFalse(row.keycode_input.is_focus()) - self.assertEqual(len(keycode_reader._unreleased), 0) + self.assertEqual(len(reader._unreleased), 0) if not expect_success: self.assertIsNone(row.get_key()) @@ -416,20 +559,20 @@ class TestIntegration(unittest.TestCase): # focused self.window.window.set_focus(row.keycode_input) - keycode_reader._pipe[1].send(new_event(*ev_1.keys[0])) - keycode_reader.read() - self.assertEqual(keycode_reader.get_unreleased_keys(), ev_1) + send_event_to_reader(new_event(*ev_1.keys[0])) + reader.read() + self.assertEqual(reader.get_unreleased_keys(), ev_1) # unfocused self.window.window.set_focus(None) - self.assertEqual(keycode_reader.get_unreleased_keys(), None) - keycode_reader._pipe[1].send(new_event(*ev_1.keys[0])) - keycode_reader.read() - self.assertEqual(keycode_reader.get_unreleased_keys(), ev_1) + self.assertEqual(reader.get_unreleased_keys(), None) + send_event_to_reader(new_event(*ev_1.keys[0])) + reader.read() + self.assertEqual(reader.get_unreleased_keys(), ev_1) # focus back self.window.window.set_focus(row.keycode_input) - self.assertEqual(keycode_reader.get_unreleased_keys(), None) + self.assertEqual(reader.get_unreleased_keys(), None) def test_rows(self): """Comprehensive test for rows.""" @@ -659,7 +802,8 @@ class TestIntegration(unittest.TestCase): self.window.save_preset() self.window.on_rename_button_clicked(None) self.assertEqual(self.window.selected_preset, 'asdf') - self.assertTrue(os.path.exists(f'{CONFIG_PATH}/presets/device 1/asdf.json')) + preset_path = f'{CONFIG_PATH}/presets/device 1/asdf.json' + self.assertTrue(os.path.exists(preset_path)) self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 14, 1)), 'b') # after renaming the preset it is still set to autoload self.assertTrue(config.is_autoloaded('device 1', 'asdf')) @@ -667,7 +811,22 @@ class TestIntegration(unittest.TestCase): error_icon = self.window.get('error_status_icon') self.assertFalse(error_icon.get_visible()) - def test_rename_and_create(self): + # otherwise save won't do anything + custom_mapping.change(Key(EV_KEY, 14, 1), 'c', None) + self.assertTrue(custom_mapping.changed) + + def save(_): + raise PermissionError + with patch.object(custom_mapping, 'save', save): + self.window.save_preset() + status = self.get_status_text() + self.assertIn('Permission denied', status) + + with patch.object(self.window, 'show_confirm_delete', lambda: Gtk.ResponseType.ACCEPT): + self.window.on_delete_preset_clicked(None) + self.assertFalse(os.path.exists(preset_path)) + + def test_rename_create_switch(self): # after renaming a preset and saving it, new presets # start with "new preset" again custom_mapping.change(Key(EV_KEY, 14, 1), 'a', None) @@ -680,9 +839,15 @@ class TestIntegration(unittest.TestCase): self.window.on_create_preset_clicked(None) self.assertEqual(self.window.selected_preset, 'new preset') self.assertIsNone(custom_mapping.get_character(Key(EV_KEY, 14, 1))) + self.window.save_preset() + + # selecting the first one again loads the saved mapping + self.window.on_select_preset(FakeDropdown('asdf')) + self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 14, 1)), 'a') config.set_autoload_preset('device 1', 'new preset') - # renaming another preset to an existing name appends a number + # renaming a preset to an existing name appends a number + self.window.on_select_preset(FakeDropdown('new preset')) self.window.get('preset_name_input').set_text('asdf') self.window.on_rename_button_clicked(None) self.assertEqual(self.window.selected_preset, 'asdf 2') @@ -690,6 +855,22 @@ class TestIntegration(unittest.TestCase): # configuration as well self.assertTrue(config.is_autoloaded('device 1', 'asdf 2')) + self.assertEqual(self.window.get('preset_name_input').get_text(), '') + + # renaming the current preset to itself doesn't append a number and + # it doesn't do anything on the file system + def _raise(*_): + # should not get called + raise AssertionError + with patch.object(os, 'rename', _raise): + self.window.get('preset_name_input').set_text('asdf 2') + self.window.on_rename_button_clicked(None) + self.assertEqual(self.window.selected_preset, 'asdf 2') + + self.window.get('preset_name_input').set_text('') + self.window.on_rename_button_clicked(None) + self.assertEqual(self.window.selected_preset, 'asdf 2') + def test_avoids_redundant_saves(self): custom_mapping.change(Key(EV_KEY, 14, 1), 'abcd', None) @@ -893,30 +1074,20 @@ class TestIntegration(unittest.TestCase): # empty custom_mapping.empty() - custom_mapping.save(get_preset_path(device_name, preset_name)) + self.window.save_preset() self.window.on_apply_preset_clicked(None) text = self.get_status_text() self.assertIn('add keys', text) - self.assertIn('save', text) self.assertTrue(error_icon.get_visible()) self.assertNotEqual(self.window.dbus.get_state(device_name), RUNNING) - # not empty, but not saved + # not empty, but keys are held down custom_mapping.change(Key(EV_KEY, KEY_A, 1), 'a') - self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertNotIn('add keys', text) - self.assertIn('save', text) - self.assertTrue(error_icon.get_visible()) - self.assertNotEqual(self.window.dbus.get_state(device_name), RUNNING) - - # saved, but keys are held down - - custom_mapping.save(get_preset_path(device_name, preset_name)) - keycode_reader._pipe[1].send(new_event(EV_KEY, KEY_A, 1)) - keycode_reader.read() - self.assertEqual(len(keycode_reader._unreleased), 1) + self.window.save_preset() + send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) + reader.read() + self.assertEqual(len(reader._unreleased), 1) self.assertFalse(self.window.unreleased_warn) self.window.on_apply_preset_clicked(None) text = self.get_status_text() @@ -952,43 +1123,55 @@ class TestIntegration(unittest.TestCase): self.assertNotEqual(self.window.dbus.get_state(device_name), RUNNING) # for the second try, release the key. that should also work - keycode_reader._pipe[1].send(new_event(EV_KEY, KEY_A, 0)) - keycode_reader.read() - self.assertEqual(len(keycode_reader._unreleased), 0) + send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) + reader.read() + self.assertEqual(len(reader._unreleased), 0) - # this time work, but changes are unsaved + # this time work properly - custom_mapping.change(Key(EV_KEY, KEY_A, 1), 'b') self.grab_fails = False + custom_mapping.save(get_preset_path(device_name, preset_name)) self.window.on_apply_preset_clicked(None) text = self.get_status_text() - # it takes a little bit of time self.assertIn('Starting injection', text) self.assertFalse(error_icon.get_visible()) wait() text = self.get_status_text() self.assertIn('Applied', text) - self.assertIn('unsaved', text) self.assertFalse(error_icon.get_visible()) self.assertEqual(self.window.dbus.get_state(device_name), RUNNING) - self.assertEqual(self.window.get('apply_system_layout').get_opacity(), 1) - # save changes, this time work properly + # because this test managed to reproduce some minor bug: + self.assertNotIn('mapping', custom_mapping._config) + def test_can_modify_mapping(self): + preset_name = 'foo preset' + device_name = 'device 2' + self.window.selected_preset = preset_name + self.window.selected_device = device_name + + self.assertNotEqual(self.window.dbus.get_state(device_name), RUNNING) + self.window.can_modify_mapping() + text = self.get_status_text() + self.assertNotIn('Restore Defaults', text) + + custom_mapping.change(Key(EV_KEY, KEY_A, 1), 'b') custom_mapping.save(get_preset_path(device_name, preset_name)) self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn('Starting injection', text) - self.assertFalse(error_icon.get_visible()) - wait() - text = self.get_status_text() - self.assertIn('Applied', text) - self.assertNotIn('unsaved', text) - self.assertFalse(error_icon.get_visible()) + + # wait for the injector to start + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if 'Starting' not in self.get_status_text(): + return + self.assertEqual(self.window.dbus.get_state(device_name), RUNNING) - # because this test managed to reproduce some minor bug: - self.assertNotIn('mapping', custom_mapping._config) + # the mapping cannot be changed anymore + self.window.can_modify_mapping() + text = self.get_status_text() + self.assertIn('Restore Defaults', text) def test_start_injecting(self): keycode_from = 9 @@ -998,15 +1181,16 @@ class TestIntegration(unittest.TestCase): system_mapping.clear() system_mapping._set('a', keycode_to) - pending_events['device 2'] = [ + push_events('device 2', [ new_event(evdev.events.EV_KEY, keycode_from, 1), new_event(evdev.events.EV_KEY, keycode_from, 0) - ] + ]) custom_mapping.save(get_preset_path('device 2', 'foo preset')) # use only the manipulated system_mapping - os.remove(os.path.join(tmp, XMODMAP_FILENAME)) + if os.path.exists(os.path.join(tmp, XMODMAP_FILENAME)): + os.remove(os.path.join(tmp, XMODMAP_FILENAME)) # spy on set_config_dir set_config_dir_history = spy(self.window.dbus, 'set_config_dir') @@ -1047,13 +1231,13 @@ class TestIntegration(unittest.TestCase): # don't consume the events in the reader, they are used to test # the injection - keycode_reader.stop_reading() + reader.terminate() time.sleep(0.1) - pending_events['gamepad'] = [ + push_events('gamepad', [ new_event(EV_ABS, ABS_RX, -MAX_ABS), new_event(EV_ABS, ABS_X, MAX_ABS) - ] * 100 + ] * 100) custom_mapping.change(Key(EV_ABS, ABS_X, 1), 'a') self.window.save_preset() @@ -1085,7 +1269,7 @@ class TestIntegration(unittest.TestCase): # not all of those events should be processed, since that takes some # time due to time.sleep in the fakes and the injection is stopped. - pending_events['device 2'] = [new_event(1, keycode_from, 1)] * 100 + push_events('device 2', [new_event(1, keycode_from, 1)] * 100) custom_mapping.save(get_preset_path('device 2', 'foo preset')) @@ -1138,56 +1322,66 @@ class TestIntegration(unittest.TestCase): self.assertEqual(self.window.selected_preset, 'new preset') self.assertEqual(self.window.selected_device, 'device 1') + def test_populate_devices(self): + preset_selection = self.window.get('preset_selection') + device_selection = self.window.get('device_selection') -original_access = os.access -original_getgrnam = grp.getgrnam -original_can_read_devices = permissions.can_read_devices - - -class TestPermissions(unittest.TestCase): - def tearDown(self): - os.access = original_access - os.getgrnam = original_getgrnam - permissions.can_read_devices = original_can_read_devices - - if self.window is not None: - self.window.on_close() - self.window.window.destroy() - gtk_iteration() - self.window = None - - shutil.rmtree('/tmp/key-mapper-test') - - def test_fails(self): - def fake(): - return ['error1', 'error2', 'error3'] + # create two presets + self.window.get('preset_name_input').set_text('preset 1') + self.window.on_rename_button_clicked(None) + self.assertEqual(preset_selection.get_active_id(), 'preset 1') - permissions.can_read_devices = fake + # to make sure the next preset has a slightly higher timestamp + time.sleep(0.1) + self.window.on_create_preset_clicked(None) + self.window.get('preset_name_input').set_text('preset 2') + self.window.on_rename_button_clicked(None) + self.assertEqual(preset_selection.get_active_id(), 'preset 2') - self.window = launch() - status = self.window.get('status_bar') - error_icon = self.window.get('error_status_icon') + # select the older one + preset_selection.set_active_id('preset 1') + self.assertEqual(self.window.selected_preset, 'preset 1') - tooltip = status.get_tooltip_text() - self.assertIn('sudo', tooltip) - self.assertIn('pkexec', tooltip) - self.assertIn('error1', tooltip) - self.assertIn('error2', tooltip) - self.assertIn('error3', tooltip) - self.assertTrue(error_icon.get_visible()) + # add a device that doesn't exist to the dropdown + device_selection.insert(0, 'foo', 'foo') - def test_good(self): - def fake(): - return [] + # now the newest preset should be selected and the non-existing + # device removed + self.window.populate_devices() + self.assertEqual(self.window.selected_preset, 'preset 2') + # Idk how I can check if foo is not in the list except for this way: + device_selection.set_active_id('foo') + self.assertEqual(device_selection.get_active_id(), 'device 1') - permissions.can_read_devices = fake - - self.window = launch() - status = self.window.get('status_bar') - error_icon = self.window.get('error_status_icon') + def test_screw_up_rows(self): + # add a row that is not present in custom_mapping + key_list = self.window.get('key_list') + key_list.forall(key_list.remove) + for i in range(5): + broken = Row(window=self.window, delete_callback=lambda: None) + broken.set_new_key(Key(1, i, 1)) + broken.character_input.set_text('a') + key_list.insert(broken, -1) + custom_mapping.empty() - self.assertIsNone(status.get_tooltip_text()) - self.assertFalse(error_icon.get_visible()) + # the ui has 5 rows, the custom_mapping 0. mismatch + num_rows_before = len(key_list.get_children()) + self.assertEqual(len(custom_mapping), 0) + self.assertEqual(num_rows_before, 5) + + # it returns true to keep the glib timeout going + self.assertTrue(self.window.check_add_row()) + # it still adds a new empty row and won't break + num_rows_after = len(key_list.get_children()) + self.assertEqual(num_rows_after, num_rows_before + 1) + + rows = key_list.get_children() + self.assertEqual(rows[0].get_character(), 'a') + self.assertEqual(rows[1].get_character(), 'a') + self.assertEqual(rows[2].get_character(), 'a') + self.assertEqual(rows[3].get_character(), 'a') + self.assertEqual(rows[4].get_character(), 'a') + self.assertEqual(rows[5].get_character(), None) if __name__ == "__main__": diff --git a/tests/testcases/test_ipc.py b/tests/testcases/test_ipc.py new file mode 100644 index 00000000..f6b6d985 --- /dev/null +++ b/tests/testcases/test_ipc.py @@ -0,0 +1,142 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2021 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 +import select + +from keymapper.ipc.pipe import Pipe +from keymapper.ipc.socket import Server, Client, Base + + +class TestSocket(unittest.TestCase): + def test_socket(self): + def test(s1, s2): + self.assertEqual(s2.recv(), None) + + s1.send(1) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 1) + self.assertFalse(s2.poll()) + self.assertEqual(s2.recv(), None) + + s1.send(2) + self.assertTrue(s2.poll()) + s1.send(3) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 2) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 3) + self.assertFalse(s2.poll()) + self.assertEqual(s2.recv(), None) + + server = Server('/tmp/key-mapper-test/socket1') + client = Client('/tmp/key-mapper-test/socket1') + test(server, client) + + client = Client('/tmp/key-mapper-test/socket2') + server = Server('/tmp/key-mapper-test/socket2') + test(client, server) + + def test_not_connected_1(self): + # client discards old message, because it might have had a purpose + # for a different client and not for the current one + server = Server('/tmp/key-mapper-test/socket3') + server.send(1) + + client = Client('/tmp/key-mapper-test/socket3') + server.send(2) + + self.assertTrue(client.poll()) + self.assertEqual(client.recv(), 2) + self.assertFalse(client.poll()) + self.assertEqual(client.recv(), None) + + def test_not_connected_2(self): + client = Client('/tmp/key-mapper-test/socket4') + client.send(1) + + server = Server('/tmp/key-mapper-test/socket4') + client.send(2) + + self.assertTrue(server.poll()) + self.assertEqual(server.recv(), 2) + self.assertFalse(server.poll()) + self.assertEqual(server.recv(), None) + + def test_select(self): + """is compatible to select.select""" + server = Server('/tmp/key-mapper-test/socket6') + client = Client('/tmp/key-mapper-test/socket6') + + server.send(1) + ready = select.select([client], [], [], 0)[0][0] + self.assertEqual(ready, client) + + client.send(2) + ready = select.select([server], [], [], 0)[0][0] + self.assertEqual(ready, server) + + def test_base_abstract(self): + self.assertRaises(NotImplementedError, lambda: Base('foo')) + self.assertRaises(NotImplementedError, lambda: Base.connect(None)) + self.assertRaises(NotImplementedError, lambda: Base.reconnect(None)) + self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) + + +class TestPipe(unittest.TestCase): + def test_pipe_single(self): + p1 = Pipe('/tmp/key-mapper-test/pipe') + self.assertEqual(p1.recv(), None) + + p1.send(1) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 1) + self.assertFalse(p1.poll()) + self.assertEqual(p1.recv(), None) + + p1.send(2) + self.assertTrue(p1.poll()) + p1.send(3) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 2) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 3) + self.assertFalse(p1.poll()) + self.assertEqual(p1.recv(), None) + + def test_pipe_duo(self): + p1 = Pipe('/tmp/key-mapper-test/pipe') + p2 = Pipe('/tmp/key-mapper-test/pipe') + self.assertEqual(p2.recv(), None) + + p1.send(1) + self.assertEqual(p2.recv(), 1) + self.assertEqual(p2.recv(), None) + + p1.send(2) + p1.send(3) + self.assertEqual(p2.recv(), 2) + self.assertEqual(p2.recv(), 3) + self.assertEqual(p2.recv(), None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index 1f3774e8..19514b44 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -133,14 +133,14 @@ class TestKeycodeMapper(unittest.TestCase): (EV_KEY, context.key_to_code[(ev_1,)]) ) self.assertEqual(unreleased.get(ev_1[:2]).input_event_tuple, ev_1) - self.assertEqual(unreleased.get(ev_1[:2]).key, (ev_1,)) # as seen in key_to_code + self.assertEqual(unreleased.get(ev_1[:2]).triggered_key, (ev_1,)) # as seen in key_to_code self.assertEqual( unreleased.get(ev_4[:2]).target_type_code, (EV_KEY, context.key_to_code[(ev_4,)]), ev_4 ) self.assertEqual(unreleased.get(ev_4[:2]).input_event_tuple, ev_4) - self.assertEqual(unreleased.get(ev_4[:2]).key, (ev_4,)) + self.assertEqual(unreleased.get(ev_4[:2]).triggered_key, (ev_4,)) # release all of them keycode_mapper.handle_keycode(new_event(*ev_3)) @@ -1139,9 +1139,9 @@ class TestKeycodeMapper(unittest.TestCase): self.assertIn(combi_1[0][:2], unreleased) self.assertIn(combi_1[1][:2], unreleased) # since this event did not trigger anything, key is None - self.assertEqual(unreleased[combi_1[0][:2]].key, None) + self.assertEqual(unreleased[combi_1[0][:2]].triggered_key, None) # that one triggered something from _key_to_code, so the key is that - self.assertEqual(unreleased[combi_1[1][:2]].key, combi_1) + self.assertEqual(unreleased[combi_1[1][:2]].triggered_key, combi_1) # release the last key of the combi first, it should # release what the combination maps to diff --git a/tests/testcases/test_logger.py b/tests/testcases/test_logger.py index e3cb9237..c7c07048 100644 --- a/tests/testcases/test_logger.py +++ b/tests/testcases/test_logger.py @@ -26,25 +26,26 @@ import logging from keymapper.logger import logger, add_filehandler, update_verbosity, \ log_info +from keymapper.paths import remove from tests.test import tmp class TestLogger(unittest.TestCase): def tearDown(self): + update_verbosity(debug=True) + # remove the file handler logger.handlers = [ handler for handler in logger.handlers if not isinstance(logger.handlers, logging.FileHandler) ] path = os.path.join(tmp, 'logger-test') - if os.path.exists(path): - shutil.rmtree(path) - - update_verbosity(debug=True) + remove(path) def test_key_spam(self): - path = add_filehandler(os.path.join(tmp, 'logger-test')) + path = os.path.join(tmp, 'logger-test') + add_filehandler(path) logger.key_spam(((1, 2, 1),), 'foo %s bar', 1234) logger.key_spam(((1, 200, -1), (1, 5, 1)), 'foo %s', (1, 2)) with open(path, 'r') as f: @@ -54,7 +55,8 @@ class TestLogger(unittest.TestCase): def test_log_info(self): update_verbosity(debug=False) - path = add_filehandler(os.path.join(tmp, 'logger-test')) + path = os.path.join(tmp, 'logger-test') + add_filehandler(path) log_info() with open(path, 'r') as f: content = f.read().lower() @@ -70,7 +72,7 @@ class TestLogger(unittest.TestCase): self.assertTrue(os.path.exists(new_path)) def test_clears_log(self): - path = os.path.join(tmp, 'logger-test', 'log') + path = os.path.join(tmp, 'logger-test') os.makedirs(os.path.dirname(path), exist_ok=True) os.mknod(path) with open(path, 'w') as f: @@ -80,7 +82,8 @@ class TestLogger(unittest.TestCase): self.assertEqual(f.read(), '') def test_debug(self): - path = add_filehandler(os.path.join(tmp, 'logger-test')) + path = os.path.join(tmp, 'logger-test') + add_filehandler(path) logger.error('abc') logger.warning('foo') logger.info('123') @@ -89,7 +92,6 @@ class TestLogger(unittest.TestCase): with open(path, 'r') as f: content = f.read().lower() self.assertIn('logger.py', content) - self.assertIn('line', content) self.assertIn('error', content) self.assertIn('abc', content) @@ -107,7 +109,8 @@ class TestLogger(unittest.TestCase): self.assertIn('789', content) def test_default(self): - path = add_filehandler(os.path.join(tmp, 'logger-test')) + path = os.path.join(tmp, 'logger-test') + add_filehandler(path) update_verbosity(debug=False) logger.error('abc') logger.warning('foo') diff --git a/tests/testcases/test_paths.py b/tests/testcases/test_paths.py index c0215b63..5fa021e3 100644 --- a/tests/testcases/test_paths.py +++ b/tests/testcases/test_paths.py @@ -21,16 +21,14 @@ import os import unittest +from unittest import mock -from keymapper.paths import get_user, touch, mkdir, \ - get_preset_path, get_config_path +from keymapper.paths import touch, mkdir, get_preset_path, get_config_path +from keymapper.user import get_user from tests.test import quick_cleanup, tmp -original_getlogin = os.getlogin() - - def _raise(error): raise error @@ -38,25 +36,23 @@ def _raise(error): class TestPaths(unittest.TestCase): def tearDown(self): quick_cleanup() - os.getlogin = original_getlogin def test_get_user(self): - os.getlogin = lambda: 'foo' - self.assertEqual(get_user(), 'foo') - - os.getlogin = lambda: 'root' - self.assertEqual(get_user(), 'root') + with mock.patch('os.getlogin', lambda: 'foo'): + self.assertEqual(get_user(), 'foo') - os.getlogin = lambda: _raise(OSError()) + with mock.patch('os.getlogin', lambda: 'root'): + self.assertEqual(get_user(), 'root') - os.environ['USER'] = 'root' - os.environ['SUDO_USER'] = 'qux' - self.assertEqual(get_user(), 'qux') + with mock.patch('os.getlogin', lambda: _raise(OSError())): + os.environ['USER'] = 'root' + os.environ['SUDO_USER'] = 'qux' + self.assertEqual(get_user(), 'qux') - os.environ['USER'] = 'root' - del os.environ['SUDO_USER'] - os.environ['PKEXEC_UID'] = '1000' - self.assertNotEqual(get_user(), 'root') + os.environ['USER'] = 'root' + del os.environ['SUDO_USER'] + os.environ['PKEXEC_UID'] = '1000' + self.assertNotEqual(get_user(), 'root') def test_touch(self): touch('/tmp/a/b/c/d/e') diff --git a/tests/testcases/test_permissions.py b/tests/testcases/test_permissions.py deleted file mode 100644 index ee84776e..00000000 --- a/tests/testcases/test_permissions.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# key-mapper - GUI for device specific keyboard mappings -# Copyright (C) 2021 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 os -import grp -import getpass -import subprocess -import unittest - -from keymapper.permissions import check_injection_rights, check_group, \ - can_read_devices -from keymapper.paths import USER -from keymapper.daemon import is_service_running - - -original_access = os.access -original_getgrnam = grp.getgrnam -original_check_output = subprocess.check_output -original_stat = os.stat -oringal_getuser = getpass.getuser - - -class TestPermissions(unittest.TestCase): - def tearDown(self): - # reset all fakes - os.access = original_access - grp.getgrnam = original_getgrnam - subprocess.check_output = original_check_output - os.stat = original_stat - getpass.getuser = oringal_getuser - - def test_check_injection_rights(self): - can_access = False - os.access = lambda *args: can_access - - self.assertIsNotNone(check_injection_rights()) - can_access = True - self.assertIsNone(check_injection_rights()) - - def fake_setup(self): - """Patch some functions to have the following fake environment: - - Groups - ------ - input: id: 0, members: $USER, used in /dev, set up - plugdev: id: 1, members: $USER, used in /dev, not in `groups` - foobar: id: 2, no members, used in /dev - a_unused: id: 0, members: $USER, not used in /dev, set up - b_unused: id: 1, members: $USER, not used in /dev, not in `groups` - c_unused: id: 2, no members, not used in /dev - """ - gr_mems = { - 'input': (0, [USER]), - 'plugdev': (1, [USER]), - 'foobar': (2, []), - 'a_unused': (3, [USER]), - 'b_unused': (4, [USER]), - 'c_unused': (5, []) - } - - stat_counter = 0 - - class stat: - def __init__(self, path): - nonlocal stat_counter - stat_counter += 1 - # make sure stat returns all of those groups at some point. - # only works if there are more than three files in /dev, which - # should be the case - self.st_gid = [0, 1, 2][stat_counter % 3] - - os.stat = stat - - class getgrnam: - def __init__(self, group): - if group not in gr_mems: - raise KeyError() - - self.gr_gid = gr_mems[group][0] - self.gr_mem = gr_mems[group][1] - - grp.getgrnam = getgrnam - - def fake_check_output(cmd): - # fake the `groups` output to act like the current session only - # has input and a_unused active - if cmd == 'groups' or cmd[0] == 'groups': - return b'foo input a_unused bar' - - return original_check_output(cmd) - - subprocess.check_output = fake_check_output - - def test_can_read_devices(self): - self.fake_setup() - self.assertFalse(is_service_running()) - - # root user doesn't need this stuff - getpass.getuser = lambda: 'root' - self.assertEqual(len(can_read_devices()), 0) - - getpass.getuser = lambda: USER - os.access = lambda *args: False - # plugdev not yet setup correctly and cannot write - self.assertEqual(len(can_read_devices()), 2) - - os.access = lambda *args: True - self.assertEqual(len(can_read_devices()), 1) - - subprocess.check_output = lambda cmd: b'plugdev input' - self.assertEqual(len(can_read_devices()), 0) - - def test_check_group(self): - self.fake_setup() - - # correctly setup - self.assertIsNone(check_group('input')) - - # session restart required, usermod already done - self.assertIsNotNone(check_group('plugdev')) - self.assertIn('plugdev', check_group('plugdev')) - self.assertIn('session', check_group('plugdev')) - - # usermod required - self.assertIsNotNone(check_group('foobar')) - self.assertIn('foobar', check_group('foobar')) - self.assertIn('group', check_group('foobar')) - - # don't exist in /dev - self.assertIsNone(check_group('a_unused')) - self.assertIsNone(check_group('b_unused')) - self.assertIsNone(check_group('c_unused')) - - # group doesn't exist - self.assertIsNone(check_group('qux')) - - def file_not_found_error(cmd): - raise FileNotFoundError() - subprocess.check_output = file_not_found_error - - # groups command doesn't exist, so cannot check this suff - self.assertIsNone(check_group('plugdev')) - # which doesn't affect the grp lib - self.assertIsNotNone(check_group('foobar')) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index b28f288a..ec0372fa 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -23,18 +23,19 @@ import unittest import time import multiprocessing -from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_COMMA, \ - BTN_LEFT, BTN_TOOL_DOUBLETAP, ABS_Z, ABS_Y, ABS_MISC, KEY_A, \ +from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, KEY_COMMA, \ + BTN_LEFT, BTN_TOOL_DOUBLETAP, ABS_Z, ABS_Y, KEY_A, \ EV_REL, REL_WHEEL, REL_X, ABS_X, ABS_RZ -from keymapper.gui.reader import keycode_reader, will_report_up, \ - event_unix_time +from keymapper.gui.reader import reader, will_report_up from keymapper.state import custom_mapping from keymapper.config import BUTTONS, MOUSE from keymapper.key import Key +from keymapper.gui.helper import RootHelper +from keymapper.getdevices import set_devices -from tests.test import new_event, pending_events, EVENT_READ_TIMEOUT, \ - quick_cleanup, MAX_ABS +from tests.test import new_event, push_events, send_event_to_reader, \ + EVENT_READ_TIMEOUT, START_READING_DELAY, quick_cleanup, MAX_ABS CODE_1 = 100 @@ -55,43 +56,50 @@ def wait(func, timeout=1.0): class TestReader(unittest.TestCase): def setUp(self): - # verify that tearDown properly cleared the reader - self.assertEqual(keycode_reader.read(), None) + self.helper = None def tearDown(self): quick_cleanup() + if self.helper is not None: + self.helper.join() + + def create_helper(self): + # this will cause pending events to be copied over to the helper + # process + def start_helper(): + helper = RootHelper() + helper.run() + + self.helper = multiprocessing.Process(target=start_helper) + self.helper.start() + time.sleep(0.1) def test_will_report_up(self): self.assertFalse(will_report_up(EV_REL)) self.assertTrue(will_report_up(EV_ABS)) self.assertTrue(will_report_up(EV_KEY)) - def test_event_unix_time(self): - event = new_event(1, 1, 1, 1234.5678) - self.assertEqual(event_unix_time(event), 1234.5678) - self.assertEqual(event_unix_time(None), 0) - def test_reading_1(self): # a single event - pending_events['device 1'] = [ - new_event(EV_ABS, ABS_HAT0X, 1), - new_event(EV_REL, REL_X, 1), # mouse movements are ignored - ] - keycode_reader.start_reading('device 1') - wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) + push_events('device 1', [new_event(EV_ABS, ABS_HAT0X, 1)]) + push_events('device 1', [new_event(EV_ABS, REL_X, 1)]) # mouse movements are ignored + self.create_helper() + reader.start_reading('device 1') + time.sleep(0.2) + self.assertEqual(reader.read(), (EV_ABS, ABS_HAT0X, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) def test_reading_wheel(self): # will be treated as released automatically at some point - keycode_reader.start_reading('device 1') + self.create_helper() + reader.start_reading('device 1') - keycode_reader._pipe[1].send(new_event(EV_REL, REL_WHEEL, 0)) - self.assertIsNone(keycode_reader.read()) + send_event_to_reader(new_event(EV_REL, REL_WHEEL, 0)) + self.assertIsNone(reader.read()) - keycode_reader._pipe[1].send(new_event(EV_REL, REL_WHEEL, 1)) - result = keycode_reader.read() + send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) + result = reader.read() self.assertIsInstance(result, Key) self.assertEqual(result, (EV_REL, REL_WHEEL, 1)) self.assertEqual(result, ((EV_REL, REL_WHEEL, 1),)) @@ -99,41 +107,41 @@ class TestReader(unittest.TestCase): self.assertEqual(result.keys, ((EV_REL, REL_WHEEL, 1),)) # it won't return the same event twice - self.assertEqual(keycode_reader.read(), None) + self.assertEqual(reader.read(), None) # but it is still remembered unreleased - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertEqual(keycode_reader.get_unreleased_keys(), (EV_REL, REL_WHEEL, 1)) - self.assertIsInstance(keycode_reader.get_unreleased_keys(), Key) + self.assertEqual(len(reader._unreleased), 1) + self.assertEqual(reader.get_unreleased_keys(), (EV_REL, REL_WHEEL, 1)) + self.assertIsInstance(reader.get_unreleased_keys(), Key) # as long as new wheel events arrive, it is considered unreleased for _ in range(10): - keycode_reader._pipe[1].send(new_event(EV_REL, REL_WHEEL, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) + send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) # read a few more times, at some point it is treated as unreleased for _ in range(4): - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) - self.assertIsNone(keycode_reader.get_unreleased_keys()) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) + self.assertIsNone(reader.get_unreleased_keys()) """combinations""" - keycode_reader._pipe[1].send(new_event(EV_REL, REL_WHEEL, 1, 1000)) - keycode_reader._pipe[1].send(new_event(EV_KEY, KEY_COMMA, 1, 1001)) + send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1, 1000)) + send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 1, 1001)) combi_1 = ((EV_REL, REL_WHEEL, 1), (EV_KEY, KEY_COMMA, 1)) combi_2 = ((EV_KEY, KEY_COMMA, 1), (EV_KEY, KEY_A, 1)) - read = keycode_reader.read() + read = reader.read() self.assertEqual(read, combi_1) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 2) - self.assertEqual(keycode_reader.get_unreleased_keys(), combi_1) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 2) + self.assertEqual(reader.get_unreleased_keys(), combi_1) # don't send new wheel down events, it should get released again i = 0 - while len(keycode_reader._unreleased) == 2: - read = keycode_reader.read() + while len(reader._unreleased) == 2: + read = reader.read() if i == 100: raise AssertionError('Did not release the wheel') i += 1 @@ -141,91 +149,112 @@ class TestReader(unittest.TestCase): # only returned when a new key is pressed. Only then the pressed # down keys are collected in a new Key object. self.assertEqual(read, None) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertEqual(keycode_reader.get_unreleased_keys(), combi_1[1]) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) + self.assertEqual(reader.get_unreleased_keys(), combi_1[1]) # press down a new key, now it will return a different combination - keycode_reader._pipe[1].send(new_event(EV_KEY, KEY_A, 1, 1002)) - self.assertEqual(keycode_reader.read(), combi_2) - self.assertEqual(len(keycode_reader._unreleased), 2) + send_event_to_reader(new_event(EV_KEY, KEY_A, 1, 1002)) + self.assertEqual(reader.read(), combi_2) + self.assertEqual(len(reader._unreleased), 2) # release all of them - keycode_reader._pipe[1].send(new_event(EV_KEY, KEY_COMMA, 0)) - keycode_reader._pipe[1].send(new_event(EV_KEY, KEY_A, 0)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) - self.assertEqual(keycode_reader.get_unreleased_keys(), None) + send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 0)) + send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) + self.assertEqual(reader.get_unreleased_keys(), None) def test_change_wheel_direction(self): + self.assertEqual(reader.read(), None) + self.create_helper() + self.assertEqual(reader.read(), None) # not just wheel, anything that suddenly reports a different value. # as long as type and code are equal its the same key, so there is no # way both directions can be held down. - keycode_reader.start_reading('device 1') + reader.start_reading('device 1') + self.assertEqual(reader.read(), None) - keycode_reader._pipe[1].send(new_event(1234, 2345, 1)) - self.assertEqual(keycode_reader.read(), (1234, 2345, 1)) - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertEqual(keycode_reader.read(), None) + send_event_to_reader(new_event(1234, 2345, 1)) + self.assertEqual(reader.read(), (1234, 2345, 1)) + self.assertEqual(len(reader._unreleased), 1) + self.assertEqual(reader.read(), None) - keycode_reader._pipe[1].send(new_event(1234, 2345, -1)) - self.assertEqual(keycode_reader.read(), (1234, 2345, -1)) + send_event_to_reader(new_event(1234, 2345, -1)) + self.assertEqual(reader.read(), (1234, 2345, -1)) # notice that this is no combination of two sides, the previous # entry in unreleased has to get overwritten. So there is still only # one element in it. - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) + self.assertEqual(reader.read(), None) + + def test_change_device(self): + push_events('device 1', [ + new_event(EV_KEY, 1, 1), + ] * 100) + + push_events('device 2', [ + new_event(EV_KEY, 2, 1), + ] * 100) + + self.create_helper() + + reader.start_reading('device 1') + time.sleep(0.1) + self.assertEqual(reader.read(), Key(EV_KEY, 1, 1)) - def test_stop_reading(self): - keycode_reader.start_reading('device 1') + reader.start_reading('device 2') + + # it's plausible that right after sending the new read command more + # events from the old device might still appear. Give the helper + # some time to handle the new command. time.sleep(0.1) - self.assertTrue(keycode_reader._process.is_alive()) - keycode_reader.stop_reading() + reader.clear() + time.sleep(0.1) - self.assertFalse(keycode_reader._process.is_alive()) - self.assertEqual(keycode_reader.read(), None) + self.assertEqual(reader.read(), Key(EV_KEY, 2, 1)) def test_reading_2(self): # a combination of events - pending_events['device 1'] = [ + push_events('device 1', [ new_event(EV_KEY, CODE_1, 1, 10000.1234), new_event(EV_KEY, CODE_3, 1, 10001.1234), new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234) - ] - keycode_reader.start_reading('device 1') + ]) + self.create_helper() + reader.start_reading('device 1') - # sending anything arbitrary does not stop the pipe - keycode_reader._pipe[0].send(856794) + # sending anything arbitrary does not stop the helper + reader._commands.send(856794) - wait(keycode_reader._pipe[0].poll, 0.5) + time.sleep(0.2) - self.assertEqual(keycode_reader.read(), ( + self.assertEqual(reader.read(), ( (EV_KEY, CODE_1, 1), (EV_KEY, CODE_3, 1), (EV_ABS, ABS_HAT0X, -1) )) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 3) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 3) def test_reading_3(self): - # a combination of events via the pipe with reads inbetween - keycode_reader.start_reading('device 1') + self.create_helper() + # a combination of events via Socket with reads inbetween + reader.start_reading('device 1') - pipe = keycode_reader._pipe - - pipe[1].send(new_event(EV_KEY, CODE_1, 1, 1001)) - self.assertEqual(keycode_reader.read(), ( + send_event_to_reader(new_event(EV_KEY, CODE_1, 1, 1001)) + self.assertEqual(reader.read(), ( (EV_KEY, CODE_1, 1) )) - pipe[1].send(new_event(EV_ABS, ABS_Y, 1, 1002)) - self.assertEqual(keycode_reader.read(), ( + send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1002)) + self.assertEqual(reader.read(), ( (EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1) )) - pipe[1].send(new_event(EV_ABS, ABS_HAT0X, -1, 1003)) - self.assertEqual(keycode_reader.read(), ( + send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1003)) + self.assertEqual(reader.read(), ( (EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1), (EV_ABS, ABS_HAT0X, -1) @@ -234,273 +263,246 @@ class TestReader(unittest.TestCase): # adding duplicate down events won't report a different combination. # import for triggers, as they keep reporting more down-events before # they are released - pipe[1].send(new_event(EV_ABS, ABS_Y, 1, 1005)) - self.assertEqual(keycode_reader.read(), None) - pipe[1].send(new_event(EV_ABS, ABS_HAT0X, -1, 1006)) - self.assertEqual(keycode_reader.read(), None) + send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1005)) + self.assertEqual(reader.read(), None) + send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1006)) + self.assertEqual(reader.read(), None) - pipe[1].send(new_event(EV_KEY, CODE_1, 0, 1004)) - read = keycode_reader.read() + send_event_to_reader(new_event(EV_KEY, CODE_1, 0, 1004)) + read = reader.read() self.assertEqual(read, None) - pipe[1].send(new_event(EV_ABS, ABS_Y, 0, 1007)) - self.assertEqual(keycode_reader.read(), None) + send_event_to_reader(new_event(EV_ABS, ABS_Y, 0, 1007)) + self.assertEqual(reader.read(), None) - pipe[1].send(new_event(EV_KEY, ABS_HAT0X, 0, 1008)) - self.assertEqual(keycode_reader.read(), None) + send_event_to_reader(new_event(EV_KEY, ABS_HAT0X, 0, 1008)) + self.assertEqual(reader.read(), None) def test_reads_joysticks(self): # if their purpose is "buttons" custom_mapping.set('gamepad.joystick.left_purpose', BUTTONS) - pending_events['gamepad'] = [ + push_events('gamepad', [ new_event(EV_ABS, ABS_Y, MAX_ABS), # the value of that one is interpreted as release, because # it is too small new_event(EV_ABS, ABS_X, MAX_ABS // 10) - ] - keycode_reader.start_reading('gamepad') - wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_Y, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) - - keycode_reader._unreleased = {} + ]) + self.create_helper() + + reader.start_reading('gamepad') + time.sleep(0.2) + self.assertEqual(reader.read(), (EV_ABS, ABS_Y, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) + + reader._unreleased = {} custom_mapping.set('gamepad.joystick.left_purpose', MOUSE) - pending_events['gamepad'] = [ + push_events('gamepad', [ new_event(EV_ABS, ABS_Y, MAX_ABS) - ] - keycode_reader.start_reading('gamepad') + ]) + self.create_helper() + + reader.start_reading('gamepad') time.sleep(0.1) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) def test_combine_triggers(self): - pipe = multiprocessing.Pipe() - keycode_reader._pipe = pipe + reader.start_reading('foo') i = 0 + def next_timestamp(): nonlocal i i += 1 - return 100 * i + return time.time() + i # based on an observed bug - pipe[1].send(new_event(3, 1, 0, next_timestamp())) - pipe[1].send(new_event(3, 0, 0, next_timestamp())) - pipe[1].send(new_event(3, 2, 1, next_timestamp())) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_Z, 1)) - pipe[1].send(new_event(3, 0, 0, next_timestamp())) - pipe[1].send(new_event(3, 5, 1, next_timestamp())) - self.assertEqual(keycode_reader.read(), ((EV_ABS, ABS_Z, 1), (EV_ABS, ABS_RZ, 1))) - pipe[1].send(new_event(3, 5, 0, next_timestamp())) - pipe[1].send(new_event(3, 0, 0, next_timestamp())) - pipe[1].send(new_event(3, 1, 0, next_timestamp())) - self.assertEqual(keycode_reader.read(), None) - pipe[1].send(new_event(3, 2, 1, next_timestamp())) - pipe[1].send(new_event(3, 1, 0, next_timestamp())) - pipe[1].send(new_event(3, 0, 0, next_timestamp())) + send_event_to_reader(new_event(3, 1, 0, next_timestamp())) + send_event_to_reader(new_event(3, 0, 0, next_timestamp())) + send_event_to_reader(new_event(3, 2, 1, next_timestamp())) + self.assertEqual(reader.read(), (EV_ABS, ABS_Z, 1)) + send_event_to_reader(new_event(3, 0, 0, next_timestamp())) + send_event_to_reader(new_event(3, 5, 1, next_timestamp())) + self.assertEqual(reader.read(), ((EV_ABS, ABS_Z, 1), (EV_ABS, ABS_RZ, 1))) + send_event_to_reader(new_event(3, 5, 0, next_timestamp())) + send_event_to_reader(new_event(3, 0, 0, next_timestamp())) + send_event_to_reader(new_event(3, 1, 0, next_timestamp())) + self.assertEqual(reader.read(), None) + send_event_to_reader(new_event(3, 2, 1, next_timestamp())) + send_event_to_reader(new_event(3, 1, 0, next_timestamp())) + send_event_to_reader(new_event(3, 0, 0, next_timestamp())) # due to not properly handling the duplicate down event it cleared # the combination and returned it. Instead it should report None # and by doing that keep the previous combination. - self.assertEqual(keycode_reader.read(), None) + self.assertEqual(reader.read(), None) def test_ignore_btn_left(self): # click events are ignored because overwriting them would render the # mouse useless, but a mouse is needed to stop the injection # comfortably. Furthermore, reading mouse events breaks clicking # around in the table. It can still be changed in the config files. - pending_events['device 1'] = [ + push_events('device 1', [ new_event(EV_KEY, BTN_LEFT, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, BTN_TOOL_DOUBLETAP, 1), - ] - keycode_reader.start_reading('device 1') + ]) + self.create_helper() + reader.start_reading('device 1') time.sleep(0.1) - self.assertEqual(keycode_reader.read(), (EV_KEY, CODE_2, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) + self.assertEqual(reader.read(), (EV_KEY, CODE_2, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) def test_ignore_value_2(self): # this is not a combination, because (EV_KEY CODE_3, 2) is ignored - pending_events['device 1'] = [ + push_events('device 1', [ new_event(EV_ABS, ABS_HAT0X, 1), new_event(EV_KEY, CODE_3, 2) - ] - keycode_reader.start_reading('device 1') - wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) + ]) + self.create_helper() + reader.start_reading('device 1') + time.sleep(0.2) + self.assertEqual(reader.read(), (EV_ABS, ABS_HAT0X, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) def test_reading_ignore_up(self): - pending_events['device 1'] = [ + push_events('device 1', [ new_event(EV_KEY, CODE_1, 0, 10), new_event(EV_KEY, CODE_2, 1, 11), new_event(EV_KEY, CODE_3, 0, 12), - ] - keycode_reader.start_reading('device 1') + ]) + self.create_helper() + reader.start_reading('device 1') time.sleep(0.1) - self.assertEqual(keycode_reader.read(), (EV_KEY, CODE_2, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) + self.assertEqual(reader.read(), (EV_KEY, CODE_2, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) def test_reading_ignore_duplicate_down(self): - pipe = multiprocessing.Pipe() - pipe[1].send(new_event(EV_ABS, ABS_Z, 1, 10)) - keycode_reader._pipe = pipe + send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10)) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_Z, 1)) - self.assertEqual(keycode_reader.read(), None) + self.assertEqual(reader.read(), (EV_ABS, ABS_Z, 1)) + self.assertEqual(reader.read(), None) # duplicate - pipe[1].send(new_event(EV_ABS, ABS_Z, 1, 10)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertEqual(len(keycode_reader.get_unreleased_keys()), 1) - self.assertIsInstance(keycode_reader.get_unreleased_keys(), Key) + send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) + self.assertEqual(len(reader.get_unreleased_keys()), 1) + self.assertIsInstance(reader.get_unreleased_keys(), Key) # release - pipe[1].send(new_event(EV_ABS, ABS_Z, 0, 10)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) - self.assertIsNone(keycode_reader.get_unreleased_keys()) + send_event_to_reader(new_event(EV_ABS, ABS_Z, 0, 10)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) + self.assertIsNone(reader.get_unreleased_keys()) def test_wrong_device(self): - pending_events['device 1'] = [ + push_events('device 1', [ new_event(EV_KEY, CODE_1, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, CODE_3, 1) - ] - keycode_reader.start_reading('device 2') + ]) + self.create_helper() + reader.start_reading('device 2') time.sleep(EVENT_READ_TIMEOUT * 5) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) def test_keymapper_devices(self): # Don't read from keymapper devices, their keycodes are not # representative for the original key. As long as this is not # intentionally programmed it won't even do that. But it was at some # point. - pending_events['key-mapper device 2'] = [ + push_events('key-mapper device 2', [ new_event(EV_KEY, CODE_1, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, CODE_3, 1) - ] - keycode_reader.start_reading('device 2') + ]) + self.create_helper() + reader.start_reading('device 2') time.sleep(EVENT_READ_TIMEOUT * 5) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) def test_clear(self): - keycode_reader.start_reading('device 1') - pipe = keycode_reader._pipe - - pipe[1].send(new_event(EV_KEY, CODE_1, 1)) - pipe[1].send(new_event(EV_KEY, CODE_2, 1)) - pipe[1].send(new_event(EV_KEY, CODE_3, 1)) - - keycode_reader.read() - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertIsNotNone(keycode_reader.previous_event) - self.assertIsNotNone(keycode_reader.previous_result) - - keycode_reader.clear() - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 0) - self.assertIsNone(keycode_reader.get_unreleased_keys()) - self.assertIsNone(keycode_reader.previous_event) - self.assertIsNone(keycode_reader.previous_result) + push_events('device 1', [ + new_event(EV_KEY, CODE_1, 1), + new_event(EV_KEY, CODE_2, 1), + new_event(EV_KEY, CODE_3, 1) + ] * 15) + + self.create_helper() + reader.start_reading('device 1') + time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT * 3) + + reader.read() + self.assertEqual(len(reader._unreleased), 3) + self.assertIsNotNone(reader.previous_event) + self.assertIsNotNone(reader.previous_result) + + # make the helper send more events to the reader + time.sleep(EVENT_READ_TIMEOUT * 2) + self.assertTrue(reader._results.poll()) + reader.clear() + + self.assertFalse(reader._results.poll()) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 0) + self.assertIsNone(reader.get_unreleased_keys()) + self.assertIsNone(reader.previous_event) + self.assertIsNone(reader.previous_result) + self.tearDown() def test_switch_device(self): - pending_events['device 2'] = [new_event(EV_KEY, CODE_1, 1)] - pending_events['device 1'] = [new_event(EV_KEY, CODE_3, 1)] + push_events('device 2', [new_event(EV_KEY, CODE_1, 1)]) + push_events('device 1', [new_event(EV_KEY, CODE_3, 1)]) + self.create_helper() - keycode_reader.start_reading('device 2') + reader.start_reading('device 2') + self.assertFalse(reader._results.poll()) + self.assertEqual(reader.device_name, 'device 2') time.sleep(EVENT_READ_TIMEOUT * 5) - keycode_reader.start_reading('device 1') + self.assertTrue(reader._results.poll()) + reader.start_reading('device 1') + self.assertEqual(reader.device_name, 'device 1') + self.assertFalse(reader._results.poll()) # pipe resets + time.sleep(EVENT_READ_TIMEOUT * 5) + self.assertTrue(reader._results.poll()) - self.assertEqual(keycode_reader.read(), (EV_KEY, CODE_3, 1)) - self.assertEqual(keycode_reader.read(), None) - - def test_prioritizing_1_normalize(self): - # filter the ABS_MISC events of the wacom intuos 5 out that come - # with every button press. Or more general, prioritize them - # based on the event type - pending_events['device 1'] = [ - # all ABS values will be fitted into [-1, 0, 1] - new_event(EV_ABS, ABS_HAT0X, 5678, 1234.0000), - new_event(EV_ABS, ABS_HAT0X, 0, 1234.0001), - - new_event(EV_ABS, ABS_HAT0X, 5678, 1235.0000), # ignored - new_event(EV_ABS, ABS_HAT0X, 0, 1235.0001), - - new_event(EV_KEY, KEY_COMMA, 1, 1235.0010), - new_event(EV_KEY, KEY_COMMA, 0, 1235.0011), - - new_event(EV_ABS, ABS_HAT0X, 5678, 1235.0020), # ignored - new_event(EV_ABS, ABS_HAT0X, 0, 1235.0021), # ignored - - new_event(EV_ABS, ABS_HAT0X, 5678, 1236.0000) - ] - keycode_reader.start_reading('device 1') - wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) - self.assertEqual( - keycode_reader.get_unreleased_keys(), - ((EV_ABS, ABS_HAT0X, 1),) - ) - - def test_prioritizing_2(self): - custom_mapping.set('gamepad.joystick.left_purpose', BUTTONS) + self.assertEqual(reader.read(), (EV_KEY, CODE_3, 1)) + self.assertEqual(reader.read(), None) + self.assertEqual(len(reader._unreleased), 1) - keycode_reader.start_reading('gamepad') - pipe = keycode_reader._pipe + def test_terminate(self): + self.create_helper() + reader.start_reading('device 1') - pipe[1].send(new_event(EV_ABS, ABS_HAT0X, 1, 1234.0000)), - pipe[1].send(new_event(EV_ABS, ABS_MISC, 1, 1235.0000)), - self.assertEqual(keycode_reader.read(), ( - (EV_ABS, ABS_HAT0X, 1), - (EV_ABS, ABS_MISC, 1) - )) + push_events('device 1', [new_event(EV_KEY, CODE_3, 1)]) + time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) + self.assertTrue(reader._results.poll()) - # will make the previous ABS_MISC event get ignored - pipe[1].send(new_event(EV_ABS, ABS_Y, 1, 1235.0010)), - pipe[1].send(new_event(EV_ABS, ABS_MISC, 1, 1235.0020)), # ignored - pipe[1].send(new_event(EV_ABS, ABS_MISC, 1, 1235.0030)) # ignored - # this time, don't release anything. the combination should - # ignore stuff as well. - self.assertEqual(keycode_reader.read(), ( - (EV_ABS, ABS_HAT0X, 1), - (EV_ABS, ABS_Y, 1) - )) + reader.terminate() + reader.clear() - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 2) - self.assertEqual(keycode_reader.get_unreleased_keys(), ( - (EV_ABS, ABS_HAT0X, 1), - (EV_ABS, ABS_Y, 1) - )) - self.assertIsInstance(keycode_reader.get_unreleased_keys(), Key) - - def test_prioritizing_3_normalize(self): - # take the sign of -1234, just like in test_prioritizing_2_normalize - pending_events['device 1'] = [ - # HAT0X usually reports only -1, 0 and 1, but that shouldn't - # matter. Everything is normalized. - new_event(EV_ABS, ABS_HAT0X, -1234, 1234.0000), - new_event(EV_ABS, ABS_HAT0Y, 0, 1234.0030) # ignored - # this time don't release anything as well, but it's not - # a combination because only one event is accepted - ] - keycode_reader.start_reading('device 1') - wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, -1)) - self.assertEqual(keycode_reader.read(), None) - self.assertEqual(len(keycode_reader._unreleased), 1) + # no new events arrive after terminating + push_events('device 1', [new_event(EV_KEY, CODE_3, 1)]) + time.sleep(EVENT_READ_TIMEOUT * 3) + self.assertFalse(reader._results.poll()) + + def test_are_new_devices_available(self): + self.create_helper() + set_devices({}) + + # read stuff from the helper, which includes the devices + self.assertFalse(reader.are_new_devices_available()) + reader.read() + + self.assertTrue(reader.are_new_devices_available()) if __name__ == "__main__": diff --git a/tests/testcases/test_test.py b/tests/testcases/test_test.py index a875dcb2..26aa85bf 100644 --- a/tests/testcases/test_test.py +++ b/tests/testcases/test_test.py @@ -21,19 +21,27 @@ import os import unittest +import time +import multiprocessing import evdev from evdev.ecodes import EV_ABS, EV_KEY from keymapper.getdevices import get_devices +from keymapper.gui.reader import reader +from keymapper.gui.helper import RootHelper -from tests.test import InputDevice, cleanup, fixtures +from tests.test import InputDevice, quick_cleanup, cleanup, fixtures,\ + new_event, push_events, EVENT_READ_TIMEOUT, START_READING_DELAY class TestTest(unittest.TestCase): def test_stubs(self): self.assertIn('device 1', get_devices()) + def tearDown(self): + quick_cleanup() + def test_fake_capabilities(self): device = InputDevice('/dev/input/event30') capabilities = device.capabilities(absinfo=False) @@ -67,6 +75,48 @@ class TestTest(unittest.TestCase): self.assertIn('USER', environ) self.assertNotIn('foo', environ) + def test_push_events(self): + """Test that push_event works properly between helper and reader. + + Using push_events after the helper is already forked should work, + as well as using push_event twice + """ + def create_helper(): + # this will cause pending events to be copied over to the helper + # process + def start_helper(): + helper = RootHelper() + helper.run() + + self.helper = multiprocessing.Process(target=start_helper) + self.helper.start() + time.sleep(0.1) + + def wait_for_results(): + # wait for the helper to send stuff + for _ in range(10): + time.sleep(EVENT_READ_TIMEOUT) + if reader._results.poll(): + break + + event = new_event(EV_KEY, 102, 1) + create_helper() + reader.start_reading('device 1') + time.sleep(START_READING_DELAY) + + push_events('device 1', [event]) + wait_for_results() + self.assertTrue(reader._results.poll()) + + reader.clear() + self.assertFalse(reader._results.poll()) + + # can push more events to the helper that is inside a separate + # process, which end up being sent to the reader + push_events('device 1', [event]) + wait_for_results() + self.assertTrue(reader._results.poll()) + if __name__ == "__main__": unittest.main()