Embedd assets icons
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# gKeyMapper daemon — implementation plan
|
||||
|
||||
Status: draft / proposal
|
||||
Status: implemented (M0–M4 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.
|
||||
|
||||
@@ -28,8 +28,16 @@ and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
|
||||
`z` is now correctly resolved to evdev 21.
|
||||
- **AltGr (added after M2)** — level-3 combinations are representable as
|
||||
`altgr+7`; the base (level 0) key is stored plus the `altgr` modifier.
|
||||
- **Next: M4 (D-Bus GUI channel)** — status, explicit `Reload`, `TestMapping`
|
||||
and signals. The reload side of it is already covered by the M3 file watch.
|
||||
- **M4 done** — D-Bus control channel: `daemon/ipc_dbus` registers
|
||||
`dev.rager.gkeymapper.Daemon` on the session bus and serves `Reload`,
|
||||
`GetStatus` and `TestMapping`, plus the `KeyPressed`, `ConfigReloaded` and
|
||||
`DeviceChanged` signals. `src/daemonclient.{h,cpp}` is the GUI side: the footer
|
||||
shows `Daemon: running/not reachable`, the device and the last G key that
|
||||
fired, and `Save mappings` calls `Reload()` (the file watch stays the
|
||||
backstop). `make install` ships a D-Bus activation file, so the GUI can start
|
||||
the daemon on demand; the unit is `Type=dbus` with `BusName=`. A second
|
||||
instance refuses to start instead of fighting over the keyboard. Verified with
|
||||
`gdbus` (methods, signals, activation) and over AT-SPI (live footer).
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
@@ -188,9 +196,15 @@ artifact so the runtime does zero parsing.
|
||||
- `uinput` kernel module must be present; ship `/etc/modules-load.d/` fragment
|
||||
or document `modprobe uinput` (here it is built in).
|
||||
- **systemd --user** unit `gkeymapper-daemon.service`:
|
||||
`Type=dbus`, `BusName=…`, `ExecStart=gkeymapper-daemon`,
|
||||
`Restart=on-failure`, `Documentation=…`. `BusName` enables D-Bus activation,
|
||||
so starting the GUI can start the daemon on demand.
|
||||
`Type=dbus`, `BusName=dev.rager.gkeymapper.Daemon`,
|
||||
`ExecStart=gkeymapper-daemon`, `Restart=on-failure`, `ExecReload=SIGHUP`.
|
||||
`Type=dbus` + `BusName` make the unit count as started only once the name is
|
||||
owned — the state D-Bus activation and the GUI see. `make install` also ships
|
||||
the D-Bus activation file
|
||||
(`share/dbus-1/services/dev.rager.gkeymapper.Daemon.service`, with
|
||||
`SystemdService=gkeymapper-daemon.service`), so the bus starts the unit when
|
||||
the GUI asks for the daemon's status and the daemon runs exactly once either
|
||||
way.
|
||||
- Do **not** require root. Commands run with the user's privileges (the
|
||||
reference's "executed as root" note is a wiki artifact, not a design goal).
|
||||
|
||||
@@ -208,34 +222,44 @@ Three layers, from loosest to tightest:
|
||||
implicitly by calling a D-Bus method (activation) and can show whether it is
|
||||
running.
|
||||
|
||||
### 5.1 Proposed D-Bus interface
|
||||
### 5.1 D-Bus interface (implemented)
|
||||
|
||||
- Bus name: `<app-id>` — decide once (§11). Suggested: `dev.rager.gkeymapper`
|
||||
(replace the current placeholder `com.example.gkeymapper`).
|
||||
- App id: `dev.rager.gkeymapper` (replaces the placeholder `com.example.gkeymapper`);
|
||||
it is also the GApplication id.
|
||||
- Bus name: `dev.rager.gkeymapper.Daemon` — **not** the plain app id.
|
||||
`GtkApplication` owns the app id itself, in order to detect a second GUI
|
||||
instance; a daemon on the same name would make every GUI start look like a
|
||||
duplicate and exit. (The original plan had `<app-id>` here; that clash was
|
||||
found while implementing M4.)
|
||||
- Object path: `/dev/rager/gkeymapper/Daemon`
|
||||
- Interface: `dev.rager.gkeymapper.Daemon`
|
||||
|
||||
Methods
|
||||
| Method | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). |
|
||||
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. |
|
||||
| `TestMapping` | `(u index) -> ()` | Fire mapping for G1..G9 without hardware (GUI "Test" button). |
|
||||
| `SetProfile` | `(s profile) -> ()` | Reserved for M-key profiles (later). |
|
||||
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). Result via the `ConfigReloaded` signal. |
|
||||
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. `device` is `""` before the keyboard is open, so the GUI can omit that part. |
|
||||
| `TestMapping` | `(u index) -> ()` | Fire the mapping of one G key without hardware; same code path as a real press. `index` is **0-based** (`0` = G1 … `8` = G9). Fails with a D-Bus error for an out-of-range index or a `disabled:`/empty row. |
|
||||
|
||||
Signals
|
||||
| Signal | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `KeyPressed` | `(u index, s action)` | A G-key fired (for GUI highlight/history). |
|
||||
| `KeyPressed` | `(u index, s action)` | A G-key fired (GUI highlight/history; also emitted by `TestMapping`). |
|
||||
| `ConfigReloaded` | `(b ok)` | Daemon finished reloading. |
|
||||
| `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. |
|
||||
|
||||
Notes
|
||||
- All are control-plane calls; none is on the keypress path.
|
||||
- All are control-plane calls; none is on the keypress path. `DaemonService::pump()`
|
||||
dispatches once per daemon loop iteration, so a reply can take up to one read
|
||||
timeout (≤500 ms) — fine for status/reload, and the GUI calls asynchronously.
|
||||
- `Reload` is best-effort: inotify is the backstop if the call is lost.
|
||||
- If D-Bus is unavailable, the daemon still works; the GUI degrades to
|
||||
"no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for
|
||||
scripted use, matching the reference.
|
||||
- `SetProfile` was dropped: it is reserved for the M-key profiles and would have
|
||||
been a no-op today.
|
||||
- The daemon requests its name with `DO_NOT_QUEUE` and exits (code 0, with a
|
||||
message) when another instance owns it, before it touches the device.
|
||||
|
||||
### 5.2 Interaction sequences
|
||||
|
||||
@@ -257,34 +281,45 @@ Start-up orders:
|
||||
- Daemon first, GUI later: GUI reads status via D-Bus; no config write needed.
|
||||
- GUI first, daemon later: GUI writes config; on daemon start it reads the file.
|
||||
|
||||
### 5.3 GUI-side changes (small)
|
||||
- On "Save mappings": keep writing the file, then call `Reload()` (ignore error).
|
||||
- Add a footer status line: daemon running / device present / last reload, fed by
|
||||
`GetStatus` + the `DeviceChanged`/`ConfigReloaded` signals.
|
||||
- Optional "Test" button per row → `TestMapping(index)`.
|
||||
- The key-name vocabulary must match: the capture dialog already writes
|
||||
lowercase GDK key names; move the canonical name list into the shared module
|
||||
(§6) so GUI and daemon agree on the same spellings.
|
||||
### 5.3 GUI-side changes (as implemented)
|
||||
- On "Save mappings": writes the file, then calls `Reload()` best-effort; the
|
||||
file watch is the backstop, so a lost call is harmless (a save therefore
|
||||
triggers two reloads, both idempotent).
|
||||
- Footer status line: `Daemon: running/not reachable` + `Device: …` + the last G
|
||||
key that fired, fed by `GetStatus`, the `DeviceChanged`/`ConfigReloaded`
|
||||
signals and `NameOwnerChanged` (so it follows the daemon starting/stopping).
|
||||
The `Device:` part is omitted while the device is not open yet.
|
||||
- No "Test" button per row: pressing the real key is the better test while the
|
||||
hardware is at hand, and `TestMapping` stays available over `gdbus`/`busctl`
|
||||
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).
|
||||
|
||||
## 6. Code layout
|
||||
|
||||
```
|
||||
src/ # GUI (existing)
|
||||
daemon/ # as implemented (see the list in §6 above)
|
||||
daemonclient.{h,cpp} # D-Bus client (status/reload/signals)
|
||||
daemon/ # as implemented (see §6 above)
|
||||
ipc_dbus.{h,cpp} # GDBus service (control plane)
|
||||
shared/
|
||||
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
|
||||
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
|
||||
keymap.{h,cpp} # layout-aware name <-> input code (xkbcommon)
|
||||
ipc_ids.h # app id, version, D-Bus names
|
||||
data/
|
||||
60-gkeymapper.rules # udev: uaccess for the device and /dev/uinput
|
||||
gkeymapper-uinput.conf # modules-load.d fragment (uinput)
|
||||
gkeymapper-daemon.service # systemd user unit (@BINDIR@ filled in on install)
|
||||
dev.rager.gkeymapper.Daemon.service # D-Bus activation for the same unit
|
||||
icons.gresource.xml # GUI icons compiled into the binary (GResource)
|
||||
po/ # shared catalogs (GUI strings)
|
||||
```
|
||||
|
||||
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
|
||||
for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
`libusb-1.0-0-dev` (GLib/GIO is already required by the GUI).
|
||||
for the unit, udev rule, modules-load fragment and activation file. New build
|
||||
dependency: `libusb-1.0-0-dev` plus `libglib2.0-dev` (gio-2.0) for the daemon's
|
||||
D-Bus channel.
|
||||
|
||||
## 7. Resource-efficiency measures (summary)
|
||||
|
||||
@@ -300,6 +335,10 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
- Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events,
|
||||
RSS in the low single-digit MB.
|
||||
|
||||
Measured after M4 (systemd user unit, daemon idle): ~780 KB RSS, 14 ms CPU
|
||||
since start. The D-Bus control channel adds no wakeups of its own: `pump()`
|
||||
only dispatches what the loop's read timeout already woke up for.
|
||||
|
||||
## 8. Milestones
|
||||
|
||||
- **M0 – Shared core.** `shared/mapping` + `shared/keys`; refactor the GUI to use
|
||||
@@ -311,8 +350,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
- **M3 – Config & lifecycle (done).** config reload via inotify (`config_watch`,
|
||||
directory watch + atomic save) and `SIGHUP`/`SIGUSR1`; systemd --user unit +
|
||||
udev rule + modules-load, installed by `make install`.
|
||||
- **M4 – IPC.** GDBus service (`Reload`, `GetStatus`, `TestMapping`, signals);
|
||||
GUI status line + Reload-on-save + optional Test button.
|
||||
- **M4 – IPC (done).** GDBus service (`Reload`, `GetStatus`, `TestMapping`,
|
||||
signals); GUI status line + Reload-on-save; D-Bus activation file; unit
|
||||
`Type=dbus` + `BusName`; single-instance guard.
|
||||
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
|
||||
(hot-plug, debounce, error reporting to the GUI).
|
||||
|
||||
@@ -323,6 +363,10 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
- Device: replay captured reports into the decoder (no hardware needed).
|
||||
- Integration: `evtest` / `libinput debug-events` to observe the virtual device;
|
||||
compare `xev` output for a configured shortcut between reference and daemon.
|
||||
- D-Bus (M4): `gdbus call` for `GetStatus`/`Reload`/`TestMapping`,
|
||||
`gdbus monitor` for the signals, and a `TestMapping` observed with
|
||||
`xinput test <keyboard-id>` to confirm the emitted keycodes. The GUI's footer
|
||||
line is read back over AT-SPI to check the live status.
|
||||
- Permission tests: verify the udev rule grants access as a normal user.
|
||||
- Resource check: `powertop`/`perf` or simple wakeup counting while idle.
|
||||
|
||||
@@ -343,6 +387,12 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
in M0 is mandatory before the daemon consumes the file.
|
||||
- **uinput device identity**: a second keyboard appears; choose stable
|
||||
name/vendor/product so the desktop does not confuse it with the real one.
|
||||
- **GUI does not load the config at startup** (found while testing M4, GUI-side,
|
||||
not a daemon issue): `build_ui()` fills the rows empty and only
|
||||
`Import configuration...` reads the file, so pressing `Save mappings` in a
|
||||
freshly opened window overwrites the user's mappings with empty ones. Loading
|
||||
`~/.config/gKeyMapper/mappings.conf` on start (or refusing to save unchanged
|
||||
rows) should be the next GUI fix.
|
||||
|
||||
## 11. Decisions to make
|
||||
|
||||
@@ -356,6 +406,24 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
|
||||
### Resolved
|
||||
|
||||
- **Bus name / app id (done in M4).** App id (and GApplication id)
|
||||
`dev.rager.gkeymapper`; the daemon uses `dev.rager.gkeymapper.Daemon` because
|
||||
GtkApplication owns the plain app id to detect a second GUI instance. The
|
||||
installed `.desktop` file keeps its name `gkeymapper.desktop` — both old and
|
||||
new app id yield the X11 `WM_CLASS` `gkeymapper`, which is what
|
||||
`StartupWMClass` matches (verified with `xprop`).
|
||||
- **IPC (done in M4).** D-Bus with GDBus: libdbus and sd-bus dev files are not
|
||||
installed on this machine, GIO is; the daemon links `gio-2.0`.
|
||||
- **IPC event integration (done in M4).** The daemon keeps its simple blocking
|
||||
loop instead of moving to a `GMainLoop`: `DaemonService::pump()` calls
|
||||
`g_main_context_iteration(ctx, FALSE)` once per iteration, so D-Bus is serviced
|
||||
without a thread and without blocking, at the cost of up to one read timeout of
|
||||
latency on the control plane.
|
||||
- **Status line widget (evaluated).** The daemon status stays a plain
|
||||
`GtkLabel` in the window footer. `GtkStatusbar` does exist in GTK4 and is
|
||||
themed, but it keeps a message stack (pop/push per context id) and GTK4 exposes
|
||||
it over AT-SPI as a generic `filler`, not as a "statusbar" role — so it was
|
||||
built, compared side by side and dropped.
|
||||
- **Event loop (as implemented, deviates from §4.2).** The daemon is
|
||||
single-threaded with a blocking `libusb_interrupt_transfer` and a 500 ms
|
||||
timeout; the config watch is drained once per iteration. That is ~2 idle
|
||||
@@ -368,8 +436,9 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
from `/etc/default/keyboard`, via xkbcommon) and only falls back to the US
|
||||
positions from `shared/keys`. This closes the vocabulary-drift risk for non-US
|
||||
layouts — a German `z` is evdev 21, not the US position 44.
|
||||
- **Reload without restart (done in M2).** `SIGHUP` re-reads the config file
|
||||
(the full D-Bus `Reload` comes with M4).
|
||||
- **Reload without restart (done in M2, completed in M4).** `SIGHUP`/`SIGUSR1`
|
||||
re-read the config file; since M4 the GUI's `Save mappings` also calls the
|
||||
D-Bus `Reload`, and the inotify watch applies anything else.
|
||||
- **Config watching (done in M3).** The daemon watches the config **directory**
|
||||
with inotify rather than the file, so an atomic save (temp file + `rename`) and
|
||||
a recreated file are caught too. The loop iteration (≤ 500 ms) is the
|
||||
@@ -377,9 +446,14 @@ for the unit, udev rule and modules-load fragment. New build dependency:
|
||||
stay as the fallback and there is no extra fd in the loop. `Save mappings` in
|
||||
the GUI now writes atomically, so the daemon can never read a partial file. A
|
||||
failed reload keeps the previous mappings instead of disabling every key.
|
||||
- **Lifecycle (done in M3).** A systemd **user** unit (not a system service): the
|
||||
daemon needs the session for `/dev/uinput` and for the `DISPLAY` a `command`
|
||||
action inherits. It is `Type=simple` for now; it becomes `Type=dbus` with a
|
||||
`BusName` once M4 adds the D-Bus service. The udev rule and the
|
||||
- **Lifecycle (done in M3, extended in M4).** A systemd **user** unit (not a
|
||||
system service): the daemon needs the session for `/dev/uinput` and for the
|
||||
`DISPLAY` a `command` action inherits. Since M4 it is `Type=dbus` with
|
||||
`BusName=dev.rager.gkeymapper.Daemon`, and a D-Bus activation file lets the bus
|
||||
start it on demand, so enabling the unit is optional. The udev rule and the
|
||||
`modules-load.d` fragment live in `data/` and are installed by `make install`
|
||||
(under `PREFIX/lib/...`, which udev and systemd both search).
|
||||
- **Single instance (done in M4).** The daemon requests its bus name with
|
||||
`DO_NOT_QUEUE` and exits with a message when it is already taken — two
|
||||
instances would detach the kernel driver from each other and create two virtual
|
||||
keyboards. The check happens before the device is opened.
|
||||
|
||||
Reference in New Issue
Block a user