fix(evdev): monitor remapper virtual keyboards ungrabbed (#932)
* fix(evdev): monitor remapper virtual keyboards ungrabbed On Wayland the device hub grabs every discovered keyboard and clones it via uinput so the dictation shortcut can be suppressed. With a remapper like keyd running, the hub grabbed its virtual keyboard too, and the remapper grabbed the clone in turn (keyd's default [ids] * matches it): forwarded keys went to the remapper instead of the compositor, so users could not type at all, and each forwarded event looped back through the chain. Detect virtual keyboards by their sysfs device path (uinput-spawned devices have no physical parent and live under /devices/virtual/input), falling back to known remapper names when sysfs is unreadable, and monitor them ungrabbed. Their events still reach the listener, so shortcuts bound to them keep working; only suppression is lost, which a grab could never provide anyway since the remapper re-emits on a device we cannot hold. keyd reports BUS_USB for its virtual devices, so the bus type cannot discriminate; the sysfs path can. Fixes #930 * test(evdev): annotate new #930 test mocks Satisfies the repo rule that every function signature carries type hints, including test fixtures. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
57f83e587c
commit
908fbb6072
@@ -90,6 +90,38 @@ else:
|
||||
# survive the kernel's uinput name-length truncation.
|
||||
_CLONE_NAME_SUFFIX = " (vocalinux)"
|
||||
|
||||
# Name fragments remapper-created keyboards are known to use. Only a
|
||||
# fallback for systems where sysfs is unreadable (snap confinement): the
|
||||
# sysfs check in ``_is_virtual_keyboard`` catches every uinput-spawned
|
||||
# device regardless of name.
|
||||
_REMAPPER_DEVICE_NAMES = (
|
||||
"keyd virtual",
|
||||
"kmonad",
|
||||
"kanata",
|
||||
"xremap",
|
||||
"interception",
|
||||
)
|
||||
|
||||
|
||||
def _is_virtual_keyboard(device_path: str, device_name: str) -> bool:
|
||||
"""True when the keyboard is a virtual device, not a real keyboard.
|
||||
|
||||
Devices spawned through uinput — key remappers (keyd, kmonad,
|
||||
interception-tools), on-screen keyboards, accessibility injectors —
|
||||
have no physical parent, so the kernel places them under
|
||||
``/devices/virtual/input``. Real devices live under bus-specific
|
||||
paths (usb, i8042, ...). When sysfs is unavailable the name check
|
||||
catches the common remappers.
|
||||
"""
|
||||
sys_path = f"/sys/class/input/{os.path.basename(device_path)}/device"
|
||||
try:
|
||||
if os.path.exists(sys_path):
|
||||
return "/devices/virtual/input/" in os.path.realpath(sys_path)
|
||||
except OSError:
|
||||
pass
|
||||
name = device_name.lower()
|
||||
return any(marker in name for marker in _REMAPPER_DEVICE_NAMES)
|
||||
|
||||
|
||||
def _clone_device_name(device: InputDevice) -> str:
|
||||
"""Clone name that keeps the suffix rescan checks rely on.
|
||||
@@ -1053,8 +1085,7 @@ class EvdevDeviceHub:
|
||||
# name suffix also covers clones of other Vocalinux processes whose
|
||||
# paths we never registered. Other virtual devices (key remappers,
|
||||
# accessibility keyboards) ARE monitored: users can bind shortcuts
|
||||
# to them, and grabbing is what lets the shortcut reach us at all
|
||||
# when a remapper has already grabbed the physical device.
|
||||
# to them.
|
||||
device_name = str(getattr(device, "name", "") or "")
|
||||
if device_path in self._clone_paths or device_name.endswith(_CLONE_NAME_SUFFIX):
|
||||
logger.debug(f"Skipping Vocalinux clone device: {device_path} ({device_name})")
|
||||
@@ -1064,22 +1095,35 @@ class EvdevDeviceHub:
|
||||
logger.debug(f"Ignoring close failure for clone {device_path}: {e}")
|
||||
return False
|
||||
|
||||
# Exclusive grab hides the device from the compositor; the paired
|
||||
# clone re-emits every event we do not consume so apps keep typing.
|
||||
forwarder = self._create_forwarder(device)
|
||||
if forwarder is not None:
|
||||
try:
|
||||
device.grab()
|
||||
except (OSError, IOError) as e:
|
||||
logger.warning(
|
||||
f"Cannot grab {device_path} ({e}); "
|
||||
"shortcut keys will also reach the focused app"
|
||||
)
|
||||
forwarder: Optional[UInput] = None
|
||||
if _is_virtual_keyboard(device_path, device_name):
|
||||
# A virtual keyboard is a remapper's output. Grabbing it
|
||||
# starves the compositor of every event the remapper emits,
|
||||
# and the paired clone is a new input device the remapper
|
||||
# grabs in turn — keyd locks the whole keyboard this way
|
||||
# (#930). Monitor it ungrabbed: its events still reach the
|
||||
# listener, so shortcuts bound to it keep working. Only
|
||||
# suppression is lost, which a grab could never provide anyway
|
||||
# — the remapper re-emits on a device we cannot hold.
|
||||
logger.debug(f"Monitoring virtual keyboard ungrabbed: {device_path} ({device_name})")
|
||||
else:
|
||||
# Exclusive grab hides the device from the compositor; the
|
||||
# paired clone re-emits every event we do not consume so apps
|
||||
# keep typing.
|
||||
forwarder = self._create_forwarder(device)
|
||||
if forwarder is not None:
|
||||
try:
|
||||
forwarder.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring clone close failure for {device_path}: {e}")
|
||||
forwarder = None
|
||||
device.grab()
|
||||
except (OSError, IOError) as e:
|
||||
logger.warning(
|
||||
f"Cannot grab {device_path} ({e}); "
|
||||
"shortcut keys will also reach the focused app"
|
||||
)
|
||||
try:
|
||||
forwarder.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring clone close failure for {device_path}: {e}")
|
||||
forwarder = None
|
||||
|
||||
with self._devices_lock:
|
||||
if (
|
||||
|
||||
@@ -28,6 +28,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
|
||||
EvdevKeyboardBackend,
|
||||
_clone_device_name,
|
||||
_find_keyboard_devices_from_evdev,
|
||||
_is_virtual_keyboard,
|
||||
device_has_key,
|
||||
device_has_modifier_key,
|
||||
ecodes,
|
||||
@@ -35,6 +36,55 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
|
||||
)
|
||||
|
||||
|
||||
class TestIsVirtualKeyboard:
|
||||
"""Test _is_virtual_keyboard() sysfs and name detection (#930)."""
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.realpath")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_sysfs_virtual_path_is_virtual(self, mock_exists: Mock, mock_realpath: Mock) -> None:
|
||||
"""A device node under /devices/virtual/input is uinput-spawned."""
|
||||
mock_exists.return_value = True
|
||||
mock_realpath.return_value = "/sys/devices/virtual/input/input30"
|
||||
|
||||
assert _is_virtual_keyboard("/dev/input/event9", "Whatever Keyboard") is True
|
||||
mock_realpath.assert_called_once_with("/sys/class/input/event9/device")
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.realpath")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_sysfs_real_path_is_not_virtual(self, mock_exists: Mock, mock_realpath: Mock) -> None:
|
||||
"""A bus-attached path wins over a remapper-sounding name."""
|
||||
mock_exists.return_value = True
|
||||
mock_realpath.return_value = "/sys/devices/pci0000:00/usb1/input/input7"
|
||||
|
||||
assert _is_virtual_keyboard("/dev/input/event9", "keyd virtual keyboard") is False
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_name_fallback_matches_remap_names(self, mock_exists: Mock) -> None:
|
||||
"""Without sysfs, known remapper names are treated as virtual."""
|
||||
mock_exists.return_value = False
|
||||
|
||||
for name in ("keyd virtual keyboard", "KMonad output", "kanata", "xremap"):
|
||||
assert _is_virtual_keyboard("/dev/input/event9", name) is True, name
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_name_fallback_ignores_real_keyboards(self, mock_exists: Mock) -> None:
|
||||
"""Without sysfs, ordinary keyboard names stay grabbed."""
|
||||
mock_exists.return_value = False
|
||||
|
||||
assert _is_virtual_keyboard("/dev/input/event9", "AT Translated Set 2 keyboard") is False
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.realpath")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_sysfs_oserror_falls_back_to_names(
|
||||
self, mock_exists: Mock, mock_realpath: Mock
|
||||
) -> None:
|
||||
"""A sysfs read failure degrades to the name check, not a crash."""
|
||||
mock_exists.side_effect = OSError("permission denied")
|
||||
|
||||
assert _is_virtual_keyboard("/dev/input/event9", "keyd virtual keyboard") is True
|
||||
assert _is_virtual_keyboard("/dev/input/event9", "AT Translated Set 2 keyboard") is False
|
||||
|
||||
|
||||
class TestFindKeyboardDevices:
|
||||
"""Test find_keyboard_devices() function."""
|
||||
|
||||
@@ -1313,15 +1363,88 @@ class TestEvdevGrabAndForwarding:
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
|
||||
def test_open_monitors_other_virtual_devices(self, mock_input_device, mock_uinput):
|
||||
"""Remapper/accessibility virtual devices are monitored like real ones.
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_open_monitors_remapper_virtual_device_ungrabbed(
|
||||
self, mock_exists: Mock, mock_input_device: Mock, mock_uinput: Mock
|
||||
) -> None:
|
||||
"""Remapper virtual keyboards are monitored without a grab or clone.
|
||||
|
||||
A user can record a shortcut on a keyd/kmonad-style virtual keyboard;
|
||||
ignoring every BUS_VIRTUAL device would leave that shortcut dead.
|
||||
keyd reports BUS_USB to look like a real keyboard; with sysfs
|
||||
unreadable the name check still catches it (#930). Grabbing the
|
||||
remapper's output device starves the compositor, and the clone is
|
||||
another uinput device the remapper grabs — so no clone is made.
|
||||
The listener still sees its events, so shortcuts bound to it work.
|
||||
"""
|
||||
mock_exists.return_value = False # no sysfs entry: name fallback
|
||||
device = MagicMock()
|
||||
device.fileno.return_value = 10
|
||||
device.info.bustype = 0x06 # BUS_VIRTUAL
|
||||
device.info.bustype = 0x03 # BUS_USB — keyd deliberately looks real
|
||||
device.name = "keyd virtual keyboard"
|
||||
mock_input_device.return_value = device
|
||||
|
||||
backend = EvdevKeyboardBackend()
|
||||
opened = backend._open_keyboard_device("/dev/input/event5")
|
||||
|
||||
assert opened is True
|
||||
device.grab.assert_not_called()
|
||||
mock_uinput.assert_not_called()
|
||||
assert backend._forwarders == {}
|
||||
assert device in backend.devices
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.realpath")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_open_detects_virtual_keyboard_by_sysfs_path(
|
||||
self,
|
||||
mock_exists: Mock,
|
||||
mock_realpath: Mock,
|
||||
mock_input_device: Mock,
|
||||
mock_uinput: Mock,
|
||||
) -> None:
|
||||
"""A uinput-spawned keyboard is ungrabbed regardless of its name.
|
||||
|
||||
keyd names and clones its device however it likes; the reliable
|
||||
signal is the sysfs device path landing under /devices/virtual.
|
||||
"""
|
||||
mock_exists.return_value = True
|
||||
mock_realpath.return_value = "/sys/devices/virtual/input/input30"
|
||||
device = MagicMock()
|
||||
device.fileno.return_value = 10
|
||||
device.info.bustype = 0x03 # BUS_USB
|
||||
device.name = "Plausible Keyboard" # name a remapper could choose
|
||||
mock_input_device.return_value = device
|
||||
|
||||
backend = EvdevKeyboardBackend()
|
||||
opened = backend._open_keyboard_device("/dev/input/event5")
|
||||
|
||||
assert opened is True
|
||||
device.grab.assert_not_called()
|
||||
mock_uinput.assert_not_called()
|
||||
assert backend._forwarders == {}
|
||||
assert device in backend.devices
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.realpath")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")
|
||||
def test_open_grabs_keyboard_on_real_sysfs_path(
|
||||
self,
|
||||
mock_exists: Mock,
|
||||
mock_realpath: Mock,
|
||||
mock_input_device: Mock,
|
||||
mock_uinput: Mock,
|
||||
) -> None:
|
||||
"""A real sysfs path keeps suppression even for a remapper-ish name.
|
||||
|
||||
sysfs is ground truth for virtualness; the name list is only a
|
||||
fallback for confined installs where sysfs cannot be read.
|
||||
"""
|
||||
mock_exists.return_value = True
|
||||
mock_realpath.return_value = "/sys/devices/pci0000:00/input/input0"
|
||||
device = MagicMock()
|
||||
device.fileno.return_value = 10
|
||||
device.info.bustype = 0x03
|
||||
device.name = "keyd virtual keyboard"
|
||||
mock_input_device.return_value = device
|
||||
forwarder = MagicMock()
|
||||
@@ -1333,7 +1456,7 @@ class TestEvdevGrabAndForwarding:
|
||||
|
||||
assert opened is True
|
||||
device.grab.assert_called_once()
|
||||
assert device in backend.devices
|
||||
assert backend._forwarders[10] is forwarder
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
|
||||
|
||||
Reference in New Issue
Block a user