[utilities/konsole/meven/osc-activation-token] /: Provide per-command xdg-activation tokens to GUI children via OSC

Méven Car <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 46bc48777d6fb04987ab0c7556c5232811887519 by Méven Car.
Committed on 23/07/2026 at 10:12.
Pushed by meven into branch 'meven/osc-activation-token'.

Provide per-command xdg-activation tokens to GUI children via OSC

A subprocess launched from an interactive shell cannot get focus on
Wayland because it has no XDG_ACTIVATION_TOKEN: the token Konsole
received at startup is single-use and the toolkit scrubs it, and the
shell is a long-lived child whose environment cannot be updated per
command.

Add a private OSC (ActivationToken) that lets shell integration ask
Konsole for a fresh token at command start, passing the command it is
about to run:

  query: OSC 6969 ; ? ; <command> ST
  reply: OSC 6969 ; <token> ST   (empty token on failure or non-GUI)

Vt102Emulation parses the query and emits activationTokenRequested.
Session resolves the command to an installed application via
KApplicationTrader (memoized), so the token is only minted for GUI
launches and is scoped to the resolved desktop id. Doing the lookup in
Konsole keeps the shell hook trivial and avoids it maintaining its own
.desktop index. The token is then requested from the active window via
KWaylandExtras and the reply written back to the pty. Non-GUI commands
still get an immediate empty reply so the shell read does not stall for
the full timeout on every prompt.

The minting logic is factored out of the existing activationToken() DBus
slot into fetchActivationToken() and shared by both paths. The token is
bound to the window lastInputSerial, which is the Enter keypress that
started the command, so the compositor treats it as a legitimate user
action.

Add a bash client under shell-integration/ as a reference. It keeps a
negative cache of commands that are not GUI apps, so repeated ls/cd/git
do a single round-trip the first time and none after, then installs a
DEBUG-trap hook only when the terminal answers the OSC.

The OSC number 6969 is a placeholder and must be coordinated upstream
before merge.

M  +1    -0    CMakeLists.txt
A  +4    -0    shell-integration/CMakeLists.txt
A  +43   -0    shell-integration/README.md
A  +77   -0    shell-integration/konsole-activation-token.bash
M  +9    -0    src/Emulation.h
M  +10   -0    src/Vt102Emulation.cpp
M  +5    -0    src/Vt102Emulation.h
M  +96   -29   src/session/Session.cpp
M  +10   -0    src/session/Session.h

https://invent.kde.org/utilities/konsole/-/commit/46bc48777d6fb04987ab0c7556c5232811887519

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0d93c5088..1fcbe4249 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -149,6 +149,7 @@ option(ENABLE_PLUGIN_QUICKCOMMANDS "Build the Quick Commands plugin" ON)
 
 add_subdirectory( src )
 add_subdirectory( desktop )
+add_subdirectory( shell-integration )
 
 if(KF6DocTools_FOUND)
     add_subdirectory( doc/manual )
