Single instance

This commit is contained in:
2026-10-18 16:59:51 +02:00
parent 798708b3e7
commit 74503c27dd
19 changed files with 670 additions and 99 deletions

View File

@@ -48,7 +48,7 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
- The daemon is split into: `device_tables` (raw HID report tables per model plus - 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 `decode_gkey_state`, the report → pressed-bitmap decoder), `g910_device` (libusb
open/detach/claim/read), `uinput_keyboard` (virtual keyboard), `action` open/detach/claim/read), `uinput_keyboard` (virtual keyboard), `action`
(dispatch `key`/`command`/`disabled`), `config_watch` (inotify auto-reload of (dispatch `key`/`command`/`disabled`/`keyhold`), `config_watch` (inotify auto-reload of
the mappings file) and `ipc_dbus` (the GDBus service). `shared/keymap` does the 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 layout lookups for both sides. The hot path never touches the GUI: D-Bus is
control plane only and is pumped once per loop. control plane only and is pumped once per loop.
@@ -58,6 +58,20 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
a chord fire every key in it. Media/memory keys and command/LED answers are 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 rejected by report length and header; keep it that way instead of
re-introducing exact-report matching. re-introducing exact-report matching.
- `keyhold` uses the **falling** edge too: `run_mapping(..., hold=true)` calls
`ActionRunner::begin_hold()`, and the 1 → 0 transition of that bit calls
`end_hold(index)` (a no-op for every other type). The held codes are stored per
G key in `ActionRunner::held_`, so a code that two G keys hold is released only
when the *last* one comes up. `release_held()` runs on device loss and on
shutdown, otherwise a key whose release report never arrived would stay down in
the whole session. `ActionRunner::run()` (the `TestMapping` path) taps a hold
mapping, because a D-Bus call cannot wait for a release. Only a single step can
be held; a sequence is replayed as a tap with a log line, and `keyhold:` on an
unresolvable name does nothing.
- Auto-repeat of a held key comes from the **X server / compositor**, not from the
virtual device: it sets no `EV_REP` bits, so the kernel repeats nothing, but X
does (measured: release+press pairs every ~40 ms after 660 ms). That matches a
physical key, so do not "fix" it by filtering.
- The daemon runs **without** a keyboard: `open()` failing (or a read error) only - 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 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 2 s while D-Bus and the config watch keep running. Never make it exit on a
@@ -87,9 +101,26 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
id — `GtkApplication` owns that itself to detect a second GUI instance, so a id — `GtkApplication` owns that itself to detect a second GUI instance, so a
daemon on the same name would make every GUI start look like a duplicate and daemon on the same name would make every GUI start look like a duplicate and
exit. All names live in `shared/ipc_ids.h`. exit. All names live in `shared/ipc_ids.h`.
- The GUI never does a blocking D-Bus call: `DaemonClient` uses async calls and - Exactly one GUI runs, and it is the one that was just started: `main()` calls
signal subscriptions, which arrive on the GTK main loop, and it follows `replace_running_instance()` (`src/singleinstance.{h,cpp}`) **before** it
`NameOwnerChanged` so the footer reacts when the daemon starts or stops. creates the `GtkApplication`. It asks the instance that owns `ipc::kAppId` to
quit over D-Bus (the `quit` action `GApplication` exports as
`org.gtk.Actions` at the app-id path, added in `on_activate`) and waits for the
name; an instance from an older build has no such action, so it is terminated
with `SIGTERM` after checking that the bus name really belongs to a
`gkeymapper` process. Only then does the new process become the primary,
because otherwise `GtkApplication` would hand over to the old process and its
window — possibly one from an older build — would stay on screen. Row edits
that were not saved are gone with the replaced window; there is no implicit
save.
- `on_activate` presents the window that is already there
(`gtk_application_get_windows()`), it does not build a second one: a remote
activation — an older build starting next to a new one — would otherwise add a
second window to the running process.
- Except for that startup takeover, the GUI never does a blocking D-Bus call:
`DaemonClient` uses async calls and signal subscriptions, which arrive on the
GTK main loop, and it follows `NameOwnerChanged` so the footer reacts when the
daemon starts or stops.
- `DaemonService::pump()` dispatches messages once per daemon loop iteration, so - `DaemonService::pump()` dispatches messages once per daemon loop iteration, so
a control call is answered within one read timeout (≤500 ms) and outgoing a control call is answered within one read timeout (≤500 ms) and outgoing
signals are flushed then too. Keep it off the keypress path. signals are flushed then too. Keep it off the keypress path.
@@ -102,8 +133,10 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
over AT-SPI as a generic `filler`, not as a "statusbar" role, so it buys over AT-SPI as a generic `filler`, not as a "statusbar" role, so it buys
nothing here. nothing here.
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`, - The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens `2=Disabled`, `3=Key (hold)`, matching the `key`/`command`/`disabled`/`keyhold`
in the file. Keep that order. tokens in the file. Keep that order (`ActionType` appends new values at the end
— `Disabled, Key, Command, KeyHold` — so an existing value never changes
meaning; the GUI maps indices explicitly).
- The capture dialog's `CaptureSession` is heap-allocated and freed only by the - The capture dialog's `CaptureSession` is heap-allocated and freed only by the
window's `"destroy"` handler. Always close the dialog with window's `"destroy"` handler. Always close the dialog with
`gtk_window_destroy` so the session is released. `gtk_window_destroy` so the session is released.
@@ -126,11 +159,13 @@ the same format:
G1=key:ctrl+c,ctrl+v G1=key:ctrl+c,ctrl+v
G2=command:/usr/bin/firefox G2=command:/usr/bin/firefox
G3=disabled: G3=disabled:
G4=keyhold:ctrl+w
``` ```
`<type>:<target>`, one line per key. `key` is a captured sequence (lowercase `<type>:<target>`, one line per key. `key` is a captured sequence (lowercase
`gdk_keyval_name` output; modifiers joined with `+`, steps joined with `,`), `gdk_keyval_name` output; modifiers joined with `+`, steps joined with `,`),
`command` is a program path or command line, `disabled` is inactive. The `command` is a program path or command line, `disabled` is inactive, and
`keyhold` presses one step and keeps it down while the G key is held. The
canonical modifier order is `ctrl`, `alt`, `shift`, `super`, `altgr` canonical modifier order is `ctrl`, `alt`, `shift`, `super`, `altgr`
(`format_shortcut_step()`; parsing accepts any order). (`format_shortcut_step()`; parsing accepts any order).
@@ -226,6 +261,28 @@ over any installed file; the file lookup remains only as a safety net.
script that dumps its environment is also how you check that a `command` 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 action sees `DISPLAY`); with `key:` mappings the fake would inject keys into
whatever window is focused. whatever window is focused.
Two details when the fake has to exercise `key:`/`keyhold:` mappings:
- **Copy `main.cpp` next to the fake**, e.g. `/tmp/…/fakesrc/main.cpp`, and put
the fake `g910_device.h` in an include directory. `#include "g910_device.h"`
searches the *including file's* directory first, so compiling the real
`daemon/main.cpp` would always pick up the real header (its inline
`is_open()` needs libusb).
- **Give the fake its own session bus** (`dbus-daemon --session --address=
unix:path=/tmp/…/fake_bus --fork --print-pid=1`, then
`DBUS_SESSION_BUS_ADDRESS=unix:path=…` for the fake). Otherwise it refuses
to start while the real daemon is running, and stopping the user service for
a test loses the media keys and the GUI footer.
- **Read the events back over X**, not from `/dev/input`: the event node of an
own uinput device has **no ACL** for the user (`/dev/uinput` does, its
children do not), so `open()` fails with `EACCES`. `xinput test <id>` works
without root; identify the new device by diffing `xinput list` ids before and
after (the name is the same as the installed daemon's virtual keyboard). X
keycodes are evdev + 8, and `xinput test` shows no `EV_SYN`, so
simultaneity of a chord cannot be read that way — the press/release order
and the durations can.
- Use inert keys: `f13`–`f24` are **not** in this layout (`Keymap::key_code`
returns -1), `pause` and `kp_equal` are. A held Shift for a few 100 ms is
harmless, a held Ctrl is not.
- D-Bus activation is only testable in a real setup: the installed - D-Bus activation is only testable in a real setup: the installed
`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` needs `share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` needs
`sudo make install`. For a quick check without root, drop a file with `sudo make install`. For a quick check without root, drop a file with
@@ -245,7 +302,10 @@ over any installed file; the file lookup remains only as a safety net.
mute/volume/play only on that very interface (`…/input1`, measured — see below), mute/volume/play only on that very interface (`…/input1`, measured — see below),
so the daemon replays those reports unchanged through its virtual keyboard. so the daemon replays those reports unchanged through its virtual keyboard.
Deleting that passthrough in `daemon/main.cpp` silently kills the media keys Deleting that passthrough in `daemon/main.cpp` silently kills the media keys
while the daemon runs. while the daemon runs. Note that they are deliberately *not* a mapping and not
going to become one: making them remappable is decided against (plan §11), so do
not turn `mediaKeys` into config rows or GUI fields without that decision being
revisited first.
- Media keys cannot be observed over the keyboard's usual event node: with the - 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` daemon stopped they appear on `event11` (`Phys …/input1`, created when `usbhid`
owns the interface), not on `event10` (`…/input0`); *both* nodes advertise the owns the interface), not on `event10` (`…/input0`); *both* nodes advertise the
@@ -273,11 +333,22 @@ a child `label` node, so target the button by its child label. `pyatspi`
and inject keys. Warn the user before synthesizing keystrokes or stealing window and inject keys. Warn the user before synthesizing keystrokes or stealing window
focus. focus.
The one-instance behavior needs no hardware: start the GUI twice and check that
one process (`pgrep -x gkeymapper`) and one window (`wmctrl -l`) are left. The
replaced process exits `0` when it was closed through the D-Bus `quit` action, or
`143` (`SIGTERM`) when it came from a build without that action — build such an
instance from an older commit (`git worktree add /tmp/legacy <commit>`), start it
and then start the current build next to it. Starting the GUI twice also covers
the `activate` guard: without it the second start adds a second window to the
running process.
The footer's daemon line is a plain `GtkLabel`, so its text can be read back over The footer's daemon line is a plain `GtkLabel`, so its text can be read back over
AT-SPI: `<app> → frame → panel → label` whose name starts with the translated AT-SPI: `<app> → frame → panel → label` whose name starts with the translated
`Daemon:` string. To pick the right window, filter the application whose tree `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 contains that label. A stale instance is not a problem any more: starting the
same binary name. Three notes for tests: binary under test closes whatever is running (see the single-instance notes
above) before it shows its window, so it is safe to start it while an older GUI
is up. Three notes for tests:
- Clicking `Save mappings` writes `~/.config/gKeyMapper/mappings.conf` for real. - 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 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 mappings and a save rewrites the same content; back the file up anyway (or skip

View File

@@ -87,6 +87,12 @@ The window starts from the saved mappings: it loads
`~/.config/gKeyMapper/mappings.conf` when it opens, so `Save mappings` in a fresh `~/.config/gKeyMapper/mappings.conf` when it opens, so `Save mappings` in a fresh
window rewrites the same content instead of emptying the file. window rewrites the same content instead of emptying the file.
Only one window is open at a time, and it is the one you just started: a new
start closes the instance that is already running — including one from an older
build — and then shows its own window, so a stale window can never stay next to a
fresh one. Row edits that were not saved are lost with the replaced window,
because saving is always explicit (`Save mappings`).
## Running the daemon ## Running the daemon
The daemon reads G1–G9 from the keyboard and replays the configured action The daemon reads G1–G9 from the keyboard and replays the configured action
@@ -129,6 +135,13 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
- A G key fires when its report first shows it as pressed: holding it down does - 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 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). carries the state of all nine keys, not one key per report).
- `keyhold` is the exception to that: its keys stay pressed for as long as the G
key is held, like a normal key (including the desktop's auto-repeat), so a game
or a push-to-talk sees a key that is down exactly as long as you hold the G key.
The keyboard's release report ends it. If the keyboard is unplugged or the
daemon stops while a key is held, the daemon lets it go, so nothing stays stuck.
Holding only makes sense for a single step: `keyhold:ctrl+w` is held,
`keyhold:ctrl+c,ctrl+v` is replayed as a tap (the log says so).
- Only one instance may run: a second one prints why it cannot get the bus name - 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 and exits, instead of fighting over the keyboard and creating a second virtual
keyboard. keyboard.
@@ -252,13 +265,16 @@ modifiers are joined with `+`, steps are separated with `,`
G1=key:ctrl+c,ctrl+v G1=key:ctrl+c,ctrl+v
G2=command:/usr/bin/firefox G2=command:/usr/bin/firefox
G3=disabled: G3=disabled:
G4=keyhold:ctrl+w
``` ```
The same format is used by Export/Import. `key` holds a captured shortcut The same format is used by Export/Import. `key` holds a captured shortcut
sequence, `command` a program path or command line, `disabled` ignores the sequence and fires it once, `command` a program path or command line,
G key. These map to the `shortcut` / `run` / `nothing` hotkey types of the `disabled` ignores the G key, and `keyhold` keeps a single shortcut pressed for
reference project; the native C++ daemon (`gkeymapper-daemon`) reads this file as long as the G key is held (in the GUI it is the `Key (hold)` entry in the
and replays the actions — no Python involved. Type dropdown). These map to the `shortcut` / `run` / `nothing` hotkey types of
the reference project; the native C++ daemon (`gkeymapper-daemon`) reads this
file and replays the actions — no Python involved.
`Save mappings` writes atomically (temporary file + `rename`), so the daemon `Save mappings` writes atomically (temporary file + `rename`), so the daemon
never sees a half-written file and the watch reloads exactly once per save. never sees a half-written file and the watch reloads exactly once per save.
@@ -271,6 +287,7 @@ src/keyrow.h # KeyRow (one per G key)
src/mappings.{h,cpp} # GUI glue: config file <-> row widgets src/mappings.{h,cpp} # GUI glue: config file <-> row widgets
src/capturedialog.{h,cpp} # key-capture dialog src/capturedialog.{h,cpp} # key-capture dialog
src/daemonclient.{h,cpp} # D-Bus client for the daemon (status/reload) src/daemonclient.{h,cpp} # D-Bus client for the daemon (status/reload)
src/singleinstance.{h,cpp} # one GUI only: a new start closes a running instance
shared/mapping.{h,cpp} # mappings format + shortcut parsing (GUI & daemon) shared/mapping.{h,cpp} # mappings format + shortcut parsing (GUI & daemon)
shared/keys.{h,cpp} # canonical key names -> Linux key codes shared/keys.{h,cpp} # canonical key names -> Linux key codes
shared/keymap.{h,cpp} # layout-aware key lookups (xkbcommon), GUI + daemon shared/keymap.{h,cpp} # layout-aware key lookups (xkbcommon), GUI + daemon
@@ -279,7 +296,7 @@ daemon/main.cpp # event loop: read G-keys, run the action
daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910) daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910)
daemon/g910_device.{h,cpp} # libusb access to the vendor interface daemon/g910_device.{h,cpp} # libusb access to the vendor interface
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled" daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled" / "keyhold"
daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload) daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload)
daemon/ipc_dbus.{h,cpp} # D-Bus service (status/reload/test/signals) daemon/ipc_dbus.{h,cpp} # D-Bus service (status/reload/test/signals)
data/60-gkeymapper.rules # udev rule (uaccess for device + /dev/uinput) data/60-gkeymapper.rules # udev rule (uaccess for device + /dev/uinput)
@@ -320,10 +337,16 @@ export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:
## Planned next steps ## Planned next steps
- Make the media keys remappable (they are forwarded unchanged today, see above) - The M1–M3 / MR keys of the G910 and profiles: swap mapping sets on the fly,
and support the M1–M3 / MR keys of the G910. which is what the reserved `SetProfile` D-Bus method was meant for. Deliberately
- `typeout`: type out a fixed text, like the reference project's action of that left for later (see `docs/daemon-plan.md` §11).
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. - Key sequence timing: per-step delays for recorded macros.
- `typeout`: type out a fixed text, like the reference project's action of that
name — not decided yet (see §11).
## Not planned
- Remapping the media keys. The daemon forwards mute / volume / play / next /
previous / stop unchanged, and that is meant to stay that way (see the note
above): they are not G keys, so they get no rows in the GUI and no entry in the
config file.

View File

@@ -2,6 +2,7 @@
#include "action.h" #include "action.h"
#include <algorithm>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -19,17 +20,28 @@ bool ActionRunner::init() {
return true; return true;
} }
bool ActionRunner::resolve_shortcut(const std::string& target,
std::vector<gkeymapper::ShortcutStep>& steps) {
// Resolve key names through the active keyboard layout.
gkeymapper::KeyResolver resolve = [this](const std::string& name) {
return keymap_.key_code(name);
};
if (!gkeymapper::parse_shortcut(target, steps, resolve)) {
std::fprintf(stderr, "gkeymapper-daemon: invalid shortcut '%s'\n", target.c_str());
return false;
}
return true;
}
void ActionRunner::run(const gkeymapper::Mapping& mapping) { void ActionRunner::run(const gkeymapper::Mapping& mapping) {
switch (mapping.type) { switch (mapping.type) {
case gkeymapper::ActionType::Key: { case gkeymapper::ActionType::Key:
case gkeymapper::ActionType::KeyHold: {
// A test (TestMapping) and any other caller that cannot wait for a
// key release fires a hold mapping as a tap. A real G-key press goes
// through begin_hold/end_hold instead.
std::vector<gkeymapper::ShortcutStep> steps; std::vector<gkeymapper::ShortcutStep> steps;
// Resolve key names through the active keyboard layout. if (!resolve_shortcut(mapping.target, steps)) {
gkeymapper::KeyResolver resolve = [this](const std::string& name) {
return keymap_.key_code(name);
};
if (!gkeymapper::parse_shortcut(mapping.target, steps, resolve)) {
std::fprintf(stderr, "gkeymapper-daemon: invalid shortcut '%s'\n",
mapping.target.c_str());
return; return;
} }
keyboard_.play(steps); keyboard_.play(steps);
@@ -44,6 +56,82 @@ void ActionRunner::run(const gkeymapper::Mapping& mapping) {
} }
} }
bool ActionRunner::begin_hold(int index, const gkeymapper::Mapping& mapping) {
if (index < 0 || index >= static_cast<int>(held_.size())) {
return false;
}
end_hold(index); // a repeated press must not stack codes
std::vector<gkeymapper::ShortcutStep> steps;
if (!resolve_shortcut(mapping.target, steps)) {
return false;
}
if (steps.size() != 1) {
// Holding only means something for a single step (that is where the
// "same duration as the key press" idea comes from, see README). A
// sequence would have to hold several chords at once, so it is replayed
// as a tap instead - exactly like a "key" mapping.
std::fprintf(stderr,
"gkeymapper-daemon: 'keyhold:%s' is not a single step, replaying it "
"as a tap\n",
mapping.target.c_str());
keyboard_.play(steps);
return false;
}
if (!keyboard_.is_open()) {
return false; // no virtual keyboard: nothing to hold
}
const std::vector<unsigned short> codes = step_key_codes(steps.front());
if (codes.empty()) {
return false;
}
held_[static_cast<size_t>(index)] = codes;
keyboard_.press(codes);
return true;
}
void ActionRunner::end_hold(int index) {
if (index < 0 || index >= static_cast<int>(held_.size())) {
return;
}
std::vector<unsigned short>& codes = held_[static_cast<size_t>(index)];
if (codes.empty()) {
return;
}
// Release in press order; UinputKeyboard::release() turns that into the
// reverse order, so the key goes up first and the modifiers go up last. A
// code another G key also holds stays down until *its* G key is released.
std::vector<unsigned short> up;
for (unsigned short code : codes) {
bool still_held = false;
for (size_t other = 0; other < held_.size(); ++other) {
if (other == static_cast<size_t>(index)) {
continue;
}
if (std::find(held_[other].begin(), held_[other].end(), code) !=
held_[other].end()) {
still_held = true;
break;
}
}
if (!still_held) {
up.push_back(code);
}
}
codes.clear();
if (!up.empty()) {
keyboard_.release(up);
}
}
void ActionRunner::release_held() {
for (size_t i = 0; i < held_.size(); ++i) {
end_hold(static_cast<int>(i));
}
}
void ActionRunner::tap_key(unsigned short code) { void ActionRunner::tap_key(unsigned short code) {
keyboard_.tap(code); keyboard_.tap(code);
} }

View File

@@ -2,7 +2,9 @@
#ifndef GKEYMAPPER_DAEMON_ACTION_H #ifndef GKEYMAPPER_DAEMON_ACTION_H
#define GKEYMAPPER_DAEMON_ACTION_H #define GKEYMAPPER_DAEMON_ACTION_H
#include <array>
#include <string> #include <string>
#include <vector>
#include "keymap.h" #include "keymap.h"
#include "mapping.h" #include "mapping.h"
@@ -18,6 +20,19 @@ public:
void run(const gkeymapper::Mapping& mapping); void run(const gkeymapper::Mapping& mapping);
// "keyhold": presses the keys of the mapping and keeps them down until the G
// key is released. Returns whether anything is being held; a sequence, an
// unusable target or a missing virtual keyboard fall back to a tap (false),
// so the caller does not have to special-case them. Releasing index 0..8 is
// done by end_hold(), all of them at once by release_held().
bool begin_hold(int index, const gkeymapper::Mapping& mapping);
void end_hold(int index);
// Let go of every held key. Called on device loss, on shutdown and before a
// new device is claimed: a virtual key that stayed down because its release
// report never arrived would look like a stuck key to the whole session.
void release_held();
// Replays one key press + release. Used by the media-key passthrough, which // 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 // is not a mapping: the keyboard reports mute/volume/play only on the
// interface the daemon holds, so they are forwarded verbatim. // interface the daemon holds, so they are forwarded verbatim.
@@ -26,8 +41,15 @@ public:
private: private:
void run_command(const std::string& command); void run_command(const std::string& command);
// Parses mapping.target through the active layout. Logs and returns false on
// an invalid shortcut.
bool resolve_shortcut(const std::string& target,
std::vector<gkeymapper::ShortcutStep>& steps);
UinputKeyboard keyboard_; UinputKeyboard keyboard_;
gkeymapper::Keymap keymap_; gkeymapper::Keymap keymap_;
// Codes each G key (0 = G1) currently keeps down, empty when not holding.
std::array<std::vector<unsigned short>, gkeymapper::kNumKeys> held_{};
}; };
} // namespace gkeymapper::daemon } // namespace gkeymapper::daemon

View File

@@ -7,6 +7,7 @@
// execution), M3 (config watching + lifecycle), M4 (D-Bus GUI channel) and // execution), M3 (config watching + lifecycle), M4 (D-Bus GUI channel) and
// M5 (hot-plug, repeat/chord decoding, error reporting to the GUI) are done. // M5 (hot-plug, repeat/chord decoding, error reporting to the GUI) are done.
#include <algorithm>
#include <csignal> #include <csignal>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
@@ -81,8 +82,10 @@ int main(int argc, char** argv) {
// Runs the mapping of G<index+1> (0-based). A real keypress and the D-Bus // Runs the mapping of G<index+1> (0-based). A real keypress and the D-Bus
// TestMapping call both go through here, so a test fires exactly the same // TestMapping call both go through here, so a test fires exactly the same
// path. Returns whether there was an action to run. // path. `hold` is set for a real press, where a "keyhold" mapping keeps its
auto run_mapping = [&](int index, bool test) -> bool { // keys down until the G key is released; a test always taps. Returns whether
// there was an action to run.
auto run_mapping = [&](int index, bool test, bool hold) -> bool {
if (index < 0 || index >= static_cast<int>(mappings.keys.size())) { if (index < 0 || index >= static_cast<int>(mappings.keys.size())) {
return false; return false;
} }
@@ -94,7 +97,11 @@ int main(int argc, char** argv) {
test ? " (test)" : ""); test ? " (test)" : "");
std::fflush(stdout); std::fflush(stdout);
if (usable) { if (usable) {
actions.run(mapping); if (hold && mapping.type == gkeymapper::ActionType::KeyHold) {
actions.begin_hold(index, mapping);
} else {
actions.run(mapping);
}
} }
return usable; return usable;
}; };
@@ -110,7 +117,7 @@ int main(int argc, char** argv) {
return profile != nullptr ? std::string(profile->name) : std::string(); return profile != nullptr ? std::string(profile->name) : std::string();
}); });
service.set_test([&](int index, std::string& action) { service.set_test([&](int index, std::string& action) {
if (!run_mapping(index, true)) { if (!run_mapping(index, true, false)) {
return false; return false;
} }
const gkeymapper::Mapping& mapping = mappings.keys[index]; const gkeymapper::Mapping& mapping = mappings.keys[index];
@@ -194,6 +201,8 @@ int main(int argc, char** argv) {
// Lost the keyboard (unplugged, or the kernel took the interface // Lost the keyboard (unplugged, or the kernel took the interface
// back). Report it and keep looking; the daemon stays up. // back). Report it and keep looking; the daemon stays up.
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), device lost\n", n); std::fprintf(stderr, "gkeymapper-daemon: read error (%d), device lost\n", n);
// A held key would stay down forever without its release report.
actions.release_held();
service.notify_device_changed(false, profile != nullptr ? profile->name : ""); service.notify_device_changed(false, profile != nullptr ? profile->name : "");
device.close(); device.close();
profile = nullptr; profile = nullptr;
@@ -207,19 +216,29 @@ int main(int argc, char** argv) {
} }
uint32_t state = 0; uint32_t state = 0;
if (gkeymapper::daemon::decode_gkey_state(*profile, buffer, n, state)) { if (gkeymapper::daemon::decode_gkey_state(*profile, buffer, n, state)) {
// Only a key that was not pressed before fires: a held key repeats its // Only a *change* of a bit acts: a held key repeats its report, and a
// report, and a chord arrives as one report with several bits set, so // chord arrives as one report with several bits set, so every key of
// every key of it fires exactly once. // it fires exactly once. The falling edge matters for "keyhold",
for (size_t i = 0; i < mappings.keys.size() && i < gkeymapper::kNumKeys; ++i) { // which lets its keys go when the G key comes up.
const size_t count =
std::min(mappings.keys.size(), static_cast<size_t>(gkeymapper::kNumKeys));
for (size_t i = 0; i < count; ++i) {
const uint32_t bit = 1u << i; const uint32_t bit = 1u << i;
if ((state & bit) == 0 || (pressed & bit) != 0) { const bool down = (state & bit) != 0;
const bool was_down = (pressed & bit) != 0;
if (down == was_down) {
continue;
}
if (!down) {
// No-op unless this G key holds a "keyhold" mapping.
actions.end_hold(static_cast<int>(i));
continue; continue;
} }
const gkeymapper::Mapping& mapping = mappings.keys[i]; const gkeymapper::Mapping& mapping = mappings.keys[i];
const std::string action = const std::string action =
std::string(gkeymapper::action_type_token(mapping.type)) + ":" + std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target; mapping.target;
run_mapping(static_cast<int>(i), false); run_mapping(static_cast<int>(i), false, true);
service.notify_key_pressed(static_cast<int>(i), action); service.notify_key_pressed(static_cast<int>(i), action);
} }
pressed = state; pressed = state;
@@ -247,6 +266,9 @@ int main(int argc, char** argv) {
} }
} }
// Shutting down: a "keyhold" key that is still down belongs to the session,
// not to the daemon, so let it go before the virtual keyboard disappears.
actions.release_held();
service.notify_device_changed(false, profile != nullptr ? profile->name : ""); service.notify_device_changed(false, profile != nullptr ? profile->name : "");
service.pump(); service.pump();
device.close(); device.close();

View File

@@ -2,6 +2,7 @@
#include "uinput_keyboard.h" #include "uinput_keyboard.h"
#include <algorithm>
#include <cstdio> #include <cstdio>
#include <fcntl.h> #include <fcntl.h>
#include <initializer_list> #include <initializer_list>
@@ -16,10 +17,7 @@
namespace gkeymapper::daemon { namespace gkeymapper::daemon {
namespace { unsigned short modifier_key_code(unsigned bit) {
// Key code for a single modifier bit (the "left" variants are enough).
unsigned short modifier_code(unsigned bit) {
switch (bit) { switch (bit) {
case gkeymapper::kModCtrl: return KEY_LEFTCTRL; case gkeymapper::kModCtrl: return KEY_LEFTCTRL;
case gkeymapper::kModAlt: return KEY_LEFTALT; case gkeymapper::kModAlt: return KEY_LEFTALT;
@@ -30,7 +28,29 @@ unsigned short modifier_code(unsigned bit) {
} }
} }
} // namespace std::vector<unsigned short> step_key_codes(const gkeymapper::ShortcutStep& step) {
std::vector<unsigned short> codes;
for (unsigned bit : {gkeymapper::kModCtrl, gkeymapper::kModAlt,
gkeymapper::kModShift, gkeymapper::kModSuper,
gkeymapper::kModAltGr}) {
if ((step.modifiers & bit) == 0) {
continue;
}
unsigned short code = modifier_key_code(bit);
if (code != 0) {
codes.push_back(code);
}
}
if (step.linux_key >= 0) {
// "ctrl+leftctrl" and the like would press the same code twice; keep
// the list unique so press()/release() stay balanced.
unsigned short code = static_cast<unsigned short>(step.linux_key);
if (std::find(codes.begin(), codes.end(), code) == codes.end()) {
codes.push_back(code);
}
}
return codes;
}
UinputKeyboard::~UinputKeyboard() { UinputKeyboard::~UinputKeyboard() {
close(); close();
@@ -110,40 +130,40 @@ void UinputKeyboard::tap(unsigned short code) {
sync(); sync();
} }
void UinputKeyboard::press(const std::vector<unsigned short>& codes) {
if (fd_ < 0) {
return;
}
for (unsigned short code : codes) {
key_event(code, true);
}
sync();
}
void UinputKeyboard::release(const std::vector<unsigned short>& codes) {
if (fd_ < 0) {
return;
}
// Reverse order: the modifiers go up last.
for (auto it = codes.rbegin(); it != codes.rend(); ++it) {
key_event(*it, false);
}
sync();
}
void UinputKeyboard::play(const std::vector<gkeymapper::ShortcutStep>& steps) { void UinputKeyboard::play(const std::vector<gkeymapper::ShortcutStep>& steps) {
if (fd_ < 0) { if (fd_ < 0) {
return; return;
} }
for (const gkeymapper::ShortcutStep& step : steps) { for (const gkeymapper::ShortcutStep& step : steps) {
std::vector<unsigned short> modifiers; const std::vector<unsigned short> codes = step_key_codes(step);
for (unsigned bit : {gkeymapper::kModCtrl, gkeymapper::kModAlt, if (codes.empty()) {
gkeymapper::kModShift, gkeymapper::kModSuper, continue;
gkeymapper::kModAltGr}) {
if ((step.modifiers & bit) != 0) {
unsigned short code = modifier_code(bit);
if (code != 0) {
modifiers.push_back(code);
}
}
} }
press(codes);
for (unsigned short code : modifiers) {
key_event(code, true);
}
if (step.linux_key >= 0) {
key_event(static_cast<unsigned short>(step.linux_key), true);
}
sync();
usleep(2000); usleep(2000);
release(codes);
if (step.linux_key >= 0) {
key_event(static_cast<unsigned short>(step.linux_key), false);
}
for (auto it = modifiers.rbegin(); it != modifiers.rend(); ++it) {
key_event(*it, false);
}
sync();
usleep(2000); usleep(2000);
} }
} }

View File

@@ -8,6 +8,15 @@
namespace gkeymapper::daemon { namespace gkeymapper::daemon {
// Key code for a single modifier bit (the "left" variants are enough), 0 when
// the bit is not a modifier.
unsigned short modifier_key_code(unsigned bit);
// Expands one shortcut step into the key codes to press: the modifiers first,
// then the key itself. Used by play() and by the KeyHold action, which has to
// release exactly the codes it pressed.
std::vector<unsigned short> step_key_codes(const gkeymapper::ShortcutStep& step);
// A virtual keyboard created through /dev/uinput. It is only used to replay // A virtual keyboard created through /dev/uinput. It is only used to replay
// shortcuts; the daemon never reads from it. // shortcuts; the daemon never reads from it.
class UinputKeyboard { class UinputKeyboard {
@@ -30,6 +39,12 @@ public:
// passes them on unchanged instead of turning them into a mapping. // passes them on unchanged instead of turning them into a mapping.
void tap(unsigned short code); void tap(unsigned short code);
// Presses, respectively releases, all codes in one EV_SYN block (release in
// reverse order). Splitting press and release is what "keyhold" needs: the
// caller keeps the list and releases it when the G key comes up.
void press(const std::vector<unsigned short>& codes);
void release(const std::vector<unsigned short>& codes);
private: private:
void emit(unsigned short type, unsigned short code, int value); void emit(unsigned short type, unsigned short code, int value);
void sync(); void sync();

View File

@@ -1,8 +1,9 @@
# gKeyMapper daemon — implementation plan # gKeyMapper daemon — implementation plan
Status: implemented (M0–M5 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 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. on the Logitech G910 and replays the mappings stored by the GUI. The media keys
are not part of that: the daemon forwards them unchanged (§11, *Decided*).
## Implementation status ## Implementation status
@@ -50,6 +51,18 @@ and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
hardware with a synthetic decoder test and a fake device driving the real loop, 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 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). passthrough were additionally confirmed on the real keyboard (see §9, §11).
- **`keyhold` (added after M5)** — `G<n>=keyhold:<shortcut>` presses one step and
keeps it down until that G key's state bit clears, i.e. for exactly as long as
the key is held (games, push-to-talk). The release report was already there, so
the work was the falling edge in the loop, per-G-key bookkeeping of the held
codes (`ActionRunner::held_`, the last release wins when two G keys hold the
same code) and `release_held()` on device loss and shutdown, so nothing can stay
stuck. A sequence falls back to a tap. In the GUI it is the fourth Type entry
(`Key (hold)`, token `keyhold`, dropdown index 3). Verified with the fake device
through the real loop plus `xinput test`: a 1.5 s hold = one press and one
release 1.5 s later (the repeated press reports fire nothing), correct release
order (key up before its modifiers), release on device loss; X repeats a held
key on its own (660 ms delay, then ~40 ms), exactly like a physical key.
## 1. Goals and non-goals ## 1. Goals and non-goals
@@ -63,6 +76,10 @@ Goals
Non-goals (v1) Non-goals (v1)
- Profiles (M1–M3/MR), per-locale text typing (`typeout`), embedded scripting. - Profiles (M1–M3/MR), per-locale text typing (`typeout`), embedded scripting.
The M keys are only deferred — they come after v1 (§11, *Decided*) — while
`typeout` is still undecided (§11).
- Remapping the media keys: they are forwarded unchanged, that is the decision
(§11, *Decided*).
- GUI redesign beyond a status indicator / reload / test button. - GUI redesign beyond a status indicator / reload / test button.
## 2. What the Python reference does (and what we keep) ## 2. What the Python reference does (and what we keep)
@@ -207,11 +224,16 @@ artifact so the runtime does zero parsing.
by the mappings, `UI_DEV_CREATE`. Keep the fd for the daemon's lifetime. by the mappings, `UI_DEV_CREATE`. Keep the fd for the daemon's lifetime.
- Emit events by `write()`-ing `struct input_event` (from - Emit events by `write()`-ing `struct input_event` (from
`<linux/uinput.h>` / `<linux/input-event-codes.h>` — no libevdev needed). `<linux/uinput.h>` / `<linux/input-event-codes.h>` — no libevdev needed).
- `key` mapping = shortcut grammar `ctrl+c,ctrl+v`: - `key` mapping = shortcut grammar `ctrl+c,ctrl+v`: every step is pressed and
- single combo: press modifiers+key in order, remember the keys, release them released (a tap of ~2 ms), so a comma list replays the chords in order. (The
when the keyboard's `release` event arrives (mirror the reference's original plan held a single combo down until the release event arrived; that
`press_release_fifo` stack). became its own type, `keyhold`, so a plain `key` still fires once.)
- comma list: `emit_combo()` each group (press+release). - `keyhold` mapping = one step, pressed on the 0 → 1 transition and released on the
1 → 0 transition, i.e. exactly as long as the G key is held. A comma list falls
back to a tap (holding several chords has no meaning). The held codes are tracked
per G key, so a code that two G keys hold goes up with the last of them, and
`release_held()` runs on device loss and on shutdown — otherwise a lost release
report would leave a key down in the whole session.
- `command` mapping: `fork()`; in the child `setsid()`, redirect stdio to - `command` mapping: `fork()`; in the child `setsid()`, redirect stdio to
`/dev/null`, `execve("/bin/sh", "-c", cmd)`. Reap via `SIGCHLD`. `/dev/null`, `execve("/bin/sh", "-c", cmd)`. Reap via `SIGCHLD`.
- `disabled`: no-op. - `disabled`: no-op.
@@ -220,7 +242,7 @@ artifact so the runtime does zero parsing.
### 4.6 Config ### 4.6 Config
- Parse the existing `mappings.conf`: - Parse the existing `mappings.conf`:
`G<n>=<type>:<target>` with `type ∈ {key, command, disabled}`. `G<n>=<type>:<target>` with `type ∈ {key, command, disabled, keyhold}`.
- Parse at load and on change only; the runtime holds a small pre-parsed action - Parse at load and on change only; the runtime holds a small pre-parsed action
per G-key (enum + fixed target buffer / `std::string`), so no parsing in the per G-key (enum + fixed target buffer / `std::string`), so no parsing in the
hot path. hot path.
@@ -333,12 +355,19 @@ Start-up orders:
without widening the fixed-width rows. without widening the fixed-width rows.
- The key-name vocabulary matches because the capture dialog already writes - The key-name vocabulary matches because the capture dialog already writes
lowercase GDK key names and the canonical list lives in `shared/keys` (§6). lowercase GDK key names and the canonical list lives in `shared/keys` (§6).
- One window, and it is the one that was just started: `main()` closes a running
instance before it becomes the primary one — over D-Bus through the `quit`
action (`src/singleinstance.{h,cpp}`), or with `SIGTERM` when that instance
comes from a build without the action — and `on_activate` re-presents the
window that is already there instead of building a second one. A stale window
therefore never stays next to a fresh one, and the daemon is not involved.
## 6. Code layout ## 6. Code layout
``` ```
src/ # GUI (existing) src/ # GUI (existing)
daemonclient.{h,cpp} # D-Bus client (status/reload/signals) daemonclient.{h,cpp} # D-Bus client (status/reload/signals)
singleinstance.{h,cpp} # one GUI only: a new start closes the running instance
daemon/ # as implemented (see §6 above) daemon/ # as implemented (see §6 above)
ipc_dbus.{h,cpp} # GDBus service (control plane) ipc_dbus.{h,cpp} # GDBus service (control plane)
shared/ shared/
@@ -462,16 +491,41 @@ only dispatches what the loop's read timeout already woke up for.
The questions this section used to list were all settled while implementing M3–M5 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) — 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 and the event loop (blocking loop + `DaemonService::pump()`, in M4). Their
answers are kept under *Resolved* below. What is genuinely still open: answers are kept under *Resolved* below. What is still open:
- **Scope beyond v1.** v1 ships `key`/`command`/`disabled` plus the media-key - **`typeout` (open, low priority).** The reference project types out a fixed
passthrough. The reference project additionally has `typeout` (type out a fixed text; our daemon has no such action. It would be a pure addition to the action
text), handles the media /memory keys itself and can *remap* them: our daemon layer with no decoding involved: a token in the `mappings.conf` format, a fourth
forwards the media keys unchanged (see *Resolved*) but ignores the memory keys Type entry in the GUI, the four `.po` files, and a typing path that maps the
(`11 ff 09/0a`) and the command/LED answers. `typeout` would be a pure action text to key codes through the active layout (xkbcommon is already linked).
addition with no decoding involved (it needs the text typed through the active Nothing decided yet.
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. ### Decided
- **The media keys stay a passthrough — they are not going to be remappable
(2026-10-18).** Mute / volume / play / next / previous / stop keep the
keyboard's own behaviour rather than becoming configurable rows. They are not G
keys: the GUI has no slot for them, they are not part of the `mappings.conf`
format, and adding them would mean a second kind of key in the format, the
decoder, the daemon and the GUI — for keys whose job the keyboard already does.
Remapping would not even let the passthrough go away, because those reports only
reach the kernel through the interface the daemon holds; the passthrough stays
in any case and is the place to hook into if this decision is ever revisited
(`decode_media_key()` and `mediaKeys` in `daemon/device_tables.cpp`).
- **The M1–M3 / MR keys and profiles come later (2026-10-18).** Not dropped, just
after v1: the M keys switch profiles, so they need the profile concept and the
reserved `SetProfile` D-Bus call first, and the device tables carry G1–G9 only.
The README lists them under "Planned next steps".
- **Holding a G key is its own type (`keyhold`), not an option on `key`
(2026-10-18).** `key` keeps firing once; `keyhold` is the mapping whose keys stay
down. Two types instead of a per-row flag keep the file format and the dropdown
simple (one token and one entry per type) and make the intent visible in the
file. The limits are deliberate: a *sequence* cannot be held (it is replayed as
a tap, with a log line), because holding several chords at once has no meaning,
and a modifier *alone* (`keyhold:shift` for a push-to-talk on Shift) is still not
expressible — `parse_shortcut()` requires a real key and the capture dialog
ignores modifier-only presses, so that would need a small change in both places.
A single key or modifier+key (`keyhold:space`, `keyhold:ctrl+w`) works.
### Resolved ### Resolved
@@ -572,8 +626,8 @@ answers are kept under *Resolved* below. What is genuinely still open:
regression. Verified live: five volume-up and three volume-down presses moved 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` / the sink by exactly two 2.5 % steps, and `media -> mute` / `media -> stop` /
`G6 -> key:` appeared in the daemon log. `G6 -> key:` appeared in the daemon log.
Remapping the media keys instead of forwarding them stays open (§11, "Scope Remapping them instead of forwarding them is decided against (see *Decided*
beyond v1"). above).
- **`command` environment (checked in M5).** Nothing to do: the daemon runs as a - **`command` environment (checked in M5).** Nothing to do: the daemon runs as a
**user** service, and the user manager's environment already carries **user** service, and the user manager's environment already carries
`DISPLAY=:0`, `XAUTHORITY` and a sane `PATH` on this machine (verified with `DISPLAY=:0`, `XAUTHORITY` and a sane `PATH` on this machine (verified with

View File

@@ -59,6 +59,9 @@ msgstr "Aufnehmen"
msgid "e.g. ctrl+c, ctrl+v" msgid "e.g. ctrl+c, ctrl+v"
msgstr "z. B. Strg+C, Strg+V" msgstr "z. B. Strg+C, Strg+V"
msgid "e.g. ctrl+w (held while the G key is held)"
msgstr "z. B. Strg+W (gehalten, solange die G-Taste gedrückt ist)"
#, c-format #, c-format
msgid "Could not save %s.\n" msgid "Could not save %s.\n"
msgstr "Konnte %s nicht speichern.\n" msgstr "Konnte %s nicht speichern.\n"
@@ -124,6 +127,9 @@ msgstr "Befehl"
msgid "Disabled" msgid "Disabled"
msgstr "Deaktiviert" msgstr "Deaktiviert"
msgid "Key (hold)"
msgstr "Taste (halten)"
msgid "Save mappings" msgid "Save mappings"
msgstr "Mappings speichern" msgstr "Mappings speichern"

View File

@@ -59,6 +59,9 @@ msgstr "Capture"
msgid "e.g. ctrl+c, ctrl+v" msgid "e.g. ctrl+c, ctrl+v"
msgstr "e.g. ctrl+c, ctrl+v" msgstr "e.g. ctrl+c, ctrl+v"
msgid "e.g. ctrl+w (held while the G key is held)"
msgstr "e.g. ctrl+w (held while the G key is held)"
#, c-format #, c-format
msgid "Could not save %s.\n" msgid "Could not save %s.\n"
msgstr "Could not save %s.\n" msgstr "Could not save %s.\n"
@@ -124,6 +127,9 @@ msgstr "Command"
msgid "Disabled" msgid "Disabled"
msgstr "Disabled" msgstr "Disabled"
msgid "Key (hold)"
msgstr "Key (hold)"
msgid "Save mappings" msgid "Save mappings"
msgstr "Save mappings" msgstr "Save mappings"

View File

@@ -57,6 +57,9 @@ msgstr "Capturar"
msgid "e.g. ctrl+c, ctrl+v" msgid "e.g. ctrl+c, ctrl+v"
msgstr "p. ej. ctrl+c, ctrl+v" msgstr "p. ej. ctrl+c, ctrl+v"
msgid "e.g. ctrl+w (held while the G key is held)"
msgstr "p. ej. ctrl+w (mantenida mientras se mantiene pulsada la tecla G)"
#, c-format #, c-format
msgid "Could not save %s.\n" msgid "Could not save %s.\n"
msgstr "No se pudo guardar %s.\n" msgstr "No se pudo guardar %s.\n"
@@ -122,6 +125,9 @@ msgstr "Comando"
msgid "Disabled" msgid "Disabled"
msgstr "Desactivado" msgstr "Desactivado"
msgid "Key (hold)"
msgstr "Tecla (mantener)"
msgid "Save mappings" msgid "Save mappings"
msgstr "Guardar asignaciones" msgstr "Guardar asignaciones"

View File

@@ -57,6 +57,9 @@ msgstr "Capturer"
msgid "e.g. ctrl+c, ctrl+v" msgid "e.g. ctrl+c, ctrl+v"
msgstr "ex. ctrl+c, ctrl+v" msgstr "ex. ctrl+c, ctrl+v"
msgid "e.g. ctrl+w (held while the G key is held)"
msgstr "ex. ctrl+w (maintenue tant que la touche G est enfoncée)"
#, c-format #, c-format
msgid "Could not save %s.\n" msgid "Could not save %s.\n"
msgstr "Impossible d'enregistrer %s.\n" msgstr "Impossible d'enregistrer %s.\n"
@@ -122,6 +125,9 @@ msgstr "Commande"
msgid "Disabled" msgid "Disabled"
msgstr "Désactivé" msgstr "Désactivé"
msgid "Key (hold)"
msgstr "Touche (maintenue)"
msgid "Save mappings" msgid "Save mappings"
msgstr "Enregistrer les attributions" msgstr "Enregistrer les attributions"

View File

@@ -55,22 +55,26 @@ msgid ""
"recorded too). Click Done to apply or Cancel to discard." "recorded too). Click Done to apply or Cancel to discard."
msgstr "" msgstr ""
#: src/main.cpp:263 #: src/main.cpp:146
msgid "Choose file..." msgid "Choose file..."
msgstr "" msgstr ""
#: src/main.cpp:265 #: src/main.cpp:148
msgid "e.g. /usr/bin/firefox or custom command" msgid "e.g. /usr/bin/firefox or custom command"
msgstr "" msgstr ""
#: src/main.cpp:267 src/main.cpp:586 #: src/main.cpp:150 src/main.cpp:154 src/main.cpp:436
msgid "Capture" msgid "Capture"
msgstr "" msgstr ""
#: src/main.cpp:269 src/main.cpp:579 #: src/main.cpp:156 src/main.cpp:429
msgid "e.g. ctrl+c, ctrl+v" msgid "e.g. ctrl+c, ctrl+v"
msgstr "" msgstr ""
#: src/main.cpp:152
msgid "e.g. ctrl+w (held while the G key is held)"
msgstr ""
#: src/main.cpp:306 #: src/main.cpp:306
#, c-format #, c-format
msgid "Could not save %s.\n" msgid "Could not save %s.\n"
@@ -145,18 +149,22 @@ msgstr ""
msgid "Action" msgid "Action"
msgstr "" msgstr ""
#: src/main.cpp:556 #: src/main.cpp:404
msgid "Key" msgid "Key"
msgstr "" msgstr ""
#: src/main.cpp:556 #: src/main.cpp:404
msgid "Command" msgid "Command"
msgstr "" msgstr ""
#: src/main.cpp:556 #: src/main.cpp:404
msgid "Disabled" msgid "Disabled"
msgstr "" msgstr ""
#: src/main.cpp:405
msgid "Key (hold)"
msgstr ""
#: src/main.cpp:603 #: src/main.cpp:603
msgid "Save mappings" msgid "Save mappings"
msgstr "" msgstr ""

View File

@@ -17,6 +17,8 @@ const char* action_type_token(ActionType type) {
return "key"; return "key";
case ActionType::Command: case ActionType::Command:
return "command"; return "command";
case ActionType::KeyHold:
return "keyhold";
case ActionType::Disabled: case ActionType::Disabled:
default: default:
return "disabled"; return "disabled";
@@ -30,6 +32,9 @@ ActionType action_type_from_token(const std::string& token) {
if (token == "command") { if (token == "command") {
return ActionType::Command; return ActionType::Command;
} }
if (token == "keyhold") {
return ActionType::KeyHold;
}
return ActionType::Disabled; return ActionType::Disabled;
} }

View File

@@ -5,6 +5,7 @@
// G1=key:ctrl+c,ctrl+v // G1=key:ctrl+c,ctrl+v
// G2=command:/usr/bin/firefox // G2=command:/usr/bin/firefox
// G3=disabled: // G3=disabled:
// G4=keyhold:ctrl+w (held down while G4 is held down)
#ifndef GKEYMAPPER_MAPPING_H #ifndef GKEYMAPPER_MAPPING_H
#define GKEYMAPPER_MAPPING_H #define GKEYMAPPER_MAPPING_H
@@ -16,7 +17,10 @@ namespace gkeymapper {
constexpr int kNumKeys = 9; // G1..G9 constexpr int kNumKeys = 9; // G1..G9
enum class ActionType { Disabled, Key, Command }; // KeyHold keeps its keys down until the G key is released; the others fire once
// on the press. The order is not part of the file format (the tokens are), but
// keep it stable: the GUI maps dropdown indices explicitly.
enum class ActionType { Disabled, Key, Command, KeyHold };
struct Mapping { struct Mapping {
ActionType type = ActionType::Disabled; ActionType type = ActionType::Disabled;

View File

@@ -15,6 +15,7 @@
#include "keyrow.h" #include "keyrow.h"
#include "mapping.h" #include "mapping.h"
#include "mappings.h" #include "mappings.h"
#include "singleinstance.h"
#ifndef PACKAGE #ifndef PACKAGE
#define PACKAGE "gkeymapper" #define PACKAGE "gkeymapper"
@@ -129,7 +130,9 @@ void on_capture_clicked(GtkButton* btn, gpointer userData) {
return; return;
} }
if (sel == 0) { // "Key" and "Key (hold)" capture the same thing: a hold mapping is a single
// step that the daemon keeps down while the G key is held.
if (sel == 0 || sel == 3) {
gkeymapper::open_key_capture_dialog(row, window); gkeymapper::open_key_capture_dialog(row, window);
} }
} }
@@ -144,6 +147,10 @@ static void update_row_state(KeyRow* row) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Choose file...")); gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Choose file..."));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry), gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. /usr/bin/firefox or custom command")); _("e.g. /usr/bin/firefox or custom command"));
} else if (sel == 3) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. ctrl+w (held while the G key is held)"));
} else { } else {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture")); gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry), gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
@@ -302,6 +309,12 @@ static void action_about(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpo
} }
} }
// Exported by GApplication as org.gtk.Actions, so a new start can close this
// instance over D-Bus before it takes over (see singleinstance.h).
static void action_quit(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpointer userData) {
g_application_quit(G_APPLICATION(userData));
}
static std::string find_asset(const std::string& name) { static std::string find_asset(const std::string& name) {
const char* env = g_getenv("GKEYMAPPER_ASSETS_DIR"); const char* env = g_getenv("GKEYMAPPER_ASSETS_DIR");
if (env && *env) { if (env && *env) {
@@ -395,7 +408,8 @@ void build_ui(GtkApplication* app) {
gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1); gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1);
} }
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), nullptr }; const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"),
_("Key (hold)"), nullptr };
rows.resize(kNumKeys); rows.resize(kNumKeys);
for (int i = 0; i < kNumKeys; ++i) { for (int i = 0; i < kNumKeys; ++i) {
@@ -484,6 +498,15 @@ void build_ui(GtkApplication* app) {
} }
static void on_activate(GtkApplication* app, gpointer /*user_data*/) { static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
// One window, always. A second "activate" (a desktop launch, or D-Bus
// activation while the app is open) must not build another window - main()
// replaces a running instance before this runs, so normally there is none.
GList* windows = gtk_application_get_windows(app);
if (windows != nullptr) {
gtk_window_present(GTK_WINDOW(windows->data));
return;
}
GSimpleAction* exportAction = g_simple_action_new("export-config", NULL); GSimpleAction* exportAction = g_simple_action_new("export-config", NULL);
g_signal_connect(exportAction, "activate", G_CALLBACK(action_export), app); g_signal_connect(exportAction, "activate", G_CALLBACK(action_export), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(exportAction)); g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(exportAction));
@@ -496,6 +519,10 @@ static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
g_signal_connect(aboutAction, "activate", G_CALLBACK(action_about), app); g_signal_connect(aboutAction, "activate", G_CALLBACK(action_about), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(aboutAction)); g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(aboutAction));
GSimpleAction* quitAction = g_simple_action_new("quit", NULL);
g_signal_connect(quitAction, "activate", G_CALLBACK(action_quit), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(quitAction));
build_ui(app); build_ui(app);
} }
} // namespace } // namespace
@@ -511,6 +538,12 @@ int main(int argc, char** argv) {
bind_textdomain_codeset(PACKAGE, "UTF-8"); bind_textdomain_codeset(PACKAGE, "UTF-8");
textdomain(PACKAGE); textdomain(PACKAGE);
// One GUI only, and it is the one that was just started: close the instance
// that already owns the app id (an older build, or the same one) before
// becoming the primary instance. Without this the new process just hands over
// to the old one and its stale window stays on screen.
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId);
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId, GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
G_APPLICATION_FLAGS_NONE); G_APPLICATION_FLAGS_NONE);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr); g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);

