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

597 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```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 <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 `GtkWindow`s 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).