Skip to content

Adding a Desktop Environment

Mina Maher edited this page Apr 8, 2026 · 9 revisions

Adding a Desktop Environment

Logitune uses per-application profiles that switch automatically when window focus changes. This requires desktop environment integration for focus tracking. This guide explains how to add support for a new DE.

Interface

All desktop integrations implement IDesktopIntegration (defined in src/core/interfaces/IDesktopIntegration.h):

class IDesktopIntegration : public QObject {
    Q_OBJECT
public:
    virtual void start() = 0;
    virtual bool available() const = 0;
    virtual QString desktopName() const = 0;
    virtual QStringList detectedCompositors() const = 0;
    virtual void blockGlobalShortcuts(bool block) = 0;
    virtual QVariantList runningApplications() const = 0;

signals:
    void activeWindowChanged(const QString &wmClass, const QString &title);
};

Required Methods

Method Purpose When Called
start() Initialize focus tracking (install scripts, connect signals, start polling) Once, after AppController::init()
available() Return true if this DE is detected and usable Checked before relying on DE features
desktopName() Human-readable name (e.g., "KDE", "GNOME") Logging and UI
detectedCompositors() List of detected compositor names Diagnostics
blockGlobalShortcuts(bool) Temporarily disable global shortcuts during keystroke capture During KeystrokeCapture QML component
runningApplications() Return list of installed GUI applications App profile picker dialog

The Critical Signal

void activeWindowChanged(const QString &wmClass, const QString &title);

This signal drives the entire profile switching system. wmClass must be a stable, unique identifier for the application. On KDE, this is the .desktop file's completeBaseName (e.g., org.kde.dolphin). On GNOME, the Shell extension resolves this from sandboxed_app_id, WindowTracker app ID, or wm_class. On other DEs, it might be the X11 WM_CLASS or a Wayland app_id.

Class Hierarchy

All desktop implementations share common .desktop file resolution logic via LinuxDesktopBase:

classDiagram
    class IDesktopIntegration {
        <<abstract>>
        +start()
        +available() bool
        +desktopName() QString
        +detectedCompositors() QStringList
        +blockGlobalShortcuts(bool block)
        +runningApplications() QVariantList
        +activeWindowChanged(wmClass, title) signal
    }

    class LinuxDesktopBase {
        <<abstract>>
        +runningApplications() QVariantList
        #desktopDirs() QStringList
        #resolveDesktopFile(resourceClass) QString
        #m_resolveCache : QHash
    }

    class KDeDesktop {
        +focusChanged(resourceClass, title, desktopFileName)
        -m_kwin : QDBusInterface
        -m_pollTimer : QTimer
    }

    class GnomeDesktop {
        +focusChanged(appId, title)
        -ensureExtensionInstalled() bool
        -detectShellMajorVersion() int
        -m_lastAppId : QString
    }

    class GenericDesktop {
        +start()
        +available() bool
    }

    IDesktopIntegration <|-- LinuxDesktopBase
    LinuxDesktopBase <|-- KDeDesktop
    LinuxDesktopBase <|-- GnomeDesktop
    LinuxDesktopBase <|-- GenericDesktop
Loading

LinuxDesktopBase

LinuxDesktopBase (src/core/desktop/LinuxDesktopBase.h/cpp) extracts shared logic that all Linux DE implementations need:

  • desktopDirs() — returns the list of directories to scan for .desktop files: /usr/share/applications, ~/.local/share/applications, Flatpak paths (/var/lib/flatpak/exports/share/applications, ~/.local/share/flatpak/exports/share/applications), and Snap paths
  • resolveDesktopFile(resourceClass) — maps a window's resource class to a canonical .desktop file baseName, using filename matching and StartupWMClass lookup, with results cached in m_resolveCache
  • runningApplications() — scans .desktop files for GUI applications (those with Type=Application and no NoDisplay=true)

Desktop Detection Factory

AppController selects the appropriate desktop integration at startup based on XDG_CURRENT_DESKTOP:

QString xdgDesktop = QProcessEnvironment::systemEnvironment()
                         .value("XDG_CURRENT_DESKTOP");
if (xdgDesktop.contains("KDE", Qt::CaseInsensitive)) {
    m_ownedDesktop = std::make_unique<KDeDesktop>();
} else if (xdgDesktop.contains("GNOME", Qt::CaseInsensitive)) {
    m_ownedDesktop = std::make_unique<GnomeDesktop>();
} else {
    m_ownedDesktop = std::make_unique<GenericDesktop>();
}

