Skip to main content
  • KDE

How KDE Plasma Badge Counts Work

Published 4 June 2023

Today I logged into Telegram on KDE Plasma and noticed something hard to ignore: the badge count on the taskbar icon updates beautifully. It is only a small detail, but a rather conspicuous one, because badge counts are designed to draw your attention back to unread activity instead of quietly fading into the background.

Here are a few screenshots:

|inline|border

|inline|border

Once some messages are read, Telegram updates the badge count on the taskbar icon accordingly:

|inline|border

The whole thing looked polished enough to pique my curiosity, so I decided to find out how it actually works.

The first thing I wanted to determine was which D-Bus interface was carrying the data. Desktop integration on Linux relies heavily on D-Bus, so dbus-monitor is a convenient way to inspect what is being transmitted.

signal time=1685861701.302893 sender=:1.174 -> destination=(null destination) serial=353 path=/com/canonical/unity/launcherentry/2857096580; interface=com.canonical.Unity.LauncherEntry; member=Update
   string "application://telegramdesktop.desktop"
   array [
      dict entry(
         string "count"
         variant             int64 1498
      )
      dict entry(
         string "count-visible"
         variant             boolean true
      )
   ]

That made the answer fairly clear. Telegram is using the Launcher API originally defined by Ubuntu, namely com.canonical.Unity.LauncherEntry.

The official documentation is here:

https://wiki.ubuntu.com/Unity/LauncherAPI

I also had a vague recollection of seeing this Ubuntu-defined interface mentioned before. After a bit of digging, I found an old reference from @probonopd here:

https://github.com/cyberos/cyber-dock/issues/7

plasma-desktop

Back to Plasma itself. On the KDE side, the relevant implementation lives in the task manager module, and the C++ class involved here is SmartLauncherBackend.

When this class is constructed, it calls setupUnity(), which is quite clearly responsible for listening for Update signals from com.canonical.Unity.LauncherEntry.

void Backend::setupUnity()
{
    auto sessionBus = QDBusConnection::sessionBus();

    if (!sessionBus.connect({},
                            {},
                            QStringLiteral("com.canonical.Unity.LauncherEntry"),
                            QStringLiteral("Update"),
                            this,
                            SLOT(update(QString, QMap<QString, QVariant>)))) {
        qWarning() << "failed to register Update signal";
        return;
    }

    if (!sessionBus.registerObject(QStringLiteral("/Unity"), this)) {
        qWarning() << "Failed to register unity object";
        return;
    }

    if (!sessionBus.registerService(QStringLiteral("com.canonical.Unity"))) {
        qWarning() << "Failed to register unity service";
        // In case an external process uses this (e.g. Latte Dock), let it just listen.
    }

    ......
}

Once Plasma receives the Update signal, the rest is fairly straightforward. It locates the corresponding taskbar entry through app_uri, and then updates the badge state in the UI.

If you want to inspect the implementation in more detail, the relevant file is:

plasma-desktop/applets/taskmanager/plugin/smartlaunchers/smartlauncherbackend.cpp

How does Telegram send it?

On Telegram’s side, the relevant code lives in MainWindow::updateUnityCounter():

void MainWindow::updateUnityCounter() {
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
    qApp->setBadgeNumber(Core::App().unreadBadge());
#else // Qt >= 6.6.0
    static const auto djbStringHash = [](const std::string &string) {
        uint hash = 5381;
        for (const auto &curChar : string) {
            hash = (hash << 5) + hash + curChar;
        }
        return hash;
    };

    const auto launcherUrl = Glib::ustring(
        "application://"
            + QGuiApplication::desktopFileName().toStdString());
    const auto counterSlice = std::min(Core::App().unreadBadge(), 9999);
    std::map<Glib::ustring, Glib::VariantBase> dbusUnityProperties;

    if (counterSlice > 0) {
        // According to the spec, it should be of 'x' D-Bus signature,
        // which corresponds to signed 64-bit integer
        // https://wiki.ubuntu.com/Unity/LauncherAPI#Low_level_DBus_API:_com.canonical.Unity.LauncherEntry
        dbusUnityProperties["count"] = Glib::Variant<int64>::create(
            counterSlice);
        dbusUnityProperties["count-visible"] =
            Glib::Variant<bool>::create(true);
    } else {
        dbusUnityProperties["count-visible"] =
            Glib::Variant<bool>::create(false);
    }

    try {
        const auto connection = Gio::DBus::Connection::get_sync(
            Gio::DBus::BusType::SESSION);

        connection->emit_signal(
            "/com/canonical/unity/launcherentry/"
                + std::to_string(djbStringHash(launcherUrl)),
            "com.canonical.Unity.LauncherEntry",
            "Update",
            {},
            base::Platform::MakeGlibVariant(std::tuple{
                launcherUrl,
                dbusUnityProperties,
            }));
    } catch (...) {
    }
#endif // Qt < 6.6.0
}

So the logic is actually quite simple. Telegram builds an application:// launcher URI, prepares a few badge-related properties, and emits an Update signal over the session D-Bus.

Qt

What makes this even more interesting is that newer versions of Qt already provide a higher-level API for the same feature.

According to the Qt documentation, QGuiApplication::setBadgeNumber() was introduced in Qt 6.5, which gives applications a cleaner and more portable way to expose unread counts.

/*!
    Sets the application's badge to \a number.

    Useful for providing feedback to the user about the number
    of unread messages or similar.

    The badge will be overlaid on the application's icon in the Dock
    on \macos, the home screen icon on iOS, or the task bar on Windows
    and Linux.

    If the number is outside the range supported by the platform, the
    number will be clamped to the supported range. If the number does
    not fit within the badge, the number may be visually elided.

    Setting the number to 0 will clear the badge.

    \since 6.5
    \sa applicationName
*/
void QGuiApplication::setBadgeNumber(qint64 number)
{
    QGuiApplicationPrivate::platformIntegration()->setApplicationBadge(number);
}

Qt handles this differently across platforms, which is exactly what you would expect from a mature abstraction layer. On Linux, the implementation eventually ends up in QGenericUnixServices::setApplicationBadge().

void QGenericUnixServices::setApplicationBadge(qint64 number)
{
#if QT_CONFIG(dbus)
    if (qGuiApp->desktopFileName().isEmpty()) {
        qWarning("QGuiApplication::desktopFileName() is empty");
        return;
    }

    const QString launcherUrl = QStringLiteral("application://") + qGuiApp->desktopFileName() + QStringLiteral(".desktop");
    const qint64 count = qBound(0, number, 9999);
    QVariantMap dbusUnityProperties;

    if (count > 0) {
        dbusUnityProperties[QStringLiteral("count")] = count;
        dbusUnityProperties[QStringLiteral("count-visible")] = true;
    } else {
        dbusUnityProperties[QStringLiteral("count-visible")] = false;
    }

    auto signal = QDBusMessage::createSignal(QStringLiteral("/com/canonical/unity/launcherentry/")
        + qGuiApp->applicationName(), QStringLiteral("com.canonical.Unity.LauncherEntry"), QStringLiteral("Update"));

    signal.setArguments({launcherUrl, dbusUnityProperties});

    QDBusConnection::sessionBus().send(signal);
#else
    Q_UNUSED(number)
#endif
}

So in practice, everything converges on the same underlying mechanism. Whether the badge count is sent directly by the application or through Qt’s abstraction, the message eventually travels through the same Unity Launcher API.

Chromium & Electron

This mechanism is not unique to Telegram. Chromium and Electron have also interacted with the same API:

Chromium code

Electron

Unity Badge Count implementation: https://github.com/electron/electron/issues/16001