diff --git a/.gitignore b/.gitignore index d22442a..18e7026 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ locale/ gkeymapper +gkeymapper-daemon *.o *.so diff --git a/AGENTS.md b/AGENTS.md index 14e37a5..5509659 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: `:`, 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 ` 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 ` (`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 diff --git a/Makefile b/Makefile index f7a494e..9b4b066 100644 --- a/Makefile +++ b/Makefile @@ -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 \ No newline at end of file + 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)" diff --git a/README.md b/README.md index 0c52c08..0335d27 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/daemon/action.cpp b/daemon/action.cpp new file mode 100644 index 0000000..aa37959 --- /dev/null +++ b/daemon/action.cpp @@ -0,0 +1,68 @@ +// gKeyMapper daemon - executes the mapping configured for a G key. + +#include "action.h" + +#include +#include + +#include + +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 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(nullptr)); + execl("/bin/sh", "sh", "-c", command.c_str(), static_cast(nullptr)); + _exit(127); + } + // Parent: children are reaped by the SIGCHLD handler. +} + +} // namespace gkeymapper::daemon diff --git a/daemon/action.h b/daemon/action.h new file mode 100644 index 0000000..8992257 --- /dev/null +++ b/daemon/action.h @@ -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 + +#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 diff --git a/daemon/config_watch.cpp b/daemon/config_watch.cpp new file mode 100644 index 0000000..1891cc0 --- /dev/null +++ b/daemon/config_watch.cpp @@ -0,0 +1,109 @@ +// gKeyMapper daemon - watches the mappings file for changes (inotify). + +#include "config_watch.h" + +#include +#include +#include +#include + +#include +#include +#include + +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(buffer + offset); + offset += static_cast(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 diff --git a/daemon/config_watch.h b/daemon/config_watch.h new file mode 100644 index 0000000..1fdd805 --- /dev/null +++ b/daemon/config_watch.h @@ -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 + +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 diff --git a/daemon/device_tables.cpp b/daemon/device_tables.cpp new file mode 100644 index 0000000..4e239e1 --- /dev/null +++ b/daemon/device_tables.cpp @@ -0,0 +1,96 @@ +// gKeyMapper daemon - per-model raw HID report tables. + +#include "device_tables.h" + +#include +#include + +namespace gkeymapper::daemon { + +namespace { + +// The G910 reports are 20 bytes; set the given prefix and zero the rest. +Report report20(std::initializer_list 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 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 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& profiles() { + // Orion Spectrum (c335) needs the disable command; the Orion Spark (c32b) + // does not (mirrors disableGKeysInterface == 0 in the reference). + static const std::vector 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(report.size()) == length && + std::equal(report.begin(), report.end(), data)) { + return static_cast(i); + } + } + return -1; +} + +bool is_release_report(const DeviceProfile& profile, const uint8_t* data, int length) { + return static_cast(profile.releaseReport.size()) == length && + std::equal(profile.releaseReport.begin(), profile.releaseReport.end(), data); +} + +} // namespace gkeymapper::daemon diff --git a/daemon/device_tables.h b/daemon/device_tables.h new file mode 100644 index 0000000..afca197 --- /dev/null +++ b/daemon/device_tables.h @@ -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 +#include + +namespace gkeymapper::daemon { + +// A raw HID report from the vendor interface. +using Report = std::vector; + +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 gKeyReports; + Report releaseReport; + + // Some models must be told to stop mapping G-keys to F-keys first. + bool disableBinding = false; + std::vector disableCommands; +}; + +const std::vector& 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 diff --git a/daemon/g910_device.cpp b/daemon/g910_device.cpp new file mode 100644 index 0000000..aaf8df0 --- /dev/null +++ b/daemon/g910_device.cpp @@ -0,0 +1,180 @@ +// gKeyMapper daemon - raw USB HID access to the keyboard's vendor interface. + +#include "g910_device.h" + +#include + +// libusb.h uses zero-length arrays, which -Wpedantic flags. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpedantic" +#include +#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(command.data()), + static_cast(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 diff --git a/daemon/g910_device.h b/daemon/g910_device.h new file mode 100644 index 0000000..c8dc9f5 --- /dev/null +++ b/daemon/g910_device.h @@ -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 + +#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 diff --git a/daemon/main.cpp b/daemon/main.cpp new file mode 100644 index 0000000..1f50190 --- /dev/null +++ b/daemon/main.cpp @@ -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 +#include +#include +#include +#include + +#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(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; +} diff --git a/daemon/uinput_keyboard.cpp b/daemon/uinput_keyboard.cpp new file mode 100644 index 0000000..ebe12bb --- /dev/null +++ b/daemon/uinput_keyboard.cpp @@ -0,0 +1,140 @@ +// gKeyMapper daemon - virtual keyboard for replaying "key" mappings. + +#include "uinput_keyboard.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#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& steps) { + if (fd_ < 0) { + return; + } + + for (const gkeymapper::ShortcutStep& step : steps) { + std::vector 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(step.linux_key), true); + } + sync(); + usleep(2000); + + if (step.linux_key >= 0) { + key_event(static_cast(step.linux_key), false); + } + for (auto it = modifiers.rbegin(); it != modifiers.rend(); ++it) { + key_event(*it, false); + } + sync(); + usleep(2000); + } +} + +} // namespace gkeymapper::daemon diff --git a/daemon/uinput_keyboard.h b/daemon/uinput_keyboard.h new file mode 100644 index 0000000..87eadd0 --- /dev/null +++ b/daemon/uinput_keyboard.h @@ -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 + +#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& 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 diff --git a/data/60-gkeymapper.rules b/data/60-gkeymapper.rules new file mode 100644 index 0000000..501d78d --- /dev/null +++ b/data/60-gkeymapper.rules @@ -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" diff --git a/data/gkeymapper-daemon.service b/data/gkeymapper-daemon.service new file mode 100644 index 0000000..37a5781 --- /dev/null +++ b/data/gkeymapper-daemon.service @@ -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 diff --git a/data/gkeymapper-uinput.conf b/data/gkeymapper-uinput.conf new file mode 100644 index 0000000..109f466 --- /dev/null +++ b/data/gkeymapper-uinput.conf @@ -0,0 +1,2 @@ +# gKeyMapper daemon - the daemon creates a virtual keyboard via /dev/uinput. +uinput diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md index edafb30..edb33af 100644 --- a/docs/daemon-plan.md +++ b/docs/daemon-plan.md @@ -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). diff --git a/po/de.po b/po/de.po index 33320ae..0201b5d 100644 --- a/po/de.po +++ b/po/de.po @@ -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..." diff --git a/po/en.po b/po/en.po index 1131c1c..eb9e3ba 100644 --- a/po/en.po +++ b/po/en.po @@ -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..." diff --git a/po/es.po b/po/es.po index b3789b3..259d6d0 100644 --- a/po/es.po +++ b/po/es.po @@ -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..." diff --git a/po/fr.po b/po/fr.po index bb86874..867f985 100644 --- a/po/fr.po +++ b/po/fr.po @@ -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..." diff --git a/po/gkeymapper.pot b/po/gkeymapper.pot index 2bb0055..9f0efce 100644 --- a/po/gkeymapper.pot +++ b/po/gkeymapper.pot @@ -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 diff --git a/shared/keymap.cpp b/shared/keymap.cpp new file mode 100644 index 0000000..5c4294c --- /dev/null +++ b/shared/keymap.cpp @@ -0,0 +1,134 @@ +// gKeyMapper - keyboard layout lookups (xkbcommon). + +#include "keymap.h" + +#include +#include +#include + +#include + +#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(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(std::tolower(static_cast(c))); + } + return name; +} + +} // namespace gkeymapper diff --git a/shared/keymap.h b/shared/keymap.h new file mode 100644 index 0000000..5dcb5e6 --- /dev/null +++ b/shared/keymap.h @@ -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 + +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 diff --git a/shared/keys.cpp b/shared/keys.cpp new file mode 100644 index 0000000..e220d3b --- /dev/null +++ b/shared/keys.cpp @@ -0,0 +1,110 @@ +// gKeyMapper - canonical key name tables. + +#include "keys.h" + +#include + +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(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 diff --git a/shared/keys.h b/shared/keys.h new file mode 100644 index 0000000..9805a37 --- /dev/null +++ b/shared/keys.h @@ -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 + +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 +}; + +// 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 diff --git a/shared/mapping.cpp b/shared/mapping.cpp new file mode 100644 index 0000000..4ee350e --- /dev/null +++ b/shared/mapping.cpp @@ -0,0 +1,193 @@ +// gKeyMapper - mappings file parsing/formatting. + +#include "mapping.h" + +#include +#include +#include +#include + +#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(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& 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 diff --git a/shared/mapping.h b/shared/mapping.h new file mode 100644 index 0000000..a34860d --- /dev/null +++ b/shared/mapping.h @@ -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 +#include +#include + +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 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; + +// 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& 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 diff --git a/src/capturedialog.cpp b/src/capturedialog.cpp index 73e6f87..0271708 100644 --- a/src/capturedialog.cpp +++ b/src/capturedialog.cpp @@ -10,6 +10,9 @@ #include +#include "keymap.h" +#include "keys.h" + namespace gkeymapper { namespace { @@ -18,9 +21,14 @@ struct CaptureSession { KeyRow* row = nullptr; GtkWidget* displayLabel = nullptr; std::vector steps; - std::set heldKeys; // non-modifier keyvals currently held (ignores auto-repeat) + std::set 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& 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(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( GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK | GDK_SUPER_MASK | GDK_META_MASK); session->steps.push_back(format_capture_step( - static_cast(state & kModMask), keyval)); + static_cast(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(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"); diff --git a/src/keyrow.h b/src/keyrow.h index 8c943bc..65bc200 100644 --- a/src/keyrow.h +++ b/src/keyrow.h @@ -4,9 +4,9 @@ #include -namespace gkeymapper { +#include "mapping.h" // kNumKeys, ActionType -constexpr int kNumKeys = 9; +namespace gkeymapper { // Widgets belonging to a single G key row. struct KeyRow { diff --git a/src/main.cpp b/src/main.cpp index a4fb07e..1350281 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ #include "capturedialog.h" #include "keyrow.h" +#include "mapping.h" #include "mappings.h" #ifndef PACKAGE diff --git a/src/mappings.cpp b/src/mappings.cpp index 0b24b0d..6af999e 100644 --- a/src/mappings.cpp +++ b/src/mappings.cpp @@ -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 -#include - #include +#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& 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& 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(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(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()); } } diff --git a/src/mappings.h b/src/mappings.h index 729a489..97a4d37 100644 --- a/src/mappings.h +++ b/src/mappings.h @@ -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& 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& rows); } // namespace gkeymapper