In tests, a MockDesktop is injected via the constructor instead.

Existing Implementations

KDeDesktop

The KDE implementation (src/core/desktop/KDeDesktop.h/cpp) uses:

  1. KWin Script — a JavaScript snippet loaded into KWin via D-Bus that calls back on workspace.windowActivated
  2. D-Bus callback — the script calls com.logitune.app /FocusWatcher focusChanged(resourceClass, title, desktopFileName)
  3. Desktop file resolution — maps resourceClass to canonical .desktop file baseName (via LinuxDesktopBase::resolveDesktopFile())
  4. kglobalaccel D-Bus — blocks global shortcuts during keystroke capture

GnomeDesktop

The GNOME implementation (src/core/desktop/GnomeDesktop.h/cpp) uses a GNOME Shell extension with D-Bus callback:

  1. Shell extension — a JavaScript extension installed into ~/.local/share/gnome-shell/extensions/logitune-focus@logitune.com/ that hooks global.display.connect('notify::focus-window', ...)
  2. Two API versionsv42/extension.js (imports-based, GNOME 42-44) and v45/extension.js (ES modules, GNOME 45+)
  3. D-Bus callback — the extension calls com.logitune.app /FocusWatcher local.Logitune.logitune.GnomeDesktop.focusChanged(appId, title)
  4. App ID resolution — the extension tries sandboxed_app_id (Flatpak), then WindowTracker app ID, then wm_class
  5. Shell.Eval — blocks global shortcuts during keystroke capture by toggling Main.layoutManager._startingUp

GNOME Focus Tracking Flow

sequenceDiagram
    participant Shell as GNOME Shell
    participant Ext as Logitune Extension
    participant DBus as D-Bus Session Bus
    participant Gnome as GnomeDesktop
    participant AC as AppController

    Note over Gnome: On start:<br/>1. Check Wayland session<br/>2. Detect Shell version (42+)<br/>3. Install correct extension variant<br/>4. Enable extension via D-Bus<br/>5. Register com.logitune.app service

    Shell->>Ext: notify::focus-window
    Ext->>Ext: resolve app ID:<br/>1. sandboxed_app_id (Flatpak)<br/>2. WindowTracker app ID<br/>3. wm_class fallback
    Ext->>DBus: call focusChanged(appId, title)
    DBus->>Gnome: focusChanged(appId, title)

    Gnome->>Gnome: resolveDesktopFile(appId) if needed
    Note over Gnome: Dedup: skip if same as m_lastAppId

    Gnome->>AC: activeWindowChanged(resolved, title)
Loading

Extension Installation

The GNOME extension ships with the package in two variants under data/gnome-extension/:

data/gnome-extension/
  metadata.json          # UUID, name, supported shell versions
  v42/extension.js       # GNOME 42-44 (imports-based API)
  v45/extension.js       # GNOME 45+ (ES modules API)

On first run, GnomeDesktop::ensureExtensionInstalled():

  1. Detects the Shell major version via org.gnome.Shell.ShellVersion D-Bus property
  2. Selects the correct variant (v42 or v45)
  3. Copies metadata.json and the correct extension.js to ~/.local/share/gnome-shell/extensions/logitune-focus@logitune.com/
  4. Enables the extension via org.gnome.Shell.Extensions.EnableExtension D-Bus call (or gnome-extensions enable CLI fallback)

The system package installs both variants to /usr/share/gnome-shell/extensions/logitune-focus@logitune.com/v42/ and v45/. The app copies the correct one to the user directory on first run.

D-Bus Interface Name

Qt auto-generates D-Bus interface names from the QApplication name and C++ namespace. Since the app name is "Logitune" (capital L), the auto-generated interface for GnomeDesktop::focusChanged is:

local.Logitune.logitune.GnomeDesktop

The extension JavaScript must use this exact interface name.

GenericDesktop

The generic fallback (src/core/desktop/GenericDesktop.h/cpp) provides a minimal implementation. It inherits runningApplications() from LinuxDesktopBase but does not implement focus tracking. Used when no specific DE is detected.

Focus Tracking Strategies

Different DEs offer different APIs for tracking window focus:

