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
`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
(dispatch `key`/`command`/`disabled`/`keyhold`), `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.
@@ -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
rejected by report length and header; keep it that way instead of
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
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
@@ -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
daemon on the same name would make every GUI start look like a duplicate and
exit. All names live in `shared/ipc_ids.h`.
- 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.
- Exactly one GUI runs, and it is the one that was just started: `main()` calls
`replace_running_instance()` (`src/singleinstance.{h,cpp}`) **before** it
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
a control call is answered within one read timeout (≤500 ms) and outgoing
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
nothing here.
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens
in the file. Keep that order.
`2=Disabled`, `3=Key (hold)`, matching the `key`/`command`/`disabled`/`keyhold`
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
window's `"destroy"` handler. Always close the dialog with
`gtk_window_destroy` so the session is released.
@@ -126,11 +159,13 @@ the same format:
G1=key:ctrl+c,ctrl+v
G2=command:/usr/bin/firefox
G3=disabled:
G4=keyhold:ctrl+w
```
`<type>:<target>`, one line per key. `key` is a captured sequence (lowercase
`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`
(`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`
action sees `DISPLAY`); with `key:` mappings the fake would inject keys into
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
`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service` needs
`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),
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.
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
daemon stopped they appear on `event11` (`Phys …/input1`, created when `usbhid`
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
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
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. Three notes for tests:
contains that label. A stale instance is not a problem any more: starting the
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.
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

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
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
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
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).
- `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
and exits, instead of fighting over the keyboard and creating a second virtual
keyboard.
@@ -252,13 +265,16 @@ modifiers are joined with `+`, steps are separated with `,`
G1=key:ctrl+c,ctrl+v
G2=command:/usr/bin/firefox
G3=disabled:
G4=keyhold:ctrl+w
```
The same format is used by Export/Import. `key` holds a captured shortcut
sequence, `command` a program path or command line, `disabled` ignores the
G key. These map to the `shortcut` / `run` / `nothing` hotkey types of the
reference project; the native C++ daemon (`gkeymapper-daemon`) reads this file
and replays the actions — no Python involved.
sequence and fires it once, `command` a program path or command line,
`disabled` ignores the G key, and `keyhold` keeps a single shortcut pressed for
as long as the G key is held (in the GUI it is the `Key (hold)` entry in the
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
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/capturedialog.{h,cpp} # key-capture dialog
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/keys.{h,cpp} # canonical key names -> Linux key codes
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/g910_device.{h,cpp} # libusb access to the vendor interface
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled"
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled" / "keyhold"
daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload)
daemon/ipc_dbus.{h,cpp} # D-Bus service (status/reload/test/signals)
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
- 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.
- The M1–M3 / MR keys of the G910 and profiles: swap mapping sets on the fly,
which is what the reserved `SetProfile` D-Bus method was meant for. Deliberately
left for later (see `docs/daemon-plan.md` §11).
- 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 <algorithm>
#include <cstdio>
#include <vector>
@@ -19,17 +20,28 @@ bool ActionRunner::init() {
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) {
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;
// Resolve key names through the active keyboard layout.
gkeymapper::KeyResolver resolve = [this](const std::string& name) {
return keymap_.key_code(name);
};
if (!gkeymapper::parse_shortcut(mapping.target, steps, resolve)) {
std::fprintf(stderr, "gkeymapper-daemon: invalid shortcut '%s'\n",
mapping.target.c_str());
if (!resolve_shortcut(mapping.target, steps)) {
return;
}
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) {
keyboard_.tap(code);
}

View File

@@ -2,7 +2,9 @@
#ifndef GKEYMAPPER_DAEMON_ACTION_H
#define GKEYMAPPER_DAEMON_ACTION_H
#include <array>
#include <string>
#include <vector>
#include "keymap.h"
#include "mapping.h"
@@ -18,6 +20,19 @@ public:
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
// is not a mapping: the keyboard reports mute/volume/play only on the
// interface the daemon holds, so they are forwarded verbatim.
@@ -26,8 +41,15 @@ public:
private:
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_;
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

View File

@@ -7,6 +7,7 @@
// 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 <algorithm>
#include <csignal>
#include <cstdint>
#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
// TestMapping call both go through here, so a test fires exactly the same
// path. Returns whether there was an action to run.
auto run_mapping = [&](int index, bool test) -> bool {
// path. `hold` is set for a real press, where a "keyhold" mapping keeps its
// 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())) {
return false;
}
@@ -94,7 +97,11 @@ int main(int argc, char** argv) {
test ? " (test)" : "");
std::fflush(stdout);
if (usable) {
actions.run(mapping);
if (hold && mapping.type == gkeymapper::ActionType::KeyHold) {
actions.begin_hold(index, mapping);
} else {
actions.run(mapping);
}
}
return usable;
};
@@ -110,7 +117,7 @@ int main(int argc, char** argv) {
return profile != nullptr ? std::string(profile->name) : std::string();
});
service.set_test([&](int index, std::string& action) {
if (!run_mapping(index, true)) {
if (!run_mapping(index, true, false)) {
return false;
}
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
// back). Report it and keep looking; the daemon stays up.
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 : "");
device.close();
profile = nullptr;
@@ -207,19 +216,29 @@ int main(int argc, char** argv) {
}
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) {
// Only a *change* of a bit acts: 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. The falling edge matters for "keyhold",
// 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;
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;
}
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);
run_mapping(static_cast<int>(i), false, true);
service.notify_key_pressed(static_cast<int>(i), action);
}
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.pump();
device.close();

