Notes for future Claude sessions on this project. Concentrates on the why behind decisions and the kglobalaccel/KWin gotchas we hit. The README is the user-facing doc; this is the implementation doc.
A run-or-raise daemon + GUI for KDE Plasma 6. User presses a global
shortcut → if a matching window is open, raise it; if not, launch the
configured command. Like jumpapp but Wayland-native and managed
centrally rather than per-shortcut in System Settings.
The closest existing tool is academo/ww-run-raise, and it's closer
than "closest" suggests: it's KDE-exclusive (Plasma 5 and 6), works on
both Wayland and X11, and raises windows with the same mechanism we
use — an on-demand KWin script loaded/run/unloaded in one go. It is a
genuine predecessor on the exact platform, not an older/weaker tool.
What it does NOT do is manage the shortcuts for you: with ww you
hand-wire a KDE custom shortcut per command in System Settings, and
there's no GUI. So our differentiators are narrow but real — the GUI,
plus kjump owning its own kglobalaccel component (one section in System
Settings, centrally managed, persistent across daemon restarts). We are
NOT "a GUI for ww": ww only replaces the compact raise-or-launch KWin
script (the core loop); the daemon + kglobalaccel ownership +
JSON↔kga sync — where all the gotchas below live — is the layer ww
deliberately leaves to the user, and it's the bulk of this project.
Wrapping ww would outsource the easy part, keep all the hard parts,
and add a runtime dependency plus a foreign (pgrep-based) matching model
that doesn't fit our resourceClass config. Considered and rejected.
Three layers and which one owns what:
| Layer | Owns |
|---|---|
apps.json |
Which apps exist (id, name, command, resourceClasses, icon hint) |
| kglobalaccel | The live binding (key combo) for each registered action |
apps.json's shortcut field |
A mirror of kglobalaccel, kept in sync by the daemon |
So:
- The GUI edits
apps.jsonand callsReload(). The daemon pushes changes to kga. - System Settings → Shortcuts edits kga directly. The daemon listens
and writes the new value back to
apps.json. - On daemon startup, if kga has a binding that differs from JSON for an existing action, kga wins (preserves user edits made while daemon was off).
This dual-source-of-truth was the conclusion after one round of
"JSON is authoritative" → realising it broke the System-Settings-edit
path → upgrading to bidirectional sync. See commit ba7b2d6.
kjumpd.py— daemon, ~500 lines. Single classDaemon, plus aKjumpService(dbus.service.Object)exposing the public D-Bus API (Reload, Trigger, Quit, plus internal callbackswindowRaised/windowNotFoundfrom the KWin one-shot scripts).kjump.py— CLI, ~100 lines. Thin dbus-python client that calls the daemon'sTrigger/Listmethods. Installed askjump. This is the "run-or-raise via command" path for scripting/automation — no key binding required. Resolution (id-or-name) lives in the daemon'sDaemon.resolve, so the CLI stays dumb and there's one source of truth.Triggerreturns the resolved id (""on no match) so the CLI can set a meaningful exit status.kjump-config.py— PySide6 GUI, ~600 lines.MainWindow,EditDialog,DesktopPicker. No threading; everything runs on the Qt main thread. UsesQProcessfor blocking external calls (KWin'squeryWindowInfo) so the event loop keeps moving.kjump.desktop.in,kjump-config.desktop.in—.desktoptemplates. The Makefile'ssedsubstitutes@BIN@.Makefile— per-user install. Defaults to~/.local.
(An early diagnostic proof-of-concept validated the kglobalaccel + KWin loadScript loop end-to-end during development; it isn't part of the released tree.)
These are what would burn time to rediscover from scratch.
KDE rewrites - to _ in D-Bus object paths. So if you register a
component called kjump-spike, the path becomes
/component/kjump_spike, and System Settings shows the latter as the
section name. Choose a hyphenless component id. We picked kjump.
If you change COMPONENT_DESC later, the disk file's
_k_friendly_name keeps the old value. To force-update, you have to
unRegister all the actions in the component (which clears the section)
and re-register. The daemon's --uninstall does this, then a fresh
start populates the new name.
setForeignShortcut(actionId, keys) is what System Settings calls.
No flags, no ownership check, just sets the binding. We tried it.
It only updates kglobalaccel's in-memory state — it does NOT persist
to ~/.config/kglobalshortcutsrc. KWin reads its key-grab list from
that file, so shortcuts silently stop firing on actual key press
(though Trigger over D-Bus still works because that path bypasses
the key grab). Lesson: verify kglobalshortcutsrc on disk after any
binding change. See commit 0e361f8.
We use owner-driven setShortcut(NoAutoloading=0x2) instead. That
writes the section + per-action lines properly.
For initial bind (no existing value), it works. For changing an
already-bound value, kga sometimes returns the old value, indicating
rejection. Fallback is unRegister + doRegister + setShortcut, which
puts us back in the "first bind" path. But the unRegister also
wipes the action entirely — destructive. Before doing the wipe, check
whether the requested key is already grabbed by some other component
via kga.action(key); if it is, log a warning and bail without
touching state. Otherwise the wipe is safe.
The D-Bus return type is ai. For an unbound action, kga gives a
single-element array containing zero, which is truthy in Python. Always
strip zeros before comparing: [k for k in ret if int(k) != 0].
yourShortcutGotChanged(actionId, ai)fires for owner-drivensetShortcutcalls.yourShortcutsChanged(actionId, a(ai))fires forsetForeignShortcut(i.e. System Settings edits).
Subscribe to both. The plural one's payload is array of struct of
array of int — three levels of unwrapping needed:
[k for seq in seqs for sub in seq for k in sub].
When the daemon calls setShortcut, kga emits the change signal,
which our own listener receives. To avoid spuriously rewriting
apps.json on every Reload, update self.apps[id] BEFORE the
setShortcut call. The signal then arrives, the handler compares
to the in-memory value, finds them matching, no-op.
getComponent(name) raises NoSuchComponent on a brand-new component.
Subscribe to the signal at the deterministic path
(/component/<name>) directly — D-Bus match rules don't require the
target path to exist at subscribe time.
System Settings → Shortcuts looks up an icon for each section by
component id. The convention: a .desktop file whose filename
basename matches the component id, in any standard XDG applications
directory. We ship kjump.desktop (NoDisplay=true so it doesn't
clutter the launcher) so the section gets a proper icon.
loadScript(QString) and loadScript(QString, QString) both exist.
dbus-python's introspection picks the 1-arg version and rejects our
2-arg call. Workaround: create the proxy with introspect=False, pass
signature="ss" on the call.
Inside the JS, the daemon's D-Bus name and method are interpolated
from Python. The script tries to raise, then calls
callDBus(BUS, PATH, IFACE, "windowRaised" or "windowNotFound", actionId).
The daemon Popens the launch command on windowNotFound.
KWin keeps reading the script file briefly after start(), so we
schedule unloadScript + os.unlink on a 2-second GLib.timeout_add.
If you don't keep a reference to the BusName object, dbus-python
releases the name as soon as the local goes out of scope. Daemon
exited owning nothing. Symptom: qdbus-qt6 org.kjump.Daemon returns
"Service does not exist" right after a "successful" launch. Always
hold the reference (we attach it to the daemon: daemon._busname = ...).
Otherwise systemd's Restart=on-failure loops indefinitely if a manual
./kjumpd.py is also running. "Another instance is already doing my
job" is not a failure.
make install calls kjumpd --install (writes the unit, enable --now).
But if a daemon was already running with an old ExecStart, systemd sees
the unit as active (Type=dbus + name owned) and won't relaunch. The
explicit systemctl --user restart kjumpd after --install cycles it
to pick up the new binary path.
KWin's queryWindowInfo blocks until the user clicks (or cancels).
subprocess.run blocks the main thread → Qt event loop frozen → dialog
unresponsive → if also hidden, no way to recover except kill -9. Use
QProcess instead; the event loop keeps running and we get the result
on the finished signal.
QKeySequence(int) round-trips with seq[0].toCombined() in Qt 6
(returns QKeyCombination, not int). The combined int matches the
encoding kglobalaccel expects.
The GUI shells out to the Qt 6 qdbus CLI (for Reload, the Ping
liveness check, and KWin's queryWindowInfo). Its name is not
portable: Fedora (qt6-qttools) and Debian (pkg qdbus-qt6) ship it as
qdbus-qt6, but Arch (qt6-tools) ships it as qdbus6, and some
minimal setups only have a generic qdbus. Hardcoding qdbus-qt6 meant
Save & Apply silently failed on Arch even with the package installed.
Fix: resolve once at import via shutil.which over
("qdbus-qt6", "qdbus6", "qdbus") into the module-level QDBUS, and use
that everywhere. make check-deps accepts any of the three. The daemon
is unaffected — it uses dbus-python directly, no qdbus.
tests/ holds stdlib-unittest tests for the pure functions — no D-Bus,
KDE, or session bus needed. Run with python3 -m unittest discover -s tests (or pytest tests/ if you have it). They cover the fiddly bits
that have bitten us or would silently regress:
resolve()— the id/name match precedence used by the CLI/Trigger.strip_zero_keys()/flatten_key_seqs()— the kga[0]-sentinel anda(ai)triple-unwrap gotchas, extracted from inline comprehensions specifically so they're testable.load_config()validation (missing fields, dup ids, bad JSON, defaults).resolve_qdbus(),parse_window_info(),_clean_exec()in the GUI.
The tests load the single-file scripts by path via importlib (the GUI's
filename has a hyphen, so it can't be a normal import). Importing is
side-effect-free — no Daemon.__init__, no QApplication.
The integration — does a keypress actually raise the window, does kga
persist to kglobalshortcutsrc — needs a live Plasma session. Manual
dance for that:
make installqdbus-qt6 org.kjump.Daemon /Daemon org.kjump.Daemon.Trigger firefox- Watch
journalctl --user -u kjumpd -f. python3 -c "import dbus; ..."ad-hoc to poke kga directly when diagnosing weird states.
Full integration scaffolding (ydotool-driven keypresses + a fixture
resetting kga state between runs) still isn't worth it — the unit tests
guard the logic, and the rest is genuinely a live-session concern.
- Single-file Python scripts (no package). Keep until there's a compelling reason.
- Versioning:
__version__is duplicated in all three scripts (no shared module, by the convention above). When cutting a release, bump all three in lockstep, add aCHANGELOG.mdentry (Keep a Changelog format), and tagvX.Y.Z. Stay on0.xuntil theapps.jsonschema / D-Bus API / CLI are stable enough to promise compatibility. - Comments explain why and document KDE-API quirks, not what the code is doing.
- Bare-bones logging via a
log()helper that prepends a timestamp. No structured logging — the journal is the log. - D-Bus calls use dbus-python in the daemon, the resolved
QDBUSbinary (qdbus-qt6/qdbus6/qdbus) as a subprocess in the GUI for one-shots,QProcessin the GUI for blocking calls. - Single Qt main thread for the GUI. No threading.
In order of how much they'd improve daily life:
- "Add another shortcut" / clone button — copy an entry into a second one with a new id and empty shortcut. The "two Firefoxes" pattern is real.
- Pre-save conflict warnings — today
_apply_bindingsilently refuses a combo already owned by another component (log only, the binding just doesn't fire). Surface that in the GUI before save by queryingkga.action(key)— e.g. warn when the user picks Meta+G while kwin owns it for Grid View. - Add a raw executable by path — entries are currently seeded from
.desktopapps (that's where the resourceClasses come from). Supporting a bare command with a hand-entered window class would cover apps with no.desktopentry. Contributions welcome. - RPM/COPR — if there's interest after public release.
- Flatpak — almost certainly won't work cleanly because the daemon needs unsandboxed kglobalaccel + KWin D-Bus access. Worth confirming so we have a definitive "no" to point users at.
- GUI close-confirm wins — on a dirty close, save fails on conflict, the modal goes one round trip too many. Could be cleaner.
Commit messages carry the design rationale for non-trivial changes. (The
public repo starts from the 0.1.0 release commit, so git log won't show
the pre-release iteration.) Then read this file — the accumulated
kglobalaccel/KWin gotchas above are the real map.