Embedd assets icons

This commit is contained in:
2026-10-18 02:57:29 +02:00
parent b77b4a3fc6
commit 726a7ae92d
19 changed files with 1202 additions and 126 deletions

140
AGENTS.md
View File

@@ -10,16 +10,18 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
## 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` and `libxkbcommon-dev` (see README for `apt install` on a
fresh machine).
- `make daemon` — builds only `gkeymapper-daemon` (needs libusb + xkbcommon,
not GTK).
`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 and
assets are used. Plain `./gkeymapper` works too (assets fall back to
`./assets`).
- `make clean` — deletes the binary and the generated `locale/` tree.
`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.
- No tests and no CI. Verify changes by building and running the app.
## i18n — easy to get wrong
@@ -37,20 +39,24 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
- 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_*`).
- The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp`)
**and** the daemon (`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`
and `libxkbcommon-dev`). `make daemon` builds only the daemon.
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 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),
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
keyboard), `action` (dispatch `key`/`command`/`disabled`) and `config_watch`
(inotify auto-reload of the mappings file). `shared/keymap` does the layout
lookups for both sides. The hot path never touches the GUI.
keyboard), `action` (dispatch `key`/`command`/`disabled`), `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.
- `make install` (PREFIX default `/usr/local`) also installs the packaging
files from `data/`: the udev rule, the `modules-load.d` fragment and the
systemd **user** unit. The unit contains an `@BINDIR@` placeholder that the
Makefile substitutes with `$(PREFIX)/bin`. Keep it a user service — it needs
the session for `/dev/uinput` and `DISPLAY`.
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
@@ -58,7 +64,27 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
- `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`. App id:
`com.example.gkeymapper`.
`dev.rager.gkeymapper` (`ipc::kAppId`); the About dialog uses `ipc::kVersion`.
- GUI ↔ daemon is D-Bus on the session bus, GDBus (GIO) on both sides:
`src/daemonclient.{h,cpp}` is the client, `daemon/ipc_dbus.{h,cpp}` the
service. The daemon 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`.
- 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.
- `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`, matching `ActionType` and the `key`/`command`/`disabled` tokens
in the file. Keep that order.
@@ -113,9 +139,20 @@ tracks `ISO_Level3_Shift` / `Mode_switch` through its own key press/release
`kModAltGr` maps to `KEY_RIGHTALT`.
## Assets
`find_asset()` resolves in order: `$GKEYMAPPER_ASSETS_DIR` → `./assets` →
compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). The icon
(`assets/icon.png`) is used in the header bar and the About dialog.
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
@@ -130,15 +167,44 @@ compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). The icon
(`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.
- Test the auto-reload without hardware: write the config through the shared
save path (atomic temp+rename, as the GUI does) and watch `/tmp/daemon.log`
for a second `loaded ...` line. `daemon/config_watch.cpp` also has a
standalone test shape: create a `ConfigWatcher`, `poll_events()` then
`take_change()` per simulated loop iteration.
- 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.
- 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/...` to verify the unit's `ExecStart` 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
`/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: while it runs the media keys may
@@ -158,3 +224,15 @@ 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 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 GUI from an earlier build may be running from the
same binary name. Two notes for tests:
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real
and the GUI does **not** load that file at startup, so a save from an
unconfigured window replaces the user's mappings with empty ones. Back the file
up first (or skip the click and call `Reload` over D-Bus instead).
- `XTest`-synthesized clicks need the window mapped and focused; the `Save`
button is `push button` + name `Mappings speichern` in the German locale.

View File

@@ -6,6 +6,7 @@ CXX ?= g++
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic
PKG_CONFIG ?= pkg-config
MSGFMT ?= msgfmt
GLIB_COMPILE_RESOURCES ?= glib-compile-resources
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4)
@@ -13,6 +14,8 @@ USB_CFLAGS := $(shell $(PKG_CONFIG) --cflags libusb-1.0)
USB_LIBS := $(shell $(PKG_CONFIG) --libs libusb-1.0)
XKB_CFLAGS := $(shell $(PKG_CONFIG) --cflags xkbcommon)
XKB_LIBS := $(shell $(PKG_CONFIG) --libs xkbcommon)
GIO_CFLAGS := $(shell $(PKG_CONFIG) --cflags gio-2.0)
GIO_LIBS := $(shell $(PKG_CONFIG) --libs gio-2.0)
PACKAGE := gkeymapper
TARGET := gkeymapper
@@ -24,6 +27,9 @@ GUI_SOURCES := $(wildcard $(SRC_DIR)/*.cpp) $(wildcard $(SHARED_DIR)/*.cpp)
GUI_HEADERS := $(wildcard $(SRC_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
DAEMON_SOURCES := $(wildcard $(DAEMON_DIR)/*.cpp) $(wildcard $(SHARED_DIR)/*.cpp)
DAEMON_HEADERS := $(wildcard $(DAEMON_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
# Icons embedded into the GUI (generated from data/icons.gresource.xml).
GUI_RES := build/gresources.c
GUI_RES_SRC := data/icons.gresource.xml
PREFIX ?= /usr/local
BINDIR := $(PREFIX)/bin
LOCALEDIR := $(PREFIX)/share/locale
@@ -31,6 +37,7 @@ ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets
UDEV_DIR := $(PREFIX)/lib/udev/rules.d
MODULES_DIR := $(PREFIX)/lib/modules-load.d
UNIT_DIR := $(PREFIX)/lib/systemd/user
DBUS_DIR := $(PREFIX)/share/dbus-1/services
CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\"
CXXFLAGS += -I$(SHARED_DIR)
@@ -44,13 +51,20 @@ MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS))
all: $(TARGET) translations $(DAEMON)
$(TARGET): $(GUI_SOURCES) $(GUI_HEADERS)
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) $(XKB_CFLAGS) -o $@ $(GUI_SOURCES) $(GTK_LIBS) $(XKB_LIBS)
# Embedded GUI icons. --c-name keeps the gkeymapper_* symbols stable; --sourcedir
# is the repository root because the XML refers to the assets by their real path.
$(GUI_RES): $(GUI_RES_SRC) assets/icon.png assets/icon.svg
@mkdir -p $(@D)
$(GLIB_COMPILE_RESOURCES) --generate-source --target=$@ --sourcedir=. \
--c-name $(PACKAGE) $(GUI_RES_SRC)
$(TARGET): $(GUI_SOURCES) $(GUI_HEADERS) $(GUI_RES)
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) $(XKB_CFLAGS) -o $@ $(GUI_SOURCES) $(GUI_RES) $(GTK_LIBS) $(XKB_LIBS)
daemon: $(DAEMON)
$(DAEMON): $(DAEMON_SOURCES) $(DAEMON_HEADERS)
$(CXX) $(CXXFLAGS) $(USB_CFLAGS) $(XKB_CFLAGS) -o $@ $(DAEMON_SOURCES) $(USB_LIBS) $(XKB_LIBS)
$(CXX) $(CXXFLAGS) $(USB_CFLAGS) $(XKB_CFLAGS) $(GIO_CFLAGS) -o $@ $(DAEMON_SOURCES) $(USB_LIBS) $(XKB_LIBS) $(GIO_LIBS)
translations: $(MO_FILES)
@@ -63,7 +77,7 @@ run: $(TARGET) translations
clean:
rm -f $(TARGET) $(DAEMON)
rm -rf locale
rm -rf locale build
install: $(TARGET) $(DAEMON) translations
install -d $(DESTDIR)$(BINDIR)
@@ -94,6 +108,10 @@ install: $(TARGET) $(DAEMON) translations
install -d $(DESTDIR)$(UNIT_DIR)
sed 's|@BINDIR@|$(BINDIR)|g' data/$(DAEMON).service > $(DESTDIR)$(UNIT_DIR)/$(DAEMON).service
chmod 644 $(DESTDIR)$(UNIT_DIR)/$(DAEMON).service
# D-Bus activation: the first GUI call can start the daemon on demand.
install -d $(DESTDIR)$(DBUS_DIR)
sed 's|@BINDIR@|$(BINDIR)|g' data/dev.rager.gkeymapper.Daemon.service > $(DESTDIR)$(DBUS_DIR)/dev.rager.gkeymapper.Daemon.service
chmod 644 $(DESTDIR)$(DBUS_DIR)/dev.rager.gkeymapper.Daemon.service
-command -v udevadm >/dev/null && udevadm control --reload-rules 2>/dev/null || true
-systemctl --user daemon-reload 2>/dev/null || true
@echo

View File

@@ -3,8 +3,10 @@
GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910**
to other keys or commands (Linux Mint / Ubuntu).
The GUI is complete. A background daemon (`gkeymapper-daemon`) that reads the
G-keys from the keyboard is in progress — see `docs/daemon-plan.md`.
The GUI is complete. The background daemon (`gkeymapper-daemon`) that reads the
G-keys from the keyboard and replays the actions is implemented as well; the
GUI and the daemon talk over a small D-Bus channel (status, reload, test) — see
`docs/daemon-plan.md`.
Documentation and source comments are written in English.
@@ -17,16 +19,18 @@ Documentation and source comments are written in English.
- GTK 4 development files
- libusb 1.0 development files (for the daemon)
- libxkbcommon development files (for the daemon's layout handling)
- GLib/GIO development files (for the GUI ↔ daemon D-Bus channel; `libgtk-4-dev`
already pulls them in, but they are needed for `make daemon` on its own)
### Install dependencies
```bash
sudo apt update
sudo apt install -y build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev
sudo apt install -y build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev libglib2.0-dev
```
Afterwards `pkg-config --modversion gtk4` and
`pkg-config --modversion libusb-1.0` should print versions.
Afterwards `pkg-config --modversion gtk4`, `pkg-config --modversion libusb-1.0`
and `pkg-config --modversion gio-2.0` should print versions.
## Setting up the project
@@ -46,7 +50,9 @@ make
This produces the `gkeymapper` GUI binary, the `gkeymapper-daemon` binary and
the `.mo` translations under `locale/`. The daemon can also be built on its own
with `make daemon`.
with `make daemon`. The GUI has its icons (`assets/icon.png`, `assets/icon.svg`)
compiled in (`data/icons.gresource.xml` → generated `build/gresources.c`), so the
window icon works without an installed `assets/` directory.
## Rebuild translations
@@ -70,6 +76,10 @@ LANG=de_DE.UTF-8 GKEYMAPPER_LOCALEDIR=$PWD/locale ./gkeymapper
Supported languages: German, English, Spanish, French.
The footer of the window shows the daemon state (`Daemon: running · Device: …`
plus the last G key that fired, or `Daemon: not reachable`) and follows it live:
start or stop the daemon while the GUI is open and the line updates itself.
## Running the daemon
The daemon reads G1–G9 from the keyboard and replays the configured action
@@ -101,6 +111,9 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
`pgrep -x gkeymapper-daemon` does **not** match.
- A failed reload keeps the previous mappings, so a transient read error never
silently disables every key.
- Only one instance may run: a second one prints why it cannot get the bus name
and exits, instead of fighting over the keyboard and creating a second virtual
keyboard.
- Key names are layout-independent: `key:ctrl+z` means the key that produces
`z` **on your layout**. The layout is read from `/etc/default/keyboard`
(`XKBLAYOUT`); without it, US key positions are used. Because the name is
@@ -113,6 +126,34 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
Stop it with Ctrl+C or `kill`; it reattaches the kernel driver on the way out.
### Status and control over D-Bus
The daemon owns the name `dev.rager.gkeymapper.Daemon` on the session bus. The
GUI uses that channel for its footer line ("Daemon: running / not reachable",
the device and the last G key that fired) and to apply a save immediately.
Nothing on this channel is on the keypress path, so a GUI that is slow or absent
never delays an action.
```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 / DeviceChanged
```
- `GetStatus` → `(version, running, device, configPath)`.
- `Reload` re-reads the mappings file; `ConfigReloaded(ok)` reports the result.
- `TestMapping` fires a mapping without touching the keyboard (useful to check a
sequence). The index is **0-based**: `0` = G1 … `8` = G9. It runs the same code
path as a real keypress.
- `KeyPressed(index, action)` is emitted for every G key, real or tested.
If no session bus is available the daemon still works — it just runs without the
GUI channel and logs that.
## Install (optional)
```bash
@@ -129,6 +170,7 @@ Installs, under `PREFIX` (default `/usr/local`):
| `lib/udev/rules.d/60-gkeymapper.rules` | lets your user reach the keyboard and `/dev/uinput` |
| `lib/modules-load.d/gkeymapper.conf` | loads the `uinput` module at boot |
| `lib/systemd/user/gkeymapper-daemon.service` | systemd user unit (binary path filled in from `PREFIX`) |
| `share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` | D-Bus activation: the GUI can start the daemon on demand |
Then enable the daemon for your session — it then starts automatically at login:
@@ -138,6 +180,10 @@ systemctl --user enable --now gkeymapper-daemon
systemctl --user status gkeymapper-daemon
```
Enabling it is optional: the D-Bus activation file above lets the bus start the
daemon as soon as the GUI asks for its status. With both in place the unit is
used either way, so the daemon never runs twice.
It is a **user** service on purpose: it needs your session for `/dev/uinput` and
so that a `command` action inherits `DISPLAY`/`WAYLAND_DISPLAY`. The unit does
not run as root. `make install` reloads the udev rules for you; on a fresh
@@ -149,7 +195,7 @@ machine replug the keyboard (or reboot) once so the ACL applies.
make clean
```
Removes the binary and the generated `locale/` directory.
Removes the binaries and the generated `locale/` and `build/` directories.
## Capturing key sequences
@@ -194,22 +240,26 @@ never sees a half-written file and the watch reloads exactly once per save.
## Project structure
```
src/main.cpp # app actions + GUI
src/main.cpp # app actions + GUI + daemon status footer
src/keyrow.h # KeyRow (one per G key)
src/mappings.{h,cpp} # GUI glue: config file <-> row widgets
src/capturedialog.{h,cpp} # key-capture dialog
src/daemonclient.{h,cpp} # D-Bus client for the daemon (status/reload)
shared/mapping.{h,cpp} # mappings format + shortcut parsing (GUI & daemon)
shared/keys.{h,cpp} # canonical key names -> Linux key codes
shared/keymap.{h,cpp} # layout-aware key lookups (xkbcommon), GUI + daemon
shared/ipc_ids.h # app id + D-Bus names shared by GUI and daemon
daemon/main.cpp # event loop: read G-keys, run the action
daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910)
daemon/g910_device.{h,cpp} # libusb access to the vendor interface
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled"
daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload)
daemon/ipc_dbus.{h,cpp} # D-Bus service (status/reload/test/signals)
data/60-gkeymapper.rules # udev rule (uaccess for device + /dev/uinput)
data/gkeymapper-uinput.conf # modules-load.d fragment for uinput
data/gkeymapper-daemon.service# systemd user unit (installed by make install)
data/dev.rager.gkeymapper.Daemon.service # D-Bus activation (session bus)
docs/daemon-plan.md # daemon plan (reference: g910-gkey-macro-support)
po/ # Translations (pot/po/mo targets)
Makefile # Build via pkg-config + gettext
@@ -244,7 +294,7 @@ export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:
## Planned next steps
- Daemon ↔ GUI channel (D-Bus): reload on save and show the daemon status.
- Support the media keys and the M1–M3 / MR keys of the G910.
- udev rule and systemd user service so the daemon can autostart.
- `make install` for the daemon binary, the udev rule and the service file.
- Profiles: swap mapping sets on the fly (the G910's M keys), which is what the
reserved `SetProfile` D-Bus method was meant for.
- Key sequence timing: per-step delays for recorded macros.

219
daemon/ipc_dbus.cpp Normal file
View File

@@ -0,0 +1,219 @@
// gKeyMapper daemon - D-Bus service for the GUI (control plane only).
#include "ipc_dbus.h"
#include <cstdio>
#include "ipc_ids.h"
#include "mapping.h"
namespace gkeymapper::daemon {
namespace {
// Index convention: 0 = G1 ... 8 = G9, the same as the internal mapping vector
// and the KeyPressed signal, so GUI and CLI agree on one numbering.
const char kIntrospectionXml[] =
"<node>"
" <interface name='dev.rager.gkeymapper.Daemon'>"
" <method name='Reload'/>"
" <method name='GetStatus'>"
" <arg type='s' name='version' direction='out'/>"
" <arg type='b' name='running' direction='out'/>"
" <arg type='s' name='device' direction='out'/>"
" <arg type='s' name='configPath' direction='out'/>"
" </method>"
" <method name='TestMapping'>"
" <arg type='u' name='index' direction='in'/>"
" </method>"
" <signal name='KeyPressed'>"
" <arg type='u' name='index'/>"
" <arg type='s' name='action'/>"
" </signal>"
" <signal name='ConfigReloaded'>"
" <arg type='b' name='ok'/>"
" </signal>"
" <signal name='DeviceChanged'>"
" <arg type='b' name='present'/>"
" <arg type='s' name='device'/>"
" </signal>"
" </interface>"
"</node>";
} // namespace
DaemonService::~DaemonService() {
if (owner_ != 0) {
g_bus_unown_name(owner_);
}
if (registration_ != 0 && connection_ != nullptr) {
g_dbus_connection_unregister_object(connection_, registration_);
}
if (connection_ != nullptr) {
g_object_unref(connection_);
}
}
StartResult DaemonService::start(std::string configPath) {
configPath_ = std::move(configPath);
GError* error = nullptr;
connection_ = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
if (connection_ == nullptr) {
std::fprintf(stderr,
"gkeymapper-daemon: no session bus (%s); running without "
"the GUI channel\n",
error != nullptr ? error->message : "unknown");
g_clear_error(&error);
return StartResult::NoBus;
}
GDBusNodeInfo* info = g_dbus_node_info_new_for_xml(kIntrospectionXml, &error);
if (info == nullptr) {
std::fprintf(stderr, "gkeymapper-daemon: bad D-Bus introspection: %s\n",
error->message);
g_clear_error(&error);
return StartResult::NoBus;
}
GDBusInterfaceVTable vtable{};
vtable.method_call = &DaemonService::handle_method_call;
registration_ = g_dbus_connection_register_object(
connection_, gkeymapper::ipc::kObjectPath, info->interfaces[0], &vtable, this,
nullptr, &error);
g_dbus_node_info_unref(info);
if (registration_ == 0) {
std::fprintf(stderr, "gkeymapper-daemon: cannot register object: %s\n",
error->message);
g_clear_error(&error);
return StartResult::NoBus;
}
// DO_NOT_QUEUE: if the name is taken, fail instead of waiting. Two daemons
// would fight over the keyboard and create two virtual keyboards.
owner_ = g_bus_own_name_on_connection(
connection_, gkeymapper::ipc::kBusName,
G_BUS_NAME_OWNER_FLAGS_DO_NOT_QUEUE,
[](GDBusConnection*, const gchar*, gpointer userData) {
static_cast<DaemonService*>(userData)->name_owned_ = true;
},
[](GDBusConnection*, const gchar* name, gpointer userData) {
auto* self = static_cast<DaemonService*>(userData);
self->name_lost_ = true;
std::fprintf(stderr, "gkeymapper-daemon: bus name %s is taken\n", name);
},
this, nullptr);
// Let the name request complete before deciding.
for (int i = 0; i < 100 && !name_owned_ && !name_lost_; ++i) {
g_main_context_iteration(nullptr, FALSE);
g_usleep(10 * 1000);
}
if (!name_owned_ && name_lost_) {
return StartResult::AlreadyRunning;
}
std::fprintf(stderr, "gkeymapper-daemon: listening on %s (%s)\n",
gkeymapper::ipc::kBusName, gkeymapper::ipc::kObjectPath);
return StartResult::Ok;
}
void DaemonService::pump() {
if (connection_ == nullptr) {
return;
}
while (g_main_context_iteration(nullptr, FALSE)) {
// Drain everything that is ready; nothing else uses this context.
}
}
void DaemonService::handle_method_call(GDBusConnection* /*connection*/, const gchar* /*sender*/,
const gchar* /*objectPath*/, const gchar* /*interfaceName*/,
const gchar* methodName, GVariant* parameters,
GDBusMethodInvocation* invocation, gpointer userData) {
auto* self = static_cast<DaemonService*>(userData);
if (g_strcmp0(methodName, "Reload") == 0) {
if (self->reload_) {
self->reload_();
}
g_dbus_method_invocation_return_value(invocation, nullptr);
return;
}
if (g_strcmp0(methodName, "GetStatus") == 0) {
const std::string device = self->device_ ? self->device_() : std::string();
g_dbus_method_invocation_return_value(
invocation, g_variant_new("(sbss)", gkeymapper::ipc::kVersion, TRUE,
device.c_str(), self->configPath_.c_str()));
return;
}
if (g_strcmp0(methodName, "TestMapping") == 0) {
guint32 index = 0;
g_variant_get(parameters, "(u)", &index);
if (index >= static_cast<guint32>(gkeymapper::kNumKeys)) {
g_dbus_method_invocation_return_error(
invocation, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"no G%u (index 0..%d)", index + 1, gkeymapper::kNumKeys - 1);
return;
}
std::string action;
if (!self->test_ || !self->test_(static_cast<int>(index), action)) {
g_dbus_method_invocation_return_error(invocation, G_IO_ERROR, G_IO_ERROR_FAILED,
"G%u has no usable mapping", index + 1);
return;
}
g_dbus_method_invocation_return_value(invocation, nullptr);
self->notify_key_pressed(static_cast<int>(index), action);
return;
}
g_dbus_method_invocation_return_error(invocation, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"unknown method %s", methodName);
}
void DaemonService::notify_key_pressed(int index, const std::string& action) {
if (connection_ == nullptr) {
return;
}
GError* error = nullptr;
g_dbus_connection_emit_signal(
connection_, nullptr, gkeymapper::ipc::kObjectPath, gkeymapper::ipc::kInterface,
"KeyPressed",
g_variant_new("(us)", static_cast<guint32>(index), action.c_str()), &error);
if (error != nullptr) {
g_clear_error(&error);
}
}
void DaemonService::notify_config_reloaded(bool ok) {
if (connection_ == nullptr) {
return;
}
GError* error = nullptr;
g_dbus_connection_emit_signal(connection_, nullptr, gkeymapper::ipc::kObjectPath,
gkeymapper::ipc::kInterface, "ConfigReloaded",
g_variant_new("(b)", ok ? TRUE : FALSE), &error);
if (error != nullptr) {
g_clear_error(&error);
}
}
void DaemonService::notify_device_changed(bool present, const std::string& device) {
if (connection_ == nullptr) {
return;
}
GError* error = nullptr;
g_dbus_connection_emit_signal(
connection_, nullptr, gkeymapper::ipc::kObjectPath, gkeymapper::ipc::kInterface,
"DeviceChanged", g_variant_new("(bs)", present ? TRUE : FALSE, device.c_str()),
&error);
if (error != nullptr) {
g_clear_error(&error);
}
}
} // namespace gkeymapper::daemon

74
daemon/ipc_dbus.h Normal file
View File

@@ -0,0 +1,74 @@
// gKeyMapper daemon - D-Bus service for the GUI (control plane only).
#ifndef GKEYMAPPER_DAEMON_IPC_DBUS_H
#define GKEYMAPPER_DAEMON_IPC_DBUS_H
#include <gio/gio.h>
#include <functional>
#include <string>
namespace gkeymapper::daemon {
enum class StartResult {
Ok, // the well-known name is owned, calls are served
NoBus, // no usable session bus; the daemon runs without IPC
AlreadyRunning, // another instance owns the name; this process must exit
};
// The daemon's control-plane interface on the session bus.
//
// The GUI uses it for status and reload; `TestMapping` also makes a mapping
// fireable from the command line (gdbus/busctl) without hardware. Nothing here
// is on the keypress path.
class DaemonService {
public:
// Re-reads the mappings file; returns whether it could be read.
using ReloadFn = std::function<bool()>;
// Description of the keyboard for GetStatus ("" when none is open).
using DeviceFn = std::function<std::string()>;
// Runs the mapping of G<index+1> (index is 0-based) and reports what ran.
using TestFn = std::function<bool(int index, std::string& action)>;
DaemonService() = default;
~DaemonService();
DaemonService(const DaemonService&) = delete;
DaemonService& operator=(const DaemonService&) = delete;
void set_reload(ReloadFn fn) { reload_ = std::move(fn); }
void set_device(DeviceFn fn) { device_ = std::move(fn); }
void set_test(TestFn fn) { test_ = std::move(fn); }
StartResult start(std::string configPath);
bool is_connected() const { return connection_ != nullptr; }
// Dispatches pending messages. Non-blocking and cheap; call it once per
// event-loop iteration. (Outgoing signals are flushed here too, so a
// notification is visible to the GUI within one loop pass.)
void pump();
void notify_config_reloaded(bool ok);
void notify_key_pressed(int index, const std::string& action);
void notify_device_changed(bool present, const std::string& device);
private:
static void handle_method_call(GDBusConnection* connection, const gchar* sender,
const gchar* objectPath, const gchar* interfaceName,
const gchar* methodName, GVariant* parameters,
GDBusMethodInvocation* invocation, gpointer userData);
GDBusConnection* connection_ = nullptr;
guint registration_ = 0;
guint owner_ = 0;
std::string configPath_;
bool name_owned_ = false;
bool name_lost_ = false;
ReloadFn reload_;
DeviceFn device_;
TestFn test_;
};
} // namespace gkeymapper::daemon
#endif // GKEYMAPPER_DAEMON_IPC_DBUS_H

View File

@@ -4,8 +4,8 @@
// configured action and replays it ("key" via uinput, "command" via exec).
//
// Milestones: M0 (shared core), M1 (device bring-up/decoding), M2 (action
// execution) and M3 (config watching + lifecycle) are done; the GUI channel
// (D-Bus) is still open (M4).
// execution), M3 (config watching + lifecycle) and M4 (D-Bus GUI channel) are
// done.
#include <csignal>
#include <cstdint>
@@ -17,6 +17,7 @@
#include "config_watch.h"
#include "device_tables.h"
#include "g910_device.h"
#include "ipc_dbus.h"
#include "mapping.h"
namespace {
@@ -50,14 +51,13 @@ int main(int argc, char** argv) {
// Reads the config; on failure the previous mappings stay in place, so a
// transient read error never silently disables every key.
auto reload = [&]() {
if (gkeymapper::load_mappings_file(configPath, mappings)) {
std::fprintf(stderr, "gkeymapper-daemon: loaded %s\n", configPath.c_str());
} else {
std::fprintf(stderr, "gkeymapper-daemon: cannot read %s\n", configPath.c_str());
}
auto load = [&]() -> bool {
const bool ok = gkeymapper::load_mappings_file(configPath, mappings);
std::fprintf(stderr, "gkeymapper-daemon: %s %s\n",
ok ? "loaded" : "cannot read", configPath.c_str());
return ok;
};
reload();
load();
// The GUI is the writer; a save is picked up through inotify, SIGHUP/SIGUSR1
// stay as the fallback for scripts and for systems without inotify.
@@ -67,17 +67,78 @@ int main(int argc, char** argv) {
gkeymapper::daemon::ActionRunner actions;
actions.init();
// Declared before the IPC service so its status callback can report the
// device. It is opened only after the name check below: a second instance
// must not detach the kernel driver from the keyboard the running one uses.
gkeymapper::daemon::GKeyDevice device;
const gkeymapper::daemon::DeviceProfile* profile = nullptr;
// Runs the mapping of G<index+1> (0-based). A real keypress and the D-Bus
// TestMapping call both go through here, so a test fires exactly the same
// path. Returns whether there was an action to run.
auto run_mapping = [&](int index, bool test) -> bool {
if (index < 0 || index >= static_cast<int>(mappings.keys.size())) {
return false;
}
const gkeymapper::Mapping& mapping = mappings.keys[index];
const bool usable = mapping.type != gkeymapper::ActionType::Disabled &&
!mapping.target.empty();
std::printf("G%d -> %s:%s%s\n", index + 1,
gkeymapper::action_type_token(mapping.type), mapping.target.c_str(),
test ? " (test)" : "");
std::fflush(stdout);
if (usable) {
actions.run(mapping);
}
return usable;
};
gkeymapper::daemon::DaemonService service;
auto reload = [&]() -> bool {
const bool ok = load();
service.notify_config_reloaded(ok);
return ok;
};
service.set_reload(reload);
service.set_device([&]() {
return profile != nullptr ? std::string(profile->name) : std::string();
});
service.set_test([&](int index, std::string& action) {
if (!run_mapping(index, true)) {
return false;
}
const gkeymapper::Mapping& mapping = mappings.keys[index];
action = std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target;
return true;
});
switch (service.start(configPath)) {
case gkeymapper::daemon::StartResult::AlreadyRunning:
// Two instances would fight over the device and create a second
// virtual keyboard, so the newcomer gives way.
std::fprintf(stderr,
"gkeymapper-daemon: another instance is already running; "
"exiting\n");
return 0;
case gkeymapper::daemon::StartResult::NoBus:
case gkeymapper::daemon::StartResult::Ok:
break;
}
if (!device.open()) {
return 1;
}
device.disable_binding();
const gkeymapper::daemon::DeviceProfile* profile = device.profile();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
uint8_t buffer[64];
while (g_running) {
// The loop wakes up at least every read timeout, which also bounds how
// long a config change takes to be applied (well under a second).
// Control plane first (bounded by the read timeout below), then the
// keyboard. The loop wakes up at least every read timeout, which also
// bounds how long a config change takes to be applied.
service.pump();
watcher.poll_events();
if (g_reload || watcher.take_change()) {
g_reload = 0;
@@ -90,29 +151,32 @@ int main(int argc, char** argv) {
}
if (n < 0) {
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), reopening\n", n);
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
device.close();
if (!device.open()) {
return 1;
}
device.disable_binding();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
continue;
}
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
run_mapping(index, false);
const gkeymapper::Mapping& mapping = mappings.keys[index];
std::printf("G%d -> %s:%s\n", index + 1,
gkeymapper::action_type_token(mapping.type),
mapping.target.c_str());
std::fflush(stdout);
actions.run(mapping);
service.notify_key_pressed(
index, std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target);
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
// Key released; nothing to do.
}
// Media keys and LED acknowledgements are ignored.
}
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
service.pump();
device.close();
return 0;
}

View File

@@ -0,0 +1,10 @@
# gKeyMapper - D-Bus activation file (session bus).
#
# Installed by "make install", which fills in the binary directory for the
# configured PREFIX. It lets the GUI start the daemon on demand: the first
# method call on the name makes the bus ask systemd for the user unit below
# (the executable is started only if that fails).
[D-BUS Service]
Name=dev.rager.gkeymapper.Daemon
Exec=@BINDIR@/gkeymapper-daemon
SystemdService=gkeymapper-daemon.service

View File

@@ -12,7 +12,12 @@
Description=gKeyMapper daemon - G1..G9 remapping for the Logitech G910
[Service]
Type=simple
# Type=dbus: the unit counts as started once the daemon owns its bus name, so
# D-Bus activation (data/dev.rager.gkeymapper.Daemon.service) and the GUI see a
# consistent state. A second instance exits instead of fighting over the
# keyboard, which two of them would do.
Type=dbus
BusName=dev.rager.gkeymapper.Daemon
ExecStart=@BINDIR@/gkeymapper-daemon
# Reload the mappings without a restart (the config file is watched as well).
ExecReload=/bin/kill -HUP $MAINPID

16
data/icons.gresource.xml Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Icons compiled into the GUI executable (glib-compile-resources), so the
window has its icon even when no assets are installed. The prefix mirrors
the reverse-DNS app id (shared/ipc_ids.h); the icons/ subtree mirrors the
hicolor layout so gtk_icon_theme_add_resource_path() can serve
gtk_window_set_icon_name("gkeymapper") from the binary. The <file> paths are
relative to the repository root (--sourcedir=.). -->
<gresources>
<gresource prefix="/dev/rager/gkeymapper">
<file alias="icon.png">assets/icon.png</file>
</gresource>
<gresource prefix="/dev/rager/gkeymapper/icons">
<file alias="256x256/apps/gkeymapper.png">assets/icon.png</file>
<file alias="scalable/apps/gkeymapper.svg">assets/icon.svg</file>
</gresource>
</gresources>

View File

@@ -1,6 +1,6 @@
# gKeyMapper daemon — implementation plan
Status: draft / proposal
Status: implemented (M0–M4 done, see "Implementation status")
Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR
and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
@@ -28,8 +28,16 @@ and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
`z` is now correctly resolved to evdev 21.
- **AltGr (added after M2)** — level-3 combinations are representable as
`altgr+7`; the base (level 0) key is stored plus the `altgr` modifier.
- **Next: M4 (D-Bus GUI channel)** — status, explicit `Reload`, `TestMapping`
and signals. The reload side of it is already covered by the M3 file watch.
- **M4 done** — D-Bus control channel: `daemon/ipc_dbus` registers
`dev.rager.gkeymapper.Daemon` on the session bus and serves `Reload`,
`GetStatus` and `TestMapping`, plus the `KeyPressed`, `ConfigReloaded` and
`DeviceChanged` signals. `src/daemonclient.{h,cpp}` is the GUI side: the footer
shows `Daemon: running/not reachable`, the device and the last G key that
fired, and `Save mappings` calls `Reload()` (the file watch stays the
backstop). `make install` ships a D-Bus activation file, so the GUI can start
the daemon on demand; the unit is `Type=dbus` with `BusName=`. A second
instance refuses to start instead of fighting over the keyboard. Verified with
`gdbus` (methods, signals, activation) and over AT-SPI (live footer).
## 1. Goals and non-goals
@@ -188,9 +196,15 @@ artifact so the runtime does zero parsing.
- `uinput` kernel module must be present; ship `/etc/modules-load.d/` fragment
or document `modprobe uinput` (here it is built in).
- **systemd --user** unit `gkeymapper-daemon.service`:
`Type=dbus`, `BusName=…`, `ExecStart=gkeymapper-daemon`,
`Restart=on-failure`, `Documentation=…`. `BusName` enables D-Bus activation,
so starting the GUI can start the daemon on demand.
`Type=dbus`, `BusName=dev.rager.gkeymapper.Daemon`,
`ExecStart=gkeymapper-daemon`, `Restart=on-failure`, `ExecReload=SIGHUP`.
`Type=dbus` + `BusName` make the unit count as started only once the name is
owned — the state D-Bus activation and the GUI see. `make install` also ships
the D-Bus activation file
(`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service`, with
`SystemdService=gkeymapper-daemon.service`), so the bus starts the unit when
the GUI asks for the daemon's status and the daemon runs exactly once either
way.
- Do **not** require root. Commands run with the user's privileges (the
reference's "executed as root" note is a wiki artifact, not a design goal).
@@ -208,34 +222,44 @@ Three layers, from loosest to tightest:
implicitly by calling a D-Bus method (activation) and can show whether it is
running.
### 5.1 Proposed D-Bus interface
### 5.1 D-Bus interface (implemented)
- Bus name: `<app-id>` — decide once (§11). Suggested: `dev.rager.gkeymapper`
(replace the current placeholder `com.example.gkeymapper`).
- App id: `dev.rager.gkeymapper` (replaces the placeholder `com.example.gkeymapper`);
it is also the GApplication id.
- Bus name: `dev.rager.gkeymapper.Daemon` — **not** the plain app id.
`GtkApplication` owns the app id itself, in order to detect a second GUI
instance; a daemon on the same name would make every GUI start look like a
duplicate and exit. (The original plan had `<app-id>` here; that clash was
found while implementing M4.)
- Object path: `/dev/rager/gkeymapper/Daemon`
- Interface: `dev.rager.gkeymapper.Daemon`
Methods
| Method | Signature | Purpose |
|---|---|---|
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). |
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. |
| `TestMapping` | `(u index) -> ()` | Fire mapping for G1..G9 without hardware (GUI "Test" button). |
| `SetProfile` | `(s profile) -> ()` | Reserved for M-key profiles (later). |
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). Result via the `ConfigReloaded` signal. |
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. `device` is `""` before the keyboard is open, so the GUI can omit that part. |
| `TestMapping` | `(u index) -> ()` | Fire the mapping of one G key without hardware; same code path as a real press. `index` is **0-based** (`0` = G1 … `8` = G9). Fails with a D-Bus error for an out-of-range index or a `disabled:`/empty row. |
Signals
| Signal | Signature | Purpose |
|---|---|---|
| `KeyPressed` | `(u index, s action)` | A G-key fired (for GUI highlight/history). |
| `KeyPressed` | `(u index, s action)` | A G-key fired (GUI highlight/history; also emitted by `TestMapping`). |
| `ConfigReloaded` | `(b ok)` | Daemon finished reloading. |
| `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. |
Notes
- All are control-plane calls; none is on the keypress path.
- All are control-plane calls; none is on the keypress path. `DaemonService::pump()`
dispatches once per daemon loop iteration, so a reply can take up to one read
timeout (≤500 ms) — fine for status/reload, and the GUI calls asynchronously.
- `Reload` is best-effort: inotify is the backstop if the call is lost.
- If D-Bus is unavailable, the daemon still works; the GUI degrades to
"no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for
scripted use, matching the reference.
- `SetProfile` was dropped: it is reserved for the M-key profiles and would have
been a no-op today.
- The daemon requests its name with `DO_NOT_QUEUE` and exits (code 0, with a
message) when another instance owns it, before it touches the device.
### 5.2 Interaction sequences
@@ -257,34 +281,45 @@ Start-up orders:
- Daemon first, GUI later: GUI reads status via D-Bus; no config write needed.
- GUI first, daemon later: GUI writes config; on daemon start it reads the file.
### 5.3 GUI-side changes (small)
- On "Save mappings": keep writing the file, then call `Reload()` (ignore error).
- Add a footer status line: daemon running / device present / last reload, fed by
`GetStatus` + the `DeviceChanged`/`ConfigReloaded` signals.
- Optional "Test" button per row → `TestMapping(index)`.
- The key-name vocabulary must match: the capture dialog already writes
lowercase GDK key names; move the canonical name list into the shared module
(§6) so GUI and daemon agree on the same spellings.
### 5.3 GUI-side changes (as implemented)
- On "Save mappings": writes the file, then calls `Reload()` best-effort; the
file watch is the backstop, so a lost call is harmless (a save therefore
triggers two reloads, both idempotent).
- Footer status line: `Daemon: running/not reachable` + `Device: …` + the last G
key that fired, fed by `GetStatus`, the `DeviceChanged`/`ConfigReloaded`
signals and `NameOwnerChanged` (so it follows the daemon starting/stopping).
The `Device:` part is omitted while the device is not open yet.
- No "Test" button per row: pressing the real key is the better test while the
hardware is at hand, and `TestMapping` stays available over `gdbus`/`busctl`
without widening the fixed-width rows.
- The key-name vocabulary matches because the capture dialog already writes
lowercase GDK key names and the canonical list lives in `shared/keys` (§6).
## 6. Code layout
```
src/ # GUI (existing)
daemon/ # as implemented (see the list in §6 above)
daemonclient.{h,cpp} # D-Bus client (status/reload/signals)
daemon/ # as implemented (see §6 above)
ipc_dbus.{h,cpp} # GDBus service (control plane)
shared/
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon)
ipc_ids.h # app id, version, D-Bus names
data/
60-gkeymapper.rules # udev: uaccess for the device and /dev/uinput
gkeymapper-uinput.conf # modules-load.d fragment (uinput)
gkeymapper-daemon.service # systemd user unit (@BINDIR@ filled in on install)
dev.rager.gkeymapper.Daemon.service # D-Bus activation for the same unit
icons.gresource.xml # GUI icons compiled into the binary (GResource)
po/ # shared catalogs (GUI strings)
```
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
for the unit, udev rule and modules-load fragment. New build dependency:
`libusb-1.0-0-dev` (GLib/GIO is already required by the GUI).
for the unit, udev rule, modules-load fragment and activation file. New build
dependency: `libusb-1.0-0-dev` plus `libglib2.0-dev` (gio-2.0) for the daemon's
D-Bus channel.
## 7. Resource-efficiency measures (summary)
@@ -300,6 +335,10 @@ for the unit, udev rule and modules-load fragment. New build dependency:
- Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events,
RSS in the low single-digit MB.
Measured after M4 (systemd user unit, daemon idle): ~780 KB RSS, 14 ms CPU
since start. The D-Bus control channel adds no wakeups of its own: `pump()`
only dispatches what the loop's read timeout already woke up for.
## 8. Milestones
- **M0 – Shared core.** `shared/mapping` + `shared/keys`; refactor the GUI to use
@@ -311,8 +350,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
- **M3 – Config & lifecycle (done).** config reload via inotify (`config_watch`,
directory watch + atomic save) and `SIGHUP`/`SIGUSR1`; systemd --user unit +
udev rule + modules-load, installed by `make install`.
- **M4 – IPC.** GDBus service (`Reload`, `GetStatus`, `TestMapping`, signals);
GUI status line + Reload-on-save + optional Test button.
- **M4 – IPC (done).** GDBus service (`Reload`, `GetStatus`, `TestMapping`,
signals); GUI status line + Reload-on-save; D-Bus activation file; unit
`Type=dbus` + `BusName`; single-instance guard.
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
(hot-plug, debounce, error reporting to the GUI).
@@ -323,6 +363,10 @@ for the unit, udev rule and modules-load fragment. New build dependency:
- Device: replay captured reports into the decoder (no hardware needed).
- Integration: `evtest` / `libinput debug-events` to observe the virtual device;
compare `xev` output for a configured shortcut between reference and daemon.
- D-Bus (M4): `gdbus call` for `GetStatus`/`Reload`/`TestMapping`,
`gdbus monitor` for the signals, and a `TestMapping` observed with
`xinput test <keyboard-id>` to confirm the emitted keycodes. The GUI's footer
line is read back over AT-SPI to check the live status.
- Permission tests: verify the udev rule grants access as a normal user.
- Resource check: `powertop`/`perf` or simple wakeup counting while idle.
@@ -343,6 +387,12 @@ for the unit, udev rule and modules-load fragment. New build dependency:
in M0 is mandatory before the daemon consumes the file.
- **uinput device identity**: a second keyboard appears; choose stable
name/vendor/product so the desktop does not confuse it with the real one.
- **GUI does not load the config at startup** (found while testing M4, GUI-side,
not a daemon issue): `build_ui()` fills the rows empty and only
`Import configuration...` reads the file, so pressing `Save mappings` in a
freshly opened window overwrites the user's mappings with empty ones. Loading
`~/.config/gKeyMapper/mappings.conf` on start (or refusing to save unchanged
rows) should be the next GUI fix.
## 11. Decisions to make
@@ -356,6 +406,24 @@ for the unit, udev rule and modules-load fragment. New build dependency:
### Resolved
- **Bus name / app id (done in M4).** App id (and GApplication id)
`dev.rager.gkeymapper`; the daemon uses `dev.rager.gkeymapper.Daemon` because
GtkApplication owns the plain app id to detect a second GUI instance. The
installed `.desktop` file keeps its name `gkeymapper.desktop` — both old and
new app id yield the X11 `WM_CLASS` `gkeymapper`, which is what
`StartupWMClass` matches (verified with `xprop`).
- **IPC (done in M4).** D-Bus with GDBus: libdbus and sd-bus dev files are not
installed on this machine, GIO is; the daemon links `gio-2.0`.
- **IPC event integration (done in M4).** The daemon keeps its simple blocking
loop instead of moving to a `GMainLoop`: `DaemonService::pump()` calls
`g_main_context_iteration(ctx, FALSE)` once per iteration, so D-Bus is serviced
without a thread and without blocking, at the cost of up to one read timeout of
latency on the control plane.
- **Status line widget (evaluated).** The daemon status stays a plain
`GtkLabel` in the window footer. `GtkStatusbar` does exist in GTK4 and is
themed, but it keeps a message stack (pop/push per context id) and GTK4 exposes
it over AT-SPI as a generic `filler`, not as a "statusbar" role — so it was
built, compared side by side and dropped.
- **Event loop (as implemented, deviates from §4.2).** The daemon is
single-threaded with a blocking `libusb_interrupt_transfer` and a 500 ms
timeout; the config watch is drained once per iteration. That is ~2 idle
@@ -368,8 +436,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
from `/etc/default/keyboard`, via xkbcommon) and only falls back to the US
positions from `shared/keys`. This closes the vocabulary-drift risk for non-US
layouts — a German `z` is evdev 21, not the US position 44.
- **Reload without restart (done in M2).** `SIGHUP` re-reads the config file
(the full D-Bus `Reload` comes with M4).
- **Reload without restart (done in M2, completed in M4).** `SIGHUP`/`SIGUSR1`
re-read the config file; since M4 the GUI's `Save mappings` also calls the
D-Bus `Reload`, and the inotify watch applies anything else.
- **Config watching (done in M3).** The daemon watches the config **directory**
with inotify rather than the file, so an atomic save (temp file + `rename`) and
a recreated file are caught too. The loop iteration (≤ 500 ms) is the
@@ -377,9 +446,14 @@ for the unit, udev rule and modules-load fragment. New build dependency:
stay as the fallback and there is no extra fd in the loop. `Save mappings` in
the GUI now writes atomically, so the daemon can never read a partial file. A
failed reload keeps the previous mappings instead of disabling every key.
- **Lifecycle (done in M3).** A systemd **user** unit (not a system service): the
daemon needs the session for `/dev/uinput` and for the `DISPLAY` a `command`
action inherits. It is `Type=simple` for now; it becomes `Type=dbus` with a
`BusName` once M4 adds the D-Bus service. The udev rule and the
- **Lifecycle (done in M3, extended in M4).** A systemd **user** unit (not a
system service): the daemon needs the session for `/dev/uinput` and for the
`DISPLAY` a `command` action inherits. Since M4 it is `Type=dbus` with
`BusName=dev.rager.gkeymapper.Daemon`, and a D-Bus activation file lets the bus
start it on demand, so enabling the unit is optional. The udev rule and the
`modules-load.d` fragment live in `data/` and are installed by `make install`
(under `PREFIX/lib/...`, which udev and systemd both search).
- **Single instance (done in M4).** The daemon requests its bus name with
`DO_NOT_QUEUE` and exits with a message when it is already taken — two
instances would detach the kernel driver from each other and create two virtual
keyboards. The check happens before the device is opened.

