diff --git a/AGENTS.md b/AGENTS.md index b306b9f..725ab3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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 ` 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: ` → 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. diff --git a/README.md b/README.md index 1ccd788..e6f2d86 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/daemon/action.cpp b/daemon/action.cpp index aa37959..79ffa8a 100644 --- a/daemon/action.cpp +++ b/daemon/action.cpp @@ -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; diff --git a/daemon/action.h b/daemon/action.h index 8992257..e7e7b82 100644 --- a/daemon/action.h +++ b/daemon/action.h @@ -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); diff --git a/daemon/device_tables.cpp b/daemon/device_tables.cpp index 4e239e1..664da98 100644 --- a/daemon/device_tables.cpp +++ b/daemon/device_tables.cpp @@ -5,6 +5,8 @@ #include #include +#include + namespace gkeymapper::daemon { namespace { @@ -36,6 +38,25 @@ std::vector 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 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; +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; } - 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); + + const Report& first = profile.gKeyReports.front(); + if (static_cast(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; } } - return -1; + 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(length); ++i) { + if (press[i] != 0 && (data[i] & press[i]) == press[i]) { + pressed |= 1u << key; + break; + } + } + } + return true; } -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); +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 diff --git a/daemon/device_tables.h b/daemon/device_tables.h index afca197..d62757c 100644 --- a/daemon/device_tables.h +++ b/daemon/device_tables.h @@ -14,6 +14,14 @@ namespace gkeymapper::daemon { // A raw HID report from the vendor interface. using Report = std::vector; +// 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 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 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& 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 diff --git a/daemon/g910_device.cpp b/daemon/g910_device.cpp index aaf8df0..ca0223d 100644 --- a/daemon/g910_device.cpp +++ b/daemon/g910_device.cpp @@ -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) { - std::fprintf(stderr, "gkeymapper-daemon: no supported keyboard found\n"); + 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) { - std::fprintf(stderr, "gkeymapper-daemon: could not open %s (permissions?)\n", - profile_->name); + 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) { - std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n", - profile_->interfaceNumber); + if (!quiet) { + std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n", + profile_->interfaceNumber); + } close(); return false; } diff --git a/daemon/g910_device.h b/daemon/g910_device.h index c8dc9f5..02cbae8 100644 --- a/daemon/g910_device.h +++ b/daemon/g910_device.h @@ -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; } diff --git a/daemon/main.cpp b/daemon/main.cpp index 21c5f33..ec624eb 100644 --- a/daemon/main.cpp +++ b/daemon/main.cpp @@ -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 #include @@ -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; - } - device.disable_binding(); - profile = device.profile(); - service.notify_device_changed(true, profile != nullptr ? profile->name : ""); + // 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(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(i), false); + service.notify_key_pressed(static_cast(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 : ""); diff --git a/daemon/uinput_keyboard.cpp b/daemon/uinput_keyboard.cpp index ebe12bb..1ff6477 100644 --- a/daemon/uinput_keyboard.cpp +++ b/daemon/uinput_keyboard.cpp @@ -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& steps) { if (fd_ < 0) { return; diff --git a/daemon/uinput_keyboard.h b/daemon/uinput_keyboard.h index 87eadd0..cabd5f3 100644 --- a/daemon/uinput_keyboard.h +++ b/daemon/uinput_keyboard.h @@ -25,6 +25,11 @@ public: // Presses and releases every step of the shortcut in order. void play(const std::vector& 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(); diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md index 87462c6..67b2179 100644 --- a/docs/daemon-plan.md +++ b/docs/daemon-plan.md @@ -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 ` 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 ` 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. diff --git a/po/de.po b/po/de.po index f9d55d3..40097bd 100644 --- a/po/de.po +++ b/po/de.po @@ -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" diff --git a/po/en.po b/po/en.po index 6bc7162..31b679f 100644 --- a/po/en.po +++ b/po/en.po @@ -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" diff --git a/po/es.po b/po/es.po index 3d34583..06f400a 100644 --- a/po/es.po +++ b/po/es.po @@ -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" diff --git a/po/fr.po b/po/fr.po index 22d8242..dad672f 100644 --- a/po/fr.po +++ b/po/fr.po @@ -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" diff --git a/po/gkeymapper.pot b/po/gkeymapper.pot index b5fe5ea..9f35cb4 100644 --- a/po/gkeymapper.pot +++ b/po/gkeymapper.pot @@ -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 \n" "Language-Team: LANGUAGE \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 "" diff --git a/src/daemonclient.cpp b/src/daemonclient.cpp index 2c6f6f1..710817b 100644 --- a/src/daemonclient.cpp +++ b/src/daemonclient.cpp @@ -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 : ""; diff --git a/src/daemonclient.h b/src/daemonclient.h index 2dc3489..1be8524 100644 --- a/src/daemonclient.h +++ b/src/daemonclient.h @@ -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_; }; diff --git a/src/main.cpp b/src/main.cpp index 711df1d..fd15658 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,6 +36,7 @@ std::vector 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);