Files
gKeyMapper/AGENTS.md
2026-10-18 22:53:39 +02:00

40 KiB
Raw Permalink Blame History

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.
  • make deb — builds a .deb package (gkeymapper_<version>_<arch>.deb) via scripts/make-deb.sh; on install it runs systemctl --user daemon-reload and systemctl --user enable --now gkeymapper-daemon for the installing user.
  • 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. The workflow, the rules behind it and adding a language are documented in po/README.md; the commands are scripts/i18n.sh (make pot, make i18n-check, make i18n-status).
  • make translations does not regenerate the .pot (a pot written by a build is a pot nobody reviews). When you add or change a UI string: make pot, then scripts/i18n.sh update to merge it into the catalogs, then fill in the msgstrs in po/*.po — and in po/en.po, whose msgstr repeats the msgid. _() is translated where it is used, N_() is only collected (the help paragraphs in src/help.cpp); the script always passes both keywords to xgettext.
  • The catalogs carry no #: file:line comments; the locations live in the pot only (update merges with msgmerge --no-location) and scripts/i18n.sh todo <lang> shows them from there. make i18n-check fails when the pot is behind the sources, when a catalog misses a message or a translation, or when po/*.po and kLanguageCodes[] (src/optionsdialog.cpp) disagree — a catalog nobody can select is dead weight.
  • The daemon is not translated (no setlocale/bindtextdomain, only the GUI sets gettext up in main()); i18n-check reports a _() marker under daemon/.
  • Never edit the generated .mo files under locale/ (gitignored). A manual msgmerge --update leaves po/*.po~ backups — the script deletes them, by hand delete them 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 GUI side is main.cpp (window, actions) plus keyrow.h, mappings, capturedialog, daemonclient, singleinstance, trayicon (StatusNotifierItem + dbusmenu), settings (gui.conf), autostart (the session's .desktop entry), help, optionsdialog and popovers (the popup input grab, see below).
  • 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/keyhold), 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.
  • keyhold uses the falling edge too: run_mapping(..., hold=true) calls ActionRunner::begin_hold(), and the 1 → 0 transition of that bit calls end_hold(index) (a no-op for every other type). The held codes are stored per G key in ActionRunner::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() (the TestMapping path) 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, and keyhold: 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_REP bits, 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, 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.
  • The About dialog is a stock GtkAboutDialog, but its texts get switched off as selectable before it is mapped (make_labels_unselectable): GTK hands the keyboard focus to the program name and a focused selectable GtkLabel selects its whole text, so the dialog opened with the name drawn as a blue block. Do not "clean that up" back — as long as the labels are selectable there is always one that grabs the focus and shows up marked.
  • 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.
  • Exactly one GUI runs, and it is the one that was just started: main() calls replace_running_instance() (src/singleinstance.{h,cpp}) before it creates the GtkApplication. It asks the instance that owns ipc::kAppId to quit over D-Bus (the quit action GApplication exports as org.gtk.Actions at the app-id path, added in on_activate) and waits for the name; an instance from an older build has no such action, so it is terminated with SIGTERM after checking that the bus name really belongs to a gkeymapper process. Only then does the new process become the primary, because otherwise GtkApplication would hand over to the old process and its window — possibly one from an older build — would stay on screen.
  • The takeover also carries the rows over, so an edit that was typed but never saved is not lost with the replaced window: before the quit, replace_running_instance() asks for them (Window.GetRows at ipc::kWindowObjectPath) and build_ui() starts the new window from that text instead of the file. The answering side is serve_rows_handover() — called from on_activate after build_ui(), since that is what fills the rows — with mappings_text(rows), i.e. the mappings file format, so both ends use src/mappings. An instance that does not answer (an older build) leaves the new window with the file; asking and serving are best effort and never fatal. The file itself is untouched until the user presses Save mappings — the handover carries row state, it does not save.
  • on_activate presents 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: 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.
  • The tray icon (src/trayicon.{h,cpp}) is a StatusNotifierItem plus a com.canonical.dbusmenu menu, spoken over GIO — not through libxapp or libappindicator: both are GTK3 libraries (measured: ldd libxapp.so.1 → libgtk-3.so.0, deps libgtk-3-0 + libdbusmenu-gtk3-4), and loading GTK3 next to GTK4 in one process is not supported. GTK4 also has no tray API at all (GtkStatusIcon is GTK3). This way the feature adds no build dependency. The icon travels as IconPixmap (ARGB32, network byte order): the panel is another process and cannot resolve the icons compiled into the binary via GResource.
  • In the dbusmenu layout the children array holds full nodes ((ia{sv}av) each), never bare ids: libdbusmenu-glib, which the panel's watcher uses, walks them and aborts the whole process on anything else (g_variant_serialised_n_children: code should not be reached) — that crashed xapp-sn-watcher on every registration until it was fixed. Do not "simplify" this back to ids.
  • The panel's watcher sends its clicks batched as EventGroup (a list of (isvu)), not as single Event calls: both must be handled, EventGroup is the path a real click takes (verified with dbus-monitor). Both entries go through one handle_event(id, eventId), which only reacts to "clicked" on the Show and Close ids.
  • Two watcher names exist (org.kde.StatusNotifierWatcher and the older org.x.StatusNotifierWatcher; on Mint both belong to xapp-sn-watcher). Both are watched and the item registers once, preferring the org.kde. name — a second registration would add a second icon. ready() (registered with a watcher that still owns the name) is what gates hiding the window.
  • The GUI's own preferences (minimize to tray, language) live in ~/.config/gKeyMapper/gui.conf (GKeyFile, src/settings.{h,cpp}), deliberately not in mappings.conf, so a mapping file stays a mapping file and can be exported/imported without dragging a window preference along.
  • Autostart is not a setting in gui.conf: it is the presence of the entry the session reads at login, ~/.config/autostart/gkeymapper.desktop (src/autostart.{h,cpp}). The switch writes that file or removes it, so there is no flag that could disagree with what the session actually does; autostart_enabled() treats Hidden=true and X-GNOME-Autostart-enabled=false as off, because the desktop's own startup tool disables an entry instead of deleting it. The Exec line is the binary that is running now (g_file_read_link("/proc/self/exe"), quoted, falls back to the name from PATH), so an enabled switch starts the build the user was looking at, and the Comment reuses the About text (no second sentence for translators). That Exec line also carries --start-hidden (kStartHiddenArg, taken off the command line in main()), so the window is built but stays in the tray: a login does not put it in front of the user, while a start by hand (no flag) always shows it. Keeping it hidden is only safe while the panel really hosts the icon, and only the panel can answer that, asynchronously — so on_start_hidden_timeout() shows the window after kStartHiddenGraceMs if the icon never turned up. A rebuild (language switch) comes up the way the window was left: build_ui() takes the visibility as an argument. Only the window is started; the daemon is a systemd user service and stays untouched — the GUI is a convenience, the daemon is the part that has to run.
  • Minimize to tray hides the window on close ("close-request" handler) and on minimize. GTK4 has no minimize signal and GdkToplevel exposes no properties, so gdk_toplevel_get_state() is polled every 250 ms while the option is on. Hiding happens only while tray.ready() — without a hosted icon there would be no way back to the window, so a close then quits as usual. The tray menu's Show uses gtk_widget_set_visible + gtk_window_present (which also restores a minimized window).
  • Help, options and the tray menu are separate windows/files instead of a GMenu: a GMenu cannot host a switch, and the options window is deliberately non-modal so the footer keeps updating while it is open. The daemon data behind both is one DaemonView (last status reply + last key); the footer shows it in one line, the options window in full.
  • The language is a setting in gui.conf, not something that is guessed: gettext gets it through LANGUAGE plus a fresh setlocale (apply_gui_language), and that is also why the English catalog is an identity one — an explicit en has to work on a German desktop. Widgets keep the strings they were built with, so switching rebuilds the windows (rebuild_ui_idle in main.cpp): the main window is destroyed and built again, options and help are closed and reopened if they were open, and the tray menu is announced to the panel with LayoutUpdated (it mirrors the menu once, so the revision is bumped). The application is held across the gap — destroying the last window would end the process. The rows go over with collect_mappings/apply_mappings, so a language switch keeps unsaved edits; never rebuild the window without that step. Rebuilding also means the poll and the tray are not started in build_ui(): they are one-time, in on_activate.
  • 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, 3=Key (hold), matching the key/command/disabled/keyhold tokens in the file. Keep that order (ActionType appends 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 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.
  • Both dropdowns (Type in the main window, Language in the options window) give every entry a trailing space, added where the model is built — the msgids stay the bare words. GtkDropDown appends the check mark of the selected entry directly behind the text (setup_item() in gtkdropdown.c), and without that space it touches the last letter. The selection is read back by index, never by the string, so the space is harmless everywhere else.
  • src/popovers.{h,cpp} takes the input grab off the popovers of a window (the dropdowns' popup lists and the header bar menu) and closes them itself: on a click in the window outside the popup, on Escape, and when the window loses the focus. GTK 4.6 grabs the pointer for an open popover, an X11 grab swallows the clicks of every other client, and this GTK does not close the popup on a click outside the application either — with one dropdown open nothing else could be clicked any more, not another program and not the desktop, and the popup stayed open until Escape (measured with a plain GTK4 probe: the click on the bare desktop never reached it and _NET_ACTIVE_WINDOW did not change; a click inside the app's own window did close the popup). GTK3 replayed such a click to the window under the pointer, GTK4 dropped that. autohide is what installs the grab, so switching it off hands the clicks back to the desktop; the dismissal is then done by hand, which is the rest of the module. It runs once per toplevel window (build_ui, OptionsDialog::show), notices popovers GTK creates later on the press that opens them, and leaves the press on the popup itself alone — that one is an entry choice and must reach the entry (recognised by its own surface, not by its position, because GTK reports it in the popup's coordinates).
  • 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:
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-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):

    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 <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, 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. Two details when the fake has to exercise key:/keyhold: mappings:
      • Copy main.cpp next to the fake, e.g. /tmp/…/fakesrc/main.cpp, and put the fake g910_device.h in an include directory. #include "g910_device.h" searches the including file's directory first, so compiling the real daemon/main.cpp would always pick up the real header (its inline is_open() needs libusb).
      • Give the fake its own session bus (dbus-daemon --session --address= unix:path=/tmp/…/fake_bus --fork --print-pid=1, then DBUS_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/uinput does, its children do not), so open() fails with EACCES. xinput test <id> works without root; identify the new device by diffing xinput list ids before and after (the name is the same as the installed daemon's virtual keyboard). X keycodes are evdev + 8, and xinput test shows no EV_SYN, so simultaneity of a chord cannot be read that way — the press/release order and the durations can.
      • Use inert keys: f13–f24 are not in this layout (Keymap::key_code returns -1), pause and kp_equal are. A held Shift for a few 100 ms is harmless, a held Ctrl is not.
  • 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=<repo>/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 <unit> 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. 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 turn mediaKeys into 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 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 <bit> 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 <keyboard-id> (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 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 rows come along (see the takeover notes above), which is checkable the same way: type into a row of the first instance without saving, start the second one and read its rows back (next bullet) — they show the edit, while the mappings file still has the old content. An instance from an older commit cannot hand rows over, so there the new window shows the file.

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 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. Easier than a screenshot for the rows themselves: the running instance answers gdbus call --session --dest dev.rager.gkeymapper --object-path /dev/rager/gkeymapper/Window --method dev.rager.gkeymapper.Window.GetRows with exactly the mappings text its rows hold (G1=command:/bin/true…). It is the same channel the takeover uses, so it also tells whether an edit is still unsaved: compare it with the file.
  • XTest-synthesized clicks need the window mapped and focused; the Save button is push button + name Mappings speichern in the German locale.

The tray icon, the help and the options window need neither hardware nor a click:

  • Is the item registered? The panel watcher lists it by name: gdbus call --session --dest org.kde.StatusNotifierWatcher --object-path /StatusNotifierWatcher --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems — the entry is org.kde.StatusNotifierItem-<pid>-1/StatusNotifierItem. A second entry (same pid, two registrations) means the "register once" rule broke.
  • Its menu is readable without the panel: gdbus call --session --dest org.kde.StatusNotifierItem-<pid>-1 --object-path /MenuBar --method com.canonical.dbusmenu.GetLayout -- 0 -1 "[]" returns the root node with the two children (the -- keeps gdbus from reading the -1 as an option, and the empty array is the property-name list GetLayout expects). Drive a click the way the panel does — batched, as EventGroup, because that is the path a real click takes: gdbus call … --method com.canonical.dbusmenu.EventGroup "[(1, 'clicked', <''>, 0)]" shows the window, id 2 quits the program (the empty string is the variant v of the event; <''> is the only spelling gdbus accepts). A single Event call works too but is not what the watcher sends.
  • A start with --start-hidden (what the autostart entry passes) is checkable the same way: no window in wmctrl -lp while the item above is registered and the process stays, and the menu's Show brings the window up. Without a tray watcher the window has to appear by itself after ~5 s — on a private bus that is awkward to see, because a fresh dbus-daemon makes GTK's startup wait on gvfs/portal services (GIO_USE_VFS=local unblocks it, and it can still take ~20 s), so on_activate runs much later than on the session bus. Do not read "no window" as the flag working until the tray item or the window object (Window.GetRows) proves the app got that far.
  • The watcher is fragile by construction: it walks the layout it mirrors, so a malformed node (bare ids instead of full nodes) aborts it. After a change to the layout, check it survived (pgrep -a xapp-sn-watcher) and that no new coredumpctl list xapp-sn-watcher entry appeared.
  • Minimize-to-tray is testable without the WM: send the WM_CHANGE_STATE client message (IconicState = 3) to the window id from wmctrl -l with python-xlib (root.send_event(..., SubstructureRedirect|SubstructureNotify)) — wmctrl -b add,hidden does not work, the WM ignores a client-set state. With the option on, the window disappears from wmctrl -l within ~250 ms (the poll) and the process stays; Show from the menu brings it back. With the option off, a close quits the process.
  • The help and options windows are plain GtkWindows in the same process, so they are reachable over their app actions: gdbus call --session --dest dev.rager.gkeymapper --object-path /dev/rager/gkeymapper --method org.gtk.Actions.Activate help "[]" "{}" (same with options), then screenshot with import -window $(wmctrl -l | grep -i Hilfe | awk '{print $1}') …. The options switch can be clicked with xtest at xwininfo's absolute position plus its offset in the window (~422, 56), and the result is visible in gui.conf and in the close behavior right away, no restart.
  • The language dropdown sits at about (82, 192) from the options window's top-left, and its popup entries follow about 25 px apart (the list opens below the widget). Check the result in three places: gui.conf (language=de), the rebuilt window, and the tray menu (GetLayout then says "Anzeigen"). Writing language=en into gui.conf and starting the GUI covers the startup path without a click; a language= (empty value) is "follow the session". One pitfall while clicking: the options window is transient for the main window, so it always stays above it — clicks meant for the main window hit the options window unless it is closed first.
  • The popups (src/popovers.cpp) are checkable without hardware, and a click test has to know two things: a popup is an override-redirect window that disappears from xwininfo's map state (or a viewable.py-style walk) but never appears in wmctrl -l, and a click on the dropdown that is already open closes it again — so "click the dropdown" does not mean "the popup is open", always read the map state. What to check: with a type dropdown open, a click on a different window must arrive there (_NET_ACTIVE_WINDOW changes to that client, which proves the press was delivered and not swallowed) and the popup must be gone; the same for a click on the bare desktop; a click in the window outside the popup closes it without changing the focus; Escape closes it; a click on an entry closes it and changes the row (Window.GetRows, e.g. G1=key:… becomes G1=command:…); a click on the header bar's menu button opens the menu and behaves like the dropdown. Every synthetic click must be guarded by the id of the window that is expected to be topmost — the popup is not in _NET_CLIENT_LIST_STACKING, so a click aimed at the popup has to be checked against the window below it.
  • The autostart switch needs no click to read and one click to write, because the .desktop entry is the state: put $HOME/.config/autostart/gkeymapper.desktop there (or delete it), close the options window (wmctrl -i -c <id>) and activate the options action again — the switch shows what the file says. A single xtest click below the tray switch (about (428, 149) from the window's top-left; find it by looking for the accent blue, the tray switch is grey when gui.conf has no minimizeToTray) writes or removes the file right away. Use a throwaway HOME — with the real one the next login would start the repo build. src/autostart.cpp also compiles on its own (glib only, plus -DGETTEXT_PACKAGE), which is the fastest way to cover the round trip and the two "disabled" spellings (Hidden=true, X-GNOME-Autostart-enabled=false).
  • A daemon that stops and starts again (systemctl --user stop/start gkeymapper-daemon) is the cheapest way to see the options window update while it is open (the rows change, the switches must not).