View File

@@ -129,3 +129,18 @@ msgstr "Mappings speichern"
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
msgstr "Gespeichert in ~/.config/gKeyMapper/mappings.conf"
msgid "Daemon: not reachable"
msgstr "Daemon: nicht erreichbar"
msgid "Daemon: running"
msgstr "Daemon: läuft"
msgid "Device: %s"
msgstr "Gerät: %s"
msgid "Last key: G%d → %s"
msgstr "Letzte Taste: G%d → %s"
msgid "Daemon: checking..."
msgstr "Daemon: wird geprüft ..."

View File

@@ -129,3 +129,18 @@ msgstr "Save mappings"
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
msgstr "Saved to ~/.config/gKeyMapper/mappings.conf"
msgid "Daemon: not reachable"
msgstr "Daemon: not reachable"
msgid "Daemon: running"
msgstr "Daemon: running"
msgid "Device: %s"
msgstr "Device: %s"
msgid "Last key: G%d → %s"
msgstr "Last key: G%d → %s"
msgid "Daemon: checking..."
msgstr "Daemon: checking..."

View File

@@ -127,3 +127,18 @@ msgstr "Guardar asignaciones"
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
msgstr "Guardado en ~/.config/gKeyMapper/mappings.conf"
msgid "Daemon: not reachable"
msgstr "Demonio: no accesible"
msgid "Daemon: running"
msgstr "Demonio: en ejecución"
msgid "Device: %s"
msgstr "Dispositivo: %s"
msgid "Last key: G%d → %s"
msgstr "Última tecla: G%d → %s"
msgid "Daemon: checking..."
msgstr "Demonio: comprobando..."

