diff --git a/AGENTS.md b/AGENTS.md index 725ab3a..9b90682 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` `:`, 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 ` 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 `), 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: ` → 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 diff --git a/README.md b/README.md index e6f2d86..b9ab2f5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/daemon/action.cpp b/daemon/action.cpp index 79ffa8a..dd06995 100644 --- a/daemon/action.cpp +++ b/daemon/action.cpp @@ -2,6 +2,7 @@ #include "action.h" +#include #include #include @@ -19,17 +20,28 @@ bool ActionRunner::init() { return true; } +bool ActionRunner::resolve_shortcut(const std::string& target, + std::vector& 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 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(held_.size())) { + return false; + } + end_hold(index); // a repeated press must not stack codes + + std::vector 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 codes = step_key_codes(steps.front()); + if (codes.empty()) { + return false; + } + held_[static_cast(index)] = codes; + keyboard_.press(codes); + return true; +} + +void ActionRunner::end_hold(int index) { + if (index < 0 || index >= static_cast(held_.size())) { + return; + } + std::vector& codes = held_[static_cast(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 up; + for (unsigned short code : codes) { + bool still_held = false; + for (size_t other = 0; other < held_.size(); ++other) { + if (other == static_cast(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(i)); + } +} + void ActionRunner::tap_key(unsigned short code) { keyboard_.tap(code); } diff --git a/daemon/action.h b/daemon/action.h index e7e7b82..df52434 100644 --- a/daemon/action.h +++ b/daemon/action.h @@ -2,7 +2,9 @@ #ifndef GKEYMAPPER_DAEMON_ACTION_H #define GKEYMAPPER_DAEMON_ACTION_H +#include #include +#include #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& steps); + UinputKeyboard keyboard_; gkeymapper::Keymap keymap_; + // Codes each G key (0 = G1) currently keeps down, empty when not holding. + std::array, gkeymapper::kNumKeys> held_{}; }; } // namespace gkeymapper::daemon diff --git a/daemon/main.cpp b/daemon/main.cpp index ec624eb..5f0e49f 100644 --- a/daemon/main.cpp +++ b/daemon/main.cpp @@ -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 #include #include #include @@ -81,8 +82,10 @@ int main(int argc, char** argv) { // Runs the mapping of G (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(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(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(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(i), false); + run_mapping(static_cast(i), false, true); service.notify_key_pressed(static_cast(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(); diff --git a/daemon/uinput_keyboard.cpp b/daemon/uinput_keyboard.cpp index 1ff6477..9e0eb49 100644 --- a/daemon/uinput_keyboard.cpp +++ b/daemon/uinput_keyboard.cpp @@ -2,6 +2,7 @@ #include "uinput_keyboard.h" +#include #include #include #include @@ -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 step_key_codes(const gkeymapper::ShortcutStep& step) { + std::vector 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(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& codes) { + if (fd_ < 0) { + return; + } + for (unsigned short code : codes) { + key_event(code, true); + } + sync(); +} + +void UinputKeyboard::release(const std::vector& 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& steps) { if (fd_ < 0) { return; } for (const gkeymapper::ShortcutStep& step : steps) { - std::vector 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 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(step.linux_key), true); - } - sync(); + press(codes); usleep(2000); - - if (step.linux_key >= 0) { - key_event(static_cast(step.linux_key), false); - } - for (auto it = modifiers.rbegin(); it != modifiers.rend(); ++it) { - key_event(*it, false); - } - sync(); + release(codes); usleep(2000); } } diff --git a/daemon/uinput_keyboard.h b/daemon/uinput_keyboard.h index cabd5f3..6ab2eb6 100644 --- a/daemon/uinput_keyboard.h +++ b/daemon/uinput_keyboard.h @@ -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 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& codes); + void release(const std::vector& codes); + private: void emit(unsigned short type, unsigned short code, int value); void sync(); diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md index 67b2179..c3b8a39 100644 --- a/docs/daemon-plan.md +++ b/docs/daemon-plan.md @@ -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=keyhold:` 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 `` / `` — 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=:` with `type ∈ {key, command, disabled}`. + `G=:` 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 diff --git a/po/de.po b/po/de.po index 40097bd..8ab4587 100644 --- a/po/de.po +++ b/po/de.po @@ -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" diff --git a/po/en.po b/po/en.po index 31b679f..23b890b 100644 --- a/po/en.po +++ b/po/en.po @@ -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" diff --git a/po/es.po b/po/es.po index 06f400a..79e0671 100644 --- a/po/es.po +++ b/po/es.po @@ -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" diff --git a/po/fr.po b/po/fr.po index dad672f..e1bfcb6 100644 --- a/po/fr.po +++ b/po/fr.po @@ -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" diff --git a/po/gkeymapper.pot b/po/gkeymapper.pot index 9f35cb4..8be6765 100644 --- a/po/gkeymapper.pot +++ b/po/gkeymapper.pot @@ -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 "" diff --git a/shared/mapping.cpp b/shared/mapping.cpp index 4ee350e..f066d8b 100644 --- a/shared/mapping.cpp +++ b/shared/mapping.cpp @@ -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; } diff --git a/shared/mapping.h b/shared/mapping.h index a34860d..c305534 100644 --- a/shared/mapping.h +++ b/shared/mapping.h @@ -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; diff --git a/src/main.cpp b/src/main.cpp index fd15658..782ed6c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); diff --git a/src/mappings.cpp b/src/mappings.cpp index 6af999e..fd87309 100644 --- a/src/mappings.cpp +++ b/src/mappings.cpp @@ -20,6 +20,7 @@ bool write_mappings(const std::string& path, const std::vector& 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& 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); diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp new file mode 100644 index 0000000..16dbc56 --- /dev/null +++ b/src/singleinstance.cpp @@ -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 + +#include +#include +#include + +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(¶meters, 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", ¶meters, &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); +} + +// 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(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(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 diff --git a/src/singleinstance.h b/src/singleinstance.h new file mode 100644 index 0000000..f7d5289 --- /dev/null +++ b/src/singleinstance.h @@ -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 + +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