How to raise a window under Wayland or X11 when using KDE / KWin / Plasma

published Feb 29, 2024

The old methods to activate Linux desktop windows no longer work well, but new methods have been invented already.

How to raise a window under Wayland or X11 when using KDE / KWin / Plasma

Under the old X11 regime, a bit of xdotool windowactivate magic used to do the trick when it came to raised windows.

Not so in Wayland anymore.  Sadly, Wayland has no protocol that user-space applications can lean on to raise a window.

However, if your desktop session uses KWin (e.g. under a KDE Plasma session), this is certainly possible.  Not only is it possible, it will work equally well whether your session is running under X11 or Wayland!

The following code is a Python script you should place under /usr/local/bin/kwin_wmgmt_helper and make executable.  Ensure you have both Python and the pydbus library installed on your system as well.  How the program works, and how to use the program is explained below.

#!/usr/bin/python3

import hashlib
import os
import pydbus
import subprocess
import sys

from gi.repository import GLib


def generate_dump_script():
    return """
function kwindump() {
    var clients = workspace.clientList();
    for (var i=0; i<clients.length; i++) {
        var client = clients[i];
        console.warn("Client: " + client + "\\n    Title: " + client.caption + "\\n    Class: " + client.resourceClass);
    }
}
kwindump();
"""


def generate_script(window_title, window_class):
    window_title_escaped = (window_title or "").replace("'", "\\'")
    window_class_escaped = (window_class or "").replace("'", "\\'")

    return """
function kwinactivateclient(clientClass, clientCaption) {
    var clients = workspace.clientList();
    var compareToCaption = new RegExp(clientCaption || '', 'i');
    var compareToClass = clientClass;
    var isCompareToClass = clientClass.length > 0
    for (var i=0; i<clients.length; i++) {
        var client = clients[i];
        console.log(client);
        var classCompare = (isCompareToClass && client.resourceClass == compareToClass)
        var captionCompare = (!isCompareToClass && compareToCaption.exec(client.caption))
        if (classCompare || captionCompare) {
            if (workspace.activeClient != client) {
                workspace.activeClient = client;
            }
            callDBus(
                'com.rudd_o.WindowManagement', '/com/rudd_o/WindowManagement', 'com.rudd_o.WindowManagement', 'WindowFound'
            );
            return;
        }
    }
    callDBus(
        'com.rudd_o.WindowManagement', '/com/rudd_o/WindowManagement', 'com.rudd_o.WindowManagement', 'WindowNotFound'
    );
}
kwinactivateclient('%(window_class_escaped)s', '%(window_title_escaped)s');
""" % locals()


class WindowManagement(object):
    """
    <node>
        <interface name='com.rudd_o.WindowManagement'>
            <method name='WindowFound'>
            </method>
            <method name='WindowNotFound'>
            </method>
        </interface>
    </node>
    """

    def WindowFound(self):
        self.found = True
        print(f"{sys.argv[0]}: window found", file=sys.stderr)

    def WindowNotFound(self):
        self.found = False
        print(f"{sys.argv[0]}: window not found", file=sys.stderr)

    def __init__(self):
        self.found = None


def loadScript(path: str, name: str) -> int:
    return int(
        subprocess.check_output(
            [
                "dbus-send",
                "--session",
                "--dest=org.kde.KWin",
                "--print-reply=literal",
                "/Scripting",
                "org.kde.kwin.Scripting.loadScript",
                f"string:{path}",
                f"string:{name}",
            ],
            text=True,
        ).split()[-1]
    )


def run_script_in_kwin(bus, path, name):
    scripting = bus.get("org.kde.KWin", "/Scripting")["org.kde.kwin.Scripting"]
    script_id = loadScript(path, name)
    if script_id == -1:
        unload = scripting.unloadScript(name)
        assert unload, unload
        script_id = loadScript(path, name)
    script = bus.get("org.kde.KWin", f"/{script_id}")["org.kde.kwin.Script"]
    script.run()
    script.stop()
    unload = scripting.unloadScript(name)