View File

@@ -127,3 +127,18 @@ msgstr "Enregistrer les attributions"
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
msgstr "Enregistré dans ~/.config/gKeyMapper/mappings.conf"
msgid "Daemon: not reachable"
msgstr "Démon : injoignable"
msgid "Daemon: running"
msgstr "Démon : en cours d'exécution"
msgid "Device: %s"
msgstr "Appareil : %s"
msgid "Last key: G%d → %s"
msgstr "Dernière touche : G%d → %s"
msgid "Daemon: checking..."
msgstr "Démon : vérification..."

View File

@@ -164,3 +164,23 @@ msgstr ""
#: src/main.cpp:608
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
msgstr ""
#: src/main.cpp:54
msgid "Daemon: not reachable"
msgstr ""
#: src/main.cpp:56
msgid "Daemon: running"
msgstr ""
#: src/main.cpp:59
msgid "Device: %s"
msgstr ""
#: src/main.cpp:82
msgid "Last key: G%d → %s"
msgstr ""
#: src/main.cpp:429
msgid "Daemon: checking..."
msgstr ""

29
shared/ipc_ids.h Normal file
View File

@@ -0,0 +1,29 @@
// gKeyMapper - identifiers shared by the GUI and the daemon.
//
// The D-Bus channel is control plane only: status, an explicit reload and
// notifications. A keypress never crosses it, so a slow or absent GUI cannot
// delay an action.
#ifndef GKEYMAPPER_IPC_IDS_H
#define GKEYMAPPER_IPC_IDS_H
namespace gkeymapper::ipc {
// Application id: GApplication id, the name of the installed .desktop file's
// window class, and the well-known bus name the daemon owns.
inline constexpr const char* kAppId = "dev.rager.gkeymapper";
// Reported by GetStatus and shown in the About dialog.
inline constexpr const char* kVersion = "0.1.0";
// The daemon owns this name; the GUI talks to it via the object below.
//
// It is deliberately not the plain app id: GtkApplication owns the app id
// itself (that is how it detects a second GUI instance), so a daemon on the
// same name would make every GUI start look like a duplicate and exit.
inline constexpr const char* kBusName = "dev.rager.gkeymapper.Daemon";
inline constexpr const char* kObjectPath = "/dev/rager/gkeymapper/Daemon";
inline constexpr const char* kInterface = "dev.rager.gkeymapper.Daemon";
} // namespace gkeymapper::ipc
#endif // GKEYMAPPER_IPC_IDS_H

