diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index cb6b118..8fa9755 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -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 ( diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 1f8bf16..635f365 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -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")