686 lines
42 KiB
Markdown
686 lines
42 KiB
Markdown
# 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<n>=keyhold:<shortcut>` 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: <https://github.com/JSubelj/g910-gkey-macro-support>
|
||
|
||
- 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 <bit>` 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
|
||
`<linux/uinput.h>` / `<linux/input-event-codes.h>` — 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<n>=<type>:<target>` 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 `<app-id>` 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 <keyboard-id>` 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 <bit>` 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.
|