graph TB
    subgraph "KDE Plasma"
        KWin[KWin Script API<br/>workspace.windowActivated]
        KWinDBus[D-Bus callback<br/>org.kde.KWin /Scripting]
    end

    subgraph "GNOME"
        Extension[GNOME Shell Extension<br/>global.display.connect<br/>'notify::focus-window']
        ExtDBus[D-Bus callback<br/>com.logitune.app /FocusWatcher]
    end

    subgraph "Hyprland"
        IPC[Hyprland IPC<br/>hyprctl activewindow]
        Socket[Unix socket events<br/>activewindow>>]
    end

    subgraph "Sway / wlroots"
        WLR[wlr-foreign-toplevel<br/>management protocol]
        SwayIPC[Sway IPC<br/>swaymsg -t subscribe]
    end

    subgraph "X11 Generic"
        Xprop[_NET_ACTIVE_WINDOW<br/>property change notification]
        XLib[XSelectInput on root<br/>PropertyChangeMask]
    end
Loading
DE Recommended Approach Latency Reliability
KDE Plasma 6 KWin script D-Bus callback <10ms High (event-driven)
GNOME 42+ Shell extension D-Bus callback <10ms High (event-driven)
Hyprland IPC socket subscription <10ms High
Sway IPC subscription <10ms High
X11 (any) _NET_ACTIVE_WINDOW via XCB <10ms High

Window Identity Resolution

The trickiest part of desktop integration is resolving a window to a stable application ID. Different compositors report different identifiers:

Compositor Identifier Example
KWin (Wayland) desktopFileName or resourceClass org.kde.dolphin or dolphin
Mutter (GNOME) sandboxed_app_id, app ID, or wm_class org.gnome.Nautilus
Hyprland class firefox
X11 WM_CLASS (instance, class) Navigator, firefox

Logitune normalizes all of these to a .desktop file baseName via LinuxDesktopBase::resolveDesktopFile():

  1. Checking desktopFileName if the compositor provides it directly (KDE)
  2. Using sandboxed_app_id for Flatpak apps (GNOME extension)
  3. Searching .desktop files for a matching filename component
  4. Searching .desktop files for a matching StartupWMClass
  5. Falling back to the raw identifier

Results are cached in m_resolveCache to avoid repeated filesystem scans.

Step-by-Step: Adding Hyprland Support

Hyprland is a wlroots-based compositor with a powerful IPC system. Here is a complete guide:

Step 1: Create the class

Create src/core/desktop/HyprlandDesktop.h:

#pragma once
#include "desktop/LinuxDesktopBase.h"
#include <QLocalSocket>

namespace logitune {

class HyprlandDesktop : public LinuxDesktopBase {
    Q_OBJECT
public:
    explicit HyprlandDesktop(QObject *parent = nullptr);

    void start() override;
    bool available() const override;
    QString desktopName() const override;
    QStringList detectedCompositors() const override;
    void blockGlobalShortcuts(bool block) override;

private:
    bool m_available = false;
    QString m_lastAppId;
    QLocalSocket *m_socket = nullptr;

    void onSocketReadyRead();
    QString socketPath() const;
};

} // namespace logitune

Step 2: Implement focus tracking

Create src/core/desktop/HyprlandDesktop.cpp:

#include "desktop/HyprlandDesktop.h"
#include "logging/LogManager.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>

