Deamon management

This commit is contained in:
2026-10-17 03:53:51 +02:00
parent 335a4674d1
commit b77b4a3fc6
35 changed files with 2001 additions and 172 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
build/
locale/
gkeymapper
gkeymapper-daemon
*.o
*.so

110
AGENTS.md
View File

@@ -1,15 +1,19 @@
# AGENTS.md
## What this is
A pure GTK4 C++ GUI for remapping the G1–G9 keys of a Logitech G910. Today it
only edits and persists mappings: there is **no** hardware G-key detection and
**no** command execution yet (a native daemon is the planned next step). The
code is split into a few small translation units under `src/` (see below).
A pure GTK4 C++ GUI plus a background daemon for remapping the G1–G9 keys of a
Logitech G910. The GUI edits and persists mappings; the daemon reads the G-keys
and replays the configured action. Hardware G-key detection and action execution
live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
(common) and `daemon/`.
## Build / run / verify
- `make` — builds the `gkeymapper` binary **and** the `.mo` catalogs.
Needs `g++` (C++17), `make`, `gettext`, `libgtk-4-dev` (see README for
`apt install` on a fresh machine).
- `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).
- `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
@@ -30,18 +34,34 @@ code is split into a few small translation units under `src/` (see below).
`po/de.po`.
## Code layout and conventions
- Modules under `src/` (shared API lives in `namespace gkeymapper`):
`keyrow.h` (`KeyRow`, `kNumKeys`), `mappings.{h,cpp}` (config file I/O),
`capturedialog.{h,cpp}` (key-capture dialog), `main.cpp` (app actions + UI).
- The Makefile globs sources (`$(wildcard src/*.cpp)`): a new `.cpp` is compiled
automatically, and `src/*.h` are prerequisites of the target.
- 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.
- 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.
- `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`.
- Daemon config reload: `config_watch` watches the config **directory** (so an
atomic save via `rename` is caught) and reports a change at most once per loop
iteration, which is the coalescing window. `SIGHUP`/`SIGUSR1` remain as a
fallback. A failed reload keeps the previous mappings on purpose.
- `main.cpp` keeps the `rows` vector (one `KeyRow` per G key, widget pointers)
in an anonymous namespace; mappings functions receive it as a parameter.
`on_activate` registers the app actions `app.export-config`,
`app.import-config`, `app.about`; `build_ui` constructs the window. App id:
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`.
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
`2=Disabled`. Keep that order — import/export map against it.
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens
in the file. Keep that order.
- The capture dialog's `CaptureSession` is heap-allocated and freed only by the
window's `"destroy"` handler. Always close the dialog with
`gtk_window_destroy` so the session is released.
@@ -68,13 +88,69 @@ G3=disabled:
`<type>:<target>`, one line per key. `key` is a captured sequence (lowercase
`gdk_keyval_name` output; modifiers joined with `+`, steps joined with `,`),
`command` is a program/file path, `disabled` is inactive.
`command` is a program path or command line, `disabled` is inactive. The
canonical modifier order is `ctrl`, `alt`, `shift`, `super`, `altgr`
(`format_shortcut_step()`; parsing accepts any order).
Key names are **layout independent**: `shared/keymap` resolves a name like `z`
to the key that produces `z` on the active layout (read from
`/etc/default/keyboard`, `XKBLAYOUT`) via xkbcommon — on a German keyboard `z` is
evdev 21, not the US position 44. `shared/keys` maps names to the US positions
and is only the fallback. When you add key names, keep them lowercase GDK keysym
names. `parse_shortcut()` takes an optional resolver for this (the daemon passes
one).
The capture dialog stores the **base (level 0) key**: it uses
`Keymap::base_key_name(keycode)` instead of GDK's shifted keyval, so
`Shift`+ß becomes `shift+ssharp` (not `shift+question`, which would double the
shift). `format_shortcut_step()` — not the dialog — renders a step from a
`ModifierBit` mask, so the order stays in one place.
AltGr (level 3) is recorded as the `altgr` modifier plus the base key
(`AltGr`+7 → `altgr+7`). GTK4 dropped the `GDK_MOD5_MASK` name, so the dialog
tracks `ISO_Level3_Shift` / `Mode_switch` through its own key press/release
(`CaptureSession::altGrHeld`) instead of a state bit. On the replay side
`kModAltGr` maps to `KEY_RIGHTALT`.
## 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.
## Driving the daemon for manual checks
- The process name is truncated by the kernel to 15 chars, so `pkill -x
gkeymapper-daemon` and `pgrep -x gkeymapper-daemon` **never match**. Use
`pkill -x gkeymapper-daem` / `pgrep -x gkeymapper-daem` (or `pkill -f
'./gkeymapper-daemon'`).
- Run it in the background with `setsid nohup ./gkeymapper-daemon [config]
> /tmp/daemon.log 2>&1 < /dev/null &`; it survives across shell calls. It
takes an optional config path as `argv[1]` (default
`~/.config/gKeyMapper/mappings.conf`).
- `SIGHUP`/`SIGUSR1` reload the mappings
(`kill -HUP $(pgrep -x gkeymapper-daem)`). Normally you do not need them: the
config file is watched, so saving in the GUI reloads within about half a
second. Use `SIGHUP` to check the signal path after touching it.
- 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.
- `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
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
not work, and `key` mappings are injected into the **focused** window.
- The keyboard is `046d:c32b` (also handle `046d:c335`); on this machine the USB
device and `/dev/uinput` already carry ACLs for the user, so no root is needed.
- Verify decoding by pressing G keys and reading `/tmp/daemon.log` (it logs
`G1 -> key:...` per press). Verify what the virtual keyboard emits at the X
level with `xinput test <keyboard-id>` (`gKeyMapper virtual keyboard` shows up
as both a pointer and a keyboard — use the **keyboard** id). X keycodes are
evdev codes + 8. There is no `xdotool`/`wtype`.
## Driving the GUI for manual checks
There is no `xdotool`/`wtype` on this machine, and the capture-dialog buttons
(`Clear`/`Cancel`/`Done`) expose an empty AT-SPI accessible name — the text is in

View File