191
src/daemonclient.cpp Normal file
View File

@@ -0,0 +1,191 @@
// gKeyMapper - talk to gkeymapper-daemon over the session bus.
#include "daemonclient.h"
#include <cstdio>
#include "ipc_ids.h"
namespace gkeymapper {
namespace {
// The daemon answers within one loop iteration, but it may have to be started
// first (D-Bus activation), so allow for a slow first reply.
constexpr int kCallTimeoutMs = 5000;
constexpr const char* kDBusName = "org.freedesktop.DBus";
constexpr const char* kDBusPath = "/org/freedesktop/DBus";
} // namespace
DaemonClient::~DaemonClient() {
if (connection_ == nullptr) {
return;
}
if (signal_subscription_ != 0) {
g_dbus_connection_signal_unsubscribe(connection_, signal_subscription_);
}
if (name_subscription_ != 0) {
g_dbus_connection_signal_unsubscribe(connection_, name_subscription_);
}
g_object_unref(connection_);
}
void DaemonClient::init(StatusFn onStatus, KeyFn onKey) {
onStatus_ = std::move(onStatus);
onKey_ = std::move(onKey);
if (started_) {
return; // a second activation reuses the existing connection
}
started_ = true;
g_bus_get(G_BUS_TYPE_SESSION, nullptr, &DaemonClient::on_bus_ready, this);
}
void DaemonClient::on_bus_ready(GObject* /*source*/, GAsyncResult* result, gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
GError* error = nullptr;
GDBusConnection* connection = g_bus_get_finish(result, &error);
if (connection == nullptr) {
// No session bus: the GUI still edits and saves the file, it just
// cannot show the daemon state.
std::fprintf(stderr, "gkeymapper: no session bus: %s\n",
error != nullptr ? error->message : "unknown error");
g_clear_error(&error);
self->report_unreachable();
return;
}
self->connection_ = connection;
self->watch_signals();
self->fetch_status();
}
void DaemonClient::watch_signals() {
signal_subscription_ = g_dbus_connection_signal_subscribe(
connection_, ipc::kBusName, ipc::kInterface, nullptr, ipc::kObjectPath, nullptr,
G_DBUS_SIGNAL_FLAGS_NONE, &DaemonClient::on_daemon_signal, this, nullptr);
// Lets the footer follow the daemon: it flips to "not reachable" when it
// stops and back when it (or D-Bus activation) starts it again.
name_subscription_ = g_dbus_connection_signal_subscribe(
connection_, kDBusName, kDBusName, "NameOwnerChanged", kDBusPath, ipc::kBusName,
G_DBUS_SIGNAL_FLAGS_NONE, &DaemonClient::on_name_owner_changed, this, nullptr);
}
void DaemonClient::on_daemon_signal(GDBusConnection* /*connection*/, const gchar* /*sender*/,
const gchar* /*objectPath*/, const gchar* /*interfaceName*/,
const gchar* signalName, GVariant* parameters,
gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
if (g_strcmp0(signalName, "KeyPressed") == 0) {
guint32 index = 0;
const gchar* action = nullptr;
g_variant_get(parameters, "(u&s)", &index, &action);
if (self->onKey_) {
self->onKey_(static_cast<int>(index), action != nullptr ? action : "");
}
return;
}
// ConfigReloaded and DeviceChanged carry no state the footer does not
// already get from GetStatus, so just refresh it.
self->fetch_status();
}
void DaemonClient::on_name_owner_changed(GDBusConnection* /*connection*/, const gchar* /*sender*/,
const gchar* /*objectPath*/,
const gchar* /*interfaceName*/,
const gchar* /*signalName*/, GVariant* parameters,
gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
const gchar* name = nullptr;
const gchar* oldOwner = nullptr;
const gchar* newOwner = nullptr;
g_variant_get(parameters, "(&s&s&s)", &name, &oldOwner, &newOwner);
if (newOwner == nullptr || *newOwner == '\0') {
self->report_unreachable();
} else {
self->fetch_status();
}
}
void DaemonClient::fetch_status() {
if (connection_ == nullptr) {
return;
}
// A call to a name without an owner makes the bus activate the service, so
// this also brings the daemon up on demand.
g_dbus_connection_call(connection_, ipc::kBusName, ipc::kObjectPath, ipc::kInterface,
"GetStatus", nullptr, G_VARIANT_TYPE("(sbss)"),
G_DBUS_CALL_FLAGS_NONE, kCallTimeoutMs, nullptr,
&DaemonClient::on_status_reply, this);
}
void DaemonClient::on_status_reply(GObject* /*source*/, GAsyncResult* result, gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
GError* error = nullptr;
GVariant* reply = g_dbus_connection_call_finish(self->connection_, result, &error);
if (reply == nullptr) {
// Normal when the daemon is not installed or not running.
g_clear_error(&error);
self->report_unreachable();
return;
}
const gchar* version = nullptr;
const gchar* device = nullptr;
const gchar* configPath = nullptr;
gboolean running = FALSE;
g_variant_get(reply, "(&sb&s&s)", &version, &running, &device, &configPath);
g_variant_unref(reply);
DaemonStatus status;
status.reachable = true;
status.version = version != nullptr ? version : "";
status.device = device != nullptr ? device : "";
status.configPath = configPath != nullptr ? configPath : "";
self->publish(status);
}
void DaemonClient::reload() {
if (connection_ == nullptr) {
return;
}
g_dbus_connection_call(connection_, ipc::kBusName, ipc::kObjectPath, ipc::kInterface,
"Reload", nullptr, nullptr, G_DBUS_CALL_FLAGS_NONE, kCallTimeoutMs,
nullptr, &DaemonClient::on_reload_reply, this);
}
void DaemonClient::on_reload_reply(GObject* /*source*/, GAsyncResult* result, gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
GError* error = nullptr;
GVariant* reply = g_dbus_connection_call_finish(self->connection_, result, &error);
if (reply != nullptr) {
g_variant_unref(reply);
}
if (error != nullptr) {
// The file watch applies the save anyway, so a missing daemon is fine.
g_clear_error(&error);
}
}
void DaemonClient::report_unreachable() {
DaemonStatus status;
status.reachable = false;
publish(status);
}
void DaemonClient::publish(const DaemonStatus& status) {
if (onStatus_) {
onStatus_(status);
}
}
} // namespace gkeymapper

