Skip to content

feat: opt-in keyboard hotplug via keyboard_device_names allowlist#165

Merged
goodroot merged 4 commits intogoodroot:mainfrom
mmacpherson:feat/keyboard-hotplug
Apr 19, 2026
Merged

feat: opt-in keyboard hotplug via keyboard_device_names allowlist#165
goodroot merged 4 commits intogoodroot:mainfrom
mmacpherson:feat/keyboard-hotplug

Conversation

@mmacpherson
Copy link
Copy Markdown
Contributor

@mmacpherson mmacpherson commented Apr 19, 2026

adds pyudev-based keyboard hotplug detection. mirrors the existing device_monitor.py pattern (which already watches subsystem='sound' for microphone hotplug) with a new keyboard_monitor.py that watches subsystem='input'.

the problem: _discover_keyboards() runs once at startup, so a keyboard plugged in later (docking a laptop, bluetooth reconnecting) is invisible until the service restarts. surfaced for me on a usb-c dock with an external keyboard.

opt-in design: a non-nil keyboard_device_names allowlist is the single switch.

  • null (default) = legacy behavior, byte-identical to current upstream.
  • set it, and two things happen: (1) startup discovery is filtered to listed devices only, (2) the pyudev observer starts, attaching listed devices plugged in later.

why gate on the allowlist: some input devices pass the target_keys.issubset(available_keys) capability filter without being keyboards. E.g. the logitech m720 triathlon mouse advertises 162 EV_KEY codes including super, alt, and common letters. without the allowlist, aggressive hotplug grabs these and breaks mouse operation after a dock cycle. making the allowlist the opt-in switch couples the two correctly: by listing real keyboards, the user has consented to runtime discovery of exactly those devices.

config:

{
  "keyboard_device_names": [
    "AT Translated Set 2 keyboard",
    "SONiX USB Keyboard"
  ]
}

self-grab prevention: both discovery paths skip any device whose name contains "hyprwhspr". grabbing our own UInput virtual keyboard creates a feedback loop (physical → virtual → re-grab) that locks out all input. relevant on in-process restart when a stale virtual-keyboard node may linger briefly.

cli improvements to hyprwhspr keyboard list:

  • [ALLOWED] / [VIRTUAL] markers alongside [SELECTED]
  • when auto-discover is active, prints a ready-to-paste allowlist snippet using a real device name from the system
  • when an allowlist is set, warns about entries that don't match any present device (catches typos vs. just-unplugged)
  • drops the grab()/ungrab() accessibility test in get_available_keyboards(); it failed on devices the running service already grabbed, hiding the primary keyboard from its own cli

graceful degradation: pyudev missing → observer doesn't start, allowlist still filters startup discovery. priority between single-device and allowlist config: selected_device_name > selected_device_path > keyboard_device_names > auto-discover. consistent between startup and hotplug paths.

commits (each self-contained and bisect-safe):

  1. feat: add keyboard_device_names allowlist — config, schema, main.py threading, startup filter
  2. feat: auto-detect hotplugged keyboards via pyudev — monitor, hotplug path, self-grab filter, start/stop wiring, gated on the allowlist from hypewhspr : No microphone available #1
  3. feat(cli): show allowlist status in keyboard list — markers, guidance, drop stale test-grab

tested locally on an arch laptop across a dock cycle: external keyboard attaches in ~1s of plug-in, triathlon mouse correctly filtered, no lockups observed.

mmacpherson and others added 3 commits April 19, 2026 11:33
some input devices report enough EV_KEY codes to pass the shortcut
capability filter in _discover_keyboards without being keyboards —
notably logitech multi-device mice (M720, MX series) that advertise
super, alt, and common letter keys for software-macro buttons.
auto-discover then grabs them, which breaks the mouse.

add optional `keyboard_device_names` config: a list of device names
that _discover_keyboards is allowed to grab. when set, only listed
devices are considered; mice/media controllers get skipped. when
unset (default), behavior is unchanged.

