fix(hotkeys): consume the dictation shortcut so it stops leaking to apps (#873)
* fix(hotkeys): consume the dictation shortcut so it stops leaking to apps Grab each monitored keyboard with EVIOCGRAB and re-emit every event the shortcut does not own through a paired uinput clone. The configured shortcut key is now consumed by Vocalinux instead of also reaching the focused app (right-alt opening menus in Thunderbird/Firefox, F11/F12 triggering browser actions). - Pure-modifier gestures reserve the modifier's events entirely; bare function-key shortcuts consume that key; modifier+key combos consume the main key only while the combo owns it, so typing and chords like Alt+Tab keep working and injected text is untouched - BUS_VIRTUAL devices (our clones, ydotool, remappers) are never grabbed - EV_REP is stripped from clones and repeats are forwarded verbatim, so repeat rate is preserved without doubling - Feedback event types (EV_LED/EV_SND/EV_FF) are never re-injected - Falls back to ungrabbed listening when /dev/uinput or the grab is unavailable, warning once that keys will also reach the app Fixes #871 * test(hotkeys): cover grab/forward edge cases Race cleanup on duplicate open, forwarder close errors during stop and device removal, mid-SYN_DROPPED forwarding, write failures, and the combo consume edge cases (unowned repeat/release, missing main key). * fix(installer): grant read on /dev/uinput so key suppression works The evdev backend's uinput clone opens /dev/uinput O_RDWR, but the installed 80-dotool.rules gave the input group write-only access (0620), so shortcut suppression silently fell back to ungrabbed monitoring on stock installs. Bump the rule to 0660 and refresh it when the installed copy differs so existing installs pick up the fix. * fix(hotkeys): harden evdev grab/forward lifecycle and udev install - release the device grab and close the clone when a uinput write fails - close all devices if the monitor exits unexpectedly (releases grabs) - monitor virtual devices too; skip only our own uinput clones - track consumed combo presses per device so releases stay paired - after SYN_DROPPED, release keys the clone still believes are held - append the uinput udev rule instead of overwriting local rules - annotate new signatures and narrow the clone-creation catch * fix(hotkeys): reconcile swallowed combo releases after SYN_DROPPED - resync the swallowed-press pairing even when the clone holds no keys; a dropped combo release otherwise leaves the next ordinary press consumed, eating a keystroke - narrow every new close-cleanup catch to OSError/IOError/RuntimeError with debug context instead of blanket Exception passes --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
004696bbe0
commit
ff108595fd
@@ -3,6 +3,12 @@ evdev keyboard backend for Wayland support.
|
||||
|
||||
This backend uses python-evdev to read keyboard events directly from
|
||||
input devices, which works on both X11 and Wayland (with proper permissions).
|
||||
|
||||
When /dev/uinput is writable, each opened keyboard is grabbed (EVIOCGRAB)
|
||||
and its events are re-emitted through a paired uinput clone, so the keys
|
||||
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.
|
||||
"""
|
||||
|
||||
import errno
|
||||
@@ -12,17 +18,20 @@ import re
|
||||
import select
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, TextIO
|
||||
from typing import Optional, Sequence, TextIO
|
||||
|
||||
# Try to import evdev
|
||||
try:
|
||||
import evdev
|
||||
from evdev import InputDevice, ecodes
|
||||
from evdev import InputDevice, InputEvent, UInput, UInputError, ecodes
|
||||
|
||||
EVDEV_AVAILABLE = True
|
||||
except ImportError:
|
||||
evdev = None # type: ignore
|
||||
InputDevice = None # type: ignore
|
||||
InputEvent = None # type: ignore
|
||||
UInput = None # type: ignore
|
||||
UInputError = OSError # type: ignore
|
||||
ecodes = None # type: ignore
|
||||
EVDEV_AVAILABLE = False
|
||||
|
||||
@@ -49,6 +58,23 @@ KEY_LEFTMETA = 125 # Super/Windows key
|
||||
KEY_RIGHTMETA = 126
|
||||
DEVICE_RESCAN_SECONDS = 2.0
|
||||
|
||||
# Event types re-emitted on the uinput clone. Feedback events (EV_LED,
|
||||
# EV_SND, EV_FF) are kernel->device traffic already delivered to every
|
||||
# capable device — re-injecting them could feed back into the LED layer.
|
||||
if EVDEV_AVAILABLE:
|
||||
_FORWARDED_EVENT_TYPES = frozenset(
|
||||
{
|
||||
ecodes.EV_KEY,
|
||||
ecodes.EV_REL,
|
||||
ecodes.EV_ABS,
|
||||
ecodes.EV_MSC,
|
||||
ecodes.EV_SW,
|
||||
getattr(ecodes, "EV_ROT", -1),
|
||||
}
|
||||
)
|
||||
else:
|
||||
_FORWARDED_EVENT_TYPES = frozenset()
|
||||
|
||||
# Map modifier key names to evdev key codes
|
||||
MODIFIER_KEY_CODES: dict[str, set[int]] = {
|
||||
"ctrl": {KEY_LEFTCTRL, KEY_RIGHTCTRL},
|
||||
@@ -311,11 +337,25 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
self._combo_all_codes: set[int] = set()
|
||||
self._combo_pressed: set[int] = set() # currently-held combo-relevant codes
|
||||
self._combo_active = False # True while a push-to-talk combo hold is live
|
||||
# fds whose combo main-key press was consumed but not yet released —
|
||||
# per-device so one keyboard's release can't consume another's press.
|
||||
self._combo_swallowed: set[int] = set()
|
||||
self._resolve_combo_targets()
|
||||
|
||||
self._devices_lock = threading.Lock()
|
||||
self._dropped_devices: set[int] = set() # fds with SYN_DROPPED pending
|
||||
self._device_paths_by_fd: dict[int, str] = {}
|
||||
# Grabbed fd -> uinput clone that re-emits events we do not consume.
|
||||
self._forwarders: dict[int, UInput] = {}
|
||||
# Grabbed fd -> its clone's /dev/input/eventN path, and the live set
|
||||
# of clone paths. Clones are never monitored: their events are the
|
||||
# ones we forwarded, so reading them would loop input back in.
|
||||
self._forwarder_paths: dict[int, str] = {}
|
||||
self._clone_paths: set[str] = set()
|
||||
# Grabbed fd -> key codes the clone currently believes are held.
|
||||
# Needed to reconcile after SYN_DROPPED eats release events.
|
||||
self._forwarded_held: dict[int, set[int]] = {}
|
||||
self._uinput_warned = False
|
||||
|
||||
if not EVDEV_AVAILABLE:
|
||||
logger.error("python-evdev not available")
|
||||
@@ -336,6 +376,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
self._combo_all_codes = set()
|
||||
self._combo_pressed = set()
|
||||
self._combo_active = False
|
||||
self._combo_swallowed = set()
|
||||
|
||||
spec = getattr(self, "_spec", None)
|
||||
if spec is None or not spec.is_combo:
|
||||
@@ -374,6 +415,12 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
push-to-talk hold could stay stuck on. Clearing the pressed set fails
|
||||
safe (the user simply re-presses the modifier), and ending any live hold
|
||||
prevents a stuck session.
|
||||
|
||||
``_combo_swallowed`` is deliberately left alone: it records consumed
|
||||
presses per device, so clearing it here would let the matching
|
||||
release of a still-held key leak to the application without its
|
||||
press. Stale entries are pruned per-device on removal and during
|
||||
the SYN_DROPPED resync instead.
|
||||
"""
|
||||
self._combo_pressed = set()
|
||||
# Ends an active push-to-talk hold (fires the release callback); no-op
|
||||
@@ -486,6 +533,11 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
self.key_pressed_devices = set()
|
||||
self._dropped_devices = set()
|
||||
self._device_paths_by_fd = {}
|
||||
self._forwarders = {}
|
||||
self._forwarder_paths = {}
|
||||
self._clone_paths = set()
|
||||
self._forwarded_held = {}
|
||||
self._uinput_warned = False
|
||||
|
||||
# Refresh combo targets and clear any stale held-key state.
|
||||
self._resolve_combo_targets()
|
||||
@@ -520,20 +572,39 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
self.monitor_thread.join(timeout=2.0)
|
||||
self.monitor_thread = None
|
||||
|
||||
# Close devices
|
||||
self._close_all_devices()
|
||||
|
||||
def _close_all_devices(self) -> None:
|
||||
"""Close every monitored device and uinput clone.
|
||||
|
||||
Closing a grabbed device releases its EVIOCGRAB, and closing a
|
||||
uinput clone makes the kernel release any keys the clone still
|
||||
holds — together they hand input delivery back to applications.
|
||||
"""
|
||||
with self._devices_lock:
|
||||
devices = list(self.devices)
|
||||
forwarders = list(self._forwarders.values())
|
||||
self.devices = []
|
||||
self.device_fds = []
|
||||
self.device_paths = set()
|
||||
self._dropped_devices = set()
|
||||
self._device_paths_by_fd = {}
|
||||
self._forwarders = {}
|
||||
self._forwarder_paths = {}
|
||||
self._clone_paths = set()
|
||||
self._forwarded_held = {}
|
||||
self._combo_swallowed = set()
|
||||
|
||||
for device in devices:
|
||||
try:
|
||||
device.close()
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring device close failure during cleanup: {e}")
|
||||
for forwarder in forwarders:
|
||||
try:
|
||||
forwarder.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring clone close failure during cleanup: {e}")
|
||||
|
||||
def _open_keyboard_device(self, device_path: str) -> bool:
|
||||
"""Open a keyboard device if it is not already monitored."""
|
||||
@@ -548,22 +619,229 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
logger.warning(f"Cannot open {device_path}: {e}")
|
||||
return False
|
||||
|
||||
# Only Vocalinux's own uinput clones are never monitored — reading
|
||||
# them would loop the events we forwarded straight back in. Clones
|
||||
# are matched by their registered device path; the " (vocalinux)"
|
||||
# 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.
|
||||
device_name = str(getattr(device, "name", "") or "")
|
||||
if device_path in self._clone_paths or device_name.endswith(" (vocalinux)"):
|
||||
logger.debug(f"Skipping Vocalinux clone device: {device_path} ({device_name})")
|
||||
try:
|
||||
device.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
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"
|
||||
)
|
||||
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 device_path in self.device_paths or fd in self.device_fds:
|
||||
try:
|
||||
device.close()
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring close failure for duplicate {device_path}: {e}")
|
||||
if forwarder is not None:
|
||||
try:
|
||||
forwarder.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(
|
||||
f"Ignoring clone close failure for duplicate {device_path}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
self.devices.append(device)
|
||||
self.device_fds.append(fd)
|
||||
self.device_paths.add(device_path)
|
||||
self._device_paths_by_fd[fd] = device_path
|
||||
if forwarder is not None:
|
||||
self._forwarders[fd] = forwarder
|
||||
clone_path = self._forwarder_device_path(forwarder)
|
||||
if clone_path is not None:
|
||||
self._forwarder_paths[fd] = clone_path
|
||||
self._clone_paths.add(clone_path)
|
||||
self._forwarded_held[fd] = set()
|
||||
|
||||
logger.debug(f"Opened keyboard device: {device_path} ({device.name})")
|
||||
logger.debug(
|
||||
f"Opened keyboard device: {device_path} ({device.name})"
|
||||
f"{' [grabbed]' if forwarder is not None else ''}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _forwarder_device_path(forwarder: UInput) -> Optional[str]:
|
||||
"""Return the clone's /dev/input/eventN path, or None if unknowable.
|
||||
|
||||
The path is what lets ``_open_keyboard_device`` recognize (and skip)
|
||||
our clones even when the clone name was truncated to the uinput
|
||||
80-byte limit.
|
||||
"""
|
||||
try:
|
||||
path = forwarder.device.path
|
||||
except (AttributeError, OSError, IOError):
|
||||
return None
|
||||
return path if isinstance(path, str) else None
|
||||
|
||||
def _create_forwarder(self, device: InputDevice) -> Optional[UInput]:
|
||||
"""Create a uinput clone of a keyboard for pass-through forwarding.
|
||||
|
||||
EV_REP is filtered out so the clone never autorepeats: the physical
|
||||
device's own repeat events are forwarded verbatim, preserving its
|
||||
repeat rate without doubling repeats.
|
||||
"""
|
||||
try:
|
||||
capabilities: dict[int, Sequence[int]] = {
|
||||
k: v for k, v in device.capabilities().items()
|
||||
}
|
||||
for event_type in (ecodes.EV_SYN, ecodes.EV_FF, ecodes.EV_REP):
|
||||
capabilities.pop(event_type, None)
|
||||
return UInput(
|
||||
events=capabilities,
|
||||
name=f"{device.name} (vocalinux)",
|
||||
bustype=ecodes.BUS_VIRTUAL,
|
||||
)
|
||||
except (OSError, IOError, TypeError, ValueError, UInputError) as e:
|
||||
if not self._uinput_warned:
|
||||
self._uinput_warned = True
|
||||
logger.warning(
|
||||
"Cannot create a virtual keyboard for key suppression "
|
||||
f"(/dev/uinput not writable?): {e}. The dictation shortcut "
|
||||
"will also reach the focused application."
|
||||
)
|
||||
return None
|
||||
|
||||
def _release_failed_forwarder(self, fd: int) -> None:
|
||||
"""Drop a dead uinput clone and release the source device's grab.
|
||||
|
||||
A keyboard that stays grabbed while its clone can no longer accept
|
||||
events swallows all user input; releasing EVIOCGRAB hands delivery
|
||||
back to the kernel so applications see keys again (the shortcut can
|
||||
then no longer be suppressed, matching the ungrabbed fallback). If
|
||||
the grab itself cannot be released, the device is removed outright
|
||||
— a grabbed device with a dead clone is worse than no listener.
|
||||
"""
|
||||
with self._devices_lock:
|
||||
device = next((d for d in self.devices if d.fileno() == fd), None)
|
||||
forwarder = self._forwarders.pop(fd, None)
|
||||
clone_path = self._forwarder_paths.pop(fd, None)
|
||||
if clone_path is not None:
|
||||
self._clone_paths.discard(clone_path)
|
||||
self._forwarded_held.pop(fd, None)
|
||||
|
||||
if forwarder is not None:
|
||||
try:
|
||||
forwarder.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring clone close failure for fd {fd}: {e}")
|
||||
if device is None:
|
||||
return
|
||||
try:
|
||||
device.ungrab()
|
||||
except (OSError, IOError) as e:
|
||||
logger.error(
|
||||
f"Cannot release grab on fd {fd} ({e}); "
|
||||
"removing the device so it stops swallowing input"
|
||||
)
|
||||
self._remove_keyboard_device(fd, device)
|
||||
return
|
||||
logger.warning(
|
||||
f"Released grab on {getattr(device, 'name', 'device')} (fd={fd}) "
|
||||
"after a forwarding failure; keys pass through to applications "
|
||||
"but the dictation shortcut can no longer be suppressed"
|
||||
)
|
||||
|
||||
def _forward_event(self, fd: int, event: InputEvent) -> None:
|
||||
"""Re-emit an event on the uinput clone paired with a grabbed device.
|
||||
|
||||
Successful EV_KEY writes also update ``_forwarded_held`` so
|
||||
``_resync_clone_key_state`` can tell which keys the clone still
|
||||
believes are held after a SYN_DROPPED burst.
|
||||
"""
|
||||
if event.type != ecodes.EV_SYN and event.type not in _FORWARDED_EVENT_TYPES:
|
||||
return
|
||||
forwarder = self._forwarders.get(fd)
|
||||
if forwarder is None:
|
||||
return
|
||||
try:
|
||||
forwarder.write_event(event)
|
||||
except (OSError, IOError) as e:
|
||||
logger.error(f"Failed to forward event on fd {fd}: {e}")
|
||||
self._release_failed_forwarder(fd)
|
||||
return
|
||||
if event.type == ecodes.EV_KEY:
|
||||
held = self._forwarded_held.get(fd)
|
||||
if held is not None:
|
||||
if event.value == 0:
|
||||
held.discard(event.code)
|
||||
else:
|
||||
held.add(event.code)
|
||||
|
||||
def _event_is_shortcut(self, fd: int, event: InputEvent) -> bool:
|
||||
"""Return True if an EV_KEY event belongs to the dictation shortcut.
|
||||
|
||||
Consumed events are handled locally and never re-emitted, so the
|
||||
focused application never sees them:
|
||||
|
||||
- Pure-modifier gestures (``right_alt+right_alt``, ``ctrl+ctrl``):
|
||||
every event of the configured modifier side(s) is consumed — the
|
||||
modifier is reserved for Vocalinux while the listener runs.
|
||||
- Bare function-key shortcuts (``f11``): every event of that key.
|
||||
- Modifier+key combos (``alt+r``): the main key is consumed only
|
||||
while the combo owns it (press with all modifiers held, plus the
|
||||
matching repeat/release). Required modifiers and unrelated keys
|
||||
pass through, so typing and chords like Alt+Tab keep working.
|
||||
|
||||
Consumption of a combo main-key press is tracked per device fd:
|
||||
when two keyboards both press the main key while the modifiers are
|
||||
held, each device's release must still find its own consumed press
|
||||
— a shared flag would let the first release expose the second
|
||||
keyboard's release without its press. Stale entries are pruned on
|
||||
device removal and by the post-SYN_DROPPED resync.
|
||||
"""
|
||||
if event.type != ecodes.EV_KEY:
|
||||
return False
|
||||
spec = getattr(self, "_spec", None)
|
||||
if spec is None:
|
||||
return False
|
||||
|
||||
if not spec.is_combo:
|
||||
return event.code in self._get_target_key_codes()
|
||||
|
||||
main_code = self._combo_main_code
|
||||
if event.code != main_code or main_code is None:
|
||||
return False
|
||||
if not spec.modifiers:
|
||||
return True # bare function key
|
||||
if event.value == 1 and self._required_modifiers_held():
|
||||
self._combo_swallowed.add(fd)
|
||||
return True
|
||||
if event.value == 0:
|
||||
# Consume the release of a swallowed press on THIS device exactly
|
||||
# once, so apps never see a press/release pair we half-forwarded.
|
||||
if fd not in self._combo_swallowed:
|
||||
return False
|
||||
self._combo_swallowed.discard(fd)
|
||||
return True
|
||||
return fd in self._combo_swallowed
|
||||
|
||||
def _scan_for_new_devices(self) -> int:
|
||||
"""Find and open keyboard devices that appeared after startup."""
|
||||
new_device_count = 0
|
||||
@@ -587,8 +865,8 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
"""Close and forget a disconnected keyboard device."""
|
||||
try:
|
||||
device.close()
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring close failure for disconnected fd {fd}: {e}")
|
||||
|
||||
with self._devices_lock:
|
||||
try:
|
||||
@@ -604,94 +882,182 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
self.device_paths.discard(device_path)
|
||||
self._dropped_devices.discard(fd)
|
||||
self.key_pressed_devices.discard(id(device))
|
||||
# A disconnect can swallow the modifier release (e.g. a wireless
|
||||
# split half dropping mid-hold); don't leave the combo logically held.
|
||||
self._reset_combo_state()
|
||||
forwarder = self._forwarders.pop(fd, None)
|
||||
clone_path = self._forwarder_paths.pop(fd, None)
|
||||
if clone_path is not None:
|
||||
self._clone_paths.discard(clone_path)
|
||||
self._forwarded_held.pop(fd, None)
|
||||
# The fd can be recycled for a newly plugged device; a stale
|
||||
# swallowed press would then eat that device's first release.
|
||||
self._combo_swallowed.discard(fd)
|
||||
|
||||
if forwarder is not None:
|
||||
try:
|
||||
forwarder.close()
|
||||
except (OSError, IOError, RuntimeError) as e:
|
||||
logger.debug(f"Ignoring clone close failure for disconnected fd {fd}: {e}")
|
||||
# A disconnect can swallow the modifier release (e.g. a wireless
|
||||
# split half dropping mid-hold); don't leave the combo logically held.
|
||||
self._reset_combo_state()
|
||||
|
||||
def _resync_clone_key_state(self, fd: int, device: InputDevice) -> None:
|
||||
"""Release clone-side keys whose releases were lost to SYN_DROPPED.
|
||||
|
||||
The kernel's dropped-event burst may have skipped release events,
|
||||
but the paired clone only saw what we forwarded — a forwarded press
|
||||
whose release was dropped leaves the key held on the clone forever,
|
||||
and no later event supplies the missing release. The burst can also
|
||||
eat the release of a swallowed combo press, which the clone never
|
||||
saw at all. Compare what the clone believes (``_forwarded_held``)
|
||||
and what we paired (``_combo_swallowed``) against the device's live
|
||||
kernel key state and emit releases for the phantom keys.
|
||||
"""
|
||||
forwarder = self._forwarders.get(fd)
|
||||
held = self._forwarded_held.get(fd)
|
||||
if (forwarder is None or not held) and fd not in self._combo_swallowed:
|
||||
return
|
||||
try:
|
||||
actually_held = set(device.active_keys())
|
||||
except (OSError, IOError) as e:
|
||||
logger.warning(f"Cannot resync key state for fd {fd}: {e}")
|
||||
return
|
||||
if forwarder is not None and held:
|
||||
for code in sorted(held - actually_held):
|
||||
try:
|
||||
forwarder.write(ecodes.EV_KEY, code, 0)
|
||||
except (OSError, IOError) as e:
|
||||
logger.error(f"Failed to release stuck key {code} on fd {fd}: {e}")
|
||||
self._release_failed_forwarder(fd)
|
||||
return
|
||||
held.discard(code)
|
||||
if self._combo_main_code is not None and self._combo_main_code not in actually_held:
|
||||
# A dropped burst can also eat the release of a swallowed combo
|
||||
# press — a key the clone never saw, so ``held`` cannot reflect
|
||||
# it. Without this prune the next ordinary press of that key
|
||||
# would be consumed too, eating a keystroke.
|
||||
self._combo_swallowed.discard(fd)
|
||||
|
||||
def _monitor_devices(self) -> None:
|
||||
"""Monitor keyboard devices for events."""
|
||||
logger.debug("Starting device monitor thread")
|
||||
last_scan = time.monotonic()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
if now - last_scan >= DEVICE_RESCAN_SECONDS:
|
||||
self._scan_for_new_devices()
|
||||
last_scan = now
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
if now - last_scan >= DEVICE_RESCAN_SECONDS:
|
||||
self._scan_for_new_devices()
|
||||
last_scan = now
|
||||
|
||||
# Use select to wait for events on any device
|
||||
with self._devices_lock:
|
||||
device_fds = list(self.device_fds)
|
||||
# Use select to wait for events on any device
|
||||
with self._devices_lock:
|
||||
device_fds = list(self.device_fds)
|
||||
|
||||
if not device_fds:
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
readable, _, _ = select.select(device_fds, [], [], 1.0) # 1 second timeout
|
||||
|
||||
for fd in readable:
|
||||
try:
|
||||
# Find the device for this fd
|
||||
with self._devices_lock:
|
||||
device = None
|
||||
for d in self.devices:
|
||||
if d.fileno() == fd:
|
||||
device = d
|
||||
break
|
||||
|
||||
if device is None:
|
||||
continue
|
||||
|
||||
# Read events from this device
|
||||
for event in device.read():
|
||||
if event.type == ecodes.EV_SYN:
|
||||
if event.code == ecodes.SYN_DROPPED:
|
||||
# Kernel buffer overflowed — discard until SYN_REPORT
|
||||
self._dropped_devices.add(fd)
|
||||
logger.warning(
|
||||
f"SYN_DROPPED on {device.name} (fd={fd}), "
|
||||
"resetting key state"
|
||||
)
|
||||
elif event.code == ecodes.SYN_REPORT:
|
||||
if fd in self._dropped_devices:
|
||||
# End of dropped sequence — clear stale state
|
||||
self._dropped_devices.discard(fd)
|
||||
self.key_pressed_devices.discard(id(device))
|
||||
# A dropped modifier release must not leave
|
||||
# the combo logically held.
|
||||
self._reset_combo_state()
|
||||
continue
|
||||
if fd in self._dropped_devices:
|
||||
continue
|
||||
if event.type == ecodes.EV_KEY:
|
||||
self._handle_key_event(event, device)
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
# Device was disconnected - remove it to avoid busy loop
|
||||
device_name = (
|
||||
device.name if device and hasattr(device, "name") else "unknown"
|
||||
)
|
||||
logger.info(f"Device disconnected: {device_name} (fd={fd})")
|
||||
if device is not None:
|
||||
self._remove_keyboard_device(fd, device)
|
||||
if not device_fds:
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
except (OSError, ValueError) as e:
|
||||
if self.running:
|
||||
logger.error(f"Error monitoring devices: {e}")
|
||||
break
|
||||
readable, _, _ = select.select(device_fds, [], [], 1.0) # 1 second timeout
|
||||
|
||||
for fd in readable:
|
||||
try:
|
||||
# Find the device for this fd
|
||||
with self._devices_lock:
|
||||
device = None
|
||||
for d in self.devices:
|
||||
if d.fileno() == fd:
|
||||
device = d
|
||||
break
|
||||
|
||||
if device is None:
|
||||
continue
|
||||
|
||||
# Read events from this device. On grabbed devices,
|
||||
# everything we do not consume is re-emitted on the
|
||||
# paired uinput clone so the focused app keeps typing.
|
||||
for event in device.read():
|
||||
if event.type == ecodes.EV_SYN:
|
||||
if event.code == ecodes.SYN_DROPPED:
|
||||
# Kernel buffer overflowed — discard until SYN_REPORT
|
||||
self._dropped_devices.add(fd)
|
||||
logger.warning(
|
||||
f"SYN_DROPPED on {device.name} (fd={fd}), "
|
||||
"resetting key state"
|
||||
)
|
||||
elif event.code == ecodes.SYN_REPORT:
|
||||
if fd in self._dropped_devices:
|
||||
# End of dropped sequence — clear stale state
|
||||
self._dropped_devices.discard(fd)
|
||||
self.key_pressed_devices.discard(id(device))
|
||||
# Release keys the clone still thinks
|
||||
# are held before the SYN_REPORT
|
||||
# reaches it, atomically.
|
||||
self._resync_clone_key_state(fd, device)
|
||||
# A dropped modifier release must not leave
|
||||
# the combo logically held.
|
||||
self._reset_combo_state()
|
||||
self._forward_event(fd, event)
|
||||
continue
|
||||
if fd in self._dropped_devices:
|
||||
# Handling shortcut state mid-drop is unsafe,
|
||||
# but surviving non-shortcut events still pass
|
||||
# through so the app sees what the kernel kept.
|
||||
if not self._event_is_shortcut(fd, event):
|
||||
self._forward_event(fd, event)
|
||||
continue
|
||||
if event.type == ecodes.EV_KEY:
|
||||
# Every key event updates shortcut state (combo
|
||||
# modifiers must be tracked AND forwarded); only
|
||||
# the forwarding decision differs.
|
||||
consumed = self._event_is_shortcut(fd, event)
|
||||
self._handle_key_event(event, device)
|
||||
if not consumed:
|
||||
self._forward_event(fd, event)
|
||||
else:
|
||||
self._forward_event(fd, event)
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
# Device was disconnected - remove it to avoid busy loop
|
||||
device_name = (
|
||||
device.name if device and hasattr(device, "name") else "unknown"
|
||||
)
|
||||
logger.info(f"Device disconnected: {device_name} (fd={fd})")
|
||||
if device is not None:
|
||||
self._remove_keyboard_device(fd, device)
|
||||
continue
|
||||
|
||||
except (OSError, ValueError) as e:
|
||||
if self.running:
|
||||
logger.error(f"Error monitoring devices: {e}")
|
||||
break
|
||||
finally:
|
||||
if self.running:
|
||||
# Exiting while still "running" means the loop died
|
||||
# unexpectedly — a failed select, an fd closed underneath
|
||||
# it, or an error escaping the handler. Nothing will ever
|
||||
# read the grabbed keyboards again, so close every device
|
||||
# and clone: closing releases each grab and hands input
|
||||
# delivery back to the kernel instead of leaving the
|
||||
# user's keyboards dead.
|
||||
logger.error(
|
||||
"Keyboard monitor exited unexpectedly; closing devices "
|
||||
"to release grabs so keyboards keep working"
|
||||
)
|
||||
self.running = False
|
||||
self.active = False
|
||||
self._close_all_devices()
|
||||
|
||||
logger.debug("Device monitor thread stopped")
|
||||
|
||||
def _handle_key_event(self, event, device) -> None:
|
||||
def _handle_key_event(self, event: InputEvent, device: InputDevice) -> None:
|
||||
"""Handle a key event from evdev."""
|
||||
if getattr(self, "_spec", None) is not None and self._spec.is_combo:
|
||||
self._handle_combo_key_event(event)
|
||||
return
|
||||
self._handle_modifier_key_event(event, device)
|
||||
|
||||
def _handle_combo_key_event(self, event) -> None:
|
||||
def _handle_combo_key_event(self, event: InputEvent) -> None:
|
||||
"""Handle a key event for a modifier+key combo (e.g. Alt+R).
|
||||
|
||||
Combo state is tracked globally across all keyboard devices, which is
|
||||
@@ -748,7 +1114,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
logger.debug(f"Combo {self._shortcut} released (evdev)")
|
||||
threading.Thread(target=self.key_release_callback, daemon=True).start()
|
||||
|
||||
def _handle_modifier_key_event(self, event, device) -> None:
|
||||
def _handle_modifier_key_event(self, event: InputEvent, device: InputDevice) -> None:
|
||||
"""Handle a key event for a single-modifier gesture (double-tap / hold)."""
|
||||
try:
|
||||
code = event.code
|
||||
|
||||
Reference in New Issue
Block a user