diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 184d9370..0f707aac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -75,6 +75,7 @@ jobs:
"build-essential"
"cmake"
"imagemagick"
+ "libnotify-dev"
"ninja-build"
"xvfb"
)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c7a2b4f4..76f5c051 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -75,11 +75,12 @@ else()
find_library(COCOA Cocoa REQUIRED)
list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m")
else()
- find_package(Qt6 COMPONENTS Widgets DBus Svg)
+ find_package(LibNotify REQUIRED)
+ find_package(Qt6 COMPONENTS Widgets Svg)
if(Qt6_FOUND)
set(TRAY_QT_VERSION 6)
else()
- find_package(Qt5 REQUIRED COMPONENTS Widgets DBus Svg)
+ find_package(Qt5 REQUIRED COMPONENTS Widgets Svg)
set(TRAY_QT_VERSION 5)
endif()
set(TRAY_QT_VERSION # cmake-lint: disable=C0103
@@ -87,7 +88,10 @@ else()
CACHE INTERNAL "Qt major version selected by tray"
)
set(CMAKE_AUTOMOC ON)
- list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_linux.cpp")
+ list(APPEND TRAY_SOURCES
+ "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_linux.cpp"
+ "${CMAKE_CURRENT_SOURCE_DIR}/src/QtTrayMenu.cpp"
+ )
endif()
endif()
endif()
@@ -110,10 +114,15 @@ else()
list(APPEND TRAY_EXTERNAL_LIBRARIES ${COCOA})
else()
if(TRAY_QT_VERSION EQUAL 6)
- list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus Qt6::Svg)
+ list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::Svg)
else()
- list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus Qt5::Svg)
+ list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::Svg)
endif()
+ list(APPEND TRAY_LIBNOTIFY=1)
+ list(APPEND TRAY_EXTERNAL_LIBRARIES ${LIBNOTIFY_LIBRARIES})
+
+ include_directories(SYSTEM ${LIBNOTIFY_INCLUDE_DIRS})
+ link_directories(${LIBNOTIFY_LIBRARY_DIRS})
endif()
endif()
endif()
diff --git a/README.md b/README.md
index 374478a5..399abc2a 100644
--- a/README.md
+++ b/README.md
@@ -43,36 +43,35 @@ This fork adds the following features:
### Linux Dependencies
-Install either Qt6 _or_ Qt5 development packages. The Linux backend requires
-Qt Widgets, DBus, and Svg modules.
+Install either Qt6 _or_ Qt5 as well as libnotify development packages. The Linux backend requires libnotify and Qt Widgets+Svg modules.
- Arch
```bash
# Qt6
- sudo pacman -S qt6-base qt6-svg
+ sudo pacman -S qt6-base qt6-svg libnotify
# Qt5
- sudo pacman -S qt5-base qt5-svg
+ sudo pacman -S qt5-base qt5-svg libnotify
```
- Debian/Ubuntu
```bash
# Qt6
- sudo apt install qt6-base-dev qt6-svg-dev
+ sudo apt install qt6-base-dev qt6-svg-dev libnotify-dev
# Qt5
- sudo apt install qtbase5-dev libqt5svg5-dev
+ sudo apt install qtbase5-dev libqt5svg5-dev libnotify-dev
```
- Fedora
```bash
# Qt6
- sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel
+ sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel libnotify-devel
# Qt5
- sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel
+ sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel libnotify-devel
```
diff --git a/cmake/FindLibNotify.cmake b/cmake/FindLibNotify.cmake
new file mode 100644
index 00000000..e76b199b
--- /dev/null
+++ b/cmake/FindLibNotify.cmake
@@ -0,0 +1,55 @@
+# - Try to find LibNotify
+# This module defines the following variables:
+#
+# LIBNOTIFY_FOUND - LibNotify was found
+# LIBNOTIFY_INCLUDE_DIRS - the LibNotify include directories
+# LIBNOTIFY_LIBRARIES - link these to use LibNotify
+#
+# Copyright (C) 2012 Raphael Kubo da Costa
+# Copyright (C) 2014 Collabora Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND ITS CONTRIBUTORS ``AS
+# IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR ITS
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+find_package(PkgConfig)
+pkg_check_modules(LIBNOTIFY QUIET libnotify)
+
+find_path(LIBNOTIFY_INCLUDE_DIRS
+ NAMES notify.h
+ HINTS ${LIBNOTIFY_INCLUDEDIR}
+ ${LIBNOTIFY_INCLUDE_DIRS}
+ PATH_SUFFIXES libnotify
+)
+
+find_library(LIBNOTIFY_LIBRARIES
+ NAMES notify
+ HINTS ${LIBNOTIFY_LIBDIR}
+ ${LIBNOTIFY_LIBRARY_DIRS}
+)
+
+include(FindPackageHandleStandardArgs)
+FIND_PACKAGE_HANDLE_STANDARD_ARGS(LibNotify REQUIRED_VARS LIBNOTIFY_INCLUDE_DIRS LIBNOTIFY_LIBRARIES
+ VERSION_VAR LIBNOTIFY_VERSION)
+
+mark_as_advanced(
+ LIBNOTIFY_INCLUDE_DIRS
+ LIBNOTIFY_LIBRARIES
+)
diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp
new file mode 100644
index 00000000..78166545
--- /dev/null
+++ b/src/QtTrayMenu.cpp
@@ -0,0 +1,347 @@
+/**
+ * @file src/QtTrayMenu.cpp
+ * @brief Definitions for Qt tray menu implemenation
+ */
+// standard includes
+#include
+
+// qt includes
+#include
+#include
+#include
+#include
+#include
+
+// local includes
+#include "QtTrayMenu.h"
+
+namespace {
+ int defaultArgc = 1; // NOSONAR(cpp:S5421): This is required for QApplication's argc/argv constructor
+ char defaultArgv0[] = "TrayMenuApp"; // NOSONAR(cpp:S5421): This is required for QApplication's argc/argv constructor
+ char *defaultArgv[] = {defaultArgv0, nullptr}; // NOSONAR(cpp:S5421,cpp:S5954): This is required for QApplication's argc/argv constructor
+} // namespace
+
+QtTrayMenu::QtTrayMenu(QObject *parent, const bool debug):
+ QtTrayMenu(-1, nullptr, parent, debug) {
+ };
+
+QtTrayMenu::QtTrayMenu(int argc, char **argv, QObject *parent, const bool debug):
+ QObject(parent) {
+ if (qgetenv("WAYLAND_DISPLAY").isEmpty() && qgetenv("DISPLAY").isEmpty()) {
+ // Force fallback to QT platform minimal if no (WAYLAND_)DISPLAY was found
+ qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("minimal"));
+ qWarning("QtTrayMenu: no reachable WAYLAND_DISPLAY or DISPLAY endpoint, forcing QT_QPA_PLATFORM=minimal");
+ }
+ if (QApplication::instance()) {
+ app = dynamic_cast(QApplication::instance());
+ if (!app) {
+ qDebug() << "QCoreApplication is not a QApplication, please contact support.";
+ }
+ } else {
+ // Note: The following is ugly but QApplication requires an argv containing the application name.
+ // We might not have access to the real argc/argv here due to being called/pulled as a dependency.
+ if (argc < 0 && argv == nullptr) {
+ app = new QApplication(defaultArgc, defaultArgv); // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ } else {
+ app = new QApplication(argc, argv); // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ }
+ }
+ if (debug) {
+ app->installEventFilter(this);
+ }
+}
+
+QtTrayMenu::~QtTrayMenu() {
+ // Cleanup app only if it was created within this class
+ if (app && app != QApplication::instance()) {
+ // Quit QApplication
+ QApplication::quit();
+ // Delete app and clear references
+ delete app; // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ app = nullptr; // Set to nullptr after deletion
+ }
+}
+
+int QtTrayMenu::init(struct tray *tray, const bool notification) {
+ if (trayIcon) {
+ // Running tray is initialized again. Fail with error.
+ return -1;
+ }
+ if (!QSystemTrayIcon::isSystemTrayAvailable()) {
+ // Qt does not support system tray. Fail with error.
+ return -1;
+ }
+
+ this->trayStruct = tray;
+ this->running = true;
+
+ if (QApplication::applicationName().isEmpty() || QApplication::applicationName() == "TrayMenuApp") {
+ QApplication::setApplicationName(tray->tooltip);
+ }
+
+ // Create tray icon
+ trayIcon = new QSystemTrayIcon(lookupIcon(tray->icon), this);
+ trayIcon->setToolTip(QString::fromUtf8(tray->tooltip));
+
+ connect(trayIcon, &QSystemTrayIcon::activated, this, &QtTrayMenu::onTrayActivated);
+ connect(trayIcon, &QSystemTrayIcon::messageClicked, this, &QtTrayMenu::onMessageClicked);
+ connect(this, &QtTrayMenu::update, this, &QtTrayMenu::onUpdate);
+ connect(this, &QtTrayMenu::exit, this, &QtTrayMenu::onExitRequested);
+ connect(this, &QtTrayMenu::showMenu, this, &QtTrayMenu::onShowMenu);
+
+ updateMenu(tray->menu);
+
+ trayIcon->setContextMenu(trayTopMenu);
+ trayIcon->show();
+
+ if (notification) {
+ createNotification();
+ }
+
+ return 0;
+}
+
+void QtTrayMenu::onUpdate(struct tray *tray, const bool notify) {
+ if (!trayIcon) {
+ return;
+ }
+ this->trayStruct = tray;
+ if (const auto newIcon = QIcon(trayStruct->icon); !newIcon.isNull()) {
+ trayIcon->setIcon(newIcon);
+ }
+ trayIcon->setToolTip(QString::fromUtf8(trayStruct->tooltip));
+
+ updateMenu(trayStruct->menu);
+ if (notify) {
+ createNotification();
+ }
+}
+
+int QtTrayMenu::loop(int blocking) const {
+ if (!running) {
+ return -1;
+ }
+ if (!app || QApplication::closingDown()) {
+ qDebug() << "Application is not in a valid state or is closing down.";
+ return -1;
+ }
+ if (blocking) {
+ QApplication::exec();
+ return -1;
+ } else {
+ QApplication::processEvents();
+ return 0;
+ }
+}
+
+void QtTrayMenu::onExitRequested() {
+ // Mark as no longer running
+ running = false;
+ // Remove tray menu references
+ if (trayTopMenu) {
+ trayTopMenu->hide();
+ if (trayIcon) {
+ trayIcon->setContextMenu(nullptr);
+ }
+ delete trayTopMenu; // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ trayTopMenu = nullptr; // Set to nullptr after deletion
+ }
+ // Remove tray icon references;
+ if (trayIcon) {
+ trayIcon->hide();
+ delete trayIcon; // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ trayIcon = nullptr; // Set to nullptr after deletion
+ }
+ // Unset tray structure
+ trayStruct = nullptr;
+}
+
+void QtTrayMenu::updateMenu(struct tray_menu *items) {
+ // Create and setup new tray menu instance
+ const auto newTrayTopMenu = new QMenu(); // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ trayIcon->setContextMenu(newTrayTopMenu);
+ // Fill new tray menu instance
+ createMenu(items, newTrayTopMenu);
+ // Clear old, unused trayTopMenu instance
+ if (trayTopMenu != nullptr) {
+ trayTopMenu->clear(); // Remove all actions
+ delete trayTopMenu; // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ }
+ // Store reference for cleanup
+ trayTopMenu = newTrayTopMenu;
+}
+
+void QtTrayMenu::createMenu(struct tray_menu *items, QMenu *menu) {
+ while (items && items->text) {
+ if (strcmp(items->text, "-") == 0) {
+ menu->addSeparator();
+ } else {
+ auto *action = new QAction(QString::fromUtf8(items->text), menu); // NOSONAR(cpp:S5025) - Qt has its own integrated memory management
+ action->setDisabled(items->disabled == 1);
+ action->setCheckable(items->checkbox == 1);
+ action->setChecked(items->checked == 1);
+ action->setProperty("tray_menu_item", QVariant::fromValue((void *) items));
+ connect(action, &QAction::triggered, this, &QtTrayMenu::onMenuItemTriggered);
+ if (items->submenu) {
+ const auto submenu = new QMenu(menu);
+ createMenu(items->submenu, submenu);
+ action->setMenu(submenu);
+ }
+ menu->addAction(action);
+ }
+ items++;
+ }
+}
+
+void QtTrayMenu::createNotification() {
+ if (trayStruct && trayStruct->notification_title && trayStruct->notification_text) {
+ const auto title = QString::fromUtf8(trayStruct->notification_title);
+ const auto text = QString::fromUtf8(trayStruct->notification_text);
+ if (trayStruct->notification_icon) {
+ showMessage(title, text, trayStruct->notification_icon, trayStruct->notification_cb);
+ } else {
+ showMessage(title, text, trayStruct->notification_cb);
+ }
+ }
+}
+
+QIcon QtTrayMenu::lookupIcon(QString icon) const {
+ // Find icon for tray
+ if (std::filesystem::exists(icon.toStdString())) {
+ if (auto result = QIcon(icon); !result.isNull()) {
+ return result;
+ }
+ }
+ if (auto result = QIcon::fromTheme(icon); !result.isNull()) {
+ return result;
+ }
+ return QApplication::style()->standardIcon(QStyle::SP_ComputerIcon);
+}
+
+bool QtTrayMenu::eventFilter(QObject *watched, QEvent *event) {
+ qDebug() << "Event Type:" << event->type();
+ return QObject::eventFilter(watched, event);
+}
+
+void QtTrayMenu::onTrayActivated(QSystemTrayIcon::ActivationReason reason) {
+ if (reason != QSystemTrayIcon::Trigger) {
+ return;
+ }
+ if (trayStruct && trayStruct->cb) {
+ trayStruct->cb(trayStruct);
+ } else {
+ showMenu();
+ }
+}
+
+void QtTrayMenu::onMenuItemTriggered() {
+ auto *action = qobject_cast(sender());
+ struct tray_menu *menuItem = getTrayMenuItem(action);
+
+ if (menuItem && menuItem->cb) {
+ menuItem->cb(menuItem);
+ }
+}
+
+struct tray_menu *QtTrayMenu::getTrayMenuItem(QAction *action) { // NOSONAR(cpp:S995) - Use as defined in function interface
+ return static_cast(action->property("tray_menu_item").value());
+}
+
+void QtTrayMenu::onMessageClicked() const {
+ if (notificationCallback != nullptr) {
+ notificationCallback();
+ }
+}
+
+void QtTrayMenu::configureAppMetadata(const QString &appName, const QString &appDisplayName, const QString &desktopName) const {
+ const QString effective_name = !appName.isEmpty() ? appName : QStringLiteral("tray");
+ if (QApplication::applicationName().isEmpty()) {
+ QApplication::setApplicationName(effective_name);
+ }
+
+ if (QApplication::applicationDisplayName().isEmpty()) {
+ if (!appDisplayName.isEmpty()) {
+ QApplication::setApplicationDisplayName(appDisplayName);
+ } else {
+ const QString display_name =
+ (trayStruct && trayStruct->tooltip) ? QString::fromUtf8(trayStruct->tooltip) : effective_name;
+ QApplication::setApplicationDisplayName(display_name);
+ }
+ }
+
+ if (!QApplication::desktopFileName().isEmpty()) {
+ return;
+ }
+
+ if (!desktopName.isEmpty()) {
+ QApplication::setDesktopFileName(desktopName);
+ return;
+ }
+
+ QString desktop_name = QApplication::applicationName();
+ if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
+ desktop_name += QStringLiteral(".desktop");
+ }
+ QApplication::setDesktopFileName(desktop_name);
+}
+
+void QtTrayMenu::onShowMenu() const {
+ if (!trayIcon) {
+ return;
+ }
+ if (QMenu *menu = trayIcon->contextMenu(); menu != nullptr) {
+ // Due to QTBUG-139921 this is currently not working on Linux/Wayland
+ // with Qt-6.9+ unless menu has a transient parent (which we do not have here).
+ menu->popup(QCursor::pos());
+ }
+}
+
+bool QtTrayMenu::supportsMessages() {
+ return QSystemTrayIcon::supportsMessages();
+}
+
+void QtTrayMenu::showMessage(const QString &title, const QString &msg, std::function callback, const QSystemTrayIcon::MessageIcon icon, const int msecs) {
+ if (!trayIcon) {
+ return;
+ }
+ if (QSystemTrayIcon::supportsMessages()) {
+ notificationCallback = std::move(callback);
+ emit trayIcon->showMessage(title, msg, icon, msecs);
+ }
+}
+
+void QtTrayMenu::showMessage(const QString &title, const QString &msg, const QString &iconPath, std::function callback, const int msecs) {
+ if (!trayIcon) {
+ return;
+ }
+ if (QSystemTrayIcon::supportsMessages()) {
+ notificationCallback = std::move(callback);
+ emit trayIcon->showMessage(title, msg, lookupIcon(iconPath), msecs);
+ }
+}
+
+void QtTrayMenu::clickMenuItem(int index) const {
+ if (!trayIcon) {
+ return;
+ }
+ const QMenu *menu = trayIcon->contextMenu();
+ if (!menu) {
+ return;
+ }
+ const QList actions = menu->actions();
+ if (index < 0 || index >= actions.size()) {
+ return;
+ }
+ QAction *action = actions.at(index);
+ if (!action || action->isSeparator() || action->menu() != nullptr || !action->isEnabled()) {
+ return;
+ }
+ emit action->trigger();
+}
+
+void QtTrayMenu::clickMessage() const {
+ if (!trayIcon) {
+ return;
+ }
+ emit trayIcon->messageClicked();
+}
diff --git a/src/QtTrayMenu.h b/src/QtTrayMenu.h
new file mode 100644
index 00000000..38a8bd40
--- /dev/null
+++ b/src/QtTrayMenu.h
@@ -0,0 +1,149 @@
+/**
+ * @file src/QtTrayMenu.h
+ * @brief Declarations for Qt tray menu implemenation
+ */
+#ifndef TRAYMENU_H
+#define TRAYMENU_H
+
+// qt includes
+#include
+#include
+#include
+#include
+
+// local includes
+#include "tray.h"
+
+/**
+ * @brief Wrapper class for platfrom-independent Qt-based tray menu.
+ */
+class QtTrayMenu: public QObject {
+ Q_OBJECT
+
+public:
+ /**
+ * @brief Create a QtTrayMenu instance
+ * @param parent optional parent Qt object
+ * @param debug if true isntall eventFilter for debug logging
+ */
+ explicit QtTrayMenu(QObject *parent = nullptr, bool debug = false);
+
+ /**
+ * @brief Create a QtTrayMenu instance
+ * @param argc argument count for QApplication (if that needs to be created)
+ * @param argv argument list for QApplication (if that needs to be created)
+ * @param parent optional parent Qt object
+ * @param debug if true isntall eventFilter for debug logging
+ */
+ explicit QtTrayMenu(int argc, char **argv, QObject *parent = nullptr, bool debug = false);
+
+ ~QtTrayMenu() override;
+ /**
+ * @brief QObject override to filter events on watched object
+ * @param watched object watched for event
+ * @param event event on object
+ * @return true if event should be filtered out and not be processed further
+ * @see https://doc.qt.io/qt-6/qobject.html#eventFilter
+ */
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
+ /**
+ * @brief Initialize tray with given structure
+ * @param tray struct containing tray configuration
+ * @param notification fire tray notification if true
+ * @return 0 on success
+ */
+ int init(struct tray *tray, bool notification = true);
+
+ /**
+ * @brief Process tray loop events
+ * @param blocking if true the function call will block until QtTrayMenu exits
+ * @return 0 on successful processing if non-blocking, -1 otherwise
+ */
+ int loop(int blocking) const;
+
+ /**
+ * @brief Configure metadata for QApplication
+ * @param appName the applications name
+ * @param appDisplayName the applications display name
+ * @param desktopName the applications desktop file name
+ */
+ void configureAppMetadata(const QString &appName, const QString &appDisplayName, const QString &desktopName) const;
+
+ /**
+ * @brief Show tray message popup
+ * @param title popup title
+ * @param msg popup message
+ * @param callback tray message callback function
+ * @param icon popup icon
+ * @param msecs popup display duration
+ */
+ void showMessage(const QString &title, const QString &msg, std::function callback = nullptr, QSystemTrayIcon::MessageIcon icon = QSystemTrayIcon::Information, int msecs = 10000);
+
+ /**
+ * @brief Show tray message popup
+ * @param title popup title
+ * @param msg popup message
+ * @param callback tray message callback function
+ * @param iconPath popup icon file path
+ * @param msecs popup display duration
+ */
+ void showMessage(const QString &title, const QString &msg, const QString &iconPath, std::function callback = nullptr, int msecs = 10000);
+
+ /**
+ * @brief Simulate click on menu item
+ * @param index Menu item index to simulate click on
+ */
+ void clickMenuItem(int index) const;
+
+ /**
+ * @brief Simulate click on popup message
+ */
+ void clickMessage() const;
+
+ /**
+ * @brief Check if QtTrayMenu supports messages
+ * @return true if messages can be shown
+ */
+ static bool supportsMessages();
+
+signals:
+ /**
+ * @brief Exit tray and cleanup resources
+ */
+ void exit();
+
+ /**
+ * @brief Update tray configuration
+ * @param tray struct containing tray configuration
+ * @param notify fire tray notification if true
+ */
+ void update(struct tray *tray, bool notify = true);
+
+ /**
+ * @brief Show tray context menu
+ */
+ void showMenu() const;
+
+private:
+ void createMenu(struct tray_menu *items, QMenu *menu);
+ void createNotification();
+ void updateMenu(struct tray_menu *items);
+ QIcon lookupIcon(QString icon) const;
+ QApplication *app = nullptr;
+ QSystemTrayIcon *trayIcon = nullptr;
+ QMenu *trayTopMenu = nullptr;
+ struct tray *trayStruct = nullptr;
+ bool running = false;
+ struct tray_menu *getTrayMenuItem(QAction *action);
+ std::function notificationCallback = nullptr;
+
+private slots:
+ void onExitRequested();
+ void onMessageClicked() const;
+ void onMenuItemTriggered();
+ void onTrayActivated(QSystemTrayIcon::ActivationReason reason);
+ void onShowMenu() const;
+ void onUpdate(struct tray *tray, bool notify);
+};
+#endif // TRAYMENU_H
diff --git a/src/tray.h b/src/tray.h
index 513ffc30..e13026a6 100644
--- a/src/tray.h
+++ b/src/tray.h
@@ -28,6 +28,7 @@ extern "C" {
const char *notification_text; ///< Text to display in the notification.
const char *notification_title; ///< Title to display in the notification.
void (*notification_cb)(); ///< Callback to invoke when the notification is clicked.
+ void (*cb)(struct tray *); ///< Callback for left click, leave null to just open menu
struct tray_menu *menu; ///< Menu items.
const int iconPathCount; ///< Number of icon paths.
const char *allIconPaths[]; ///< Array of icon paths.
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index a9facdff..3cdb7c4f 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -6,262 +6,197 @@
#include
#include
#include
+#include
#include
+#include
+#include
+#include
#include
+// lib includes
+#include
+
// local includes
+#include "QtTrayMenu.h"
#include "tray.h"
-// Qt includes
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-/**
- * @brief Handles D-Bus notification action signals.
- *
- * Receives the org.freedesktop.Notifications ActionInvoked signal so that
- * notification click callbacks work when notifications are sent via D-Bus
- * rather than Qt's built-in balloon (QSystemTrayIcon::showMessage).
- *
- * Defined in tray_linux.cpp rather than a separate header to keep the moc
- * output self-contained via the inline `#include "tray_linux.moc"` at the
- * bottom of this file. Any CMake target that compiles tray_linux.cpp with
- * AUTOMOC ON will automatically generate and inline the moc output.
- */
-class TrayNotificationHandler: public QObject {
- Q_OBJECT
-
-public:
- uint notification_id = 0; ///< ID of the most recently sent D-Bus notification.
- void (*cb)() = nullptr; ///< Callback to invoke when the notification is activated.
-
-public slots:
+namespace tray_linux {
+ /**
+ * Notification element struct
+ */
+ struct notification_data {
+ /**
+ * @brief Notification object
+ */
+ NotifyNotification *obj = nullptr;
+ /**
+ * @brief Notification callback
+ */
+ void (*cb)() = nullptr;
+ /**
+ * @brief Notification shown indicator
+ */
+ bool shown = false;
+ /**
+ * @brief Notification mutex for async thread synchronization
+ */
+ std::mutex mutex;
+ };
/**
- * @brief Invoked when a D-Bus notification action is triggered.
- * @param id The notification ID.
- * @param action_key The action key that was triggered.
+ * Currently shown notifications
*/
- void onActionInvoked(uint id, const QString &action_key) const {
- if (id == notification_id && cb != nullptr && action_key == QLatin1String("default")) {
- cb();
- }
- }
-};
+ std::vector> notifications; // NOSONAR(cpp:S5421) - mutable state, not const
+ /**
+ * Lock for currently shown notifications vector
+ */
+ std::mutex notifications_mutex; // NOSONAR(cpp:S5421) - mutable state, not const
-namespace {
- std::unique_ptr g_app; // NOSONAR(cpp:S5421) - mutable state, not const
- QSystemTrayIcon *g_tray_icon = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
- TrayNotificationHandler *g_notification_handler = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
- int g_loop_result = 0; // NOSONAR(cpp:S5421) - mutable state, not const
- bool g_app_owned = false; // NOSONAR(cpp:S5421) - mutable state, not const
- std::atomic g_exit_pending {false}; // NOSONAR(cpp:S5421) - written from any thread, read from tray_loop
- uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup
- std::uint64_t g_notification_generation = 0; // NOSONAR(cpp:S5421) - invalidates stale async Notify replies
- std::uint64_t g_notification_active_generation = 0; // NOSONAR(cpp:S5421) - generation currently allowed to own notification_id
- void (*g_log_cb)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
- QString g_app_name; // NOSONAR(cpp:S5421) - set via tray_set_app_info before tray_init
- QString g_app_display_name; // NOSONAR(cpp:S5421) - set via tray_set_app_info before tray_init
- QString g_desktop_name; // NOSONAR(cpp:S5421) - set via tray_set_app_info before tray_init
+ /**
+ * QtTrayMenu instance
+ */
+ std::unique_ptr qt_tray_menu = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
+ /**
+ * Logging callback for qt_message_handler
+ */
+ void (*log_callback)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
/**
- * @brief Invoke @p f on the Qt application's thread.
- *
- * When the caller is already on the Qt thread (or there is no QApplication),
- * @p f is called directly. When called from any other thread,
- * QMetaObject::invokeMethod with Qt::BlockingQueuedConnection is used so that
- * the caller blocks until the Qt thread finishes executing @p f. This ensures
- * all Qt GUI operations happen on the thread that owns the QApplication,
- * preventing cross-thread Qt object access that causes D-Bus relay warnings.
- *
- * Requires Qt 5.10+.
- *
- * @param f Callable to execute on the Qt thread.
+ * @brief Show notification asynchronously with timeout to avoid Dbus lockups
+ * @param notification - Tray notification to show
+ * @param timeout - optional timeout for async run in ms (default: 1000)
+ * @return true if notification was successfully shown
*/
- template
- void run_on_qt_thread(Func f) {
- QCoreApplication *app = QCoreApplication::instance();
- if (app == nullptr || QThread::currentThread() == app->thread()) {
- f();
- return;
+ bool async_tray_notification_show_(const std::shared_ptr ¬ification, int timeout = 1000) {
+ std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards
+ std::scoped_lock lock(notification->mutex);
+ if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_show(notification->obj, nullptr)) {
+ notification->shown = true;
+ }
+ });
+ t.detach(); // NOSONAR(cpp:S5962)
+ while (!notification->shown && timeout > 0) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
+ timeout -= 10;
}
- QMetaObject::invokeMethod(app, std::move(f), Qt::BlockingQueuedConnection);
+ return notification->shown;
}
- bool is_wayland_session() {
- if (const QString platform = QGuiApplication::platformName().toLower();
- platform.contains(QStringLiteral("wayland"))) {
- return true;
+ /**
+ * @brief Acknowledge notification asynchronously with timeout to avoid Dbus lockups
+ * @param notification - Tray notification to close
+ * @param timeout - optional timeout for async run in ms (default: 1000)
+ * @return true if notification was successfully closed
+ */
+ bool async_tray_notification_acknowledge_(const std::shared_ptr ¬ification, int timeout = 1000) {
+ std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards
+ std::scoped_lock lock(notification->mutex);
+ if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_close(notification->obj, nullptr)) {
+ notification->shown = false;
+ g_object_unref(G_OBJECT(notification->obj));
+ notification->obj = nullptr;
+ notification->cb = nullptr;
+ }
+ });
+ t.detach(); // NOSONAR(cpp:S5962)
+ while (notification->obj != nullptr && timeout > 0) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
+ timeout -= 10;
}
- return !qgetenv("WAYLAND_DISPLAY").isEmpty();
+ return notification->obj == nullptr;
}
- bool has_wayland_display_endpoint() {
- const QByteArray wayland_display = qgetenv("WAYLAND_DISPLAY");
- if (wayland_display.isEmpty()) {
- return false;
- }
-
- const QString display_name = QString::fromLocal8Bit(wayland_display).trimmed();
- if (display_name.isEmpty()) {
- return false;
- }
-
- if (const QFileInfo direct_path(display_name); direct_path.exists()) {
- return true;
- }
-
- const QByteArray runtime_dir = qgetenv("XDG_RUNTIME_DIR");
- if (runtime_dir.isEmpty()) {
- return false;
+ /**
+ * @brief Acknowledge/click current notifications
+ * @param run_callback - Run notification callback when acknowledging
+ */
+ void acknowledge_notifications(bool run_callback = false) {
+ if (notify_is_initted()) {
+ std::scoped_lock lock(notifications_mutex);
+ for (auto notification : notifications) {
+ if (run_callback && notification->cb != nullptr) {
+ notification->cb();
+ }
+ async_tray_notification_acknowledge_(notification);
+ }
+ notifications.clear();
+ } else if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) {
+ qt_tray_menu->clickMessage();
}
-
- const QString socket_path = QDir(QString::fromLocal8Bit(runtime_dir)).filePath(display_name);
- return QFileInfo::exists(socket_path);
}
- QString discover_wayland_display_name() {
- if (!qEnvironmentVariableIsEmpty("WAYLAND_DISPLAY")) {
- return QString();
- }
-
- const QByteArray runtime_dir_env = qgetenv("XDG_RUNTIME_DIR");
- if (runtime_dir_env.isEmpty()) {
- return QString();
- }
-
- const QString runtime_dir_path = QString::fromLocal8Bit(runtime_dir_env).trimmed();
- if (runtime_dir_path.isEmpty()) {
- return QString();
- }
-
- const QDir runtime_dir(runtime_dir_path);
- if (!runtime_dir.exists()) {
- return QString();
- }
-
- const QStringList entries = runtime_dir.entryList(
- QStringList() << QStringLiteral("wayland-*"),
- QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System,
- QDir::Name
- );
- if (entries.isEmpty()) {
- return QString();
+ /**
+ * @brief Show tray notification via desktop-independent interface
+ * @param tray Tray structure containing notification information
+ */
+ void notify(struct tray *tray) {
+ if (tray->notification_text == nullptr || std::string(tray->notification_text).empty()) {
+ return;
}
-
- QString selected;
- for (const QString &entry : entries) {
- if (const QString candidate_path = runtime_dir.filePath(entry); !QFileInfo::exists(candidate_path)) {
- continue;
+ // Try to notify using libnotify
+ if (notify_is_initted()) {
+ std::scoped_lock lock(notifications_mutex);
+ if (!notifications.empty()) {
+ acknowledge_notifications();
}
- if (entry == QStringLiteral("wayland-0")) {
- return entry;
+ std::filesystem::path notification_icon = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon;
+ if (std::filesystem::exists(notification_icon)) {
+ // Use absolute path for filesystem icon files, not a relative one
+ notification_icon = std::filesystem::absolute(notification_icon);
}
- if (selected.isEmpty()) {
- selected = entry;
+ auto notification = std::make_shared();
+ notification->obj = notify_notification_new(tray->notification_title, tray->notification_text, notification_icon.c_str());
+ if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj)) {
+ if (tray->notification_cb != nullptr) {
+ notification->cb = tray->notification_cb;
+ notify_notification_add_action(notification->obj, "default", "Default", NOTIFY_ACTION_CALLBACK(tray->notification_cb), nullptr, nullptr);
+ }
+ notifications.emplace_back(notification);
+ if (async_tray_notification_show_(notification)) {
+ return;
+ }
}
}
- return selected;
- }
-
- bool try_autodiscover_wayland_display() {
- const QString discovered = discover_wayland_display_name();
- if (discovered.isEmpty()) {
- return false;
+ // Fallback to QtTrayMenu notification
+ if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) {
+ qt_tray_menu->showMessage(tray->notification_title, tray->notification_text, tray->notification_icon, tray->notification_cb);
}
- return qputenv("WAYLAND_DISPLAY", discovered.toLocal8Bit());
}
- bool has_x11_display_endpoint() {
- const QByteArray display_env = qgetenv("DISPLAY");
- if (display_env.isEmpty()) {
- return false;
- }
-
- const QString display = QString::fromLocal8Bit(display_env).trimmed();
- if (display.isEmpty()) {
- return false;
- }
-
- if (display.startsWith('/')) {
- return QFileInfo::exists(display);
- }
-
- if (!display.startsWith(':')) {
- // Remote/TCP displays are not locally discoverable; treat as potentially usable.
- return true;
- }
-
- int digit_end = 1;
- while (digit_end < display.size() && display.at(digit_end).isDigit()) {
- digit_end++;
- }
- if (digit_end == 1) {
- return true;
- }
-
- bool ok = false;
- const int display_number = display.mid(1, digit_end - 1).toInt(&ok);
- if (!ok) {
- return true;
+ /**
+ * @brief Initialize notifications
+ * @param app_name application name for notifications
+ * @return true if successful
+ */
+ bool init_notify(const char *app_name) {
+ if (!notify_is_initted()) {
+ if (!notifications.empty()) {
+ acknowledge_notifications();
+ }
+ return notify_init(app_name);
}
-
- const QString socket_path = QStringLiteral("/tmp/.X11-unix/X%1").arg(display_number);
- return QFileInfo::exists(socket_path);
+ return true; // Already initialized, so init was successful
}
- bool should_force_headless_qpa_fallback() {
- if (!qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) {
- return false;
+ /**
+ * @brief Uninitialize notifications
+ */
+ void uninit_notify() {
+ if (notify_is_initted()) {
+ acknowledge_notifications();
+ notify_uninit();
}
- return !has_wayland_display_endpoint() && !has_x11_display_endpoint();
}
- QPoint screen_anchor_point(const QScreen *screen) {
- if (screen == nullptr) {
- return QPoint();
- }
-
- const QRect full = screen->geometry();
- const QRect avail = screen->availableGeometry();
-
- if (avail.top() > full.top()) {
- return QPoint(avail.right(), avail.top());
- }
- if (avail.bottom() < full.bottom()) {
- return QPoint(avail.right(), avail.bottom());
- }
- if (avail.left() > full.left()) {
- return QPoint(avail.left(), avail.bottom());
- }
- if (avail.right() < full.right()) {
- return QPoint(avail.right(), avail.bottom());
+ /**
+ * @brief Update notification app name
+ * @param app_name the current application name
+ */
+ void set_notify_app_info(const char *app_name) {
+ if (app_name) {
+ uninit_notify();
+ init_notify(app_name);
}
-
- // Some compositors report no reserved panel area; top-right is a safer fallback than (0, 0).
- return avail.topRight();
}
/**
@@ -270,7 +205,7 @@ namespace {
* @param msg The message string.
*/
void qt_message_handler(QtMsgType type, const QMessageLogContext &, const QString &msg) {
- if (g_log_cb == nullptr) {
+ if (log_callback == nullptr) {
return;
}
int level;
@@ -288,720 +223,94 @@ namespace {
level = 3;
break;
}
- g_log_cb(level, msg.toUtf8().constData());
+ log_callback(level, msg.toUtf8().constData());
}
+} // namespace tray_linux
- /**
- * @brief Calculate the best position to show the context menu.
- *
- * Priority:
- * 1. Tray icon geometry (reliable on X11/XEmbed, sometimes on SNI).
- * 2. On a pure Xorg session, QCursor::pos() is accurate.
- * 3. On a Wayland session (detected via WAYLAND_DISPLAY), QCursor::pos() goes
- * through XWayland and reflects the last X11 cursor position, which is NOT
- * updated when the pointer interacts with Wayland-native surfaces such as the
- * GNOME Shell top bar. A screen-geometry heuristic is used instead: the panel
- * edge is inferred from the difference between the screen's full and available
- * geometries.
- *
- * Qt's QMenu::popup() will adjust the final position to keep the menu fully
- * on-screen, including flipping it above the anchor point when needed.
- *
- * @return The point at which to show the context menu.
- */
- QPoint calculateMenuPosition(const QPoint &preferred_pos = QPoint()) {
- if (g_tray_icon != nullptr) {
- const QRect iconGeo = g_tray_icon->geometry();
- if (iconGeo.isValid()) {
- return iconGeo.bottomLeft();
- }
- }
-
- if (!preferred_pos.isNull() && !is_wayland_session()) {
- return preferred_pos;
- }
-
- // When running under a Wayland compositor, XWayland cursor coordinates are stale
- // for events originating from Wayland-native surfaces (e.g., the GNOME top bar).
- // Detect a Wayland session regardless of the Qt platform plugin in use.
- if (const bool wayland_session = is_wayland_session(); !wayland_session) {
- // Pure Xorg: QCursor::pos() is accurate.
- return QCursor::pos();
- }
-
- const QPoint cursor_pos = QCursor::pos();
- if (!cursor_pos.isNull()) {
- const QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos);
- if (cursor_screen != nullptr) {
- return cursor_pos;
- }
- }
-
- // Wayland session fallback: infer panel anchor from the relevant screen.
- const QScreen *screen = QGuiApplication::screenAt(cursor_pos);
- if (screen == nullptr) {
- screen = QGuiApplication::primaryScreen();
- }
- if (const QPoint anchored = screen_anchor_point(screen); !anchored.isNull()) {
- return anchored;
- }
-
- return cursor_pos;
- }
-
- QIcon icon_from_source(const QString &icon_source) {
- if (icon_source.isEmpty()) {
- return QIcon();
- }
-
- if (const QFileInfo icon_fi(icon_source); icon_fi.exists()) {
- const QString file_path = icon_fi.absoluteFilePath();
- if (const QIcon file_icon(file_path); !file_icon.isNull()) {
- return file_icon;
- }
-
- const QPixmap pixmap(file_path);
- if (!pixmap.isNull()) {
- QIcon icon;
- icon.addPixmap(pixmap);
- return icon;
- }
- }
-
- if (const QIcon themed = QIcon::fromTheme(icon_source); !themed.isNull()) {
- return themed;
- }
-
- return QIcon();
- }
-
- QIcon resolve_tray_icon(const struct tray *tray_data) {
- if (tray_data == nullptr) {
- return QIcon();
- }
-
- if (tray_data->icon != nullptr) {
- const QIcon icon = icon_from_source(QString::fromUtf8(tray_data->icon));
- if (!icon.isNull()) {
- return icon;
- }
- }
-
- if (tray_data->iconPathCount > 0 && tray_data->iconPathCount < 64) {
- for (int i = 0; i < tray_data->iconPathCount; i++) {
- if (tray_data->allIconPaths[i] == nullptr) {
- continue;
- }
- const QIcon icon = icon_from_source(QString::fromUtf8(tray_data->allIconPaths[i]));
- if (!icon.isNull()) {
- return icon;
- }
- }
- }
-
- return QIcon();
- }
-
- void popup_menu_for_activation(const QPoint &preferred_pos, int retries_left = 3) {
- if (g_tray_icon == nullptr) {
- return;
- }
-
- QMenu *menu = g_tray_icon->contextMenu();
- if (menu == nullptr || menu->isVisible()) {
- return;
- }
-
- menu->activateWindow();
- menu->setWindowFlag(Qt::Popup, true);
- menu->popup(calculateMenuPosition(preferred_pos));
- menu->setFocus(Qt::PopupFocusReason);
-
- if (!menu->isVisible() && retries_left > 0) {
- QTimer::singleShot(30, g_tray_icon, [preferred_pos, retries_left]() {
- popup_menu_for_activation(preferred_pos, retries_left - 1);
- });
- }
- }
-
- void close_notification_id(uint notification_id) {
- if (notification_id == 0) {
- return;
- }
- QDBusInterface iface(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications")
- );
- if (iface.isValid()) {
- iface.asyncCall(QStringLiteral("CloseNotification"), notification_id);
- }
- }
-
- void close_notification() {
- if (g_notification_id == 0) {
- return;
- }
- const uint id_to_close = g_notification_id;
- g_notification_id = 0;
- close_notification_id(id_to_close);
- }
-
- QMenu *build_menu(struct tray_menu *m, QWidget *parent) {
- auto *menu = new QMenu(parent); // NOSONAR(cpp:S5025) - submenus owned by parent via Qt; top-level deleted manually
- for (; m != nullptr && m->text != nullptr; m++) {
- if (std::strcmp(m->text, "-") == 0) {
- menu->addSeparator();
- } else if (m->submenu != nullptr) {
- QMenu *sub = build_menu(m->submenu, menu);
- sub->setTitle(QString::fromUtf8(m->text));
- QAction *sub_action = menu->addMenu(sub);
- sub_action->setEnabled(m->disabled == 0);
- } else {
- auto *action = menu->addAction(QString::fromUtf8(m->text));
- action->setEnabled(m->disabled == 0);
- if (m->checkbox) {
- action->setCheckable(true);
- action->setChecked(m->checked != 0);
- }
- action->setData(QVariant::fromValue(static_cast(m)));
- QObject::connect(action, &QAction::triggered, menu, [action]() {
- auto *item = static_cast(action->data().value());
- if (item != nullptr && item->cb != nullptr) {
- item->cb(item);
- }
- });
- }
- }
- return menu;
- }
-
- bool menu_layout_matches(const QMenu *menu, const struct tray_menu *items) {
- if (menu == nullptr) {
- return false;
- }
-
- const QList actions = menu->actions();
- int action_index = 0;
- for (const struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) {
- if (action_index >= actions.size()) {
- return false;
- }
-
- const int current_action_index = action_index;
- action_index++;
- const QAction *action = actions.at(current_action_index);
- if (std::strcmp(item->text, "-") == 0) {
- if (!action->isSeparator()) {
- return false;
- }
- continue;
- }
-
- if (item->submenu != nullptr) {
- const QMenu *submenu = action->menu();
- if (submenu == nullptr || !menu_layout_matches(submenu, item->submenu)) {
- return false;
- }
- } else if (action->isSeparator() || action->menu() != nullptr) {
- return false;
- }
- }
-
- return action_index == actions.size();
- }
+extern "C" {
+ void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) {
+ tray_linux::set_notify_app_info(app_name);
- void update_menu_state(const QMenu *menu, struct tray_menu *items) {
- if (menu == nullptr || items == nullptr) {
+ if (tray_linux::qt_tray_menu == nullptr) {
return;
}
-
- const QList actions = menu->actions();
- int action_index = 0;
- for (struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) {
- const int current_action_index = action_index;
- action_index++;
- QAction *action = actions.at(current_action_index);
- if (std::strcmp(item->text, "-") == 0) {
- continue;
- }
-
- action->setText(QString::fromUtf8(item->text));
- action->setEnabled(item->disabled == 0);
- if (item->submenu != nullptr) {
- update_menu_state(action->menu(), item->submenu);
- continue;
- }
-
- action->setCheckable(item->checkbox != 0);
- if (item->checkbox != 0) {
- action->setChecked(item->checked != 0);
- }
- action->setData(QVariant::fromValue(static_cast(item)));
- }
+ const auto app_name_ = app_name != nullptr ? QString::fromUtf8(app_name) : QString();
+ const auto app_display_name_ = app_display_name != nullptr ? QString::fromUtf8(app_display_name) : QString();
+ const auto desktop_name_ = desktop_name != nullptr ? QString::fromUtf8(desktop_name) : QString();
+ tray_linux::qt_tray_menu->configureAppMetadata(app_name_, app_display_name_, desktop_name_);
}
- void configure_app_metadata(const struct tray *tray) {
- const QString effective_name = !g_app_name.isEmpty() ? g_app_name : QStringLiteral("tray");
- if (QCoreApplication::applicationName().isEmpty()) {
- QCoreApplication::setApplicationName(effective_name);
- }
-
- if (QGuiApplication::applicationDisplayName().isEmpty()) {
- if (!g_app_display_name.isEmpty()) {
- QGuiApplication::setApplicationDisplayName(g_app_display_name);
- } else {
- const QString display_name =
- (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : effective_name;
- QGuiApplication::setApplicationDisplayName(display_name);
- }
+ int tray_init(struct tray *tray) {
+ if (tray_linux::qt_tray_menu == nullptr) {
+ // Create a new unique pointer to QtTrayMenu instance
+ tray_linux::qt_tray_menu = std::make_unique();
}
- if (!QGuiApplication::desktopFileName().isEmpty()) {
- return;
+ if (const auto result = tray_linux::qt_tray_menu->init(tray, false); result < 0) {
+ // Tray init failed. Clean up and return error.
+ tray_exit();
+ return result;
}
- if (!g_desktop_name.isEmpty()) {
- QGuiApplication::setDesktopFileName(g_desktop_name);
- return;
+ if (!tray_linux::init_notify("tray") && !QtTrayMenu::supportsMessages()) {
+ // Notification init failed. Clean up and return error.
+ tray_exit();
+ return -1;
}
- QString desktop_name = QCoreApplication::applicationName();
- if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
- desktop_name += QStringLiteral(".desktop");
- }
- QGuiApplication::setDesktopFileName(desktop_name);
+ // Fire notification if there is one
+ tray_linux::notify(tray);
+ return 0;
}
- void connect_activation_handler() {
- // Show the context menu on left-click (Trigger).
- // Qt handles right-click natively via setContextMenu on both X11/XEmbed and
- // SNI (Wayland/AppIndicators), so we do not handle Context here.
- // The menu position is captured immediately before deferring by a short timer.
- // Deferring allows any platform pointer grab from the tray click to be released
- // before the menu establishes its own grab.
- QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) {
- if (const bool left_click_activation =
- (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context);
- !left_click_activation) {
- return;
- }
-
- const QPoint click_pos = QCursor::pos();
- QTimer::singleShot(30, g_tray_icon, [click_pos]() {
- popup_menu_for_activation(click_pos);
- });
- });
- }
-
- void ensure_notification_handler_connected() {
- if (g_notification_handler != nullptr) {
- return;
+ int tray_loop(int blocking) {
+ if (tray_linux::qt_tray_menu == nullptr) {
+ return -1;
}
-
- g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app()
- // Defer D-Bus ActionInvoked handler setup to the first event-loop iteration.
- // Creating QDBusConnection socket notifiers before the event loop starts can
- // trigger a "QSocketNotifier: Can only be used with threads started with QThread"
- // warning when the tray runs in a std::thread.
- QTimer::singleShot(0, g_notification_handler, []() {
- if (g_notification_handler == nullptr) {
- return;
- }
- QDBusConnection::sessionBus().connect(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("ActionInvoked"),
- g_notification_handler,
- SLOT(onActionInvoked(uint, QString))
- );
- });
+ return tray_linux::qt_tray_menu->loop(blocking);
}
- void update_context_menu(const struct tray *tray) {
- if (tray->menu == nullptr) {
- return;
- }
-
- QMenu *existing_menu = g_tray_icon->contextMenu();
- if (existing_menu != nullptr && menu_layout_matches(existing_menu, tray->menu)) {
- update_menu_state(existing_menu, tray->menu);
- return;
- }
-
- // setContextMenu does not take ownership; delete the old menu before replacing it.
- QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via existing_menu path or on next update
- g_tray_icon->setContextMenu(new_menu);
- if (existing_menu == nullptr) {
+ void tray_update(struct tray *tray) { // NOSONAR(cpp:S995) - C API requires this exact mutable-pointer signature
+ if (tray_linux::qt_tray_menu == nullptr) {
return;
}
-
- // hide() before delete releases any X11 pointer grab held by the popup.
- // Skipping this leaves the grab active, causing future popup menus to appear
- // but receive no pointer events, so QAction::triggered is never emitted.
- existing_menu->hide();
- delete existing_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this
+ tray_linux::qt_tray_menu->update(tray, false);
+ tray_linux::notify(tray);
}
- void reset_notification_state() {
- g_notification_generation++;
- g_notification_active_generation = 0;
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = 0;
- g_notification_handler->cb = nullptr;
- }
- if (g_tray_icon != nullptr) {
- QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr);
- }
- close_notification();
- }
-
- QString resolve_notification_icon(const struct tray *tray) {
- const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon;
- if (icon_path == nullptr) {
- return QString();
- }
-
- if (const QFileInfo fi(QString::fromUtf8(icon_path)); fi.exists()) {
- return QUrl::fromLocalFile(fi.absoluteFilePath()).toString();
- }
- return QString::fromUtf8(icon_path);
- }
-
- void destroy_tray();
-
- void handle_notification_reply(QDBusPendingCallWatcher *watcher, const std::uint64_t notification_generation) {
- const QDBusPendingReply reply = *watcher;
- if (!reply.isValid() || g_tray_icon == nullptr) {
- watcher->deleteLater();
- return;
- }
+ void tray_exit(void) {
+ tray_linux::uninit_notify();
- const uint reply_id = reply.value();
- const bool stale_reply =
- notification_generation != g_notification_active_generation || g_notification_active_generation == 0;
- if (stale_reply) {
- // The request was cleared or superseded before Notify returned; close it immediately.
- close_notification_id(reply_id);
- watcher->deleteLater();
+ if (tray_linux::qt_tray_menu == nullptr) {
return;
}
-
- g_notification_id = reply_id;
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = g_notification_id;
- }
- watcher->deleteLater();
- }
-
- bool send_dbus_notification(
- const struct tray *tray,
- const QString &title,
- const QString &text,
- const QString &icon,
- const std::uint64_t notification_generation
- ) {
- QVariantMap hints;
- if (!icon.isEmpty()) {
- hints[QStringLiteral("image-path")] = icon;
- }
-
- QDBusInterface iface(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications")
- );
- if (!iface.isValid()) {
- return false;
- }
-
- QStringList actions;
- if (tray->notification_cb != nullptr) {
- actions << QStringLiteral("default") << QString();
- }
- if (g_notification_handler != nullptr) {
- g_notification_handler->cb = tray->notification_cb;
- }
-
- QDBusPendingCall pending = iface.asyncCall(
- QStringLiteral("Notify"),
- QGuiApplication::applicationDisplayName(),
- static_cast(0),
- icon,
- title,
- text,
- actions,
- hints,
- 5000
- );
- auto *watcher = new QDBusPendingCallWatcher(pending); // NOSONAR(cpp:S5025) - deleted via deleteLater in finished handler
- QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [notification_generation](QDBusPendingCallWatcher *finished) {
- handle_notification_reply(finished, notification_generation);
- });
- return true;
+ tray_linux::qt_tray_menu->exit();
}
- void send_qt_notification_fallback(const struct tray *tray, const QString &title, const QString &text) {
- if (tray->notification_cb != nullptr && g_notification_handler != nullptr) {
- g_notification_handler->cb = tray->notification_cb;
- QObject::connect(g_tray_icon, &QSystemTrayIcon::messageClicked, []() {
- if (g_notification_handler == nullptr || g_notification_handler->cb == nullptr) {
- return;
- }
- g_notification_handler->cb();
- });
+ void tray_set_log_callback(void (*cb)(int level, const char *msg)) { // NOSONAR(cpp:S5205) - C API requires a plain function pointer callback type
+ tray_linux::log_callback = cb;
+ if (cb != nullptr) {
+ qInstallMessageHandler(tray_linux::qt_message_handler);
+ } else {
+ qInstallMessageHandler(nullptr);
}
- g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000);
}
- void update_notification(const struct tray *tray) {
- const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString();
- reset_notification_state();
- if (text.isEmpty()) {
+ void tray_show_menu(void) {
+ if (tray_linux::qt_tray_menu == nullptr) {
return;
}
-
- const std::uint64_t notification_generation = g_notification_generation;
- g_notification_active_generation = notification_generation;
-
- const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString();
- const QString icon = resolve_notification_icon(tray);
-
- if (!send_dbus_notification(tray, title, text, icon, notification_generation)) {
- // D-Bus may be unavailable; fall back to Qt's built-in balloon.
- send_qt_notification_fallback(tray, title, text);
- }
+ tray_linux::qt_tray_menu->showMenu();
}
- void update_tray_state(const struct tray *tray) {
- if (g_tray_icon == nullptr) {
- return;
- }
-
- QIcon tray_icon = resolve_tray_icon(tray);
- if (tray_icon.isNull() && !g_tray_icon->icon().isNull()) {
- tray_icon = g_tray_icon->icon();
- }
- if (tray_icon.isNull()) {
- tray_icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon);
- }
- if (!tray_icon.isNull()) {
- g_tray_icon->setIcon(tray_icon);
- }
-
- if (tray->tooltip != nullptr) {
- g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip));
- }
-
- update_context_menu(tray);
- update_notification(tray);
- }
-
- void initialize_tray(struct tray *tray, int *result) {
- destroy_tray();
- g_loop_result = 0;
- g_exit_pending = false;
-
- g_tray_icon = new QSystemTrayIcon(); // NOSONAR(cpp:S5025) - raw pointer; deleted in destroy_tray() before QApplication
- if (!QSystemTrayIcon::isSystemTrayAvailable()) {
- destroy_tray();
- *result = -1;
+ void tray_simulate_menu_item_click(int index) {
+ if (tray_linux::qt_tray_menu == nullptr) {
return;
}
-
- configure_app_metadata(tray);
- connect_activation_handler();
- ensure_notification_handler_connected();
- update_tray_state(tray);
- g_tray_icon->show();
- }
-
- void destroy_tray() {
- reset_notification_state();
- if (g_tray_icon != nullptr) {
- g_tray_icon->hide();
- QMenu *menu = g_tray_icon->contextMenu();
- g_tray_icon->setContextMenu(nullptr);
- delete g_tray_icon; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication is destroyed
- g_tray_icon = nullptr;
- if (menu != nullptr) {
- menu->hide();
- delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu
- }
- }
- }
-
- void destroy_app() {
- if (g_notification_handler != nullptr) {
- delete g_notification_handler; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication
- g_notification_handler = nullptr;
- }
- if (g_app_owned && g_app) {
- // Destroy QApplication here (during active program execution) rather than letting
- // the unique_ptr destructor run at static-destruction time. At static-destruction
- // time, Qt's lazily-initialized D-Bus statics have already been destroyed (LIFO
- // order), so calling QApplication::~QApplication() then would crash.
- g_app.reset();
- g_app_owned = false;
- }
- }
-} // namespace
-
-extern "C" {
-
- int tray_init(struct tray *tray) {
- if (QApplication::instance() == nullptr) {
- if (try_autodiscover_wayland_display() && g_log_cb != nullptr) {
- g_log_cb(1, "Qt tray: auto-discovered WAYLAND_DISPLAY from XDG_RUNTIME_DIR");
- }
- if (should_force_headless_qpa_fallback()) {
- qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("minimal"));
- if (g_log_cb != nullptr) {
- g_log_cb(2, "Qt tray: no reachable WAYLAND_DISPLAY or DISPLAY endpoint, forcing QT_QPA_PLATFORM=minimal");
- }
- }
- static int argc = 0;
- g_app = std::make_unique(argc, nullptr);
- g_app_owned = true;
- }
-
- int result = 0;
- run_on_qt_thread([tray, &result]() {
- initialize_tray(tray, &result);
- });
- return result;
- }
-
- int tray_loop(int blocking) {
- if (g_exit_pending) {
- g_exit_pending = false;
- run_on_qt_thread([]() {
- destroy_tray();
- destroy_app();
- });
- return g_loop_result;
- }
-
- if (blocking) {
- if (g_app_owned) {
- QApplication::exec();
- if (g_exit_pending) {
- g_exit_pending = false;
- destroy_tray();
- destroy_app();
- }
- } else {
- // An external event loop owns Qt processing; block until tray_exit() fires.
- while (!g_exit_pending) {
- QThread::msleep(10);
- }
- g_exit_pending = false;
- run_on_qt_thread([]() {
- destroy_tray();
- destroy_app();
- });
- }
- } else {
- if (g_app_owned) {
- QApplication::processEvents();
- } else {
- const QCoreApplication *app_inst = QCoreApplication::instance();
- if (app_inst != nullptr && QThread::currentThread() == app_inst->thread()) {
- QApplication::processEvents();
- }
- // On a non-Qt thread with an external app the external event loop handles processing.
- }
- }
- return g_loop_result;
- }
-
- void tray_update(struct tray *tray) { // NOSONAR(cpp:S995) - C API requires this exact mutable-pointer signature
- run_on_qt_thread([tray]() {
- update_tray_state(tray);
- });
- }
-
- void tray_show_menu(void) {
- run_on_qt_thread([]() {
- if (g_tray_icon != nullptr) {
- const QMenu *menu = g_tray_icon->contextMenu();
- if (menu != nullptr) {
- popup_menu_for_activation(QPoint());
- QApplication::processEvents();
- }
- }
- });
+ tray_linux::qt_tray_menu->clickMenuItem(index);
}
void tray_simulate_notification_click(void) {
- run_on_qt_thread([]() {
- if (g_notification_handler != nullptr && g_notification_handler->cb != nullptr) {
- if (g_notification_handler->notification_id != 0) {
- // Simulate the D-Bus ActionInvoked signal for the current notification.
- g_notification_handler->onActionInvoked(
- g_notification_handler->notification_id,
- QStringLiteral("default")
- );
- } else {
- // Fallback path (no D-Bus): invoke the callback directly.
- g_notification_handler->cb();
- }
- }
- });
- }
-
- void tray_simulate_menu_item_click(int index) {
- run_on_qt_thread([index]() {
- if (g_tray_icon == nullptr || index < 0) {
- return;
- }
- const QMenu *menu = g_tray_icon->contextMenu();
- if (menu == nullptr) {
- return;
- }
- const QList actions = menu->actions();
- if (index >= actions.size()) {
- return;
- }
- QAction *action = actions.at(index);
- if (action == nullptr || action->isSeparator() || action->menu() != nullptr || !action->isEnabled()) {
- return;
- }
- action->trigger();
- });
- }
-
- void tray_exit(void) {
- g_loop_result = -1;
- g_exit_pending = true;
- if (g_app_owned) {
- run_on_qt_thread([]() {
- if (QApplication::instance() != nullptr) {
- QApplication::quit();
- }
- });
- }
+ tray_linux::acknowledge_notifications(true);
}
-
- void tray_set_log_callback(void (*cb)(int level, const char *msg)) { // NOSONAR(cpp:S5205) - C API requires a plain function pointer callback type
- g_log_cb = cb;
- if (cb != nullptr) {
- qInstallMessageHandler(qt_message_handler);
- } else {
- qInstallMessageHandler(nullptr);
- }
- }
-
- void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) {
- g_app_name = app_name != nullptr ? QString::fromUtf8(app_name) : QString();
- g_app_display_name = app_display_name != nullptr ? QString::fromUtf8(app_display_name) : QString();
- g_desktop_name = desktop_name != nullptr ? QString::fromUtf8(desktop_name) : QString();
- }
-
} // extern "C"
-
-// Must be included at the end of a .cpp file when Q_OBJECT classes are defined
-// in that .cpp (not in a header). AUTOMOC sees this directive and generates
-// tray_linux.moc, which is then inlined here at compile time.
-#include "tray_linux.moc"
diff --git a/tests/screenshot_utils.cpp b/tests/screenshot_utils.cpp
index 859e8316..a7c85488 100644
--- a/tests/screenshot_utils.cpp
+++ b/tests/screenshot_utils.cpp
@@ -120,6 +120,12 @@ namespace screenshot {
return true;
}
}
+ if (std::system("which spectacle > /dev/null 2>&1") == 0) {
+ std::string cmd = "spectacle -f -b -n -o " + target;
+ if (std::system(cmd.c_str()) == 0) {
+ return true;
+ }
+ }
std::string cmd = "gnome-screenshot -f " + target;
return std::system(cmd.c_str()) == 0;
}
diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp
index 4a91562f..103896e8 100644
--- a/tests/unit/test_tray.cpp
+++ b/tests/unit/test_tray.cpp
@@ -3,6 +3,7 @@
// standard includes
#include
+#include
#include
#include
#include
@@ -97,8 +98,8 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must
trayRunning = false;
}
- // Dismisses the open menu and exits the tray event loop from a background thread.
- void closeMenuAndExit() {
+ // Dismisses the open menu from a background thread.
+ void closeMenu() {
#if defined(TRAY_WINAPI)
PostMessage(tray_get_hwnd(), WM_CANCELMODE, 0, 0);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
@@ -111,18 +112,22 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must
CFRelease(event2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
#endif
- tray_exit();
}
// Capture a screenshot while the tray menu is open, then dismiss and exit.
void captureMenuStateAndExit(const char *screenshotName) {
- std::thread capture_thread([this, screenshotName]() { // NOSONAR(cpp:S6168) - std::jthread is unavailable on AppleClang 17/libc++ used in CI
+ std::atomic_bool exitRequested {false};
+ std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168) - std::jthread is unavailable on AppleClang 17/libc++ used in CI
EXPECT_TRUE(captureScreenshot(screenshotName));
- closeMenuAndExit();
+ closeMenu();
+ exitRequested.store(true, std::memory_order_release);
});
tray_show_menu();
while (tray_loop(0) == 0) {
+ if (exitRequested.load(std::memory_order_acquire)) {
+ tray_exit();
+ }
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
capture_thread.join();