gtk not running as root anymore

xkb
sezanzeb 3 years ago committed by sezanzeb
parent 3c97fd34b4
commit 5e4ae668dd

@ -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

@ -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.

@ -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)

@ -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()

@ -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

@ -0,0 +1,58 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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()

@ -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()

@ -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

@ -9,10 +9,10 @@
<message>Authentication is required to discover and read devices.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/key-mapper-gtk</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/key-mapper-control</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">false</annotate>
</action>
</policyconfig>

@ -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.

@ -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,

@ -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.

@ -0,0 +1,209 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@hip70890b.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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
)
})

@ -19,253 +19,81 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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()

@ -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):

@ -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)

@ -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)

@ -0,0 +1,144 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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()

@ -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.

@ -0,0 +1,297 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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)

@ -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)

@ -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')

@ -1,111 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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

@ -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):

@ -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()

@ -0,0 +1,65 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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')

@ -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)

@ -1,23 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20">
<svg xmlns="http://www.w3.org/2000/svg" width="73" height="20">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="anybadge_1">
<rect width="80" height="20" rx="3" fill="#fff"/>
<rect width="73" height="20" rx="3" fill="#fff"/>
</mask>
<g mask="url(#anybadge_1)">
<path fill="#555" d="M0 0h44v20H0z"/>
<path fill="#4c1" d="M44 0h36v20H44z"/>
<path fill="url(#b)" d="M0 0h80v20H0z"/>
<path fill="#4c1" d="M44 0h29v20H44z"/>
<path fill="url(#b)" d="M0 0h73v20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="23.0" y="15" fill="#010101" fill-opacity=".3">pylint</text>
<text x="22.0" y="14">pylint</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.84</text>
<text x="62.0" y="14">9.84</text>
<text x="59.5" y="15" fill="#010101" fill-opacity=".3">9.8</text>
<text x="58.5" y="14">9.8</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -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`.
<p align="center">
<img src="usage_1.png"/>
@ -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/"

@ -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',

@ -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:]

@ -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()

@ -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

@ -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

@ -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()

@ -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__":

@ -0,0 +1,142 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
import unittest
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()

@ -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

@ -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')

@ -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')

@ -1,166 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
import 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()

@ -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__":

@ -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()

Loading…
Cancel
Save