Embedd assets icons
This commit is contained in:
140
AGENTS.md
140
AGENTS.md
@@ -10,16 +10,18 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
|||||||
## Build / run / verify
|
## Build / run / verify
|
||||||
- `make` — builds the `gkeymapper` GUI, the `gkeymapper-daemon` and the `.mo`
|
- `make` — builds the `gkeymapper` GUI, the `gkeymapper-daemon` and the `.mo`
|
||||||
catalogs. Needs `g++` (C++17), `make`, `gettext`, `libgtk-4-dev`,
|
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
|
`libusb-1.0-0-dev`, `libxkbcommon-dev` and `libglib2.0-dev` (see README for
|
||||||
fresh machine).
|
`apt install` on a fresh machine). `libglib2.0-dev` also provides
|
||||||
- `make daemon` — builds only `gkeymapper-daemon` (needs libusb + xkbcommon,
|
`glib-compile-resources`, which generates the icons embedded into the GUI.
|
||||||
not GTK).
|
- `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 translations` — only runs `msgfmt` on the existing `.po` files.
|
||||||
- `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and
|
- `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and
|
||||||
`GKEYMAPPER_ASSETS_DIR=$PWD/assets` so the working-tree translations and
|
`GKEYMAPPER_ASSETS_DIR=$PWD/assets` so the working-tree translations are used.
|
||||||
assets are used. Plain `./gkeymapper` works too (assets fall back to
|
Plain `./gkeymapper` works too (assets fall back to `./assets`). The window
|
||||||
`./assets`).
|
icon itself is compiled into the binary, so it never depends on these.
|
||||||
- `make clean` — deletes the binary and the generated `locale/` tree.
|
- `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.
|
- No tests and no CI. Verify changes by building and running the app.
|
||||||
|
|
||||||
## i18n — easy to get wrong
|
## 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/`
|
- Modules under `src/` (GUI), `shared/` (used by both binaries) and `daemon/`
|
||||||
(the background service). Shared API lives in `namespace gkeymapper`.
|
(the background service). Shared API lives in `namespace gkeymapper`.
|
||||||
`shared/mapping` owns the mappings file format, `shared/keys` the canonical
|
`shared/mapping` owns the mappings file format, `shared/keys` the canonical
|
||||||
key-name table (lowercase GDK names -> Linux `KEY_*`).
|
key-name table (lowercase GDK names -> Linux `KEY_*`), `shared/ipc_ids.h` the
|
||||||
- The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp`)
|
app id, the version and the D-Bus names both sides use.
|
||||||
**and** the daemon (`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`
|
- The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp`
|
||||||
and `libxkbcommon-dev`). `make daemon` builds only the daemon.
|
+ 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),
|
- The daemon is split into: `device_tables` (raw HID report tables per model),
|
||||||
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
|
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
|
||||||
keyboard), `action` (dispatch `key`/`command`/`disabled`) and `config_watch`
|
keyboard), `action` (dispatch `key`/`command`/`disabled`), `config_watch`
|
||||||
(inotify auto-reload of the mappings file). `shared/keymap` does the layout
|
(inotify auto-reload of the mappings file) and `ipc_dbus` (the GDBus service).
|
||||||
lookups for both sides. The hot path never touches the GUI.
|
`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
|
- `make install` (PREFIX default `/usr/local`) also installs the packaging
|
||||||
files from `data/`: the udev rule, the `modules-load.d` fragment and the
|
files from `data/`: the udev rule, the `modules-load.d` fragment, the
|
||||||
systemd **user** unit. The unit contains an `@BINDIR@` placeholder that the
|
systemd **user** unit and the D-Bus activation file. Both contain an
|
||||||
Makefile substitutes with `$(PREFIX)/bin`. Keep it a user service — it needs
|
`@BINDIR@` placeholder that the Makefile substitutes with `$(PREFIX)/bin`.
|
||||||
the session for `/dev/uinput` and `DISPLAY`.
|
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
|
- 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
|
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
|
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)
|
- `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
|
in an anonymous namespace; `src/mappings.cpp` is GUI glue that moves data
|
||||||
between the widgets and the shared `MappingSet`. App id:
|
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`,
|
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
|
||||||
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens
|
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens
|
||||||
in the file. Keep that order.
|
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`.
|
`kModAltGr` maps to `KEY_RIGHTALT`.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
`find_asset()` resolves in order: `$GKEYMAPPER_ASSETS_DIR` → `./assets` →
|
The icons (`assets/icon.png`, `assets/icon.svg`) are compiled into the GUI:
|
||||||
compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). The icon
|
`data/icons.gresource.xml` is turned into the generated `build/gresources.c` by
|
||||||
(`assets/icon.png`) is used in the header bar and the About dialog.
|
`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
|
## Driving the daemon for manual checks
|
||||||
- The process name is truncated by the kernel to 15 chars, so `pkill -x
|
- 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
|
(`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
|
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.
|
second. Use `SIGHUP` to check the signal path after touching it.
|
||||||
- Test the auto-reload without hardware: write the config through the shared
|
- If the systemd user service is enabled, **stop it first**
|
||||||
save path (atomic temp+rename, as the GUI does) and watch `/tmp/daemon.log`
|
(`systemctl --user stop gkeymapper-daemon`) before starting the repo build by
|
||||||
for a second `loaded ...` line. `daemon/config_watch.cpp` also has a
|
hand: the daemon refuses to run when the bus name is taken (a second instance
|
||||||
standalone test shape: create a `ConfigWatcher`, `poll_events()` then
|
would fight over the device). Restore it afterwards with
|
||||||
`take_change()` per simulated loop iteration.
|
`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
|
- `make install DESTDIR=/tmp/stage` stages the packaging files; inspect
|
||||||
`/tmp/stage/usr/local/lib/...` to verify the unit's `ExecStart` substitution.
|
`/tmp/stage/usr/local/lib/...` and
|
||||||
`systemd-analyze --user verify <unit>` checks the unit. There is no
|
`/tmp/stage/usr/local/share/dbus-1/services/...` to verify the `@BINDIR@`
|
||||||
`udevadm verify` on systemd 249, so the udev rule is checked by eye against
|
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.
|
the reference project's rule.
|
||||||
- Starting it detaches the kernel driver from the vendor interface and creates a
|
- 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
|
`/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
|
(`queryAction().doAction(0)`) plus `Xlib`'s `xtest.fake_input` can click buttons
|
||||||
and inject keys. Warn the user before synthesizing keystrokes or stealing window
|
and inject keys. Warn the user before synthesizing keystrokes or stealing window
|
||||||
focus.
|
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.
|
||||||
|
|||||||
26
Makefile
26
Makefile
@@ -6,6 +6,7 @@ CXX ?= g++
|
|||||||
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic
|
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic
|
||||||
PKG_CONFIG ?= pkg-config
|
PKG_CONFIG ?= pkg-config
|
||||||
MSGFMT ?= msgfmt
|
MSGFMT ?= msgfmt
|
||||||
|
GLIB_COMPILE_RESOURCES ?= glib-compile-resources
|
||||||
|
|
||||||
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
|
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
|
||||||
GTK_LIBS := $(shell $(PKG_CONFIG) --libs 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)
|
USB_LIBS := $(shell $(PKG_CONFIG) --libs libusb-1.0)
|
||||||
XKB_CFLAGS := $(shell $(PKG_CONFIG) --cflags xkbcommon)
|
XKB_CFLAGS := $(shell $(PKG_CONFIG) --cflags xkbcommon)
|
||||||
XKB_LIBS := $(shell $(PKG_CONFIG) --libs 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
|
PACKAGE := gkeymapper
|
||||||
TARGET := 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)
|
GUI_HEADERS := $(wildcard $(SRC_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
|
||||||
DAEMON_SOURCES := $(wildcard $(DAEMON_DIR)/*.cpp) $(wildcard $(SHARED_DIR)/*.cpp)
|
DAEMON_SOURCES := $(wildcard $(DAEMON_DIR)/*.cpp) $(wildcard $(SHARED_DIR)/*.cpp)
|
||||||
DAEMON_HEADERS := $(wildcard $(DAEMON_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
|
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
|
PREFIX ?= /usr/local
|
||||||
BINDIR := $(PREFIX)/bin
|
BINDIR := $(PREFIX)/bin
|
||||||
LOCALEDIR := $(PREFIX)/share/locale
|
LOCALEDIR := $(PREFIX)/share/locale
|
||||||
@@ -31,6 +37,7 @@ ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets
|
|||||||
UDEV_DIR := $(PREFIX)/lib/udev/rules.d
|
UDEV_DIR := $(PREFIX)/lib/udev/rules.d
|
||||||
MODULES_DIR := $(PREFIX)/lib/modules-load.d
|
MODULES_DIR := $(PREFIX)/lib/modules-load.d
|
||||||
UNIT_DIR := $(PREFIX)/lib/systemd/user
|
UNIT_DIR := $(PREFIX)/lib/systemd/user
|
||||||
|
DBUS_DIR := $(PREFIX)/share/dbus-1/services
|
||||||
|
|
||||||
CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\"
|
CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\"
|
||||||
CXXFLAGS += -I$(SHARED_DIR)
|
CXXFLAGS += -I$(SHARED_DIR)
|
||||||
@@ -44,13 +51,20 @@ MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS))
|
|||||||
|
|
||||||
all: $(TARGET) translations $(DAEMON)
|
all: $(TARGET) translations $(DAEMON)
|
||||||
|
|
||||||
$(TARGET): $(GUI_SOURCES) $(GUI_HEADERS)
|
# Embedded GUI icons. --c-name keeps the gkeymapper_* symbols stable; --sourcedir
|
||||||
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) $(XKB_CFLAGS) -o $@ $(GUI_SOURCES) $(GTK_LIBS) $(XKB_LIBS)
|
# 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)
|
||||||
|
|
||||||
$(DAEMON): $(DAEMON_SOURCES) $(DAEMON_HEADERS)
|
$(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)
|
translations: $(MO_FILES)
|
||||||
|
|
||||||
@@ -63,7 +77,7 @@ run: $(TARGET) translations
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(TARGET) $(DAEMON)
|
rm -f $(TARGET) $(DAEMON)
|
||||||
rm -rf locale
|
rm -rf locale build
|
||||||
|
|
||||||
install: $(TARGET) $(DAEMON) translations
|
install: $(TARGET) $(DAEMON) translations
|
||||||
install -d $(DESTDIR)$(BINDIR)
|
install -d $(DESTDIR)$(BINDIR)
|
||||||
@@ -94,6 +108,10 @@ install: $(TARGET) $(DAEMON) translations
|
|||||||
install -d $(DESTDIR)$(UNIT_DIR)
|
install -d $(DESTDIR)$(UNIT_DIR)
|
||||||
sed 's|@BINDIR@|$(BINDIR)|g' data/$(DAEMON).service > $(DESTDIR)$(UNIT_DIR)/$(DAEMON).service
|
sed 's|@BINDIR@|$(BINDIR)|g' data/$(DAEMON).service > $(DESTDIR)$(UNIT_DIR)/$(DAEMON).service
|
||||||
chmod 644 $(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
|
-command -v udevadm >/dev/null && udevadm control --reload-rules 2>/dev/null || true
|
||||||
-systemctl --user daemon-reload 2>/dev/null || true
|
-systemctl --user daemon-reload 2>/dev/null || true
|
||||||
@echo
|
@echo
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -3,8 +3,10 @@
|
|||||||
GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910**
|
GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910**
|
||||||
to other keys or commands (Linux Mint / Ubuntu).
|
to other keys or commands (Linux Mint / Ubuntu).
|
||||||
|
|
||||||
The GUI is complete. A background daemon (`gkeymapper-daemon`) that reads the
|
The GUI is complete. The background daemon (`gkeymapper-daemon`) that reads the
|
||||||
G-keys from the keyboard is in progress — see `docs/daemon-plan.md`.
|
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.
|
Documentation and source comments are written in English.
|
||||||
|
|
||||||
@@ -17,16 +19,18 @@ Documentation and source comments are written in English.
|
|||||||
- GTK 4 development files
|
- GTK 4 development files
|
||||||
- libusb 1.0 development files (for the daemon)
|
- libusb 1.0 development files (for the daemon)
|
||||||
- libxkbcommon development files (for the daemon's layout handling)
|
- 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
|
### Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt update
|
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
|
Afterwards `pkg-config --modversion gtk4`, `pkg-config --modversion libusb-1.0`
|
||||||
`pkg-config --modversion libusb-1.0` should print versions.
|
and `pkg-config --modversion gio-2.0` should print versions.
|
||||||
|
|
||||||
## Setting up the project
|
## Setting up the project
|
||||||
|
|
||||||
@@ -46,7 +50,9 @@ make
|
|||||||
|
|
||||||
This produces the `gkeymapper` GUI binary, the `gkeymapper-daemon` binary and
|
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
|
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
|
## Rebuild translations
|
||||||
|
|
||||||
@@ -70,6 +76,10 @@ LANG=de_DE.UTF-8 GKEYMAPPER_LOCALEDIR=$PWD/locale ./gkeymapper
|
|||||||
|
|
||||||
Supported languages: German, English, Spanish, French.
|
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
|
## Running the daemon
|
||||||
|
|
||||||
The daemon reads G1–G9 from the keyboard and replays the configured action
|
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.
|
`pgrep -x gkeymapper-daemon` does **not** match.
|
||||||
- A failed reload keeps the previous mappings, so a transient read error never
|
- A failed reload keeps the previous mappings, so a transient read error never
|
||||||
silently disables every key.
|
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
|
- 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`
|
`z` **on your layout**. The layout is read from `/etc/default/keyboard`
|
||||||
(`XKBLAYOUT`); without it, US key positions are used. Because the name is
|
(`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.
|
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)
|
## Install (optional)
|
||||||
|
|
||||||
```bash
|
```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/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/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`) |
|
| `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:
|
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
|
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
|
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
|
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
|
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
|
make clean
|
||||||
```
|
```
|
||||||
|
|
||||||
Removes the binary and the generated `locale/` directory.
|
Removes the binaries and the generated `locale/` and `build/` directories.
|
||||||
|
|
||||||
## Capturing key sequences
|
## Capturing key sequences
|
||||||
|
|
||||||
@@ -194,22 +240,26 @@ never sees a half-written file and the watch reloads exactly once per save.
|
|||||||
## Project structure
|
## 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/keyrow.h # KeyRow (one per G key)
|
||||||
src/mappings.{h,cpp} # GUI glue: config file <-> row widgets
|
src/mappings.{h,cpp} # GUI glue: config file <-> row widgets
|
||||||
src/capturedialog.{h,cpp} # key-capture dialog
|
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/mapping.{h,cpp} # mappings format + shortcut parsing (GUI & daemon)
|
||||||
shared/keys.{h,cpp} # canonical key names -> Linux key codes
|
shared/keys.{h,cpp} # canonical key names -> Linux key codes
|
||||||
shared/keymap.{h,cpp} # layout-aware key lookups (xkbcommon), GUI + daemon
|
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/main.cpp # event loop: read G-keys, run the action
|
||||||
daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910)
|
daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910)
|
||||||
daemon/g910_device.{h,cpp} # libusb access to the vendor interface
|
daemon/g910_device.{h,cpp} # libusb access to the vendor interface
|
||||||
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
|
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
|
||||||
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled"
|
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled"
|
||||||
daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload)
|
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/60-gkeymapper.rules # udev rule (uaccess for device + /dev/uinput)
|
||||||
data/gkeymapper-uinput.conf # modules-load.d fragment for uinput
|
data/gkeymapper-uinput.conf # modules-load.d fragment for uinput
|
||||||
data/gkeymapper-daemon.service# systemd user unit (installed by make install)
|
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)
|
docs/daemon-plan.md # daemon plan (reference: g910-gkey-macro-support)
|
||||||
po/ # Translations (pot/po/mo targets)
|
po/ # Translations (pot/po/mo targets)
|
||||||
Makefile # Build via pkg-config + gettext
|
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
|
## 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.
|
- Support the media keys and the M1–M3 / MR keys of the G910.
|
||||||
- udev rule and systemd user service so the daemon can autostart.
|
- Profiles: swap mapping sets on the fly (the G910's M keys), which is what the
|
||||||
- `make install` for the daemon binary, the udev rule and the service file.
|
reserved `SetProfile` D-Bus method was meant for.
|
||||||
|
- Key sequence timing: per-step delays for recorded macros.
|
||||||
|
|||||||
219
daemon/ipc_dbus.cpp
Normal file
219
daemon/ipc_dbus.cpp
Normal 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
74
daemon/ipc_dbus.h
Normal 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
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
// configured action and replays it ("key" via uinput, "command" via exec).
|
// configured action and replays it ("key" via uinput, "command" via exec).
|
||||||
//
|
//
|
||||||
// Milestones: M0 (shared core), M1 (device bring-up/decoding), M2 (action
|
// Milestones: M0 (shared core), M1 (device bring-up/decoding), M2 (action
|
||||||
// execution) and M3 (config watching + lifecycle) are done; the GUI channel
|
// execution), M3 (config watching + lifecycle) and M4 (D-Bus GUI channel) are
|
||||||
// (D-Bus) is still open (M4).
|
// done.
|
||||||
|
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "config_watch.h"
|
#include "config_watch.h"
|
||||||
#include "device_tables.h"
|
#include "device_tables.h"
|
||||||
#include "g910_device.h"
|
#include "g910_device.h"
|
||||||
|
#include "ipc_dbus.h"
|
||||||
#include "mapping.h"
|
#include "mapping.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -50,14 +51,13 @@ int main(int argc, char** argv) {
|
|||||||
|
|
||||||
// Reads the config; on failure the previous mappings stay in place, so a
|
// Reads the config; on failure the previous mappings stay in place, so a
|
||||||
// transient read error never silently disables every key.
|
// transient read error never silently disables every key.
|
||||||
auto reload = [&]() {
|
auto load = [&]() -> bool {
|
||||||
if (gkeymapper::load_mappings_file(configPath, mappings)) {
|
const bool ok = gkeymapper::load_mappings_file(configPath, mappings);
|
||||||
std::fprintf(stderr, "gkeymapper-daemon: loaded %s\n", configPath.c_str());
|
std::fprintf(stderr, "gkeymapper-daemon: %s %s\n",
|
||||||
} else {
|
ok ? "loaded" : "cannot read", configPath.c_str());
|
||||||
std::fprintf(stderr, "gkeymapper-daemon: cannot read %s\n", configPath.c_str());
|
return ok;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
reload();
|
load();
|
||||||
|
|
||||||
// The GUI is the writer; a save is picked up through inotify, SIGHUP/SIGUSR1
|
// 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.
|
// 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;
|
gkeymapper::daemon::ActionRunner actions;
|
||||||
actions.init();
|
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;
|
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()) {
|
if (!device.open()) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
device.disable_binding();
|
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];
|
uint8_t buffer[64];
|
||||||
while (g_running) {
|
while (g_running) {
|
||||||
// The loop wakes up at least every read timeout, which also bounds how
|
// Control plane first (bounded by the read timeout below), then the
|
||||||
// long a config change takes to be applied (well under a second).
|
// 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();
|
watcher.poll_events();
|
||||||
if (g_reload || watcher.take_change()) {
|
if (g_reload || watcher.take_change()) {
|
||||||
g_reload = 0;
|
g_reload = 0;
|
||||||
@@ -90,29 +151,32 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
if (n < 0) {
|
if (n < 0) {
|
||||||
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), reopening\n", n);
|
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), reopening\n", n);
|
||||||
|
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
|
||||||
device.close();
|
device.close();
|
||||||
if (!device.open()) {
|
if (!device.open()) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
device.disable_binding();
|
device.disable_binding();
|
||||||
profile = device.profile();
|
profile = device.profile();
|
||||||
|
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
|
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
|
||||||
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
|
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
|
||||||
|
run_mapping(index, false);
|
||||||
const gkeymapper::Mapping& mapping = mappings.keys[index];
|
const gkeymapper::Mapping& mapping = mappings.keys[index];
|
||||||
std::printf("G%d -> %s:%s\n", index + 1,
|
service.notify_key_pressed(
|
||||||
gkeymapper::action_type_token(mapping.type),
|
index, std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
|
||||||
mapping.target.c_str());
|
mapping.target);
|
||||||
std::fflush(stdout);
|
|
||||||
actions.run(mapping);
|
|
||||||
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
|
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
|
||||||
// Key released; nothing to do.
|
// Key released; nothing to do.
|
||||||
}
|
}
|
||||||
// Media keys and LED acknowledgements are ignored.
|
// Media keys and LED acknowledgements are ignored.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
|
||||||
|
service.pump();
|
||||||
device.close();
|
device.close();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
10
data/dev.rager.gkeymapper.Daemon.service
Normal file
10
data/dev.rager.gkeymapper.Daemon.service
Normal 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
|
||||||
@@ -12,7 +12,12 @@
|
|||||||
Description=gKeyMapper daemon - G1..G9 remapping for the Logitech G910
|
Description=gKeyMapper daemon - G1..G9 remapping for the Logitech G910
|
||||||
|
|
||||||
[Service]
|
[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
|
ExecStart=@BINDIR@/gkeymapper-daemon
|
||||||
# Reload the mappings without a restart (the config file is watched as well).
|
# Reload the mappings without a restart (the config file is watched as well).
|
||||||
ExecReload=/bin/kill -HUP $MAINPID
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
|||||||
16
data/icons.gresource.xml
Normal file
16
data/icons.gresource.xml
Normal 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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# gKeyMapper daemon — implementation plan
|
# 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
|
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.
|
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.
|
`z` is now correctly resolved to evdev 21.
|
||||||
- **AltGr (added after M2)** — level-3 combinations are representable as
|
- **AltGr (added after M2)** — level-3 combinations are representable as
|
||||||
`altgr+7`; the base (level 0) key is stored plus the `altgr` modifier.
|
`altgr+7`; the base (level 0) key is stored plus the `altgr` modifier.
|
||||||
- **Next: M4 (D-Bus GUI channel)** — status, explicit `Reload`, `TestMapping`
|
- **M4 done** — D-Bus control channel: `daemon/ipc_dbus` registers
|
||||||
and signals. The reload side of it is already covered by the M3 file watch.
|
`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
|
## 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
|
- `uinput` kernel module must be present; ship `/etc/modules-load.d/` fragment
|
||||||
or document `modprobe uinput` (here it is built in).
|
or document `modprobe uinput` (here it is built in).
|
||||||
- **systemd --user** unit `gkeymapper-daemon.service`:
|
- **systemd --user** unit `gkeymapper-daemon.service`:
|
||||||
`Type=dbus`, `BusName=…`, `ExecStart=gkeymapper-daemon`,
|
`Type=dbus`, `BusName=dev.rager.gkeymapper.Daemon`,
|
||||||
`Restart=on-failure`, `Documentation=…`. `BusName` enables D-Bus activation,
|
`ExecStart=gkeymapper-daemon`, `Restart=on-failure`, `ExecReload=SIGHUP`.
|
||||||
so starting the GUI can start the daemon on demand.
|
`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
|
- 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).
|
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
|
implicitly by calling a D-Bus method (activation) and can show whether it is
|
||||||
running.
|
running.
|
||||||
|
|
||||||
### 5.1 Proposed D-Bus interface
|
### 5.1 D-Bus interface (implemented)
|
||||||
|
|
||||||
- Bus name: `<app-id>` — decide once (§11). Suggested: `dev.rager.gkeymapper`
|
- App id: `dev.rager.gkeymapper` (replaces the placeholder `com.example.gkeymapper`);
|
||||||
(replace the current 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`
|
- Object path: `/dev/rager/gkeymapper/Daemon`
|
||||||
- Interface: `dev.rager.gkeymapper.Daemon`
|
- Interface: `dev.rager.gkeymapper.Daemon`
|
||||||
|
|
||||||
Methods
|
Methods
|
||||||
| Method | Signature | Purpose |
|
| Method | Signature | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). |
|
| `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. |
|
| `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 mapping for G1..G9 without hardware (GUI "Test" button). |
|
| `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. |
|
||||||
| `SetProfile` | `(s profile) -> ()` | Reserved for M-key profiles (later). |
|
|
||||||
|
|
||||||
Signals
|
Signals
|
||||||
| Signal | Signature | Purpose |
|
| 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. |
|
| `ConfigReloaded` | `(b ok)` | Daemon finished reloading. |
|
||||||
| `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. |
|
| `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. |
|
||||||
|
|
||||||
Notes
|
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.
|
- `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
|
- If D-Bus is unavailable, the daemon still works; the GUI degrades to
|
||||||
"no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for
|
"no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for
|
||||||
scripted use, matching the reference.
|
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
|
### 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.
|
- 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.
|
- GUI first, daemon later: GUI writes config; on daemon start it reads the file.
|
||||||
|
|
||||||
### 5.3 GUI-side changes (small)
|
### 5.3 GUI-side changes (as implemented)
|
||||||
- On "Save mappings": keep writing the file, then call `Reload()` (ignore error).
|
- On "Save mappings": writes the file, then calls `Reload()` best-effort; the
|
||||||
- Add a footer status line: daemon running / device present / last reload, fed by
|
file watch is the backstop, so a lost call is harmless (a save therefore
|
||||||
`GetStatus` + the `DeviceChanged`/`ConfigReloaded` signals.
|
triggers two reloads, both idempotent).
|
||||||
- Optional "Test" button per row → `TestMapping(index)`.
|
- Footer status line: `Daemon: running/not reachable` + `Device: …` + the last G
|
||||||
- The key-name vocabulary must match: the capture dialog already writes
|
key that fired, fed by `GetStatus`, the `DeviceChanged`/`ConfigReloaded`
|
||||||
lowercase GDK key names; move the canonical name list into the shared module
|
signals and `NameOwnerChanged` (so it follows the daemon starting/stopping).
|
||||||
(§6) so GUI and daemon agree on the same spellings.
|
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
|
## 6. Code layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/ # GUI (existing)
|
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/
|
shared/
|
||||||
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
|
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
|
||||||
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
|
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
|
||||||
keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon)
|
keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon)
|
||||||
|
ipc_ids.h # app id, version, D-Bus names
|
||||||
data/
|
data/
|
||||||
60-gkeymapper.rules # udev: uaccess for the device and /dev/uinput
|
60-gkeymapper.rules # udev: uaccess for the device and /dev/uinput
|
||||||
gkeymapper-uinput.conf # modules-load.d fragment (uinput)
|
gkeymapper-uinput.conf # modules-load.d fragment (uinput)
|
||||||
gkeymapper-daemon.service # systemd user unit (@BINDIR@ filled in on install)
|
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)
|
po/ # shared catalogs (GUI strings)
|
||||||
```
|
```
|
||||||
|
|
||||||
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
|
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
|
||||||
for the unit, udev rule and modules-load fragment. New build dependency:
|
for the unit, udev rule, modules-load fragment and activation file. New build
|
||||||
`libusb-1.0-0-dev` (GLib/GIO is already required by the GUI).
|
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)
|
## 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,
|
- Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events,
|
||||||
RSS in the low single-digit MB.
|
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
|
## 8. Milestones
|
||||||
|
|
||||||
- **M0 – Shared core.** `shared/mapping` + `shared/keys`; refactor the GUI to use
|
- **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`,
|
- **M3 – Config & lifecycle (done).** config reload via inotify (`config_watch`,
|
||||||
directory watch + atomic save) and `SIGHUP`/`SIGUSR1`; systemd --user unit +
|
directory watch + atomic save) and `SIGHUP`/`SIGUSR1`; systemd --user unit +
|
||||||
udev rule + modules-load, installed by `make install`.
|
udev rule + modules-load, installed by `make install`.
|
||||||
- **M4 – IPC.** GDBus service (`Reload`, `GetStatus`, `TestMapping`, signals);
|
- **M4 – IPC (done).** GDBus service (`Reload`, `GetStatus`, `TestMapping`,
|
||||||
GUI status line + Reload-on-save + optional Test button.
|
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
|
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
|
||||||
(hot-plug, debounce, error reporting to the GUI).
|
(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).
|
- Device: replay captured reports into the decoder (no hardware needed).
|
||||||
- Integration: `evtest` / `libinput debug-events` to observe the virtual device;
|
- Integration: `evtest` / `libinput debug-events` to observe the virtual device;
|
||||||
compare `xev` output for a configured shortcut between reference and daemon.
|
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.
|
- Permission tests: verify the udev rule grants access as a normal user.
|
||||||
- Resource check: `powertop`/`perf` or simple wakeup counting while idle.
|
- 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.
|
in M0 is mandatory before the daemon consumes the file.
|
||||||
- **uinput device identity**: a second keyboard appears; choose stable
|
- **uinput device identity**: a second keyboard appears; choose stable
|
||||||
name/vendor/product so the desktop does not confuse it with the real one.
|
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
|
## 11. Decisions to make
|
||||||
|
|
||||||
@@ -356,6 +406,24 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
|||||||
|
|
||||||
### Resolved
|
### 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
|
- **Event loop (as implemented, deviates from §4.2).** The daemon is
|
||||||
single-threaded with a blocking `libusb_interrupt_transfer` and a 500 ms
|
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
|
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
|
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
|
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.
|
layouts — a German `z` is evdev 21, not the US position 44.
|
||||||
- **Reload without restart (done in M2).** `SIGHUP` re-reads the config file
|
- **Reload without restart (done in M2, completed in M4).** `SIGHUP`/`SIGUSR1`
|
||||||
(the full D-Bus `Reload` comes with M4).
|
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**
|
- **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
|
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
|
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
|
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
|
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.
|
failed reload keeps the previous mappings instead of disabling every key.
|
||||||
- **Lifecycle (done in M3).** A systemd **user** unit (not a system service): the
|
- **Lifecycle (done in M3, extended in M4).** A systemd **user** unit (not a
|
||||||
daemon needs the session for `/dev/uinput` and for the `DISPLAY` a `command`
|
system service): the daemon needs the session for `/dev/uinput` and for the
|
||||||
action inherits. It is `Type=simple` for now; it becomes `Type=dbus` with a
|
`DISPLAY` a `command` action inherits. Since M4 it is `Type=dbus` with
|
||||||
`BusName` once M4 adds the D-Bus service. The udev rule and the
|
`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`
|
`modules-load.d` fragment live in `data/` and are installed by `make install`
|
||||||
(under `PREFIX/lib/...`, which udev and systemd both search).
|
(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.
|
||||||
|
|||||||
15
po/de.po
15
po/de.po
@@ -129,3 +129,18 @@ msgstr "Mappings speichern"
|
|||||||
|
|
||||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||||
msgstr "Gespeichert in ~/.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 ..."
|
||||||
|
|||||||
15
po/en.po
15
po/en.po
@@ -129,3 +129,18 @@ msgstr "Save mappings"
|
|||||||
|
|
||||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||||
msgstr "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..."
|
||||||
|
|||||||
15
po/es.po
15
po/es.po
@@ -127,3 +127,18 @@ msgstr "Guardar asignaciones"
|
|||||||
|
|
||||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||||
msgstr "Guardado en ~/.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..."
|
||||||
|
|||||||
15
po/fr.po
15
po/fr.po
@@ -127,3 +127,18 @@ msgstr "Enregistrer les attributions"
|
|||||||
|
|
||||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||||
msgstr "Enregistré dans ~/.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..."
|
||||||
|
|||||||
@@ -164,3 +164,23 @@ msgstr ""
|
|||||||
#: src/main.cpp:608
|
#: src/main.cpp:608
|
||||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||||
msgstr ""
|
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
29
shared/ipc_ids.h
Normal 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
191
src/daemonclient.cpp
Normal 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
69
src/daemonclient.h
Normal 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
|
||||||
155
src/main.cpp
155
src/main.cpp
@@ -10,6 +10,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "capturedialog.h"
|
#include "capturedialog.h"
|
||||||
|
#include "daemonclient.h"
|
||||||
|
#include "ipc_ids.h"
|
||||||
#include "keyrow.h"
|
#include "keyrow.h"
|
||||||
#include "mapping.h"
|
#include "mapping.h"
|
||||||
#include "mappings.h"
|
#include "mappings.h"
|
||||||
@@ -29,6 +31,59 @@ using gkeymapper::kNumKeys;
|
|||||||
|
|
||||||
std::vector<KeyRow> rows;
|
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) {
|
static void on_pick_file_response(GtkDialog* dialog, int response, gpointer userData) {
|
||||||
if (response == GTK_RESPONSE_ACCEPT) {
|
if (response == GTK_RESPONSE_ACCEPT) {
|
||||||
KeyRow* row = static_cast<KeyRow*>(userData);
|
KeyRow* row = static_cast<KeyRow*>(userData);
|
||||||
@@ -101,6 +156,10 @@ void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
|
|||||||
return;
|
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";
|
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);
|
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) {
|
static void show_about_dialog(GtkWindow* parent) {
|
||||||
GtkWidget* dialog = gtk_about_dialog_new();
|
GtkWidget* dialog = gtk_about_dialog_new();
|
||||||
gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), _("gKeyMapper"));
|
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),
|
gtk_about_dialog_set_comments(GTK_ABOUT_DIALOG(dialog),
|
||||||
_("G-Key mapper for the Logitech G910 (GTK4)."));
|
_("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(GTK_ABOUT_DIALOG(dialog), "https://rager.dev");
|
||||||
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
|
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
|
||||||
|
|
||||||
std::string iconPath = find_asset("icon.png");
|
GdkTexture* texture = nullptr;
|
||||||
GError* error = nullptr;
|
if (have_embedded_icon()) {
|
||||||
GdkTexture* texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
|
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) {
|
if (texture) {
|
||||||
gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture));
|
gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture));
|
||||||
g_object_unref(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);
|
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||||
g_signal_connect(dialog, "response", G_CALLBACK(gtk_window_destroy), NULL);
|
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();
|
GtkWidget* headerBar = gtk_header_bar_new();
|
||||||
gtk_window_set_titlebar(GTK_WINDOW(window), headerBar);
|
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");
|
gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper");
|
||||||
|
|
||||||
GtkWidget* titleBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
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_image_set_pixel_size(GTK_IMAGE(iconImage), 24);
|
||||||
gtk_box_append(GTK_BOX(titleBox), iconImage);
|
gtk_box_append(GTK_BOX(titleBox), iconImage);
|
||||||
gtk_box_append(GTK_BOX(titleBox), gtk_label_new(_("gKeyMapper")));
|
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);
|
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
gtk_window_set_child(GTK_WINDOW(window), outer);
|
gtk_window_set_child(GTK_WINDOW(window), outer);
|
||||||
|
|
||||||
// Title / subtitle
|
// Subtitle only: the app name is already in the header bar, and the heading
|
||||||
GtkWidget* headerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
// spelled it differently ("G-Key Mapper") on top of that.
|
||||||
gtk_widget_set_margin_top(headerBox, 16);
|
GtkWidget* subtitle =
|
||||||
gtk_widget_set_margin_bottom(headerBox, 8);
|
gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
|
||||||
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"));
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(subtitle), 0.0f);
|
gtk_label_set_xalign(GTK_LABEL(subtitle), 0.0f);
|
||||||
gtk_widget_add_css_class(subtitle, "dim-label");
|
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)
|
// Table with all nine G keys (no scrolling needed, everything stays visible)
|
||||||
GtkWidget* grid = gtk_grid_new();
|
GtkWidget* grid = gtk_grid_new();
|
||||||
@@ -340,28 +423,44 @@ void build_ui(GtkApplication* app) {
|
|||||||
update_row_state(&rows[i]);
|
update_row_state(&rows[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer with save button
|
// Footer with save button and the daemon status line
|
||||||
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||||
gtk_widget_set_margin_top(footer, 8);
|
gtk_widget_set_margin_top(footer, 8);
|
||||||
gtk_widget_set_margin_bottom(footer, 16);
|
gtk_widget_set_margin_bottom(footer, 16);
|
||||||
gtk_widget_set_margin_start(footer, 16);
|
gtk_widget_set_margin_start(footer, 16);
|
||||||
gtk_widget_set_margin_end(footer, 16);
|
gtk_widget_set_margin_end(footer, 16);
|
||||||
gtk_box_append(GTK_BOX(outer), footer);
|
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"));
|
GtkWidget* saveBtn = gtk_button_new_with_label(_("Save mappings"));
|
||||||
// No "suggested-action" class on purpose: it gets a strong accent color
|
// 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
|
// (e.g. Mint-Y green) that clashes with the rest of the layout. The plain
|
||||||
// button follows the system theme like everything else.
|
// button follows the system theme like everything else.
|
||||||
g_signal_connect(saveBtn, "clicked", G_CALLBACK(on_save_clicked), nullptr);
|
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"));
|
GtkWidget* hint = gtk_label_new(_("Saved to ~/.config/gKeyMapper/mappings.conf"));
|
||||||
gtk_widget_add_css_class(hint, "dim-label");
|
gtk_widget_add_css_class(hint, "dim-label");
|
||||||
gtk_widget_set_halign(hint, GTK_ALIGN_END);
|
gtk_widget_set_halign(hint, GTK_ALIGN_END);
|
||||||
gtk_widget_set_hexpand(hint, TRUE);
|
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));
|
gtk_window_present(GTK_WINDOW(window));
|
||||||
|
|
||||||
|
daemonClient.init(on_daemon_status, on_daemon_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
|
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");
|
bind_textdomain_codeset(PACKAGE, "UTF-8");
|
||||||
textdomain(PACKAGE);
|
textdomain(PACKAGE);
|
||||||
|
|
||||||
GtkApplication* app = gtk_application_new("com.example.gkeymapper",
|
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
|
||||||
G_APPLICATION_FLAGS_NONE);
|
G_APPLICATION_FLAGS_NONE);
|
||||||
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
|
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
|
||||||
int status = g_application_run(G_APPLICATION(app), argc, argv);
|
int status = g_application_run(G_APPLICATION(app), argc, argv);
|
||||||
|
|||||||
Reference in New Issue
Block a user