Media keys working again

This commit is contained in:
2026-10-18 03:50:33 +02:00
parent 726a7ae92d
commit 798708b3e7
20 changed files with 590 additions and 112 deletions

View File

@@ -45,12 +45,25 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
+ the generated `build/gresources.c`, see Assets) **and** the daemon
(`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`,
`libxkbcommon-dev` and `libglib2.0-dev`). `make daemon` builds only the daemon.
- The daemon is split into: `device_tables` (raw HID report tables per model),
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
keyboard), `action` (dispatch `key`/`command`/`disabled`), `config_watch`
(inotify auto-reload of the mappings file) and `ipc_dbus` (the GDBus service).
`shared/keymap` does the layout lookups for both sides. The hot path never
touches the GUI: D-Bus is control plane only and is pumped once per loop.
- The daemon is split into: `device_tables` (raw HID report tables per model plus
`decode_gkey_state`, the report → pressed-bitmap decoder), `g910_device` (libusb
open/detach/claim/read), `uinput_keyboard` (virtual keyboard), `action`
(dispatch `key`/`command`/`disabled`), `config_watch` (inotify auto-reload of
the mappings file) and `ipc_dbus` (the GDBus service). `shared/keymap` does the
layout lookups for both sides. The hot path never touches the GUI: D-Bus is
control plane only and is pumped once per loop.
- G-key decoding is **state-based**, not per-key matching: the macro report holds
a bitmap (byte 4 = G1–G8, low bit of byte 5 = G9), so the daemon fires a key on
a 0 → 1 transition. That is what suppresses the repeats of a held key and makes
a chord fire every key in it. Media/memory keys and command/LED answers are
rejected by report length and header; keep it that way instead of
re-introducing exact-report matching.
- The daemon runs **without** a keyboard: `open()` failing (or a read error) only
logs the absence once, reports `DeviceChanged(false)` and the loop retries every
2 s while D-Bus and the config watch keep running. Never make it exit on a
missing device — that would drop the bus name and make the GUI look like the
daemon died. `GKeyDevice::open(quiet)` exists so those retries stay out of the
journal (libusb-level errors always log).
- `make install` (PREFIX default `/usr/local`) also installs the packaging
files from `data/`: the udev rule, the `modules-load.d` fragment, the
systemd **user** unit and the D-Bus activation file. Both contain an
@@ -63,7 +76,10 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
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; `src/mappings.cpp` is GUI glue that moves data
between the widgets and the shared `MappingSet`. App id:
between the widgets and the shared `MappingSet`. `build_ui()` loads
`default_config_path()` into the rows as soon as they exist (a missing file is
not an error), so a save from a fresh window rewrites the file instead of
emptying it. App id:
`dev.rager.gkeymapper` (`ipc::kAppId`); the About dialog uses `ipc::kVersion`.
- GUI ↔ daemon is D-Bus on the session bus, GDBus (GIO) on both sides:
`src/daemonclient.{h,cpp}` is the client, `daemon/ipc_dbus.{h,cpp}` the
@@ -193,6 +209,23 @@ over any installed file; the file lookup remains only as a safety net.
`take_change()` per simulated loop iteration), and `ConfigReloaded` on the
`gdbus monitor` above reports when a save was applied. A GUI save triggers two
reloads on purpose — the file watch and the explicit `Reload()` call.
- Decoding and the whole loop are also testable **without** the keyboard, which is
the fastest way to check hot-plug and the GUI footer states (plan §9):
- compile `daemon/device_tables.cpp` standalone and feed `decode_gkey_state()`
synthetic reports: single keys, a chord, a repeated (held) report, the release
report, and the families that must be rejected (memory keys `11 ff 09` /
`11 ff 0a`, command `11 ff 08 2e` and LED answers); cover `decode_media_key()`
with the `02 <bit>` presses, the release, a padded report and the same
families, which must *not* decode as media keys (see the bit table above);
- build the daemon with a **fake** `GKeyDevice`: keep the header, script
`open()`/`read_report()`, link `daemon/*.cpp` **without** `g910_device.cpp`
and without libusb. That runs the real loop, so it covers "no keyboard at
start", "device lost" and the reopen, emits real D-Bus signals and never
claims the real device — nothing touches the keyboard and no key is injected
into the focused window. Use a config with `command:` mappings only (a helper
script that dumps its environment is also how you check that a `command`
action sees `DISPLAY`); with `key:` mappings the fake would inject keys into
whatever window is focused.
- D-Bus activation is only testable in a real setup: the installed
`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` needs
`sudo make install`. For a quick check without root, drop a file with
@@ -207,8 +240,23 @@ over any installed file; the file lookup remains only as a safety net.
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.
`/dev/uinput` keyboard. Warn the user first: `key` mappings are injected into
the **focused** window. The media keys are *not* affected: the keyboard reports
mute/volume/play only on that very interface (`…/input1`, measured — see below),
so the daemon replays those reports unchanged through its virtual keyboard.
Deleting that passthrough in `daemon/main.cpp` silently kills the media keys
while the daemon runs.
- Media keys cannot be observed over the keyboard's usual event node: with the
daemon stopped they appear on `event11` (`Phys …/input1`, created when `usbhid`
owns the interface), not on `event10` (`…/input0`); *both* nodes advertise the
consumer usages in their HID descriptors, so the capability bitmap proves
nothing. The authoritative data is the raw report on the `hidraw` node of
interface `1.1` (`usb-…-5.4.3/input1`, exists only while `usbhid` owns it):
`02 <bit>` per press with bit 0 `01` next, `02` previous, `04` stop, `08`
play/pause, `10` volume up, `20` volume down, `40` mute, and `02 00` for the
release. Verify the passthrough by reading `/tmp/daemon.log`: a press logs
`media -> mute` / `media -> volume up` / … and the desktop reacts as usual.
The bit table lives in `g910MediaKeys()` (`daemon/device_tables.cpp`).
- 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
@@ -229,10 +277,16 @@ The footer's daemon line is a plain `GtkLabel`, so its text can be read back ove
AT-SPI: `<app> → frame → panel → label` whose name starts with the translated
`Daemon:` string. To pick the right window, filter the application whose tree
contains that label — a stale GUI from an earlier build may be running from the
same binary name. Two notes for tests:
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real
and the GUI does **not** load that file at startup, so a save from an
unconfigured window replaces the user's mappings with empty ones. Back the file
up first (or skip the click and call `Reload` over D-Bus instead).
same binary name. Three notes for tests:
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real.
Since M5 the window loads that file at startup, so the rows show the saved
mappings and a save rewrites the same content; back the file up anyway (or skip
the click and call `Reload` over D-Bus instead).
- The `GtkEntry` texts are **not** readable over AT-SPI here (their text interface
answers with an empty string), so verify what the rows hold with a screenshot:
`import -window $(wmctrl -l | grep 'gKeyMapper – Logitech' | awk '{print $1}')
/tmp/gui.png`. The footer label works over AT-SPI (its accessible *name* is the
text), but a screenshot of the cropped footer is easier and shows what the user
sees.
- `XTest`-synthesized clicks need the window mapped and focused; the `Save`
button is `push button` + name `Mappings speichern` in the German locale.