@@ -1,6 +1,6 @@
# gKeyMapper - Build
# Dependencies: g++, pkg-config, msgfmt, libgtk-4-dev
# Installation: sudo apt install build-essential gettext libgtk-4-dev
# Dependencies: g++, pkg-config, msgfmt, libgtk-4-dev, libusb-1.0-0-dev, libxkbcommon-dev
# Installation: sudo apt install build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev
CXX ?= g++
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic
@@ -9,29 +9,48 @@ MSGFMT ?= msgfmt
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4)
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)
PACKAGE := gkeymapper
TARGET := gkeymapper
SRC_DIR := src
SOURCES := $(wildcard $(SRC_DIR)/*.cpp)
HEADERS := $(wildcard $(SRC_DIR)/*.h)
PREFIX ?= /usr/local
LOCALEDIR := $(PREFIX)/share/locale
PACKAGE := gkeymapper
TARGET := gkeymapper
DAEMON := gkeymapper-daemon
SRC_DIR := src
SHARED_DIR := shared
DAEMON_DIR := daemon
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)
PREFIX ?= /usr/local
BINDIR := $(PREFIX)/bin
LOCALEDIR := $(PREFIX)/share/locale
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
CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\"
CXXFLAGS += -I$(SHARED_DIR)
# Translations
PO_FILES := $(wildcard po/*.po)
LANGS := $(patsubst po/%.po,%,$(PO_FILES))
MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS))
.PHONY: all clean run install translations
.PHONY: all clean run install translations daemon
all: $(TARGET) translations
all: $(TARGET) translations $(DAEMON)
$(TARGET): $(SOURCES) $(HEADERS)
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) -o $@ $(SOURCES) $(GTK_LIBS)
$(TARGET): $(GUI_SOURCES) $(GUI_HEADERS)
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) $(XKB_CFLAGS) -o $@ $(GUI_SOURCES) $(GTK_LIBS) $(XKB_LIBS)
daemon: $(DAEMON)
$(DAEMON): $(DAEMON_SOURCES) $(DAEMON_HEADERS)
$(CXX) $(CXXFLAGS) $(USB_CFLAGS) $(XKB_CFLAGS) -o $@ $(DAEMON_SOURCES) $(USB_LIBS) $(XKB_LIBS)
translations: $(MO_FILES)
@@ -43,12 +62,13 @@ run: $(TARGET) translations
GKEYMAPPER_LOCALEDIR=$(CURDIR)/locale GKEYMAPPER_ASSETS_DIR=$(CURDIR)/assets ./$(TARGET)
clean:
rm -f $(TARGET)
rm -f $(TARGET) $(DAEMON)
rm -rf locale
install: $(TARGET) translations
install -d $(DESTDIR)$(PREFIX)/bin
install -m 755 $(TARGET) $(DESTDIR)$(PREFIX)/bin/$(TARGET)
install: $(TARGET) $(DAEMON) translations
install -d $(DESTDIR)$(BINDIR)
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/$(TARGET)
install -m 755 $(DAEMON) $(DESTDIR)$(BINDIR)/$(DAEMON)
install -d $(DESTDIR)$(ASSETS_DIR)
install -m 644 assets/* $(DESTDIR)$(ASSETS_DIR)/
install -d $(DESTDIR)$(PREFIX)/share/icons/hicolor/256x256/apps
@@ -63,4 +83,20 @@ install: $(TARGET) translations
install -d $(DESTDIR)$(LOCALEDIR)/$$lang/LC_MESSAGES; \
install -m 644 locale/$$lang/LC_MESSAGES/$(PACKAGE).mo \
$(DESTDIR)$(LOCALEDIR)/$$lang/LC_MESSAGES/$(PACKAGE).mo; \
done
done
# udev: access to the vendor interface and /dev/uinput without root.
install -d $(DESTDIR)$(UDEV_DIR)
install -m 644 data/60-gkeymapper.rules $(DESTDIR)$(UDEV_DIR)/60-gkeymapper.rules
# Kernel module needed for the virtual keyboard.
install -d $(DESTDIR)$(MODULES_DIR)
install -m 644 data/gkeymapper-uinput.conf $(DESTDIR)$(MODULES_DIR)/$(PACKAGE).conf
# systemd user unit (@BINDIR@ is substituted with the configured PREFIX).
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
-command -v udevadm >/dev/null && udevadm control --reload-rules 2>/dev/null || true
-systemctl --user daemon-reload 2>/dev/null || true
@echo
@echo "Installed. Enable the daemon in your user session with:"
@echo " systemctl --user daemon-reload"
@echo " systemctl --user enable --now $(DAEMON)"

125
README.md
View File

@@ -3,7 +3,8 @@
GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910**
to other keys or commands (Linux Mint / Ubuntu).
The current focus is on the **GUI** only (no actual key capture/command execution yet).
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`.
Documentation and source comments are written in English.
@@ -14,15 +15,18 @@ Documentation and source comments are written in English.
- pkg-config
- gettext (for translations, `msgfmt`)
- GTK 4 development files
- libusb 1.0 development files (for the daemon)
- libxkbcommon development files (for the daemon's layout handling)
### Install dependencies
```bash
sudo apt update
sudo apt install -y build-essential gettext libgtk-4-dev
sudo apt install -y build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev
```
Afterwards `pkg-config --modversion gtk4` should print a version (e.g. `4.6.x`).
Afterwards `pkg-config --modversion gtk4` and
`pkg-config --modversion libusb-1.0` should print versions.
## Setting up the project
@@ -40,7 +44,9 @@ cd /home/trager/Nextcloud/Projekte/C++/gKeyMapper
make
```
This produces the `gkeymapper` binary and the `.mo` translations under `locale/`.
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`.
## Rebuild translations
@@ -64,14 +70,78 @@ LANG=de_DE.UTF-8 GKEYMAPPER_LOCALEDIR=$PWD/locale ./gkeymapper
Supported languages: German, English, Spanish, French.
## Running the daemon
The daemon reads G1–G9 from the keyboard and replays the configured action
(`key` through a virtual keyboard, `command` by launching the program):
```bash
./gkeymapper-daemon # uses ~/.config/gKeyMapper/mappings.conf
./gkeymapper-daemon /path/to.conf # or an explicit file
```
- It reaches the keyboard through libusb (raw USB HID, vendor interface 1) and
creates a virtual keyboard via `/dev/uinput`. Both need access for your user;
`make install` ships a udev rule (`TAG+="uaccess"`) for that. On Linux Mint the
device already has such an ACL, otherwise install the rule or add your user to
the `input`/`uinput` group.
- While it runs, the kernel driver is detached from the vendor interface, so the
media keys (mute / volume / play) may stop working until it exits — it
reattaches the driver on shutdown.
- The mappings file is **watched**: save in the GUI and the daemon picks the
change up by itself (within about half a second), no restart and no signal.
`SIGHUP`/`SIGUSR1` reload it on demand, which is also the fallback on systems
without inotify:
```bash
kill -HUP $(pgrep -x gkeymapper-daem)
```
Note: the kernel truncates the process name to 15 characters, so
`pgrep -x gkeymapper-daemon` does **not** match.
- A failed reload keeps the previous mappings, so a transient read error never
silently disables every key.
- 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
symbolic, the same entry keeps its meaning if the layout changes (as long as
the layout has that key); a name the layout does not provide is skipped.
- `altgr+...` presses AltGr (level 3), e.g. `key:altgr+7` types `{` on a German
layout.
- `command:...` is run through the shell, so arguments work
(`command:/usr/bin/firefox --new-window`); a plain program path is also fine.
Stop it with Ctrl+C or `kill`; it reattaches the kernel driver on the way out.
## Install (optional)
```bash
sudo make install
```
Installs `gkeymapper` to `/usr/local/bin` and the `.mo` files to
`/usr/local/share/locale`.
Installs, under `PREFIX` (default `/usr/local`):
| Path | What |
|---|---|
| `bin/gkeymapper`, `bin/gkeymapper-daemon` | the GUI and the daemon |
| `share/locale/*/LC_MESSAGES/gkeymapper.mo` | translations |
| `share/gkeymapper/assets/`, `share/applications/`, `share/icons/` | icon, `.desktop` file, icons |
| `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`) |
Then enable the daemon for your session — it then starts automatically at login:
```bash
systemctl --user daemon-reload
systemctl --user enable --now gkeymapper-daemon
systemctl --user status gkeymapper-daemon
```
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
machine replug the keyboard (or reboot) once so the ACL applies.
## Clean
@@ -88,13 +158,18 @@ For rows of type **Key**, the action button opens a capture dialog:
- Press keys one after another; each press becomes one step.
- Modifier keys (Ctrl, Alt, Shift, Super) held during a press are recorded
as part of that step.
- Each step stores the **base key** (the unshifted key), so `Shift`+ß is
recorded as `shift+ssharp` and not as the shifted symbol `question`.
- AltGr (level 3) is recorded as the `altgr` modifier plus the base key, so
`AltGr`+7 becomes `altgr+7`.
- The live preview shows the sequence. `Clear` resets it, `Done` writes it
into the row, `Cancel` discards it.
The sequence uses the same format as
[g910-gkey-macro-support](https://github.com/JSubelj/g910-gkey-macro-support):
modifiers are joined with `+`, steps are separated with `,`
(e.g. `ctrl+c,ctrl+v` or `shift+f4,super+t`).
(e.g. `ctrl+c,ctrl+v` or `shift+f4,super+t`). The modifier order is
`ctrl`, `alt`, `shift`, `super`, `altgr`; parsing accepts any order.
## Configuration format
@@ -108,18 +183,34 @@ G3=disabled:
```
The same format is used by Export/Import. `key` holds a captured shortcut
sequence, `command` a program or file path, `disabled` ignores the G key
(similar to the `shortcut` / `run` / `nothing` hotkey types of the
reference project above; a native C++ daemon for G-key detection and
replay is planned next, no Python involved).
sequence, `command` a program path or command line, `disabled` ignores the
G key. These map to the `shortcut` / `run` / `nothing` hotkey types of the
reference project; the native C++ daemon (`gkeymapper-daemon`) reads this file
and replays the actions — no Python involved.
`Save mappings` writes atomically (temporary file + `rename`), so the daemon
never sees a half-written file and the watch reloads exactly once per save.
## Project structure
```
src/main.cpp # app actions + GUI
src/keyrow.h # KeyRow / kNumKeys
src/mappings.{h,cpp} # mappings.conf read/write
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
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
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)
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)
docs/daemon-plan.md # daemon plan (reference: g910-gkey-macro-support)
po/ # Translations (pot/po/mo targets)
Makefile # Build via pkg-config + gettext
.vscode/tasks.json # VS Code tasks for make
@@ -153,7 +244,7 @@ export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:
## Planned next steps
- Real capture of key presses (evdev/uinput or libinput).
- Execution of commands when a G key is pressed.
- Loading and saving profiles.
- Autostart service / background daemon.
- 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.

68
daemon/action.cpp Normal file
View File

@@ -0,0 +1,68 @@
// gKeyMapper daemon - executes the mapping configured for a G key.
#include "action.h"
#include <cstdio>
#include <vector>
#include <unistd.h>
namespace gkeymapper::daemon {
bool ActionRunner::init() {
keymap_.init();
if (!keyboard_.open()) {
std::fprintf(stderr,
"gkeymapper-daemon: no virtual keyboard, 'key' mappings disabled\n");
return false;
}
return true;
}
void ActionRunner::run(const gkeymapper::Mapping& mapping) {
switch (mapping.type) {
case gkeymapper::ActionType::Key: {
std::vector<gkeymapper::ShortcutStep> steps;
// Resolve key names through the active keyboard layout.
gkeymapper::KeyResolver resolve = [this](const std::string& name) {
return keymap_.key_code(name);
};
if (!gkeymapper::parse_shortcut(mapping.target, steps, resolve)) {
std::fprintf(stderr, "gkeymapper-daemon: invalid shortcut '%s'\n",
mapping.target.c_str());
return;
}
keyboard_.play(steps);
break;
}
case gkeymapper::ActionType::Command:
run_command(mapping.target);
break;
case gkeymapper::ActionType::Disabled:
default:
break;
}
}
void ActionRunner::run_command(const std::string& command) {
if (command.empty()) {
return;
}
pid_t pid = fork();
if (pid < 0) {
std::fprintf(stderr, "gkeymapper-daemon: fork failed\n");
return;
}
if (pid == 0) {
// The field may be a bare program path (which may contain spaces) or a
// command line with arguments. Try the path directly first, then fall
// back to a shell so arguments and quoting also work.
execlp(command.c_str(), command.c_str(), static_cast<char*>(nullptr));
execl("/bin/sh", "sh", "-c", command.c_str(), static_cast<char*>(nullptr));
_exit(127);
}
// Parent: children are reaped by the SIGCHLD handler.
}
} // namespace gkeymapper::daemon

30
daemon/action.h Normal file
View File

@@ -0,0 +1,30 @@
// gKeyMapper daemon - executes the mapping configured for a G key.
#ifndef GKEYMAPPER_DAEMON_ACTION_H
#define GKEYMAPPER_DAEMON_ACTION_H
#include <string>
#include "keymap.h"
#include "mapping.h"
#include "uinput_keyboard.h"
namespace gkeymapper::daemon {
class ActionRunner {
public:
// Opens the virtual keyboard and the keyboard layout. Without the virtual
// keyboard, "key" mappings are skipped but "command" mappings still work.
bool init();
void run(const gkeymapper::Mapping& mapping);
private:
void run_command(const std::string& command);
UinputKeyboard keyboard_;
gkeymapper::Keymap keymap_;
};
} // namespace gkeymapper::daemon
#endif // GKEYMAPPER_DAEMON_ACTION_H

109
daemon/config_watch.cpp Normal file
View File

@@ -0,0 +1,109 @@
// gKeyMapper daemon - watches the mappings file for changes (inotify).
#include "config_watch.h"
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <sys/inotify.h>
#include <sys/stat.h>
#include <unistd.h>
namespace gkeymapper::daemon {
namespace {
// IN_CLOSE_WRITE is the useful one (the writer closed the file); IN_MODIFY
// covers writers that hold the file open, IN_MOVED_TO/CREATE the atomic save.
constexpr uint32_t kMask = IN_CLOSE_WRITE | IN_MODIFY | IN_MOVED_TO | IN_CREATE;
std::string directory_of(const std::string& path) {
size_t slash = path.find_last_of('/');
if (slash == std::string::npos) {
return ".";
}
return slash == 0 ? std::string("/") : path.substr(0, slash);
}
std::string basename_of(const std::string& path) {
size_t slash = path.find_last_of('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
} // namespace
ConfigWatcher::~ConfigWatcher() {
if (fd_ >= 0) {
::close(fd_);
}
}
bool ConfigWatcher::init(const std::string& path) {
name_ = basename_of(path);
directory_ = directory_of(path);
fd_ = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
if (fd_ < 0) {
std::fprintf(stderr,
"gkeymapper-daemon: inotify unavailable (%s); "
"use SIGHUP to reload\n",
std::strerror(errno));
return false;
}
int watch = inotify_add_watch(fd_, directory_.c_str(), kMask);
if (watch < 0 && errno == ENOENT) {
// First start before anything was ever saved: create the directory the
// GUI would create and try once more.
::mkdir(directory_.c_str(), 0755);
watch = inotify_add_watch(fd_, directory_.c_str(), kMask);
}
if (watch < 0) {
std::fprintf(stderr,
"gkeymapper-daemon: cannot watch %s (%s); "
"use SIGHUP to reload\n",
directory_.c_str(), std::strerror(errno));
::close(fd_);
fd_ = -1;
return false;
}
std::fprintf(stderr, "gkeymapper-daemon: watching %s for changes\n",
directory_.c_str());
return true;
}
void ConfigWatcher::poll_events() {
if (fd_ < 0) {
return;
}
alignas(inotify_event) char buffer[4096];
for (;;) {
ssize_t length = ::read(fd_, buffer, sizeof(buffer));
if (length <= 0) {
return; // nothing queued (EAGAIN) or a read error
}
for (ssize_t offset = 0; offset < length;) {
const auto* event = reinterpret_cast<const inotify_event*>(buffer + offset);
offset += static_cast<ssize_t>(sizeof(inotify_event)) + event->len;
// Only the configured file matters; the directory may hold others.
if (event->len == 0 || name_ == event->name) {
pending_ = true;
}
}
}
}
bool ConfigWatcher::take_change() {
if (!pending_) {
return false;
}
pending_ = false;
return true;
}
} // namespace gkeymapper::daemon

55
daemon/config_watch.h Normal file
View File

@@ -0,0 +1,55 @@
// gKeyMapper daemon - watches the mappings file for changes (inotify).
#ifndef GKEYMAPPER_DAEMON_CONFIG_WATCH_H
#define GKEYMAPPER_DAEMON_CONFIG_WATCH_H
#include <string>
namespace gkeymapper::daemon {
// Watches mappings.conf so a save in the GUI is picked up without a signal.
//
// The *directory* is watched, not the file: that also catches an atomic save
// (write a temporary file, rename it over the config) and the config appearing
// after it was deleted.
//
// poll_events() collects whatever happened since the last call and take_change()
// reports it once. Because the daemon evaluates this once per loop iteration
// (at least every read timeout, i.e. 500 ms), all events a single save produces
// collapse into one reload, and the file is complete by the time it is read
// (that is what the atomic save in shared/mapping.cpp guarantees).
//
// The daemon still accepts SIGHUP/SIGUSR1 as a fallback when inotify is
// unavailable.
class ConfigWatcher {
public:
ConfigWatcher() = default;
~ConfigWatcher();
ConfigWatcher(const ConfigWatcher&) = delete;
ConfigWatcher& operator=(const ConfigWatcher&) = delete;
// Watches the directory holding `path` (creating it if it does not exist
// yet). Returns false if inotify cannot be set up; the daemon then relies
// on signals only.
bool init(const std::string& path);
bool is_watching() const { return fd_ >= 0; }
// Consumes queued inotify events. Non-blocking and cheap; call it once per
// event-loop iteration.
void poll_events();
// True if the mappings file changed since the last call; clears the mark so
// every change is reported exactly once.
bool take_change();
private:
int fd_ = -1;
std::string directory_;
std::string name_;
bool pending_ = false;
};
} // namespace gkeymapper::daemon
#endif // GKEYMAPPER_DAEMON_CONFIG_WATCH_H

96
daemon/device_tables.cpp Normal file
View File

@@ -0,0 +1,96 @@
// gKeyMapper daemon - per-model raw HID report tables.
#include "device_tables.h"
#include <algorithm>
#include <initializer_list>
namespace gkeymapper::daemon {
namespace {
// The G910 reports are 20 bytes; set the given prefix and zero the rest.
Report report20(std::initializer_list<uint8_t> prefix) {
Report report(20, 0);
std::copy(prefix.begin(), prefix.end(), report.begin());
return report;
}
// G1..G9 press reports, ported from LogitechG910EventInterface.macroKeys.
std::vector<Report> g910GKeys() {
return {
report20({0x11, 0xff, 0x08, 0x00, 0x01}), // G1
report20({0x11, 0xff, 0x08, 0x00, 0x02}), // G2
report20({0x11, 0xff, 0x08, 0x00, 0x04}), // G3
report20({0x11, 0xff, 0x08, 0x00, 0x08}), // G4
report20({0x11, 0xff, 0x08, 0x00, 0x10}), // G5
report20({0x11, 0xff, 0x08, 0x00, 0x20}), // G6
report20({0x11, 0xff, 0x08, 0x00, 0x40}), // G7
report20({0x11, 0xff, 0x08, 0x00, 0x80}), // G8
report20({0x11, 0xff, 0x08, 0x00, 0x00, 0x01}), // G9
};
}
// "disable F-key to G-key binding" command (disableGKeys[0]).
std::vector<Report> g910DisableCommands() {
return {report20({0x11, 0xff, 0x08, 0x2e, 0x01})};
}
DeviceProfile make_g910(const char* name, uint16_t product, bool disableBinding) {
DeviceProfile profile;
profile.name = name;
profile.vendor = 0x046d;
profile.product = product;
profile.configuration = 0;
profile.interfaceNumber = 1;
profile.altSetting = 0;
profile.gKeyReports = g910GKeys();
profile.releaseReport = report20({0x11, 0xff, 0x08, 0x00});
profile.disableBinding = disableBinding;
if (disableBinding) {
profile.disableCommands = g910DisableCommands();
}
return profile;
}
} // namespace
const std::vector<DeviceProfile>& profiles() {
// Orion Spectrum (c335) needs the disable command; the Orion Spark (c32b)
// does not (mirrors disableGKeysInterface == 0 in the reference).
static const std::vector<DeviceProfile> list = {
make_g910("Logitech G910 Orion Spectrum", 0xc335, true),
make_g910("Logitech G910 Orion Spark", 0xc32b, false),
};
return list;
}
const DeviceProfile* find_profile(uint16_t vendor, uint16_t product) {
for (const DeviceProfile& profile : profiles()) {
if (profile.vendor == vendor && profile.product == product) {
return &profile;
}
}
return nullptr;
}
int match_gkey(const DeviceProfile& profile, const uint8_t* data, int length) {
if (length <= 0) {
return -1;
}
for (size_t i = 0; i < profile.gKeyReports.size(); ++i) {
const Report& report = profile.gKeyReports[i];
if (static_cast<int>(report.size()) == length &&
std::equal(report.begin(), report.end(), data)) {
return static_cast<int>(i);
}
}
return -1;
}
bool is_release_report(const DeviceProfile& profile, const uint8_t* data, int length) {
return static_cast<int>(profile.releaseReport.size()) == length &&
std::equal(profile.releaseReport.begin(), profile.releaseReport.end(), data);
}
} // namespace gkeymapper::daemon

47
daemon/device_tables.h Normal file
View File

@@ -0,0 +1,47 @@
// gKeyMapper daemon - per-model raw HID report tables.
//
// Ported from the byte tables of the Python reference project
// (g910_gkeys/data_mappers/supported_devices.py). Keys are matched against the
// exact bytes read from the keyboard's vendor interface.
#ifndef GKEYMAPPER_DAEMON_DEVICE_TABLES_H
#define GKEYMAPPER_DAEMON_DEVICE_TABLES_H
#include <cstdint>
#include <vector>
namespace gkeymapper::daemon {
// A raw HID report from the vendor interface.
using Report = std::vector<uint8_t>;
struct DeviceProfile {
const char* name = "";
uint16_t vendor = 0;
uint16_t product = 0;
int configuration = 0; // 0-based configuration index
int interfaceNumber = 1;
int altSetting = 0;
// gKeyReports[i] is the press report for G(i+1).
std::vector<Report> gKeyReports;
Report releaseReport;
// Some models must be told to stop mapping G-keys to F-keys first.
bool disableBinding = false;
std::vector<Report> disableCommands;
};
const std::vector<DeviceProfile>& profiles();
// Matching profile for vendor/product, or nullptr.
const DeviceProfile* find_profile(uint16_t vendor, uint16_t product);
// Index of the G key (0..N-1) for a report, or -1 if it is not a known press.
int match_gkey(const DeviceProfile& profile, const uint8_t* data, int length);
// True if the report is the "all macro keys released" report.
bool is_release_report(const DeviceProfile& profile, const uint8_t* data, int length);
} // namespace gkeymapper::daemon
#endif // GKEYMAPPER_DAEMON_DEVICE_TABLES_H

180
daemon/g910_device.cpp Normal file
View File

@@ -0,0 +1,180 @@
// gKeyMapper daemon - raw USB HID access to the keyboard's vendor interface.
#include "g910_device.h"
#include <cstdio>
// libusb.h uses zero-length arrays, which -Wpedantic flags.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wpedantic"
#include <libusb.h>
#pragma GCC diagnostic pop
namespace gkeymapper::daemon {
GKeyDevice::~GKeyDevice() {
close();
}
bool GKeyDevice::open() {
if (handle_) {
return true;
}
if (libusb_init(&context_) != 0) {
std::fprintf(stderr, "gkeymapper-daemon: libusb_init failed\n");
return false;
}
libusb_device** devices = nullptr;
ssize_t count = libusb_get_device_list(context_, &devices);
if (count < 0) {
std::fprintf(stderr, "gkeymapper-daemon: no USB devices\n");
close();
return false;
}
libusb_device* target = nullptr;
for (ssize_t i = 0; i < count && !target; ++i) {
libusb_device_descriptor desc{};
if (libusb_get_device_descriptor(devices[i], &desc) != 0) {
continue;
}
if (find_profile(desc.idVendor, desc.idProduct) != nullptr) {
target = devices[i];
profile_ = find_profile(desc.idVendor, desc.idProduct);
}
}
if (!target) {
std::fprintf(stderr, "gkeymapper-daemon: no supported keyboard found\n");
libusb_free_device_list(devices, 1);
close();
return false;
}
if (libusb_open(target, &handle_) != 0) {
std::fprintf(stderr, "gkeymapper-daemon: could not open %s (permissions?)\n",
profile_->name);
libusb_free_device_list(devices, 1);
close();
return false;
}
libusb_free_device_list(devices, 1);
if (!find_endpoint()) {
close();
return false;
}
// Detach and claim the vendor interface, like the reference driver.
if (libusb_kernel_driver_active(handle_, profile_->interfaceNumber) == 1) {
if (libusb_detach_kernel_driver(handle_, profile_->interfaceNumber) == 0) {
detached_kernel_driver_ = true;
}
}
if (libusb_claim_interface(handle_, profile_->interfaceNumber) != 0) {
std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n",
profile_->interfaceNumber);
close();
return false;
}
claimed_interface_ = profile_->interfaceNumber;
std::fprintf(stderr, "gkeymapper-daemon: %s found (endpoint 0x%02x)\n",
profile_->name, endpoint_);
return true;
}
bool GKeyDevice::find_endpoint() {
libusb_device* device = libusb_get_device(handle_);
libusb_config_descriptor* config = nullptr;
if (libusb_get_config_descriptor(device, profile_->configuration, &config) != 0) {
return false;
}
bool found = false;
for (int i = 0; i < config->bNumInterfaces && !found; ++i) {
const libusb_interface& iface = config->interface[i];
for (int a = 0; a < iface.num_altsetting && !found; ++a) {
const libusb_interface_descriptor& desc = iface.altsetting[a];
if (desc.bInterfaceNumber == profile_->interfaceNumber &&
desc.bAlternateSetting == profile_->altSetting &&
desc.bNumEndpoints > 0) {
endpoint_ = desc.endpoint[0].bEndpointAddress;
found = true;
}
}
}
libusb_free_config_descriptor(config);
return found;
}
bool GKeyDevice::disable_binding() {
if (!handle_ || !profile_) {
return false;
}
if (!profile_->disableBinding) {
return true;
}
for (const Report& command : profile_->disableCommands) {
int written = libusb_control_transfer(handle_, 0x21, 0x09, 0x0212,
profile_->interfaceNumber,
const_cast<unsigned char*>(command.data()),
static_cast<uint16_t>(command.size()),
1000);
if (written < 0) {
std::fprintf(stderr, "gkeymapper-daemon: disable-binding write failed (%d)\n",
written);
return false;
}
}
// Drain the confirmation reports so they do not show up as key events.
uint8_t scratch[64];
for (int i = 0; i < 6; ++i) {
if (read_report(scratch, sizeof(scratch), 200) <= 0) {
break;
}
}
return true;
}
int GKeyDevice::read_report(uint8_t* buffer, int length, int timeoutMs) {
if (!handle_) {
return -1;
}
int transferred = 0;
int rc = libusb_interrupt_transfer(handle_, endpoint_, buffer, length,
&transferred, timeoutMs);
if (rc == 0) {
return transferred;
}
if (rc == LIBUSB_ERROR_TIMEOUT) {
return 0;
}
return rc; // negative
}
void GKeyDevice::close() {
if (handle_) {
if (claimed_interface_ >= 0) {
libusb_release_interface(handle_, claimed_interface_);
}
if (detached_kernel_driver_) {
libusb_attach_kernel_driver(handle_, profile_->interfaceNumber);
}
libusb_close(handle_);
}
handle_ = nullptr;
if (context_) {
libusb_exit(context_);
context_ = nullptr;
}
claimed_interface_ = -1;
detached_kernel_driver_ = false;
profile_ = nullptr;
}
} // namespace gkeymapper::daemon

50
daemon/g910_device.h Normal file
View File

@@ -0,0 +1,50 @@
// gKeyMapper daemon - raw USB HID access to the keyboard's vendor interface.
#ifndef GKEYMAPPER_DAEMON_GKEY_DEVICE_H
#define GKEYMAPPER_DAEMON_GKEY_DEVICE_H
#include <cstdint>
#include "device_tables.h"
struct libusb_context;
struct libusb_device_handle;
namespace gkeymapper::daemon {
class GKeyDevice {
public:
GKeyDevice() = default;
~GKeyDevice();
GKeyDevice(const GKeyDevice&) = delete;
GKeyDevice& operator=(const GKeyDevice&) = delete;
// Finds a supported keyboard, opens it and detaches the kernel driver from
// the vendor interface. Returns false (and logs) if none is found.
bool open();
void close();
bool is_open() const { return handle_ != nullptr; }
// Sends the "stop mapping G-keys to F-keys" command if the model needs it.
bool disable_binding();
// Reads one interrupt report. Returns the number of bytes (>0), 0 on
// timeout, or a negative value on error.
int read_report(uint8_t* buffer, int length, int timeoutMs);
const DeviceProfile* profile() const { return profile_; }
private:
bool find_endpoint();
libusb_context* context_ = nullptr;
libusb_device_handle* handle_ = nullptr;
const DeviceProfile* profile_ = nullptr;
uint8_t endpoint_ = 0;
int claimed_interface_ = -1;
bool detached_kernel_driver_ = false;
};
} // namespace gkeymapper::daemon
#endif // GKEYMAPPER_DAEMON_GKEY_DEVICE_H

118
daemon/main.cpp Normal file
View File

@@ -0,0 +1,118 @@
// gKeyMapper daemon - remaps G1..G9 on the Logitech G910.
//
// It reads the raw HID reports of the keyboard's vendor interface, looks up the
// 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).
#include <csignal>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include "action.h"
#include "config_watch.h"
#include "device_tables.h"
#include "g910_device.h"
#include "mapping.h"
namespace {
volatile std::sig_atomic_t g_running = 1;
volatile std::sig_atomic_t g_reload = 0;
void handle_stop(int /*signum*/) {
g_running = 0;
}
void handle_reload(int /*signum*/) {
g_reload = 1;
}
} // namespace
int main(int argc, char** argv) {
std::signal(SIGINT, handle_stop);
std::signal(SIGTERM, handle_stop);
std::signal(SIGHUP, handle_reload);
std::signal(SIGUSR1, handle_reload);
std::signal(SIGCHLD, SIG_IGN); // auto-reap launched commands
std::string configPath = gkeymapper::default_config_path();
if (argc > 1) {
configPath = argv[1];
}
gkeymapper::MappingSet mappings = gkeymapper::parse_mappings("");
// 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());
}
};
reload();
// 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.
gkeymapper::daemon::ConfigWatcher watcher;
watcher.init(configPath);
gkeymapper::daemon::ActionRunner actions;
actions.init();
gkeymapper::daemon::GKeyDevice device;
if (!device.open()) {
return 1;
}
device.disable_binding();
const gkeymapper::daemon::DeviceProfile* profile = device.profile();
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).
watcher.poll_events();
if (g_reload || watcher.take_change()) {
g_reload = 0;
reload();
}
int n = device.read_report(buffer, sizeof(buffer), 500);
if (n == 0) {
continue; // no report within the timeout
}
if (n < 0) {
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), reopening\n", n);
device.close();
if (!device.open()) {
return 1;
}
device.disable_binding();
profile = device.profile();
continue;
}
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
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);
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
// Key released; nothing to do.
}
// Media keys and LED acknowledgements are ignored.
}
device.close();
return 0;
}

140
daemon/uinput_keyboard.cpp Normal file
View File

@@ -0,0 +1,140 @@
// gKeyMapper daemon - virtual keyboard for replaying "key" mappings.
#include "uinput_keyboard.h"
#include <cstdio>
#include <fcntl.h>
#include <initializer_list>
#include <sys/ioctl.h>
#include <unistd.h>
#include <linux/input-event-codes.h>
#include <linux/input.h>
#include <linux/uinput.h>
#include "keys.h"
namespace gkeymapper::daemon {
namespace {
// Key code for a single modifier bit (the "left" variants are enough).
unsigned short modifier_code(unsigned bit) {
switch (bit) {
case gkeymapper::kModCtrl: return KEY_LEFTCTRL;
case gkeymapper::kModAlt: return KEY_LEFTALT;
case gkeymapper::kModShift: return KEY_LEFTSHIFT;
case gkeymapper::kModSuper: return KEY_LEFTMETA;
case gkeymapper::kModAltGr: return KEY_RIGHTALT;
default: return 0;
}
}
} // namespace
UinputKeyboard::~UinputKeyboard() {
close();
}
bool UinputKeyboard::open() {
if (fd_ >= 0) {
return true;
}
fd_ = ::open("/dev/uinput", O_WRONLY | O_NONBLOCK);
if (fd_ < 0) {
std::fprintf(stderr, "gkeymapper-daemon: cannot open /dev/uinput (permissions?)\n");
fd_ = -1;
return false;
}
ioctl(fd_, UI_SET_EVBIT, EV_KEY);
ioctl(fd_, UI_SET_EVBIT, EV_SYN);
for (int code = 0; code <= KEY_MAX; ++code) {
ioctl(fd_, UI_SET_KEYBIT, code);
}
uinput_user_dev device{};
std::snprintf(device.name, UINPUT_MAX_NAME_SIZE, "gKeyMapper virtual keyboard");
device.id.bustype = BUS_USB;
device.id.vendor = 0x046d;
device.id.product = 0xc32b;
device.id.version = 1;
if (::write(fd_, &device, sizeof(device)) < 0) {
std::fprintf(stderr, "gkeymapper-daemon: uinput setup failed\n");
close();
return false;
}
if (ioctl(fd_, UI_DEV_CREATE) < 0) {
std::fprintf(stderr, "gkeymapper-daemon: UI_DEV_CREATE failed\n");
close();
return false;
}
return true;
}
void UinputKeyboard::close() {
if (fd_ >= 0) {
ioctl(fd_, UI_DEV_DESTROY);
::close(fd_);
fd_ = -1;
}
}
void UinputKeyboard::emit(unsigned short type, unsigned short code, int value) {
input_event event{};
event.type = type;
event.code = code;
event.value = value;
// A short write is not recoverable here; drop the event.
ssize_t written = ::write(fd_, &event, sizeof(event));
(void)written;
}
void UinputKeyboard::sync() {
emit(EV_SYN, SYN_REPORT, 0);
}
void UinputKeyboard::key_event(unsigned short code, bool down) {
emit(EV_KEY, code, down ? 1 : 0);
}
void UinputKeyboard::play(const std::vector<gkeymapper::ShortcutStep>& steps) {
if (fd_ < 0) {
return;
}
for (const gkeymapper::ShortcutStep& step : steps) {
std::vector<unsigned short> modifiers;
for (unsigned bit : {gkeymapper::kModCtrl, gkeymapper::kModAlt,
gkeymapper::kModShift, gkeymapper::kModSuper,
gkeymapper::kModAltGr}) {
if ((step.modifiers & bit) != 0) {
unsigned short code = modifier_code(bit);
if (code != 0) {
modifiers.push_back(code);
}
}
}
for (unsigned short code : modifiers) {
key_event(code, true);
}
if (step.linux_key >= 0) {
key_event(static_cast<unsigned short>(step.linux_key), true);
}
sync();
usleep(2000);
if (step.linux_key >= 0) {
key_event(static_cast<unsigned short>(step.linux_key), false);
}
for (auto it = modifiers.rbegin(); it != modifiers.rend(); ++it) {
key_event(*it, false);
}
sync();
usleep(2000);
}
}
} // namespace gkeymapper::daemon

38
daemon/uinput_keyboard.h Normal file
View File

@@ -0,0 +1,38 @@
// gKeyMapper daemon - virtual keyboard for replaying "key" mappings.
#ifndef GKEYMAPPER_DAEMON_UINPUT_KEYBOARD_H
#define GKEYMAPPER_DAEMON_UINPUT_KEYBOARD_H
#include <vector>
#include "mapping.h"
namespace gkeymapper::daemon {
// A virtual keyboard created through /dev/uinput. It is only used to replay
// shortcuts; the daemon never reads from it.
class UinputKeyboard {
public:
UinputKeyboard() = default;
~UinputKeyboard();
UinputKeyboard(const UinputKeyboard&) = delete;
UinputKeyboard& operator=(const UinputKeyboard&) = delete;
bool open();
void close();
bool is_open() const { return fd_ >= 0; }
// Presses and releases every step of the shortcut in order.
void play(const std::vector<gkeymapper::ShortcutStep>& steps);
private:
void emit(unsigned short type, unsigned short code, int value);
void sync();
void key_event(unsigned short code, bool down);
int fd_ = -1;
};
} // namespace gkeymapper::daemon
#endif // GKEYMAPPER_DAEMON_UINPUT_KEYBOARD_H

9
data/60-gkeymapper.rules Normal file
View File

@@ -0,0 +1,9 @@
# gKeyMapper - grant the logged-in user access to the keyboard's vendor
# interface and to /dev/uinput, so the daemon needs no root.
#
# Only models the daemon has a device table for are listed (see
# daemon/device_tables.cpp); add the idProduct of a new model there first.
SUBSYSTEMS=="usb", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c32b", TAG+="uaccess"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c335", TAG+="uaccess"
KERNEL=="uinput", TAG+="uaccess"

View File

@@ -0,0 +1,24 @@
# gKeyMapper daemon - systemd user unit.
#
# This file is installed by "make install", which fills in the binary directory
# for the configured PREFIX. Enable it per user with:
#
# systemctl --user daemon-reload
# systemctl --user enable --now gkeymapper-daemon
#
# It runs as a user service on purpose: the daemon needs the user's session for
# /dev/uinput and for the DISPLAY/WAYLAND_DISPLAY a "command" action inherits.
[Unit]
Description=gKeyMapper daemon - G1..G9 remapping for the Logitech G910
[Service]
Type=simple
ExecStart=@BINDIR@/gkeymapper-daemon
# Reload the mappings without a restart (the config file is watched as well).
ExecReload=/bin/kill -HUP $MAINPID
TimeoutStopSec=5
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,2 @@
# gKeyMapper daemon - the daemon creates a virtual keyboard via /dev/uinput.
uinput

View File

@@ -4,6 +4,33 @@ Status: draft / proposal
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.
## Implementation status
- **M0 done** — `shared/mapping.{h,cpp}` (file format + shortcut parsing) and
`shared/keys.{h,cpp}` (canonical names → Linux codes); the GUI uses them.
Round-trip and shortcut parsing are covered by a small standalone test.
- **M1 done** — `daemon/` builds (`gkeymapper-daemon`): device discovery,
kernel-driver detach, F-key binding disable, 64-byte interrupt reads and G1–G9
decoding. Verified on hardware: all nine keys decode correctly (press and
release), in order.
- **M2 done** — action execution: `key` shortcuts are replayed through a
`/dev/uinput` virtual keyboard, `command` runs via fork/exec (with a shell
fallback so arguments work), `disabled` does nothing. Verified on hardware.
- **M3 done** — config watching and lifecycle: `daemon/config_watch` watches the
config *directory* with inotify, so a save in the GUI reloads within about
half a second; `SIGHUP`/`SIGUSR1` stay as the fallback. `Save mappings` now
writes atomically (temp file + `rename`), so the daemon never reads a
half-written file. `make install` ships the udev rule, the `modules-load.d`
fragment and the systemd **user** unit (`data/`).
- **Layout handling (added during M2)** — key names are resolved through the
active layout (`XKBLAYOUT` from `/etc/default/keyboard`, via xkbcommon). This
matters because the US-position code `KEY_Z` (44) is `y` on a German keyboard;
`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.
## 1. Goals and non-goals
Goals
@@ -243,24 +270,16 @@ Start-up orders:
```
src/ # GUI (existing)
daemon/
main.cpp # event loop, wiring
device_libusb.{h,cpp} # discovery, detach/attach, read, disable-binding
decoder.{h,cpp} # report bytes -> action id (compile-time tables)
uinput_keyboard.{h,cpp}# virtual keyboard, emit / combo / press-release stack
actions.{h,cpp} # key / command / disabled execution, child reaping
config.{h,cpp} # mappings.conf parse + watch
ipc_dbus.{h,cpp} # GDBus service
generated/devices.inc # ported from supported_devices.py
daemon/ # as implemented (see the list in §6 above)
shared/
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
ipc_ids.h # D-Bus name/path/interface constants
keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon)
data/
60-gkeymapper.rules
gkeymapper-daemon.service
uinput.conf # modules-load.d
po/ # daemon strings, reuse the existing catalogs
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)
po/ # shared catalogs (GUI strings)
```
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
@@ -289,8 +308,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
F-key binding, logs `G1..G9` presses. No output yet.
- **M2 – Action layer.** uinput keyboard; `key` (shortcut, incl. release) and
`command` work end-to-end. `disabled` no-op.
- **M3 – Config & lifecycle.** config parse/reload (inotify, SIGUSR1), signals,
systemd --user unit + udev rule + modules-load.
- **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.
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
@@ -333,3 +353,33 @@ for the unit, udev rule and modules-load fragment. New build dependency:
dependency). Recommendation: D-Bus, with signals as a fallback.
4. Scope of v1: keep `key`/`command`/`disabled` only, or also port
`typeout`/`media`?
### Resolved
- **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
wakeups/s at 0 % CPU (the "simple variant" of §4.2) rather than the preferred
async libusb + GMainLoop design. Switching to libusb's async API with its poll
fds in a `poll()` loop stays open if idle wakeups ever become a concern — the
config watch would fold into the same set.
- **Layout handling (done in M2).** Key names in the file are layout
independent; the daemon resolves them through the active layout (`XKBLAYOUT`
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).
- **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
coalescing window, so one save yields exactly one reload; `SIGHUP`/`SIGUSR1`
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
`modules-load.d` fragment live in `data/` and are installed by `make install`
(under `PREFIX/lib/...`, which udev and systemd both search).

