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
@@ -10,6 +10,9 @@ that make up the dictation shortcut are consumed here and never reach the
focused application. Without uinput access the backend still listens
ungrabbed, but shortcut presses also pass through to apps.
Keyboard nodes that also report pointer axes are monitored ungrabbed so
pointer motion stays on the compositor's direct input path.
All EvdevKeyboardBackend instances in the process share one device layer
(EvdevDeviceHub): a grabbed device reports to a single fd, so a second
reader opening the same keyboard would see no events. The hub owns the
@@ -343,12 +346,11 @@ def evdev_code_for_key(token: str) -> Optional[int]:
def find_keyboard_devices() -> list[str]:
"""
Find all keyboard input devices.
Find devices with real keyboard keys.
Devices that emit pointer motion (REL_X/Y, ABS_X/Y, or multitouch
ABS_MT_POSITION_X/Y) are excluded: a mouse is not a keyboard, and
monitoring one proxies all cursor movement through the reader thread
(cursor stutter, #900; dead touchpad, #914).
Nodes that also emit pointer motion are included but monitored ungrabbed
so pointer movement stays on the compositor's direct input path (#953).
Pointer-only devices and button blocks are excluded.
Prefers /proc/bus/input/devices (host installs). Falls back to
``evdev.list_devices()`` when that path cannot be read (common under snap
@@ -375,10 +377,9 @@ def find_keyboard_devices() -> list[str]:
# Bits 0 and 1 of a REL or ABS capability bitmap: REL_X|REL_Y or
# ABS_X|ABS_Y — pointer motion. A keyboard never reports both; devices
# that do are mice, trackpads or tablets, and monitoring one meant
# grabbing it and piping all cursor movement through the reader thread,
# which showed up as periodic cursor stutter (#900).
# ABS_X|ABS_Y — pointer motion. Some keyboards report these axes too
# (Logitech HID++ keyboards, #953), so pointer-capable nodes are monitored
# ungrabbed to keep cursor motion off the reader thread (#900).
_POINTER_AXIS_BITS = 0x3
# Key codes only real keyboards report: the modifiers plus KEY_A — the
@@ -485,10 +486,9 @@ def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]:
"""Parse an open /proc/bus/input/devices stream for event KEY devices.
Only blocks carrying a real keyboard key (a modifier or KEY_A, the
same rule the evdev fallback applies) qualify. Devices that emit
pointer motion (REL_X/Y, ABS_X/Y or ABS_MT_POSITION_X/Y) are
skipped: mice and tablets report EV_KEY bits for their buttons, but
grabbing them proxies every cursor movement through this reader.
same rule the evdev fallback applies) qualify. Pointer-capable keyboard
nodes are kept for ungrabbed monitoring; pointer-only and button-only
nodes remain excluded (#953).
"""
keyboard_devices: list[str] = []
device: Optional[dict[str, Any]] = None
@@ -503,12 +503,12 @@ def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]:
"Skipping non-keyboard device during keyboard discovery: "
f"{device.get('name', '?')} ({device.get('handlers', [])})"
)
elif _proc_block_has_pointer_axes(device):
logger.debug(
"Skipping pointer device during keyboard discovery: "
f"{device.get('name', '?')} ({device.get('handlers', [])})"
)
else:
if _proc_block_has_pointer_axes(device):
logger.debug(
"Keeping combined keyboard+pointer device for ungrabbed monitoring: "
f"{device.get('name', '?')} ({device.get('handlers', [])})"
)
for handler in device.get("handlers", []):
if handler.startswith("event"):
device_path = f"/dev/input/{handler}"
@@ -557,6 +557,17 @@ def _capabilities_have_pointer_axes(capabilities: Mapping[int, Sequence[int]]) -
return ecodes.ABS_MT_POSITION_X in abs_codes and ecodes.ABS_MT_POSITION_Y in abs_codes
def _device_has_pointer_axes(device: InputDevice) -> bool:
"""Return True for pointer axes or when the device capabilities are unknown."""
try:
capabilities = device.capabilities()
if not isinstance(capabilities, Mapping):
return True
return _capabilities_have_pointer_axes(capabilities)
except (OSError, IOError, TypeError, ValueError):
return True
def _find_keyboard_devices_from_evdev() -> list[str]:
"""Discover keyboards by opening devices from ``evdev.list_devices()``."""
if not EVDEV_AVAILABLE:
@@ -574,18 +585,11 @@ def _find_keyboard_devices_from_evdev() -> list[str]:
device = InputDevice(path)
try:
capabilities = device.capabilities()
device_name = str(getattr(device, "name", "") or "")
finally:
device.close()
except (OSError, IOError, TypeError, ValueError):
continue
if capabilities and _capabilities_have_pointer_axes(capabilities):
# Mice/tablets also report EV_KEY for their buttons; monitoring
# a pointer pipes all cursor movement through this reader.
logger.debug(f"Skipping pointer device during keyboard discovery: {device_name}")
continue
# Prefer devices that look like keyboards: EV_KEY with a common letter
# or a modifier. This includes combo "Keyboard" nodes on mice/remotes.
key_caps = capabilities.get(ecodes.EV_KEY, []) if capabilities else []
@@ -1201,6 +1205,14 @@ class EvdevDeviceHub:
# 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})")
elif _device_has_pointer_axes(device):
# Grabbing would pipe pointer motion through the reader (#900).
# Leave it ungrabbed so the compositor reads the node directly;
# only shortcut suppression is lost on this device, the same
# tradeoff as for remappers.
logger.debug(
f"Monitoring keyboard+pointer device 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
+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