namespace logitune {

HyprlandDesktop::HyprlandDesktop(QObject *parent)
    : LinuxDesktopBase(parent)
{
}

void HyprlandDesktop::start()
{
    // Check Hyprland is running
    QString sig = QProcessEnvironment::systemEnvironment()
                      .value(QStringLiteral("HYPRLAND_INSTANCE_SIGNATURE"));
    if (sig.isEmpty()) {
        m_available = false;
        return;
    }

    // Connect to Hyprland IPC event socket (socket2)
    m_socket = new QLocalSocket(this);
    connect(m_socket, &QLocalSocket::readyRead,
            this, &HyprlandDesktop::onSocketReadyRead);
    m_socket->connectToServer(socketPath());

    m_available = m_socket->waitForConnected(2000);
    if (m_available)
        qCInfo(lcFocus) << "Hyprland desktop integration started";
}

QString HyprlandDesktop::socketPath() const
{
    QString sig = QProcessEnvironment::systemEnvironment()
                      .value(QStringLiteral("HYPRLAND_INSTANCE_SIGNATURE"));
    QString xdgRuntime = QProcessEnvironment::systemEnvironment()
                             .value(QStringLiteral("XDG_RUNTIME_DIR"));
    return xdgRuntime + "/hypr/" + sig + "/.socket2.sock";
}

void HyprlandDesktop::onSocketReadyRead()
{
    while (m_socket->canReadLine()) {
        QString line = QString::fromUtf8(m_socket->readLine()).trimmed();
        // Hyprland events: "activewindow>>CLASS,TITLE"
        if (!line.startsWith(QStringLiteral("activewindow>>")))
            continue;

        QString data = line.mid(14); // skip "activewindow>>"
        int comma = data.indexOf(',');
        QString wmClass = (comma > 0) ? data.left(comma) : data;
        QString title = (comma > 0) ? data.mid(comma + 1) : QString();

        QString resolved = resolveDesktopFile(wmClass);
        if (resolved == m_lastAppId) continue;
        m_lastAppId = resolved;
        emit activeWindowChanged(resolved, title);
    }
}

bool HyprlandDesktop::available() const { return m_available; }
QString HyprlandDesktop::desktopName() const { return QStringLiteral("Hyprland"); }

QStringList HyprlandDesktop::detectedCompositors() const
{
    return m_available ? QStringList{QStringLiteral("Hyprland")} : QStringList{};
}

void HyprlandDesktop::blockGlobalShortcuts(bool block)
{
    // Switch to an empty submap to block all shortcuts
    QProcess::execute(QStringLiteral("hyprctl"),
        {QStringLiteral("dispatch"), QStringLiteral("submap"),
         block ? QStringLiteral("logitune_capture") : QStringLiteral("reset")});
}

} // namespace logitune

Step 3: Register in the desktop factory

Edit src/app/AppController.cpp:

#include "desktop/HyprlandDesktop.h"

// In the constructor, add before the GenericDesktop fallback:
} else if (QProcessEnvironment::systemEnvironment()
               .contains("HYPRLAND_INSTANCE_SIGNATURE")) {
    m_ownedDesktop = std::make_unique<HyprlandDesktop>();
} else {
    m_ownedDesktop = std::make_unique<GenericDesktop>();
}

Step 4: Add to CMakeLists.txt

Edit src/core/CMakeLists.txt:

target_sources(logitune-core PRIVATE
    # ... existing files ...
    desktop/HyprlandDesktop.cpp
)

Step 5: Add to focus ignore list

In AppController::onWindowFocusChanged(), add Hyprland shell components to the ignore list:

static const QSet<QString> kIgnore = {
    // KDE
    "plasmashell", "krunner", "org.kde.plasmashell", "org.kde.krunner",
    // GNOME
    "gnome-shell", "org.gnome.Shell", "org.gnome.Shell.Extensions",
    // Hyprland — add any launcher/bar apps that shouldn't trigger profile switches
};

Step 6: Testing

The existing MockDesktop infrastructure works for all DEs. Add a detection test:

TEST(DesktopDetectionTest, HyprlandDetected) {
    // Set HYPRLAND_INSTANCE_SIGNATURE and verify HyprlandDesktop is created
}

Key Lessons from the GNOME Implementation

These lessons apply to any new DE integration:

  1. Event-driven > polling — The GNOME Shell extension with D-Bus callback has <10ms latency. The original polling approach (Shell.Introspect every 500ms) was unreliable and wasteful.

  2. D-Bus interface names are case-sensitive — Qt auto-generates interface names from the QApplication name. The GNOME extension had to use local.Logitune.logitune.GnomeDesktop (capital L) to match.

  3. App ID resolution varies wildly — Flatpak apps have sandboxed_app_id, native apps have wm_class, and GNOME's WindowTracker provides yet another format. The extension tries all three in order.

  4. Extension API versions break — GNOME 45 moved from imports.gi to ES module import syntax. Ship both variants and select at runtime based on Shell version.

  5. System vs user extension paths — Packages install to /usr/share/gnome-shell/extensions/ but GNOME Shell loads from ~/.local/share/gnome-shell/extensions/. The app copies the correct variant on first run.

  6. Flush the command queue on profile switch — When focus changes rapidly, stale commands from the previous profile can still be in the queue. Call m_deviceManager.flushCommandQueue() before applying a new profile.

  7. Send settings before button diversions — DPI, SmartShift, scroll, and thumb wheel commands should be sent before button divert commands. Settings take effect immediately; button diversions have higher latency and are less critical for the user experience.


Logitune Wiki


🏠 Home

📚 User Guide

🏗️ Architecture

🔧 Extending

🧪 Quality

Clone this wiki locally