View File

@@ -40,11 +40,12 @@ msgid "Done"
msgstr "Fertig"
msgid ""
"Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded "
"too). Click Done to apply or Cancel to discard."
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
"recorded too). Click Done to apply or Cancel to discard."
msgstr ""
"Tasten der Reihe nach drücken (Modifikatoren wie Strg, Alt, Shift und Super "
"werden mit aufgezeichnet). Mit Fertig übernehmen oder mit Abbrechen verwerfen."
"Tasten der Reihe nach drücken (Modifikatoren wie Strg, Alt, Shift, Super und "
"AltGr werden mit aufgezeichnet). Mit Fertig übernehmen oder mit Abbrechen "
"verwerfen."
msgid "Choose file..."
msgstr "Datei auswählen..."

View File

@@ -41,11 +41,11 @@ msgid "Done"
msgstr "Done"
msgid ""
"Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded "
"too). Click Done to apply or Cancel to discard."
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
"recorded too). Click Done to apply or Cancel to discard."
msgstr ""
"Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded "
"too). Click Done to apply or Cancel to discard."
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
"recorded too). Click Done to apply or Cancel to discard."
msgid "Choose file..."
msgstr "Choose file..."

View File

@@ -38,12 +38,12 @@ msgid "Done"
msgstr "Aceptar"
msgid ""
"Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded "
"too). Click Done to apply or Cancel to discard."
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
"recorded too). Click Done to apply or Cancel to discard."
msgstr ""
"Pulsa las teclas en orden (los modificadores como Ctrl, Alt, Mayús y Super "
"también se registran). Haz clic en Aceptar para aplicar o en Cancelar para "
"descartar."
"Pulsa las teclas en orden (los modificadores como Ctrl, Alt, Mayús, Super y "
"AltGr también se registran). Haz clic en Aceptar para aplicar o en Cancelar "
"para descartar."
msgid "Choose file..."
msgstr "Elegir archivo..."