def execute(bus, window_title, window_class, quitter):
    script_folder_root = os.getenv("XDG_CONFIG_HOME", os.environ["HOME"])
    script_folder = os.path.join(script_folder_root, ".wwscripts")
    name = hashlib.md5(f"{window_title}-{window_class}".encode("utf-8")).hexdigest()
    path = os.path.join(script_folder, name)

    script = generate_script(window_title, window_class)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        f.write(script)

    try:
        run_script_in_kwin(bus, path, name)
    except Exception as e:
        print(f"{sys.argv[0]}: fatal error: {e}", file=sys.stderr)
        sys.exit(4)

    GLib.idle_add(quitter)


def dump(bus):
    script_folder_root = os.getenv("XDG_CONFIG_HOME", os.environ["HOME"])
    script_folder = os.path.join(script_folder_root, ".wwscripts")
    name = hashlib.md5(f"{sys.argv[0]}-dump".encode("utf-8")).hexdigest()
    path = os.path.join(script_folder, name)

    script = generate_dump_script()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        f.write(script)

    try:
        run_script_in_kwin(bus, path, name)
    except Exception as e:
        print(f"{sys.argv[0]}: fatal error: {e}", file=sys.stderr)
        sys.exit(4)


def usage():
    print(
        "usage:\n\n"
        f"  {sys.argv[0]} <window title regexp> [optional command and args if not found]\n"
        f"  {sys.argv[0]} --class <window class> [optional command and args if not found]\n"
        f"  {sys.argv[0]} --dump\n"
        "\n"
        "Returns 1 when the window is not found, 0 when it is.\n"
        "\n"
        "If an optional command (with or without arguments) is specified"
        " then the command will be run if the window is not found.\n"
        "\n"
        "--dump makes KWin dump to the journal a list of clients with their"
        " window titles and resource classes.  You can retrieve this list by"
        " running journalctl _COMM=kwin_wayland under Wayland.",
        file=sys.stderr,
    )
    sys.exit(os.EX_USAGE)


args = sys.argv[1:]
try:
    window_class: str | None = None
    window_title: str | None = args[0]
    args = args[1:]
except IndexError:
    usage()

if window_title == "--help":
    usage()

if window_title == "--dump":
    dump(pydbus.SessionBus())
    sys.exit(0)

if window_title == "--class":
    try:
        window_title, window_class, args = None, args[0], args[1:]
    except IndexError:
        print(
            f"{sys.argv[0]}: error: the --class parameter requires a window class",
            file=sys.stderr,
        )
        usage()

optional_cmd = args

bus = pydbus.SessionBus()
wm = WindowManagement()
bus.publish("com.rudd_o.WindowManagement", wm)
loop = GLib.MainLoop()
GLib.idle_add(execute, bus, window_title, window_class, loop.quit)
loop.run()

if not optional_cmd:
    sys.exit(0 if wm.found else 1)

os.execvp(optional_cmd[0], optional_cmd)

The small program above does the following, when you invoke it as such: kwin_wmgmt_helper Kodi

  • It tells KWin, using a script loaded via hot code reloading, to look for a window titled Kodi (specifically, searching that regular expression in the window title of all running applications).
  • If KWin finds the window, then it calls back to the program (via D-Bus) confirming the window was found.  If not, then it tells the program the window was not found.
  • If the window was found, KWin will raise the window for you.
  • Depending on the message the program receives from KWin, it will either exit with success (0) status if the window was found, or failure (1) if the window was not found.

This makes the program extremely useful for scripting desktop automation scenarios.  I myself use it in Kodi — with my XDG desktop menu add-on for Kodi — to bring up specific applications that are already running on my desktop, instead of launching them.  If I open Chromium, after all, I don't want to open another window of Chromium — I want to raise the window that's already open.

Other ways you can invoke the program as:

  • kwin_wmgmt_helper --class google-chrome
    finds a window whose class matches google-chrome, and raises it.
  • kwin_wmgmt_helper Konsole konsole
    finds a Konsole window and raises it; if it can't find Konsole, it will execute the konsole command instead.
  • kwin_wmgmt_helper --dump
    makes KWin dump a list of open window names and classes to the system journal.  You can look at it using journalctl (peruse the last few dozen lines).