-
Notifications
You must be signed in to change notification settings - Fork 37
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.
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); };
| 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 |
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.
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
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.desktopfiles:/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.desktopfile baseName, using filename matching andStartupWMClasslookup, with results cached inm_resolveCache -
runningApplications()— scans.desktopfiles for GUI applications (those withType=Applicationand noNoDisplay=true)
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.
The KDE implementation (src/core/desktop/KDeDesktop.h/cpp) uses:
-
KWin Script — a JavaScript snippet loaded into KWin via D-Bus that calls back on
workspace.windowActivated -
D-Bus callback — the script calls
com.logitune.app /FocusWatcher focusChanged(resourceClass, title, desktopFileName) -
Desktop file resolution — maps
resourceClassto canonical.desktopfile baseName (viaLinuxDesktopBase::resolveDesktopFile()) - kglobalaccel D-Bus — blocks global shortcuts during keystroke capture
The GNOME implementation (src/core/desktop/GnomeDesktop.h/cpp) uses a GNOME Shell extension with D-Bus callback:
-
Shell extension — a JavaScript extension installed into
~/.local/share/gnome-shell/extensions/logitune-focus@logitune.com/that hooksglobal.display.connect('notify::focus-window', ...) -
Two API versions —
v42/extension.js(imports-based, GNOME 42-44) andv45/extension.js(ES modules, GNOME 45+) -
D-Bus callback — the extension calls
com.logitune.app /FocusWatcher local.Logitune.logitune.GnomeDesktop.focusChanged(appId, title) -
App ID resolution — the extension tries
sandboxed_app_id(Flatpak), thenWindowTrackerapp ID, thenwm_class -
Shell.Eval — blocks global shortcuts during keystroke capture by toggling
Main.layoutManager._startingUp
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)
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():
- Detects the Shell major version via
org.gnome.Shell.ShellVersionD-Bus property - Selects the correct variant (
v42orv45) - Copies
metadata.jsonand the correctextension.jsto~/.local/share/gnome-shell/extensions/logitune-focus@logitune.com/ - Enables the extension via
org.gnome.Shell.Extensions.EnableExtensionD-Bus call (orgnome-extensions enableCLI 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.
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.
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.
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
| 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 |
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():
- Checking
desktopFileNameif the compositor provides it directly (KDE) - Using
sandboxed_app_idfor Flatpak apps (GNOME extension) - Searching
.desktopfiles for a matching filename component - Searching
.desktopfiles for a matchingStartupWMClass - Falling back to the raw identifier
Results are cached in m_resolveCache to avoid repeated filesystem scans.
Hyprland is a wlroots-based compositor with a powerful IPC system. Here is a complete guide:
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
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
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>(); }
Edit src/core/CMakeLists.txt:
target_sources(logitune-core PRIVATE # ... existing files ... desktop/HyprlandDesktop.cpp )
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 };
The existing MockDesktop infrastructure works for all DEs. Add a detection test:
TEST(DesktopDetectionTest, HyprlandDetected) { // Set HYPRLAND_INSTANCE_SIGNATURE and verify HyprlandDesktop is created }
These lessons apply to any new DE integration:
-
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.
-
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. -
App ID resolution varies wildly — Flatpak apps have
sandboxed_app_id, native apps havewm_class, and GNOME'sWindowTrackerprovides yet another format. The extension tries all three in order. -
Extension API versions break — GNOME 45 moved from
imports.gito ES moduleimportsyntax. Ship both variants and select at runtime based on Shell version. -
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. -
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. -
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.