View File

@@ -38,11 +38,11 @@ msgid "Done"
msgstr "Terminer"
msgid ""
"Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded "
"too). Click Done to apply or Cancel to discard."
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
"recorded too). Click Done to apply or Cancel to discard."
msgstr ""
"Appuyez sur les touches dans l'ordre (les modificateurs comme Ctrl, Alt, "
"Maj et Super sont également enregistrés). Cliquez sur Terminer pour "
"Maj, Super et AltGr sont également enregistrés). Cliquez sur Terminer pour "
"appliquer ou sur Annuler pour abandonner."
msgid "Choose file..."

View File

@@ -51,8 +51,8 @@ msgstr ""
#: src/main.cpp:213
msgid ""
"Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded "
"too). Click Done to apply or Cancel to discard."
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
"recorded too). Click Done to apply or Cancel to discard."
msgstr ""
#: src/main.cpp:263

134
shared/keymap.cpp Normal file
View File

@@ -0,0 +1,134 @@
// gKeyMapper - keyboard layout lookups (xkbcommon).
#include "keymap.h"
#include <cctype>
#include <cstdio>
#include <fstream>
#include <xkbcommon/xkbcommon.h>
#include "keys.h"
namespace gkeymapper {
namespace {
// xkbcommon uses X11 keycodes, which are the evdev codes offset by 8.
constexpr int kXkbKeycodeOffset = 8;
// Reads XKBLAYOUT from /etc/default/keyboard, e.g. "de".
std::string read_layout_name() {
std::ifstream in("/etc/default/keyboard");
std::string line;
while (std::getline(in, line)) {
const std::string key = "XKBLAYOUT=";
auto pos = line.find(key);
if (pos == std::string::npos) {
continue;
}
std::string value = line.substr(pos + key.size());
if (!value.empty() && value.front() == '"') {
value.erase(0, 1);
}
if (!value.empty() && value.back() == '"') {
value.pop_back();
}
return value;
}
return std::string();
}
} // namespace
Keymap::~Keymap() {
if (state_) {
xkb_state_unref(state_);
}
if (keymap_) {
xkb_keymap_unref(keymap_);
}
}
bool Keymap::init() {
layoutName_ = read_layout_name();
if (layoutName_.empty()) {
std::fprintf(stderr, "gkeymapper: no XKBLAYOUT, using US key positions\n");
return false;
}
xkb_context* context = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!context) {
return false;
}
xkb_rule_names names{};
names.rules = "evdev";
names.model = "pc105";
names.layout = layoutName_.c_str();
names.variant = "";
names.options = "";
keymap_ = xkb_keymap_new_from_names(context, &names, XKB_KEYMAP_COMPILE_NO_FLAGS);
xkb_context_unref(context);
if (!keymap_) {
std::fprintf(stderr, "gkeymapper: unknown layout '%s', using US key positions\n",
layoutName_.c_str());
return false;
}
state_ = xkb_state_new(keymap_);
if (!state_) {
return false;
}
return true;
}
int Keymap::key_code(const std::string& name) const {
if (!state_) {
return linux_key_code(name);
}
xkb_keysym_t keysym =
xkb_keysym_from_name(name.c_str(), XKB_KEYSYM_CASE_INSENSITIVE);
if (keysym != XKB_KEY_NoSymbol) {
for (xkb_keycode_t code = xkb_keymap_min_keycode(keymap_);
code <= xkb_keymap_max_keycode(keymap_); ++code) {
if (xkb_state_key_get_one_sym(state_, code) == keysym) {
int evdev = static_cast<int>(code) - kXkbKeycodeOffset;
if (evdev > 0) {
return evdev;
}
}
}
}
// Not present on this layout (e.g. "semicolon" on a German keyboard) or
// unknown: fall back to the US position.
return linux_key_code(name);
}
std::string Keymap::base_key_name(unsigned int keycode) const {
if (!state_) {
return std::string();
}
xkb_keysym_t keysym = xkb_state_key_get_one_sym(state_, keycode);
if (keysym == XKB_KEY_NoSymbol) {
return std::string();
}
char buffer[64];
int written = xkb_keysym_get_name(keysym, buffer, sizeof(buffer));
if (written <= 0) {
return std::string();
}
std::string name(buffer);
for (char& c : name) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return name;
}
} // namespace gkeymapper