69
src/daemonclient.h Normal file
View File

@@ -0,0 +1,69 @@
// gKeyMapper - talk to gkeymapper-daemon over the session bus.
#ifndef GKEYMAPPER_DAEMONCLIENT_H
#define GKEYMAPPER_DAEMONCLIENT_H
#include <gio/gio.h>
#include <functional>
#include <string>
namespace gkeymapper {
// What the footer needs to know about the daemon.
struct DaemonStatus {
bool reachable = false;
std::string version;
std::string device;
std::string configPath;
};
// Control-plane client for the daemon. Every call is asynchronous and its
// result arrives on the GTK main loop, so the UI never blocks on the daemon
// (a call is answered at most one daemon loop iteration late). Keypresses never
// travel through here.
class DaemonClient {
public:
using StatusFn = std::function<void(const DaemonStatus&)>;
using KeyFn = std::function<void(int index, const std::string& action)>;
DaemonClient() = default;
~DaemonClient();
DaemonClient(const DaemonClient&) = delete;
DaemonClient& operator=(const DaemonClient&) = delete;
void init(StatusFn onStatus, KeyFn onKey);
// Best effort: asks the daemon to re-read the mappings file. Its file watch
// would pick the save up by itself, this only makes it immediate.
void reload();
private:
static void on_bus_ready(GObject* source, GAsyncResult* result, gpointer userData);
static void on_daemon_signal(GDBusConnection* connection, const gchar* sender,
const gchar* objectPath, const gchar* interfaceName,
const gchar* signalName, GVariant* parameters,
gpointer userData);
static void on_name_owner_changed(GDBusConnection* connection, const gchar* sender,
const gchar* objectPath, const gchar* interfaceName,
const gchar* signalName, GVariant* parameters,
gpointer userData);
static void on_status_reply(GObject* source, GAsyncResult* result, gpointer userData);
static void on_reload_reply(GObject* source, GAsyncResult* result, gpointer userData);
void watch_signals();
void fetch_status();
void report_unreachable();
void publish(const DaemonStatus& status);
GDBusConnection* connection_ = nullptr;
guint signal_subscription_ = 0;
guint name_subscription_ = 0;
bool started_ = false;
StatusFn onStatus_;
KeyFn onKey_;
};
} // namespace gkeymapper
#endif // GKEYMAPPER_DAEMONCLIENT_H

