Repository navigation
Conversation
The voice shortcut could only be bound to controller buttons, because SteamClient.Input is the only input API the frontend can see. Binding push-to-talk to a key on an attached keyboard, or to a spare mouse button while the Deck is docked, needs a different source: the backend reads /dev/input/event* directly. A keydown listener in the panel is not an option — the CEF context has no keyboard focus while a game is running, which is the exact situation the feature exists for. No new privileges are required. SteamOS ships 70-steam-jupiter-input.rules, which tags input devices uaccess; logind then grants the active session user a POSIX ACL on the event nodes. plugin.json stays "flags": []. Verified on-device for a USB keyboard and a Bluetooth mouse: press and release both received while a game held focus. Implementation notes: - Readability is probed, never inferred. It is not deducible from the bus or the vendor id, so the reader attempts open() and offers only what succeeds; an unreadable device is reported rather than ignored. - EVIOCGRAB is never used. The grab is per device, not per key, so it would take the whole keyboard away from the game. - Bindings persist a device fingerprint, not /dev/input/eventN, because node numbers are reassigned after suspend and on reconnect. Devices are re-resolved on read error and on a periodic rescan. - The reader lives in its own module so its privacy guarantee is auditable in one place: no key code is ever logged or persisted, only the binding the user chose. Autorepeat is ignored and SYN_DROPPED releases push-to-talk rather than risk leaving the mic open. Also fixes two pre-existing problems found along the way: - set_ptt carried no notion of which input requested it, so with a controller button and a keyboard key both held, releasing either closed the mic while the other was still down. State is now tracked per source and only the aggregate edge reaches Discord. - Voice-shortcut config was written as a whole blob with no merge, so any caller unaware of a key silently dropped it. Writes now merge onto the stored file under a lock. The config schema becomes version 2, holding a list of bindings instead of a single button array. Existing configs are migrated in memory and only rewritten in the new shape once the user saves, so an install stays rollback-friendly and no existing controller binding is lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for this — it's a genuinely well-built contribution, and the writeup made reviewing it much faster. The constraints you documented at the top of I tested this on a BC-250 running Bazzite rather than a Deck, and that surfaced a problem with the permission model that I think is blocking. The uaccess premise does not hold outside SteamOS. The PR rests on Joysticks only. Keyboards and mice get nothing generically. What that looks like in practice, on my machine right now, with both devices plugged in and working normally: The keyboard is readable purely by accident: it picked up So on this machine the feature silently covers no mouse at all, and covers the keyboard only through an unrelated package. The related problem: unreadable devices are silently dropped, not surfaced. The PR description says "a device that is present but unreadable is surfaced to the user instead of silently missing", but I think that gap matters more than the permissions themselves. You cannot fix another distro's udev rules from a plugin, but you can tell the user why their mouse is not responding. Something like: have Worth deciding explicitly, too, whether this is documented as Deck/SteamOS-only. If so, the README should say it, because on other distributions the feature will look broken rather than absent. Two smaller points, neither blocking:
Separately: the fingerprint cannot distinguish nodes, on ordinary consumer hardware.
I nearly withdrew this point, because my wired keyboard exposes distinct names per node (
I did not manage to make either of those two nodes emit during testing, so I am reporting this as a demonstrated ambiguity rather than a demonstrated failure. But this is a plain 2.4GHz keyboard/mouse receiver, not an exotic case, and dropping the For completeness, the parts I was able to verify end-to-end: the
Happy to merge once unreadable devices are reported rather than dropped, and the node resolution stops depending on ordering. I'll squash with a re-signed commit message and add you to For what it's worth, I confirmed the permissions side is fixable downstream: adding made the mouse readable immediately, no reboot. That is the right fix for my distro-side tooling rather than for this PR — I mention it only so the README can point non-SteamOS users somewhere, and because it is worth being explicit that the rule hands every process running as that user the ability to read all keystrokes system-wide. That is a real trade, and users should get to make it knowingly rather than discover it. |
|
Update: rather than leave you waiting on my review, I went ahead and implemented the three changes myself. Flagging it here so you don't duplicate the work. Unreadable devices are now reported. The wrinkle is that a node we cannot open cannot be classified either — Node resolution no longer stops at the first match. I owe you a correction here. In my review I said I could not reproduce the ambiguity and was downgrading it to hardening — that was based on my wired keyboard, whose nodes carry distinct names. Once I added a udev rule making my wireless receiver readable, it showed up immediately:
What I verified: Your commit stays as the base; my changes sit on top. I'll squash before merging and you'll be in |
|
Merged and shipped in v1.22.0. Squashed with your commit as the base, and you are in Two things I changed in the changelog text rather than publishing as written, both worth knowing about:
One packaging note that caught me and may be useful to you: Closing this as merged. Thanks again — genuinely good work, and the two bugs you found on the way had been sitting there unreported. |
The voice shortcut could only be bound to controller buttons, because
SteamClient.Inputis the only input API the frontend can see. This addskeyboard-key and mouse-button bindings for push-to-talk (and mute-toggle),
which is mostly useful when the Deck is docked.
Keyboard and mouse are read in the backend from
/dev/input/event*. Akeydownlistener in the QAM panel is not an option: the CEF context has no keyboard focus
while a game is running, which is the exact situation the shortcut exists for.
No new privileges
plugin.jsonstays"flags": []. SteamOS already ships/usr/lib/udev/rules.d/70-steam-jupiter-input.rules, which tags input devicesuaccess; logind then grants the active session user a POSIX ACL on the eventnodes (
user:deck:rw-).Verified on-device (SteamOS
holo, gamescope 3.16.23.4): a USB keyboard and aBluetooth mouse, press and release both received, while a game held focus and
the Decky panel was closed.
Implementation notes
deducible from its bus or vendor id — I predicted wrongly from the rule text
twice while building this. The reader attempts
open()and offers only whatsucceeds; a device that is present but unreadable is surfaced to the user
instead of silently missing.
EVIOCGRABis never called. The grab is per device, not per key, so itwould take the whole keyboard away from the game. As a passive reader we see
the same events the game does.
/dev/input/eventN— nodenumbers get reassigned on reconnect and after suspend (observed: the same
keyboard returned as
event19having beenevent18). Devices are re-resolvedon read error and on a periodic rescan, so a binding survives sleep and
Bluetooth reconnects.
defaults/input_watch.py) so theguarantee is auditable in one place: it never logs or persists a key code, only
the binding the user picked and a count of readable devices. Autorepeat
(
value == 2) is ignored, andSYN_DROPPEDreleases push-to-talk rather thanrisk leaving the mic open.
ctypes+fcntlonly.python-evdevwasavoided because it carries a C extension and
py_modules/holds pure Python.Two pre-existing bugs fixed along the way
set_pttcarried nonotion of which input asked for it, so with a controller button and a
keyboard key both held, releasing either one closed the mic while the other was
still down. Push-to-talk state is now tracked per source and only the aggregate
edge is sent to Discord. This is why the aggregation exists rather than a second
independent caller.
written as a whole blob with no merge, so any caller that did not know about a
key silently removed it. Writes now merge onto the stored file under a lock.
Config migration
The schema becomes
version: 2and holds a list of bindings instead of a singlebutton array. Existing configs are migrated in memory and only rewritten in
the new shape once the user saves — so an install can still be rolled back, and
nobody loses an existing controller binding.
UI
One capture button; press whatever you want and the type is detected from
whichever device answered. There is deliberately no "choose keyboard / mouse /
controller" dropdown before pressing — on a handheld, asking the user whether
their mouse side button reports as
BTN_SIDEor as a key is the wrong question.Cancel stays reachable from the controller, since the keyboard being bound
may not be within reach.
One binding per input type, OR-ed: controller in handheld, mouse while docked, no
reconfiguring in between.
i18n
13 new keys across all 9 locales. UI wording is translated; the Linux namespace
(
KEY_F13,BTN_SIDE) is shown raw rather than translating ~300 constants × 9languages. Common mouse buttons get friendly localized names.
Note: an earlier iteration of this branch localized those labels in the backend,
which was wrong — the backend has no idea what locale the user is in, so everyone
saw French. Localization now lives in the frontend, keyed off the raw name.
Things worth a reviewer's scepticism
machine wakes from sleep. This is a fix for a symptom I could reproduce but not
fully explain: after a suspend/resume the shortcut stopped responding and no
listener received events at all — including a freshly registered one — until
Steam was restarted. Retaining the subscription and re-subscribing on resume is
the plausible mitigation, not a proven root cause. Held-button state is cleared
at the same time, since a button held before suspend is not held after.
defaults/input_watch.pyis new and is unavoidably the security-relevant partof this diff. The constraints it must satisfy are stated at the top of the file
for exactly that reason.
Testing
Manually on a Steam Deck (Gaming Mode, game running):
BTN_SIDE/BTN_EXTRA/BTN_LEFT/BTN_RIGHT/BTN_MIDDLE:press/release received
pnpm run buildclean; no new TypeScript errors in changed files🤖 Generated with Claude Code