51
shared/keymap.h Normal file
View File

@@ -0,0 +1,51 @@
// gKeyMapper - keyboard layout lookups (xkbcommon), shared by GUI and daemon.
//
// The active layout is read from /etc/default/keyboard (XKBLAYOUT). Without it,
// names fall back to the layout-independent US key positions in shared/keys.
#ifndef GKEYMAPPER_KEYMAP_H
#define GKEYMAPPER_KEYMAP_H
#include <string>
struct xkb_keymap;
struct xkb_state;
namespace gkeymapper {
// Two-way lookup on the active keyboard layout:
// - daemon: canonical key name -> Linux input (evdev) code
// - GUI: pressed X keycode -> canonical base key name (level 0)
class Keymap {
public:
Keymap() = default;
~Keymap();
Keymap(const Keymap&) = delete;
Keymap& operator=(const Keymap&) = delete;
// Builds the keymap from /etc/default/keyboard. Returns false if it cannot
// be built; the lookups then use the US fallback.
bool init();
bool is_ready() const { return state_ != nullptr; }
const std::string& layout_name() const { return layoutName_; }
// Canonical key name -> Linux input (evdev) code, e.g. "z" -> 21 on a German
// layout. Falls back to linux_key_code() for names that are not part of the
// layout.
int key_code(const std::string& name) const;
// X keycode (same value GDK reports) -> level-0 keysym name, lowercased
// ("ssharp", "a", "f4"). Empty if it cannot be resolved. Used by the capture
// dialog so "shift+ssharf" is recorded instead of the shifted symbol.
std::string base_key_name(unsigned int keycode) const;
private:
xkb_keymap* keymap_ = nullptr;
xkb_state* state_ = nullptr;
std::string layoutName_;
};
} // namespace gkeymapper
#endif // GKEYMAPPER_KEYMAP_H