diff --git a/shell-integration/CMakeLists.txt b/shell-integration/CMakeLists.txt
new file mode 100644
index 000000000..2692570d6
--- /dev/null
+++ b/shell-integration/CMakeLists.txt
@@ -0,0 +1,4 @@
+install(
+    FILES konsole-activation-token.bash
+    DESTINATION ${KDE_INSTALL_DATADIR}/konsole/shell-integration
+)
diff --git a/shell-integration/README.md b/shell-integration/README.md
new file mode 100644
index 000000000..ccbe99549
--- /dev/null
+++ b/shell-integration/README.md
@@ -0,0 +1,43 @@
+# Konsole shell integration: xdg-activation tokens
+
+On Wayland, a GUI application launched from an interactive shell normally
+cannot take keyboard focus: it has no `XDG_ACTIVATION_TOKEN`, so the compositor
+applies focus-stealing prevention and the window opens unfocused.
+
+These scripts close that gap. At each command, the shell asks Konsole (over the
+private `ActivationToken` OSC) for a fresh activation token. Konsole resolves
+the command to an installed application via its `.desktop` files and, only for
+GUI apps, mints a token bound to the keypress that started the command and
+sends it back. The shell exports it as `XDG_ACTIVATION_TOKEN` so the launched
+app can activate its window.
+
+## Usage
+
+Source the file for your shell from your shell rc file:
+
+```sh
+# ~/.bashrc
+. /usr/share/konsole/shell-integration/konsole-activation-token.bash
+```
+
+It is safe to source unconditionally: it probes for terminal support once and
+does nothing in terminals that do not implement the OSC.
+
+## Cost
+
+The gating (is this a GUI app?) lives in Konsole, not the shell, so the shell
+does not scan `.desktop` files. To keep non-GUI commands cheap, the client
+remembers commands that are not GUI apps and skips them: `ls`, `cd`, `git`,
+... do one terminal round-trip the first time they are seen and none after.
+GUI launches request a fresh token each time, because activation tokens are
+single-use.
+
+## Protocol
+
+```
+query:  OSC 6969 ; ? ; <command> ST
+reply:  OSC 6969 ; <token> ST       (empty token if <command> is not a GUI app)
+```
+
+`ST` is the string terminator `ESC \`. The OSC number `6969` is a placeholder
+pending an agreed value.
diff --git a/shell-integration/konsole-activation-token.bash b/shell-integration/konsole-activation-token.bash
new file mode 100644
index 000000000..bfe6458d4
--- /dev/null
+++ b/shell-integration/konsole-activation-token.bash
@@ -0,0 +1,77 @@
+# konsole-activation-token.bash
+#
+# Give GUI applications launched from this interactive shell a fresh
+# xdg-activation token, so they can take keyboard focus on Wayland without
+# tripping the compositor's focus-stealing prevention.
+#
+# Requires a Konsole that implements the ActivationToken OSC. Konsole itself
+# decides whether a command maps to a GUI application (from its installed
+# .desktop files) and only then mints a token, so this client stays simple:
+# it asks once per distinct command and remembers the ones that are not GUI
+# apps. That keeps the common ls/cd/git workflow free of any per-command
+# terminal round-trip after the first time each command is seen.
+#
+# Source it from ~/.bashrc, e.g.:
+#   . /usr/share/konsole/shell-integration/konsole-activation-token.bash
+
+# Commands already known not to map to a GUI app. Skipping these avoids the
+# terminal round-trip for the common ls/cd/grep/... case.
+declare -gA __konsole_not_gui=()
+
+# Ask Konsole for an activation token for $1 and export it if one comes back.
+# An empty reply means "$1 is not a GUI app": cache that and stop asking.
+__konsole_request_activation_token() {
+    local cmd=$1 saved reply token
+    saved=$(stty -g 2>/dev/null) || return
+    stty -echo -icanon min 0 time 2          # up to 0.2s, never blocks the prompt
+    printf '\033]6969;?;%s\033\\' "$cmd" > /dev/tty
+    IFS= read -r -d '\' reply < /dev/tty
+    stty "$saved"
+    token=${reply#*$'\033]6969;'}            # strip the OSC prefix
+    token=${token%$'\033'}                   # strip the trailing ESC of ST
+    if [ -n "$token" ]; then
+        export XDG_ACTIVATION_TOKEN=$token
+    else
+        __konsole_not_gui[$cmd]=1
+        unset XDG_ACTIVATION_TOKEN
+    fi
+}
+
+# Runs before each command via the DEBUG trap. Pull the executable name out of
+# the command line, skip the negative cache, and otherwise ask Konsole.
+__konsole_activation_preexec() {
+    local w cmd=
+    set -f                                   # no globbing while splitting
+    local words=($BASH_COMMAND)
+    set +f
+    for w in "${words[@]}"; do
+        case $w in
+            *=*) continue ;;                                        # VAR=value
+            sudo|doas|env|nohup|setsid|nice|stdbuf|systemd-run) continue ;; # wrappers
+            *) cmd=$w; break ;;
+        esac
+    done
+    [ -n "$cmd" ] || return
+    cmd=${cmd##*/}
+    [ -n "${__konsole_not_gui[$cmd]}" ] && return   # known non-GUI: no round-trip
+    __konsole_request_activation_token "$cmd"
+}
+
+# Probe once: only install the hook when the terminal actually answers the OSC.
+# This makes the file a no-op in other terminals, so it is safe to source
+# unconditionally (an unsupported terminal costs one timed-out read at startup).
+__konsole_activation_probe() {
+    [ -t 1 ] || return 1
+    local saved reply
+    saved=$(stty -g 2>/dev/null) || return 1
+    stty -echo -icanon min 0 time 2
+    printf '\033]6969;?;\033\\' > /dev/tty
+    IFS= read -r -d '\' reply < /dev/tty
+    stty "$saved"
+    [[ $reply == *$'\033]6969;'* ]]
+}
+
+if __konsole_activation_probe; then
+    trap '__konsole_activation_preexec' DEBUG
+fi
+unset -f __konsole_activation_probe
diff --git a/src/Emulation.h b/src/Emulation.h
index 99230dea3..b60a75205 100644
--- a/src/Emulation.h
+++ b/src/Emulation.h
@@ -271,6 +271,15 @@ Q_SIGNALS:
      */
     void sendData(const QByteArray &data);
 
+    /**
+     * Emitted when the shell requests an xdg-activation token via the
+     * private ActivationToken OSC. The Session resolves the command to a
+     * GUI application, mints a token and writes the reply back to the pty.
+     *
+     * @param command The executable name the shell is about to launch
+     */
+    void activationTokenRequested(const QString &command);
+
     /**
      * Requests that the pty used by the terminal process
      * be set to UTF 8 mode.
diff --git a/src/Vt102Emulation.cpp b/src/Vt102Emulation.cpp
index 9d36205f2..611f81573 100644
--- a/src/Vt102Emulation.cpp
+++ b/src/Vt102Emulation.cpp
@@ -1222,6 +1222,16 @@ void Vt102Emulation::processSessionAttributeRequest(const int tokenSize, const u
             }
         }
     }
+    if (attribute == ActivationToken) {
+        // Query form only: OSC 6969 ; ? ; <command> ST
+        // The Session answers asynchronously by writing the reply to the pty.
+        const auto parts = value.split(QLatin1Char(';'));
+        if (!parts.isEmpty() && parts.at(0) == QLatin1String("?")) {
+            const QString command = parts.size() > 1 ? parts.at(1) : QString();
+            Q_EMIT activationTokenRequested(command);
+        }
+        return;
+    }
     if (attribute == ReportColors) {
         // RGB colors
         QStringList params = value.split(QLatin1Char(';'));
diff --git a/src/Vt102Emulation.h b/src/Vt102Emulation.h
index 9f99d5e44..e11f8eab6 100644
--- a/src/Vt102Emulation.h
+++ b/src/Vt102Emulation.h
@@ -202,6 +202,11 @@ private:
         Image = 1337,
         // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
         ConEmu = 9,
+        // Konsole private: shell asks for an xdg-activation token for the next
+        // launched child. Query: OSC 6969 ; ? [ ; app_id ] ST
+        // Reply: OSC 6969 ; <token> ST  (empty token on failure)
+        // NOTE: placeholder number, must be coordinated upstream before merge.
+        ActivationToken = 6969,
     };
 
     ParserStates _state = Ground;
diff --git a/src/session/Session.cpp b/src/session/Session.cpp
index b7057cb34..5fa64d978 100644
--- a/src/session/Session.cpp
+++ b/src/session/Session.cpp
@@ -32,12 +32,14 @@
 
 // KDE
 #include <KActionCollection>
+#include <KApplicationTrader>
 #include <KConfigGroup>
 #include <KIO/DesktopExecParser>
 #include <KLocalizedString>
 #include <KNotification>
 #include <KProcess>
 #include <KSelectAction>
+#include <KService>
 #include <KWindowSystem>
 
 #ifndef Q_OS_WIN
@@ -185,6 +187,7 @@ void Session::openTeletype(int fd, bool runShell)
     // connect the I/O between emulator and pty process
     connect(_shellProcess, &Konsole::Pty::receivedData, this, &Konsole::Session::onReceiveBlock);
     connect(_emulation, &Konsole::Emulation::sendData, _shellProcess, &Konsole::Pty::sendData);
+    connect(_emulation, &Konsole::Emulation::activationTokenRequested, this, &Konsole::Session::onActivationTokenRequested);
 
     // UTF8 mode
     connect(_emulation, &Konsole::Emulation::useUtf8Request, _shellProcess, &Konsole::Pty::setUtf8Mode);
@@ -2570,63 +2573,127 @@ void Session::runCommandFromLayout(const QString &command) const
     _emulation->sendText(command + QLatin1Char('\n'));
 }
 
-QString Session::activationToken(const QString &cookieForRequest) const
+void Session::fetchActivationToken(const QString &appId, std::function<void(const QString &)> onToken) const
 {
-    // safety check, only work if the caller knows our id
-    // they will read it from the SHELL_SESSION_ID env var inside this session
-    if (cookieForRequest != m_activationCookie) {
-        return {};
-    }
-
-#if HAVE_DBUS && HAVE_WAYLAND
-    // no window active, no token
-    // same if we don't run wayland
+#if HAVE_WAYLAND
+    // no active window, or not on wayland: no token, answer immediately so
+    // the caller never stalls waiting for a reply.
     const auto window = qApp->activeWindow();
     if (!window || !window->window() || !KWindowSystem::isPlatformWayland()) {
-        return {};
+        onToken({});
+        return;
     }
 
-    // we will respond delayed, as the token needs to arrive
-    Q_ASSERT(calledFromDBus());
-    const auto msg = message();
-    setDelayedReply(true);
-
-    // we need to filter the response with the request serial
+    // the token is tied to the serial of the event that triggered the request
+    // (e.g. the Enter keypress that started the command).
     const int launchedSerial = KWaylandExtras::self()->lastInputSerial(window->window()->windowHandle());
 #if KWINDOWSYSTEM_VERSION < QT_VERSION_CHECK(6, 19, 0)
     connect(
         KWaylandExtras::self(),
         &KWaylandExtras::xdgActivationTokenArrived,
         this,
-        [msg, launchedSerial](int tokenSerial, QString token) {
-            // if wrong token, ignore it, but we must always reply to not stall the caller
-            // we use here a SingleShotConnection, we will just be called once!
+        [launchedSerial, onToken](int tokenSerial, QString token) {
+            // wrong serial: not our token, reply empty but always reply once.
             if (tokenSerial != launchedSerial) {
                 token.clear();
             }
-            auto reply = msg.createReply(token);
-            QDBusConnection::sessionBus().send(reply);
+            onToken(token);
         },
         Qt::SingleShotConnection);
 
-    KWaylandExtras::requestXdgActivationToken(window->window()->windowHandle(), launchedSerial, {});
+    KWaylandExtras::requestXdgActivationToken(window->window()->windowHandle(), launchedSerial, appId);
 #else
     auto *watch = new QFutureWatcher<QString>();
-    watch->setFuture(KWaylandExtras::xdgActivationToken(window->window()->windowHandle(), launchedSerial, {}));
-    connect(watch, &QFutureWatcher<QString>::finished, this, [msg, watch]() {
-        const auto token = watch->result();
-        auto reply = msg.createReply(token);
-        QDBusConnection::sessionBus().send(reply);
-
+    watch->setFuture(KWaylandExtras::xdgActivationToken(window->window()->windowHandle(), launchedSerial, appId));
+    connect(watch, &QFutureWatcher<QString>::finished, this, [watch, onToken]() {
+        onToken(watch->result());
         watch->deleteLater();
     });
 #endif
+#else
+    Q_UNUSED(appId)
+    onToken({});
+#endif
+}
+
+QString Session::activationToken(const QString &cookieForRequest) const
+{
+    // safety check, only work if the caller knows our id
+    // they will read it from the SHELL_SESSION_ID env var inside this session
+    if (cookieForRequest != m_activationCookie) {
+        return {};
+    }
+
+#if HAVE_DBUS && HAVE_WAYLAND
+    // we will respond delayed, as the token needs to arrive
+    Q_ASSERT(calledFromDBus());
+    const auto msg = message();
+    setDelayedReply(true);
 
+    fetchActivationToken({}, [msg](const QString &token) {
+        auto reply = msg.createReply(token);
+        QDBusConnection::sessionBus().send(reply);
+    });
 #endif
 
     return {};
 }
 
+// If command (a bare executable name) maps to an installed application, return
+// its desktop entry name (e.g. org.kde.dolphin), otherwise an empty string.
+// Used to gate token minting to GUI launches and to scope the token.
+static QString desktopAppIdForCommand(const QString &command)
+{
+    if (command.isEmpty()) {
+        return {};
+    }
+
+    // KApplicationTrader queries the in-memory ksycoca cache, but iterating
+    // every application per command is wasteful, so memoize per command name.
+    static QHash<QString, QString> cache;
+    const auto cached = cache.constFind(command);
+    if (cached != cache.constEnd()) {
+        return cached.value();
+    }
+
+    const KService::List apps = KApplicationTrader::query([&command](const KService::Ptr &service) {
+        // service->exec() may carry a path and field codes (e.g. "dolphin %u"),
+        // so compare against the first token's basename.
+        const QString exec = service->exec().section(QLatin1Char(' '), 0, 0);
+        return exec.mid(exec.lastIndexOf(QLatin1Char('/')) + 1) == command;
+    });
+
+    const QString appId = apps.isEmpty() ? QString() : apps.constFirst()->desktopEntryName();
+    cache.insert(command, appId);
+    return appId;
+}
+
+void Session::onActivationTokenRequested(const QString &command)
+{
+    // Reply on the pty with: OSC Vt102Emulation::ActivationToken ; <token> ST
+    // An empty token is still a valid reply so the shell does not block.
+    auto sendReply = [this](const QString &token) {
+        QByteArray reply = QByteArrayLiteral("\033]6969;");
+        reply += token.toUtf8();
+        reply += QByteArrayLiteral("\033\\"); // ST
+        _shellProcess->sendData(reply);
+    };
+
+#if HAVE_WAYLAND
+    // Gate: only mint a token when the command is a known GUI application, and
+    // scope the token to that application's desktop id.
+    if (KWindowSystem::isPlatformWayland()) {
+        const QString appId = desktopAppIdForCommand(command);
+        if (!appId.isEmpty()) {
+            fetchActivationToken(appId, sendReply);
+            return;
+        }
+    }
+#endif
+
+    sendReply({});
+}
+
 bool Session::isCalledViaDbusAndForbidden() const
 {
 #if HAVE_DBUS
diff --git a/src/session/Session.h b/src/session/Session.h
index 8388fcfd2..04039471b 100644
--- a/src/session/Session.h
+++ b/src/session/Session.h
@@ -12,6 +12,8 @@
 #include "config-konsole.h"
 
 // Qt
+#include <functional>
+
 #include <QHash>
 #include <QLoggingCategory>
 #include <QProcess>
@@ -979,6 +981,9 @@ private Q_SLOTS:
     void fireZModemUploadDetected();
 
     void onReceiveBlock(const char *buf, int len);
+    // Answer a shell ActivationToken OSC request: if command maps to a GUI
+    // application, mint a token and write the OSC reply back to the pty.
+    void onActivationTokenRequested(const QString &command);
     void silenceTimerDone();
     void activityTimerDone();
     void resetNotifications();
@@ -1010,6 +1015,11 @@ private Q_SLOTS:
 private:
     bool isCalledViaDbusAndForbidden() const;
 
+    // Request an xdg-activation token from the compositor for the currently
+    // active window and deliver it to onToken (empty string if unavailable).
+    // Shared by the activationToken() DBus slot and the ActivationToken OSC.
+    void fetchActivationToken(const QString &appId, std::function<void(const QString &)> onToken) const;
+
     Q_DISABLE_COPY(Session)
 
     void updateTerminalSize();
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.