hæx.com logo

Patterns

Math: patterns
Physics: patterns per measurement
Chemistry: patterns per bond
Biology: patterns per survival
Ecology: patterns per system
Neuroscience: patterns per substrate per survival
Psychology: patterns per context per consciousness
Sociology: patterns per context per consciousness per collective
Philosophy: patterns?

Launching Apps from a Bash Script the Way(land) KDE Does

Kubuntu 25.10 · KDE Plasma 6.4.5 · systemd 257

I have a small bash script that opens one of my dev environments; Dolphin on my project assets, Unity Hub, VS Code, and a Konsole in the project directory. It works, but two things always annoyed me:

  1. The script lingers in System Monitor as a phantom process doing nothing, with no visible window.
  2. Dolphin opens as "dolphin" (lowercase, no icon) instead of "Dolphin" with its proper icon, like it would from the KDE start menu. I wondered why.

Time for some Claude Code. Fixing this turned out to be a deep dive into how KDE Plasma actually launches applications.

The original script

#!/bin/bash
PPATH=/home/stig/Projects/Lone/Lone
kstart dolphin "$PPATH/Assets" &
kstart /opt/unityhub/unityhub &
kstart code $PPATH &
konsole --workdir="$PPATH" &

Simple enough. kstart is a KDE utility for launching windowed apps. But it's a holdover from Plasma 5 that doesn't integrate with the way Plasma 6 tracks applications.

Attempt 1: Just drop kstart

#!/bin/bash
PPATH=/home/stig/Projects/Lone/Lone

dolphin "$PPATH/Assets" &>/dev/null &
/opt/unityhub/unityhub &>/dev/null &
code "$PPATH" &>/dev/null &
konsole --workdir="$PPATH" &>/dev/null &

disown -a

This fixed the Dolphin icon issue (the app now sets its own _KDE_NET_WM_DESKTOP_FILE window property), but the apps vanished entirely from System Monitor's Applications tab. The script itself still lingered.

The problem: KDE System Monitor's "Applications" view doesn't use window properties. It uses systemd cgroups.

How KDE tracks applications

When you launch an app from KDE's start menu or KRunner, the launcher doesn't just fork a process. It creates a systemd scope; a named cgroup container; following a specific naming convention:

app-org.kde.dolphin-72323.scope
app-org.kde.konsole-81510.scope

You can see your active scopes with:

systemctl --user list-units --type=scope 'app-*'

KDE System Monitor (specifically libksysguard) parses these scope names with a regex to extract the desktop file ID, then looks up the .desktop file to get the app name and icon. No scope, no entry in the Applications tab.

Attempt 2: systemd-run --scope

#!/bin/bash
PPATH=/home/stig/Projects/Lone/Lone

systemd-run --user --scope -d --unit="app-org.kde.dolphin-$RANDOM.scope" \
    -- dolphin "$PPATH/Assets" 2>/dev/null
systemd-run --user --scope -d --unit="app-unityhub-$RANDOM.scope" \
    -- /opt/unityhub/unityhub 2>/dev/null
systemd-run --user --scope -d --unit="app-code-$RANDOM.scope" \
    -- code "$PPATH" 2>/dev/null
systemd-run --user --scope -d --unit="app-org.kde.konsole-$RANDOM.scope" \
    -- konsole --workdir="$PPATH" 2>/dev/null

This seemed right, but systemd-run --scope acts as the scope lifecycle manager; it blocks, keeping the scope alive as long as the app runs. Only Dolphin would open; the script would hang on line 1 waiting for Dolphin to exit. Running the script a second time would open the remaining three apps (because Dolphin, a single-instance app, would return immediately).

Worse: when I killed the lingering script from System Monitor, it took Unity Hub down with it; because killing the systemd-run process tears down the scope.

System Monitor showing the script lingering alongside properly launched Dolphin, Konsole, and VS Code
After attempt 2: apps show correctly, but the script still lingers and is entangled with the app processes.

The solution: D-Bus scope creation

Looking at how KDE's own launcher works (in KIO::ScopedProcessRunner), the actual mechanism is:

  1. Start the process normally (fork)
  2. Call systemd's StartTransientUnit over D-Bus to create a scope
  3. Pass the PID to move it into the new scope

The scope is created by systemd and persists independently; no lifecycle manager needed. We can do the same from bash with busctl:

#!/bin/bash
PPATH=/home/stig/Projects/Lone/Lone

launch() {
    local id="$1"
    shift
    "$@" &>/dev/null &
    local pid=$!
    busctl call --user org.freedesktop.systemd1 /org/freedesktop/systemd1 \
        org.freedesktop.systemd1.Manager StartTransientUnit \
        'ssa(sv)a(sa(sv))' \
        "app-${id}-${pid}.scope" "fail" \
        1 "PIDs" "au" 1 "$pid" \
        0 &>/dev/null
    disown "$pid" 2>/dev/null
}

