-
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 other DEs, it might be the X11 WM_CLASS or a Wayland app_id.
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 - kglobalaccel D-Bus — blocks global shortcuts during keystroke capture
The generic fallback (src/core/desktop/GenericDesktop.h/cpp) provides a minimal implementation. It is used when no specific DE is detected.
Create src/core/desktop/GnomeDesktop.h:
#pragma once #include "interfaces/IDesktopIntegration.h" #include <QDBusInterface> #include <QTimer> namespace logitune { class GnomeDesktop : public IDesktopIntegration { Q_OBJECT public: explicit GnomeDesktop(QObject *parent = nullptr); void start() override; bool available() const override; QString desktopName() const override; QStringList detectedCompositors() const override; void blockGlobalShortcuts(bool block) override; QVariantList runningApplications() const override; private: bool m_available = false; QString m_lastAppId; QTimer *m_pollTimer = nullptr; void pollActiveWindow(); QString resolveAppId(const QString &wmClass) const; }; } // namespace logitune
Note — GNOME Wayland only. The shipped
GnomeDesktoprefuses to start on X11 sessions (XDG_SESSION_TYPE != "wayland"). The pattern below assumes Wayland; if you want X11 support, fall back to theGenericDesktoppolling loop.
The real GnomeDesktop uses an event-driven approach: a small GNOME Shell extension watches global.display::notify::focus-window and calls back into the app via D-Bus. No polling. The app auto-installs and enables the extension on first run, then registers com.logitune.app / /FocusWatcher on the session bus for the extension to call.
Key pieces (all already in-tree):
-
src/core/desktop/GnomeDesktop.{h,cpp}— the C++ integration class. SeeGnomeDesktop::start()for session-type check,ensureExtensionInstalled()for the install flow (system dir → user dir copy),detectAppIndicatorStatus()for tray-icon support detection viaorg.kde.StatusNotifierWatcher. -
data/gnome-extension/— the Shell extension source, with v42 and v45 variants for the two GNOME Shell extension API generations.
Create src/core/desktop/<DE>Desktop.cpp using the same shape:
#include "desktop/<DE>Desktop.h" #include "logging/LogManager.h" #include <QDBusConnection> #include <QDBusConnectionInterface> #include <QDBusMessage> #include <QProcessEnvironment> namespace logitune { <DE>Desktop::<DE>Desktop(QObject *parent) : LinuxDesktopBase(parent) { } void <DE>Desktop::start() { // 1. Sanity-check the session — bail if this DE isn't actually running // or if the session type is wrong for your focus API. // 2. If you need a Shell extension / KWin script / wlr protocol, // install or activate it here. // 3. Register your D-Bus callback so the DE-side agent can call // back into the app with focus events. focusChanged() is // inherited from LinuxDesktopBase and does the desktop-file // resolution + duplicate suppression. // // See GnomeDesktop::start() for a complete worked example. m_available = true; } bool GnomeDesktop::available() const { return m_available; } QString GnomeDesktop::desktopName() const { return QStringLiteral("GNOME"); } QStringList GnomeDesktop::detectedCompositors() const { QStringList compositors; const QString desktop = QProcessEnvironment::systemEnvironment() .value(QStringLiteral("XDG_CURRENT_DESKTOP")); if (desktop.contains(QStringLiteral("GNOME"), Qt::CaseInsensitive)) compositors << QStringLiteral("Mutter"); return compositors; } // focusChanged() is the D-Bus entry point the DE-side agent calls. // LinuxDesktopBase provides resolveDesktopFile() + duplicate-event // suppression, so subclasses only implement the DE-specific glue. void <DE>Desktop::focusChanged(const QString &appId, const QString &title) { QString resolved = appId; if (!appId.contains('.')) resolved = resolveDesktopFile(appId); if (resolved == m_lastAppId) return; m_lastAppId = resolved; emit activeWindowChanged(resolved, title); } // runningApplications() is inherited from LinuxDesktopBase — do not // reimplement unless your DE has a faster dedicated API. } // namespace logitune
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"
Introspect[Shell.Introspect<br/>GetWindows]
Extension[GNOME Shell Extension<br/>global.display.connect<br/>'notify::focus-window']
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 45+ | Shell.Introspect polling or Extension | ~500ms (poll) / <10ms (extension) | Medium / High |
| 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) |
app-id (from Wayland) |
org.gnome.Nautilus |
| Hyprland | class |
firefox |
| X11 |
WM_CLASS (instance, class) |
Navigator, firefox
|
Logitune normalizes all of these to a .desktop file baseName. The resolveDesktopFile() method on LinuxDesktopBase (inherited by both KDeDesktop and GnomeDesktop) does this by:
- Checking
desktopFileNameif the compositor provides it directly - Searching
.desktopfiles for a matching filename component - Searching
.desktopfiles for a matchingStartupWMClass - Falling back to the raw identifier
This logic lives in LinuxDesktopBase so every Linux DE implementation inherits it for free — runningApplications() and desktopDirs() are in the same base class.
Edit src/app/AppController.cpp to select the right desktop integration:
AppController::AppController(IDesktopIntegration *desktop, IInputInjector *injector, QObject *parent) : QObject(parent) , m_deviceManager(&m_registry) , m_actionExecutor(nullptr) { if (desktop) { m_desktop = desktop; } else { // Detect desktop environment and create appropriate integration 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>(); } m_desktop = m_ownedDesktop.get(); } // ... rest unchanged }
Edit src/core/CMakeLists.txt:
target_sources(logitune-core PRIVATE # ... existing files ... desktop/KDeDesktop.cpp desktop/GenericDesktop.cpp desktop/GnomeDesktop.cpp # Add this )
The mock infrastructure is already DE-agnostic. MockDesktop implements IDesktopIntegration and provides simulateFocus() to trigger focus changes in tests. No DE-specific test infrastructure is needed.
However, you should add a test for the detection logic:
TEST(DesktopDetectionTest, GnomeDetected) { // Set XDG_CURRENT_DESKTOP to GNOME and verify GnomeDesktop is created // (This may require environment variable manipulation) }
If you are adding a second DE implementation, consider extracting these shared utilities:
-
resolveDesktopFile()—.desktopfile lookup by resourceClass/StartupWMClass -
runningApplications()— scanning.desktopfiles for GUI applications -
Desktop directory list —
/usr/share/applications,~/.local/share/applications, Flatpak/Snap paths, etc.
These could live in a DesktopUtils static class or be moved to the GenericDesktop base class.
Hyprland is a wlroots-based compositor with a powerful IPC system. Here is a brief outline:
-
Class:
HyprlandDesktopextendingIDesktopIntegration -
Focus tracking: Subscribe to Hyprland IPC socket (
$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock) foractivewindow>>events -
Window identity: Hyprland reports the
classproperty, equivalent to X11WM_CLASS. Run throughresolveDesktopFile(). -
blockGlobalShortcuts: Use
hyprctl keyword bindto temporarily unbind all shortcuts, or usehyprctl dispatch submapto switch to an empty submap -
Detection: Check for
HYPRLAND_INSTANCE_SIGNATUREenvironment variable
The Hyprland IPC approach would be event-driven (no polling), making it more efficient than the GNOME polling fallback.