9.1 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-devandlibxkbcommon-dev(see README forapt installon a fresh machine).make daemon— builds onlygkeymapper-daemon(needs libusb + xkbcommon, 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 and assets are used. Plain./gkeymapperworks too (assets fall back to./assets).make clean— deletes the binary and the generatedlocale/tree.- 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_*). - The Makefile globs sources:
makebuilds the GUI (src/*.cpp+shared/*.cpp) and the daemon (daemon/*.cpp+shared/*.cpp, needslibusb-1.0-0-devandlibxkbcommon-dev).make daemonbuilds only the daemon. - The daemon is split into:
device_tables(raw HID report tables per model),g910_device(libusb open/detach/claim/read),uinput_keyboard(virtual keyboard),action(dispatchkey/command/disabled) andconfig_watch(inotify auto-reload of the mappings file).shared/keymapdoes the layout lookups for both sides. The hot path never touches the GUI. make install(PREFIX default/usr/local) also installs the packaging files fromdata/: the udev rule, themodules-load.dfragment and the systemd user unit. The unit contains 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. App id:com.example.gkeymapper.- The Type dropdown index is meaningful everywhere:
0=Key,1=Command,2=Disabled, matchingActionTypeand thekey/command/disabledtokens in the file. Keep that order. - 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:
<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. 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
find_asset() resolves in order: $GKEYMAPPER_ASSETS_DIR → ./assets →
compiled-in ASSETS_DIR (/usr/local/share/gkeymapper/assets). The icon
(assets/icon.png) is used in the header bar and the About dialog.
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.- Test the auto-reload without hardware: write the config through the shared
save path (atomic temp+rename, as the GUI does) and watch
/tmp/daemon.logfor a secondloaded ...line.daemon/config_watch.cppalso has a standalone test shape: create aConfigWatcher,poll_events()thentake_change()per simulated loop iteration. make install DESTDIR=/tmp/stagestages the packaging files; inspect/tmp/stage/usr/local/lib/...to verify the unit'sExecStartsubstitution.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: while it runs the media keys may not work, andkeymappings are injected into the focused window. - 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.