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

@@ -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