# gKeyMapper daemon — implementation plan Status: implemented (M0–M5 done, see "Implementation status") Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR) on the Logitech G910 and replays the mappings stored by the GUI. The media keys are not part of that: the daemon forwards them unchanged (§11, *Decided*). ## Implementation status - **M0 done** — `shared/mapping.{h,cpp}` (file format + shortcut parsing) and `shared/keys.{h,cpp}` (canonical names → Linux codes); the GUI uses them. Round-trip and shortcut parsing are covered by a small standalone test. - **M1 done** — `daemon/` builds (`gkeymapper-daemon`): device discovery, kernel-driver detach, F-key binding disable, 64-byte interrupt reads and G1–G9 decoding. Verified on hardware: all nine keys decode correctly (press and release), in order. - **M2 done** — action execution: `key` shortcuts are replayed through a `/dev/uinput` virtual keyboard, `command` runs via fork/exec (with a shell fallback so arguments work), `disabled` does nothing. Verified on hardware. - **M3 done** — config watching and lifecycle: `daemon/config_watch` watches the config *directory* with inotify, so a save in the GUI reloads within about half a second; `SIGHUP`/`SIGUSR1` stay as the fallback. `Save mappings` now writes atomically (temp file + `rename`), so the daemon never reads a half-written file. `make install` ships the udev rule, the `modules-load.d` fragment and the systemd **user** unit (`data/`). - **Layout handling (added during M2)** — key names are resolved through the active layout (`XKBLAYOUT` from `/etc/default/keyboard`, via xkbcommon). This matters because the US-position code `KEY_Z` (44) is `y` on a German keyboard; `z` is now correctly resolved to evdev 21. - **AltGr (added after M2)** — level-3 combinations are representable as `altgr+7`; the base (level 0) key is stored plus the `altgr` modifier. - **M4 done** — D-Bus control channel: `daemon/ipc_dbus` registers `dev.rager.gkeymapper.Daemon` on the session bus and serves `Reload`, `GetStatus` and `TestMapping`, plus the `KeyPressed`, `ConfigReloaded` and `DeviceChanged` signals. `src/daemonclient.{h,cpp}` is the GUI side: the footer shows `Daemon: running/not reachable`, the device and the last G key that fired, and `Save mappings` calls `Reload()` (the file watch stays the backstop). `make install` ships a D-Bus activation file, so the GUI can start the daemon on demand; the unit is `Type=dbus` with `BusName=`. A second instance refuses to start instead of fighting over the keyboard. Verified with `gdbus` (methods, signals, activation) and over AT-SPI (live footer). - **M5 done** — hardening and GUI polish: the daemon survives a missing or disappearing keyboard and reopens it without losing its bus name (hot-plug, quiet retry every 2 s); G-key decoding is state-based, so a held key no longer repeats and two keys held at once both fire; the media keys are decoded and forwarded through the virtual keyboard, so mute / volume / play keep working while the daemon runs (see §4.4 and §11); the GUI loads the saved mappings at startup (a fresh window can no longer save empty rows over the file) and the footer distinguishes `Device: not found` and `Configuration error`; the `GtkAboutDialog` `response` handler (invalid in GTK4) is gone. Verified without hardware with a synthetic decoder test and a fake device driving the real loop, with a GUI attached to it over D-Bus; the bitmap decoding and the media passthrough were additionally confirmed on the real keyboard (see §9, §11). - **`keyhold` (added after M5)** — `G=keyhold:` presses one step and keeps it down until that G key's state bit clears, i.e. for exactly as long as the key is held (games, push-to-talk). The release report was already there, so the work was the falling edge in the loop, per-G-key bookkeeping of the held codes (`ActionRunner::held_`, the last release wins when two G keys hold the same code) and `release_held()` on device loss and shutdown, so nothing can stay stuck. A sequence falls back to a tap. In the GUI it is the fourth Type entry (`Key (hold)`, token `keyhold`, dropdown index 3). Verified with the fake device through the real loop plus `xinput test`: a 1.5 s hold = one press and one release 1.5 s later (the repeated press reports fire nothing), correct release order (key up before its modifiers), release on device loss; X repeats a held key on its own (660 ms delay, then ~40 ms), exactly like a physical key. ## 1. Goals and non-goals Goals - Detect G-key presses in C/C++ and execute the configured action. - Run with a very small, stable resource footprint (idle ≈ 0 % CPU, few MB RSS, no interpreter, no polling when nothing happens). - Reuse the existing `mappings.conf` format as the single source of truth. - Let the GUI and the daemon run independently; either can start first. - No Python at runtime. Non-goals (v1) - Profiles (M1–M3/MR), per-locale text typing (`typeout`), embedded scripting. The M keys are only deferred — they come after v1 (§11, *Decided*) — while `typeout` is still undecided (§11). - Remapping the media keys: they are forwarded unchanged, that is the decision (§11, *Decided*). - GUI redesign beyond a status indicator / reload / test button. ## 2. What the Python reference does (and what we keep) Reference: - G-keys are **not** exposed as distinct evdev key codes. The driver therefore opens the keyboard's **vendor HID interface (USB interface 1) directly via libusb**, detaches the kernel driver from that interface, and reads raw interrupt-IN reports. - On start it sends a control transfer to **disable the firmware F-key→G-key binding**, so pressing G1..G9 no longer also emits F1..F9 on the normal keyboard interface. - Raw report byte patterns are looked up in tables (`events.macroKeys`, `memoryKeys`, `mediaKeys`, `releaseEvents`) and turned into actions. - A **uinput virtual keyboard** replays `shortcut` (press, later release), `typeout` (per-char), `uinput` (direct `KEY_*`); `run` uses `/bin/bash -c`. - Config is JSON at `~/.config/g910-gkeys/config.json`, reloaded on directory change (inotify via `fcntl`), plus `SIGUSR1` reload. - Ship: a **systemd --user** service and **udev rules** with `TAG+="uaccess"` for the device and `/dev/uinput`, so no root is needed. Relevant USB IDs (from the udev rules): `046d:c335`, `c33f`, `c33e`, `c32b`, `c24d`, `c22d`. The G910 in this project is `046d:c32b`. What we keep: libusb raw HID access, disabling the F-key binding, uinput output, config-dir watching. What we change: JSON→our line format, single-threaded event-driven loop instead of a Python loop with fixed sleeps, fork/exec reaping, and a real IPC channel to the GUI. ## 3. Architecture overview ``` +---------------------+ mappings.conf (shared truth) +----------------------+ | gkeymapper (GUI) | <------------ write / inotify ---------> | gkeymapper-daemon | | GTK4 + GIO/GDBus | | libusb + uinput | | | <===== D-Bus (session bus) ============> | GDBus + inotify | +---------------------+ status / reload / events +----------+-----------+ | raw HID (iface 1) v +----------------------+ | Logitech G910 | +----------+-----------+ | uinput v +----------------------+ | /dev/uinput keyboard | +----------------------+ ``` Key property: the **hot path never crosses IPC**. G-key press → decode → uinput happens entirely inside the daemon. IPC (D-Bus) is only used for control, status and notifications, so a slow/absent GUI never delays a keypress. ## 4. Daemon design ### 4.1 Process model - One process, single-threaded, event loop. - No threads in v1. `run`/`command` actions are launched with `fork()` + `execve()`; children are reaped via a `SIGCHLD`/`signalfd` handler so no zombies accumulate (the reference leaks a `Popen` per invocation). - `python` type is intentionally dropped. ### 4.2 Event loop Use one `poll()`/`epoll()` loop over: - the **libusb** file descriptor (from `libusb_get_pollfds()`; use libusb's async API or `libusb_interrupt_transfer` on a worker, see below), - an **inotify** fd for the config file/directory, - a **signalfd** for `SIGTERM`/`SIGINT`/`SIGUSR1`, - the **D-Bus** connection (GDBus integrates with the same `GMainContext`). Preferred: drive libusb asynchronously with `libusb_submit_transfer()` and fold the reported poll fds into the same loop (libusb has an official "poll with another event loop" pattern). That yields exactly **0 wakeups while idle** (excluding the kernel's own HID interrupts). If that is too much work for v1, fall back to a blocking `libusb_interrupt_transfer(..., timeout=1000)` inside a dedicated thread that posts events into the main loop — still cheap (1 wake/s). To keep GDBus and the fd loop together, the loop is easiest on the **GLib main loop** (`GMainLoop`): GDBus, `GFileMonitor` and `GSocket`/`GSource` cover inotify and signalfd, and libusb poll fds can be wrapped in a custom `GSource`. GLib is already a dependency of the GUI, so this adds no new library. If minimal footprint is the priority over convenience, a raw `epoll` loop is the alternative; decision point in §11. ### 4.3 Device layer (`libusb`) - Find the device by vendor/product from the supported-device table; pick interface 1, endpoint from the same table. - `libusb_detach_kernel_driver()` on interface 1; remember to `libusb_attach_kernel_driver()` on shutdown (reference does this). - Send the "disable F-key to G-key binding" control transfer (`bmRequestType=0x21, bRequest=0x09, wValue=0x0212, wIndex=1`) and consume the confirmation reports; ignore the known LED-return patterns (`\x11\xff\x0f`, `\x11\xff\x10`, `\x11\xff\xff`). - Hot-plug / re-plug (as implemented in M5): a read error closes the device, reports `DeviceChanged(false)` and the loop keeps looking for it every 2 s without exiting. A keyboard that is unplugged — or not there yet when the session starts — never costs the bus name, so the GUI keeps its connection and D-Bus activation does not start a second instance. The retries are quiet (the absence is logged once); `GKeyDevice::open(quiet)` suppresses only the cannot-use-the-keyboard lines, libusb-level failures always log. - Optional (later): `libusb_hotplug_register_callback` to react to device add/remove without polling. The device table in `supported_devices.py` (byte patterns per model) is ported to a generated C++ header (`constexpr` tables) — keep it a build-time artifact so the runtime does zero parsing. ### 4.4 Decoding - The report is a fixed-length byte blob. Build a **compile-time lookup**: a sorted array of `{report bytes, action id}` with binary search, or a small open-addressed hash keyed on the first 8 bytes. This replaces the Python `dict` + `list(...).index()` O(n) scans with O(1)/O(log n) and no allocation. - Categories: `macro` (G1..G9), `memory` (profiles, later), `media` (later), `release` (stops a held shortcut), and ignored/known-noise. - **As implemented (M1, reworked in M5):** one `decode_gkey_state()` instead of a report lookup table. The G910 macro report is a *state* report, not a single-key event: bytes 0–3 are the fixed header (`11 ff 08 00`), byte 4 is the bitmap of G1–G8 and the low bit of byte 5 is G9. The decoder derives the header and the bit of every key from the per-model table, returns the pressed bitmap and rejects the other families by length or header (the `11 ff 09`/`11 ff 0a` memory keys, the `11 ff 08 2e` command and the LED answers). The daemon fires a key on a 0 → 1 transition, which is what makes a held key (repeating report) fire once, makes a chord fire each key in it, and makes the release report (`11 ff 08 00` + zero bitmap) a no-op. - **Media keys (measured and added in M5):** they are *not* a lookup table either. The keyboard reports them as a 2-byte report `02 ` on the same interface (bit 0 `01` next, `02` previous, `04` stop, `08` play/pause, `10` volume up, `20` volume down, `40` mute, `02 00` release), so `decode_media_key()` reads the bit byte and the daemon replays every bit it finds through its virtual keyboard. That is a passthrough, not a mapping: without it the media keys would do nothing at all while the daemon runs, because they reach the kernel only through this interface (see §10 and §11). - Consequence: exact-report matching is gone, so `match_gkey`, `is_release_report` and the `releaseReport` table entry went with it. Verified without hardware against synthetic reports and through a fake device running the real loop. ### 4.5 Action layer (uinput) - Open `/dev/uinput` once; `UI_SET_EVBIT` for `EV_KEY`, register the key set used by the mappings, `UI_DEV_CREATE`. Keep the fd for the daemon's lifetime. - Emit events by `write()`-ing `struct input_event` (from `` / `` — no libevdev needed). - `key` mapping = shortcut grammar `ctrl+c,ctrl+v`: every step is pressed and released (a tap of ~2 ms), so a comma list replays the chords in order. (The original plan held a single combo down until the release event arrived; that became its own type, `keyhold`, so a plain `key` still fires once.) - `keyhold` mapping = one step, pressed on the 0 → 1 transition and released on the 1 → 0 transition, i.e. exactly as long as the G key is held. A comma list falls back to a tap (holding several chords has no meaning). The held codes are tracked per G key, so a code that two G keys hold goes up with the last of them, and `release_held()` runs on device loss and on shutdown — otherwise a lost release report would leave a key down in the whole session. - `command` mapping: `fork()`; in the child `setsid()`, redirect stdio to `/dev/null`, `execve("/bin/sh", "-c", cmd)`. Reap via `SIGCHLD`. - `disabled`: no-op. - Give the uinput device a clear name/IDs (e.g. `gkeymapper virtual keyboard`) so `libinput`/users can identify it. ### 4.6 Config - Parse the existing `mappings.conf`: `G=:` with `type ∈ {key, command, disabled, keyhold}`. - Parse at load and on change only; the runtime holds a small pre-parsed action per G-key (enum + fixed target buffer / `std::string`), so no parsing in the hot path. - Watch the config file (and, for atomic-save editors, its directory) with inotify/`GFileMonitor`; reload on `IN_CLOSE_WRITE`/`IN_MOVED_TO`. Debounce (e.g. 100 ms) to coalesce editor save bursts. - Also reload on `SIGUSR1` (manual/test) and on `Reload()` D-Bus call. ### 4.7 Privileges and packaging - udev rule with `TAG+="uaccess"` for the supported USB IDs and for `KERNEL=="uinput"` (mirrors the reference's `60-g910-gkeys.rules`). - `uinput` kernel module must be present; ship `/etc/modules-load.d/` fragment or document `modprobe uinput` (here it is built in). - **systemd --user** unit `gkeymapper-daemon.service`: `Type=dbus`, `BusName=dev.rager.gkeymapper.Daemon`, `ExecStart=gkeymapper-daemon`, `Restart=on-failure`, `ExecReload=SIGHUP`. `Type=dbus` + `BusName` make the unit count as started only once the name is owned — the state D-Bus activation and the GUI see. `make install` also ships the D-Bus activation file (`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service`, with `SystemdService=gkeymapper-daemon.service`), so the bus starts the unit when the GUI asks for the daemon's status and the daemon runs exactly once either way. - Do **not** require root. Commands run with the user's privileges (the reference's "executed as root" note is a wiki artifact, not a design goal). ## 5. GUI ↔ daemon interaction Three layers, from loosest to tightest: 1. **Shared state = the config file** (`~/.config/gKeyMapper/mappings.conf`). The GUI is the writer, the daemon is a watcher/reader. This alone is enough for basic operation and is robust: whichever side starts first, the other converges. The daemon reloads via inotify; no coupling to the GUI process. 2. **Control/status = D-Bus on the session bus** (GDBus; part of GIO, already linked by the GUI). Used for status, explicit reload, and notifications. 3. **Lifecycle**: the daemon is a `systemd --user` service; the GUI can start it implicitly by calling a D-Bus method (activation) and can show whether it is running. ### 5.1 D-Bus interface (implemented) - App id: `dev.rager.gkeymapper` (replaces the placeholder `com.example.gkeymapper`); it is also the GApplication id. - Bus name: `dev.rager.gkeymapper.Daemon` — **not** the plain app id. `GtkApplication` owns the app id itself, in order to detect a second GUI instance; a daemon on the same name would make every GUI start look like a duplicate and exit. (The original plan had `` here; that clash was found while implementing M4.) - Object path: `/dev/rager/gkeymapper/Daemon` - Interface: `dev.rager.gkeymapper.Daemon` Methods | Method | Signature | Purpose | |---|---|---| | `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). Result via the `ConfigReloaded` signal. | | `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. `device` is `""` before the keyboard is open, so the GUI can omit that part. | | `TestMapping` | `(u index) -> ()` | Fire the mapping of one G key without hardware; same code path as a real press. `index` is **0-based** (`0` = G1 … `8` = G9). Fails with a D-Bus error for an out-of-range index or a `disabled:`/empty row. | Signals | Signal | Signature | Purpose | |---|---|---| | `KeyPressed` | `(u index, s action)` | A G-key fired (GUI highlight/history; also emitted by `TestMapping`). | | `ConfigReloaded` | `(b ok)` | Daemon finished reloading. | | `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. | Notes - All are control-plane calls; none is on the keypress path. `DaemonService::pump()` dispatches once per daemon loop iteration, so a reply can take up to one read timeout (≤500 ms) — fine for status/reload, and the GUI calls asynchronously. - `Reload` is best-effort: inotify is the backstop if the call is lost. - If D-Bus is unavailable, the daemon still works; the GUI degrades to "no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for scripted use, matching the reference. - `SetProfile` was dropped: it is reserved for the M-key profiles and would have been a no-op today. - The daemon requests its name with `DO_NOT_QUEUE` and exits (code 0, with a message) when another instance owns it, before it touches the device. ### 5.2 Interaction sequences Save in the GUI: ``` GUI: write mappings.conf (atomic: temp + rename) GUI: call Daemon.Reload() [optional, for immediacy] Daemon: inotify IN_MOVED_TO -> (re)parse -> apply Daemon: emit ConfigReloaded(ok) ``` Key press: ``` G910 --raw HID--> Daemon -> decode -> uinput emit / fork+exec Daemon: emit KeyPressed(index, action) [GUI only, non-blocking] ``` Start-up orders: - Daemon first, GUI later: GUI reads status via D-Bus; no config write needed. - GUI first, daemon later: GUI writes config; on daemon start it reads the file. ### 5.3 GUI-side changes (as implemented) - On "Save mappings": writes the file, then calls `Reload()` best-effort; the file watch is the backstop, so a lost call is harmless (a save therefore triggers two reloads, both idempotent). - Footer status line: `Daemon: running/not reachable` + `Device: …` + the last G key that fired, fed by `GetStatus`, the `DeviceChanged`/`ConfigReloaded` signals and `NameOwnerChanged` (so it follows the daemon starting/stopping). The `Device:` part is omitted while the device is not open yet. - No "Test" button per row: pressing the real key is the better test while the hardware is at hand, and `TestMapping` stays available over `gdbus`/`busctl` without widening the fixed-width rows. - The key-name vocabulary matches because the capture dialog already writes lowercase GDK key names and the canonical list lives in `shared/keys` (§6). - One window, and it is the one that was just started: `main()` closes a running instance before it becomes the primary one — over D-Bus through the `quit` action (`src/singleinstance.{h,cpp}`), or with `SIGTERM` when that instance comes from a build without the action — and `on_activate` re-presents the window that is already there instead of building a second one. A stale window therefore never stays next to a fresh one, and the daemon is not involved. Closing the old window would drop row edits that were never saved, so the takeover asks it for its rows first (`GetRows` at `/dev/rager/gkeymapper/Window`, see `shared/ipc_ids.h`) and the new `build_ui()` starts from that mappings text instead of from `mappings.conf`. An instance that does not answer — an older build — leaves the new window with the file, and the file stays untouched until the user presses `Save mappings`. - Menu, help and options: `Help` (also `F1`) opens a one-page explanation of the rows, types, capture dialog, daemon status and tray; `Options` opens a small non-modal window with the GUI's own switches (tray behaviour, language, autostart) and the daemon in detail (state, version, keyboard, config file, last key), while the footer keeps the one-line summary. A `GMenu` cannot host a switch, so the options are a window. - Autostart is the session's `.desktop` entry and nothing else (`src/autostart.{h,cpp}`): `~/.config/autostart/gkeymapper.desktop` existing (and not switched off by the desktop's own tool) *is* "on", so the switch cannot disagree with what the session reads at login. It starts the window, never the daemon: the daemon is a user service, and it stays the only part that has to run. The window is a convenience — the mappings can be edited any time, and the G keys work with it closed. - Language switching is immediate, without a restart and without losing rows that were not saved: gettext is handed the code through `LANGUAGE` plus a fresh `setlocale`, and the windows are built again (the rows are carried over with `collect_mappings`/`apply_mappings`; the tray menu is announced with `LayoutUpdated`). `System language` is the default and follows the desktop; the choice lives in `gui.conf` and is applied before the first string is built, so a restart keeps it. - `Minimize to tray`: closing or minimizing hides the window instead of quitting, and the tray icon shows it again (`Show`) or quits the program (`Close`). GTK4 has no minimize signal and `GdkToplevel` has no properties, so the state is polled (250 ms) while the option is on; hiding happens only while a panel really hosts the icon — without one there would be no way back to the window. The preference lives in `~/.config/gKeyMapper/gui.conf`, next to `mappings.conf` but a file of its own, so a mapping file stays a mapping file. - The tray icon is a `StatusNotifierItem` plus a `com.canonical.dbusmenu` menu spoken over GIO (`src/trayicon.{h,cpp}`): the protocol libxapp and libappindicator wrap, without their GTK3 dependency — a GTK4 process must not load GTK3. The icon travels as an `IconPixmap`, because the panel is another process and cannot read the icons compiled into the binary. Both watcher names are watched and the item registers once, preferring `org.kde.StatusNotifierWatcher`. ## 6. Code layout ``` src/ # GUI (existing) daemonclient.{h,cpp} # D-Bus client (status/reload/signals) singleinstance.{h,cpp} # one GUI only: a new start closes the running instance trayicon.{h,cpp} # tray icon: StatusNotifierItem + dbusmenu over GIO settings.{h,cpp} # gui.conf (window preferences, e.g. minimize to tray) autostart.{h,cpp} # the session's autostart entry (~/.config/autostart) help.{h,cpp} # help window optionsdialog.{h,cpp} # options window: switches + detailed daemon status daemon/ # as implemented (see §6 above) ipc_dbus.{h,cpp} # GDBus service (control plane) shared/ mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon) keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_* keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon) ipc_ids.h # app id, version, D-Bus names data/ 60-gkeymapper.rules # udev: uaccess for the device and /dev/uinput gkeymapper-uinput.conf # modules-load.d fragment (uinput) gkeymapper-daemon.service # systemd user unit (@BINDIR@ filled in on install) dev.rager.gkeymapper.Daemon.service # D-Bus activation for the same unit icons.gresource.xml # GUI icons compiled into the binary (GResource) po/ # shared catalogs (GUI strings) ``` Build: the Makefile gets a second target `gkeymapper-daemon` and install rules for the unit, udev rule, modules-load fragment and activation file. New build dependency: `libusb-1.0-0-dev` plus `libglib2.0-dev` (gio-2.0) for the daemon's D-Bus channel. ## 7. Resource-efficiency measures (summary) - No Python interpreter; native binary, tables in `.rodata`. - Event-driven loop, **no idle polling** (libusb async + inotify + signalfd; or a 1 s blocking USB read in the simple variant). - O(1)/O(log n) decode via compile-time tables instead of dict/linear scan. - One `uinput` fd for the process lifetime; fixed-size event writes; no per-event heap allocation. - Config parsed only on load/reload; hot path is table lookups + `write()`. - `fork`+`exec` for programs, children reaped (no zombies), stdio to `/dev/null`; no shell parsing for `key`/`uinput` actions. - Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events, RSS in the low single-digit MB. Measured after M4 (systemd user unit, daemon idle): ~780 KB RSS, 14 ms CPU since start. The D-Bus control channel adds no wakeups of its own: `pump()` only dispatches what the loop's read timeout already woke up for. ## 8. Milestones - **M0 – Shared core.** `shared/mapping` + `shared/keys`; refactor the GUI to use them (still file-only). Unit tests for parse/format and key tables. - **M1 – Device bring-up.** `daemon` opens the G910 via libusb, disables the F-key binding, logs `G1..G9` presses. No output yet. - **M2 – Action layer.** uinput keyboard; `key` (shortcut, incl. release) and `command` work end-to-end. `disabled` no-op. - **M3 – Config & lifecycle (done).** config reload via inotify (`config_watch`, directory watch + atomic save) and `SIGHUP`/`SIGUSR1`; systemd --user unit + udev rule + modules-load, installed by `make install`. - **M4 – IPC (done).** GDBus service (`Reload`, `GetStatus`, `TestMapping`, signals); GUI status line + Reload-on-save; D-Bus activation file; unit `Type=dbus` + `BusName`; single-instance guard. - **M5 – Polish (done).** Packaging/install rules, docs, translations, hardening (hot-plug, debounce, error reporting to the GUI): the daemon survives a missing/disappearing keyboard and reopens it without losing the bus name; decoding is state-based (repeats suppressed, chords work); the GUI loads the saved mappings at startup and reports `Device: not found` / `Configuration error` in the footer; `GtkAboutDialog` no longer gets a `response` handler it does not have in GTK4. ## 9. Testing strategy - Unit: mapping parser round-trip, key-name tables, decoder against recorded byte sequences from the reference tables. - Device: replay captured reports into the decoder (no hardware needed). - Integration: `evtest` / `libinput debug-events` to observe the virtual device; compare `xev` output for a configured shortcut between reference and daemon. - D-Bus (M4): `gdbus call` for `GetStatus`/`Reload`/`TestMapping`, `gdbus monitor` for the signals, and a `TestMapping` observed with `xinput test ` to confirm the emitted keycodes. The GUI's footer line is read back over AT-SPI to check the live status. - Permission tests: verify the udev rule grants access as a normal user. - Resource check: `powertop`/`perf` or simple wakeup counting while idle. - Decoder without hardware (used in M5): compile `daemon/device_tables.cpp` standalone and feed it synthetic reports — single keys, chords, a repeated report, release and the families that must be ignored (media, memory, command, LED). Scratch test, not part of the repo. - Loop without hardware (used in M5): build the daemon with a *fake* `GKeyDevice` (same header, scripted `open()`/`read_report()`, linked without libusb) to exercise hot-plug (no keyboard at start, device lost, reopen), the D-Bus signals and the GUI footer states — without claiming the real keyboard. - Hardware confirmation (done in M5, 2026-10-18): the bitmap model was checked on the real keyboard. Pressing two G keys at once logs both keys from a single report (`G6 -> key:` and `G7 -> key:`, same timestamp) and a key held for three seconds logs exactly one line, with the release logging nothing. Use two rows whose target is empty so the check injects no key into the focused window. - Media keys (checked in M5, 2026-10-18): they are *not* on the keyboard's usual event node. With the daemon stopped they arrive on `event11` (`Phys …/input1`, created when `usbhid` rebinds interface `1.1`), while `event10` (interface `1.0`) only advertises them in its capability bitmap; with the daemon running neither node delivers them, which is why the daemon forwards them itself. Raw reports can be captured from the `hidraw` node of interface `1.1` while the daemon is stopped (that is how the bit table was measured), and the passthrough is checked by pressing the keys with the repo daemon running: `/tmp/daemon.log` logs `media -> mute` / `media -> volume up` / `media -> stop` and the desktop reacts as it does without the daemon. A released key logs nothing, and a `key`/`command` mapping is unaffected (press a G key in between). ## 10. Risks / open points - **Device table size**: `supported_devices.py` is large; porting/generating the byte tables is real work and model-specific. - **Interface detach** may momentarily disturb the device; must re-attach on exit and handle crashes gracefully. - **Media keys need the passthrough (measured 2026-10-18).** The keyboard reports mute/volume/play only on the interface the daemon holds, so without the passthrough of §4.4 they would do nothing while it runs — measured, and the reason the decode path must not be dropped as "just a media key". See §11. - **Permissions**: relies on `TAG+="uaccess"` + a user service; must never fall back to requiring root silently. - **Key-name vocabulary drift** between GUI (GDK names) and daemon (Linux KEY_*) is the most likely source of silent mismatches — that is why the shared table in M0 is mandatory before the daemon consumes the file. - **uinput device identity**: a second keyboard appears; choose stable name/vendor/product so the desktop does not confuse it with the real one. ## 11. Decisions to make The questions this section used to list were all settled while implementing M3–M5 — app id/bus name (`dev.rager.gkeymapper` in M4), IPC (D-Bus with GDBus, in M4) and the event loop (blocking loop + `DaemonService::pump()`, in M4). Their answers are kept under *Resolved* below. What is still open: - **`typeout` (open, low priority).** The reference project types out a fixed text; our daemon has no such action. It would be a pure addition to the action layer with no decoding involved: a token in the `mappings.conf` format, a fourth Type entry in the GUI, the four `.po` files, and a typing path that maps the text to key codes through the active layout (xkbcommon is already linked). Nothing decided yet. ### Decided - **The media keys stay a passthrough — they are not going to be remappable (2026-10-18).** Mute / volume / play / next / previous / stop keep the keyboard's own behaviour rather than becoming configurable rows. They are not G keys: the GUI has no slot for them, they are not part of the `mappings.conf` format, and adding them would mean a second kind of key in the format, the decoder, the daemon and the GUI — for keys whose job the keyboard already does. Remapping would not even let the passthrough go away, because those reports only reach the kernel through the interface the daemon holds; the passthrough stays in any case and is the place to hook into if this decision is ever revisited (`decode_media_key()` and `mediaKeys` in `daemon/device_tables.cpp`). - **The M1–M3 / MR keys and profiles come later (2026-10-18).** Not dropped, just after v1: the M keys switch profiles, so they need the profile concept and the reserved `SetProfile` D-Bus call first, and the device tables carry G1–G9 only. The README lists them under "Planned next steps". - **Holding a G key is its own type (`keyhold`), not an option on `key` (2026-10-18).** `key` keeps firing once; `keyhold` is the mapping whose keys stay down. Two types instead of a per-row flag keep the file format and the dropdown simple (one token and one entry per type) and make the intent visible in the file. The limits are deliberate: a *sequence* cannot be held (it is replayed as a tap, with a log line), because holding several chords at once has no meaning, and a modifier *alone* (`keyhold:shift` for a push-to-talk on Shift) is still not expressible — `parse_shortcut()` requires a real key and the capture dialog ignores modifier-only presses, so that would need a small change in both places. A single key or modifier+key (`keyhold:space`, `keyhold:ctrl+w`) works. ### Resolved - **Bus name / app id (done in M4).** App id (and GApplication id) `dev.rager.gkeymapper`; the daemon uses `dev.rager.gkeymapper.Daemon` because GtkApplication owns the plain app id to detect a second GUI instance. The installed `.desktop` file keeps its name `gkeymapper.desktop` — both old and new app id yield the X11 `WM_CLASS` `gkeymapper`, which is what `StartupWMClass` matches (verified with `xprop`). - **IPC (done in M4).** D-Bus with GDBus: libdbus and sd-bus dev files are not installed on this machine, GIO is; the daemon links `gio-2.0`. - **IPC event integration (done in M4).** The daemon keeps its simple blocking loop instead of moving to a `GMainLoop`: `DaemonService::pump()` calls `g_main_context_iteration(ctx, FALSE)` once per iteration, so D-Bus is serviced without a thread and without blocking, at the cost of up to one read timeout of latency on the control plane. - **Status line widget (evaluated).** The daemon status stays a plain `GtkLabel` in the window footer. `GtkStatusbar` does exist in GTK4 and is themed, but it keeps a message stack (pop/push per context id) and GTK4 exposes it over AT-SPI as a generic `filler`, not as a "statusbar" role — so it was built, compared side by side and dropped. - **Event loop (as implemented, deviates from §4.2).** The daemon is single-threaded with a blocking `libusb_interrupt_transfer` and a 500 ms timeout; the config watch is drained once per iteration. That is ~2 idle wakeups/s at 0 % CPU (the "simple variant" of §4.2) rather than the preferred async libusb + GMainLoop design. Switching to libusb's async API with its poll fds in a `poll()` loop stays open if idle wakeups ever become a concern — the config watch would fold into the same set. - **Layout handling (done in M2).** Key names in the file are layout independent; the daemon resolves them through the active layout (`XKBLAYOUT` from `/etc/default/keyboard`, via xkbcommon) and only falls back to the US positions from `shared/keys`. This closes the vocabulary-drift risk for non-US layouts — a German `z` is evdev 21, not the US position 44. - **Reload without restart (done in M2, completed in M4).** `SIGHUP`/`SIGUSR1` re-read the config file; since M4 the GUI's `Save mappings` also calls the D-Bus `Reload`, and the inotify watch applies anything else. - **Config watching (done in M3).** The daemon watches the config **directory** with inotify rather than the file, so an atomic save (temp file + `rename`) and a recreated file are caught too. The loop iteration (≤ 500 ms) is the coalescing window, so one save yields exactly one reload; `SIGHUP`/`SIGUSR1` stay as the fallback and there is no extra fd in the loop. `Save mappings` in the GUI now writes atomically, so the daemon can never read a partial file. A failed reload keeps the previous mappings instead of disabling every key. - **Lifecycle (done in M3, extended in M4).** A systemd **user** unit (not a system service): the daemon needs the session for `/dev/uinput` and for the `DISPLAY` a `command` action inherits. Since M4 it is `Type=dbus` with `BusName=dev.rager.gkeymapper.Daemon`, and a D-Bus activation file lets the bus start it on demand, so enabling the unit is optional. The udev rule and the `modules-load.d` fragment live in `data/` and are installed by `make install` (under `PREFIX/lib/...`, which udev and systemd both search). - **Single instance (done in M4).** The daemon requests its bus name with `DO_NOT_QUEUE` and exits with a message when it is already taken — two instances would detach the kernel driver from each other and create two virtual keyboards. The check happens before the device is opened. - **GUI loads the config at startup (done in M5).** `build_ui()` reads `default_config_path()` into the rows once they exist (skipped while the file does not exist, so a first start stays quiet). This closes the M4 finding that `Save mappings` in a fresh window overwrote the user's mappings with empty rows; since the rows are filled from exactly what the file holds, saving rewrites the same content. - **Hot-plug (done in M5).** The daemon no longer exits when the keyboard cannot be opened: it reports the absence once, keeps serving D-Bus and the config watch and retries every 2 s (sleeping 200 ms per iteration in between), so a re-plug is picked up without a restart and without losing the bus name. A read error is treated the same way (`DeviceChanged(false)` → reopen → `DeviceChanged(true)`). - **G-key decoding (reworked in M5).** Deliberately not the report-lookup table of §4.4: the macro report is a state bitmap, and a transition decoder is both simpler and more correct — a held key no longer fires repeatedly and a chord fires every key in it. The exact-match helpers it replaced were removed rather than kept as dead code. - **Bitmap decoding confirmed on hardware (M5, 2026-10-18).** The bitmap model is what the device really sends, not just what the reference tables suggested: one report with two bits set fired both keys (G6 and G7, same second in the journal), a key held for three seconds fired exactly once, and the release fired nothing. `decode_gkey_state()` is still the single place to change if another model in `device_tables` turns out to differ. - **Media keys keep working while the daemon runs (added in M5).** They are the second report family on the same interface: a 2-byte `02 ` report that the first decoder version rejected by length. The daemon now decodes it (`decode_media_key()`) and replays every bit it contains through the virtual keyboard (`UinputKeyboard::tap()` via `ActionRunner::tap_key()`), so mute / volume / play / next / previous / stop behave exactly as they do without the daemon — the report is forwarded, not turned into a mapping, and the release report carries no bit and is dropped. The bit table was *measured* rather than guessed: the `hidraw` node of interface `1.1` (which exists only while `usbhid` owns it) gave `02 01` next, `02 02` previous, `02 04` stop, `02 08` play/pause, `02 10` volume up, `02 20` volume down, `02 40` mute and `02 00` for the release, and the kernel's own input device for that interface confirmed the codes (`KEY_NEXTSONG`, `KEY_PREVIOUSSONG`, `KEY_STOPCD`, `KEY_PLAYPAUSE`, `KEY_VOLUMEUP`, `KEY_VOLUMEDOWN`, `KEY_MUTE`). Both interfaces advertise the consumer usages in their HID descriptors, so the capability bitmap alone is misleading — the raw report is the only reliable source. Why it has to exist at all: those keys reach the kernel only through this interface, so while the daemon held it they did nothing. The README called that a "may stop working" limitation until the measurement showed it was a plain regression. Verified live: five volume-up and three volume-down presses moved the sink by exactly two 2.5 % steps, and `media -> mute` / `media -> stop` / `G6 -> key:` appeared in the daemon log. Remapping them instead of forwarding them is decided against (see *Decided* above). - **`command` environment (checked in M5).** Nothing to do: the daemon runs as a **user** service, and the user manager's environment already carries `DISPLAY=:0`, `XAUTHORITY` and a sane `PATH` on this machine (verified with `systemctl --user show-environment` and by letting a `command` mapping dump its environment). If a command cannot reach the display on another setup, `systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XAUTHORITY` is the fix; the README says so. - **About dialog (fixed in M5).** It must not be connected to `response`: GTK4's `GtkAboutDialog` is not a `GtkDialog` subclass with buttons, so the signal does not exist and connecting to it only logged `GLib-GObject-WARNING`. Closing goes through `close-request`, whose default handler destroys the window.