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