From 726a7ae92d79f07c3db4c1bbd5ba16994ac69225 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 18 Oct 2026 02:57:29 +0200 Subject: [PATCH] Embedd assets icons --- AGENTS.md | 140 +++++++++++---- Makefile | 26 ++- README.md | 72 ++++++-- daemon/ipc_dbus.cpp | 219 +++++++++++++++++++++++ daemon/ipc_dbus.h | 74 ++++++++ daemon/main.cpp | 98 ++++++++-- data/dev.rager.gkeymapper.Daemon.service | 10 ++ data/gkeymapper-daemon.service | 7 +- data/icons.gresource.xml | 16 ++ docs/daemon-plan.md | 142 +++++++++++---- po/de.po | 15 ++ po/en.po | 15 ++ po/es.po | 15 ++ po/fr.po | 15 ++ po/gkeymapper.pot | 20 +++ shared/ipc_ids.h | 29 +++ src/daemonclient.cpp | 191 ++++++++++++++++++++ src/daemonclient.h | 69 +++++++ src/main.cpp | 155 +++++++++++++--- 19 files changed, 1202 insertions(+), 126 deletions(-) create mode 100644 daemon/ipc_dbus.cpp create mode 100644 daemon/ipc_dbus.h create mode 100644 data/dev.rager.gkeymapper.Daemon.service create mode 100644 data/icons.gresource.xml create mode 100644 shared/ipc_ids.h create mode 100644 src/daemonclient.cpp create mode 100644 src/daemonclient.h diff --git a/AGENTS.md b/AGENTS.md index 5509659..b306b9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=/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 ` 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 ` 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: ` → 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. diff --git a/Makefile b/Makefile index 9b4b066..0aabb74 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 0335d27..1ccd788 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/daemon/ipc_dbus.cpp b/daemon/ipc_dbus.cpp new file mode 100644 index 0000000..f62e47f --- /dev/null +++ b/daemon/ipc_dbus.cpp @@ -0,0 +1,219 @@ +// gKeyMapper daemon - D-Bus service for the GUI (control plane only). + +#include "ipc_dbus.h" + +#include + +#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[] = + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + ""; + +} // 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(userData)->name_owned_ = true; + }, + [](GDBusConnection*, const gchar* name, gpointer userData) { + auto* self = static_cast(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(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(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(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(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(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 diff --git a/daemon/ipc_dbus.h b/daemon/ipc_dbus.h new file mode 100644 index 0000000..2a328a6 --- /dev/null +++ b/daemon/ipc_dbus.h @@ -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 + +#include +#include + +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; + // Description of the keyboard for GetStatus ("" when none is open). + using DeviceFn = std::function; + // Runs the mapping of G (index is 0-based) and reports what ran. + using TestFn = std::function; + + 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 diff --git a/daemon/main.cpp b/daemon/main.cpp index 1f50190..21c5f33 100644 --- a/daemon/main.cpp +++ b/daemon/main.cpp @@ -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 #include @@ -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 (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(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(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; } diff --git a/data/dev.rager.gkeymapper.Daemon.service b/data/dev.rager.gkeymapper.Daemon.service new file mode 100644 index 0000000..cab01d4 --- /dev/null +++ b/data/dev.rager.gkeymapper.Daemon.service @@ -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 diff --git a/data/gkeymapper-daemon.service b/data/gkeymapper-daemon.service index 37a5781..bbda906 100644 --- a/data/gkeymapper-daemon.service +++ b/data/gkeymapper-daemon.service @@ -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 diff --git a/data/icons.gresource.xml b/data/icons.gresource.xml new file mode 100644 index 0000000..3c97c7f --- /dev/null +++ b/data/icons.gresource.xml @@ -0,0 +1,16 @@ + + + + + assets/icon.png + + + assets/icon.png + assets/icon.svg + + diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md index edb33af..87462c6 100644 --- a/docs/daemon-plan.md +++ b/docs/daemon-plan.md @@ -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: `` — 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 `` 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 ` 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. diff --git a/po/de.po b/po/de.po index 0201b5d..f9d55d3 100644 --- a/po/de.po +++ b/po/de.po @@ -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 ..." diff --git a/po/en.po b/po/en.po index eb9e3ba..6bc7162 100644 --- a/po/en.po +++ b/po/en.po @@ -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..." diff --git a/po/es.po b/po/es.po index 259d6d0..3d34583 100644 --- a/po/es.po +++ b/po/es.po @@ -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..." diff --git a/po/fr.po b/po/fr.po index 867f985..22d8242 100644 --- a/po/fr.po +++ b/po/fr.po @@ -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..." diff --git a/po/gkeymapper.pot b/po/gkeymapper.pot index 9f0efce..b5fe5ea 100644 --- a/po/gkeymapper.pot +++ b/po/gkeymapper.pot @@ -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 "" diff --git a/shared/ipc_ids.h b/shared/ipc_ids.h new file mode 100644 index 0000000..f00be6c --- /dev/null +++ b/shared/ipc_ids.h @@ -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 diff --git a/src/daemonclient.cpp b/src/daemonclient.cpp new file mode 100644 index 0000000..2c6f6f1 --- /dev/null +++ b/src/daemonclient.cpp @@ -0,0 +1,191 @@ +// gKeyMapper - talk to gkeymapper-daemon over the session bus. + +#include "daemonclient.h" + +#include + +#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(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(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(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(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(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(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 diff --git a/src/daemonclient.h b/src/daemonclient.h new file mode 100644 index 0000000..2dc3489 --- /dev/null +++ b/src/daemonclient.h @@ -0,0 +1,69 @@ +// gKeyMapper - talk to gkeymapper-daemon over the session bus. +#ifndef GKEYMAPPER_DAEMONCLIENT_H +#define GKEYMAPPER_DAEMONCLIENT_H + +#include + +#include +#include + +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; + using KeyFn = std::function; + + 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 diff --git a/src/main.cpp b/src/main.cpp index 1350281..711df1d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,8 @@ #include #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 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(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"); - std::string iconPath = find_asset("icon.png"); - GError* error = nullptr; - GdkTexture* texture = gdk_texture_new_from_filename(iconPath.c_str(), &error); + 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; + 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), "G-Key Mapper"); - 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);