Media keys working again
This commit is contained in:
82
AGENTS.md
82
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 <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.
|
||||
|
||||
41
README.md
41
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
114
daemon/main.cpp
114
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 <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 : "");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
10
po/de.po
10
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"
|
||||
|
||||
|
||||
10
po/en.po
10
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"
|
||||
|
||||
|
||||
10
po/es.po
10
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"
|
||||
|
||||
|
||||
10
po/fr.po
10
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"
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
26
src/main.cpp
26
src/main.cpp
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user