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:
devin-ai-integration[bot]
2026-10-08 16:02:57 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 57f83e587c
commit 908fbb6072
2 changed files with 190 additions and 23 deletions
+129 -6
View File
@@ -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")