23 KiB
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 thegkeymapperGUI, thegkeymapper-daemonand the.mocatalogs. Needsg++(C++17),make,gettext,libgtk-4-dev,libusb-1.0-0-dev,libxkbcommon-devandlibglib2.0-dev(see README forapt installon a fresh machine).libglib2.0-devalso providesglib-compile-resources, which generates the icons embedded into the GUI.make daemon— builds onlygkeymapper-daemon(needs libusb, xkbcommon and gio-2.0, not GTK).make translations— only runsmsgfmton the existing.pofiles.make run— builds, then runs withGKEYMAPPER_LOCALEDIR=$PWD/localeandGKEYMAPPER_ASSETS_DIR=$PWD/assetsso the working-tree translations are used. Plain./gkeymapperworks 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 generatedlocale/andbuild/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 catalogpo/en.po. Languages:de,en,es,fr. make translationsdoes not regenerate the.pot. When you add or change a UI string, editpo/gkeymapper.potandpo/*.poby hand, then runmake translations; validate withmsgfmt --check -o /dev/null po/<lang>.po.- Never edit the generated
.mofiles underlocale/(gitignored). Delete anypo/*.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) anddaemon/(the background service). Shared API lives innamespace gkeymapper.shared/mappingowns the mappings file format,shared/keysthe canonical key-name table (lowercase GDK names -> LinuxKEY_*),shared/ipc_ids.hthe app id, the version and the D-Bus names both sides use. - The Makefile globs sources:
makebuilds the GUI (src/*.cpp+shared/*.cpp- the generated
build/gresources.c, see Assets) and the daemon (daemon/*.cpp+shared/*.cpp, needslibusb-1.0-0-dev,libxkbcommon-devandlibglib2.0-dev).make daemonbuilds only the daemon.
- the generated
- The daemon is split into:
device_tables(raw HID report tables per model plusdecode_gkey_state, the report → pressed-bitmap decoder),g910_device(libusb open/detach/claim/read),uinput_keyboard(virtual keyboard),action(dispatchkey/command/disabled/keyhold),config_watch(inotify auto-reload of the mappings file) andipc_dbus(the GDBus service).shared/keymapdoes 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.
keyholduses the falling edge too:run_mapping(..., hold=true)callsActionRunner::begin_hold(), and the 1 → 0 transition of that bit callsend_hold(index)(a no-op for every other type). The held codes are stored per G key inActionRunner::held_, so a code that two G keys hold is released only when the last one comes up.release_held()runs on device loss and on shutdown, otherwise a key whose release report never arrived would stay down in the whole session.ActionRunner::run()(theTestMappingpath) taps a hold mapping, because a D-Bus call cannot wait for a release. Only a single step can be held; a sequence is replayed as a tap with a log line, andkeyhold:on an unresolvable name does nothing.- Auto-repeat of a held key comes from the X server / compositor, not from the
virtual device: it sets no
EV_REPbits, so the kernel repeats nothing, but X does (measured: release+press pairs every ~40 ms after 660 ms). That matches a physical key, so do not "fix" it by filtering. - The daemon runs without a keyboard:
open()failing (or a read error) only logs the absence once, reportsDeviceChanged(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 fromdata/: the udev rule, themodules-load.dfragment, 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/uinputandDISPLAY.- Daemon config reload:
config_watchwatches the config directory (so an atomic save viarenameis caught) and reports a change at most once per loop iteration, which is the coalescing window.SIGHUP/SIGUSR1remain as a fallback. A failed reload keeps the previous mappings on purpose. main.cppkeeps therowsvector (oneKeyRowper G key, widget pointers) in an anonymous namespace;src/mappings.cppis GUI glue that moves data between the widgets and the sharedMappingSet.build_ui()loadsdefault_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 usesipc::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 ownsdev.rager.gkeymapper.Daemon, not the plain app id —GtkApplicationowns 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 inshared/ipc_ids.h. - Exactly one GUI runs, and it is the one that was just started:
main()callsreplace_running_instance()(src/singleinstance.{h,cpp}) before it creates theGtkApplication. It asks the instance that ownsipc::kAppIdto quit over D-Bus (thequitactionGApplicationexports asorg.gtk.Actionsat the app-id path, added inon_activate) and waits for the name; an instance from an older build has no such action, so it is terminated withSIGTERMafter checking that the bus name really belongs to agkeymapperprocess. Only then does the new process become the primary, because otherwiseGtkApplicationwould hand over to the old process and its window — possibly one from an older build — would stay on screen. Row edits that were not saved are gone with the replaced window; there is no implicit save. on_activatepresents the window that is already there (gtk_application_get_windows()), it does not build a second one: a remote activation — an older build starting next to a new one — would otherwise add a second window to the running process.- Except for that startup takeover, the GUI never does a blocking D-Bus call:
DaemonClientuses async calls and signal subscriptions, which arrive on the GTK main loop, and it followsNameOwnerChangedso 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
GtkLabelin the window footer, updated with a singlegtk_label_set_text(). It is deliberately not aGtkStatusbar: that needs message pop/push bookkeeping per context id, and GTK4 exposes it over AT-SPI as a genericfiller, not as a "statusbar" role, so it buys nothing here. - The Type dropdown index is meaningful everywhere:
0=Key,1=Command,2=Disabled,3=Key (hold), matching thekey/command/disabled/keyholdtokens in the file. Keep that order (ActionTypeappends new values at the end —Disabled, Key, Command, KeyHold— so an existing value never changes meaning; the GUI maps indices explicitly). - The capture dialog's
CaptureSessionis heap-allocated and freed only by the window's"destroy"handler. Always close the dialog withgtk_window_destroyso the session is released. - Use GTK4 API only:
gtk_editable_set_text/gtk_editable_get_text(there is nogtk_entry_set_text),gtk_window_destroy(nogtk_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.0theme). Do not add thesuggested-actionCSS 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:
G4=keyhold:ctrl+w
<type>:<target>, 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, and
keyhold presses one step and keeps it down while the G key is held. 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-daemonandpgrep -x gkeymapper-daemonnever match. Usepkill -x gkeymapper-daem/pgrep -x gkeymapper-daem(orpkill -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 asargv[1](default~/.config/gKeyMapper/mappings.conf`). -
SIGHUP/SIGUSR1reload 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. UseSIGHUPto 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 withsystemctl --user start gkeymapper-daemon. Note that the installed unit runs/usr/local/bin/gkeymapper-daemon, so that binary is the old one until the nextsudo make install. -
D-Bus control channel (the daemon owns
dev.rager.gkeymapper.Daemon):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 / ...TestMappingruns the same path as a real keypress, so it verifies a mapping without hardware (index is 0-based,0= G1). ATestMappingfor adisabled:/empty row fails withG_n_ has no usable mapping.busctl --useralso works. -
The config watch is testable without the device:
daemon/config_watch.cpphas a standalone test shape (create aConfigWatcher,poll_events()thentake_change()per simulated loop iteration), andConfigReloadedon thegdbus monitorabove reports when a save was applied. A GUI save triggers two reloads on purpose — the file watch and the explicitReload()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.cppstandalone and feeddecode_gkey_state()synthetic reports: single keys, a chord, a repeated (held) report, the release report, and the families that must be rejected (memory keys11 ff 09/11 ff 0a, command11 ff 08 2eand LED answers); coverdecode_media_key()with the02 <bit>presses, the release, a padded report and the same families, which must not decode as media keys (see the bit table above); - build the daemon with a fake
GKeyDevice: keep the header, scriptopen()/read_report(), linkdaemon/*.cppwithoutg910_device.cppand 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 withcommand:mappings only (a helper script that dumps its environment is also how you check that acommandaction seesDISPLAY); withkey:mappings the fake would inject keys into whatever window is focused. Two details when the fake has to exercisekey:/keyhold:mappings:- Copy
main.cppnext to the fake, e.g./tmp/…/fakesrc/main.cpp, and put the fakeg910_device.hin an include directory.#include "g910_device.h"searches the including file's directory first, so compiling the realdaemon/main.cppwould always pick up the real header (its inlineis_open()needs libusb). - Give the fake its own session bus (
dbus-daemon --session --address= unix:path=/tmp/…/fake_bus --fork --print-pid=1, thenDBUS_SESSION_BUS_ADDRESS=unix:path=…for the fake). Otherwise it refuses to start while the real daemon is running, and stopping the user service for a test loses the media keys and the GUI footer. - Read the events back over X, not from
/dev/input: the event node of an own uinput device has no ACL for the user (/dev/uinputdoes, its children do not), soopen()fails withEACCES.xinput test <id>works without root; identify the new device by diffingxinput listids before and after (the name is the same as the installed daemon's virtual keyboard). X keycodes are evdev + 8, andxinput testshows noEV_SYN, so simultaneity of a chord cannot be read that way — the press/release order and the durations can. - Use inert keys:
f13–f24are not in this layout (Keymap::key_codereturns -1),pauseandkp_equalare. A held Shift for a few 100 ms is harmless, a held Ctrl is not.
- Copy
- compile
-
D-Bus activation is only testable in a real setup: the installed
share/dbus-1/services/dev.rager.gkeymapper.Daemon.serviceneedssudo make install. For a quick check without root, drop a file withExec=<repo>/gkeymapper-daemon(noSystemdService=) 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/stagestages 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 <unit>checks the unit. There is noudevadm verifyon 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/uinputkeyboard. Warn the user first:keymappings 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 indaemon/main.cppsilently kills the media keys while the daemon runs. Note that they are deliberately not a mapping and not going to become one: making them remappable is decided against (plan §11), so do not turnmediaKeysinto config rows or GUI fields without that decision being revisited first. -
Media keys cannot be observed over the keyboard's usual event node: with the daemon stopped they appear on
event11(Phys …/input1, created whenusbhidowns the interface), not onevent10(…/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 thehidrawnode of interface1.1(usb-…-5.4.3/input1, exists only whileusbhidowns it):02 <bit>per press with bit 001next,02previous,04stop,08play/pause,10volume up,20volume down,40mute, and02 00for the release. Verify the passthrough by reading/tmp/daemon.log: a press logsmedia -> mute/media -> volume up/ … and the desktop reacts as usual. The bit table lives ing910MediaKeys()(daemon/device_tables.cpp). -
The keyboard is
046d:c32b(also handle046d:c335); on this machine the USB device and/dev/uinputalready carry ACLs for the user, so no root is needed. -
Verify decoding by pressing G keys and reading
/tmp/daemon.log(it logsG1 -> key:...per press). Verify what the virtual keyboard emits at the X level withxinput test <keyboard-id>(gKeyMapper virtual keyboardshows up as both a pointer and a keyboard — use the keyboard id). X keycodes are evdev codes + 8. There is noxdotool/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 one-instance behavior needs no hardware: start the GUI twice and check that
one process (pgrep -x gkeymapper) and one window (wmctrl -l) are left. The
replaced process exits 0 when it was closed through the D-Bus quit action, or
143 (SIGTERM) when it came from a build without that action — build such an
instance from an older commit (git worktree add /tmp/legacy <commit>), start it
and then start the current build next to it. Starting the GUI twice also covers
the activate guard: without it the second start adds a second window to the
running process.
The footer's daemon line is a plain GtkLabel, so its text can be read back over
AT-SPI: <app> → frame → panel → label whose name starts with the translated
Daemon: string. To pick the right window, filter the application whose tree
contains that label. A stale instance is not a problem any more: starting the
binary under test closes whatever is running (see the single-instance notes
above) before it shows its window, so it is safe to start it while an older GUI
is up. Three notes for tests:
- Clicking
Save mappingswrites~/.config/gKeyMapper/mappings.conffor 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 callReloadover D-Bus instead). - The
GtkEntrytexts 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; theSavebutton ispush button+ nameMappings speichernin the German locale.