View File

@@ -10,6 +10,8 @@
#include <vector>
#include "capturedialog.h"
#include "daemonclient.h"
#include "ipc_ids.h"
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
@@ -29,6 +31,59 @@ using gkeymapper::kNumKeys;
std::vector<KeyRow> rows;
// Footer view of the daemon. It is filled by the asynchronous callbacks of
// DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
// for the daemon.
struct DaemonFooter {
bool reachable = false;
std::string device;
std::string lastKey;
} daemonFooter;
GtkWidget* daemonLabel = nullptr;
gkeymapper::DaemonClient daemonClient;
// Rebuilds the footer line from the state above. " · " is a separator, not a
// sentence, so it is not translated.
static void refresh_footer() {
if (daemonLabel == nullptr) {
return;
}
std::string text;
if (!daemonFooter.reachable) {
text = _("Daemon: not reachable");
} else {
text = _("Daemon: running");
if (!daemonFooter.device.empty()) {
g_autofree gchar* device =
g_strdup_printf(_("Device: %s"), daemonFooter.device.c_str());
text += " · ";
text += device;
}
if (!daemonFooter.lastKey.empty()) {
text += " · ";
text += daemonFooter.lastKey;
}
}
gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str());
}
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
daemonFooter.reachable = status.reachable;
daemonFooter.device = status.device;
if (!status.reachable) {
daemonFooter.lastKey.clear(); // stale once the daemon is gone
}
refresh_footer();
}
static void on_daemon_key(int index, const std::string& action) {
g_autofree gchar* text =
g_strdup_printf(_("Last key: G%d → %s"), index + 1, action.c_str());
daemonFooter.lastKey = text;
refresh_footer();
}
static void on_pick_file_response(GtkDialog* dialog, int response, gpointer userData) {
if (response == GTK_RESPONSE_ACCEPT) {
KeyRow* row = static_cast<KeyRow*>(userData);
@@ -101,6 +156,10 @@ void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
return;
}
// Best effort: the daemon would pick the save up through its file watch
// within about half a second, this just applies it right away.
daemonClient.reload();
std::cout << _("Mappings saved to ") << path << "\n";
}
@@ -134,24 +193,50 @@ static void on_import_response(GtkDialog* dialog, int response, gpointer /*userD
static std::string find_asset(const std::string& name);
// The icons are compiled into the executable (data/icons.gresource.xml), so the
// GUI has them even when no assets are installed. The file lookup stays as a
// fallback for a build without the resource; because the resource is embedded,
// it wins over any installed file.
static const char* kIconResource = "/dev/rager/gkeymapper/icon.png";
static const char* kIconThemeResource = "/dev/rager/gkeymapper/icons";
static bool have_embedded_icon() {
return g_resources_get_info(kIconResource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr, nullptr,
nullptr);
}
static GtkWidget* make_icon_image(int pixelSize) {
GtkWidget* image = have_embedded_icon()
? gtk_image_new_from_resource(kIconResource)
: gtk_image_new_from_file(find_asset("icon.png").c_str());
gtk_image_set_pixel_size(GTK_IMAGE(image), pixelSize);
return image;
}
static void show_about_dialog(GtkWindow* parent) {
GtkWidget* dialog = gtk_about_dialog_new();
gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), _("gKeyMapper"));
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), "0.1.0");
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), gkeymapper::ipc::kVersion);
gtk_about_dialog_set_comments(GTK_ABOUT_DIALOG(dialog),
_("G-Key mapper for the Logitech G910 (GTK4)."));
gtk_about_dialog_set_website(GTK_ABOUT_DIALOG(dialog), "https://rager.dev");
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
GdkTexture* texture = nullptr;
if (have_embedded_icon()) {
texture = gdk_texture_new_from_resource(kIconResource);
} else {
std::string iconPath = find_asset("icon.png");
GError* error = nullptr;
GdkTexture* texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
if (!texture && error) {
g_printerr("Could not load icon: %s\n", error->message);
g_error_free(error);
}
}
if (texture) {
gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture));
g_object_unref(texture);
} else if (error) {
g_printerr("Could not load icon: %s\n", error->message);
g_error_free(error);
}
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
g_signal_connect(dialog, "response", G_CALLBACK(gtk_window_destroy), NULL);
@@ -236,10 +321,14 @@ void build_ui(GtkApplication* app) {
GtkWidget* headerBar = gtk_header_bar_new();
gtk_window_set_titlebar(GTK_WINDOW(window), headerBar);
// Serve "gkeymapper" from the embedded icons, so the window icon resolves
// even without an installed hicolor theme.
gtk_icon_theme_add_resource_path(
gtk_icon_theme_get_for_display(gtk_widget_get_display(window)), kIconThemeResource);
gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper");
GtkWidget* titleBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget* iconImage = gtk_image_new_from_file(find_asset("icon.png").c_str());
GtkWidget* iconImage = make_icon_image(24);
gtk_image_set_pixel_size(GTK_IMAGE(iconImage), 24);
gtk_box_append(GTK_BOX(titleBox), iconImage);
gtk_box_append(GTK_BOX(titleBox), gtk_label_new(_("gKeyMapper")));
@@ -261,23 +350,17 @@ void build_ui(GtkApplication* app) {
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_window_set_child(GTK_WINDOW(window), outer);
// Title / subtitle
GtkWidget* headerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_margin_top(headerBox, 16);
gtk_widget_set_margin_bottom(headerBox, 8);
gtk_widget_set_margin_start(headerBox, 16);
gtk_widget_set_margin_end(headerBox, 16);
gtk_box_append(GTK_BOX(outer), headerBox);
GtkWidget* title = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(title), "<span size='x-large' weight='bold'>G-Key Mapper</span>");
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
gtk_box_append(GTK_BOX(headerBox), title);
GtkWidget* subtitle = gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
// Subtitle only: the app name is already in the header bar, and the heading
// spelled it differently ("G-Key Mapper") on top of that.
GtkWidget* subtitle =
gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
gtk_label_set_xalign(GTK_LABEL(subtitle), 0.0f);
gtk_widget_add_css_class(subtitle, "dim-label");
gtk_box_append(GTK_BOX(headerBox), subtitle);
gtk_widget_set_margin_top(subtitle, 16);
gtk_widget_set_margin_bottom(subtitle, 8);
gtk_widget_set_margin_start(subtitle, 16);
gtk_widget_set_margin_end(subtitle, 16);
gtk_box_append(GTK_BOX(outer), subtitle);
// Table with all nine G keys (no scrolling needed, everything stays visible)
GtkWidget* grid = gtk_grid_new();
@@ -340,28 +423,44 @@ void build_ui(GtkApplication* app) {
update_row_state(&rows[i]);
}
// Footer with save button
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
// Footer with save button and the daemon status line
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(footer, 8);
gtk_widget_set_margin_bottom(footer, 16);
gtk_widget_set_margin_start(footer, 16);
gtk_widget_set_margin_end(footer, 16);
gtk_box_append(GTK_BOX(outer), footer);
GtkWidget* footerRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(footer), footerRow);
GtkWidget* saveBtn = gtk_button_new_with_label(_("Save mappings"));
// No "suggested-action" class on purpose: it gets a strong accent color
// (e.g. Mint-Y green) that clashes with the rest of the layout. The plain
// button follows the system theme like everything else.
g_signal_connect(saveBtn, "clicked", G_CALLBACK(on_save_clicked), nullptr);
gtk_box_append(GTK_BOX(footer), saveBtn);
gtk_box_append(GTK_BOX(footerRow), saveBtn);
GtkWidget* hint = gtk_label_new(_("Saved to ~/.config/gKeyMapper/mappings.conf"));
gtk_widget_add_css_class(hint, "dim-label");
gtk_widget_set_halign(hint, GTK_ALIGN_END);
gtk_widget_set_hexpand(hint, TRUE);
gtk_box_append(GTK_BOX(footer), hint);
gtk_box_append(GTK_BOX(footerRow), hint);
// Daemon status: control plane only, the keypress path never leaves the
// daemon. Text starts neutral so the footer does not change height when the
// first reply arrives. Plain label on purpose: GTK4's GtkStatusbar would add
// a message stack (pop/push) and is exposed over AT-SPI as a generic filler,
// so it brings no benefit here.
daemonLabel = gtk_label_new(_("Daemon: checking..."));
gtk_widget_add_css_class(daemonLabel, "dim-label");
gtk_label_set_xalign(GTK_LABEL(daemonLabel), 0.0f);
gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(footer), daemonLabel);
gtk_window_present(GTK_WINDOW(window));
daemonClient.init(on_daemon_status, on_daemon_key);
}
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
@@ -392,7 +491,7 @@ int main(int argc, char** argv) {
bind_textdomain_codeset(PACKAGE, "UTF-8");
textdomain(PACKAGE);
GtkApplication* app = gtk_application_new("com.example.gkeymapper",
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
G_APPLICATION_FLAGS_NONE);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
int status = g_application_run(G_APPLICATION(app), argc, argv);