Skip to content

Latest commit

 

History

History
321 lines (249 loc) · 14.5 KB

File metadata and controls

321 lines (249 loc) · 14.5 KB

CLAUDE.md

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.

What kjump is

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.

Architecture

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.json and calls Reload(). 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.

Files at a glance

  • kjumpd.py — daemon, ~500 lines. Single class Daemon, plus a KjumpService(dbus.service.Object) exposing the public D-Bus API (Reload, Trigger, Quit, plus internal callbacks windowRaised / windowNotFound from the KWin one-shot scripts).
  • kjump.py — CLI, ~100 lines. Thin dbus-python client that calls the daemon's Trigger/List methods. Installed as kjump. This is the "run-or-raise via command" path for scripting/automation — no key binding required. Resolution (id-or-name) lives in the daemon's Daemon.resolve, so the CLI stays dumb and there's one source of truth. Trigger returns 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. Uses QProcess for blocking external calls (KWin's queryWindowInfo) so the event loop keeps moving.
  • kjump.desktop.in, kjump-config.desktop.in.desktop templates. The Makefile's sed substitutes @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.)

kglobalaccel gotchas (the painful learnings)

These are what would burn time to rediscover from scratch.

Hyphens in component names get rewritten to underscores

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.

_k_friendly_name is set on first doRegister and not updated

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 looks great until you check the disk

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.

setShortcut with NoAutoloading won't always override

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.

kga.shortcut(actionId) returns [0] for "no binding", not []

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].

Two signals for shortcut changes, different shapes

  • yourShortcutGotChanged(actionId, ai) fires for owner-driven setShortcut calls.
  • yourShortcutsChanged(actionId, a(ai)) fires for setForeignShortcut (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].

Echo guard: update in-memory state BEFORE setShortcut

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.

Component path doesn't exist until first register

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.

Component icon comes from a .desktop lookup

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.

KWin scripting gotchas

loadScript is overloaded; dbus-python picks the wrong one

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.

One-shot KWin scripts call back via callDBus

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.

Tempfile cleanup is delayed

KWin keeps reading the script file briefly after start(), so we schedule unloadScript + os.unlink on a 2-second GLib.timeout_add.

Process / persistence gotchas

dbus.service.BusName releases on garbage collection

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 = ...).

Exit 0 on BusName conflict, not 1

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.

systemctl restart after --install

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.

GUI gotchas

Don't subprocess.run a blocking command from a Qt slot

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.

QKeySequenceEdit and Qt 6's QKeyCombination

QKeySequence(int) round-trips with seq[0].toCombined() in Qt 6 (returns QKeyCombination, not int). The combined int matches the encoding kglobalaccel expects.

The Qt 6 qdbus binary has a different name per distro

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.

Testing

Unit tests (pure logic)

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 and a(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.

What's NOT covered (deliberately)

The integration — does a keypress actually raise the window, does kga persist to kglobalshortcutsrc — needs a live Plasma session. Manual dance for that:

  1. make install
  2. qdbus-qt6 org.kjump.Daemon /Daemon org.kjump.Daemon.Trigger firefox
  3. Watch journalctl --user -u kjumpd -f.
  4. 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.

Conventions / style

  • 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 a CHANGELOG.md entry (Keep a Changelog format), and tag vX.Y.Z. Stay on 0.x until the apps.json schema / 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 QDBUS binary (qdbus-qt6/qdbus6/qdbus) as a subprocess in the GUI for one-shots, QProcess in the GUI for blocking calls.
  • Single Qt main thread for the GUI. No threading.

Roadmap (informal)

In order of how much they'd improve daily life:

  1. "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.
  2. Pre-save conflict warnings — today _apply_binding silently refuses a combo already owned by another component (log only, the binding just doesn't fire). Surface that in the GUI before save by querying kga.action(key) — e.g. warn when the user picks Meta+G while kwin owns it for Grid View.
  3. Add a raw executable by path — entries are currently seeded from .desktop apps (that's where the resourceClasses come from). Supporting a bare command with a hand-entered window class would cover apps with no .desktop entry. Contributions welcome.
  4. RPM/COPR — if there's interest after public release.
  5. 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.
  6. GUI close-confirm wins — on a dirty close, save fails on conflict, the modal goes one round trip too many. Could be cleaner.

When picking the project up again

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.