fix(hotkeys): monitor keyboard nodes with pointer axes ungrabbed (#978)

* fix(hotkeys): keep Logitech HID++ keyboards that report pointer axes

The pointer-axis filter from #900 drops every device that reports
REL_X+REL_Y, assuming a keyboard never does. Logitech HID++ keyboards
(MX Keys, ERGO K860) expose a single node with the same capabilities
as the mice on their receiver, so they were skipped and the shortcut
no longer fired from them.

Capabilities cannot tell them apart, so a device whose name says
keyboard ("keyboard", "keys", "K<digits>") is exempted from the
pointer-axis rule, in both the /proc and the evdev discovery paths.
The MX Master 3 on the same receiver is still skipped.

* fix(evdev): monitor keyboard-pointer combos ungrabbed

* fix(evdev): keep parse_shortcut import and drop per-rescan discovery log

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
ikarius
2026-10-20 23:56:51 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 545b6d0feb
commit cf23e9d82d
2 changed files with 372 additions and 72 deletions
+335 -47
View File
@@ -22,8 +22,6 @@ import pytest
from vocalinux.ui.keyboard_backends.evdev_backend import (
DEVICE_RESCAN_SECONDS,
EVDEV_AVAILABLE,
MODIFIER_KEY_CODES,
EvdevDeviceHub,
EvdevKeyboardBackend,
_clone_device_name,
@@ -36,6 +34,21 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
)
def _proc_key_bitmap(*codes: int, word_bits: int = 64) -> str:
"""Format key codes as the words printed in /proc device bitmaps."""
bits = sum(1 << code for code in codes)
word_mask = (1 << word_bits) - 1
word_count = max(1, (bits.bit_length() + word_bits - 1) // word_bits)
return " ".join(
f"{(bits >> (word_index * word_bits)) & word_mask:x}"
for word_index in reversed(range(word_count))
)
def _keyboard_only_capabilities() -> dict[int, list[int]]:
return {ecodes.EV_KEY: [ecodes.KEY_A, ecodes.KEY_RIGHTCTRL]}
class TestIsVirtualKeyboard:
"""Test _is_virtual_keyboard() sysfs and name detection (#930)."""
@@ -195,9 +208,9 @@ B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe
result = find_keyboard_devices()
assert "/dev/input/event0" not in result
def test_find_keyboard_devices_skips_relative_pointer(self) -> None:
"""A device emitting REL_X/Y motion is a mouse, not a keyboard (#900)."""
mock_proc_content = """I: Bus=0011 Vendor=0001 Product=0001 Version=ab83
def test_find_keyboard_devices_keeps_relative_combo_and_skips_mouse(self) -> None:
"""Keyboard keys keep a REL_X/Y combo; button-only pointer nodes stay excluded."""
mock_proc_content = f"""I: Bus=0011 Vendor=0001 Product=0001 Version=ab83
N: Name="AT Translated Set 2 keyboard"
H: Handlers=sysrq kbd event0
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe
@@ -207,28 +220,34 @@ H: Handlers=mouse0 event5
B: KEY=1f0000 0 0 0 38000000
B: REL=143
B: MSC=10
I: Bus=0003 Vendor=046d Product=0001 Version=0110
N: Name="Test Mouse"
H: Handlers=mouse0 event6
B: KEY={_proc_key_bitmap(ecodes.BTN_LEFT, ecodes.BTN_RIGHT)}
B: REL=143
"""
# KEY carries real keyboard keys (bits 29-31) so only the pointer
# check can drop this device.
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
with patch("os.path.exists", return_value=True):
result = find_keyboard_devices()
assert result == ["/dev/input/event0"]
assert result == ["/dev/input/event0", "/dev/input/event5"]
def test_find_keyboard_devices_skips_absolute_pointer(self) -> None:
"""ABS_X/Y devices (touchpads, tablets) are also pointers."""
mock_proc_content = """I: Bus=0018 Vendor=04f3 Product=0033 Version=0500
def test_find_keyboard_devices_keeps_absolute_combo_and_skips_touchpad(self) -> None:
"""Keyboard keys keep an ABS_X/Y combo; button-only touchpads stay excluded."""
mock_proc_content = f"""I: Bus=0018 Vendor=04f3 Product=0033 Version=0500
N: Name="Elan Touchpad"
H: Handlers=mouse0 event1
B: KEY=ff000000000000 0 0 38000000
B: ABS=273000000000003
I: Bus=0018 Vendor=04f3 Product=0034 Version=0500
N: Name="Button-only Touchpad"
H: Handlers=mouse0 event2
B: KEY={_proc_key_bitmap(ecodes.BTN_TOUCH)}
B: ABS=273000000000003
"""
# KEY carries real keyboard keys (bits 29-31) so only the pointer
# check can drop this device.
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
with patch("os.path.exists", return_value=True):
result = find_keyboard_devices()
assert result == []
assert result == ["/dev/input/event1"]
def test_find_keyboard_devices_keeps_wheel_only_device(self) -> None:
"""REL_WHEEL without X/Y motion does not mark a device as a pointer."""
@@ -243,23 +262,62 @@ B: REL=100
result = find_keyboard_devices()
assert result == ["/dev/input/event3"]
def test_find_keyboard_devices_pointer_axes_split_bitmap(self) -> None:
"""Pointer axes are read from the low word of a multi-word bitmap."""
mock_proc_content = """I: Bus=0003 Vendor=046d Product=c52b Version=1201
def test_find_keyboard_devices_keeps_hidpp_keyboard_pointer_combos(self) -> None:
"""All keyboard-capable HID++ nodes remain eligible regardless of name."""
# Real blocks from one Bolt receiver: keyboards and mouse share the
# same REL bitmap, only the name tells them apart.
mock_proc_content = """I: Bus=0003 Vendor=046d Product=408a Version=0111
N: Name="Logitech MX Keys"
H: Handlers=sysrq kbd leds mouse1 event3
B: EV=12001f
B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 \
ffff0001 130ff38b17d007 ffff7bfad941dfff ffbeffdfffefffff fffffffffffffffe
B: REL=1943
B: ABS=100000000
I: Bus=0003 Vendor=046d Product=4088 Version=0111
N: Name="Logitech ERGO K860"
H: Handlers=sysrq kbd leds mouse0 event4
B: EV=12001f
B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 \
ffff0001 130ff38b17d007 ffff7bfad941dfff ffbeffdfffefffff fffffffffffffffe
B: REL=1943
B: ABS=100000000
I: Bus=0003 Vendor=046d Product=4082 Version=0111
N: Name="Logitech MX Master 3"
H: Handlers=sysrq kbd leds mouse2 event5
B: EV=120017
B: KEY=ffff0000 1000000000007 ff9f207ac14057ff febeffdfffefffff fffffffffffffffe
B: REL=1943
"""
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
with patch("os.path.exists", return_value=True):
result = find_keyboard_devices()
assert result == [
"/dev/input/event3",
"/dev/input/event4",
"/dev/input/event5",
]
def test_find_keyboard_devices_keeps_split_bitmap_combo_and_skips_mouse(self) -> None:
"""Low-word REL_X/Y axes keep keyboard combos, not button-only mice."""
mock_proc_content = f"""I: Bus=0003 Vendor=046d Product=c52b Version=1201
N: Name="Logitech USB Receiver"
H: Handlers=mouse0 event4
B: KEY=ffff0000 0 0 0 38000000
B: REL=0 0 143
I: Bus=0003 Vendor=046d Product=0001 Version=0110
N: Name="Button-only Mouse"
H: Handlers=mouse0 event5
B: KEY={_proc_key_bitmap(ecodes.BTN_LEFT, ecodes.BTN_RIGHT)}
B: REL=0 0 143
"""
# KEY carries real keyboard keys (bits 29-31) so only the pointer
# check can drop this device.
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
with patch("os.path.exists", return_value=True):
result = find_keyboard_devices()
assert result == []
assert result == ["/dev/input/event4"]
def test_find_keyboard_devices_skips_button_only_devices(self) -> None:
"""Devices whose KEY bitmap has no real keyboard key are not keyboards (#915)."""
"""Button-only devices are excluded while keyboard-pointer combos remain eligible."""
mock_proc_content = """I: Bus=0019 Vendor=0000 Product=0001 Version=0000
N: Name="Power Button"
H: Handlers=kbd event4
@@ -293,7 +351,11 @@ B: REL=143
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
with patch("os.path.exists", return_value=True):
result = find_keyboard_devices()
assert result == ["/dev/input/event0", "/dev/input/event8"]
assert result == [
"/dev/input/event0",
"/dev/input/event8",
"/dev/input/event9",
]
def test_find_keyboard_devices_32bit_words(self) -> None:
"""On a 32-bit kernel each bitmap word holds 32 bits, not 64.
@@ -315,12 +377,12 @@ B: KEY=2 0 0 0
def test_find_keyboard_devices_skips_multitouch_only_touchpad(self) -> None:
"""A touchpad reporting only MT axes (no ABS_X/Y) is a pointer (#914)."""
mock_proc_content = """I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100
mock_proc_content = f"""I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100
N: Name="GXTP5100:00 27C6:01E9"
H: Handlers=mouse0 event2
B: PROP=85
B: EV=1b
B: KEY=10000 0 0 0 38000000
B: KEY={_proc_key_bitmap(ecodes.BTN_TOUCH)}
B: ABS=60000000000000
B: MSC=10
"""
@@ -330,11 +392,11 @@ B: MSC=10
assert result == []
def test_find_keyboard_devices_skips_mixed_single_and_mt_touchpad(self) -> None:
"""A touchpad with both ABS_X/Y and MT axes stays skipped."""
mock_proc_content = """I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100
"""A button-only touchpad with both ABS and MT axes stays skipped."""
mock_proc_content = f"""I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100
N: Name="GXTP5100:00 27C6:01E9"
H: Handlers=mouse0 event2
B: KEY=10000 0 0 0 38000000
B: KEY={_proc_key_bitmap(ecodes.BTN_TOUCH)}
B: ABS=60000000000003
"""
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
@@ -343,18 +405,23 @@ B: ABS=60000000000003
assert result == []
def test_find_keyboard_devices_mt_axes_32bit_words(self) -> None:
"""On a 32-bit kernel the same MT axes print as two 32-bit words."""
mock_proc_content = """I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100
"""32-bit MT axes retain keyboard combos and skip button-only touchpads."""
mock_proc_content = f"""I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100
N: Name="GXTP5100:00 27C6:01E9"
H: Handlers=mouse0 event2
B: KEY=10000 0 0 0 38000000
B: KEY={_proc_key_bitmap(ecodes.KEY_RIGHTCTRL, word_bits=32)}
B: ABS=600000 0
I: Bus=0018 Vendor=27c6 Product=01ea Version=0100
N: Name="Button-only MT touchpad"
H: Handlers=mouse0 event3
B: KEY={_proc_key_bitmap(ecodes.BTN_TOUCH, word_bits=32)}
B: ABS=600000 0
"""
with patch("vocalinux.ui.keyboard_backends.evdev_backend._WORD_BITS", 32):
with patch("builtins.open", mock_open(read_data=mock_proc_content)):
with patch("os.path.exists", return_value=True):
result = find_keyboard_devices()
assert result == []
assert result == ["/dev/input/event2"]
class TestFindKeyboardDevicesFromEvdev:
@@ -380,16 +447,18 @@ class TestFindKeyboardDevicesFromEvdev:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.evdev")
def test_skips_pointer_devices(self, mock_evdev: Mock, mock_input_device: Mock) -> None:
"""Mice/tablets with EV_KEY buttons are skipped, keyboards kept."""
def test_keeps_keyboard_pointer_devices_and_skips_button_only_pointers(
self, mock_evdev: Mock, mock_input_device: Mock
) -> None:
"""Keyboard combos are kept; pointer nodes with only buttons are excluded."""
caps = {
"/dev/input/event0": {ecodes.EV_KEY: [ecodes.KEY_A]},
# Mouse-style node: keyboard keys plus pointer motion.
# Keyboard-plus-pointer combo.
"/dev/input/event1": {
ecodes.EV_KEY: [ecodes.KEY_LEFTCTRL],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
},
# Tablet/touchpad: absolute pointer axes (AbsInfo tuples).
# Keyboard-plus-absolute-pointer combo (AbsInfo tuples).
"/dev/input/event2": {
ecodes.EV_KEY: [ecodes.KEY_A],
ecodes.EV_ABS: [(ecodes.ABS_X, MagicMock()), (ecodes.ABS_Y, MagicMock())],
@@ -399,8 +468,7 @@ class TestFindKeyboardDevicesFromEvdev:
ecodes.EV_KEY: [ecodes.KEY_A],
ecodes.EV_REL: [ecodes.REL_WHEEL],
},
# Multitouch-only touchpad: MT position axes, no ABS_X/Y (#914).
# Carries a real key so only the MT check can drop it.
# Keyboard-plus-MT combo: no ABS_X/Y.
"/dev/input/event4": {
ecodes.EV_KEY: [ecodes.KEY_LEFTCTRL, ecodes.BTN_LEFT],
ecodes.EV_ABS: [
@@ -408,13 +476,61 @@ class TestFindKeyboardDevicesFromEvdev:
(ecodes.ABS_MT_POSITION_Y, MagicMock()),
],
},
# Button-only pointer nodes remain excluded.
"/dev/input/event5": {
ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y],
},
"/dev/input/event6": {
ecodes.EV_KEY: [ecodes.BTN_TOUCH],
ecodes.EV_ABS: [
(ecodes.ABS_MT_POSITION_X, MagicMock()),
(ecodes.ABS_MT_POSITION_Y, MagicMock()),
],
},
}
devices = self._devices(caps)
mock_evdev.list_devices.return_value = list(caps)
mock_input_device.side_effect = lambda path: devices[path]
assert _find_keyboard_devices_from_evdev() == [
"/dev/input/event0",
"/dev/input/event1",
"/dev/input/event2",
"/dev/input/event3",
"/dev/input/event4",
]
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.evdev")
def test_keeps_hidpp_keyboard_pointer_combos(
self, mock_evdev: Mock, mock_input_device: Mock
) -> None:
"""All three HID++ nodes have keyboard keys and pointer axes."""
hidpp_caps = {
ecodes.EV_KEY: [ecodes.KEY_A, ecodes.KEY_RIGHTALT, ecodes.BTN_LEFT],
ecodes.EV_REL: [
ecodes.REL_X,
ecodes.REL_Y,
ecodes.REL_HWHEEL,
ecodes.REL_WHEEL,
ecodes.REL_WHEEL_HI_RES,
ecodes.REL_HWHEEL_HI_RES,
],
}
names = {
"/dev/input/event3": "Logitech MX Keys",
"/dev/input/event4": "Logitech ERGO K860",
"/dev/input/event5": "Logitech MX Master 3",
}
devices = self._devices({path: hidpp_caps for path in names})
for path, name in names.items():
devices[path].name = name
mock_evdev.list_devices.return_value = list(names)
mock_input_device.side_effect = lambda path: devices[path]
assert _find_keyboard_devices_from_evdev() == [
"/dev/input/event3",
"/dev/input/event4",
"/dev/input/event5",
]
@@ -1341,14 +1457,168 @@ class TestEvdevGrabAndForwarding:
def _syn_event(self) -> MagicMock:
return MagicMock(type=0, code=0) # EV_SYN / SYN_REPORT
@pytest.mark.parametrize(
"axes",
[
pytest.param(
{ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y]},
id="relative",
),
pytest.param(
{
ecodes.EV_ABS: [
(ecodes.ABS_MT_POSITION_X, MagicMock()),
(ecodes.ABS_MT_POSITION_Y, MagicMock()),
]
},
id="multitouch",
),
],
)
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend._is_virtual_keyboard")
def test_open_monitors_keyboard_pointer_ungrabbed(
self,
mock_is_virtual: Mock,
mock_uinput: Mock,
mock_input_device: Mock,
axes: dict[int, list[object]],
) -> None:
"""Pointer-axis keyboard combos register without grabbing or cloning."""
mock_is_virtual.return_value = False
device = MagicMock()
device.fileno.return_value = 10
device.name = "MX Mechanical"
device.capabilities.return_value = {
ecodes.EV_KEY: [ecodes.KEY_A, ecodes.KEY_RIGHTCTRL],
**axes,
}
mock_input_device.return_value = device
backend = EvdevKeyboardBackend()
create_forwarder = Mock()
backend._create_forwarder = create_forwarder
assert backend._open_keyboard_device("/dev/input/event5") is True
mock_is_virtual.assert_called_once_with("/dev/input/event5", "MX Mechanical")
create_forwarder.assert_not_called()
mock_uinput.assert_not_called()
device.grab.assert_not_called()
assert backend.device_paths == {"/dev/input/event5"}
assert backend.device_fds == [10]
assert backend._forwarders == {}
assert device in backend.devices
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend._is_virtual_keyboard")
def test_open_monitors_keyboard_with_unknown_capabilities_ungrabbed(
self,
mock_is_virtual: Mock,
mock_uinput: Mock,
mock_input_device: Mock,
) -> None:
"""Capability read errors choose the safe ungrabbed path."""
mock_is_virtual.return_value = False
device = MagicMock()
device.fileno.return_value = 10
device.name = "Unknown keyboard"
device.capabilities.side_effect = OSError("capabilities unavailable")
mock_input_device.return_value = device
backend = EvdevKeyboardBackend()
create_forwarder = Mock()
backend._create_forwarder = create_forwarder
assert backend._open_keyboard_device("/dev/input/event5") is True
create_forwarder.assert_not_called()
mock_uinput.assert_not_called()
device.grab.assert_not_called()
assert backend.device_paths == {"/dev/input/event5"}
assert backend.device_fds == [10]
assert backend._forwarders == {}
@pytest.mark.parametrize("capabilities", [None, []])
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend._is_virtual_keyboard")
def test_open_monitors_keyboard_with_non_mapping_capabilities_ungrabbed(
self,
mock_is_virtual: Mock,
mock_uinput: Mock,
mock_input_device: Mock,
capabilities: object,
) -> None:
"""Non-mapping capability results are unknown and remain ungrabbed."""
mock_is_virtual.return_value = False
device = MagicMock()
device.fileno.return_value = 10
device.name = "Unknown keyboard"
device.capabilities.return_value = capabilities
mock_input_device.return_value = device
backend = EvdevKeyboardBackend()
create_forwarder = Mock()
backend._create_forwarder = create_forwarder
assert backend._open_keyboard_device("/dev/input/event5") is True
create_forwarder.assert_not_called()
mock_uinput.assert_not_called()
device.grab.assert_not_called()
assert backend.device_paths == {"/dev/input/event5"}
assert backend.device_fds == [10]
assert backend._forwarders == {}
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend._is_virtual_keyboard")
def test_ungrabbed_keyboard_pointer_dispatches_shortcut_without_forwarder(
self,
mock_is_virtual: Mock,
mock_uinput: Mock,
mock_input_device: Mock,
) -> None:
"""RIGHTCTRL events reach the registered engine without a clone."""
mock_is_virtual.return_value = False
device = MagicMock()
device.fileno.return_value = 10
device.name = "MX Mechanical"
device.capabilities.return_value = {
ecodes.EV_KEY: [ecodes.KEY_A, ecodes.KEY_RIGHTCTRL],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y],
}
mock_input_device.return_value = device
backend = EvdevKeyboardBackend(shortcut="right_ctrl+right_ctrl", mode="push_to_talk")
activated = Mock()
released = Mock()
backend.register_press_callback(activated)
backend.register_release_callback(released)
assert backend._open_keyboard_device("/dev/input/event5") is True
hub = backend._hub
press = MagicMock(type=ecodes.EV_KEY, code=ecodes.KEY_RIGHTCTRL, value=1)
release = MagicMock(type=ecodes.EV_KEY, code=ecodes.KEY_RIGHTCTRL, value=0)
hub._dispatch_event(10, device, press, [backend])
hub._dispatch_event(10, device, release, [backend])
time.sleep(0.1)
activated.assert_called_once()
released.assert_called_once()
mock_uinput.assert_not_called()
assert hub._forwarders == {}
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_grabs_device_and_registers_forwarder(self, mock_input_device, mock_uinput):
def test_open_grabs_device_and_registers_forwarder(
self, mock_input_device: Mock, mock_uinput: Mock
) -> None:
"""A keyboard that can be cloned is grabbed and paired with a forwarder."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03 # BUS_USB
device.active_keys.return_value = []
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
forwarder = MagicMock()
mock_uinput.return_value = forwarder
@@ -1511,6 +1781,7 @@ class TestEvdevGrabAndForwarding:
device.info.bustype = 0x03
device.name = "keyd virtual keyboard"
device.active_keys.return_value = []
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
forwarder = MagicMock()
forwarder.device.path = "/dev/input/event20"
@@ -1567,12 +1838,13 @@ class TestEvdevGrabAndForwarding:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_falls_back_when_uinput_unavailable(
self, mock_input_device, mock_uinput, mock_write_only
):
self, mock_input_device: Mock, mock_uinput: Mock, mock_write_only: Mock
) -> None:
"""Without /dev/uinput the device still works ungrabbed (keys leak)."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
mock_uinput.side_effect = OSError("uinput denied")
mock_write_only.side_effect = OSError(errno.EACCES, "uinput denied")
@@ -1590,8 +1862,8 @@ class TestEvdevGrabAndForwarding:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_retries_uinput_write_only_on_eacces(
self, mock_input_device, mock_uinput, mock_write_only
):
self, mock_input_device: Mock, mock_uinput: Mock, mock_write_only: Mock
) -> None:
"""An O_RDWR refusal retries O_WRONLY so the device still gets grabbed.
Installs carrying the original write-only udev rule (MODE=0620)
@@ -1602,6 +1874,7 @@ class TestEvdevGrabAndForwarding:
device.fileno.return_value = 10
device.info.bustype = 0x03
device.active_keys.return_value = []
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
mock_uinput.side_effect = OSError("could not open uinput device in write mode")
forwarder = MagicMock()
@@ -1620,12 +1893,13 @@ class TestEvdevGrabAndForwarding:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_does_not_retry_write_only_on_other_oserror(
self, mock_input_device, mock_uinput, mock_write_only
):
self, mock_input_device: Mock, mock_uinput: Mock, mock_write_only: Mock
) -> None:
"""A UInput failure with a real errno is not a permission retry."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
mock_uinput.side_effect = OSError(errno.EIO, "i/o error")
@@ -1656,6 +1930,7 @@ class TestEvdevGrabAndForwarding:
device = MagicMock()
device.fileno.return_value = 10
device.active_keys.return_value = [54]
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
forwarder = MagicMock()
mock_uinput.return_value = forwarder
@@ -1687,6 +1962,7 @@ class TestEvdevGrabAndForwarding:
device.fileno.return_value = 10
device.active_keys.return_value = []
device.read_one.side_effect = [press, None]
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
forwarder = MagicMock()
mock_uinput.return_value = forwarder
@@ -1722,6 +1998,7 @@ class TestEvdevGrabAndForwarding:
device = MagicMock()
device.fileno.return_value = 10
device.active_keys.return_value = []
device.capabilities.return_value = _keyboard_only_capabilities()
# The pre-grab drain comes up empty; the straddle drain sees both.
device.read_one.side_effect = [None, press, release, None]
mock_input_device.return_value = device
@@ -1757,6 +2034,7 @@ class TestEvdevGrabAndForwarding:
device = MagicMock()
device.fileno.return_value = 10
device.active_keys.return_value = []
device.capabilities.return_value = _keyboard_only_capabilities()
device.read_one.side_effect = [None, stale_press, held_press, None]
mock_input_device.return_value = device
forwarder = MagicMock()
@@ -1804,6 +2082,7 @@ class TestEvdevGrabAndForwarding:
device = MagicMock()
device.fileno.return_value = 10
device.active_keys.return_value = []
device.capabilities.return_value = _keyboard_only_capabilities()
# First None empties the pre-grab drain; the straddle drain follows.
device.read_one.side_effect = [None, *queue, None]
mock_input_device.return_value = device
@@ -1826,11 +2105,14 @@ class TestEvdevGrabAndForwarding:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_falls_back_when_grab_fails(self, mock_input_device, mock_uinput):
def test_open_falls_back_when_grab_fails(
self, mock_input_device: Mock, mock_uinput: Mock
) -> None:
"""A failed grab drops the forwarder but keeps the listener running."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
device.capabilities.return_value = _keyboard_only_capabilities()
device.grab.side_effect = OSError(errno.EBUSY, "device grabbed")
mock_input_device.return_value = device
forwarder = MagicMock()
@@ -2006,11 +2288,14 @@ class TestEvdevGrabAndForwarding:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_stop_closes_forwarders(self, mock_input_device, mock_find_devices, mock_uinput):
def test_stop_closes_forwarders(
self, mock_input_device: Mock, mock_find_devices: Mock, mock_uinput: Mock
) -> None:
"""stop() releases grabs (via close) and closes every uinput clone."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
forwarder = MagicMock()
mock_uinput.return_value = forwarder
@@ -2242,11 +2527,14 @@ class TestEvdevGrabAndForwarding:
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_device_race_closes_device_and_forwarder(self, mock_input_device, mock_uinput):
def test_open_device_race_closes_device_and_forwarder(
self, mock_input_device: Mock, mock_uinput: Mock
) -> None:
"""A device that loses the registration race is cleaned up fully."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
device.capabilities.return_value = _keyboard_only_capabilities()
mock_input_device.return_value = device
forwarder = MagicMock()
mock_uinput.return_value = forwarder