Media keys working again

This commit is contained in:
2026-10-18 03:50:33 +02:00
parent 726a7ae92d
commit 798708b3e7
20 changed files with 590 additions and 112 deletions

View File

@@ -45,12 +45,25 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
+ the generated `build/gresources.c`, see Assets) **and** the daemon
(`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`,
`libxkbcommon-dev` and `libglib2.0-dev`). `make daemon` builds only the daemon.
- The daemon is split into: `device_tables` (raw HID report tables per model),
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
keyboard), `action` (dispatch `key`/`command`/`disabled`), `config_watch`
(inotify auto-reload of the mappings file) and `ipc_dbus` (the GDBus service).
`shared/keymap` does the layout lookups for both sides. The hot path never
touches the GUI: D-Bus is control plane only and is pumped once per loop.
- The daemon is split into: `device_tables` (raw HID report tables per model plus
`decode_gkey_state`, the report → pressed-bitmap decoder), `g910_device` (libusb
open/detach/claim/read), `uinput_keyboard` (virtual keyboard), `action`
(dispatch `key`/`command`/`disabled`), `config_watch` (inotify auto-reload of
the mappings file) and `ipc_dbus` (the GDBus service). `shared/keymap` does the
layout lookups for both sides. The hot path never touches the GUI: D-Bus is
control plane only and is pumped once per loop.
- G-key decoding is **state-based**, not per-key matching: the macro report holds
a bitmap (byte 4 = G1–G8, low bit of byte 5 = G9), so the daemon fires a key on
a 0 → 1 transition. That is what suppresses the repeats of a held key and makes
a chord fire every key in it. Media/memory keys and command/LED answers are
rejected by report length and header; keep it that way instead of
re-introducing exact-report matching.
- The daemon runs **without** a keyboard: `open()` failing (or a read error) only
logs the absence once, reports `DeviceChanged(false)` and the loop retries every
2 s while D-Bus and the config watch keep running. Never make it exit on a
missing device — that would drop the bus name and make the GUI look like the
daemon died. `GKeyDevice::open(quiet)` exists so those retries stay out of the
journal (libusb-level errors always log).
- `make install` (PREFIX default `/usr/local`) also installs the packaging
files from `data/`: the udev rule, the `modules-load.d` fragment, the
systemd **user** unit and the D-Bus activation file. Both contain an
@@ -63,7 +76,10 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
fallback. A failed reload keeps the previous mappings on purpose.
- `main.cpp` keeps the `rows` vector (one `KeyRow` per G key, widget pointers)
in an anonymous namespace; `src/mappings.cpp` is GUI glue that moves data
between the widgets and the shared `MappingSet`. App id:
between the widgets and the shared `MappingSet`. `build_ui()` loads
`default_config_path()` into the rows as soon as they exist (a missing file is
not an error), so a save from a fresh window rewrites the file instead of
emptying it. App id:
`dev.rager.gkeymapper` (`ipc::kAppId`); the About dialog uses `ipc::kVersion`.
- GUI ↔ daemon is D-Bus on the session bus, GDBus (GIO) on both sides:
`src/daemonclient.{h,cpp}` is the client, `daemon/ipc_dbus.{h,cpp}` the
@@ -193,6 +209,23 @@ over any installed file; the file lookup remains only as a safety net.
`take_change()` per simulated loop iteration), and `ConfigReloaded` on the
`gdbus monitor` above reports when a save was applied. A GUI save triggers two
reloads on purpose — the file watch and the explicit `Reload()` call.
- Decoding and the whole loop are also testable **without** the keyboard, which is
the fastest way to check hot-plug and the GUI footer states (plan §9):
- compile `daemon/device_tables.cpp` standalone and feed `decode_gkey_state()`
synthetic reports: single keys, a chord, a repeated (held) report, the release
report, and the families that must be rejected (memory keys `11 ff 09` /
`11 ff 0a`, command `11 ff 08 2e` and LED answers); cover `decode_media_key()`
with the `02 <bit>` presses, the release, a padded report and the same
families, which must *not* decode as media keys (see the bit table above);
- build the daemon with a **fake** `GKeyDevice`: keep the header, script
`open()`/`read_report()`, link `daemon/*.cpp` **without** `g910_device.cpp`
and without libusb. That runs the real loop, so it covers "no keyboard at
start", "device lost" and the reopen, emits real D-Bus signals and never
claims the real device — nothing touches the keyboard and no key is injected
into the focused window. Use a config with `command:` mappings only (a helper
script that dumps its environment is also how you check that a `command`
action sees `DISPLAY`); with `key:` mappings the fake would inject keys into
whatever window is focused.
- D-Bus activation is only testable in a real setup: the installed
`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` needs
`sudo make install`. For a quick check without root, drop a file with
@@ -207,8 +240,23 @@ over any installed file; the file lookup remains only as a safety net.
no `udevadm verify` on systemd 249, so the udev rule is checked by eye against
the reference project's rule.
- Starting it detaches the kernel driver from the vendor interface and creates a
`/dev/uinput` keyboard. Warn the user first: while it runs the media keys may
not work, and `key` mappings are injected into the **focused** window.
`/dev/uinput` keyboard. Warn the user first: `key` mappings are injected into
the **focused** window. The media keys are *not* affected: the keyboard reports
mute/volume/play only on that very interface (`…/input1`, measured — see below),
so the daemon replays those reports unchanged through its virtual keyboard.
Deleting that passthrough in `daemon/main.cpp` silently kills the media keys
while the daemon runs.
- Media keys cannot be observed over the keyboard's usual event node: with the
daemon stopped they appear on `event11` (`Phys …/input1`, created when `usbhid`
owns the interface), not on `event10` (`…/input0`); *both* nodes advertise the
consumer usages in their HID descriptors, so the capability bitmap proves
nothing. The authoritative data is the raw report on the `hidraw` node of
interface `1.1` (`usb-…-5.4.3/input1`, exists only while `usbhid` owns it):
`02 <bit>` per press with bit 0 `01` next, `02` previous, `04` stop, `08`
play/pause, `10` volume up, `20` volume down, `40` mute, and `02 00` for the
release. Verify the passthrough by reading `/tmp/daemon.log`: a press logs
`media -> mute` / `media -> volume up` / … and the desktop reacts as usual.
The bit table lives in `g910MediaKeys()` (`daemon/device_tables.cpp`).
- The keyboard is `046d:c32b` (also handle `046d:c335`); on this machine the USB
device and `/dev/uinput` already carry ACLs for the user, so no root is needed.
- Verify decoding by pressing G keys and reading `/tmp/daemon.log` (it logs
@@ -229,10 +277,16 @@ The footer's daemon line is a plain `GtkLabel`, so its text can be read back ove
AT-SPI: `<app> → frame → panel → label` whose name starts with the translated
`Daemon:` string. To pick the right window, filter the application whose tree
contains that label — a stale GUI from an earlier build may be running from the
same binary name. Two notes for tests:
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real
and the GUI does **not** load that file at startup, so a save from an
unconfigured window replaces the user's mappings with empty ones. Back the file
up first (or skip the click and call `Reload` over D-Bus instead).
same binary name. Three notes for tests:
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real.
Since M5 the window loads that file at startup, so the rows show the saved
mappings and a save rewrites the same content; back the file up anyway (or skip
the click and call `Reload` over D-Bus instead).
- The `GtkEntry` texts are **not** readable over AT-SPI here (their text interface
answers with an empty string), so verify what the rows hold with a screenshot:
`import -window $(wmctrl -l | grep 'gKeyMapper – Logitech' | awk '{print $1}')
/tmp/gui.png`. The footer label works over AT-SPI (its accessible *name* is the
text), but a screenshot of the cropped footer is easier and shows what the user
sees.
- `XTest`-synthesized clicks need the window mapped and focused; the `Save`
button is `push button` + name `Mappings speichern` in the German locale.