Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wayland support with keyboard emulation and capture using uinput #1679

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
36 changes: 36 additions & 0 deletions linux/appimage/apprun.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
}

Expand Down
1 change: 1 addition & 0 deletions news.d/feature/1679.linux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added keyboard emulation and capture using uinput, compatible with X11, Wayland and anything else on linux and bsd.
15 changes: 15 additions & 0 deletions plover/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions plover/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions plover/gui_qt/config_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
QLabel,
QScrollArea,
QSpinBox,
QLineEdit,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'), (
Expand Down
15 changes: 13 additions & 2 deletions plover/gui_qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from PyQt5.QtWidgets import (
QMainWindow,
QMenu,
QApplication,
)

from plover import _, log
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand All @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions plover/oslayer/linux/display_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import os

"""
This value should be one of:
- x11
- wayland
- tty
"""
DISPLAY_SERVER = os.environ.get("XDG_SESSION_TYPE", None)
7 changes: 6 additions & 1 deletion plover/oslayer/linux/keyboardcontrol.py
Original file line number Diff line number Diff line change
@@ -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
Loading