110
shared/keys.cpp Normal file
View File

@@ -0,0 +1,110 @@
// gKeyMapper - canonical key name tables.
#include "keys.h"
#include <linux/input-event-codes.h>
namespace gkeymapper {
namespace {
const KeyDef kKeys[] = {
// Modifiers (also needed as key codes when a shortcut is replayed).
{"ctrl", KEY_LEFTCTRL},
{"control", KEY_LEFTCTRL},
{"alt", KEY_LEFTALT},
{"shift", KEY_LEFTSHIFT},
{"super", KEY_LEFTMETA},
{"meta", KEY_LEFTMETA},
{"altgr", KEY_RIGHTALT},
{"iso_level3_shift", KEY_RIGHTALT},
// Letters.
{"a", KEY_A}, {"b", KEY_B}, {"c", KEY_C}, {"d", KEY_D},
{"e", KEY_E}, {"f", KEY_F}, {"g", KEY_G}, {"h", KEY_H},
{"i", KEY_I}, {"j", KEY_J}, {"k", KEY_K}, {"l", KEY_L},
{"m", KEY_M}, {"n", KEY_N}, {"o", KEY_O}, {"p", KEY_P},
{"q", KEY_Q}, {"r", KEY_R}, {"s", KEY_S}, {"t", KEY_T},
{"u", KEY_U}, {"v", KEY_V}, {"w", KEY_W}, {"x", KEY_X},
{"y", KEY_Y}, {"z", KEY_Z},
// Digits.
{"0", KEY_0}, {"1", KEY_1}, {"2", KEY_2}, {"3", KEY_3}, {"4", KEY_4},
{"5", KEY_5}, {"6", KEY_6}, {"7", KEY_7}, {"8", KEY_8}, {"9", KEY_9},
// Function keys.
{"f1", KEY_F1}, {"f2", KEY_F2}, {"f3", KEY_F3}, {"f4", KEY_F4},
{"f5", KEY_F5}, {"f6", KEY_F6}, {"f7", KEY_F7}, {"f8", KEY_F8},
{"f9", KEY_F9}, {"f10", KEY_F10}, {"f11", KEY_F11}, {"f12", KEY_F12},
// Editing / navigation.
{"escape", KEY_ESC},
{"tab", KEY_TAB},
{"space", KEY_SPACE},
{"return", KEY_ENTER},
{"backspace", KEY_BACKSPACE},
{"delete", KEY_DELETE},
{"insert", KEY_INSERT},
{"home", KEY_HOME},
{"end", KEY_END},
{"page_up", KEY_PAGEUP},
{"page_down", KEY_PAGEDOWN},
{"up", KEY_UP},
{"down", KEY_DOWN},
{"left", KEY_LEFT},
{"right", KEY_RIGHT},
{"caps_lock", KEY_CAPSLOCK},
// Punctuation.
{"minus", KEY_MINUS},
{"equal", KEY_EQUAL},
{"bracketleft", KEY_LEFTBRACE},
{"bracketright", KEY_RIGHTBRACE},
{"semicolon", KEY_SEMICOLON},
{"apostrophe", KEY_APOSTROPHE},
{"grave", KEY_GRAVE},
{"backslash", KEY_BACKSLASH},
{"comma", KEY_COMMA},
{"period", KEY_DOT},
{"slash", KEY_SLASH},
};
} // namespace
const KeyDef* key_definitions() {
return kKeys;
}
int key_definition_count() {
return static_cast<int>(sizeof(kKeys) / sizeof(kKeys[0]));
}
int linux_key_code(const std::string& name) {
for (const KeyDef& def : kKeys) {
if (name == def.name) {
return def.linux_code;
}
}
return -1;
}
unsigned modifier_bit(const std::string& name) {
if (name == "ctrl" || name == "control") {
return kModCtrl;
}
if (name == "alt") {
return kModAlt;
}
if (name == "shift") {
return kModShift;
}
if (name == "super" || name == "meta") {
return kModSuper;
}
if (name == "altgr" || name == "iso_level3_shift") {
return kModAltGr;
}
return kModNone;
}
} // namespace gkeymapper