launch org.kde.dolphin dolphin "$PPATH"
launch unityhub /opt/unityhub/unityhub
launch code code "$PPATH"
launch org.kde.konsole konsole --workdir="$PPATH"

The launch function:

  1. Starts the app in the background
  2. Captures its PID
  3. Calls StartTransientUnit via D-Bus, creating a scope named app-<desktop-id>-<pid>.scope and moving the process into it
  4. Disowns the process from the shell

Each app now lives in its own cgroup scope. The script's cgroup becomes empty when it exits, so it vanishes from System Monitor. All apps show up with proper names and icons.

After the fix: all apps appear correctly in KDE System Monitor, and the script is gone.

A nice side effect: re-running the script doesn't re-open single-instance apps (like Dolphin), while Konsole opens a new window each time; which is exactly the behaviour I wanted.

Verifying against KDE source

To make sure this approach is stable and won't need migrating, I checked the actual KDE source code.

KIO's ScopedProcessRunner (scopedprocessrunner.cpp) does the same thing: fork the process, then call StartTransientUnit with the PIDs property. KDE uses a UUID for the scope suffix instead of PID, and sets three additional properties:

PropertyValuePurpose
PIDsProcess IDMove process into scope
Sliceapp.sliceProper cgroup hierarchy
DescriptionApp nameHuman-readable in systemctl
SourcePathPath to .desktop fileFallback for desktop file lookup

libksysguard's cgroup.cpp parses the scope name with this regex:

(apps|app|flatpak|dbus)-(?:[^-]*-)?([^-]+(?=-.*\.scope)|[^@]+(?=(?:@.*)?\\.service|.slice))

It extracts the desktop file ID from between app- and -<suffix>.scope, then calls KService::serviceByMenuId() to find the .desktop file. This is why the naming convention matters; use the desktop file's stem (e.g., org.kde.dolphin) as the ID.

Interesting note: on systemd ≥ 250, KDE internally prefers launching apps as transient services (where systemd itself forks the process). But scopes remain the correct mechanism for external launchers that start the process themselves; exactly our case. This is standardized in the systemd XDG desktop specification and isn't going anywhere.

Why is this so hard?

KDE has a clean C++ API for this; KIO::ApplicationLauncherJob is essentially a one-liner that handles scope creation, desktop file lookup, and process management. But no CLI tool exposes it properly:

  • kioclient exec launches .desktop files but doesn't accept app arguments (no --workdir, no directory to open)
  • kstart is a Plasma 5 holdover that doesn't create scopes
  • systemd-run --scope blocks as a lifecycle manager
  • gtk-launch doesn't create KDE-style scopes

There's a missing CLI tool in the KDE ecosystem; something like klaunch org.kde.dolphin /some/path that does what the start menu does. Until that exists, the busctl + StartTransientUnit approach is the way to go.

Environment

OSKubuntu 25.10 (Questing Quokka)
KDE Plasma6.4.5
systemd257
Display serverWayland (KWin)

Title

I thought the title should be "The Greatest Power", but then what if "Media" is the title, and the greatest power is.. you? Woops, too soon, yes, you, you have to read, and then. Only then. Do you read again. Then you think, and you evaluate, and you theorize, speculate, conjecture, postulate, suppose, and propose. Wait, what was the narrative again? :smirk:

Det var den midtlivsdagen

Lang snot fra nesa.

Fantasidrømmen for livet i situasjonen muligens konkretisert.

Snus oppå postkassene ved nedoppen i trapperullen.

Terrascan på 3.7.0 fra clauden, godt jobba.

Thor fått sæ by på Lyneth-tjeneren i askene, endelig, goblinene i tjatten var glade ska si. Fint for utviklerne med indiesemberen og oppvartning av de små, tårer i glede på den, ja.

Plussa opp i kunnskapspoeng der hvor fem eller seks akser skal plastere ut fremtiden vår, ja herremin. Takk for den, du med skjegget i stømmen.

Lone på gli mot fullmodulus, gøy og motivert.

Katter og ketter i alle kantter. En puff var det også med flaggermusene.

Sjekka to-nullen på my spilla.

Klementin og cola zero på boks.

Nabon allerede fått kasta blomene mot gamle druer. Neida. Seim prosjidur, James? Alt vel på joggeturen.

Kvakken og ekskrementer i lydmodus helt til det minste landet med mest av hva jeg vil ha.

Restmat etter jule-karolen som var min egen med spisepinner i sekkekanten.

Da var det jol.

Også vet jeg at Askepott har en sko til overs for nyttår.

En veldig god dag, kanskje den beste hittil. Takk, univers.