View File

@@ -2,6 +2,7 @@
#include "uinput_keyboard.h"
#include <algorithm>
#include <cstdio>
#include <fcntl.h>
#include <initializer_list>
@@ -16,10 +17,7 @@
namespace gkeymapper::daemon {
namespace {
// Key code for a single modifier bit (the "left" variants are enough).
unsigned short modifier_code(unsigned bit) {
unsigned short modifier_key_code(unsigned bit) {
switch (bit) {
case gkeymapper::kModCtrl: return KEY_LEFTCTRL;
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() {
close();
@@ -110,40 +130,40 @@ void UinputKeyboard::tap(unsigned short code) {
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) {
if (fd_ < 0) {
return;
}
for (const gkeymapper::ShortcutStep& step : steps) {
std::vector<unsigned short> modifiers;
for (unsigned bit : {gkeymapper::kModCtrl, gkeymapper::kModAlt,
gkeymapper::kModShift, gkeymapper::kModSuper,
gkeymapper::kModAltGr}) {
if ((step.modifiers & bit) != 0) {
unsigned short code = modifier_code(bit);
if (code != 0) {
modifiers.push_back(code);
}
}
const std::vector<unsigned short> codes = step_key_codes(step);
if (codes.empty()) {
continue;
}
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();
press(codes);
usleep(2000);
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();
release(codes);
usleep(2000);
}
}

View File

@@ -8,6 +8,15 @@
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
// shortcuts; the daemon never reads from it.
class UinputKeyboard {
@@ -30,6 +39,12 @@ public:
// passes them on unchanged instead of turning them into a mapping.
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:
void emit(unsigned short type, unsigned short code, int value);
void sync();

View File

@@ -1,8 +1,9 @@
# gKeyMapper daemon — implementation plan
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.
Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR)
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
@@ -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,
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).
- **`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
@@ -63,6 +76,10 @@ Goals
Non-goals (v1)
- 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.
## 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.
- Emit events by `write()`-ing `struct input_event` (from
`<linux/uinput.h>` / `<linux/input-event-codes.h>` — no libevdev needed).
- `key` mapping = shortcut grammar `ctrl+c,ctrl+v`:
- single combo: press modifiers+key in order, remember the keys, release them
when the keyboard's `release` event arrives (mirror the reference's
`press_release_fifo` stack).
- comma list: `emit_combo()` each group (press+release).
- `key` mapping = shortcut grammar `ctrl+c,ctrl+v`: every step is pressed and
released (a tap of ~2 ms), so a comma list replays the chords in order. (The
original plan held a single combo down until the release event arrived; that
became its own type, `keyhold`, so a plain `key` still fires once.)
- `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
`/dev/null`, `execve("/bin/sh", "-c", cmd)`. Reap via `SIGCHLD`.
- `disabled`: no-op.
@@ -220,7 +242,7 @@ artifact so the runtime does zero parsing.
### 4.6 Config
- 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
per G-key (enum + fixed target buffer / `std::string`), so no parsing in the
hot path.
@@ -333,12 +355,19 @@ Start-up orders:
without widening the fixed-width rows.
- The key-name vocabulary matches because the capture dialog already writes
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
```
src/ # GUI (existing)
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)
ipc_dbus.{h,cpp} # GDBus service (control plane)
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
— 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:
answers are kept under *Resolved* below. What is 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.
- **`typeout` (open, low priority).** The reference project types out a fixed
text; our daemon has no such action. It would be a pure addition to the action
layer with no decoding involved: a token in the `mappings.conf` format, a fourth
Type entry in the GUI, the four `.po` files, and a typing path that maps the
text to key codes through the active layout (xkbcommon is already linked).
Nothing decided yet.
### 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
@@ -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
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").
Remapping them instead of forwarding them is decided against (see *Decided*
above).
- **`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

View File

@@ -59,6 +59,9 @@ msgstr "Aufnehmen"
msgid "e.g. ctrl+c, ctrl+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
msgid "Could not save %s.\n"
msgstr "Konnte %s nicht speichern.\n"
@@ -124,6 +127,9 @@ msgstr "Befehl"
msgid "Disabled"
msgstr "Deaktiviert"
msgid "Key (hold)"
msgstr "Taste (halten)"
msgid "Save mappings"
msgstr "Mappings speichern"

View File

@@ -59,6 +59,9 @@ msgstr "Capture"
msgid "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
msgid "Could not save %s.\n"
msgstr "Could not save %s.\n"
@@ -124,6 +127,9 @@ msgstr "Command"
msgid "Disabled"
msgstr "Disabled"
msgid "Key (hold)"
msgstr "Key (hold)"
msgid "Save mappings"
msgstr "Save mappings"

View File

@@ -57,6 +57,9 @@ msgstr "Capturar"
msgid "e.g. 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
msgid "Could not save %s.\n"
msgstr "No se pudo guardar %s.\n"
@@ -122,6 +125,9 @@ msgstr "Comando"
msgid "Disabled"
msgstr "Desactivado"
msgid "Key (hold)"
msgstr "Tecla (mantener)"
msgid "Save mappings"
msgstr "Guardar asignaciones"

View File

@@ -57,6 +57,9 @@ msgstr "Capturer"
msgid "e.g. 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
msgid "Could not save %s.\n"
msgstr "Impossible d'enregistrer %s.\n"
@@ -122,6 +125,9 @@ msgstr "Commande"
msgid "Disabled"
msgstr "Désactivé"
msgid "Key (hold)"
msgstr "Touche (maintenue)"
msgid "Save mappings"
msgstr "Enregistrer les attributions"

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
// G1=key:ctrl+c,ctrl+v
// G2=command:/usr/bin/firefox
// G3=disabled:
// G4=keyhold:ctrl+w (held down while G4 is held down)
#ifndef GKEYMAPPER_MAPPING_H
#define GKEYMAPPER_MAPPING_H
@@ -16,7 +17,10 @@ namespace gkeymapper {
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 {
ActionType type = ActionType::Disabled;

View File

@@ -15,6 +15,7 @@
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
#include "singleinstance.h"
#ifndef PACKAGE
#define PACKAGE "gkeymapper"
@@ -129,7 +130,9 @@ void on_capture_clicked(GtkButton* btn, gpointer userData) {
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);
}
}
@@ -144,6 +147,10 @@ static void update_row_state(KeyRow* row) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Choose file..."));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("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 {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
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) {
const char* env = g_getenv("GKEYMAPPER_ASSETS_DIR");
if (env && *env) {
@@ -395,7 +408,8 @@ void build_ui(GtkApplication* app) {
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);
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*/) {
// 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);
g_signal_connect(exportAction, "activate", G_CALLBACK(action_export), app);
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_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);
}
} // namespace
@@ -511,6 +538,12 @@ int main(int argc, char** argv) {
bind_textdomain_codeset(PACKAGE, "UTF-8");
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,
G_APPLICATION_FLAGS_NONE);
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))) {
case 0: mapping.type = ActionType::Key; break;
case 1: mapping.type = ActionType::Command; break;
case 3: mapping.type = ActionType::KeyHold; break;
default: mapping.type = ActionType::Disabled; break;
}
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) {
case ActionType::Key: idx = 0; break;
case ActionType::Command: idx = 1; break;
case ActionType::KeyHold: idx = 3; break;
default: idx = 2; break;
}
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