41
shared/keys.h Normal file
View File

@@ -0,0 +1,41 @@
// gKeyMapper - canonical key names shared by GUI and daemon.
//
// Canonical names are the lowercase GDK keysym names the GUI already writes
// (e.g. "f4", "escape", "page_up", "ctrl"). The daemon maps the same names to
// Linux input codes, so both halves of the project agree on the spelling.
#ifndef GKEYMAPPER_KEYS_H
#define GKEYMAPPER_KEYS_H
#include <string>
namespace gkeymapper {
// Modifier bits used by the shortcut grammar ("ctrl+c", "altgr+7").
enum ModifierBit : unsigned {
kModNone = 0,
kModCtrl = 1u << 0,
kModAlt = 1u << 1,
kModShift = 1u << 2,
kModSuper = 1u << 3,
kModAltGr = 1u << 4,
};
struct KeyDef {
const char* name; // canonical, lowercase; may also be a modifier name
int linux_code; // KEY_* from <linux/input-event-codes.h>
};
// All supported keys (regular keys and modifiers), no particular order.
const KeyDef* key_definitions();
int key_definition_count();
// Linux KEY_* code for a canonical name, or -1 if unknown.
int linux_key_code(const std::string& name);
// Modifier bit for "ctrl"/"alt"/"shift"/"super" ("meta" counts as super),
// 0 for everything else.
unsigned modifier_bit(const std::string& name);
} // namespace gkeymapper
#endif // GKEYMAPPER_KEYS_H

193
shared/mapping.cpp Normal file
View File

@@ -0,0 +1,193 @@
// gKeyMapper - mappings file parsing/formatting.
#include "mapping.h"
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include "keys.h"
namespace gkeymapper {
const char* action_type_token(ActionType type) {
switch (type) {
case ActionType::Key:
return "key";
case ActionType::Command:
return "command";
case ActionType::Disabled:
default:
return "disabled";
}
}
ActionType action_type_from_token(const std::string& token) {
if (token == "key") {
return ActionType::Key;
}
if (token == "command") {
return ActionType::Command;
}
return ActionType::Disabled;
}
std::string format_mappings(const MappingSet& set) {
std::string out = "# gKeyMapper mappings (G1..G9)\n";
for (int i = 0; i < kNumKeys; ++i) {
Mapping mapping;
if (i < static_cast<int>(set.keys.size())) {
mapping = set.keys[i];
}
out += "G";
out += std::to_string(i + 1);
out += "=";
out += action_type_token(mapping.type);
out += ":";
out += mapping.target;
out += "\n";
}
return out;
}
MappingSet parse_mappings(const std::string& text) {
MappingSet set;
set.keys.assign(kNumKeys, Mapping{});
std::istringstream in(text);
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') {
continue;
}
auto eq = line.find('=');
if (eq == std::string::npos) {
continue;
}
std::string key = line.substr(0, eq);
if (key.size() < 2 || key[0] != 'G') {
continue;
}
int n = 0;
try {
n = std::stoi(key.substr(1)) - 1;
} catch (...) {
continue;
}
if (n < 0 || n >= kNumKeys) {
continue;
}
std::string rest = line.substr(eq + 1);
auto col = rest.find(':');
std::string typeToken = (col == std::string::npos) ? rest : rest.substr(0, col);
std::string target = (col == std::string::npos) ? std::string() : rest.substr(col + 1);
set.keys[n].type = action_type_from_token(typeToken);
set.keys[n].target = target;
}
return set;
}
bool load_mappings_file(const std::string& path, MappingSet& out) {
std::ifstream in(path);
if (!in) {
return false;
}
std::stringstream buffer;
buffer << in.rdbuf();
out = parse_mappings(buffer.str());
return true;
}
bool save_mappings_file(const std::string& path, const MappingSet& set) {
// Write a temporary file first and rename it into place: a reader (the
// daemon) either sees the old or the new file, never a half-written one.
const std::string tempPath = path + ".tmp";
{
std::ofstream out(tempPath);
if (!out) {
return false;
}
out << format_mappings(set);
out.flush();
if (!out) {
return false;
}
}
return std::rename(tempPath.c_str(), path.c_str()) == 0;
}
std::string default_config_path() {
const char* home = std::getenv("HOME");
return std::string(home ? home : "") + "/.config/gKeyMapper/mappings.conf";
}
std::string format_shortcut_step(unsigned modifiers, const std::string& key_name) {
std::string step;
if ((modifiers & kModCtrl) != 0) {
step += "ctrl+";
}
if ((modifiers & kModAlt) != 0) {
step += "alt+";
}
if ((modifiers & kModShift) != 0) {
step += "shift+";
}
if ((modifiers & kModSuper) != 0) {
step += "super+";
}
if ((modifiers & kModAltGr) != 0) {
step += "altgr+";
}
step += key_name;
return step;
}
bool parse_shortcut(const std::string& text, std::vector<ShortcutStep>& out,
const KeyResolver& resolve) {
out.clear();
if (text.empty()) {
return false;
}
std::istringstream combos(text);
std::string combo;
while (std::getline(combos, combo, ',')) {
if (combo.empty()) {
return false;
}
ShortcutStep step;
bool haveKey = false;
std::istringstream parts(combo);
std::string part;
while (std::getline(parts, part, '+')) {
if (part.empty()) {
return false;
}
unsigned mod = modifier_bit(part);
if (mod != kModNone) {
step.modifiers |= mod;
continue;
}
int code = resolve ? resolve(part) : linux_key_code(part);
if (code < 0) {
return false; // unknown key name
}
step.key_name = part;
step.linux_key = code;
haveKey = true;
}
if (!haveKey) {
return false; // combo without an actual key
}
out.push_back(step);
}
return !out.empty();
}
} // namespace gkeymapper

68
shared/mapping.h Normal file
View File

@@ -0,0 +1,68 @@
// gKeyMapper - the mappings file format, shared by GUI and daemon.
//
// File format (one line per G key):
// # gKeyMapper mappings (G1..G9)
// G1=key:ctrl+c,ctrl+v
// G2=command:/usr/bin/firefox
// G3=disabled:
#ifndef GKEYMAPPER_MAPPING_H
#define GKEYMAPPER_MAPPING_H
#include <functional>
#include <string>
#include <vector>
namespace gkeymapper {
constexpr int kNumKeys = 9; // G1..G9
enum class ActionType { Disabled, Key, Command };
struct Mapping {
ActionType type = ActionType::Disabled;
std::string target;
};
// One entry per G key; index 0 -> G1.
struct MappingSet {
std::vector<Mapping> keys;
};
const char* action_type_token(ActionType type);
ActionType action_type_from_token(const std::string& token);
// Serialize to / parse from the text representation. parse_mappings always
// returns exactly kNumKeys entries (missing ones default to Disabled).
std::string format_mappings(const MappingSet& set);
MappingSet parse_mappings(const std::string& text);
bool load_mappings_file(const std::string& path, MappingSet& out);
bool save_mappings_file(const std::string& path, const MappingSet& set);
// Default config file: ~/.config/gKeyMapper/mappings.conf.
std::string default_config_path();
// One step of a "ctrl+c" shortcut, ready to be replayed via uinput.
struct ShortcutStep {
unsigned modifiers = 0; // ModifierBit mask
std::string key_name; // canonical key name (layout independent)
int linux_key = -1; // resolved input code, -1 if unresolved
};
// Resolves a canonical key name to an input code for the keyboard layout in
// use. The default (empty) resolver uses linux_key_code(), i.e. the
// layout-independent US key positions.
using KeyResolver = std::function<int(const std::string& name)>;
// Parse "ctrl+c,ctrl+v" into individual steps. Returns false if the text is
// empty or contains an unknown key name.
bool parse_shortcut(const std::string& text, std::vector<ShortcutStep>& out,
const KeyResolver& resolve = KeyResolver());
// Formats one step ("ctrl+shift+t") from a ModifierBit mask and a base key
// name. The modifier order is fixed: ctrl, alt, shift, super, altgr.
std::string format_shortcut_step(unsigned modifiers, const std::string& key_name);
} // namespace gkeymapper
#endif // GKEYMAPPER_MAPPING_H

View File

