# AGENTS.md ## What this is A pure GTK4 C++ GUI plus a background daemon for remapping the G1–G9 keys of a Logitech G910. The GUI edits and persists mappings; the daemon reads the G-keys and replays the configured action. Hardware G-key detection and action execution live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` (common) and `daemon/`. ## Build / run / verify - `make` — builds the `gkeymapper` GUI, the `gkeymapper-daemon` and the `.mo` catalogs. Needs `g++` (C++17), `make`, `gettext`, `libgtk-4-dev`, `libusb-1.0-0-dev`, `libxkbcommon-dev` and `libglib2.0-dev` (see README for `apt install` on a fresh machine). `libglib2.0-dev` also provides `glib-compile-resources`, which generates the icons embedded into the GUI. - `make daemon` — builds only `gkeymapper-daemon` (needs libusb, xkbcommon and gio-2.0, not GTK). - `make translations` — only runs `msgfmt` on the existing `.po` files. - `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and `GKEYMAPPER_ASSETS_DIR=$PWD/assets` so the working-tree translations are used. Plain `./gkeymapper` works too (assets fall back to `./assets`). The window icon itself is compiled into the binary, so it never depends on these. - `make clean` — deletes the binaries and the generated `locale/` and `build/` trees. - No tests and no CI. Verify changes by building and running the app. ## i18n — easy to get wrong - English is the source language (the `msgid`) **and** also has an explicit identity catalog `po/en.po`. Languages: `de`, `en`, `es`, `fr`. - `make translations` does **not** regenerate the `.pot`. When you add or change a UI string, edit `po/gkeymapper.pot` and `po/*.po` by hand, then run `make translations`; validate with `msgfmt --check -o /dev/null po/.po`. - Never edit the generated `.mo` files under `locale/` (gitignored). Delete any `po/*.po~` backups before committing. - All documentation and source comments are English. German appears only inside `po/de.po`. ## Code layout and conventions - Modules under `src/` (GUI), `shared/` (used by both binaries) and `daemon/` (the background service). Shared API lives in `namespace gkeymapper`. `shared/mapping` owns the mappings file format, `shared/keys` the canonical key-name table (lowercase GDK names -> Linux `KEY_*`), `shared/ipc_ids.h` the app id, the version and the D-Bus names both sides use. - The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp` + 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 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 `@BINDIR@` placeholder that the Makefile substitutes with `$(PREFIX)/bin`. Keep it a user service — it needs the session for `/dev/uinput` and `DISPLAY`. - Daemon config reload: `config_watch` watches the config **directory** (so an atomic save via `rename` is caught) and reports a change at most once per loop iteration, which is the coalescing window. `SIGHUP`/`SIGUSR1` remain as a 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`. `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 service. The daemon owns `dev.rager.gkeymapper.Daemon`, **not** the plain app id — `GtkApplication` owns that itself to detect a second GUI instance, so a daemon on the same name would make every GUI start look like a duplicate and exit. All names live in `shared/ipc_ids.h`. - The GUI never does a blocking D-Bus call: `DaemonClient` uses async calls and signal subscriptions, which arrive on the GTK main loop, and it follows `NameOwnerChanged` so the footer reacts when the daemon starts or stops. - `DaemonService::pump()` dispatches messages once per daemon loop iteration, so a control call is answered within one read timeout (≤500 ms) and outgoing signals are flushed then too. Keep it off the keypress path. - The daemon refuses to start when the name is taken (`DO_NOT_QUEUE` + exit 0 with a message): two instances would fight over the device and create two virtual keyboards. That check runs **before** the device is opened. - The daemon status line is a plain `GtkLabel` in the window footer, updated with a single `gtk_label_set_text()`. It is deliberately **not** a `GtkStatusbar`: that needs message pop/push bookkeeping per context id, and GTK4 exposes it over AT-SPI as a generic `filler`, not as a "statusbar" role, so it buys nothing here. - The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`, `2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens in the file. Keep that order. - The capture dialog's `CaptureSession` is heap-allocated and freed only by the window's `"destroy"` handler. Always close the dialog with `gtk_window_destroy` so the session is released. - Use GTK4 API only: `gtk_editable_set_text` / `gtk_editable_get_text` (there is no `gtk_entry_set_text`), `gtk_window_destroy` (no `gtk_dialog_run`), `G_APPLICATION_FLAGS_NONE`. - The main window is intentionally **non-resizable** and has **no** `GtkScrolledWindow` (one collapsed the rows earlier). Keep the fixed widths (Type dropdown 150 px, target entry 440 px min, action button 180 px) so rows do not jump when the language changes. - Because it is pure GTK4 it follows the system theme (Mint-Y ships a `gtk-4.0` theme). Do **not** add the `suggested-action` CSS class — Mint-Y tints it green and it clashes with the rest of the layout. ## Config file format `Save mappings` writes `~/.config/gKeyMapper/mappings.conf`; Export/Import use the same format: ``` G1=key:ctrl+c,ctrl+v G2=command:/usr/bin/firefox G3=disabled: ``` `:`, one line per key. `key` is a captured sequence (lowercase `gdk_keyval_name` output; modifiers joined with `+`, steps joined with `,`), `command` is a program path or command line, `disabled` is inactive. The canonical modifier order is `ctrl`, `alt`, `shift`, `super`, `altgr` (`format_shortcut_step()`; parsing accepts any order). Key names are **layout independent**: `shared/keymap` resolves a name like `z` to the key that produces `z` on the active layout (read from `/etc/default/keyboard`, `XKBLAYOUT`) via xkbcommon — on a German keyboard `z` is evdev 21, not the US position 44. `shared/keys` maps names to the US positions and is only the fallback. When you add key names, keep them lowercase GDK keysym names. `parse_shortcut()` takes an optional resolver for this (the daemon passes one). The capture dialog stores the **base (level 0) key**: it uses `Keymap::base_key_name(keycode)` instead of GDK's shifted keyval, so `Shift`+ß becomes `shift+ssharp` (not `shift+question`, which would double the shift). `format_shortcut_step()` — not the dialog — renders a step from a `ModifierBit` mask, so the order stays in one place. AltGr (level 3) is recorded as the `altgr` modifier plus the base key (`AltGr`+7 → `altgr+7`). GTK4 dropped the `GDK_MOD5_MASK` name, so the dialog tracks `ISO_Level3_Shift` / `Mode_switch` through its own key press/release (`CaptureSession::altGrHeld`) instead of a state bit. On the replay side `kModAltGr` maps to `KEY_RIGHTALT`. ## Assets The icons (`assets/icon.png`, `assets/icon.svg`) are compiled into the GUI: `data/icons.gresource.xml` is turned into the generated `build/gresources.c` by `glib-compile-resources` and linked into `gkeymapper`, so the window has its icon with nothing installed. The resource prefix mirrors the app id (`/dev/rager/gkeymapper`) and the `icons/` subtree mirrors the hicolor layout, so `gtk_icon_theme_add_resource_path()` also answers `gtk_window_set_icon_name("gkeymapper")` from the binary. The daemon embeds no resource (it has no UI). `make_icon_image()` and the About dialog take the embedded copy first and fall back to `find_asset()`, which resolves `$GKEYMAPPER_ASSETS_DIR` → `./assets` → compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). Because the resource is built from `assets/` it cannot go stale, and it deliberately wins over any installed file; the file lookup remains only as a safety net. ## Driving the daemon for manual checks - The process name is truncated by the kernel to 15 chars, so `pkill -x gkeymapper-daemon` and `pgrep -x gkeymapper-daemon` **never match**. Use `pkill -x gkeymapper-daem` / `pgrep -x gkeymapper-daem` (or `pkill -f './gkeymapper-daemon'`). - Run it in the background with `setsid nohup ./gkeymapper-daemon [config] > /tmp/daemon.log 2>&1 < /dev/null &`; it survives across shell calls. It takes an optional config path as `argv[1]` (default `~/.config/gKeyMapper/mappings.conf`). - `SIGHUP`/`SIGUSR1` reload the mappings (`kill -HUP $(pgrep -x gkeymapper-daem)`). Normally you do not need them: the config file is watched, so saving in the GUI reloads within about half a second. Use `SIGHUP` to check the signal path after touching it. - If the systemd user service is enabled, **stop it first** (`systemctl --user stop gkeymapper-daemon`) before starting the repo build by hand: the daemon refuses to run when the bus name is taken (a second instance would fight over the device). Restore it afterwards with `systemctl --user start gkeymapper-daemon`. Note that the installed unit runs `/usr/local/bin/gkeymapper-daemon`, so *that* binary is the old one until the next `sudo make install`. - D-Bus control channel (the daemon owns `dev.rager.gkeymapper.Daemon`): ```bash D=dev.rager.gkeymapper.Daemon; O=/dev/rager/gkeymapper/Daemon gdbus call --session --dest $D --object-path $O --method $D.GetStatus gdbus call --session --dest $D --object-path $O --method $D.Reload gdbus call --session --dest $D --object-path $O --method $D.TestMapping 0 gdbus monitor --session --dest $D # KeyPressed / ConfigReloaded / ... ``` `TestMapping` runs the same path as a real keypress, so it verifies a mapping without hardware (index is 0-based, `0` = G1). A `TestMapping` for a `disabled:`/empty row fails with `G_n_ has no usable mapping`. `busctl --user` also works. - The config watch is testable without the device: `daemon/config_watch.cpp` has a standalone test shape (create a `ConfigWatcher`, `poll_events()` then `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 ` 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 `Exec=/gkeymapper-daemon` (no `SystemdService=`) into `~/.local/share/dbus-1/services/` — the session bus picks it up without a restart — then call a method with no daemon running and watch it appear. Remove that file afterwards, or it shadows the installed one. - `make install DESTDIR=/tmp/stage` stages the packaging files; inspect `/tmp/stage/usr/local/lib/...` and `/tmp/stage/usr/local/share/dbus-1/services/...` to verify the `@BINDIR@` substitution. `systemd-analyze --user verify ` checks the unit. There is 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: `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 ` 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 `G1 -> key:...` per press). Verify what the virtual keyboard emits at the X level with `xinput test ` (`gKeyMapper virtual keyboard` shows up as both a pointer and a keyboard — use the **keyboard** id). X keycodes are evdev codes + 8. There is no `xdotool`/`wtype`. ## Driving the GUI for manual checks There is no `xdotool`/`wtype` on this machine, and the capture-dialog buttons (`Clear`/`Cancel`/`Done`) expose an empty AT-SPI accessible name — the text is in a child `label` node, so target the button by its child label. `pyatspi` (`queryAction().doAction(0)`) plus `Xlib`'s `xtest.fake_input` can click buttons and inject keys. Warn the user before synthesizing keystrokes or stealing window focus. The footer's daemon line is a plain `GtkLabel`, so its text can be read back over AT-SPI: ` → 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. 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.