diff --git a/linux/README.md b/linux/README.md index 17f90702b..e94faf491 100644 --- a/linux/README.md +++ b/linux/README.md @@ -8,5 +8,6 @@ distribution corresponding packages): - Treal support: `libusb` (1.0) and `libudev` are needed by the [`hidapi` package](https://pypi.org/project/hidapi/). - log / notifications support: `libdbus` is needed. +- Uinput support: `libxkbcommon` is needed by the [`xkbcommon` package](https://pypi.org/project/xkbcommon) For the rest of the steps, follow the [developer guide](../doc/developer_guide.md). diff --git a/linux/appimage/apprun.sh b/linux/appimage/apprun.sh index d610dfc52..1af393555 100755 --- a/linux/appimage/apprun.sh +++ b/linux/appimage/apprun.sh @@ -62,8 +62,44 @@ appimage_python() exec "${APPDIR}/usr/bin/python" "$@" } +install_udev_rule() +{ + # Pass through variables because pkexec doesn't pass through env + local UDEV_RULE_FILE="$1" + local USER="$2" + if ! grep -q "^plover:" /etc/group; then + groupadd plover + fi + # NOTE: this requires a reboot! + if ! groups "$USER" | grep -qw "plover"; then + usermod -aG plover "$USER" + fi + if [ ! -f "$UDEV_RULE_FILE" ]; then + echo 'KERNEL=="uinput", GROUP="plover", MODE="0660", OPTIONS+="static_node=uinput"' > "$UDEV_RULE_FILE" + chmod 644 "$UDEV_RULE_FILE" + udevadm control --reload-rules + udevadm trigger + # Temporarily give the current user access + # This is done because the groupadd does not take effect until next reboot + # And this temporary solution works *until* the next reboot + # FIXME if someone can find a better solution + chown "${USER}:plover" /dev/uinput + chmod 660 /dev/uinput + fi +} + appimage_launch() { + # Install the udev rule required for uinput + UDEV_RULE_FILE="/etc/udev/rules.d/99-plover-uinput.rules" + # It's done like this to have the lowest possible number of pkexec calls + # Each time it's called, the user gets shown a new password input dialog + # FIXME if there is an easier way to do it + if [ ! -f "$UDEV_RULE_FILE" ] || ! grep -q "^plover:" /etc/group || ! groups | grep -qw "plover"; then + notify-send -t 10000 "Installing udev rules" "You will be prompted for your password" + pkexec bash -c "$(declare -f install_udev_rule); install_udev_rule '$UDEV_RULE_FILE' '$USER'" + notify-send -t 10000 "Successfully installed udev rules" "A reboot may be required for output to work" + fi appimage_python -s -m plover.scripts.dist_main "$@" } diff --git a/news.d/feature/1679.linux.md b/news.d/feature/1679.linux.md new file mode 100644 index 000000000..9ca0b7b36 --- /dev/null +++ b/news.d/feature/1679.linux.md @@ -0,0 +1 @@ +Added keyboard emulation and capture using uinput, compatible with X11, Wayland and anything else on linux and bsd. diff --git a/plover/config.py b/plover/config.py index 7ae6c3330..e9861ae5f 100644 --- a/plover/config.py +++ b/plover/config.py @@ -122,6 +122,20 @@ def validate(config, key, value): return value return ConfigOption(name, lambda c, k: default, getter, setter, validate, None) +def str_option(name, default, section, option=None): + option = option or name + def getter(config, key): + return config._config[section][option] + def setter(config, key, value): + config._set(section, option, str(value)) + def validate(config, key, value): + try: + value = str(value) + except ValueError as e: + raise InvalidConfigOption(value, default) from e + return value + return ConfigOption(name, lambda c, k: default, getter, setter, validate, None) + def boolean_option(name, default, section, option=None): option = option or name def getter(config, key): @@ -338,6 +352,7 @@ def _set(self, section, option, value): boolean_option('start_capitalized', False, OUTPUT_CONFIG_SECTION), int_option('undo_levels', DEFAULT_UNDO_LEVELS, MINIMUM_UNDO_LEVELS, None, OUTPUT_CONFIG_SECTION), int_option('time_between_key_presses', DEFAULT_TIME_BETWEEN_KEY_PRESSES, MINIMUM_TIME_BETWEEN_KEY_PRESSES, None, OUTPUT_CONFIG_SECTION), + str_option('xkb_layout', "us", OUTPUT_CONFIG_SECTION), # Logging. path_option('log_file_name', expand_path('strokes.log'), LOGGING_CONFIG_SECTION, 'log_file'), boolean_option('enable_stroke_logging', False, LOGGING_CONFIG_SECTION), diff --git a/plover/engine.py b/plover/engine.py index cc83c5259..c1c5b8336 100644 --- a/plover/engine.py +++ b/plover/engine.py @@ -212,6 +212,9 @@ def _update(self, config_update=None, full=False, reset_machine=False): self._formatter.start_capitalized = config['start_capitalized'] self._translator.set_min_undo_length(config['undo_levels']) self._keyboard_emulation.set_key_press_delay(config['time_between_key_presses']) + # This only applies to UInput, because it emulates a physical keyboard and follows the layout set in software. Because there is no standard of defining it, the user has to do so manually if not using an US keyboard if not using an US keyboard. + if hasattr(self._keyboard_emulation, '_update_layout'): + self._keyboard_emulation._update_layout(config['xkb_layout']) # Update system. system_name = config['system_name'] if system.NAME != system_name: diff --git a/plover/gui_qt/config_window.py b/plover/gui_qt/config_window.py index a200a10d4..bc045f276 100644 --- a/plover/gui_qt/config_window.py +++ b/plover/gui_qt/config_window.py @@ -20,6 +20,7 @@ QLabel, QScrollArea, QSpinBox, + QLineEdit, QStyledItemDelegate, QTableWidget, QTableWidgetItem, @@ -71,6 +72,17 @@ def __init__(self, maximum=None, minimum=None): self.setMinimum(minimum) +class StrOption(QLineEdit): + + valueChanged = pyqtSignal(str) + + def __init__(self): + super().__init__() + self.textChanged.connect(self.valueChanged.emit) + + def setValue(self, value): + self.setText(value) + class ChoiceOption(QComboBox): valueChanged = pyqtSignal(str) @@ -392,6 +404,14 @@ def __init__(self, engine): 'programs time to process each key press.\n' 'Setting the delay too high will negatively impact the\n' 'performance of key stroke output.')), + ConfigOption(_('Keyboard Layout:'), 'xkb_layout', StrOption, + _('Set the keyboard layout configured in your system.\n' + 'Examples: "us", "gb", "fr", "no"\n' + '\n' + 'This only applies when using Linux/BSD and not using X11.\n' + 'If you\'re unsure, you probably don\'t need to change it.\n' + 'If you use a different layout variant, format it as\n' + '"language:layout", for example "us:colemak"')), )), # i18n: Widget: “ConfigWindow”. (_('Plugins'), ( diff --git a/plover/gui_qt/main_window.py b/plover/gui_qt/main_window.py index cc6de0df4..4a9bc414f 100644 --- a/plover/gui_qt/main_window.py +++ b/plover/gui_qt/main_window.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import ( QMainWindow, QMenu, + QApplication, ) from plover import _, log @@ -151,6 +152,14 @@ def set_visible(self, visible): else: self.showMinimized() + def _is_wayland(self): + """ + Calling .activateWindow() on a window results in this error: + Qt: Wayland does not support QWindow::requestActivate() + This function returns a boolean used to determine whether to run activateWindow() + """ + return "wayland" in QApplication.platformName().lower() + def _activate_dialog(self, name, args=(), manage_windows=False): if manage_windows: previous_window = wmctrl.GetForegroundWindow() @@ -166,7 +175,8 @@ def on_finished(): wmctrl.SetForegroundWindow(previous_window) dialog.finished.connect(on_finished) dialog.showNormal() - dialog.activateWindow() + if not self._is_wayland(): + dialog.activateWindow() dialog.raise_() def _add_translation(self, dictionary=None, manage_windows=False): @@ -177,7 +187,8 @@ def _add_translation(self, dictionary=None, manage_windows=False): def _focus(self): self.showNormal() - self.activateWindow() + if not self._is_wayland(): + self.activateWindow() self.raise_() def _configure(self, manage_windows=False): diff --git a/plover/oslayer/linux/display_server.py b/plover/oslayer/linux/display_server.py new file mode 100644 index 000000000..04d5fb0ca --- /dev/null +++ b/plover/oslayer/linux/display_server.py @@ -0,0 +1,9 @@ +import os + +""" +This value should be one of: + - x11 + - wayland + - tty +""" +DISPLAY_SERVER = os.environ.get("XDG_SESSION_TYPE", None) diff --git a/plover/oslayer/linux/keyboardcontrol.py b/plover/oslayer/linux/keyboardcontrol.py index bb1356144..cf5e019ab 100644 --- a/plover/oslayer/linux/keyboardcontrol.py +++ b/plover/oslayer/linux/keyboardcontrol.py @@ -1 +1,6 @@ -from .keyboardcontrol_x11 import KeyboardCapture, KeyboardEmulation # pylint: disable=unused-import +from .display_server import DISPLAY_SERVER + +if DISPLAY_SERVER == "x11": + from .keyboardcontrol_x11 import KeyboardCapture, KeyboardEmulation # pylint: disable=unused-import +else: + from .keyboardcontrol_uinput import KeyboardCapture, KeyboardEmulation #pylint: disable=unused-import diff --git a/plover/oslayer/linux/keyboardcontrol_uinput.py b/plover/oslayer/linux/keyboardcontrol_uinput.py new file mode 100644 index 000000000..04b0cdcb6 --- /dev/null +++ b/plover/oslayer/linux/keyboardcontrol_uinput.py @@ -0,0 +1,330 @@ +from evdev import UInput, ecodes as e, util, InputDevice, list_devices +import threading +from select import select + +from .xkb_symbols import generate_symbols + +from plover.output.keyboard import GenericKeyboardEmulation +from plover.machine.keyboard_capture import Capture +from plover.key_combo import parse_key_combo +from plover import log + + +modifiers = { + "alt_l": e.KEY_LEFTALT, + "alt_r": e.KEY_RIGHTALT, + "alt": e.KEY_LEFTALT, + "ctrl_l": e.KEY_LEFTCTRL, + "ctrl_r": e.KEY_RIGHTCTRL, + "ctrl": e.KEY_LEFTCTRL, + "control_l": e.KEY_LEFTCTRL, + "control_r": e.KEY_RIGHTCTRL, + "control": e.KEY_LEFTCTRL, + "shift_l": e.KEY_LEFTSHIFT, + "shift_r": e.KEY_RIGHTSHIFT, + "shift": e.KEY_LEFTSHIFT, + "super_l": e.KEY_LEFTMETA, + "super_r": e.KEY_RIGHTMETA, + "super": e.KEY_LEFTMETA, +} + + +keys = { + # Lowercase + "a": e.KEY_A, + "b": e.KEY_B, + "c": e.KEY_C, + "d": e.KEY_D, + "e": e.KEY_E, + "f": e.KEY_F, + "g": e.KEY_G, + "h": e.KEY_H, + "i": e.KEY_I, + "j": e.KEY_J, + "k": e.KEY_K, + "l": e.KEY_L, + "m": e.KEY_M, + "n": e.KEY_N, + "o": e.KEY_O, + "p": e.KEY_P, + "q": e.KEY_Q, + "r": e.KEY_R, + "s": e.KEY_S, + "t": e.KEY_T, + "u": e.KEY_U, + "v": e.KEY_V, + "w": e.KEY_W, + "x": e.KEY_X, + "y": e.KEY_Y, + "z": e.KEY_Z, + # Numbers + "1": e.KEY_1, + "2": e.KEY_2, + "3": e.KEY_3, + "4": e.KEY_4, + "5": e.KEY_5, + "6": e.KEY_6, + "7": e.KEY_7, + "8": e.KEY_8, + "9": e.KEY_9, + "0": e.KEY_0, + # Symbols + "-": e.KEY_MINUS, + "=": e.KEY_EQUAL, + " ": e.KEY_SPACE, + "[": e.KEY_LEFTBRACE, + "]": e.KEY_RIGHTBRACE, + ";": e.KEY_SEMICOLON, + "'": e.KEY_APOSTROPHE, + "`": e.KEY_GRAVE, + "\\": e.KEY_BACKSLASH, + ".": e.KEY_DOT, + ",": e.KEY_COMMA, + "/": e.KEY_SLASH, + "\b": e.KEY_BACKSPACE, + "\n": e.KEY_ENTER, + # https://github.com/openstenoproject/plover/blob/9b5a357f1fb57cb0a9a8596ae12cd1e84fcff6c4/plover/oslayer/osx/keyboardcontrol.py#L75 + # https://gist.github.com/jfortin42/68a1fcbf7738a1819eb4b2eef298f4f8 + "return": e.KEY_ENTER, + "tab": e.KEY_TAB, + "backspace": e.KEY_BACKSPACE, + "delete": e.KEY_DELETE, + "escape": e.KEY_ESC, + "clear": e.KEY_CLEAR, + # Navigation + "up": e.KEY_UP, + "down": e.KEY_DOWN, + "left": e.KEY_LEFT, + "right": e.KEY_RIGHT, + "page_up": e.KEY_PAGEUP, + "page_down": e.KEY_PAGEDOWN, + "home": e.KEY_HOME, + "insert": e.KEY_INSERT, + "end": e.KEY_END, + "space": e.KEY_SPACE, + "print": e.KEY_PRINT, + # Function keys + "fn": e.KEY_FN, + "f1": e.KEY_F1, + "f2": e.KEY_F2, + "f3": e.KEY_F3, + "f4": e.KEY_F4, + "f5": e.KEY_F5, + "f6": e.KEY_F6, + "f7": e.KEY_F7, + "f8": e.KEY_F8, + "f9": e.KEY_F9, + "f10": e.KEY_F10, + "f11": e.KEY_F11, + "f12": e.KEY_F12, + "f13": e.KEY_F13, + "f14": e.KEY_F14, + "f15": e.KEY_F15, + "f16": e.KEY_F16, + "f17": e.KEY_F17, + "f18": e.KEY_F18, + "f19": e.KEY_F19, + "f20": e.KEY_F20, + "f21": e.KEY_F21, + "f22": e.KEY_F22, + "f23": e.KEY_F23, + "f24": e.KEY_F24, + # Numpad + "kp_1": e.KEY_KP1, + "kp_2": e.KEY_KP2, + "kp_3": e.KEY_KP3, + "kp_4": e.KEY_KP4, + "kp_5": e.KEY_KP5, + "kp_6": e.KEY_KP6, + "kp_7": e.KEY_KP7, + "kp_8": e.KEY_KP8, + "kp_9": e.KEY_KP9, + "kp_0": e.KEY_KP0, + "kp_add": e.KEY_KPPLUS, + "kp_decimal": e.KEY_KPDOT, + "kp_delete": e.KEY_DELETE, # There is no KPDELETE + "kp_divide": e.KEY_KPSLASH, + "kp_enter": e.KEY_KPENTER, + "kp_equal": e.KEY_KPEQUAL, + "kp_multiply": e.KEY_KPASTERISK, + "kp_subtract": e.KEY_KPMINUS, + # Media keys + "audioraisevolume": e.KEY_VOLUMEUP, + "audiolowervolume": e.KEY_VOLUMEDOWN, + "monbrightnessup": e.KEY_BRIGHTNESSUP, + "monbrightnessdown": e.KEY_BRIGHTNESSDOWN, + "audiomute": e.KEY_MUTE, + "num_lock": e.KEY_NUMLOCK, + "eject": e.KEY_EJECTCD, + "audiopause": e.KEY_PAUSE, + "audioplay": e.KEY_PLAY, + "audionext": e.KEY_NEXT, + "audiorewind": e.KEY_REWIND, + "kbdbrightnessup": e.KEY_KBDILLUMUP, + "kbdbrightnessdown": e.KEY_KBDILLUMDOWN, +} +KEY_TO_KEYCODE = {**keys, **modifiers} +KEYCODE_TO_KEY = dict(zip(KEY_TO_KEYCODE.values(), KEY_TO_KEYCODE.keys())) + +class KeyboardEmulation(GenericKeyboardEmulation): + def __init__(self): + super().__init__() + # Initialize UInput with all keys available + self._res = util.find_ecodes_by_regex(r"KEY_.*") + self._ui = UInput(self._res) + + def _update_layout(self, _layout): + log.info("Using keyboard layout " + + _layout + " for keyboard emulation.") + _layout_options = _layout.split(":") + layout = _layout_options[0] + variant = _layout_options[1] if len(_layout_options) > 1 else "" + symbols = generate_symbols(layout, variant) + # Remove symbols not in KEY_TO_KEYCODE + syms_to_remove = [] + for sym in symbols: + (base, _) = symbols[sym] + if base not in KEY_TO_KEYCODE: + syms_to_remove.append(sym) + for sym in syms_to_remove: + symbols.pop(sym) + self._symbols = symbols + + def _press_key(self, key, state): + self._ui.write(e.EV_KEY, key, 1 if state else 0) + self._ui.syn() + + def _send_key(self, key): + self._press_key(key, True) + self._press_key(key, False) + self._ui.syn() + + """ + Send a unicode character. + This depends on an IME such as iBus or fcitx5. iBus is used by GNOME, and fcitx5 by KDE. + It assumes the default keybinding ctrl-shift-u, enter hex, enter is used, which is the default in both. + From my testing, it works fine in using iBus and fcitx5, but in kitty terminal emulator, which uses + the same keybinding, it's too fast for it to handle and ends up writing random stuff. I don't + think there is a way to fix that other than increasing the delay. + """ + def _send_unicode(self, hex): + self.send_key_combination("ctrl_l(shift(u))") + self.delay() + self.send_string(hex) + self.delay() + self._send_char("\n") + + def _send_char(self, char): + # Key can be sent with a key combination + if char in self._symbols: + (base, mods) = self._symbols[char] + for mod in mods: + self._press_key(KEY_TO_KEYCODE[mod], True) + self.delay() + self._send_key(KEY_TO_KEYCODE[base]) + for mod in mods: + self._press_key(KEY_TO_KEYCODE[mod], False) + + # Key press can not be emulated - send unicode symbol instead + else: + # Convert to hex and remove leading "0x" + unicode_hex = hex(ord(char))[2:] + self._send_unicode(unicode_hex) + + def send_string(self, string): + for key in self.with_delay(list(string)): + self._send_char(key) + + def send_backspaces(self, count): + for _ in range(count): + self._send_key(KEY_TO_KEYCODE["\b"]) + + def send_key_combination(self, combo): + # https://plover.readthedocs.io/en/latest/api/key_combo.html#module-plover.key_combo + key_events = parse_key_combo(combo) + + for key, pressed in self.with_delay(key_events): + try: + if key.lower() in self._symbols: + # Ignore modifier layers for this one, there may be better ways to do this logic + (base, _) = self._symbols[key.lower()] + k = KEY_TO_KEYCODE[base] + else: + k = KEY_TO_KEYCODE[key.lower()] + self._press_key(k, pressed) + except KeyError: # In case the key does not exist + log.warning("Key " + key + " is not valid!") + + +class KeyboardCapture(Capture): + def __init__(self): + super().__init__() + # This is based on the example from the python-evdev documentation, using the first of the three alternative methods: https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select + self._devices = self._get_devices() + self._running = False + self._thread = None + self._res = util.find_ecodes_by_regex(r"KEY_.*") + self._ui = UInput(self._res) + self._suppressed_keys = [] + + def _get_devices(self): + input_devices = [InputDevice(path) for path in list_devices()] + keyboard_devices = [dev for dev in input_devices if self._filter_devices(dev)] + return {dev.fd: dev for dev in keyboard_devices} + + def _filter_devices(self, device): + """ + Filter out devices that should not be grabbed and suppressed, to avoid output feeding into itself. + """ + is_uinput = device.name == "py-evdev-uinput" or device.phys == "py-evdev-uinput" + capabilities = device.capabilities() + is_mouse = e.EV_REL in capabilities or e.EV_ABS in capabilities + return not is_uinput and not is_mouse + + def start(self): + self._running = True + self._thread = threading.Thread(target=self._run) + self._thread.start() + + def cancel(self): + self._running = False + [dev.ungrab() for dev in self._devices.values()] + if self._thread is not None: + self._thread.join() + self._ui.close() + + def suppress(self, suppressed_keys=()): + """ + UInput is not capable of suppressing only specific keys. To get around this, non-suppressed keys + are passed through to a UInput device and emulated, while keys in this list get sent to plover. + It does add a little bit of delay, but that is not noticeable. + """ + self._suppressed_keys = suppressed_keys + + def _run(self): + [dev.grab() for dev in self._devices.values()] + while self._running: + """ + The select() call blocks the loop until it gets an input, which meant that the keyboard + had to be pressed once after executing `cancel()`. Now, there is a 1 second delay instead + FIXME: maybe use one of the other options to avoid the timeout + https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select + """ + r, _, _ = select(self._devices, [], [], 1) + for fd in r: + for event in self._devices[fd].read(): + if event.type == e.EV_KEY: + """ + It could be possible to support different layouts through the xkb_symbols script + , however, i think having it like this is actually better, because the keys + are supposed to be in these locations, and reduces the amount of work done + configuring it afterwards. + """ + if event.code in KEYCODE_TO_KEY: + key_name = KEYCODE_TO_KEY[event.code] + if key_name in self._suppressed_keys: + pressed = event.value == 1 + (self.key_down if pressed else self.key_up)(key_name) + continue # Go to the next iteration, skipping the below code: + self._ui.write(e.EV_KEY, event.code, event.value) + self._ui.syn() diff --git a/plover/oslayer/linux/xkb_symbols.py b/plover/oslayer/linux/xkb_symbols.py new file mode 100644 index 000000000..c192c6cd3 --- /dev/null +++ b/plover/oslayer/linux/xkb_symbols.py @@ -0,0 +1,59 @@ +""" +Simple loop to go through all keys in a keymap, and find the possible keys to type using modifiers, +then return a list in the format [(base, modifiers)], that can be used to emulate the key presses +This code is a bit messy, and could be improved on a lot, but from my testing, it seems to work with different layouts +""" + +from xkbcommon import xkb + + +# layout can be "no", "us", "gb", "fr" or any other xkb layout +def generate_symbols(layout="us", variant = ""): + ctx = xkb.Context() + keymap = ctx.keymap_new_from_names(layout=layout, variant=variant) + # The keymaps have to be "translated" to a US layout keyboard for evdev + keymap_us = ctx.keymap_new_from_names(layout="us") + + # Modifier names from xkb, converted to lists of modifiers + level_mapping = { + 0: [], + 1: ["shift_l"], + 2: ["alt_r"], + 3: ["shift_l", "alt_r"], + } + + symbols = {} + + for key in iter(keymap): + try: + # Levels are different outputs from the same key with modifiers pressed + levels = keymap.num_levels_for_key(key, 1) + if levels == 0: # Key has no output + continue + + # === Base key symbol === + base_key_syms = keymap_us.key_get_syms_by_level(key, 1, 0) + if len(base_key_syms) == 0: # There are no symbols for this key + continue + base_key = xkb.keysym_to_string(base_key_syms[0]) + if base_key is None: + continue + + # === Key variations === + if levels < 1: # There are no variations (Check maybe not needed) + continue + for level in range(0, levels + 1): # Ignoring the first (base) one + level_key_syms = keymap.key_get_syms_by_level(key, 1, level) + if len(level_key_syms) == 0: + continue + level_key = xkb.keysym_to_string(level_key_syms[0]) + if level_key is None: + continue + modifiers = level_mapping.get(level, "") + if not level_key in symbols: + symbols[level_key] = (base_key, modifiers) + except xkb.XKBInvalidKeycode: + # Iter *should* return only valid, but still returns some invalid... + pass + + return symbols diff --git a/reqs/dist.txt b/reqs/dist.txt index 43102086a..af671e290 100644 --- a/reqs/dist.txt +++ b/reqs/dist.txt @@ -7,6 +7,8 @@ pyobjc-framework-Quartz>=4.0; "darwin" in sys_platform pyserial>=2.7 python-xlib>=0.16; ("linux" in sys_platform or "bsd" in sys_platform) and python_version < "3.9" python-xlib>=0.29; ("linux" in sys_platform or "bsd" in sys_platform) and python_version >= "3.9" +evdev; "linux" in sys_platform or "bsd" in sys_platform +xkbcommon<1.1; "linux" in sys_platform or "bsd" in sys_platform rtf_tokenize setuptools wcwidth