@@ -10,6 +10,9 @@
#include <glib/gi18n.h>
#include "keymap.h"
#include "keys.h"
namespace gkeymapper {
namespace {
@@ -18,9 +21,14 @@ struct CaptureSession {
KeyRow* row = nullptr;
GtkWidget* displayLabel = nullptr;
std::vector<std::string> steps;
std::set<guint> heldKeys; // non-modifier keyvals currently held (ignores auto-repeat)
std::set<guint> heldKeys; // keycodes currently held (ignores auto-repeat)
bool altGrHeld = false; // ISO_Level3_Shift / Mode_switch is down
};
bool is_altgr_keyval(guint keyval) {
return keyval == GDK_KEY_ISO_Level3_Shift || keyval == GDK_KEY_Mode_switch;
}
bool is_modifier_keyval(guint keyval) {
switch (keyval) {
case GDK_KEY_Control_L:
@@ -34,34 +42,60 @@ bool is_modifier_keyval(guint keyval) {
case GDK_KEY_Meta_L:
case GDK_KEY_Meta_R:
case GDK_KEY_ISO_Level3_Shift: // AltGr
case GDK_KEY_Mode_switch: // AltGr on some layouts
return true;
default:
return false;
}
}
// One keymap for the whole app; building it takes a moment, so it is created
// lazily on the first captured key.
const Keymap& capture_keymap() {
static Keymap keymap;
static bool initialized = false;
if (!initialized) {
keymap.init();
initialized = true;
}
return keymap;
}
// Format one step like the reference project: modifiers joined with '+',
// e.g. "ctrl+shift+t". Multiple steps are joined with ',' ("ctrl+c,ctrl+v").
std::string format_capture_step(GdkModifierType state, guint keyval) {
std::string step;
//
// The key is stored as its *base* (level 0) name, so Shift+s-sharp is recorded
// as "shift+ssharp" rather than the shifted symbol "question" (GDK's keyval is
// already shifted, so using it would double the shift on replay). AltGr
// (level 3) becomes the "altgr" modifier plus the base key, e.g. "altgr+7".
std::string format_capture_step(GdkModifierType state, guint keycode, guint keyval,
bool altgr) {
unsigned modifiers = 0;
if (state & GDK_CONTROL_MASK) {
step += "ctrl+";
modifiers |= kModCtrl;
}
if (state & GDK_ALT_MASK) {
step += "alt+";
modifiers |= kModAlt;
}
if (state & GDK_SHIFT_MASK) {
step += "shift+";
modifiers |= kModShift;
}
if (state & (GDK_SUPER_MASK | GDK_META_MASK)) {
step += "super+";
modifiers |= kModSuper;
}
const char* name = gdk_keyval_name(keyval);
std::string key = name ? name : "?";
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return std::tolower(c); });
step += key;
return step;
// GTK4 no longer exposes a Mod5 mask, so AltGr is tracked via its key.
if (altgr) {
modifiers |= kModAltGr;
}
std::string key = capture_keymap().base_key_name(keycode);
if (key.empty()) {
const char* name = gdk_keyval_name(keyval);
key = name ? name : "?";
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return std::tolower(c); });
}
return format_shortcut_step(modifiers, key);
}
std::string join_capture_steps(const std::vector<std::string>& steps) {
@@ -84,7 +118,7 @@ void update_capture_display(CaptureSession* session) {
}
gboolean on_capture_key_pressed(GtkEventControllerKey* controller,
guint keyval, guint /*keycode*/,
guint keyval, guint keycode,
GdkModifierType state, gpointer userData) {
auto* session = static_cast<CaptureSession*>(userData);
@@ -96,27 +130,36 @@ gboolean on_capture_key_pressed(GtkEventControllerKey* controller,
return TRUE;
}
if (is_altgr_keyval(keyval)) {
// GTK4 dropped the Mod5 mask, so AltGr is tracked through its own key.
session->altGrHeld = true;
return TRUE;
}
if (is_modifier_keyval(keyval)) {
return TRUE; // modifiers are recorded as part of the next real key
}
if (session->heldKeys.count(keyval)) {
if (session->heldKeys.count(keycode)) {
return TRUE; // ignore auto-repeat while the key is held down
}
session->heldKeys.insert(keyval);
session->heldKeys.insert(keycode);
constexpr GdkModifierType kModMask = static_cast<GdkModifierType>(
GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK |
GDK_SUPER_MASK | GDK_META_MASK);
session->steps.push_back(format_capture_step(
static_cast<GdkModifierType>(state & kModMask), keyval));
static_cast<GdkModifierType>(state & kModMask), keycode, keyval,
session->altGrHeld));
update_capture_display(session);
return TRUE;
}
gboolean on_capture_key_released(GtkEventControllerKey* /*controller*/,
guint keyval, guint /*keycode*/,
guint keyval, guint keycode,
GdkModifierType /*state*/, gpointer userData) {
auto* session = static_cast<CaptureSession*>(userData);
session->heldKeys.erase(keyval);
if (is_altgr_keyval(keyval)) {
session->altGrHeld = false;
}
session->heldKeys.erase(keycode);
return TRUE;
}
@@ -174,7 +217,7 @@ void open_key_capture_dialog(KeyRow* row, GtkWindow* parent) {
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
gtk_box_append(GTK_BOX(outer), title);
GtkWidget* info = gtk_label_new(_("Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded too). Click Done to apply or Cancel to discard."));
GtkWidget* info = gtk_label_new(_("Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are recorded too). Click Done to apply or Cancel to discard."));
gtk_label_set_wrap(GTK_LABEL(info), TRUE);
gtk_label_set_xalign(GTK_LABEL(info), 0.0f);
gtk_widget_add_css_class(info, "dim-label");

View File

@@ -4,9 +4,9 @@
#include <gtk/gtk.h>
namespace gkeymapper {
#include "mapping.h" // kNumKeys, ActionType
constexpr int kNumKeys = 9;
namespace gkeymapper {
// Widgets belonging to a single G key row.
struct KeyRow {

View File

@@ -11,6 +11,7 @@
#include "capturedialog.h"
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
#ifndef PACKAGE

View File

@@ -1,84 +1,55 @@
// gKeyMapper - mappings configuration file I/O.
// gKeyMapper - GUI glue between the mappings file and the row widgets.
#include "mappings.h"
#include <fstream>
#include <string>
#include <glib/gi18n.h>
#include "mapping.h"
namespace gkeymapper {
std::string default_config_path() {
const char* home = g_get_home_dir();
return std::string(home ? home : "") + "/.config/gKeyMapper/mappings.conf";
}
bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
std::ofstream out(path);
if (!out) {
return false;
}
MappingSet set;
set.keys.assign(kNumKeys, Mapping{});
out << "# gKeyMapper mappings (G1..G9)\n";
for (const auto& row : rows) {
const char* type = "disabled";
for (const KeyRow& row : rows) {
if (row.index < 0 || row.index >= kNumKeys) {
continue;
}
Mapping mapping;
switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) {
case 0: type = "key"; break;
case 1: type = "command"; break;
default: type = "disabled"; break;
case 0: mapping.type = ActionType::Key; break;
case 1: mapping.type = ActionType::Command; break;
default: mapping.type = ActionType::Disabled; break;
}
const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry));
out << "G" << (row.index + 1) << "=" << type << ":" << (target ? target : "") << "\n";
mapping.target = target ? target : "";
set.keys[row.index] = mapping;
}
return true;
return save_mappings_file(path, set);
}
void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
std::ifstream in(path);
if (!in) {
MappingSet set;
if (!load_mappings_file(path, set)) {
g_printerr(_("Could not open %s.\n"), path.c_str());
return;
}
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') {
for (const KeyRow& row : rows) {
if (row.index < 0 || row.index >= static_cast<int>(set.keys.size())) {
continue;
}
auto eq = line.find('=');
if (eq == std::string::npos) {
continue;
}
std::string key = line.substr(0, eq);
if (key.size() < 2 || key[0] != 'G') {
continue;
}
int n = 0;
try {
n = std::stoi(key.substr(1)) - 1;
} catch (...) {
continue;
}
if (n < 0 || n >= static_cast<int>(rows.size())) {
continue;
}
std::string rest = line.substr(eq + 1);
auto col = rest.find(':');
std::string typeToken = (col == std::string::npos) ? rest : rest.substr(0, col);
std::string target = (col == std::string::npos) ? std::string() : rest.substr(col + 1);
guint idx = 2;
if (typeToken == "key") {
idx = 0;
} else if (typeToken == "command") {
idx = 1;
} else if (typeToken == "disabled") {
idx = 2;
switch (set.keys[row.index].type) {
case ActionType::Key: idx = 0; break;
case ActionType::Command: idx = 1; break;
default: idx = 2; break;
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(rows[n].typeDrop), idx);
gtk_editable_set_text(GTK_EDITABLE(rows[n].targetEntry), target.c_str());
gtk_drop_down_set_selected(GTK_DROP_DOWN(row.typeDrop), idx);
gtk_editable_set_text(GTK_EDITABLE(row.targetEntry),
set.keys[row.index].target.c_str());
}
}

View File

@@ -1,4 +1,4 @@
// gKeyMapper - reading and writing the mappings configuration file.
// gKeyMapper - GUI glue between the mappings file and the row widgets.
#ifndef GKEYMAPPER_MAPPINGS_H
#define GKEYMAPPER_MAPPINGS_H
@@ -9,14 +9,10 @@
namespace gkeymapper {
// Default config file: ~/.config/gKeyMapper/mappings.conf.
std::string default_config_path();
// Writes all rows as "G1=type:target" lines. Returns false on I/O error.
// Collects the current widget state and writes it to path.
bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows);
// Loads mappings from path into the rows' widgets. Missing/invalid lines are
// skipped; a missing file is reported and otherwise ignored.
// Loads path into the rows' widgets (type dropdown + target entry).
void read_mappings(const std::string& path, const std::vector<KeyRow>& rows);
} // namespace gkeymapper