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
|
||||
- `make` — builds the `gkeymapper` GUI, the `gkeymapper-daemon` and the `.mo`
|
||||
catalogs. Needs `g++` (C++17), `make`, `gettext`, `libgtk-4-dev`,
|
||||
`libusb-1.0-0-dev` and `libxkbcommon-dev` (see README for `apt install` on a
|
||||
fresh machine).
|
||||
- `make daemon` — builds only `gkeymapper-daemon` (needs libusb + xkbcommon,
|
||||
not GTK).
|
||||
`libusb-1.0-0-dev`, `libxkbcommon-dev` and `libglib2.0-dev` (see README for
|
||||
`apt install` on a fresh machine). `libglib2.0-dev` also provides
|
||||
`glib-compile-resources`, which generates the icons embedded into the GUI.
|
||||
- `make daemon` — builds only `gkeymapper-daemon` (needs libusb, xkbcommon and
|
||||
gio-2.0, not GTK).
|
||||
- `make translations` — only runs `msgfmt` on the existing `.po` files.
|
||||
- `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and
|
||||
`GKEYMAPPER_ASSETS_DIR=$PWD/assets` so the working-tree translations and
|
||||
assets are used. Plain `./gkeymapper` works too (assets fall back to
|
||||
`./assets`).
|
||||
- `make clean` — deletes the binary and the generated `locale/` tree.
|
||||
`GKEYMAPPER_ASSETS_DIR=$PWD/assets` so the working-tree translations are used.
|
||||
Plain `./gkeymapper` works too (assets fall back to `./assets`). The window
|
||||
icon itself is compiled into the binary, so it never depends on these.
|
||||
- `make clean` — deletes the binaries and the generated `locale/` and `build/`
|
||||
trees.
|
||||
- No tests and no CI. Verify changes by building and running the app.
|
||||
|
||||
## i18n — easy to get wrong
|
||||
@@ -37,20 +39,24 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
- Modules under `src/` (GUI), `shared/` (used by both binaries) and `daemon/`
|
||||
(the background service). Shared API lives in `namespace gkeymapper`.
|
||||
`shared/mapping` owns the mappings file format, `shared/keys` the canonical
|
||||
key-name table (lowercase GDK names -> Linux `KEY_*`).
|
||||
- The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp`)
|
||||
**and** the daemon (`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`
|
||||
and `libxkbcommon-dev`). `make daemon` builds only the daemon.
|
||||
key-name table (lowercase GDK names -> Linux `KEY_*`), `shared/ipc_ids.h` the
|
||||
app id, the version and the D-Bus names both sides use.
|
||||
- The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp`
|
||||
+ the generated `build/gresources.c`, see Assets) **and** the daemon
|
||||
(`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`,
|
||||
`libxkbcommon-dev` and `libglib2.0-dev`). `make daemon` builds only the daemon.
|
||||
- The daemon is split into: `device_tables` (raw HID report tables per model),
|
||||
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
|
||||
keyboard), `action` (dispatch `key`/`command`/`disabled`) and `config_watch`
|
||||
(inotify auto-reload of the mappings file). `shared/keymap` does the layout
|
||||
lookups for both sides. The hot path never touches the GUI.
|
||||
keyboard), `action` (dispatch `key`/`command`/`disabled`), `config_watch`
|
||||
(inotify auto-reload of the mappings file) and `ipc_dbus` (the GDBus service).
|
||||
`shared/keymap` does the layout lookups for both sides. The hot path never
|
||||
touches the GUI: D-Bus is control plane only and is pumped once per loop.
|
||||
- `make install` (PREFIX default `/usr/local`) also installs the packaging
|
||||
files from `data/`: the udev rule, the `modules-load.d` fragment and the
|
||||
systemd **user** unit. The unit contains an `@BINDIR@` placeholder that the
|
||||
Makefile substitutes with `$(PREFIX)/bin`. Keep it a user service — it needs
|
||||
the session for `/dev/uinput` and `DISPLAY`.
|
||||
files from `data/`: the udev rule, the `modules-load.d` fragment, the
|
||||
systemd **user** unit and the D-Bus activation file. Both contain an
|
||||
`@BINDIR@` placeholder that the Makefile substitutes with `$(PREFIX)/bin`.
|
||||
Keep it a user service — it needs the session for `/dev/uinput` and
|
||||
`DISPLAY`.
|
||||
- Daemon config reload: `config_watch` watches the config **directory** (so an
|
||||
atomic save via `rename` is caught) and reports a change at most once per loop
|
||||
iteration, which is the coalescing window. `SIGHUP`/`SIGUSR1` remain as a
|
||||
@@ -58,7 +64,27 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
- `main.cpp` keeps the `rows` vector (one `KeyRow` per G key, widget pointers)
|
||||
in an anonymous namespace; `src/mappings.cpp` is GUI glue that moves data
|
||||
between the widgets and the shared `MappingSet`. App id:
|
||||
`com.example.gkeymapper`.
|
||||
`dev.rager.gkeymapper` (`ipc::kAppId`); the About dialog uses `ipc::kVersion`.
|
||||
- GUI ↔ daemon is D-Bus on the session bus, GDBus (GIO) on both sides:
|
||||
`src/daemonclient.{h,cpp}` is the client, `daemon/ipc_dbus.{h,cpp}` the
|
||||
service. The daemon owns `dev.rager.gkeymapper.Daemon`, **not** the plain app
|
||||
id — `GtkApplication` owns that itself to detect a second GUI instance, so a
|
||||
daemon on the same name would make every GUI start look like a duplicate and
|
||||
exit. All names live in `shared/ipc_ids.h`.
|
||||
- The GUI never does a blocking D-Bus call: `DaemonClient` uses async calls and
|
||||
signal subscriptions, which arrive on the GTK main loop, and it follows
|
||||
`NameOwnerChanged` so the footer reacts when the daemon starts or stops.
|
||||
- `DaemonService::pump()` dispatches messages once per daemon loop iteration, so
|
||||
a control call is answered within one read timeout (≤500 ms) and outgoing
|
||||
signals are flushed then too. Keep it off the keypress path.
|
||||
- The daemon refuses to start when the name is taken (`DO_NOT_QUEUE` + exit 0
|
||||
with a message): two instances would fight over the device and create two
|
||||
virtual keyboards. That check runs **before** the device is opened.
|
||||
- The daemon status line is a plain `GtkLabel` in the window footer, updated with
|
||||
a single `gtk_label_set_text()`. It is deliberately **not** a `GtkStatusbar`:
|
||||
that needs message pop/push bookkeeping per context id, and GTK4 exposes it
|
||||
over AT-SPI as a generic `filler`, not as a "statusbar" role, so it buys
|
||||
nothing here.
|
||||
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
|
||||
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens
|
||||
in the file. Keep that order.
|
||||
@@ -113,9 +139,20 @@ tracks `ISO_Level3_Shift` / `Mode_switch` through its own key press/release
|
||||
`kModAltGr` maps to `KEY_RIGHTALT`.
|
||||
|
||||
## Assets
|
||||
`find_asset()` resolves in order: `$GKEYMAPPER_ASSETS_DIR` → `./assets` →
|
||||
compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). The icon
|
||||
(`assets/icon.png`) is used in the header bar and the About dialog.
|
||||
The icons (`assets/icon.png`, `assets/icon.svg`) are compiled into the GUI:
|
||||
`data/icons.gresource.xml` is turned into the generated `build/gresources.c` by
|
||||
`glib-compile-resources` and linked into `gkeymapper`, so the window has its icon
|
||||
with nothing installed. The resource prefix mirrors the app id
|
||||
(`/dev/rager/gkeymapper`) and the `icons/` subtree mirrors the hicolor layout, so
|
||||
`gtk_icon_theme_add_resource_path()` also answers
|
||||
`gtk_window_set_icon_name("gkeymapper")` from the binary. The daemon embeds no
|
||||
resource (it has no UI).
|
||||
|
||||
`make_icon_image()` and the About dialog take the embedded copy first and fall
|
||||
back to `find_asset()`, which resolves `$GKEYMAPPER_ASSETS_DIR` → `./assets` →
|
||||
compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). Because the
|
||||
resource is built from `assets/` it cannot go stale, and it deliberately wins
|
||||
over any installed file; the file lookup remains only as a safety net.
|
||||
|
||||
## Driving the daemon for manual checks
|
||||
- The process name is truncated by the kernel to 15 chars, so `pkill -x
|
||||
@@ -130,15 +167,44 @@ compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). The icon
|
||||
(`kill -HUP $(pgrep -x gkeymapper-daem)`). Normally you do not need them: the
|
||||
config file is watched, so saving in the GUI reloads within about half a
|
||||
second. Use `SIGHUP` to check the signal path after touching it.
|
||||
- Test the auto-reload without hardware: write the config through the shared
|
||||
save path (atomic temp+rename, as the GUI does) and watch `/tmp/daemon.log`
|
||||
for a second `loaded ...` line. `daemon/config_watch.cpp` also has a
|
||||
standalone test shape: create a `ConfigWatcher`, `poll_events()` then
|
||||
`take_change()` per simulated loop iteration.
|
||||
- If the systemd user service is enabled, **stop it first**
|
||||
(`systemctl --user stop gkeymapper-daemon`) before starting the repo build by
|
||||
hand: the daemon refuses to run when the bus name is taken (a second instance
|
||||
would fight over the device). Restore it afterwards with
|
||||
`systemctl --user start gkeymapper-daemon`. Note that the installed unit runs
|
||||
`/usr/local/bin/gkeymapper-daemon`, so *that* binary is the old one until the
|
||||
next `sudo make install`.
|
||||
- D-Bus control channel (the daemon owns `dev.rager.gkeymapper.Daemon`):
|
||||
|
||||
```bash
|
||||
D=dev.rager.gkeymapper.Daemon; O=/dev/rager/gkeymapper/Daemon
|
||||
gdbus call --session --dest $D --object-path $O --method $D.GetStatus
|
||||
gdbus call --session --dest $D --object-path $O --method $D.Reload
|
||||
gdbus call --session --dest $D --object-path $O --method $D.TestMapping 0
|
||||
gdbus monitor --session --dest $D # KeyPressed / ConfigReloaded / ...
|
||||
```
|
||||
|
||||
`TestMapping` runs the same path as a real keypress, so it verifies a mapping
|
||||
without hardware (index is 0-based, `0` = G1). A `TestMapping` for a
|
||||
`disabled:`/empty row fails with `G_n_ has no usable mapping`. `busctl --user`
|
||||
also works.
|
||||
- The config watch is testable without the device: `daemon/config_watch.cpp` has
|
||||
a standalone test shape (create a `ConfigWatcher`, `poll_events()` then
|
||||
`take_change()` per simulated loop iteration), and `ConfigReloaded` on the
|
||||
`gdbus monitor` above reports when a save was applied. A GUI save triggers two
|
||||
reloads on purpose — the file watch and the explicit `Reload()` call.
|
||||
- D-Bus activation is only testable in a real setup: the installed
|
||||
`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` needs
|
||||
`sudo make install`. For a quick check without root, drop a file with
|
||||
`Exec=<repo>/gkeymapper-daemon` (no `SystemdService=`) into
|
||||
`~/.local/share/dbus-1/services/` — the session bus picks it up without a
|
||||
restart — then call a method with no daemon running and watch it appear.
|
||||
Remove that file afterwards, or it shadows the installed one.
|
||||
- `make install DESTDIR=/tmp/stage` stages the packaging files; inspect
|
||||
`/tmp/stage/usr/local/lib/...` to verify the unit's `ExecStart` substitution.
|
||||
`systemd-analyze --user verify <unit>` checks the unit. There is no
|
||||
`udevadm verify` on systemd 249, so the udev rule is checked by eye against
|
||||
`/tmp/stage/usr/local/lib/...` and
|
||||
`/tmp/stage/usr/local/share/dbus-1/services/...` to verify the `@BINDIR@`
|
||||
substitution. `systemd-analyze --user verify <unit>` checks the unit. There is
|
||||
no `udevadm verify` on systemd 249, so the udev rule is checked by eye against
|
||||
the reference project's rule.
|
||||
- Starting it detaches the kernel driver from the vendor interface and creates a
|
||||
`/dev/uinput` keyboard. Warn the user first: while it runs the media keys may
|
||||
@@ -158,3 +224,15 @@ a child `label` node, so target the button by its child label. `pyatspi`
|
||||
(`queryAction().doAction(0)`) plus `Xlib`'s `xtest.fake_input` can click buttons
|
||||
and inject keys. Warn the user before synthesizing keystrokes or stealing window
|
||||
focus.
|
||||
|
||||
The footer's daemon line is a plain `GtkLabel`, so its text can be read back over
|
||||
AT-SPI: `<app> → frame → panel → label` whose name starts with the translated
|
||||
`Daemon:` string. To pick the right window, filter the application whose tree
|
||||
contains that label — a stale GUI from an earlier build may be running from the
|
||||
same binary name. Two notes for tests:
|
||||
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real
|
||||
and the GUI does **not** load that file at startup, so a save from an
|
||||
unconfigured window replaces the user's mappings with empty ones. Back the file
|
||||
up first (or skip the click and call `Reload` over D-Bus instead).
|
||||
- `XTest`-synthesized clicks need the window mapped and focused; the `Save`
|
||||
button is `push button` + name `Mappings speichern` in the German locale.
|
||||
|
||||
26
Makefile
26
Makefile
@@ -6,6 +6,7 @@ CXX ?= g++
|
||||
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic
|
||||
PKG_CONFIG ?= pkg-config
|
||||
MSGFMT ?= msgfmt
|
||||
GLIB_COMPILE_RESOURCES ?= glib-compile-resources
|
||||
|
||||
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
|
||||
GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4)
|
||||
@@ -13,6 +14,8 @@ USB_CFLAGS := $(shell $(PKG_CONFIG) --cflags libusb-1.0)
|
||||
USB_LIBS := $(shell $(PKG_CONFIG) --libs libusb-1.0)
|
||||
XKB_CFLAGS := $(shell $(PKG_CONFIG) --cflags xkbcommon)
|
||||
XKB_LIBS := $(shell $(PKG_CONFIG) --libs xkbcommon)
|
||||
GIO_CFLAGS := $(shell $(PKG_CONFIG) --cflags gio-2.0)
|
||||
GIO_LIBS := $(shell $(PKG_CONFIG) --libs gio-2.0)
|
||||
|
||||
PACKAGE := gkeymapper
|
||||
TARGET := gkeymapper
|
||||
@@ -24,6 +27,9 @@ GUI_SOURCES := $(wildcard $(SRC_DIR)/*.cpp) $(wildcard $(SHARED_DIR)/*.cpp)
|
||||
GUI_HEADERS := $(wildcard $(SRC_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
|
||||
DAEMON_SOURCES := $(wildcard $(DAEMON_DIR)/*.cpp) $(wildcard $(SHARED_DIR)/*.cpp)
|
||||
DAEMON_HEADERS := $(wildcard $(DAEMON_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
|
||||
# Icons embedded into the GUI (generated from data/icons.gresource.xml).
|
||||
GUI_RES := build/gresources.c
|
||||
GUI_RES_SRC := data/icons.gresource.xml
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR := $(PREFIX)/bin
|
||||
LOCALEDIR := $(PREFIX)/share/locale
|
||||
@@ -31,6 +37,7 @@ ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets
|
||||
UDEV_DIR := $(PREFIX)/lib/udev/rules.d
|
||||
MODULES_DIR := $(PREFIX)/lib/modules-load.d
|
||||
UNIT_DIR := $(PREFIX)/lib/systemd/user
|
||||
DBUS_DIR := $(PREFIX)/share/dbus-1/services
|
||||
|
||||
CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\"
|
||||
CXXFLAGS += -I$(SHARED_DIR)
|
||||
@@ -44,13 +51,20 @@ MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS))
|
||||
|
||||
all: $(TARGET) translations $(DAEMON)
|
||||
|
||||
$(TARGET): $(GUI_SOURCES) $(GUI_HEADERS)
|
||||
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) $(XKB_CFLAGS) -o $@ $(GUI_SOURCES) $(GTK_LIBS) $(XKB_LIBS)
|
||||
# Embedded GUI icons. --c-name keeps the gkeymapper_* symbols stable; --sourcedir
|
||||
# is the repository root because the XML refers to the assets by their real path.
|
||||
$(GUI_RES): $(GUI_RES_SRC) assets/icon.png assets/icon.svg
|
||||
@mkdir -p $(@D)
|
||||
$(GLIB_COMPILE_RESOURCES) --generate-source --target=$@ --sourcedir=. \
|
||||
--c-name $(PACKAGE) $(GUI_RES_SRC)
|
||||
|
||||
$(TARGET): $(GUI_SOURCES) $(GUI_HEADERS) $(GUI_RES)
|
||||
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) $(XKB_CFLAGS) -o $@ $(GUI_SOURCES) $(GUI_RES) $(GTK_LIBS) $(XKB_LIBS)
|
||||
|
||||
daemon: $(DAEMON)
|
||||
|
||||
$(DAEMON): $(DAEMON_SOURCES) $(DAEMON_HEADERS)
|
||||
$(CXX) $(CXXFLAGS) $(USB_CFLAGS) $(XKB_CFLAGS) -o $@ $(DAEMON_SOURCES) $(USB_LIBS) $(XKB_LIBS)
|
||||
$(CXX) $(CXXFLAGS) $(USB_CFLAGS) $(XKB_CFLAGS) $(GIO_CFLAGS) -o $@ $(DAEMON_SOURCES) $(USB_LIBS) $(XKB_LIBS) $(GIO_LIBS)
|
||||
|
||||
translations: $(MO_FILES)
|
||||
|
||||
@@ -63,7 +77,7 @@ run: $(TARGET) translations
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET) $(DAEMON)
|
||||
rm -rf locale
|
||||
rm -rf locale build
|
||||
|
||||
install: $(TARGET) $(DAEMON) translations
|
||||
install -d $(DESTDIR)$(BINDIR)
|
||||
@@ -94,6 +108,10 @@ install: $(TARGET) $(DAEMON) translations
|
||||
install -d $(DESTDIR)$(UNIT_DIR)
|
||||
sed 's|@BINDIR@|$(BINDIR)|g' data/$(DAEMON).service > $(DESTDIR)$(UNIT_DIR)/$(DAEMON).service
|
||||
chmod 644 $(DESTDIR)$(UNIT_DIR)/$(DAEMON).service
|
||||
# D-Bus activation: the first GUI call can start the daemon on demand.
|
||||
install -d $(DESTDIR)$(DBUS_DIR)
|
||||
sed 's|@BINDIR@|$(BINDIR)|g' data/dev.rager.gkeymapper.Daemon.service > $(DESTDIR)$(DBUS_DIR)/dev.rager.gkeymapper.Daemon.service
|
||||
chmod 644 $(DESTDIR)$(DBUS_DIR)/dev.rager.gkeymapper.Daemon.service
|
||||
-command -v udevadm >/dev/null && udevadm control --reload-rules 2>/dev/null || true
|
||||
-systemctl --user daemon-reload 2>/dev/null || true
|
||||
@echo
|
||||
|
||||
72
README.md
72
README.md
@@ -3,8 +3,10 @@
|
||||
GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910**
|
||||
to other keys or commands (Linux Mint / Ubuntu).
|
||||
|
||||
The GUI is complete. A background daemon (`gkeymapper-daemon`) that reads the
|
||||
G-keys from the keyboard is in progress — see `docs/daemon-plan.md`.
|
||||
The GUI is complete. The background daemon (`gkeymapper-daemon`) that reads the
|
||||
G-keys from the keyboard and replays the actions is implemented as well; the
|
||||
GUI and the daemon talk over a small D-Bus channel (status, reload, test) — see
|
||||
`docs/daemon-plan.md`.
|
||||
|
||||
Documentation and source comments are written in English.
|
||||
|
||||
@@ -17,16 +19,18 @@ Documentation and source comments are written in English.
|
||||
- GTK 4 development files
|
||||
- libusb 1.0 development files (for the daemon)
|
||||
- libxkbcommon development files (for the daemon's layout handling)
|
||||
- GLib/GIO development files (for the GUI ↔ daemon D-Bus channel; `libgtk-4-dev`
|
||||
already pulls them in, but they are needed for `make daemon` on its own)
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev
|
||||
sudo apt install -y build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev libglib2.0-dev
|
||||
```
|
||||
|
||||
Afterwards `pkg-config --modversion gtk4` and
|
||||
`pkg-config --modversion libusb-1.0` should print versions.
|
||||
Afterwards `pkg-config --modversion gtk4`, `pkg-config --modversion libusb-1.0`
|
||||
and `pkg-config --modversion gio-2.0` should print versions.
|
||||
|
||||
## Setting up the project
|
||||
|
||||
@@ -46,7 +50,9 @@ make
|
||||
|
||||
This produces the `gkeymapper` GUI binary, the `gkeymapper-daemon` binary and
|
||||
the `.mo` translations under `locale/`. The daemon can also be built on its own
|
||||
with `make daemon`.
|
||||
with `make daemon`. The GUI has its icons (`assets/icon.png`, `assets/icon.svg`)
|
||||
compiled in (`data/icons.gresource.xml` → generated `build/gresources.c`), so the
|
||||
window icon works without an installed `assets/` directory.
|
||||
|
||||
## Rebuild translations
|
||||
|
||||
@@ -70,6 +76,10 @@ LANG=de_DE.UTF-8 GKEYMAPPER_LOCALEDIR=$PWD/locale ./gkeymapper
|
||||
|
||||
Supported languages: German, English, Spanish, French.
|
||||
|
||||
The footer of the window shows the daemon state (`Daemon: running · Device: …`
|
||||
plus the last G key that fired, or `Daemon: not reachable`) and follows it live:
|
||||
start or stop the daemon while the GUI is open and the line updates itself.
|
||||
|
||||
## Running the daemon
|
||||
|
||||
The daemon reads G1–G9 from the keyboard and replays the configured action
|
||||
@@ -101,6 +111,9 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
|
||||
`pgrep -x gkeymapper-daemon` does **not** match.
|
||||
- A failed reload keeps the previous mappings, so a transient read error never
|
||||
silently disables every key.
|
||||
- Only one instance may run: a second one prints why it cannot get the bus name
|
||||
and exits, instead of fighting over the keyboard and creating a second virtual
|
||||
keyboard.
|
||||
- Key names are layout-independent: `key:ctrl+z` means the key that produces
|
||||
`z` **on your layout**. The layout is read from `/etc/default/keyboard`
|
||||
(`XKBLAYOUT`); without it, US key positions are used. Because the name is
|
||||
@@ -113,6 +126,34 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
|
||||
|
||||
Stop it with Ctrl+C or `kill`; it reattaches the kernel driver on the way out.
|
||||
|
||||
### Status and control over D-Bus
|
||||
|
||||
The daemon owns the name `dev.rager.gkeymapper.Daemon` on the session bus. The
|
||||
GUI uses that channel for its footer line ("Daemon: running / not reachable",
|
||||
the device and the last G key that fired) and to apply a save immediately.
|
||||
Nothing on this channel is on the keypress path, so a GUI that is slow or absent
|
||||
never delays an action.
|
||||
|
||||
```bash
|
||||
D=dev.rager.gkeymapper.Daemon
|
||||
O=/dev/rager/gkeymapper/Daemon
|
||||
|
||||
gdbus call --session --dest $D --object-path $O --method $D.GetStatus
|
||||
gdbus call --session --dest $D --object-path $O --method $D.Reload
|
||||
gdbus call --session --dest $D --object-path $O --method $D.TestMapping 0
|
||||
gdbus monitor --session --dest $D # KeyPressed / ConfigReloaded / DeviceChanged
|
||||
```
|
||||
|
||||
- `GetStatus` → `(version, running, device, configPath)`.
|
||||
- `Reload` re-reads the mappings file; `ConfigReloaded(ok)` reports the result.
|
||||
- `TestMapping` fires a mapping without touching the keyboard (useful to check a
|
||||
sequence). The index is **0-based**: `0` = G1 … `8` = G9. It runs the same code
|
||||
path as a real keypress.
|
||||
- `KeyPressed(index, action)` is emitted for every G key, real or tested.
|
||||
|
||||
If no session bus is available the daemon still works — it just runs without the
|
||||
GUI channel and logs that.
|
||||
|
||||
## Install (optional)
|
||||
|
||||
```bash
|
||||
@@ -129,6 +170,7 @@ Installs, under `PREFIX` (default `/usr/local`):
|
||||
| `lib/udev/rules.d/60-gkeymapper.rules` | lets your user reach the keyboard and `/dev/uinput` |
|
||||
| `lib/modules-load.d/gkeymapper.conf` | loads the `uinput` module at boot |
|
||||
| `lib/systemd/user/gkeymapper-daemon.service` | systemd user unit (binary path filled in from `PREFIX`) |
|
||||
| `share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` | D-Bus activation: the GUI can start the daemon on demand |
|
||||
|
||||
Then enable the daemon for your session — it then starts automatically at login:
|
||||
|
||||
@@ -138,6 +180,10 @@ systemctl --user enable --now gkeymapper-daemon
|
||||
systemctl --user status gkeymapper-daemon
|
||||
```
|
||||
|
||||
Enabling it is optional: the D-Bus activation file above lets the bus start the
|
||||
daemon as soon as the GUI asks for its status. With both in place the unit is
|
||||
used either way, so the daemon never runs twice.
|
||||
|
||||
It is a **user** service on purpose: it needs your session for `/dev/uinput` and
|
||||
so that a `command` action inherits `DISPLAY`/`WAYLAND_DISPLAY`. The unit does
|
||||
not run as root. `make install` reloads the udev rules for you; on a fresh
|
||||
@@ -149,7 +195,7 @@ machine replug the keyboard (or reboot) once so the ACL applies.
|
||||
make clean
|
||||
```
|
||||
|
||||
Removes the binary and the generated `locale/` directory.
|
||||
Removes the binaries and the generated `locale/` and `build/` directories.
|
||||
|
||||
## Capturing key sequences
|
||||
|
||||
@@ -194,22 +240,26 @@ never sees a half-written file and the watch reloads exactly once per save.
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/main.cpp # app actions + GUI
|
||||
src/main.cpp # app actions + GUI + daemon status footer
|
||||
src/keyrow.h # KeyRow (one per G key)
|
||||
src/mappings.{h,cpp} # GUI glue: config file <-> row widgets
|
||||
src/capturedialog.{h,cpp} # key-capture dialog
|
||||
src/daemonclient.{h,cpp} # D-Bus client for the daemon (status/reload)
|
||||
shared/mapping.{h,cpp} # mappings format + shortcut parsing (GUI & daemon)
|
||||
shared/keys.{h,cpp} # canonical key names -> Linux key codes
|
||||
shared/keymap.{h,cpp} # layout-aware key lookups (xkbcommon), GUI + daemon
|
||||
shared/ipc_ids.h # app id + D-Bus names shared by GUI and daemon
|
||||
daemon/main.cpp # event loop: read G-keys, run the action
|
||||
daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910)
|
||||
daemon/g910_device.{h,cpp} # libusb access to the vendor interface
|
||||
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
|
||||
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled"
|
||||
daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload)
|
||||
daemon/ipc_dbus.{h,cpp} # D-Bus service (status/reload/test/signals)
|
||||
data/60-gkeymapper.rules # udev rule (uaccess for device + /dev/uinput)
|
||||
data/gkeymapper-uinput.conf # modules-load.d fragment for uinput
|
||||
data/gkeymapper-daemon.service# systemd user unit (installed by make install)
|
||||
data/dev.rager.gkeymapper.Daemon.service # D-Bus activation (session bus)
|
||||
docs/daemon-plan.md # daemon plan (reference: g910-gkey-macro-support)
|
||||
po/ # Translations (pot/po/mo targets)
|
||||
Makefile # Build via pkg-config + gettext
|
||||
@@ -244,7 +294,7 @@ export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:
|
||||
|
||||
## Planned next steps
|
||||
|
||||
- Daemon ↔ GUI channel (D-Bus): reload on save and show the daemon status.
|
||||
- Support the media keys and the M1–M3 / MR keys of the G910.
|
||||
- udev rule and systemd user service so the daemon can autostart.
|
||||
- `make install` for the daemon binary, the udev rule and the service file.
|
||||
- Profiles: swap mapping sets on the fly (the G910's M keys), which is what the
|
||||
reserved `SetProfile` D-Bus method was meant for.
|
||||
- Key sequence timing: per-step delays for recorded macros.
|
||||
|
||||
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).
|
||||
//
|
||||
// Milestones: M0 (shared core), M1 (device bring-up/decoding), M2 (action
|
||||
// execution) and M3 (config watching + lifecycle) are done; the GUI channel
|
||||
// (D-Bus) is still open (M4).
|
||||
// execution), M3 (config watching + lifecycle) and M4 (D-Bus GUI channel) are
|
||||
// done.
|
||||
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "config_watch.h"
|
||||
#include "device_tables.h"
|
||||
#include "g910_device.h"
|
||||
#include "ipc_dbus.h"
|
||||
#include "mapping.h"
|
||||
|
||||
namespace {
|
||||
@@ -50,14 +51,13 @@ int main(int argc, char** argv) {
|
||||
|
||||
// Reads the config; on failure the previous mappings stay in place, so a
|
||||
// transient read error never silently disables every key.
|
||||
auto reload = [&]() {
|
||||
if (gkeymapper::load_mappings_file(configPath, mappings)) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: loaded %s\n", configPath.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: cannot read %s\n", configPath.c_str());
|
||||
}
|
||||
auto load = [&]() -> bool {
|
||||
const bool ok = gkeymapper::load_mappings_file(configPath, mappings);
|
||||
std::fprintf(stderr, "gkeymapper-daemon: %s %s\n",
|
||||
ok ? "loaded" : "cannot read", configPath.c_str());
|
||||
return ok;
|
||||
};
|
||||
reload();
|
||||
load();
|
||||
|
||||
// The GUI is the writer; a save is picked up through inotify, SIGHUP/SIGUSR1
|
||||
// stay as the fallback for scripts and for systems without inotify.
|
||||
@@ -67,17 +67,78 @@ int main(int argc, char** argv) {
|
||||
gkeymapper::daemon::ActionRunner actions;
|
||||
actions.init();
|
||||
|
||||
// Declared before the IPC service so its status callback can report the
|
||||
// device. It is opened only after the name check below: a second instance
|
||||
// must not detach the kernel driver from the keyboard the running one uses.
|
||||
gkeymapper::daemon::GKeyDevice device;
|
||||
const gkeymapper::daemon::DeviceProfile* profile = nullptr;
|
||||
|
||||
// Runs the mapping of G<index+1> (0-based). A real keypress and the D-Bus
|
||||
// TestMapping call both go through here, so a test fires exactly the same
|
||||
// path. Returns whether there was an action to run.
|
||||
auto run_mapping = [&](int index, bool test) -> bool {
|
||||
if (index < 0 || index >= static_cast<int>(mappings.keys.size())) {
|
||||
return false;
|
||||
}
|
||||
const gkeymapper::Mapping& mapping = mappings.keys[index];
|
||||
const bool usable = mapping.type != gkeymapper::ActionType::Disabled &&
|
||||
!mapping.target.empty();
|
||||
std::printf("G%d -> %s:%s%s\n", index + 1,
|
||||
gkeymapper::action_type_token(mapping.type), mapping.target.c_str(),
|
||||
test ? " (test)" : "");
|
||||
std::fflush(stdout);
|
||||
if (usable) {
|
||||
actions.run(mapping);
|
||||
}
|
||||
return usable;
|
||||
};
|
||||
|
||||
gkeymapper::daemon::DaemonService service;
|
||||
auto reload = [&]() -> bool {
|
||||
const bool ok = load();
|
||||
service.notify_config_reloaded(ok);
|
||||
return ok;
|
||||
};
|
||||
service.set_reload(reload);
|
||||
service.set_device([&]() {
|
||||
return profile != nullptr ? std::string(profile->name) : std::string();
|
||||
});
|
||||
service.set_test([&](int index, std::string& action) {
|
||||
if (!run_mapping(index, true)) {
|
||||
return false;
|
||||
}
|
||||
const gkeymapper::Mapping& mapping = mappings.keys[index];
|
||||
action = std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
|
||||
mapping.target;
|
||||
return true;
|
||||
});
|
||||
|
||||
switch (service.start(configPath)) {
|
||||
case gkeymapper::daemon::StartResult::AlreadyRunning:
|
||||
// Two instances would fight over the device and create a second
|
||||
// virtual keyboard, so the newcomer gives way.
|
||||
std::fprintf(stderr,
|
||||
"gkeymapper-daemon: another instance is already running; "
|
||||
"exiting\n");
|
||||
return 0;
|
||||
case gkeymapper::daemon::StartResult::NoBus:
|
||||
case gkeymapper::daemon::StartResult::Ok:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!device.open()) {
|
||||
return 1;
|
||||
}
|
||||
device.disable_binding();
|
||||
const gkeymapper::daemon::DeviceProfile* profile = device.profile();
|
||||
profile = device.profile();
|
||||
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
|
||||
|
||||
uint8_t buffer[64];
|
||||
while (g_running) {
|
||||
// The loop wakes up at least every read timeout, which also bounds how
|
||||
// long a config change takes to be applied (well under a second).
|
||||
// Control plane first (bounded by the read timeout below), then the
|
||||
// keyboard. The loop wakes up at least every read timeout, which also
|
||||
// bounds how long a config change takes to be applied.
|
||||
service.pump();
|
||||
watcher.poll_events();
|
||||
if (g_reload || watcher.take_change()) {
|
||||
g_reload = 0;
|
||||
@@ -90,29 +151,32 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
if (n < 0) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), reopening\n", n);
|
||||
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
|
||||
device.close();
|
||||
if (!device.open()) {
|
||||
return 1;
|
||||
}
|
||||
device.disable_binding();
|
||||
profile = device.profile();
|
||||
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
|
||||
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
|
||||
run_mapping(index, false);
|
||||
const gkeymapper::Mapping& mapping = mappings.keys[index];
|
||||
std::printf("G%d -> %s:%s\n", index + 1,
|
||||
gkeymapper::action_type_token(mapping.type),
|
||||
mapping.target.c_str());
|
||||
std::fflush(stdout);
|
||||
actions.run(mapping);
|
||||
service.notify_key_pressed(
|
||||
index, std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
|
||||
mapping.target);
|
||||
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
|
||||
// Key released; nothing to do.
|
||||
}
|
||||
// Media keys and LED acknowledgements are ignored.
|
||||
}
|
||||
|
||||
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
|
||||
service.pump();
|
||||
device.close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Type=dbus: the unit counts as started once the daemon owns its bus name, so
|
||||
# D-Bus activation (data/dev.rager.gkeymapper.Daemon.service) and the GUI see a
|
||||
# consistent state. A second instance exits instead of fighting over the
|
||||
# keyboard, which two of them would do.
|
||||
Type=dbus
|
||||
BusName=dev.rager.gkeymapper.Daemon
|
||||
ExecStart=@BINDIR@/gkeymapper-daemon
|
||||
# Reload the mappings without a restart (the config file is watched as well).
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
16
data/icons.gresource.xml
Normal file
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
|
||||
|
||||
Status: draft / proposal
|
||||
Status: implemented (M0–M4 done, see "Implementation status")
|
||||
Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR
|
||||
and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
|
||||
|
||||
@@ -28,8 +28,16 @@ and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
|
||||
`z` is now correctly resolved to evdev 21.
|
||||
- **AltGr (added after M2)** — level-3 combinations are representable as
|
||||
`altgr+7`; the base (level 0) key is stored plus the `altgr` modifier.
|
||||
- **Next: M4 (D-Bus GUI channel)** — status, explicit `Reload`, `TestMapping`
|
||||
and signals. The reload side of it is already covered by the M3 file watch.
|
||||
- **M4 done** — D-Bus control channel: `daemon/ipc_dbus` registers
|
||||
`dev.rager.gkeymapper.Daemon` on the session bus and serves `Reload`,
|
||||
`GetStatus` and `TestMapping`, plus the `KeyPressed`, `ConfigReloaded` and
|
||||
`DeviceChanged` signals. `src/daemonclient.{h,cpp}` is the GUI side: the footer
|
||||
shows `Daemon: running/not reachable`, the device and the last G key that
|
||||
fired, and `Save mappings` calls `Reload()` (the file watch stays the
|
||||
backstop). `make install` ships a D-Bus activation file, so the GUI can start
|
||||
the daemon on demand; the unit is `Type=dbus` with `BusName=`. A second
|
||||
instance refuses to start instead of fighting over the keyboard. Verified with
|
||||
`gdbus` (methods, signals, activation) and over AT-SPI (live footer).
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
@@ -188,9 +196,15 @@ artifact so the runtime does zero parsing.
|
||||
- `uinput` kernel module must be present; ship `/etc/modules-load.d/` fragment
|
||||
or document `modprobe uinput` (here it is built in).
|
||||
- **systemd --user** unit `gkeymapper-daemon.service`:
|
||||
`Type=dbus`, `BusName=…`, `ExecStart=gkeymapper-daemon`,
|
||||
`Restart=on-failure`, `Documentation=…`. `BusName` enables D-Bus activation,
|
||||
so starting the GUI can start the daemon on demand.
|
||||
`Type=dbus`, `BusName=dev.rager.gkeymapper.Daemon`,
|
||||
`ExecStart=gkeymapper-daemon`, `Restart=on-failure`, `ExecReload=SIGHUP`.
|
||||
`Type=dbus` + `BusName` make the unit count as started only once the name is
|
||||
owned — the state D-Bus activation and the GUI see. `make install` also ships
|
||||
the D-Bus activation file
|
||||
(`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service`, with
|
||||
`SystemdService=gkeymapper-daemon.service`), so the bus starts the unit when
|
||||
the GUI asks for the daemon's status and the daemon runs exactly once either
|
||||
way.
|
||||
- Do **not** require root. Commands run with the user's privileges (the
|
||||
reference's "executed as root" note is a wiki artifact, not a design goal).
|
||||
|
||||
@@ -208,34 +222,44 @@ Three layers, from loosest to tightest:
|
||||
implicitly by calling a D-Bus method (activation) and can show whether it is
|
||||
running.
|
||||
|
||||
### 5.1 Proposed D-Bus interface
|
||||
### 5.1 D-Bus interface (implemented)
|
||||
|
||||
- Bus name: `<app-id>` — decide once (§11). Suggested: `dev.rager.gkeymapper`
|
||||
(replace the current placeholder `com.example.gkeymapper`).
|
||||
- App id: `dev.rager.gkeymapper` (replaces the placeholder `com.example.gkeymapper`);
|
||||
it is also the GApplication id.
|
||||
- Bus name: `dev.rager.gkeymapper.Daemon` — **not** the plain app id.
|
||||
`GtkApplication` owns the app id itself, in order to detect a second GUI
|
||||
instance; a daemon on the same name would make every GUI start look like a
|
||||
duplicate and exit. (The original plan had `<app-id>` here; that clash was
|
||||
found while implementing M4.)
|
||||
- Object path: `/dev/rager/gkeymapper/Daemon`
|
||||
- Interface: `dev.rager.gkeymapper.Daemon`
|
||||
|
||||
Methods
|
||||
| Method | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). |
|
||||
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. |
|
||||
| `TestMapping` | `(u index) -> ()` | Fire mapping for G1..G9 without hardware (GUI "Test" button). |
|
||||
| `SetProfile` | `(s profile) -> ()` | Reserved for M-key profiles (later). |
|
||||
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). Result via the `ConfigReloaded` signal. |
|
||||
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. `device` is `""` before the keyboard is open, so the GUI can omit that part. |
|
||||
| `TestMapping` | `(u index) -> ()` | Fire the mapping of one G key without hardware; same code path as a real press. `index` is **0-based** (`0` = G1 … `8` = G9). Fails with a D-Bus error for an out-of-range index or a `disabled:`/empty row. |
|
||||
|
||||
Signals
|
||||
| Signal | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `KeyPressed` | `(u index, s action)` | A G-key fired (for GUI highlight/history). |
|
||||
| `KeyPressed` | `(u index, s action)` | A G-key fired (GUI highlight/history; also emitted by `TestMapping`). |
|
||||
| `ConfigReloaded` | `(b ok)` | Daemon finished reloading. |
|
||||
| `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. |
|
||||
|
||||
Notes
|
||||
- All are control-plane calls; none is on the keypress path.
|
||||
- All are control-plane calls; none is on the keypress path. `DaemonService::pump()`
|
||||
dispatches once per daemon loop iteration, so a reply can take up to one read
|
||||
timeout (≤500 ms) — fine for status/reload, and the GUI calls asynchronously.
|
||||
- `Reload` is best-effort: inotify is the backstop if the call is lost.
|
||||
- If D-Bus is unavailable, the daemon still works; the GUI degrades to
|
||||
"no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for
|
||||
scripted use, matching the reference.
|
||||
- `SetProfile` was dropped: it is reserved for the M-key profiles and would have
|
||||
been a no-op today.
|
||||
- The daemon requests its name with `DO_NOT_QUEUE` and exits (code 0, with a
|
||||
message) when another instance owns it, before it touches the device.
|
||||
|
||||
### 5.2 Interaction sequences
|
||||
|
||||
@@ -257,34 +281,45 @@ Start-up orders:
|
||||
- Daemon first, GUI later: GUI reads status via D-Bus; no config write needed.
|
||||
- GUI first, daemon later: GUI writes config; on daemon start it reads the file.
|
||||
|
||||
### 5.3 GUI-side changes (small)
|
||||
- On "Save mappings": keep writing the file, then call `Reload()` (ignore error).
|
||||
- Add a footer status line: daemon running / device present / last reload, fed by
|
||||
`GetStatus` + the `DeviceChanged`/`ConfigReloaded` signals.
|
||||
- Optional "Test" button per row → `TestMapping(index)`.
|
||||
- The key-name vocabulary must match: the capture dialog already writes
|
||||
lowercase GDK key names; move the canonical name list into the shared module
|
||||
(§6) so GUI and daemon agree on the same spellings.
|
||||
### 5.3 GUI-side changes (as implemented)
|
||||
- On "Save mappings": writes the file, then calls `Reload()` best-effort; the
|
||||
file watch is the backstop, so a lost call is harmless (a save therefore
|
||||
triggers two reloads, both idempotent).
|
||||
- Footer status line: `Daemon: running/not reachable` + `Device: …` + the last G
|
||||
key that fired, fed by `GetStatus`, the `DeviceChanged`/`ConfigReloaded`
|
||||
signals and `NameOwnerChanged` (so it follows the daemon starting/stopping).
|
||||
The `Device:` part is omitted while the device is not open yet.
|
||||
- No "Test" button per row: pressing the real key is the better test while the
|
||||
hardware is at hand, and `TestMapping` stays available over `gdbus`/`busctl`
|
||||
without widening the fixed-width rows.
|
||||
- The key-name vocabulary matches because the capture dialog already writes
|
||||
lowercase GDK key names and the canonical list lives in `shared/keys` (§6).
|
||||
|
||||
## 6. Code layout
|
||||
|
||||
```
|
||||
src/ # GUI (existing)
|
||||
daemon/ # as implemented (see the list in §6 above)
|
||||
daemonclient.{h,cpp} # D-Bus client (status/reload/signals)
|
||||
daemon/ # as implemented (see §6 above)
|
||||
ipc_dbus.{h,cpp} # GDBus service (control plane)
|
||||
shared/
|
||||
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
|
||||
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
|
||||
keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon)
|
||||
ipc_ids.h # app id, version, D-Bus names
|
||||
data/
|
||||
60-gkeymapper.rules # udev: uaccess for the device and /dev/uinput
|
||||
gkeymapper-uinput.conf # modules-load.d fragment (uinput)
|
||||
gkeymapper-daemon.service # systemd user unit (@BINDIR@ filled in on install)
|
||||
dev.rager.gkeymapper.Daemon.service # D-Bus activation for the same unit
|
||||
icons.gresource.xml # GUI icons compiled into the binary (GResource)
|
||||
po/ # shared catalogs (GUI strings)
|
||||
```
|
||||
|
||||
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
|
||||
for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
`libusb-1.0-0-dev` (GLib/GIO is already required by the GUI).
|
||||
for the unit, udev rule, modules-load fragment and activation file. New build
|
||||
dependency: `libusb-1.0-0-dev` plus `libglib2.0-dev` (gio-2.0) for the daemon's
|
||||
D-Bus channel.
|
||||
|
||||
## 7. Resource-efficiency measures (summary)
|
||||
|
||||
@@ -300,6 +335,10 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
- Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events,
|
||||
RSS in the low single-digit MB.
|
||||
|
||||
Measured after M4 (systemd user unit, daemon idle): ~780 KB RSS, 14 ms CPU
|
||||
since start. The D-Bus control channel adds no wakeups of its own: `pump()`
|
||||
only dispatches what the loop's read timeout already woke up for.
|
||||
|
||||
## 8. Milestones
|
||||
|
||||
- **M0 – Shared core.** `shared/mapping` + `shared/keys`; refactor the GUI to use
|
||||
@@ -311,8 +350,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
- **M3 – Config & lifecycle (done).** config reload via inotify (`config_watch`,
|
||||
directory watch + atomic save) and `SIGHUP`/`SIGUSR1`; systemd --user unit +
|
||||
udev rule + modules-load, installed by `make install`.
|
||||
- **M4 – IPC.** GDBus service (`Reload`, `GetStatus`, `TestMapping`, signals);
|
||||
GUI status line + Reload-on-save + optional Test button.
|
||||
- **M4 – IPC (done).** GDBus service (`Reload`, `GetStatus`, `TestMapping`,
|
||||
signals); GUI status line + Reload-on-save; D-Bus activation file; unit
|
||||
`Type=dbus` + `BusName`; single-instance guard.
|
||||
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
|
||||
(hot-plug, debounce, error reporting to the GUI).
|
||||
|
||||
@@ -323,6 +363,10 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
- Device: replay captured reports into the decoder (no hardware needed).
|
||||
- Integration: `evtest` / `libinput debug-events` to observe the virtual device;
|
||||
compare `xev` output for a configured shortcut between reference and daemon.
|
||||
- D-Bus (M4): `gdbus call` for `GetStatus`/`Reload`/`TestMapping`,
|
||||
`gdbus monitor` for the signals, and a `TestMapping` observed with
|
||||
`xinput test <keyboard-id>` to confirm the emitted keycodes. The GUI's footer
|
||||
line is read back over AT-SPI to check the live status.
|
||||
- Permission tests: verify the udev rule grants access as a normal user.
|
||||
- Resource check: `powertop`/`perf` or simple wakeup counting while idle.
|
||||
|
||||
@@ -343,6 +387,12 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
in M0 is mandatory before the daemon consumes the file.
|
||||
- **uinput device identity**: a second keyboard appears; choose stable
|
||||
name/vendor/product so the desktop does not confuse it with the real one.
|
||||
- **GUI does not load the config at startup** (found while testing M4, GUI-side,
|
||||
not a daemon issue): `build_ui()` fills the rows empty and only
|
||||
`Import configuration...` reads the file, so pressing `Save mappings` in a
|
||||
freshly opened window overwrites the user's mappings with empty ones. Loading
|
||||
`~/.config/gKeyMapper/mappings.conf` on start (or refusing to save unchanged
|
||||
rows) should be the next GUI fix.
|
||||
|
||||
## 11. Decisions to make
|
||||
|
||||
@@ -356,6 +406,24 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
|
||||
### Resolved
|
||||
|
||||
- **Bus name / app id (done in M4).** App id (and GApplication id)
|
||||
`dev.rager.gkeymapper`; the daemon uses `dev.rager.gkeymapper.Daemon` because
|
||||
GtkApplication owns the plain app id to detect a second GUI instance. The
|
||||
installed `.desktop` file keeps its name `gkeymapper.desktop` — both old and
|
||||
new app id yield the X11 `WM_CLASS` `gkeymapper`, which is what
|
||||
`StartupWMClass` matches (verified with `xprop`).
|
||||
- **IPC (done in M4).** D-Bus with GDBus: libdbus and sd-bus dev files are not
|
||||
installed on this machine, GIO is; the daemon links `gio-2.0`.
|
||||
- **IPC event integration (done in M4).** The daemon keeps its simple blocking
|
||||
loop instead of moving to a `GMainLoop`: `DaemonService::pump()` calls
|
||||
`g_main_context_iteration(ctx, FALSE)` once per iteration, so D-Bus is serviced
|
||||
without a thread and without blocking, at the cost of up to one read timeout of
|
||||
latency on the control plane.
|
||||
- **Status line widget (evaluated).** The daemon status stays a plain
|
||||
`GtkLabel` in the window footer. `GtkStatusbar` does exist in GTK4 and is
|
||||
themed, but it keeps a message stack (pop/push per context id) and GTK4 exposes
|
||||
it over AT-SPI as a generic `filler`, not as a "statusbar" role — so it was
|
||||
built, compared side by side and dropped.
|
||||
- **Event loop (as implemented, deviates from §4.2).** The daemon is
|
||||
single-threaded with a blocking `libusb_interrupt_transfer` and a 500 ms
|
||||
timeout; the config watch is drained once per iteration. That is ~2 idle
|
||||
@@ -368,8 +436,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
from `/etc/default/keyboard`, via xkbcommon) and only falls back to the US
|
||||
positions from `shared/keys`. This closes the vocabulary-drift risk for non-US
|
||||
layouts — a German `z` is evdev 21, not the US position 44.
|
||||
- **Reload without restart (done in M2).** `SIGHUP` re-reads the config file
|
||||
(the full D-Bus `Reload` comes with M4).
|
||||
- **Reload without restart (done in M2, completed in M4).** `SIGHUP`/`SIGUSR1`
|
||||
re-read the config file; since M4 the GUI's `Save mappings` also calls the
|
||||
D-Bus `Reload`, and the inotify watch applies anything else.
|
||||
- **Config watching (done in M3).** The daemon watches the config **directory**
|
||||
with inotify rather than the file, so an atomic save (temp file + `rename`) and
|
||||
a recreated file are caught too. The loop iteration (≤ 500 ms) is the
|
||||
@@ -377,9 +446,14 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
stay as the fallback and there is no extra fd in the loop. `Save mappings` in
|
||||
the GUI now writes atomically, so the daemon can never read a partial file. A
|
||||
failed reload keeps the previous mappings instead of disabling every key.
|
||||
- **Lifecycle (done in M3).** A systemd **user** unit (not a system service): the
|
||||
daemon needs the session for `/dev/uinput` and for the `DISPLAY` a `command`
|
||||
action inherits. It is `Type=simple` for now; it becomes `Type=dbus` with a
|
||||
`BusName` once M4 adds the D-Bus service. The udev rule and the
|
||||
- **Lifecycle (done in M3, extended in M4).** A systemd **user** unit (not a
|
||||
system service): the daemon needs the session for `/dev/uinput` and for the
|
||||
`DISPLAY` a `command` action inherits. Since M4 it is `Type=dbus` with
|
||||
`BusName=dev.rager.gkeymapper.Daemon`, and a D-Bus activation file lets the bus
|
||||
start it on demand, so enabling the unit is optional. The udev rule and the
|
||||
`modules-load.d` fragment live in `data/` and are installed by `make install`
|
||||
(under `PREFIX/lib/...`, which udev and systemd both search).
|
||||
- **Single instance (done in M4).** The daemon requests its bus name with
|
||||
`DO_NOT_QUEUE` and exits with a message when it is already taken — two
|
||||
instances would detach the kernel driver from each other and create two virtual
|
||||
keyboards. The check happens before the device is opened.
|
||||
|
||||
15
po/de.po
15
po/de.po
@@ -129,3 +129,18 @@ msgstr "Mappings speichern"
|
||||
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Gespeichert in ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Daemon: nicht erreichbar"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Daemon: läuft"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Gerät: %s"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Letzte Taste: G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Daemon: wird geprüft ..."
|
||||
|
||||
15
po/en.po
15
po/en.po
@@ -129,3 +129,18 @@ msgstr "Save mappings"
|
||||
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Daemon: not reachable"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Daemon: running"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Device: %s"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Last key: G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Daemon: checking..."
|
||||
|
||||
15
po/es.po
15
po/es.po
@@ -127,3 +127,18 @@ msgstr "Guardar asignaciones"
|
||||
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Guardado en ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Demonio: no accesible"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Demonio: en ejecución"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Dispositivo: %s"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Última tecla: G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Demonio: comprobando..."
|
||||
|
||||
15
po/fr.po
15
po/fr.po
@@ -127,3 +127,18 @@ msgstr "Enregistrer les attributions"
|
||||
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Enregistré dans ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Démon : injoignable"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Démon : en cours d'exécution"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Appareil : %s"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Dernière touche : G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Démon : vérification..."
|
||||
|
||||
@@ -164,3 +164,23 @@ msgstr ""
|
||||
#: src/main.cpp:608
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:54
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:56
|
||||
msgid "Daemon: running"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:59
|
||||
msgid "Device: %s"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:82
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:429
|
||||
msgid "Daemon: checking..."
|
||||
msgstr ""
|
||||
|
||||
29
shared/ipc_ids.h
Normal file
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
|
||||
151
src/main.cpp
151
src/main.cpp
@@ -10,6 +10,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include "capturedialog.h"
|
||||
#include "daemonclient.h"
|
||||
#include "ipc_ids.h"
|
||||
#include "keyrow.h"
|
||||
#include "mapping.h"
|
||||
#include "mappings.h"
|
||||
@@ -29,6 +31,59 @@ using gkeymapper::kNumKeys;
|
||||
|
||||
std::vector<KeyRow> rows;
|
||||
|
||||
// Footer view of the daemon. It is filled by the asynchronous callbacks of
|
||||
// DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
|
||||
// for the daemon.
|
||||
struct DaemonFooter {
|
||||
bool reachable = false;
|
||||
std::string device;
|
||||
std::string lastKey;
|
||||
} daemonFooter;
|
||||
|
||||
GtkWidget* daemonLabel = nullptr;
|
||||
gkeymapper::DaemonClient daemonClient;
|
||||
|
||||
// Rebuilds the footer line from the state above. " · " is a separator, not a
|
||||
// sentence, so it is not translated.
|
||||
static void refresh_footer() {
|
||||
if (daemonLabel == nullptr) {
|
||||
return;
|
||||
}
|
||||
std::string text;
|
||||
if (!daemonFooter.reachable) {
|
||||
text = _("Daemon: not reachable");
|
||||
} else {
|
||||
text = _("Daemon: running");
|
||||
if (!daemonFooter.device.empty()) {
|
||||
g_autofree gchar* device =
|
||||
g_strdup_printf(_("Device: %s"), daemonFooter.device.c_str());
|
||||
text += " · ";
|
||||
text += device;
|
||||
}
|
||||
if (!daemonFooter.lastKey.empty()) {
|
||||
text += " · ";
|
||||
text += daemonFooter.lastKey;
|
||||
}
|
||||
}
|
||||
gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str());
|
||||
}
|
||||
|
||||
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
|
||||
daemonFooter.reachable = status.reachable;
|
||||
daemonFooter.device = status.device;
|
||||
if (!status.reachable) {
|
||||
daemonFooter.lastKey.clear(); // stale once the daemon is gone
|
||||
}
|
||||
refresh_footer();
|
||||
}
|
||||
|
||||
static void on_daemon_key(int index, const std::string& action) {
|
||||
g_autofree gchar* text =
|
||||
g_strdup_printf(_("Last key: G%d → %s"), index + 1, action.c_str());
|
||||
daemonFooter.lastKey = text;
|
||||
refresh_footer();
|
||||
}
|
||||
|
||||
static void on_pick_file_response(GtkDialog* dialog, int response, gpointer userData) {
|
||||
if (response == GTK_RESPONSE_ACCEPT) {
|
||||
KeyRow* row = static_cast<KeyRow*>(userData);
|
||||
@@ -101,6 +156,10 @@ void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Best effort: the daemon would pick the save up through its file watch
|
||||
// within about half a second, this just applies it right away.
|
||||
daemonClient.reload();
|
||||
|
||||
std::cout << _("Mappings saved to ") << path << "\n";
|
||||
}
|
||||
|
||||
@@ -134,24 +193,50 @@ static void on_import_response(GtkDialog* dialog, int response, gpointer /*userD
|
||||
|
||||
static std::string find_asset(const std::string& name);
|
||||
|
||||
// The icons are compiled into the executable (data/icons.gresource.xml), so the
|
||||
// GUI has them even when no assets are installed. The file lookup stays as a
|
||||
// fallback for a build without the resource; because the resource is embedded,
|
||||
// it wins over any installed file.
|
||||
static const char* kIconResource = "/dev/rager/gkeymapper/icon.png";
|
||||
static const char* kIconThemeResource = "/dev/rager/gkeymapper/icons";
|
||||
|
||||
static bool have_embedded_icon() {
|
||||
return g_resources_get_info(kIconResource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr, nullptr,
|
||||
nullptr);
|
||||
}
|
||||
|
||||
static GtkWidget* make_icon_image(int pixelSize) {
|
||||
GtkWidget* image = have_embedded_icon()
|
||||
? gtk_image_new_from_resource(kIconResource)
|
||||
: gtk_image_new_from_file(find_asset("icon.png").c_str());
|
||||
gtk_image_set_pixel_size(GTK_IMAGE(image), pixelSize);
|
||||
return image;
|
||||
}
|
||||
|
||||
static void show_about_dialog(GtkWindow* parent) {
|
||||
GtkWidget* dialog = gtk_about_dialog_new();
|
||||
gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), _("gKeyMapper"));
|
||||
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), "0.1.0");
|
||||
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), gkeymapper::ipc::kVersion);
|
||||
gtk_about_dialog_set_comments(GTK_ABOUT_DIALOG(dialog),
|
||||
_("G-Key mapper for the Logitech G910 (GTK4)."));
|
||||
gtk_about_dialog_set_website(GTK_ABOUT_DIALOG(dialog), "https://rager.dev");
|
||||
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
|
||||
|
||||
GdkTexture* texture = nullptr;
|
||||
if (have_embedded_icon()) {
|
||||
texture = gdk_texture_new_from_resource(kIconResource);
|
||||
} else {
|
||||
std::string iconPath = find_asset("icon.png");
|
||||
GError* error = nullptr;
|
||||
GdkTexture* texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
|
||||
texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
|
||||
if (!texture && error) {
|
||||
g_printerr("Could not load icon: %s\n", error->message);
|
||||
g_error_free(error);
|
||||
}
|
||||
}
|
||||
if (texture) {
|
||||
gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture));
|
||||
g_object_unref(texture);
|
||||
} else if (error) {
|
||||
g_printerr("Could not load icon: %s\n", error->message);
|
||||
g_error_free(error);
|
||||
}
|
||||
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||
g_signal_connect(dialog, "response", G_CALLBACK(gtk_window_destroy), NULL);
|
||||
@@ -236,10 +321,14 @@ void build_ui(GtkApplication* app) {
|
||||
|
||||
GtkWidget* headerBar = gtk_header_bar_new();
|
||||
gtk_window_set_titlebar(GTK_WINDOW(window), headerBar);
|
||||
// Serve "gkeymapper" from the embedded icons, so the window icon resolves
|
||||
// even without an installed hicolor theme.
|
||||
gtk_icon_theme_add_resource_path(
|
||||
gtk_icon_theme_get_for_display(gtk_widget_get_display(window)), kIconThemeResource);
|
||||
gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper");
|
||||
|
||||
GtkWidget* titleBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||
GtkWidget* iconImage = gtk_image_new_from_file(find_asset("icon.png").c_str());
|
||||
GtkWidget* iconImage = make_icon_image(24);
|
||||
gtk_image_set_pixel_size(GTK_IMAGE(iconImage), 24);
|
||||
gtk_box_append(GTK_BOX(titleBox), iconImage);
|
||||
gtk_box_append(GTK_BOX(titleBox), gtk_label_new(_("gKeyMapper")));
|
||||
@@ -261,23 +350,17 @@ void build_ui(GtkApplication* app) {
|
||||
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_window_set_child(GTK_WINDOW(window), outer);
|
||||
|
||||
// Title / subtitle
|
||||
GtkWidget* headerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||
gtk_widget_set_margin_top(headerBox, 16);
|
||||
gtk_widget_set_margin_bottom(headerBox, 8);
|
||||
gtk_widget_set_margin_start(headerBox, 16);
|
||||
gtk_widget_set_margin_end(headerBox, 16);
|
||||
gtk_box_append(GTK_BOX(outer), headerBox);
|
||||
|
||||
GtkWidget* title = gtk_label_new(nullptr);
|
||||
gtk_label_set_markup(GTK_LABEL(title), "<span size='x-large' weight='bold'>G-Key Mapper</span>");
|
||||
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
|
||||
gtk_box_append(GTK_BOX(headerBox), title);
|
||||
|
||||
GtkWidget* subtitle = gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
|
||||
// Subtitle only: the app name is already in the header bar, and the heading
|
||||
// spelled it differently ("G-Key Mapper") on top of that.
|
||||
GtkWidget* subtitle =
|
||||
gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
|
||||
gtk_label_set_xalign(GTK_LABEL(subtitle), 0.0f);
|
||||
gtk_widget_add_css_class(subtitle, "dim-label");
|
||||
gtk_box_append(GTK_BOX(headerBox), subtitle);
|
||||
gtk_widget_set_margin_top(subtitle, 16);
|
||||
gtk_widget_set_margin_bottom(subtitle, 8);
|
||||
gtk_widget_set_margin_start(subtitle, 16);
|
||||
gtk_widget_set_margin_end(subtitle, 16);
|
||||
gtk_box_append(GTK_BOX(outer), subtitle);
|
||||
|
||||
// Table with all nine G keys (no scrolling needed, everything stays visible)
|
||||
GtkWidget* grid = gtk_grid_new();
|
||||
@@ -340,28 +423,44 @@ void build_ui(GtkApplication* app) {
|
||||
update_row_state(&rows[i]);
|
||||
}
|
||||
|
||||
// Footer with save button
|
||||
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||
// Footer with save button and the daemon status line
|
||||
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||
gtk_widget_set_margin_top(footer, 8);
|
||||
gtk_widget_set_margin_bottom(footer, 16);
|
||||
gtk_widget_set_margin_start(footer, 16);
|
||||
gtk_widget_set_margin_end(footer, 16);
|
||||
gtk_box_append(GTK_BOX(outer), footer);
|
||||
|
||||
GtkWidget* footerRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||
gtk_box_append(GTK_BOX(footer), footerRow);
|
||||
|
||||
GtkWidget* saveBtn = gtk_button_new_with_label(_("Save mappings"));
|
||||
// No "suggested-action" class on purpose: it gets a strong accent color
|
||||
// (e.g. Mint-Y green) that clashes with the rest of the layout. The plain
|
||||
// button follows the system theme like everything else.
|
||||
g_signal_connect(saveBtn, "clicked", G_CALLBACK(on_save_clicked), nullptr);
|
||||
gtk_box_append(GTK_BOX(footer), saveBtn);
|
||||
gtk_box_append(GTK_BOX(footerRow), saveBtn);
|
||||
|
||||
GtkWidget* hint = gtk_label_new(_("Saved to ~/.config/gKeyMapper/mappings.conf"));
|
||||
gtk_widget_add_css_class(hint, "dim-label");
|
||||
gtk_widget_set_halign(hint, GTK_ALIGN_END);
|
||||
gtk_widget_set_hexpand(hint, TRUE);
|
||||
gtk_box_append(GTK_BOX(footer), hint);
|
||||
gtk_box_append(GTK_BOX(footerRow), hint);
|
||||
|
||||
// Daemon status: control plane only, the keypress path never leaves the
|
||||
// daemon. Text starts neutral so the footer does not change height when the
|
||||
// first reply arrives. Plain label on purpose: GTK4's GtkStatusbar would add
|
||||
// a message stack (pop/push) and is exposed over AT-SPI as a generic filler,
|
||||
// so it brings no benefit here.
|
||||
daemonLabel = gtk_label_new(_("Daemon: checking..."));
|
||||
gtk_widget_add_css_class(daemonLabel, "dim-label");
|
||||
gtk_label_set_xalign(GTK_LABEL(daemonLabel), 0.0f);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(footer), daemonLabel);
|
||||
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
|
||||
daemonClient.init(on_daemon_status, on_daemon_key);
|
||||
}
|
||||
|
||||
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
|
||||
@@ -392,7 +491,7 @@ int main(int argc, char** argv) {
|
||||
bind_textdomain_codeset(PACKAGE, "UTF-8");
|
||||
textdomain(PACKAGE);
|
||||
|
||||
GtkApplication* app = gtk_application_new("com.example.gkeymapper",
|
||||
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
|
||||
G_APPLICATION_FLAGS_NONE);
|
||||
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
|
||||
int status = g_application_run(G_APPLICATION(app), argc, argv);
|
||||
|
||||
Reference in New Issue
Block a user