View File

@@ -20,6 +20,7 @@ bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) { switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) {
case 0: mapping.type = ActionType::Key; break; case 0: mapping.type = ActionType::Key; break;
case 1: mapping.type = ActionType::Command; break; case 1: mapping.type = ActionType::Command; break;
case 3: mapping.type = ActionType::KeyHold; break;
default: mapping.type = ActionType::Disabled; break; default: mapping.type = ActionType::Disabled; break;
} }
const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry)); const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry));
@@ -45,6 +46,7 @@ void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
switch (set.keys[row.index].type) { switch (set.keys[row.index].type) {
case ActionType::Key: idx = 0; break; case ActionType::Key: idx = 0; break;
case ActionType::Command: idx = 1; break; case ActionType::Command: idx = 1; break;
case ActionType::KeyHold: idx = 3; break;
default: idx = 2; break; default: idx = 2; break;
} }
gtk_drop_down_set_selected(GTK_DROP_DOWN(row.typeDrop), idx); gtk_drop_down_set_selected(GTK_DROP_DOWN(row.typeDrop), idx);

157
src/singleinstance.cpp Normal file
View File

@@ -0,0 +1,157 @@
// gKeyMapper - keep exactly one GUI: a new start replaces the running instance.
//
// GtkApplication already makes a second process hand over to the first one (the
// app id is unique), but that leaves the *old* window on screen - including one
// from an earlier build. So main() first closes what is running and only then
// becomes the primary instance itself.
#include "singleinstance.h"
#include <csignal>
#include <gio/gio.h>
#include <glib.h>
#include <unistd.h>
namespace gkeymapper {
namespace {
constexpr int kCallTimeoutMs = 1000;
constexpr int kQuitWaitMs = 1500;
constexpr int kTermWaitMs = 1500;
constexpr int kPollMs = 50;
GDBusConnection* session_bus() {
GError* error = nullptr;
GDBusConnection* bus = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
if (bus == nullptr) {
g_printerr("gkeymapper: no session bus (%s)\n", error != nullptr ? error->message : "?");
g_clear_error(&error);
}
return bus;
}
// Sync call with a short timeout; nullptr means "no answer" (the caller decides
// what that means) and the error is dropped - a missing action or an absent name
// is an expected outcome here, not a failure to report.
GVariant* call(GDBusConnection* bus, const char* name, const char* path, const char* interface,
const char* method, GVariant* args, const GVariantType* replyType) {
GError* error = nullptr;
GVariant* reply = g_dbus_connection_call_sync(bus, name, path, interface, method, args,
replyType, G_DBUS_CALL_FLAGS_NONE,
kCallTimeoutMs, nullptr, &error);
g_clear_error(&error);
return reply;
}
bool has_owner(GDBusConnection* bus, const std::string& name) {
GVariant* reply = call(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "NameHasOwner",
g_variant_new("(s)", name.c_str()), G_VARIANT_TYPE("(b)"));
if (reply == nullptr) {
return false;
}
gboolean owned = FALSE;
g_variant_get(reply, "(b)", &owned);
g_variant_unref(reply);
return owned;
}
bool wait_until_free(GDBusConnection* bus, const std::string& name, int timeoutMs) {
for (int waited = 0; waited <= timeoutMs; waited += kPollMs) {
if (!has_owner(bus, name)) {
return true;
}
g_usleep(kPollMs * 1000);
}
return !has_owner(bus, name);
}
// GApplication exports the app actions under the object path derived from the app
// id (dots become slashes), so "quit" is reachable before GTK is even started.
std::string action_object_path(const std::string& appId) {
std::string path = "/";
for (char c : appId) {
path += c == '.' ? '/' : c;
}
return path;
}
bool ask_to_quit(GDBusConnection* bus, const std::string& name) {
GVariantBuilder parameters;
g_variant_builder_init(&parameters, G_VARIANT_TYPE("av"));
GVariantBuilder platform;
g_variant_builder_init(&platform, G_VARIANT_TYPE("a{sv}"));
const std::string path = action_object_path(name);
GVariant* reply =
call(bus, name.c_str(), path.c_str(), "org.gtk.Actions", "Activate",
g_variant_new("(sava{sv})", "quit", &parameters, &platform), nullptr);
if (reply == nullptr) {
return false; // no such action (older build) or no answer
}
g_variant_unref(reply);
return true;
}
pid_t owner_pid(GDBusConnection* bus, const std::string& name) {
GVariant* reply = call(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "GetConnectionUnixProcessID",
g_variant_new("(s)", name.c_str()), G_VARIANT_TYPE("(u)"));
if (reply == nullptr) {
return -1;
}
guint32 pid = 0;
g_variant_get(reply, "(u)", &pid);
g_variant_unref(reply);
return static_cast<pid_t>(pid);
}
// The bus name only yields a pid; check that it is our GUI before signalling it,
// so a wedged or recycled pid is never hit.
bool is_our_gui(pid_t pid) {
gchar* path = g_strdup_printf("/proc/%d/comm", static_cast<int>(pid));
gchar* contents = nullptr;
const bool read = g_file_get_contents(path, &contents, nullptr, nullptr);
g_free(path);
std::string comm = read && contents != nullptr ? contents : "";
g_free(contents);
while (!comm.empty() && (comm.back() == '\n' || comm.back() == '\r')) {
comm.pop_back();
}
return comm == "gkeymapper";
}
} // namespace
bool replace_running_instance(const std::string& appId) {
GDBusConnection* bus = session_bus();
if (bus == nullptr) {
return true; // without a bus there is no other instance either
}
bool free = !has_owner(bus, appId);
if (!free) {
// Clean path: the running instance quits itself.
ask_to_quit(bus, appId);
free = wait_until_free(bus, appId, kQuitWaitMs);
if (!free) {
// An instance from before the "quit" action existed, or a hung one.
const pid_t pid = owner_pid(bus, appId);
if (pid > 0 && is_our_gui(pid)) {
g_printerr("gkeymapper: closing the running instance (pid %d)\n",
static_cast<int>(pid));
::kill(pid, SIGTERM);
}
free = wait_until_free(bus, appId, kTermWaitMs);
}
}
g_object_unref(bus);
if (!free) {
g_printerr("gkeymapper: another instance still holds %s\n", appId.c_str());
}
return free;
}
} // namespace gkeymapper

23
src/singleinstance.h Normal file
View File

@@ -0,0 +1,23 @@
// gKeyMapper - keep exactly one GUI: a new start replaces the running instance.
#ifndef GKEYMAPPER_SINGLEINSTANCE_H
#define GKEYMAPPER_SINGLEINSTANCE_H
#include <string>
namespace gkeymapper {
// Asks the instance that owns `appId` on the session bus to quit and waits until
// it has let go of the name, so the caller can become the only instance. The
// request goes through the app's `quit` action (exported by GApplication as
// org.gtk.Actions); an instance that does not offer it — an older build — is
// terminated with SIGTERM instead, after checking that the bus name really
// belongs to a gkeymapper process.
//
// Returns true when the name is free afterwards, which is also the case when
// nobody owned it. The caller still runs its usual single-instance logic, so a
// `false` (a hung instance that cannot be closed) only means "no new window".
bool replace_running_instance(const std::string& appId);
} // namespace gkeymapper
#endif // GKEYMAPPER_SINGLEINSTANCE_H