2026-10-17 02:42:09 +02:00
|
|
|
|
# AGENTS.md
|
|
|
|
|
|
|
|
|
|
|
|
## What this is
|
2026-10-17 03:53:51 +02:00
|
|
|
|
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/`.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
|
|
|
|
|
|
## Build / run / verify
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- `make` — builds the `gkeymapper` GUI, the `gkeymapper-daemon` and the `.mo`
|
|
|
|
|
|
catalogs. Needs `g++` (C++17), `make`, `gettext`, `libgtk-4-dev`,
|
2026-10-18 02:57:29 +02:00
|
|
|
|
`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).
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- `make translations` — only runs `msgfmt` on the existing `.po` files.
|
|
|
|
|
|
- `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and
|
2026-10-18 02:57:29 +02:00
|
|
|
|
`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.
|
2026-10-18 22:53:39 +02:00
|
|
|
|
- `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.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- 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
|
2026-10-18 20:26:22 +02:00
|
|
|
|
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.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- All documentation and source comments are English. German appears only inside
|
|
|
|
|
|
`po/de.po`.
|
|
|
|
|
|
|
|
|
|
|
|
## Code layout and conventions
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- 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
|
2026-10-18 02:57:29 +02:00
|
|
|
|
key-name table (lowercase GDK names -> Linux `KEY_*`), `shared/ipc_ids.h` the
|
2026-10-18 18:26:06 +02:00
|
|
|
|
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),
|
2026-10-18 20:26:22 +02:00
|
|
|
|
`settings` (`gui.conf`), `autostart` (the session's `.desktop` entry), `help`,
|
|
|
|
|
|
`optionsdialog` and `popovers` (the popup input grab, see below).
|
2026-10-18 02:57:29 +02:00
|
|
|
|
- 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.
|
2026-10-18 03:50:33 +02:00
|
|
|
|
- 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`
|
2026-10-18 16:59:51 +02:00
|
|
|
|
(dispatch `key`/`command`/`disabled`/`keyhold`), `config_watch` (inotify auto-reload of
|
2026-10-18 03:50:33 +02:00
|
|
|
|
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.
|
2026-10-18 16:59:51 +02:00
|
|
|
|
- `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.
|
2026-10-18 03:50:33 +02:00
|
|
|
|
- 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).
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- `make install` (PREFIX default `/usr/local`) also installs the packaging
|
2026-10-18 02:57:29 +02:00
|
|
|
|
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`.
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- 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.
|
2026-10-17 02:57:21 +02:00
|
|
|
|
- `main.cpp` keeps the `rows` vector (one `KeyRow` per G key, widget pointers)
|
2026-10-17 03:53:51 +02:00
|
|
|
|
in an anonymous namespace; `src/mappings.cpp` is GUI glue that moves data
|
2026-10-18 03:50:33 +02:00
|
|
|
|
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:
|
2026-10-18 02:57:29 +02:00
|
|
|
|
`dev.rager.gkeymapper` (`ipc::kAppId`); the About dialog uses `ipc::kVersion`.
|
2026-10-18 18:26:06 +02:00
|
|
|
|
- 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.
|
2026-10-18 02:57:29 +02:00
|
|
|
|
- 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`.
|
2026-10-18 16:59:51 +02:00
|
|
|
|
- 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
|
2026-10-18 20:26:22 +02:00
|
|
|
|
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.
|
2026-10-18 16:59:51 +02:00
|
|
|
|
- `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.
|
2026-10-18 18:26:06 +02:00
|
|
|
|
- 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
|
2026-10-18 20:26:22 +02:00
|
|
|
|
`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.
|
2026-10-18 18:26:06 +02:00
|
|
|
|
- `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`.
|
2026-10-18 02:57:29 +02:00
|
|
|
|
- `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.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
|
2026-10-18 16:59:51 +02:00
|
|
|
|
`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).
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- 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.
|
2026-10-18 20:26:22 +02:00
|
|
|
|
- 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).
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- 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:
|
2026-10-18 16:59:51 +02:00
|
|
|
|
G4=keyhold:ctrl+w
|
2026-10-17 02:42:09 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`<type>:<target>`, one line per key. `key` is a captured sequence (lowercase
|
|
|
|
|
|
`gdk_keyval_name` output; modifiers joined with `+`, steps joined with `,`),
|
2026-10-18 16:59:51 +02:00
|
|
|
|
`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
|
2026-10-17 03:53:51 +02:00
|
|
|
|
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`.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
|
|
|
|
|
|
## Assets
|
2026-10-18 02:57:29 +02:00
|
|
|
|
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.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
|
2026-10-17 03:53:51 +02:00
|
|
|
|
## 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.
|
2026-10-18 02:57:29 +02:00
|
|
|
|
- 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.
|
2026-10-18 03:50:33 +02:00
|
|
|
|
- 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.
|
2026-10-18 16:59:51 +02:00
|
|
|
|
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.
|
2026-10-18 02:57:29 +02:00
|
|
|
|
- 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.
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- `make install DESTDIR=/tmp/stage` stages the packaging files; inspect
|
2026-10-18 02:57:29 +02:00
|
|
|
|
`/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
|
2026-10-17 03:53:51 +02:00
|
|
|
|
the reference project's rule.
|
|
|
|
|
|
- Starting it detaches the kernel driver from the vendor interface and creates a
|
2026-10-18 03:50:33 +02:00
|
|
|
|
`/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
|
2026-10-18 16:59:51 +02:00
|
|
|
|
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.
|
2026-10-18 03:50:33 +02:00
|
|
|
|
- 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`).
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- 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`.
|
|
|
|
|
|
|
2026-10-17 02:42:09 +02:00
|
|
|
|
## 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.
|
2026-10-18 02:57:29 +02:00
|
|
|
|
|
2026-10-18 16:59:51 +02:00
|
|
|
|
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
|
2026-10-18 20:26:22 +02:00
|
|
|
|
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.
|
2026-10-18 16:59:51 +02:00
|
|
|
|
|
2026-10-18 02:57:29 +02:00
|
|
|
|
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
|
2026-10-18 16:59:51 +02:00
|
|
|
|
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:
|
2026-10-18 03:50:33 +02:00
|
|
|
|
- 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
|
2026-10-18 20:26:22 +02:00
|
|
|
|
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.
|
2026-10-18 02:57:29 +02:00
|
|
|
|
- `XTest`-synthesized clicks need the window mapped and focused; the `Save`
|
|
|
|
|
|
button is `push button` + name `Mappings speichern` in the German locale.
|
2026-10-18 18:26:06 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-18 20:26:22 +02:00
|
|
|
|
/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:
|
2026-10-18 18:26:06 +02:00
|
|
|
|
`gdbus call … --method com.canonical.dbusmenu.EventGroup
|
2026-10-18 20:26:22 +02:00
|
|
|
|
"[(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.
|
2026-10-18 18:26:06 +02:00
|
|
|
|
- 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.
|
2026-10-18 20:26:22 +02:00
|
|
|
|
- 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.
|
2026-10-18 18:26:06 +02:00
|
|
|
|
- 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).
|