View File

@@ -78,7 +78,14 @@ Supported languages: German, English, Spanish, French.
The footer of the window shows the daemon state (`Daemon: running · Device: …`
plus the last G key that fired, or `Daemon: not reachable`) and follows it live:
start or stop the daemon while the GUI is open and the line updates itself.
start or stop the daemon while the GUI is open and the line updates itself. Two
more states are reported explicitly: `Device: not found` (the daemon is up but
has no keyboard) and `Configuration error` (a reload failed, so the daemon kept
the mappings it had).
The window starts from the saved mappings: it loads
`~/.config/gKeyMapper/mappings.conf` when it opens, so `Save mappings` in a fresh
window rewrites the same content instead of emptying the file.
## Running the daemon
@@ -95,9 +102,12 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
`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 media keys (mute / volume / play / next / previous / stop) keep working
while it runs. The keyboard delivers them on the vendor interface — as a second
kernel input device (`…/input1`, the "… G910 Keyboard" consumer device) when the
daemon is *not* running — so the daemon picks those reports up and forwards them
unchanged through its virtual keyboard. There is nothing to configure; they
behave exactly as they do without the daemon.
- 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
@@ -110,7 +120,15 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
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.
silently disables every key. The GUI shows it as `Configuration error` in the
footer.
- It does **not** need the keyboard to be present: when it is unplugged (or not
there yet when the session starts) the daemon keeps running, reports
`Device: not found` and looks for it again every 2 s. A re-plug needs no
restart and keeps the GUI connection.
- A G key fires when its report first shows it as pressed: holding it down does
not repeat the action, and two G keys held at once both fire (the report
carries the state of all nine keys, not one key per report).
- Only one instance may run: a second one prints why it cannot get the bus name
and exits, instead of fighting over the keyboard and creating a second virtual
keyboard.
@@ -123,6 +141,14 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
layout.
- `command:...` is run through the shell, so arguments work
(`command:/usr/bin/firefox --new-window`); a plain program path is also fine.
It inherits the environment of the user service, i.e. of your desktop session —
`DISPLAY`, `XAUTHORITY` and `PATH` are already there on a normal setup. If a
command cannot reach the display, export them into the user manager:
```bash
systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XAUTHORITY
systemctl --user show-environment | grep DISPLAY
```
Stop it with Ctrl+C or `kill`; it reattaches the kernel driver on the way out.
@@ -294,7 +320,10 @@ export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:
## Planned next steps
- Support the media keys and the M1–M3 / MR keys of the G910.
- Make the media keys remappable (they are forwarded unchanged today, see above)
and support the M1–M3 / MR keys of the G910.
- `typeout`: type out a fixed text, like the reference project's action of that
name (the daemon would resolve the text through the active layout).
- Profiles: swap mapping sets on the fly (the G910's M keys), which is what the
reserved `SetProfile` D-Bus method was meant for.
- Key sequence timing: per-step delays for recorded macros.

View File

@@ -44,6 +44,10 @@ void ActionRunner::run(const gkeymapper::Mapping& mapping) {
}
}
void ActionRunner::tap_key(unsigned short code) {
keyboard_.tap(code);
}
void ActionRunner::run_command(const std::string& command) {
if (command.empty()) {
return;

View File

@@ -18,6 +18,11 @@ public:
void run(const gkeymapper::Mapping& mapping);
// Replays one key press + release. Used by the media-key passthrough, which
// is not a mapping: the keyboard reports mute/volume/play only on the
// interface the daemon holds, so they are forwarded verbatim.
void tap_key(unsigned short code);
private:
void run_command(const std::string& command);

View File

@@ -5,6 +5,8 @@
#include <algorithm>
#include <initializer_list>
#include <linux/input-event-codes.h>
namespace gkeymapper::daemon {
namespace {
@@ -36,6 +38,25 @@ std::vector<Report> g910DisableCommands() {
return {report20({0x11, 0xff, 0x08, 0x2e, 0x01})};
}
// Media keys: a 2-byte report, "02" followed by one bit per key ("02 00" is
// "all released"). The bit assignment and the Linux codes they map to were
// measured on a G910 Orion Spark (2026-10-18) by reading the hidraw node of the
// vendor interface while the kernel driver owned it, and cross-checked against
// the key codes the kernel emits for that interface (input/event* with
// Phys …/input1). The keyboard reports these keys *only* there, so the daemon
// has to replay them once it holds the interface (see README).
std::vector<MediaKey> g910MediaKeys() {
return {
{0x01, KEY_NEXTSONG, "next track"},
{0x02, KEY_PREVIOUSSONG, "previous track"},
{0x04, KEY_STOPCD, "stop"},
{0x08, KEY_PLAYPAUSE, "play/pause"},
{0x10, KEY_VOLUMEUP, "volume up"},
{0x20, KEY_VOLUMEDOWN, "volume down"},
{0x40, KEY_MUTE, "mute"},
};
}
DeviceProfile make_g910(const char* name, uint16_t product, bool disableBinding) {
DeviceProfile profile;
profile.name = name;
@@ -45,7 +66,8 @@ DeviceProfile make_g910(const char* name, uint16_t product, bool disableBinding)
profile.interfaceNumber = 1;
profile.altSetting = 0;
profile.gKeyReports = g910GKeys();
profile.releaseReport = report20({0x11, 0xff, 0x08, 0x00});
profile.mediaPrefix = 0x02;
profile.mediaKeys = g910MediaKeys();
profile.disableBinding = disableBinding;
if (disableBinding) {
profile.disableCommands = g910DisableCommands();
@@ -74,23 +96,67 @@ const DeviceProfile* find_profile(uint16_t vendor, uint16_t product) {
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 decode_gkey_state(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& pressed) {
pressed = 0;
if (data == nullptr || length <= 0 || profile.gKeyReports.empty()) {
return false;
}
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);
const Report& first = profile.gKeyReports.front();
if (static_cast<int>(first.size()) != length) {
return false; // a different report family, e.g. the 2-byte media keys
}
// Bytes that are the same in every key report are that model's fixed header;
// the bytes after it are the key bitmap (G1..G8 in byte 4, G9 in byte 5).
size_t header = 0;
for (; header < first.size(); ++header) {
bool shared = true;
for (const Report& report : profile.gKeyReports) {
if (report.size() != first.size() || report[header] != first[header]) {
shared = false;
break;
}
}
if (!shared) {
break;
}
}
if (header >= first.size()) {
return false; // no bit to read: pressed and released cannot be told apart
}
for (size_t i = 0; i < header; ++i) {
if (data[i] != first[i]) {
return false; // disable/enable G-key command, memory key, LED answer
}
}
for (size_t key = 0; key < profile.gKeyReports.size() && key < 32; ++key) {
const Report& press = profile.gKeyReports[key];
for (size_t i = header; i < press.size() && i < static_cast<size_t>(length); ++i) {
if (press[i] != 0 && (data[i] & press[i]) == press[i]) {
pressed |= 1u << key;
break;
}
}
}
return true;
}
bool decode_media_key(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& bits) {
bits = 0;
if (data == nullptr || profile.mediaPrefix == 0 || length < 2) {
return false;
}
if (data[0] != profile.mediaPrefix) {
return false; // a macro-key report, a memory key, a command or LED answer
}
// The report is two bytes; a longer read would be the same report padded by
// the endpoint, so only the bit byte is of interest.
bits = data[1];
return true;
}
} // namespace gkeymapper::daemon

View File

@@ -14,6 +14,14 @@ namespace gkeymapper::daemon {
// A raw HID report from the vendor interface.
using Report = std::vector<uint8_t>;
// One media key: the bit it has in the media report and the Linux input code it
// is replayed as.
struct MediaKey {
uint8_t bit = 0;
uint16_t key = 0; // Linux KEY_* code
const char* name = ""; // for the log line
};
struct DeviceProfile {
const char* name = "";
uint16_t vendor = 0;
@@ -22,9 +30,18 @@ struct DeviceProfile {
int interfaceNumber = 1;
int altSetting = 0;
// gKeyReports[i] is the press report for G(i+1).
// Media keys (mute / volume / play) arrive as a short report: this prefix
// byte followed by one bit per key, 0 when they are all released. 0 means
// the model reports none. This is the only path the keyboard offers for
// those keys, so while the daemon holds the interface the kernel does not see
// them at all — the daemon replays them through its virtual keyboard.
uint8_t mediaPrefix = 0;
std::vector<MediaKey> mediaKeys;
// gKeyReports[i] is the report that has the bit of G(i+1) set. Both the
// shared header bytes and the bit of each key are derived from these, so a
// new model only needs its table (see decode_gkey_state).
std::vector<Report> gKeyReports;
Report releaseReport;
// Some models must be told to stop mapping G-keys to F-keys first.
bool disableBinding = false;
@@ -36,11 +53,24 @@ 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);
// Decodes a macro-key report into the keys it reports as pressed: bit 0 = G1 ..
// bit 8 = G9. Returns false for every other report — the media keys have their
// own decoder below, and memory keys, LED and command acknowledgements are
// ignored on purpose.
//
// The report carries the state of all G keys, not a single key: one report with
// several bits set means a chord, and "no bit set" is the release. The caller
// compares consecutive states, which makes a held key (repeating report) fire
// once and a chord fire every key it contains.
bool decode_gkey_state(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& pressed);
// True if the report is the "all macro keys released" report.
bool is_release_report(const DeviceProfile& profile, const uint8_t* data, int length);
// Decodes a media-key report into its bits (bit set = key held), using the bit
// table of the profile. `bits` is 0 for the release report, which is a valid
// media report. Returns false for every other report, in particular for the
// macro-key reports.
bool decode_media_key(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& bits);
} // namespace gkeymapper::daemon

View File

@@ -16,7 +16,7 @@ GKeyDevice::~GKeyDevice() {
close();
}
bool GKeyDevice::open() {
bool GKeyDevice::open(bool quiet) {
if (handle_) {
return true;
}
@@ -47,15 +47,19 @@ bool GKeyDevice::open() {
}
if (!target) {
if (!quiet) {
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) {
if (!quiet) {
std::fprintf(stderr, "gkeymapper-daemon: could not open %s (permissions?)\n",
profile_->name);
}
libusb_free_device_list(devices, 1);
close();
return false;
@@ -74,8 +78,10 @@ bool GKeyDevice::open() {
}
}
if (libusb_claim_interface(handle_, profile_->interfaceNumber) != 0) {
if (!quiet) {
std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n",
profile_->interfaceNumber);
}
close();
return false;
}

View File

@@ -21,7 +21,11 @@ public:
// 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();
//
// `quiet` silences the "cannot use the keyboard" lines only, which the
// hot-plug retry uses so an unplugged keyboard does not flood the journal
// with one line every retry; libusb-level failures always log.
bool open(bool quiet = false);
void close();
bool is_open() const { return handle_ != nullptr; }

View File

@@ -4,8 +4,8 @@
// configured action and replays it ("key" via uinput, "command" via exec).
//
// Milestones: M0 (shared core), M1 (device bring-up/decoding), M2 (action
// execution), M3 (config watching + lifecycle) and M4 (D-Bus GUI channel) are
// done.
// execution), M3 (config watching + lifecycle), M4 (D-Bus GUI channel) and
// M5 (hot-plug, repeat/chord decoding, error reporting to the GUI) are done.
#include <csignal>
#include <cstdint>
@@ -33,6 +33,12 @@ void handle_reload(int /*signum*/) {
g_reload = 1;
}
// How often the daemon looks for the keyboard while it has none. The loop keeps
// pumping D-Bus and the config watch in between, so the control plane stays
// responsive and GetStatus can report that there is no device.
constexpr gint64 kReopenIntervalUs = 2 * 1000 * 1000;
constexpr int kReopenSleepMs = 200;
} // namespace
int main(int argc, char** argv) {
@@ -126,14 +132,26 @@ int main(int argc, char** argv) {
break;
}
if (!device.open()) {
return 1;
// Opens the keyboard and tells the GUI. Returns false when there is none,
// which is not an error: the session can start before the keyboard is
// plugged, and a lost or re-plugged keyboard must not cost the bus name —
// the GUI would see the daemon as gone and D-Bus activation would start a
// second one for nothing.
bool missingReported = false;
auto open_device = [&](bool quiet) -> bool {
if (!device.open(quiet)) {
return false;
}
device.disable_binding();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
missingReported = false;
return true;
};
uint8_t buffer[64];
uint32_t pressed = 0; // G keys reported as pressed by the previous report
gint64 next_open = 0; // earliest time to look for the keyboard again
while (g_running) {
// Control plane first (bounded by the read timeout below), then the
// keyboard. The loop wakes up at least every read timeout, which also
@@ -145,34 +163,88 @@ int main(int argc, char** argv) {
reload();
}
if (!device.is_open()) {
const gint64 now = g_get_monotonic_time();
if (now >= next_open) {
next_open = now + kReopenIntervalUs;
// The first attempt of an absence explains itself, the retries
// stay quiet: one line per unplug instead of one per retry.
if (open_device(missingReported)) {
continue; // read from it right away
}
if (!missingReported) {
missingReported = true;
std::fprintf(stderr,
"gkeymapper-daemon: no usable keyboard, will keep "
"looking\n");
}
}
// Sleep briefly instead of the 500 ms read timeout, so a keyboard
// that appears is picked up sooner and the loop still services the
// control plane.
g_usleep(kReopenSleepMs * 1000);
continue;
}
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);
// Lost the keyboard (unplugged, or the kernel took the interface
// back). Report it and keep looking; the daemon stays up.
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), device lost\n", n);
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
device.close();
if (!device.open()) {
return 1;
}
device.disable_binding();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
profile = nullptr;
pressed = 0;
next_open = 0;
continue;
}
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
run_mapping(index, false);
const gkeymapper::Mapping& mapping = mappings.keys[index];
service.notify_key_pressed(
index, std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target);
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
// Key released; nothing to do.
if (profile == nullptr) {
continue;
}
uint32_t state = 0;
if (gkeymapper::daemon::decode_gkey_state(*profile, buffer, n, state)) {
// Only a key that was not pressed before fires: a held key repeats its
// report, and a chord arrives as one report with several bits set, so
// every key of it fires exactly once.
for (size_t i = 0; i < mappings.keys.size() && i < gkeymapper::kNumKeys; ++i) {
const uint32_t bit = 1u << i;
if ((state & bit) == 0 || (pressed & bit) != 0) {
continue;
}
const gkeymapper::Mapping& mapping = mappings.keys[i];
const std::string action =
std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target;
run_mapping(static_cast<int>(i), false);
service.notify_key_pressed(static_cast<int>(i), action);
}
pressed = state;
continue;
}
// Media keys (mute / volume / play) are forwarded unchanged: they are
// reported only on the interface the daemon holds, so without this they
// would do nothing at all while it runs (see README). Replaying every
// report the keyboard sends keeps the behaviour identical to a keyboard
// the kernel drives itself — including its release report, which carries
// no bit and is therefore dropped below.
uint32_t media = 0;
if (!gkeymapper::daemon::decode_media_key(*profile, buffer, n, media) ||
media == 0) {
continue; // memory key, LED or command acknowledgement, or a release
}
for (const gkeymapper::daemon::MediaKey& key : profile->mediaKeys) {
if ((media & key.bit) == 0) {
continue;
}
std::printf("media -> %s\n", key.name);
std::fflush(stdout);
actions.tap_key(key.key);
}
// Media keys and LED acknowledgements are ignored.
}
service.notify_device_changed(false, profile != nullptr ? profile->name : "");

View File

@@ -99,6 +99,17 @@ void UinputKeyboard::key_event(unsigned short code, bool down) {
emit(EV_KEY, code, down ? 1 : 0);
}
void UinputKeyboard::tap(unsigned short code) {
if (fd_ < 0) {
return;
}
key_event(code, true);
sync();
usleep(2000);
key_event(code, false);
sync();
}
void UinputKeyboard::play(const std::vector<gkeymapper::ShortcutStep>& steps) {
if (fd_ < 0) {
return;

View File

@@ -25,6 +25,11 @@ public:
// Presses and releases every step of the shortcut in order.
void play(const std::vector<gkeymapper::ShortcutStep>& steps);
// Presses and releases a single Linux key code. The media keys are replayed
// with this: the keyboard reports them on its own (see README), so the daemon
// passes them on unchanged instead of turning them into a mapping.
void tap(unsigned short code);
private:
void emit(unsigned short type, unsigned short code, int value);
void sync();

View File

@@ -1,6 +1,6 @@
# gKeyMapper daemon — implementation plan
Status: implemented (M0–M4 done, see "Implementation status")
Status: implemented (M0–M5 done, see "Implementation status")
Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR
and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
@@ -38,6 +38,18 @@ and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
the daemon on demand; the unit is `Type=dbus` with `BusName=`. A second
instance refuses to start instead of fighting over the keyboard. Verified with
`gdbus` (methods, signals, activation) and over AT-SPI (live footer).
- **M5 done** — hardening and GUI polish: the daemon survives a missing or
disappearing keyboard and reopens it without losing its bus name (hot-plug,
quiet retry every 2 s); G-key decoding is state-based, so a held key no longer
repeats and two keys held at once both fire; the media keys are decoded and
forwarded through the virtual keyboard, so mute / volume / play keep working
while the daemon runs (see §4.4 and §11); the GUI loads the saved mappings
at startup (a fresh window can no longer save empty rows over the file) and the
footer distinguishes `Device: not found` and `Configuration error`; the
`GtkAboutDialog` `response` handler (invalid in GTK4) is gone. Verified without
hardware with a synthetic decoder test and a fake device driving the real loop,
with a GUI attached to it over D-Bus; the bitmap decoding and the media
passthrough were additionally confirmed on the real keyboard (see §9, §11).
## 1. Goals and non-goals
@@ -146,8 +158,13 @@ alternative; decision point in §11.
(`bmRequestType=0x21, bRequest=0x09, wValue=0x0212, wIndex=1`) and consume the
confirmation reports; ignore the known LED-return patterns
(`\x11\xff\x0f`, `\x11\xff\x10`, `\x11\xff\xff`).
- Hot-plug / re-plug: on `LIBUSB_ERROR_NO_DEVICE`/`IO` re-run discovery with a
small backoff (reference re-inits in place).
- Hot-plug / re-plug (as implemented in M5): a read error closes the device,
reports `DeviceChanged(false)` and the loop keeps looking for it every 2 s
without exiting. A keyboard that is unplugged — or not there yet when the
session starts — never costs the bus name, so the GUI keeps its connection and
D-Bus activation does not start a second instance. The retries are quiet (the
absence is logged once); `GKeyDevice::open(quiet)` suppresses only the
cannot-use-the-keyboard lines, libusb-level failures always log.
- Optional (later): `libusb_hotplug_register_callback` to react to device
add/remove without polling.
@@ -162,6 +179,28 @@ artifact so the runtime does zero parsing.
`dict` + `list(...).index()` O(n) scans with O(1)/O(log n) and no allocation.
- Categories: `macro` (G1..G9), `memory` (profiles, later), `media` (later),
`release` (stops a held shortcut), and ignored/known-noise.
- **As implemented (M1, reworked in M5):** one `decode_gkey_state()` instead of a
report lookup table. The G910 macro report is a *state* report, not a
single-key event: bytes 0–3 are the fixed header (`11 ff 08 00`), byte 4 is the
bitmap of G1–G8 and the low bit of byte 5 is G9. The decoder derives the header
and the bit of every key from the per-model table, returns the pressed bitmap
and rejects the other families by length or header (the `11 ff 09`/`11 ff 0a`
memory keys, the `11 ff 08 2e` command and the LED answers). The daemon fires a
key on a 0 → 1 transition, which is what makes a held key (repeating report)
fire once, makes a chord fire each key in it, and makes the release report
(`11 ff 08 00` + zero bitmap) a no-op.
- **Media keys (measured and added in M5):** they are *not* a lookup table either.
The keyboard reports them as a 2-byte report `02 <bit>` on the same interface
(bit 0 `01` next, `02` previous, `04` stop, `08` play/pause, `10` volume up,
`20` volume down, `40` mute, `02 00` release), so `decode_media_key()` reads the
bit byte and the daemon replays every bit it finds through its virtual keyboard.
That is a passthrough, not a mapping: without it the media keys would do nothing
at all while the daemon runs, because they reach the kernel only through this
interface (see §10 and §11).
- Consequence: exact-report matching is gone, so `match_gkey`,
`is_release_report` and the `releaseReport` table entry went with it. Verified
without hardware against synthetic reports and through a fake device running
the real loop.
### 4.5 Action layer (uinput)
- Open `/dev/uinput` once; `UI_SET_EVBIT` for `EV_KEY`, register the key set used
@@ -353,8 +392,13 @@ only dispatches what the loop's read timeout already woke up for.
- **M4 – IPC (done).** GDBus service (`Reload`, `GetStatus`, `TestMapping`,
signals); GUI status line + Reload-on-save; D-Bus activation file; unit
`Type=dbus` + `BusName`; single-instance guard.
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
(hot-plug, debounce, error reporting to the GUI).
- **M5 – Polish (done).** Packaging/install rules, docs, translations, hardening
(hot-plug, debounce, error reporting to the GUI): the daemon survives a
missing/disappearing keyboard and reopens it without losing the bus name;
decoding is state-based (repeats suppressed, chords work); the GUI loads the
saved mappings at startup and reports `Device: not found` / `Configuration
error` in the footer; `GtkAboutDialog` no longer gets a `response` handler it
does not have in GTK4.
## 9. Testing strategy
@@ -369,6 +413,31 @@ only dispatches what the loop's read timeout already woke up for.
line is read back over AT-SPI to check the live status.
- Permission tests: verify the udev rule grants access as a normal user.
- Resource check: `powertop`/`perf` or simple wakeup counting while idle.
- Decoder without hardware (used in M5): compile `daemon/device_tables.cpp`
standalone and feed it synthetic reports — single keys, chords, a repeated
report, release and the families that must be ignored (media, memory, command,
LED). Scratch test, not part of the repo.
- Loop without hardware (used in M5): build the daemon with a *fake*
`GKeyDevice` (same header, scripted `open()`/`read_report()`, linked without
libusb) to exercise hot-plug (no keyboard at start, device lost, reopen), the
D-Bus signals and the GUI footer states — without claiming the real keyboard.
- Hardware confirmation (done in M5, 2026-10-18): the bitmap model was checked on
the real keyboard. Pressing two G keys at once logs both keys from a single
report (`G6 -> key:` and `G7 -> key:`, same timestamp) and a key held for three
seconds logs exactly one line, with the release logging nothing. Use two rows
whose target is empty so the check injects no key into the focused window.
- Media keys (checked in M5, 2026-10-18): they are *not* on the keyboard's usual
event node. With the daemon stopped they arrive on `event11`
(`Phys …/input1`, created when `usbhid` rebinds interface `1.1`), while
`event10` (interface `1.0`) only advertises them in its capability bitmap;
with the daemon running neither node delivers them, which is why the daemon
forwards them itself. Raw reports can be captured from the `hidraw` node of
interface `1.1` while the daemon is stopped (that is how the bit table was
measured), and the passthrough is checked by pressing the keys with the repo
daemon running: `/tmp/daemon.log` logs `media -> mute` / `media -> volume up` /
`media -> stop` and the desktop reacts as it does without the daemon. A released
key logs nothing, and a `key`/`command` mapping is unaffected (press a G key in
between).
## 10. Risks / open points
@@ -376,33 +445,33 @@ only dispatches what the loop's read timeout already woke up for.
byte tables is real work and model-specific.
- **Interface detach** may momentarily disturb the device; must re-attach on
exit and handle crashes gracefully.
- **Media keys need the passthrough (measured 2026-10-18).** The keyboard reports
mute/volume/play only on the interface the daemon holds, so without the
passthrough of §4.4 they would do nothing while it runs — measured, and the
reason the decode path must not be dropped as "just a media key". See §11.
- **Permissions**: relies on `TAG+="uaccess"` + a user service; must never fall
back to requiring root silently.
- **`command` environment**: `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY` and
`PATH` differ between a user service and an interactive shell; decide whether
to inherit the user manager environment (`systemd --user` import-environment)
or document `run` snippets setting `DISPLAY=:0` explicitly.
- **Key-name vocabulary drift** between GUI (GDK names) and daemon (Linux KEY_*)
is the most likely source of silent mismatches — that is why the shared table
in M0 is mandatory before the daemon consumes the file.
- **uinput device identity**: a second keyboard appears; choose stable
name/vendor/product so the desktop does not confuse it with the real one.
- **GUI does not load the config at startup** (found while testing M4, GUI-side,
not a daemon issue): `build_ui()` fills the rows empty and only
`Import configuration...` reads the file, so pressing `Save mappings` in a
freshly opened window overwrites the user's mappings with empty ones. Loading
`~/.config/gKeyMapper/mappings.conf` on start (or refusing to save unchanged
rows) should be the next GUI fix.
## 11. Decisions to make
1. Bus name / app-id: replace `com.example.gkeymapper` with a real reverse-DNS id
(suggested `dev.rager.gkeymapper`)? Affects the `.desktop` name too.
2. Event loop: GLib `GMainLoop` (simplest, reuses GIO) vs. raw `epoll` (leanest).
3. IPC: D-Bus (idiomatic, GIO already present) vs. a Unix socket (no bus
dependency). Recommendation: D-Bus, with signals as a fallback.
4. Scope of v1: keep `key`/`command`/`disabled` only, or also port
`typeout`/`media`?
The questions this section used to list were all settled while implementing M3–M5
— app id/bus name (`dev.rager.gkeymapper` in M4), IPC (D-Bus with GDBus, in M4)
and the event loop (blocking loop + `DaemonService::pump()`, in M4). Their
answers are kept under *Resolved* below. What is genuinely still open:
- **Scope beyond v1.** v1 ships `key`/`command`/`disabled` plus the media-key
passthrough. The reference project additionally has `typeout` (type out a fixed
text), handles the media /memory keys itself and can *remap* them: our daemon
forwards the media keys unchanged (see *Resolved*) but ignores the memory keys
(`11 ff 09/0a`) and the command/LED answers. `typeout` would be a pure action
addition with no decoding involved (it needs the text typed through the active
layout); media-key *remapping* would turn the passthrough into mappings with
config entries and GUI rows. Not in v1 — decide when the need comes up.
### Resolved
@@ -457,3 +526,62 @@ only dispatches what the loop's read timeout already woke up for.
`DO_NOT_QUEUE` and exits with a message when it is already taken — two
instances would detach the kernel driver from each other and create two virtual
keyboards. The check happens before the device is opened.
- **GUI loads the config at startup (done in M5).** `build_ui()` reads
`default_config_path()` into the rows once they exist (skipped while the file
does not exist, so a first start stays quiet). This closes the M4 finding that
`Save mappings` in a fresh window overwrote the user's mappings with empty
rows; since the rows are filled from exactly what the file holds, saving
rewrites the same content.
- **Hot-plug (done in M5).** The daemon no longer exits when the keyboard cannot
be opened: it reports the absence once, keeps serving D-Bus and the config
watch and retries every 2 s (sleeping 200 ms per iteration in between), so a
re-plug is picked up without a restart and without losing the bus name. A read
error is treated the same way (`DeviceChanged(false)` → reopen →
`DeviceChanged(true)`).
- **G-key decoding (reworked in M5).** Deliberately not the report-lookup table
of §4.4: the macro report is a state bitmap, and a transition decoder is both
simpler and more correct — a held key no longer fires repeatedly and a chord
fires every key in it. The exact-match helpers it replaced were removed rather
than kept as dead code.
- **Bitmap decoding confirmed on hardware (M5, 2026-10-18).** The bitmap model is
what the device really sends, not just what the reference tables suggested: one
report with two bits set fired both keys (G6 and G7, same second in the journal),
a key held for three seconds fired exactly once, and the release fired nothing.
`decode_gkey_state()` is still the single place to change if another model in
`device_tables` turns out to differ.
- **Media keys keep working while the daemon runs (added in M5).** They are the
second report family on the same interface: a 2-byte `02 <bit>` report that the
first decoder version rejected by length. The daemon now decodes it
(`decode_media_key()`) and replays every bit it contains through the virtual
keyboard (`UinputKeyboard::tap()` via `ActionRunner::tap_key()`), so mute /
volume / play / next / previous / stop behave exactly as they do without the
daemon — the report is forwarded, not turned into a mapping, and the release
report carries no bit and is dropped.
The bit table was *measured* rather than guessed: the `hidraw` node of interface
`1.1` (which exists only while `usbhid` owns it) gave `02 01` next, `02 02`
previous, `02 04` stop, `02 08` play/pause, `02 10` volume up, `02 20` volume
down, `02 40` mute and `02 00` for the release, and the kernel's own input
device for that interface confirmed the codes (`KEY_NEXTSONG`,
`KEY_PREVIOUSSONG`, `KEY_STOPCD`, `KEY_PLAYPAUSE`, `KEY_VOLUMEUP`,
`KEY_VOLUMEDOWN`, `KEY_MUTE`). Both interfaces advertise the consumer usages in
their HID descriptors, so the capability bitmap alone is misleading — the raw
report is the only reliable source.
Why it has to exist at all: those keys reach the kernel only through this
interface, so while the daemon held it they did nothing. The README called that
a "may stop working" limitation until the measurement showed it was a plain
regression. Verified live: five volume-up and three volume-down presses moved
the sink by exactly two 2.5 % steps, and `media -> mute` / `media -> stop` /
`G6 -> key:` appeared in the daemon log.
Remapping the media keys instead of forwarding them stays open (§11, "Scope
beyond v1").
- **`command` environment (checked in M5).** Nothing to do: the daemon runs as a
**user** service, and the user manager's environment already carries
`DISPLAY=:0`, `XAUTHORITY` and a sane `PATH` on this machine (verified with
`systemctl --user show-environment` and by letting a `command` mapping dump its
environment). If a command cannot reach the display on another setup,
`systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XAUTHORITY` is the
fix; the README says so.
- **About dialog (fixed in M5).** It must not be connected to `response`: GTK4's
`GtkAboutDialog` is not a `GtkDialog` subclass with buttons, so the signal does
not exist and connecting to it only logged `GLib-GObject-WARNING`. Closing goes
through `close-request`, whose default handler destroys the window.

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gkeymapper 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-16 22:12+0200\n"
"PO-Revision-Date: 2026-10-16 22:12+0200\n"
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de\n"
@@ -139,6 +139,12 @@ msgstr "Daemon: läuft"
msgid "Device: %s"
msgstr "Gerät: %s"
msgid "Device: not found"
msgstr "Gerät: nicht gefunden"
msgid "Configuration error"
msgstr "Konfigurationsfehler"
msgid "Last key: G%d → %s"
msgstr "Letzte Taste: G%d → %s"

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gkeymapper 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-16 22:12+0200\n"
"PO-Revision-Date: 2026-10-16 22:12+0200\n"
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en\n"
@@ -139,6 +139,12 @@ msgstr "Daemon: running"
msgid "Device: %s"
msgstr "Device: %s"
msgid "Device: not found"
msgstr "Device: not found"
msgid "Configuration error"
msgstr "Configuration error"
msgid "Last key: G%d → %s"
msgstr "Last key: G%d → %s"

View File

@@ -4,8 +4,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gkeymapper 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-16 22:12+0200\n"
"PO-Revision-Date: 2026-10-16 22:12+0200\n"
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es\n"
@@ -137,6 +137,12 @@ msgstr "Demonio: en ejecución"
msgid "Device: %s"
msgstr "Dispositivo: %s"
msgid "Device: not found"
msgstr "Dispositivo: no encontrado"
msgid "Configuration error"
msgstr "Error de configuración"
msgid "Last key: G%d → %s"
msgstr "Última tecla: G%d → %s"

View File

@@ -4,8 +4,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gkeymapper 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-16 22:12+0200\n"
"PO-Revision-Date: 2026-10-16 22:12+0200\n"
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr\n"
@@ -137,6 +137,12 @@ msgstr "Démon : en cours d'exécution"
msgid "Device: %s"
msgstr "Appareil : %s"
msgid "Device: not found"
msgstr "Appareil : introuvable"
msgid "Configuration error"
msgstr "Erreur de configuration"
msgid "Last key: G%d → %s"
msgstr "Dernière touche : G%d → %s"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-16 22:12+0200\n"
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -165,22 +165,30 @@ msgstr ""
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
msgstr ""
#: src/main.cpp:54
#: src/main.cpp:55
msgid "Daemon: not reachable"
msgstr ""
#: src/main.cpp:56
#: src/main.cpp:57
msgid "Daemon: running"
msgstr ""
#: src/main.cpp:59
#: src/main.cpp:62
msgid "Device: not found"
msgstr ""
#: src/main.cpp:65
msgid "Device: %s"
msgstr ""
#: src/main.cpp:82
#: src/main.cpp:70
msgid "Configuration error"
msgstr ""
#: src/main.cpp:92
msgid "Last key: G%d → %s"
msgstr ""
#: src/main.cpp:429
#: src/main.cpp:475
msgid "Daemon: checking..."
msgstr ""

View File

@@ -90,8 +90,17 @@ void DaemonClient::on_daemon_signal(GDBusConnection* /*connection*/, const gchar
return;
}
// ConfigReloaded and DeviceChanged carry no state the footer does not
// already get from GetStatus, so just refresh it.
if (g_strcmp0(signalName, "ConfigReloaded") == 0) {
// The daemon keeps the previous mappings when the file cannot be read,
// so remember that and let the footer show it. It stays set until a
// reload succeeds.
gboolean ok = FALSE;
g_variant_get(parameters, "(b)", &ok);
self->configOk_ = ok != FALSE;
}
// DeviceChanged and the config state carry nothing GetStatus does not
// report, so a single status refresh covers both.
self->fetch_status();
}
@@ -147,6 +156,7 @@ void DaemonClient::on_status_reply(GObject* /*source*/, GAsyncResult* result, gp
DaemonStatus status;
status.reachable = true;
status.configOk = self->configOk_;
status.version = version != nullptr ? version : "";
status.device = device != nullptr ? device : "";
status.configPath = configPath != nullptr ? configPath : "";

View File

@@ -12,6 +12,7 @@ namespace gkeymapper {
// What the footer needs to know about the daemon.
struct DaemonStatus {
bool reachable = false;
bool configOk = true; // false after a ConfigReloaded(false); see fetch_status
std::string version;
std::string device;
std::string configPath;
@@ -60,6 +61,7 @@ private:
guint signal_subscription_ = 0;
guint name_subscription_ = 0;
bool started_ = false;
bool configOk_ = true;
StatusFn onStatus_;
KeyFn onKey_;
};

View File

@@ -36,6 +36,7 @@ std::vector<KeyRow> rows;
// for the daemon.
struct DaemonFooter {
bool reachable = false;
bool configOk = true;
std::string device;
std::string lastKey;
} daemonFooter;
@@ -54,12 +55,20 @@ static void refresh_footer() {
text = _("Daemon: not reachable");
} else {
text = _("Daemon: running");
if (!daemonFooter.device.empty()) {
text += " · ";
if (daemonFooter.device.empty()) {
// Reachable but no keyboard: unplugged, unsupported, or not found
// yet. Saying so is the difference between "broken" and "unplugged".
text += _("Device: not found");
} else {
g_autofree gchar* device =
g_strdup_printf(_("Device: %s"), daemonFooter.device.c_str());
text += " · ";
text += device;
}
if (!daemonFooter.configOk) {
text += " · ";
text += _("Configuration error");
}
if (!daemonFooter.lastKey.empty()) {
text += " · ";
text += daemonFooter.lastKey;
@@ -70,6 +79,7 @@ static void refresh_footer() {
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
daemonFooter.reachable = status.reachable;
daemonFooter.configOk = status.configOk;
daemonFooter.device = status.device;
if (!status.reachable) {
daemonFooter.lastKey.clear(); // stale once the daemon is gone
@@ -239,7 +249,9 @@ static void show_about_dialog(GtkWindow* parent) {
g_object_unref(texture);
}
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
g_signal_connect(dialog, "response", G_CALLBACK(gtk_window_destroy), NULL);
// No "response" handler: GtkAboutDialog has no such signal in GTK4 (only
// GtkDialog subclasses with buttons, like GtkFileChooserDialog, do). Closing
// it goes through close-request, whose default handler destroys the window.
gtk_window_present(GTK_WINDOW(dialog));
}
@@ -423,6 +435,14 @@ void build_ui(GtkApplication* app) {
update_row_state(&rows[i]);
}
// Start from the saved mappings. Without this the rows are empty and the
// first "Save mappings" of a freshly opened window would overwrite the
// user's file with empty rows. A missing file is not an error (first start).
const std::string configPath = gkeymapper::default_config_path();
if (g_file_test(configPath.c_str(), G_FILE_TEST_EXISTS)) {
gkeymapper::read_mappings(configPath, rows);
}
// Footer with save button and the daemon status line
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(footer, 8);