priority, consistent with existing single-device overrides:
  selected_device_name > selected_device_path > keyboard_device_names
                                              > auto-discover

threaded through all 9 GlobalShortcuts() call sites in main.py.
schema enforces array-of-strings or null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
keyboards plugged in after the service starts (e.g. docking a laptop
to a usb-c monitor, bluetooth keyboard reconnecting) were invisible
until the service was restarted, because _discover_keyboards() runs
once at startup and the event loop only reads from already-attached
devices.

add pyudev MonitorObserver on subsystem=input. mirrors the existing
device_monitor.py pattern (subsystem=sound for microphone hotplug)
with a new keyboard_monitor.py. on 'add' events for /dev/input/event*
nodes, open the device, apply the same filters as initial discovery,
and attach it to the live event loop. on 'remove' events, drop it
promptly so the event loop doesn't have to wait for the next read
error.

opt-in via the allowlist added in the previous commit:
_start_hotplug_monitor returns early when keyboard_device_names is
nil. rationale: the same aggressive discovery that makes hotplug
useful (grab anything that passes the target-key capability filter)
also grabs mice/media controllers on dock. the allowlist is the
user's consent that they've listed real keyboards — without it we
stay on the legacy startup-only path.

self-grab prevention: skip any device whose name contains "hyprwhspr"
in both _discover_keyboards and the hotplug path. grabbing our own
UInput virtual keyboard creates a feedback loop (physical key ->
virtual -> re-grab) that locks out all input. relevant on in-process
restart when a stale virtual-keyboard node may briefly be present.

concurrency:
  - observer runs on its own daemon thread; each event spawns a
    short-lived worker so a slow grab can't block the next event.
  - self._device_lock already guards self.devices / self.device_fds;
    every add/remove site acquires it, with a double-check-under-lock
    to handle racey duplicate adds.
  - stop() tears down monitor first, then joins listener, then
    closes devices — reverse order of construction.

graceful degradation: pyudev missing or observer start failure logs
one line and returns. service works identically to upstream for
already-connected keyboards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
makes the allowlist self-documenting. previously a user who saw a
mouse get grabbed had no obvious path to a fix — they had to read
source.

changes to `hyprwhspr keyboard list`:

  - `[ALLOWED]` marker on devices on the allowlist; `[VIRTUAL]` on
    hyprwhspr's UInput device, ydotoold, and similar virtual nodes
    (so users don't accidentally add them to the allowlist);
    alongside the existing `[SELECTED]`.
  - when an allowlist is set, print it back and note that hotplug is
    enabled for listed devices. warn about entries that don't match
    any present device (catches typos vs. just-unplugged).
  - when no selection is set, suggest the allowlist with a ready-to-
    paste snippet using a real device name from the user's system.
    also explains that setting it enables hotplug.

drop the grab()/ungrab() accessibility test in
get_available_keyboards(). that test failed on devices the running
service already grabbed, so `hyprwhspr keyboard list` hid the
primary keyboard from its own output. a successful InputDevice()
open is sufficient to list the device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mmacpherson mmacpherson requested a review from goodroot as a code owner April 19, 2026 18:41
@goodroot
Copy link
Copy Markdown
Owner

Oh dang, this is really interesting. I'll give this a review shortly. Thank you very much for taking the time to put up the pull request.

@goodroot goodroot merged commit e80f8ea into goodroot:main Apr 19, 2026
@goodroot
Copy link
Copy Markdown
Owner

Thanks again.

I will bump the AUR version for the main package in the next day or so after I sprint things locally to be sure there's no regressions. This is just tradition.

For now, it's available within the hyprwhspr-git AUR package and will also in the manual install method for everyone else.

@mmacpherson
Copy link
Copy Markdown
Contributor Author

Thanks very much for the quick review @goodroot . Very much appreciate your work on this package; cheers!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants