From b409a01dec94d4b16acad3484fc8758d3f08fd4e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:47:51 -0400 Subject: [PATCH 01/23] fix(linux): migrate to qt tray --- .github/workflows/ci.yml | 18 +-- CMakeLists.txt | 35 +++-- README.md | 15 +-- src/example.c | 8 +- src/tray_linux.c | 275 --------------------------------------- src/tray_linux.cpp | 160 +++++++++++++++++++++++ tests/unit/test_tray.cpp | 14 +- 7 files changed, 197 insertions(+), 328 deletions(-) delete mode 100644 src/tray_linux.c create mode 100644 src/tray_linux.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5692e1d..4d25c12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} build: - name: Build (${{ matrix.os }} - ${{ matrix.appindicator || 'default' }}) + name: Build (${{ matrix.os }}) defaults: run: shell: ${{ matrix.shell }} @@ -50,12 +50,6 @@ jobs: - os: macos-latest shell: "bash" - os: ubuntu-latest - appindicator: "libayatana-appindicator3-dev" - appindicator_type: "ayatana" - shell: "bash" - - os: ubuntu-latest - appindicator: "libappindicator3-dev" - appindicator_type: "legacy" shell: "bash" - os: windows-latest shell: "msys2 {0}" @@ -72,18 +66,15 @@ jobs: sudo apt-get install -y \ build-essential \ cmake \ - ${{ matrix.appindicator }} \ imagemagick \ - libglib2.0-dev \ - libnotify-dev \ ninja-build \ + qtbase5-dev \ xvfb - name: Setup virtual desktop if: runner.os == 'Linux' uses: LizardByte/actions/actions/virtual_desktop@0affa4f7bcb27562658960eee840eff8ff844578 # v2026.328.161128 with: - appindicator-version: ${{ matrix.appindicator_type }} environment: mate - name: Setup Dependencies macOS @@ -236,7 +227,7 @@ jobs: (steps.test.outcome == 'success' || steps.test.outcome == 'failure') uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: tray-screenshots-${{ runner.os }}${{ matrix.appindicator && format('-{0}', matrix.appindicator) || '' }} + name: tray-screenshots-${{ runner.os }} path: build/tests/screenshots if-no-files-found: error @@ -264,9 +255,6 @@ jobs: id: codecov_flags run: | flags="${{ runner.os }}" - if [ -n "${{ matrix.appindicator }}" ]; then - flags="${flags},${{ matrix.appindicator }}" - fi echo "flags=${flags}" >> "${GITHUB_OUTPUT}" - name: Upload coverage diff --git a/CMakeLists.txt b/CMakeLists.txt index cb263d4..076be83 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ cmake_minimum_required(VERSION 3.13 FATAL_ERROR) # target_link_directories project(tray VERSION 0.0.0 DESCRIPTION "A cross-platform system tray library" HOMEPAGE_URL "https://app.lizardbyte.dev" - LANGUAGES C) + LANGUAGES C CXX) set(PROJECT_LICENSE "MIT") @@ -29,7 +29,7 @@ endif() set(CMAKE_COLOR_MAKEFILE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -find_package (PkgConfig REQUIRED) +find_package(PkgConfig) file(GLOB TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/*.h" @@ -44,15 +44,22 @@ else() find_library(COCOA Cocoa REQUIRED) list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_darwin.m") else() - find_package(APPINDICATOR REQUIRED) - find_package(LibNotify REQUIRED) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_linux.c") + find_package(Qt6 QUIET COMPONENTS Widgets) + if(Qt6_FOUND) + set(TRAY_QT_VERSION 6) + else() + find_package(Qt5 REQUIRED COMPONENTS Widgets) + set(TRAY_QT_VERSION 5) + endif() + set(CMAKE_AUTOMOC ON) + list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_linux.cpp") endif() endif() endif() add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) +set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 11) if(WIN32) list(APPEND TRAY_DEFINITIONS TRAY_WINAPI=1 WIN32_LEAN_AND_MEAN NOMINMAX) @@ -65,20 +72,12 @@ else() list(APPEND TRAY_DEFINITIONS TRAY_APPKIT=1) list(APPEND TRAY_EXTERNAL_LIBRARIES ${COCOA}) else() - list(APPEND TRAY_COMPILE_OPTIONS ${APPINDICATOR_CFLAGS}) - list(APPEND TRAY_EXTERNAL_DIRECTORIES ${APPINDICATOR_LIBRARY_DIRS}) - list(APPEND TRAY_DEFINITIONS TRAY_APPINDICATOR=1) - if(APPINDICATOR_AYATANA) - list(APPEND TRAY_DEFINITIONS TRAY_AYATANA_APPINDICATOR=1) - endif() - if(APPINDICATOR_LEGACY) - list(APPEND TRAY_DEFINITIONS TRAY_LEGACY_APPINDICATOR=1) + list(APPEND TRAY_DEFINITIONS TRAY_QT=1) + if(TRAY_QT_VERSION EQUAL 6) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets) + else() + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets) endif() - list(APPEND TRAY_LIBNOTIFY=1) - list(APPEND TRAY_EXTERNAL_LIBRARIES ${APPINDICATOR_LIBRARIES} ${LIBNOTIFY_LIBRARIES}) - - include_directories(SYSTEM ${APPINDICATOR_INCLUDE_DIRS} ${LIBNOTIFY_INCLUDE_DIRS}) - link_directories(${APPINDICATOR_LIBRARY_DIRS} ${LIBNOTIFY_LIBRARY_DIRS}) endif() endif() endif() diff --git a/README.md b/README.md index ccbdd38..9478599 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,10 @@ The code is C++ friendly and will compile fine in C++98 and up. This is a fork o This fork adds the following features: - system tray notifications -- support for both linux appindicator versions - unit tests - code coverage -- refactored code, e.g. moved source code into the `src` directory -- doxygen documentation, and readthedocs configuration +- refactored code, e.g., moved source code into the `src` directory +- doxygen documentation and readthedocs configuration ## Screenshots @@ -33,14 +32,14 @@ This fork adds the following features: ## Supported platforms -* Linux/Gtk (libayatana-appindicator3 or libappindicator3) +* Linux/Qt (Qt5 or Qt6 Widgets) * Windows XP or newer (shellapi.h) * MacOS (Cocoa/AppKit) ## Prerequisites * CMake -* [Ninja](https://ninja-build.org/), in order to have the same build commands on all platforms +* [Ninja](https://ninja-build.org/), to have the same build commands on all platforms ### Linux Dependencies @@ -48,17 +47,17 @@ This fork adds the following features: - Arch ```bash - sudo pacman -S libayatana-appindicator + sudo pacman -S qt6-base ``` - Debian/Ubuntu ```bash - sudo apt install libappindicator3-dev + sudo apt install qtbase5-dev ``` - Fedora ```bash - sudo dnf install libappindicator-gtk3-devel + sudo dnf install qt6-qtbase-devel ``` diff --git a/src/example.c b/src/example.c index bf9e19a..7198593 100644 --- a/src/example.c +++ b/src/example.c @@ -9,7 +9,7 @@ #if defined(_WIN32) || defined(_WIN64) #define TRAY_WINAPI 1 ///< Use WinAPI. #elif defined(__linux__) || defined(linux) || defined(__linux) - #define TRAY_APPINDICATOR 1 + #define TRAY_QT 1 #elif defined(__APPLE__) || defined(__MACH__) #define TRAY_APPKIT 1 #endif @@ -17,9 +17,9 @@ // local includes #include "tray.h" -#if TRAY_APPINDICATOR - #define TRAY_ICON1 "mail-message-new" - #define TRAY_ICON2 "mail-message-new" +#if TRAY_QT + #define TRAY_ICON1 "icon.png" + #define TRAY_ICON2 "icon.png" #elif TRAY_APPKIT #define TRAY_ICON1 "icon.png" #define TRAY_ICON2 "icon.png" diff --git a/src/tray_linux.c b/src/tray_linux.c deleted file mode 100644 index 3b9c678..0000000 --- a/src/tray_linux.c +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @file src/tray_linux.c - * @brief System tray implementation for Linux. - */ -// standard includes -#include -#include -#include -#include -#include - -// local includes -#include "tray.h" - -// lib includes -#ifdef TRAY_AYATANA_APPINDICATOR - #include -#elif TRAY_LEGACY_APPINDICATOR - #include -#endif -#ifndef IS_APP_INDICATOR - #define IS_APP_INDICATOR APP_IS_INDICATOR ///< Define IS_APP_INDICATOR for app-indicator compatibility. -#endif -#include - -// Use a per-process AppIndicator id to avoid DBus collisions when tests create multiple -// tray instances in the same desktop/session. -static unsigned long tray_appindicator_seq = 0; - -static bool async_update_pending = false; -static pthread_cond_t async_update_cv = PTHREAD_COND_INITIALIZER; -static pthread_mutex_t async_update_mutex = PTHREAD_MUTEX_INITIALIZER; - -static AppIndicator *indicator = NULL; -static int loop_result = 0; -static NotifyNotification *currentNotification = NULL; -static GtkMenu *current_menu = NULL; -static GtkMenu *current_popup = NULL; -static GtkWidget *menu_anchor_window = NULL; - -static void _tray_menu_cb(GtkMenuItem *item, gpointer data) { - (void) item; - struct tray_menu *m = (struct tray_menu *) data; - m->cb(m); -} - -static GtkMenuShell *_tray_menu(struct tray_menu *m) { - GtkMenuShell *menu = (GtkMenuShell *) gtk_menu_new(); - for (; m != NULL && m->text != NULL; m++) { - GtkWidget *item; - if (strcmp(m->text, "-") == 0) { - item = gtk_separator_menu_item_new(); - } else { - if (m->submenu != NULL) { - item = gtk_menu_item_new_with_label(m->text); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(item), GTK_WIDGET(_tray_menu(m->submenu))); - } else if (m->checkbox) { - item = gtk_check_menu_item_new_with_label(m->text); - gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), !!m->checked); - } else { - item = gtk_menu_item_new_with_label(m->text); - } - gtk_widget_set_sensitive(item, !m->disabled); - if (m->cb != NULL) { - g_signal_connect(item, "activate", G_CALLBACK(_tray_menu_cb), m); - } - } - gtk_widget_show(item); - gtk_menu_shell_append(menu, item); - } - return menu; -} - -int tray_init(struct tray *tray) { - if (gtk_init_check(0, NULL) == FALSE) { - return -1; - } - - // If a previous tray instance wasn't fully torn down (common in unit tests), - // drop our references before creating a new indicator. - if (indicator != NULL) { - g_object_unref(G_OBJECT(indicator)); - indicator = NULL; - } - loop_result = 0; - notify_init("tray-icon"); - // The id is used as part of the exported DBus object path. - // Make it unique per *tray instance* to prevent collisions inside a single test process. - // Avoid underscores and other characters that may be normalized/stripped. - char appindicator_id[64]; - tray_appindicator_seq++; - snprintf(appindicator_id, sizeof(appindicator_id), "trayid%ld%lu", (long) getpid(), tray_appindicator_seq); - - indicator = app_indicator_new(appindicator_id, tray->icon, APP_INDICATOR_CATEGORY_APPLICATION_STATUS); - if (indicator == NULL || !IS_APP_INDICATOR(indicator)) { - return -1; - } - app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE); - tray_update(tray); - return 0; -} - -int tray_loop(int blocking) { - gtk_main_iteration_do(blocking); - return loop_result; -} - -static gboolean tray_update_internal(gpointer user_data) { - struct tray *tray = user_data; - - if (indicator != NULL && IS_APP_INDICATOR(indicator)) { - app_indicator_set_icon_full(indicator, tray->icon, tray->icon); - // GTK is all about reference counting, so previous menu should be destroyed - // here - GtkMenu *menu = GTK_MENU(_tray_menu(tray->menu)); - app_indicator_set_menu(indicator, menu); - if (current_menu != NULL) { - g_object_unref(current_menu); - } - current_menu = menu; - g_object_ref(current_menu); // Keep a reference for showing - } - if (tray->notification_text != 0 && strlen(tray->notification_text) > 0 && notify_is_initted()) { - if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { - notify_notification_close(currentNotification, NULL); - g_object_unref(G_OBJECT(currentNotification)); - } - const char *notification_icon = tray->notification_icon != NULL ? tray->notification_icon : tray->icon; - currentNotification = notify_notification_new(tray->notification_title, tray->notification_text, notification_icon); - if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { - if (tray->notification_cb != NULL) { - notify_notification_add_action(currentNotification, "default", "Default", NOTIFY_ACTION_CALLBACK(tray->notification_cb), NULL, NULL); - } - notify_notification_show(currentNotification, NULL); - } - } - - // Unwait any pending tray_update() calls - pthread_mutex_lock(&async_update_mutex); - async_update_pending = false; - pthread_cond_broadcast(&async_update_cv); - pthread_mutex_unlock(&async_update_mutex); - return G_SOURCE_REMOVE; -} - -void tray_update(struct tray *tray) { - // Perform the tray update on the tray loop thread, but block - // in this thread to ensure none of the strings stored in the - // tray icon struct go out of scope before the callback runs. - - if (g_main_context_is_owner(g_main_context_default())) { - // Invoke the callback directly if we're on the loop thread - tray_update_internal(tray); - } else { - // If there's already an update pending, wait for it to complete - // and claim the next pending update slot. - pthread_mutex_lock(&async_update_mutex); - while (async_update_pending) { - pthread_cond_wait(&async_update_cv, &async_update_mutex); - } - async_update_pending = true; - pthread_mutex_unlock(&async_update_mutex); - - // Queue the update callback to the tray thread - g_main_context_invoke(NULL, tray_update_internal, tray); - - // Wait for the callback to run - pthread_mutex_lock(&async_update_mutex); - while (async_update_pending) { - pthread_cond_wait(&async_update_cv, &async_update_mutex); - } - pthread_mutex_unlock(&async_update_mutex); - } -} - -static void _tray_popup(GtkMenu *menu) { - if (menu == NULL) { - return; - } - - // Dismiss any previously shown popup - if (current_popup != NULL) { - gtk_menu_popdown(current_popup); - current_popup = NULL; - } - if (menu_anchor_window != NULL) { - gtk_widget_destroy(menu_anchor_window); - menu_anchor_window = NULL; - } - - GtkWidget *anchor_window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - if (anchor_window != NULL) { - gtk_window_set_type_hint(GTK_WINDOW(anchor_window), GDK_WINDOW_TYPE_HINT_POPUP_MENU); - gtk_window_set_decorated(GTK_WINDOW(anchor_window), FALSE); - gtk_window_set_skip_taskbar_hint(GTK_WINDOW(anchor_window), TRUE); - gtk_window_set_skip_pager_hint(GTK_WINDOW(anchor_window), TRUE); - gtk_window_move(GTK_WINDOW(anchor_window), 100, 100); - gtk_window_resize(GTK_WINDOW(anchor_window), 1, 1); - gtk_widget_show(anchor_window); - menu_anchor_window = anchor_window; - - while (gtk_events_pending()) { - gtk_main_iteration(); - } - - if (gtk_check_version(3, 22, 0) == NULL) { - GdkWindow *gdk_window = gtk_widget_get_window(anchor_window); - if (gdk_window != NULL) { - GdkRectangle rect = {0, 0, 1, 1}; - gtk_menu_popup_at_rect(menu, gdk_window, &rect, GDK_GRAVITY_NORTH_WEST, GDK_GRAVITY_NORTH_WEST, NULL); - } else { - gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); - } - } else { - gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); - } - current_popup = menu; - } else { - gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); - current_popup = menu; - } -} - -void tray_show_menu(void) { - _tray_popup(current_menu); -} - -static gboolean tray_exit_internal(gpointer user_data) { - (void) user_data; - - if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { - int v = notify_notification_close(currentNotification, NULL); - if (v == TRUE) { - g_object_unref(G_OBJECT(currentNotification)); - } - currentNotification = NULL; - } - - if (current_popup != NULL) { - gtk_menu_popdown(current_popup); - current_popup = NULL; - } - - if (current_menu != NULL) { - g_object_unref(current_menu); - current_menu = NULL; - } - - if (menu_anchor_window != NULL) { - gtk_widget_destroy(menu_anchor_window); - menu_anchor_window = NULL; - } - - if (indicator != NULL) { - // Make the indicator passive before unref to encourage a clean DBus unexport. - app_indicator_set_status(indicator, APP_INDICATOR_STATUS_PASSIVE); - g_object_unref(G_OBJECT(indicator)); - indicator = NULL; - } - notify_uninit(); - return G_SOURCE_REMOVE; -} - -void tray_exit(void) { - // Wait for any pending callbacks to complete - pthread_mutex_lock(&async_update_mutex); - while (async_update_pending) { - pthread_cond_wait(&async_update_cv, &async_update_mutex); - } - pthread_mutex_unlock(&async_update_mutex); - - // Perform cleanup on the main thread - loop_result = -1; - g_main_context_invoke(NULL, tray_exit_internal, NULL); -} diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp new file mode 100644 index 0000000..869ce20 --- /dev/null +++ b/src/tray_linux.cpp @@ -0,0 +1,160 @@ +/** + * @file src/tray_linux.cpp + * @brief System tray implementation for Linux using Qt. + */ +// standard includes +#include + +// local includes +#include "tray.h" + +// Qt includes +#include +#include +#include +#include + +static QApplication *app = nullptr; +static QSystemTrayIcon *tray_icon = nullptr; +static QMenu *tray_menu = nullptr; +static int loop_result = 0; +static bool app_owned = false; + +static QMenu *_tray_menu(struct tray_menu *m) { + auto *menu = new QMenu(); + for (; m != nullptr && m->text != nullptr; m++) { + if (std::strcmp(m->text, "-") == 0) { + menu->addSeparator(); + } else if (m->submenu != nullptr) { + QMenu *sub = _tray_menu(m->submenu); + sub->setTitle(QString::fromUtf8(m->text)); + menu->addMenu(sub); + } else if (m->checkbox) { + auto *action = menu->addAction(QString::fromUtf8(m->text)); + action->setCheckable(true); + action->setChecked(m->checked != 0); + action->setEnabled(m->disabled == 0); + if (m->cb != nullptr) { + struct tray_menu *item = m; + QObject::connect(action, &QAction::triggered, [item]() { + item->cb(item); + }); + } + } else { + auto *action = menu->addAction(QString::fromUtf8(m->text)); + action->setEnabled(m->disabled == 0); + if (m->cb != nullptr) { + struct tray_menu *item = m; + QObject::connect(action, &QAction::triggered, [item]() { + item->cb(item); + }); + } + } + } + return menu; +} + +extern "C" { + +int tray_init(struct tray *tray) { + if (QApplication::instance() == nullptr) { + static int argc = 0; + app = new QApplication(argc, nullptr); + app_owned = true; + } + + if (tray_icon != nullptr) { + delete tray_icon; + tray_icon = nullptr; + } + if (tray_menu != nullptr) { + delete tray_menu; + tray_menu = nullptr; + } + + loop_result = 0; + + tray_icon = new QSystemTrayIcon(); + + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + delete tray_icon; + tray_icon = nullptr; + return -1; + } + + tray_update(tray); + tray_icon->show(); + return 0; +} + +int tray_loop(int blocking) { + if (blocking) { + QApplication::exec(); + } else { + QApplication::processEvents(); + } + return loop_result; +} + +void tray_update(struct tray *tray) { + if (tray_icon == nullptr) { + return; + } + + tray_icon->setIcon(QIcon(QString::fromUtf8(tray->icon))); + + if (tray->tooltip != nullptr) { + tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); + } + + if (tray->menu != nullptr) { + QMenu *new_menu = _tray_menu(tray->menu); + tray_icon->setContextMenu(new_menu); + if (tray_menu != nullptr) { + delete tray_menu; + } + tray_menu = new_menu; + } + + if (tray->notification_text != nullptr && std::strlen(tray->notification_text) > 0) { + QSystemTrayIcon::MessageIcon icon = QSystemTrayIcon::Information; + const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); + const QString text = QString::fromUtf8(tray->notification_text); + tray_icon->showMessage(title, text, icon, 5000); + + if (tray->notification_cb != nullptr) { + void (*cb)() = tray->notification_cb; + QObject::connect(tray_icon, &QSystemTrayIcon::messageClicked, [cb]() { + cb(); + }); + } + } +} + +void tray_show_menu(void) { + if (tray_icon != nullptr && tray_menu != nullptr) { + tray_menu->popup(QCursor::pos()); + QApplication::processEvents(); + } +} + +void tray_exit(void) { + loop_result = -1; + + if (tray_icon != nullptr) { + tray_icon->hide(); + delete tray_icon; + tray_icon = nullptr; + } + + if (tray_menu != nullptr) { + delete tray_menu; + tray_menu = nullptr; + } + + if (app_owned && app != nullptr) { + app->quit(); + } +} + +} // extern "C" diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 5bd0792..69ae1ba 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -16,7 +16,7 @@ // clang-format on #define TRAY_WINAPI 1 #elif defined(__linux__) || defined(linux) || defined(__linux) - #define TRAY_APPINDICATOR 1 + #define TRAY_QT 1 #elif defined(__APPLE__) || defined(__MACH__) #include #define TRAY_APPKIT 1 @@ -26,9 +26,9 @@ #include "src/tray.h" #include "tests/screenshot_utils.h" -#if TRAY_APPINDICATOR -constexpr const char *TRAY_ICON1 = "mail-message-new"; -constexpr const char *TRAY_ICON2 = "mail-message-new"; +#if TRAY_QT +constexpr const char *TRAY_ICON1 = "icon.png"; +constexpr const char *TRAY_ICON2 = "icon.png"; #elif TRAY_APPKIT constexpr const char *TRAY_ICON1 = "icon.png"; constexpr const char *TRAY_ICON2 = "icon.png"; @@ -159,7 +159,6 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must GTEST_SKIP() << "Screenshot output path not initialized"; } -#if defined(TRAY_WINAPI) || defined(TRAY_APPKIT) // Ensure icon files exist in test binary directory std::filesystem::path projectRoot = testBinaryDir.parent_path(); std::filesystem::path iconSource; @@ -182,7 +181,6 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } } } -#endif trayRunning = false; testTray.icon = TRAY_ICON1; @@ -199,7 +197,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Process pending events to allow tray icon to appear. // Call this ONLY before screenshots to ensure the icon is visible. void WaitForTrayReady() { -#if defined(TRAY_APPINDICATOR) +#if defined(TRAY_QT) for (int i = 0; i < 100; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(5)); @@ -405,7 +403,7 @@ TEST_F(TrayTest, TestNotificationCallback) { tray_update(&testTray); - // Note: callback would be invoked by user interaction in real scenario + // Note: callback would be invoked by user interaction in a real scenario // In test environment, we verify it's set correctly EXPECT_NE(testTray.notification_cb, nullptr); From 10556135f7ce4edb834d5480d7b2182982ebb9ad Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:09:45 -0400 Subject: [PATCH 02/23] style: sonar fixes --- CMakeLists.txt | 2 +- README.md | 2 +- src/tray_linux.cpp | 224 ++++++++++++++++++++++----------------------- 3 files changed, 111 insertions(+), 117 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 076be83..59842f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,7 +59,7 @@ endif() add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) -set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 11) +set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 14) if(WIN32) list(APPEND TRAY_DEFINITIONS TRAY_WINAPI=1 WIN32_LEAN_AND_MEAN NOMINMAX) diff --git a/README.md b/README.md index 9478599..b104bb8 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ This fork adds the following features: ## Prerequisites * CMake -* [Ninja](https://ninja-build.org/), to have the same build commands on all platforms +* [Ninja](https://ninja-build.org/), to have the same build commands on all platforms. ### Linux Dependencies diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 869ce20..87c4e62 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -4,157 +4,151 @@ */ // standard includes #include +#include // local includes #include "tray.h" // Qt includes #include +#include #include #include #include -static QApplication *app = nullptr; -static QSystemTrayIcon *tray_icon = nullptr; -static QMenu *tray_menu = nullptr; -static int loop_result = 0; -static bool app_owned = false; - -static QMenu *_tray_menu(struct tray_menu *m) { - auto *menu = new QMenu(); - for (; m != nullptr && m->text != nullptr; m++) { - if (std::strcmp(m->text, "-") == 0) { - menu->addSeparator(); - } else if (m->submenu != nullptr) { - QMenu *sub = _tray_menu(m->submenu); - sub->setTitle(QString::fromUtf8(m->text)); - menu->addMenu(sub); - } else if (m->checkbox) { - auto *action = menu->addAction(QString::fromUtf8(m->text)); - action->setCheckable(true); - action->setChecked(m->checked != 0); - action->setEnabled(m->disabled == 0); - if (m->cb != nullptr) { - struct tray_menu *item = m; - QObject::connect(action, &QAction::triggered, [item]() { - item->cb(item); - }); - } - } else { - auto *action = menu->addAction(QString::fromUtf8(m->text)); - action->setEnabled(m->disabled == 0); - if (m->cb != nullptr) { - struct tray_menu *item = m; - QObject::connect(action, &QAction::triggered, [item]() { - item->cb(item); - }); +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 + int g_loop_result = 0; // NOSONAR(cpp:S5421) - mutable state, not const + bool g_app_owned = false; // NOSONAR(cpp:S5421) - mutable state, not const + bool g_exit_pending = false; // NOSONAR(cpp:S5421) - mutable state, not const + + 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)); + menu->addMenu(sub); + } 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); + } + if (m->cb != nullptr) { + struct tray_menu *item = m; + QObject::connect(action, &QAction::triggered, [item]() { + item->cb(item); + }); + } } } + return menu; } - return menu; -} -extern "C" { - -int tray_init(struct tray *tray) { - if (QApplication::instance() == nullptr) { - static int argc = 0; - app = new QApplication(argc, nullptr); - app_owned = true; + void destroy_tray() { + 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; + delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu + } } +} // namespace - if (tray_icon != nullptr) { - delete tray_icon; - tray_icon = nullptr; - } - if (tray_menu != nullptr) { - delete tray_menu; - tray_menu = nullptr; - } +extern "C" { - loop_result = 0; + int tray_init(struct tray *tray) { + if (QApplication::instance() == nullptr) { + static int argc = 0; + g_app = std::make_unique(argc, nullptr); + g_app_owned = true; + } - tray_icon = new QSystemTrayIcon(); + destroy_tray(); + g_loop_result = 0; - if (!QSystemTrayIcon::isSystemTrayAvailable()) { - delete tray_icon; - tray_icon = nullptr; - return -1; - } + g_tray_icon = new QSystemTrayIcon(); // NOSONAR(cpp:S5025) - raw pointer; deleted in destroy_tray() before QApplication - tray_update(tray); - tray_icon->show(); - return 0; -} + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + destroy_tray(); + return -1; + } -int tray_loop(int blocking) { - if (blocking) { - QApplication::exec(); - } else { - QApplication::processEvents(); + tray_update(tray); + g_tray_icon->show(); + return 0; } - return loop_result; -} -void tray_update(struct tray *tray) { - if (tray_icon == nullptr) { - return; + int tray_loop(int blocking) { + if (blocking) { + QApplication::exec(); + } else { + QApplication::processEvents(); + } + return g_loop_result; } - tray_icon->setIcon(QIcon(QString::fromUtf8(tray->icon))); + void tray_update(struct tray *tray) { + if (g_tray_icon == nullptr) { + return; + } - if (tray->tooltip != nullptr) { - tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); - } + g_tray_icon->setIcon(QIcon(QString::fromUtf8(tray->icon))); - if (tray->menu != nullptr) { - QMenu *new_menu = _tray_menu(tray->menu); - tray_icon->setContextMenu(new_menu); - if (tray_menu != nullptr) { - delete tray_menu; + if (tray->tooltip != nullptr) { + g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); } - tray_menu = new_menu; - } - if (tray->notification_text != nullptr && std::strlen(tray->notification_text) > 0) { - QSystemTrayIcon::MessageIcon icon = QSystemTrayIcon::Information; - const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); - const QString text = QString::fromUtf8(tray->notification_text); - tray_icon->showMessage(title, text, icon, 5000); - - if (tray->notification_cb != nullptr) { - void (*cb)() = tray->notification_cb; - QObject::connect(tray_icon, &QSystemTrayIcon::messageClicked, [cb]() { - cb(); - }); + if (tray->menu != nullptr) { + // setContextMenu does not take ownership; delete the old menu before replacing it. + QMenu *old_menu = g_tray_icon->contextMenu(); + QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update + g_tray_icon->setContextMenu(new_menu); + delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this } - } -} -void tray_show_menu(void) { - if (tray_icon != nullptr && tray_menu != nullptr) { - tray_menu->popup(QCursor::pos()); - QApplication::processEvents(); + const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); + QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); + if (!text.isEmpty()) { + const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); + if (tray->notification_cb != nullptr) { + void (*cb)() = tray->notification_cb; + QObject::connect(g_tray_icon, &QSystemTrayIcon::messageClicked, [cb]() { + cb(); + }); + } + g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); + } else { + g_tray_icon->showMessage(QString(), QString(), QSystemTrayIcon::NoIcon, 0); + } } -} -void tray_exit(void) { - loop_result = -1; - - if (tray_icon != nullptr) { - tray_icon->hide(); - delete tray_icon; - tray_icon = nullptr; + void tray_show_menu(void) { + if (g_tray_icon != nullptr) { + QMenu *menu = g_tray_icon->contextMenu(); + if (menu != nullptr) { + menu->popup(QCursor::pos()); + QApplication::processEvents(); + } + } } - if (tray_menu != nullptr) { - delete tray_menu; - tray_menu = nullptr; - } + void tray_exit(void) { + g_loop_result = -1; + destroy_tray(); - if (app_owned && app != nullptr) { - app->quit(); + if (g_app_owned) { + QApplication::quit(); + g_app_owned = false; + g_app.reset(); + } } -} } // extern "C" From 290e3a592c3fec5f544d618d99fc40cbd59dbdad Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:31:47 -0400 Subject: [PATCH 03/23] fix(linux): tray destruction --- CMakeLists.txt | 8 ++--- src/tray_linux.cpp | 80 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 59842f2..d702f18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,11 +44,11 @@ else() find_library(COCOA Cocoa REQUIRED) list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_darwin.m") else() - find_package(Qt6 QUIET COMPONENTS Widgets) + find_package(Qt6 QUIET COMPONENTS Widgets DBus) if(Qt6_FOUND) set(TRAY_QT_VERSION 6) else() - find_package(Qt5 REQUIRED COMPONENTS Widgets) + find_package(Qt5 REQUIRED COMPONENTS Widgets DBus) set(TRAY_QT_VERSION 5) endif() set(CMAKE_AUTOMOC ON) @@ -74,9 +74,9 @@ else() else() list(APPEND TRAY_DEFINITIONS TRAY_QT=1) if(TRAY_QT_VERSION EQUAL 6) - list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus) else() - list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus) endif() endif() endif() diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 87c4e62..a911554 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -12,9 +12,12 @@ // Qt includes #include #include +#include +#include #include #include #include +#include namespace { std::unique_ptr g_app; // NOSONAR(cpp:S5421) - mutable state, not const @@ -22,6 +25,22 @@ namespace { int g_loop_result = 0; // NOSONAR(cpp:S5421) - mutable state, not const bool g_app_owned = false; // NOSONAR(cpp:S5421) - mutable state, not const bool g_exit_pending = false; // NOSONAR(cpp:S5421) - mutable state, not const + uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup + + void close_notification() { + if (g_notification_id == 0) { + return; + } + QDBusInterface iface( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications") + ); + if (iface.isValid()) { + iface.call(QStringLiteral("CloseNotification"), g_notification_id); + } + g_notification_id = 0; + } 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 @@ -51,6 +70,7 @@ namespace { } void destroy_tray() { + close_notification(); if (g_tray_icon != nullptr) { g_tray_icon->hide(); QMenu *menu = g_tray_icon->contextMenu(); @@ -60,6 +80,17 @@ namespace { delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu } } + + void destroy_app() { + 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" { @@ -73,6 +104,7 @@ extern "C" { 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 @@ -87,8 +119,20 @@ extern "C" { } int tray_loop(int blocking) { + if (g_exit_pending) { + g_exit_pending = false; + destroy_tray(); + destroy_app(); + return g_loop_result; + } + if (blocking) { QApplication::exec(); + if (g_exit_pending) { + g_exit_pending = false; + destroy_tray(); + destroy_app(); + } } else { QApplication::processEvents(); } @@ -116,17 +160,40 @@ extern "C" { const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); + close_notification(); if (!text.isEmpty()) { const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); + const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; + const QString icon = icon_path != nullptr ? QString::fromUtf8(icon_path) : QString(); if (tray->notification_cb != nullptr) { void (*cb)() = tray->notification_cb; QObject::connect(g_tray_icon, &QSystemTrayIcon::messageClicked, [cb]() { cb(); }); } - g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); - } else { - g_tray_icon->showMessage(QString(), QString(), QSystemTrayIcon::NoIcon, 0); + QDBusInterface iface( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications") + ); + if (iface.isValid()) { + QDBusReply reply = iface.call( + QStringLiteral("Notify"), + QStringLiteral("tray"), + static_cast(0), + icon, + title, + text, + QStringList(), + QVariantMap(), + 5000 + ); + if (reply.isValid()) { + g_notification_id = reply.value(); + } + } else { + g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); + } } } @@ -142,12 +209,9 @@ extern "C" { void tray_exit(void) { g_loop_result = -1; - destroy_tray(); - - if (g_app_owned) { + g_exit_pending = true; + if (g_app_owned && QApplication::instance() != nullptr) { QApplication::quit(); - g_app_owned = false; - g_app.reset(); } } From 0fccbce47777324188a3cfe294c37a1c3eaae40c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:00:31 -0400 Subject: [PATCH 04/23] fix(linux): notification icon --- src/tray_linux.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index a911554..9d17bf3 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -14,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include namespace { @@ -164,7 +166,14 @@ extern "C" { if (!text.isEmpty()) { const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; - const QString icon = icon_path != nullptr ? QString::fromUtf8(icon_path) : QString(); + QString icon; + if (icon_path != nullptr) { + icon = QUrl::fromLocalFile(QFileInfo(QString::fromUtf8(icon_path)).absoluteFilePath()).toString(); + } + QVariantMap hints; + if (!icon.isEmpty()) { + hints[QStringLiteral("image-path")] = icon; + } if (tray->notification_cb != nullptr) { void (*cb)() = tray->notification_cb; QObject::connect(g_tray_icon, &QSystemTrayIcon::messageClicked, [cb]() { @@ -185,7 +194,7 @@ extern "C" { title, text, QStringList(), - QVariantMap(), + hints, 5000 ); if (reply.isValid()) { From e969048eeceae23380289ae99fd0b5e094a5ab16 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:13:27 -0400 Subject: [PATCH 05/23] fix(linux): support themed icons --- .github/workflows/ci.yml | 4 ++++ src/tray_linux.cpp | 8 ++++++-- tests/unit/test_tray.cpp | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d25c12..1d293e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,10 +64,12 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ + adwaita-icon-theme \ build-essential \ cmake \ imagemagick \ ninja-build \ + qt5-gtk-platformtheme \ qtbase5-dev \ xvfb @@ -219,6 +221,8 @@ jobs: # TODO: tests randomly hang on Linux, https://github.com/LizardByte/tray/issues/45 timeout-minutes: 3 working-directory: build/tests + env: + QT_QPA_PLATFORMTHEME: gtk3 run: ./test_tray --gtest_color=yes --gtest_output=xml:test_results.xml - name: Upload screenshots diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 9d17bf3..a4eb208 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -146,7 +146,10 @@ extern "C" { return; } - g_tray_icon->setIcon(QIcon(QString::fromUtf8(tray->icon))); + const QString icon_str = QString::fromUtf8(tray->icon); + g_tray_icon->setIcon( + QFileInfo(icon_str).exists() ? QIcon(icon_str) : QIcon::fromTheme(icon_str) + ); if (tray->tooltip != nullptr) { g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); @@ -168,7 +171,8 @@ extern "C" { const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; QString icon; if (icon_path != nullptr) { - icon = QUrl::fromLocalFile(QFileInfo(QString::fromUtf8(icon_path)).absoluteFilePath()).toString(); + QFileInfo fi(QString::fromUtf8(icon_path)); + icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path); } QVariantMap hints; if (!icon.isEmpty()) { diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 69ae1ba..7cc5242 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -29,6 +29,7 @@ #if TRAY_QT constexpr const char *TRAY_ICON1 = "icon.png"; constexpr const char *TRAY_ICON2 = "icon.png"; +constexpr const char *TRAY_ICON_THEMED = "mail-message-new"; #elif TRAY_APPKIT constexpr const char *TRAY_ICON1 = "icon.png"; constexpr const char *TRAY_ICON2 = "icon.png"; @@ -596,3 +597,36 @@ TEST_F(TrayTest, TestTrayShowMenu) { TEST_F(TrayTest, TestTrayExit) { tray_exit(); } + +#if defined(TRAY_QT) + +TEST_F(TrayTest, TestTrayIconThemed) { + testTray.icon = TRAY_ICON_THEMED; + int result = tray_init(&testTray); + trayRunning = (result == 0); + ASSERT_EQ(result, 0); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_themed")); + testTray.icon = TRAY_ICON1; +} + +TEST_F(TrayTest, TestNotificationWithThemedIcon) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + testTray.notification_title = "Test Notification"; + testTray.notification_text = "This is a test notification message"; + testTray.notification_icon = TRAY_ICON_THEMED; + tray_update(&testTray); + + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_notification_themed_icon")); + + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + tray_update(&testTray); +} + +#endif // TRAY_QT From 39992ed86e195353af26f5e3ce165b7c1945ce66 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:41:19 -0400 Subject: [PATCH 06/23] Simulate notification clicks and D-Bus handler Add support for simulating notification clicks and routing D-Bus notification actions to callbacks. - Export tray_simulate_notification_click() in tray.h and provide platform implementations: no-op stubs for macOS and Windows, and a working simulator for Linux. - Introduce TrayNotificationHandler (src/tray_notification_handler.h) to handle org.freedesktop.Notifications ActionInvoked signals and invoke the stored callback when the "default" action is triggered. - Integrate the handler into the Linux tray: create/connect a single handler per QApplication, store callback and notification ID before calling Notify, set up a fallback that connects QSystemTrayIcon::messageClicked when D-Bus is unavailable, and clean up/disconnect the handler on app destroy. - Also connect QSystemTrayIcon::activated to popup the context menu on left/middle clicks (fixes missing menu on left-click). - Add unit tests verifying left-click menu popup and that notification callbacks fire when simulated (tests call tray_simulate_notification_click()). These changes ensure notification clicks are handled consistently for D-Bus-dispatched notifications and add a test hook for exercising the click behavior. --- CMakeLists.txt | 18 ++--- src/tray.h | 8 +++ src/tray_darwin.m | 5 ++ src/tray_linux.cpp | 140 +++++++++++++++++++++++++++++++++++++-- src/tray_windows.c | 5 ++ tests/unit/test_tray.cpp | 45 +++++++++++++ 6 files changed, 205 insertions(+), 16 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d702f18..8e35fd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,17 +32,17 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_package(PkgConfig) file(GLOB TRAY_SOURCES - "${CMAKE_SOURCE_DIR}/src/*.h" - "${CMAKE_SOURCE_DIR}/icons/*.ico" - "${CMAKE_SOURCE_DIR}/icons/*.png") + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" + "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.ico" + "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png") if(WIN32) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_windows.c") + list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c") else() if(UNIX) if(APPLE) find_library(COCOA Cocoa REQUIRED) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_darwin.m") + list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m") else() find_package(Qt6 QUIET COMPONENTS Widgets DBus) if(Qt6_FOUND) @@ -52,7 +52,7 @@ else() set(TRAY_QT_VERSION 5) endif() set(CMAKE_AUTOMOC ON) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_linux.cpp") + list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_linux.cpp") endif() endif() endif() @@ -84,11 +84,11 @@ endif() add_library(tray::tray ALIAS ${PROJECT_NAME}) -add_executable(tray_example "${CMAKE_SOURCE_DIR}/src/example.c") +add_executable(tray_example "${CMAKE_CURRENT_SOURCE_DIR}/src/example.c") target_link_libraries(tray_example tray::tray) -configure_file("${CMAKE_SOURCE_DIR}/icons/icon.ico" "${CMAKE_BINARY_DIR}/icon.ico" COPYONLY) -configure_file("${CMAKE_SOURCE_DIR}/icons/icon.png" "${CMAKE_BINARY_DIR}/icon.png" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico" "${CMAKE_CURRENT_BINARY_DIR}/icon.ico" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png" "${CMAKE_CURRENT_BINARY_DIR}/icon.png" COPYONLY) INSTALL(TARGETS tray tray DESTINATION lib) diff --git a/src/tray.h b/src/tray.h index 164a436..b3aab78 100644 --- a/src/tray.h +++ b/src/tray.h @@ -73,6 +73,14 @@ extern "C" { */ void tray_show_menu(void); + /** + * @brief Simulate a notification click, invoking the notification callback (for testing purposes). + * + * On Linux (Qt): triggers the stored notification callback as if the user clicked the notification. + * On other platforms: no-op. + */ + void tray_simulate_notification_click(void); + /** * @brief Terminate UI loop. */ diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 2cebb17..626fe9d 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -130,6 +130,11 @@ void tray_show_menu(void) { [statusItem popUpStatusItemMenu:statusItem.menu]; } +void tray_simulate_notification_click(void) { + // macOS notification clicks are handled by the OS notification center. + // Simulation is not supported here. +} + void tray_exit(void) { // Remove the status item from the status bar on the main thread // NSStatusBar operations must be performed on the main thread diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index a4eb208..e4ab5c8 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -12,6 +12,7 @@ // Qt includes #include #include +#include #include #include #include @@ -21,9 +22,43 @@ #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: + + /** + * @brief Invoked when a D-Bus notification action is triggered. + * @param id The notification ID. + * @param action_key The action key that was triggered. + */ + void onActionInvoked(uint id, const QString &action_key) { + if (id == notification_id && cb != nullptr && action_key == QLatin1String("default")) { + cb(); + } + } +}; + 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 bool g_exit_pending = false; // NOSONAR(cpp:S5421) - mutable state, not const @@ -73,6 +108,10 @@ namespace { void destroy_tray() { close_notification(); + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = 0; + g_notification_handler->cb = nullptr; + } if (g_tray_icon != nullptr) { g_tray_icon->hide(); QMenu *menu = g_tray_icon->contextMenu(); @@ -84,6 +123,18 @@ namespace { } void destroy_app() { + if (g_notification_handler != nullptr) { + QDBusConnection::sessionBus().disconnect( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("ActionInvoked"), + g_notification_handler, + SLOT(onActionInvoked(uint, QString)) + ); + 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 @@ -115,6 +166,35 @@ extern "C" { return -1; } + // Show the context menu on left-click (Trigger) in addition to the default right-click path. + // QSystemTrayIcon::setContextMenu only handles right-click on X11/XEmbed; SNI-based desktops + // may not show the menu at all without this explicit connection. + QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::MiddleClick) { + if (g_tray_icon != nullptr) { + QMenu *menu = g_tray_icon->contextMenu(); + if (menu != nullptr) { + menu->popup(QCursor::pos()); + } + } + } + }); + + // Create the D-Bus ActionInvoked handler once per QApplication lifetime. + // This receives the "default" action event when the user clicks a D-Bus notification, + // which QSystemTrayIcon::messageClicked does NOT emit for D-Bus-dispatched notifications. + if (g_notification_handler == nullptr) { + g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app() + QDBusConnection::sessionBus().connect( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("ActionInvoked"), + g_notification_handler, + SLOT(onActionInvoked(uint, QString)) + ); + } + tray_update(tray); g_tray_icon->show(); return 0; @@ -164,8 +244,15 @@ extern "C" { } const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); + + // Reset previous notification state before setting up the new one. + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = 0; + g_notification_handler->cb = nullptr; + } QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); close_notification(); + if (!text.isEmpty()) { const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; @@ -178,18 +265,25 @@ extern "C" { if (!icon.isEmpty()) { hints[QStringLiteral("image-path")] = icon; } - if (tray->notification_cb != nullptr) { - void (*cb)() = tray->notification_cb; - QObject::connect(g_tray_icon, &QSystemTrayIcon::messageClicked, [cb]() { - cb(); - }); - } + QDBusInterface iface( QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("/org/freedesktop/Notifications"), QStringLiteral("org.freedesktop.Notifications") ); if (iface.isValid()) { + // Include the "default" action so that clicking the notification body fires ActionInvoked. + // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications, + // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead. + QStringList actions; + if (tray->notification_cb != nullptr) { + actions << QStringLiteral("default") << QString(); + } + // Store the callback before calling Notify so tray_simulate_notification_click works + // even when the notification daemon is unavailable and the D-Bus reply is invalid. + if (g_notification_handler != nullptr) { + g_notification_handler->cb = tray->notification_cb; + } QDBusReply reply = iface.call( QStringLiteral("Notify"), QStringLiteral("tray"), @@ -197,14 +291,26 @@ extern "C" { icon, title, text, - QStringList(), + actions, hints, 5000 ); if (reply.isValid()) { g_notification_id = reply.value(); + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = g_notification_id; + } } } else { + // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal. + 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) { + g_notification_handler->cb(); + } + }); + } g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); } } @@ -220,6 +326,21 @@ extern "C" { } } + void tray_simulate_notification_click(void) { + 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_exit(void) { g_loop_result = -1; g_exit_pending = true; @@ -229,3 +350,8 @@ extern "C" { } } // 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/src/tray_windows.c b/src/tray_windows.c index 9f0d14e..5283b81 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -331,6 +331,11 @@ void tray_show_menu(void) { PostMessage(hwnd, WM_TRAY_CALLBACK_MESSAGE, 0, WM_RBUTTONUP); } +void tray_simulate_notification_click(void) { + // Windows handles notification clicks via NIN_BALLOONUSERCLICK in the window proc. + // Simulating this from outside the message pump is not supported here. +} + void tray_exit(void) { Shell_NotifyIconW(NIM_DELETE, &nid); SendMessage(hwnd, WM_CLOSE, 0, 0); diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 7cc5242..c2f9fe9 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -629,4 +629,49 @@ TEST_F(TrayTest, TestNotificationWithThemedIcon) { tray_update(&testTray); } +TEST_F(TrayTest, TestMenuAppearsOnLeftClick) { + // Regression test for: clicking the tray icon did not bring up the menu. + // The activated(Trigger) signal was not connected to the menu popup logic. + // tray_show_menu() exercises the same code path that the activated handler calls. + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + captureMenuStateAndExit("tray_menu_left_click"); // NOSONAR(cpp:S6168) - helper uses std::thread for AppleClang 17 compatibility +} + +TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { + // Regression test for: clicking a notification did not invoke the callback. + // On the D-Bus path, QSystemTrayIcon::messageClicked is never emitted; the + // callback must be routed through TrayNotificationHandler::onActionInvoked. + static bool callbackInvoked = false; + callbackInvoked = false; + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + testTray.notification_title = "Clickable Notification"; + testTray.notification_text = "Click to test callback"; + testTray.notification_icon = TRAY_ICON1; + testTray.notification_cb = []() { + callbackInvoked = true; + }; + tray_update(&testTray); + + // Allow the notification to be sent before simulating the click. + WaitForTrayReady(); + + tray_simulate_notification_click(); + tray_loop(0); + + EXPECT_TRUE(callbackInvoked); + + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; + tray_update(&testTray); +} + #endif // TRAY_QT From 7f3e3bd81a915005e839fcd8a6b51e2f6afa43fd Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:35:33 -0400 Subject: [PATCH 07/23] Add tray log callback and menu positioning fixes Expose tray_set_log_callback in the public API and implement it on Linux to forward Qt diagnostic messages to a user-provided callback (NULL restores default behavior). Add a Qt message handler and global callback state, and route qInstallMessageHandler accordingly; provide no-op stubs on macOS and Windows since Qt logging is not used there. Improve context-menu positioning by calculating a sensible popup point from the tray icon geometry (falling back to cursor position) and use it when showing menus. Defer D-Bus ActionInvoked connection via QTimer::singleShot to avoid QSocketNotifier warnings when the tray runs in a std::thread, and add a few related include adjustments and minor activation handling cleanup. --- src/tray.h | 12 ++++++ src/tray_darwin.m | 5 +++ src/tray_linux.cpp | 103 +++++++++++++++++++++++++++++++++++++-------- src/tray_windows.c | 5 +++ 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/tray.h b/src/tray.h index b3aab78..52d02a8 100644 --- a/src/tray.h +++ b/src/tray.h @@ -86,6 +86,18 @@ extern "C" { */ void tray_exit(void); + /** + * @brief Set a callback for log messages produced by the tray library. + * + * On Linux the callback is installed as a Qt message handler so all Qt + * diagnostic output is routed through it. On other platforms this function + * is a no-op. + * + * @param cb Callback invoked with level (0=debug, 1=info, 2=warning, 3=error) + * and the message string. Pass NULL to restore the default logging behaviour. + */ + void tray_set_log_callback(void (*cb)(int level, const char *msg)); + #if defined(TRAY_WINAPI) /** * @brief Get the tray window handle. diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 626fe9d..2fe9b35 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -135,6 +135,11 @@ void tray_simulate_notification_click(void) { // Simulation is not supported here. } +void tray_set_log_callback(void (*cb)(int level, const char *msg)) { + // Qt is not used on macOS; log routing is not applicable. + (void) cb; +} + void tray_exit(void) { // Remove the status item from the status bar on the main thread // NSStatusBar operations must be performed on the main thread diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index e4ab5c8..7cc1661 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -16,9 +16,12 @@ #include #include #include +#include #include #include +#include #include +#include #include #include @@ -63,6 +66,55 @@ namespace { bool g_app_owned = false; // NOSONAR(cpp:S5421) - mutable state, not const bool g_exit_pending = false; // NOSONAR(cpp:S5421) - mutable state, not const uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup + void (*g_log_cb)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + + /** + * @brief Qt message handler that forwards to the registered log callback. + * @param type The Qt message type. + * @param msg The message string. + */ + void qt_message_handler(QtMsgType type, const QMessageLogContext &, const QString &msg) { + if (g_log_cb == nullptr) { + return; + } + int level; + switch (type) { + case QtDebugMsg: + level = 0; + break; + case QtInfoMsg: + level = 1; + break; + case QtWarningMsg: + level = 2; + break; + default: + level = 3; + break; + } + g_log_cb(level, msg.toUtf8().constData()); + } + + /** + * @brief Calculate the best position to show the context menu. + * + * Uses the tray icon geometry when available (reliable on X11/XEmbed and + * some SNI desktops). Falls back to the current cursor position on systems + * where the icon geometry cannot be determined. Qt's QMenu::popup() will + * adjust the final position to keep the menu fully on-screen. + * + * @return The point at which to show the context menu. + */ + QPoint calculateMenuPosition() { + if (g_tray_icon != nullptr) { + const QRect iconGeometry = g_tray_icon->geometry(); + if (iconGeometry.isValid()) { + // Qt's popup() will flip the menu above the icon if it would go off-screen. + return iconGeometry.bottomLeft(); + } + } + return QCursor::pos(); + } void close_notification() { if (g_notification_id == 0) { @@ -166,33 +218,41 @@ extern "C" { return -1; } - // Show the context menu on left-click (Trigger) in addition to the default right-click path. - // QSystemTrayIcon::setContextMenu only handles right-click on X11/XEmbed; SNI-based desktops - // may not show the menu at all without this explicit connection. + // Show the context menu on the default trigger (clicked). + // QSystemTrayIcon::setContextMenu only handles right-click on X11/XEmbed; + // SNI-based desktops may not show the menu at all without this explicit connection. + // The menu is positioned using the tray icon geometry rather than the cursor position, + // which is unreliable on Wayland. QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::MiddleClick) { + if (reason == QSystemTrayIcon::Trigger) { if (g_tray_icon != nullptr) { QMenu *menu = g_tray_icon->contextMenu(); if (menu != nullptr) { - menu->popup(QCursor::pos()); + menu->popup(calculateMenuPosition()); } } } }); - // Create the D-Bus ActionInvoked handler once per QApplication lifetime. - // This receives the "default" action event when the user clicks a D-Bus notification, - // which QSystemTrayIcon::messageClicked does NOT emit for D-Bus-dispatched notifications. + // 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. Deferring via QTimer::singleShot + // ensures the socket notifiers are created while the event dispatcher is active. if (g_notification_handler == nullptr) { g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app() - QDBusConnection::sessionBus().connect( - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("/org/freedesktop/Notifications"), - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("ActionInvoked"), - g_notification_handler, - SLOT(onActionInvoked(uint, QString)) - ); + QTimer::singleShot(0, g_notification_handler, []() { + if (g_notification_handler != nullptr) { + QDBusConnection::sessionBus().connect( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("ActionInvoked"), + g_notification_handler, + SLOT(onActionInvoked(uint, QString)) + ); + } + }); } tray_update(tray); @@ -320,7 +380,7 @@ extern "C" { if (g_tray_icon != nullptr) { QMenu *menu = g_tray_icon->contextMenu(); if (menu != nullptr) { - menu->popup(QCursor::pos()); + menu->popup(calculateMenuPosition()); QApplication::processEvents(); } } @@ -349,6 +409,15 @@ extern "C" { } } + void tray_set_log_callback(void (*cb)(int level, const char *msg)) { + g_log_cb = cb; + if (cb != nullptr) { + qInstallMessageHandler(qt_message_handler); + } else { + qInstallMessageHandler(nullptr); + } + } + } // extern "C" // Must be included at the end of a .cpp file when Q_OBJECT classes are defined diff --git a/src/tray_windows.c b/src/tray_windows.c index 5283b81..bcc7eec 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -336,6 +336,11 @@ void tray_simulate_notification_click(void) { // Simulating this from outside the message pump is not supported here. } +void tray_set_log_callback(void (*cb)(int level, const char *msg)) { + // Qt is not used on Windows; log routing is not applicable. + (void) cb; +} + void tray_exit(void) { Shell_NotifyIconW(NIM_DELETE, &nid); SendMessage(hwnd, WM_CLOSE, 0, 0); From 25d0cf43dadf2b532acf51d01c6a734380ba0b5c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:35:22 -0400 Subject: [PATCH 08/23] Improve tray menu positioning and icon handling Detect Wayland sessions and improve context-menu placement by prioritizing tray icon geometry, then using QCursor on Xorg, and falling back to a screen-geometry heuristic on Wayland (inferring panel edge from full vs available geometry). Defer showing the menu with QTimer::singleShot(0) and call QApplication::setActiveWindow before popup() so pointer grabs and XGrabPointer behavior work reliably; avoid showing the menu if it's already visible. Also avoid clearing the tray icon by only setting it when the resolved QIcon is valid, preventing spurious "No Icon set" warnings. --- src/tray_linux.cpp | 96 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 7cc1661..17265a2 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -98,21 +98,62 @@ namespace { /** * @brief Calculate the best position to show the context menu. * - * Uses the tray icon geometry when available (reliable on X11/XEmbed and - * some SNI desktops). Falls back to the current cursor position on systems - * where the icon geometry cannot be determined. Qt's QMenu::popup() will - * adjust the final position to keep the menu fully on-screen. + * 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() { if (g_tray_icon != nullptr) { - const QRect iconGeometry = g_tray_icon->geometry(); - if (iconGeometry.isValid()) { - // Qt's popup() will flip the menu above the icon if it would go off-screen. - return iconGeometry.bottomLeft(); + const QRect iconGeo = g_tray_icon->geometry(); + if (iconGeo.isValid()) { + return iconGeo.bottomLeft(); + } + } + + // 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. + const bool wayland_session = !qgetenv("WAYLAND_DISPLAY").isEmpty(); + if (!wayland_session) { + // Pure Xorg: QCursor::pos() is accurate. + return QCursor::pos(); + } + + // Wayland session fallback: infer the panel edge from available vs full screen + // geometry and anchor the menu to that edge. popup() keeps the menu on-screen. + QScreen *screen = QGuiApplication::primaryScreen(); + if (screen != nullptr) { + const QRect full = screen->geometry(); + const QRect avail = screen->availableGeometry(); + if (avail.top() > full.top()) { + // Panel at top (e.g., GNOME default): anchor below the panel at the right edge. + return QPoint(avail.right(), avail.top()); + } + if (avail.bottom() < full.bottom()) { + // Panel at the bottom (e.g., KDE Plasma default): popup() flips upward automatically. + return QPoint(avail.right(), avail.bottom()); + } + if (avail.left() > full.left()) { + // Panel on the left. + return QPoint(avail.left(), avail.bottom()); + } + if (avail.right() < full.right()) { + // Panel on the right. + return QPoint(avail.right(), avail.bottom()); } } + return QCursor::pos(); } @@ -218,19 +259,28 @@ extern "C" { return -1; } - // Show the context menu on the default trigger (clicked). - // QSystemTrayIcon::setContextMenu only handles right-click on X11/XEmbed; - // SNI-based desktops may not show the menu at all without this explicit connection. - // The menu is positioned using the tray icon geometry rather than the cursor position, - // which is unreliable on Wayland. + // 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 to the next + // event-loop iteration via QTimer::singleShot(0). Deferring allows any + // platform pointer grab from the tray click to be released before the menu + // establishes its own grab. + // QApplication::setActiveWindow gives the menu window X11 focus so that the + // subsequent XGrabPointer inside popup() succeeds, enabling click-outside + // dismissal on Xorg. QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { if (reason == QSystemTrayIcon::Trigger) { - if (g_tray_icon != nullptr) { - QMenu *menu = g_tray_icon->contextMenu(); - if (menu != nullptr) { - menu->popup(calculateMenuPosition()); + const QPoint pos = calculateMenuPosition(); + QTimer::singleShot(0, g_tray_icon, [pos]() { + if (g_tray_icon != nullptr) { + QMenu *menu = g_tray_icon->contextMenu(); + if (menu != nullptr && !menu->isVisible()) { + QApplication::setActiveWindow(menu); + menu->popup(pos); + } } - } + }); } }); @@ -287,9 +337,13 @@ extern "C" { } const QString icon_str = QString::fromUtf8(tray->icon); - g_tray_icon->setIcon( - QFileInfo(icon_str).exists() ? QIcon(icon_str) : QIcon::fromTheme(icon_str) - ); + const QIcon icon = QFileInfo(icon_str).exists() ? QIcon(icon_str) : QIcon::fromTheme(icon_str); + // Only update the icon when the resolved icon is valid. Setting a null icon + // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter + // about QIcon::fromTheme when the name is not found in the active theme). + if (!icon.isNull()) { + g_tray_icon->setIcon(icon); + } if (tray->tooltip != nullptr) { g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); From e9363c6572bf527a9d087a5144dd5f4e5f0ddf99 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:01:58 -0400 Subject: [PATCH 09/23] Fix tray menu focus and icon loading on Linux Replace QApplication::setActiveWindow with menu->activateWindow() to give the menu X11 focus before popup() and clarify the comment. Include QPixmap and change icon resolution logic: when the icon string refers to an existing file, load a QPixmap and add it to a QIcon immediately (avoiding QIcon's lazy loading that leaves availableSizes() empty and can produce a blank tray icon with Qt6 SNI). Fall back to QIcon::fromTheme() when the file doesn't exist. Keep guarding against setting null icons. --- src/tray_linux.cpp | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 17265a2..96f795d 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -266,9 +267,8 @@ extern "C" { // event-loop iteration via QTimer::singleShot(0). Deferring allows any // platform pointer grab from the tray click to be released before the menu // establishes its own grab. - // QApplication::setActiveWindow gives the menu window X11 focus so that the - // subsequent XGrabPointer inside popup() succeeds, enabling click-outside - // dismissal on Xorg. + // activateWindow() gives the menu window X11 focus so that the subsequent + // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg. QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { if (reason == QSystemTrayIcon::Trigger) { const QPoint pos = calculateMenuPosition(); @@ -276,7 +276,7 @@ extern "C" { if (g_tray_icon != nullptr) { QMenu *menu = g_tray_icon->contextMenu(); if (menu != nullptr && !menu->isVisible()) { - QApplication::setActiveWindow(menu); + menu->activateWindow(); menu->popup(pos); } } @@ -337,7 +337,20 @@ extern "C" { } const QString icon_str = QString::fromUtf8(tray->icon); - const QIcon icon = QFileInfo(icon_str).exists() ? QIcon(icon_str) : QIcon::fromTheme(icon_str); + QIcon icon; + const QFileInfo icon_fi(icon_str); + if (icon_fi.exists()) { + // Explicitly load via QPixmap so that the icon engine has pixmap data and + // availableSizes() is populated immediately. QIcon(filename) lazy-loads the + // pixmap, which leaves availableSizes() empty; Qt6's SNI tray backend then + // sees no sizes and sends no icon data, causing the tray icon to be blank. + const QPixmap pixmap(icon_fi.absoluteFilePath()); + if (!pixmap.isNull()) { + icon.addPixmap(pixmap); + } + } else { + icon = QIcon::fromTheme(icon_str); + } // Only update the icon when the resolved icon is valid. Setting a null icon // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter // about QIcon::fromTheme when the name is not found in the active theme). From cac14ab20f6edf41ef3eb640b57710e5507dcea8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:47:18 -0400 Subject: [PATCH 10/23] Remove FindAPPINDICATOR and FindLibNotify Delete legacy CMake find modules for AppIndicator and LibNotify --- cmake/FindAPPINDICATOR.cmake | 34 ---------------------- cmake/FindLibNotify.cmake | 55 ------------------------------------ 2 files changed, 89 deletions(-) delete mode 100644 cmake/FindAPPINDICATOR.cmake delete mode 100644 cmake/FindLibNotify.cmake diff --git a/cmake/FindAPPINDICATOR.cmake b/cmake/FindAPPINDICATOR.cmake deleted file mode 100644 index 94f6203..0000000 --- a/cmake/FindAPPINDICATOR.cmake +++ /dev/null @@ -1,34 +0,0 @@ -# Remmina - The GTK+ Remote Desktop Client -# -# Copyright (C) 2011 Marc-Andre Moreau -# Copyright (C) 2014-2015 Antenore Gatta, Fabio Castelli, Giovanni Panozzo -# Copyright (C) 2016-2023 Antenore Gatta, Giovanni Panozzo -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, -# Boston, MA 02110-1301, USA. - -include(FindPackageHandleStandardArgs) - -pkg_check_modules(APPINDICATOR ayatana-appindicator3-0.1) -if(APPINDICATOR_FOUND) - SET(APPINDICATOR_AYATANA 1) -else() - pkg_check_modules(APPINDICATOR appindicator3-0.1) - if(APPINDICATOR_FOUND) - SET(APPINDICATOR_LEGACY 1) - endif() -endif() - -mark_as_advanced(APPINDICATOR_INCLUDE_DIR APPINDICATOR_LIBRARY) diff --git a/cmake/FindLibNotify.cmake b/cmake/FindLibNotify.cmake deleted file mode 100644 index e76b199..0000000 --- a/cmake/FindLibNotify.cmake +++ /dev/null @@ -1,55 +0,0 @@ -# - 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 -) From edd9008c40fc20effb0bdda1672ab22a600e295b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:24:47 -0400 Subject: [PATCH 11/23] Add SVG support and improve Linux tray behavior Add SVG tray icon support and make Linux tray handling more robust: - CMake: include icons/*.svg, link Qt Svg (Qt5/Qt6), and copy icon.svg to build dir. - Add new icons/icon.svg asset. - src/tray_linux.cpp: improve Wayland detection, calculate menu position with preferred position, derive screen anchor, retry popup logic, robust icon resolution (file, pixmap, themed), set application/desktop names, and fallback standard icon. - tests/unit/test_tray.cpp: ensure test icons are copied into the test binary directory, add TestTrayIconSvgFile unit test. These changes fix issues with blank tray icons (Qt/SNI) and unreliable menu placement on Wayland, and add SVG icon support across platforms. --- CMakeLists.txt | 13 ++- icons/icon.svg | 4 + src/tray_linux.cpp | 220 ++++++++++++++++++++++++++++++--------- tests/unit/test_tray.cpp | 52 ++++++--- 4 files changed, 216 insertions(+), 73 deletions(-) create mode 100644 icons/icon.svg diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e35fd8..e2f2fa4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,7 +34,9 @@ find_package(PkgConfig) file(GLOB TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.ico" - "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png") + "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png" + "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.svg" +) if(WIN32) list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c") @@ -44,11 +46,11 @@ else() find_library(COCOA Cocoa REQUIRED) list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m") else() - find_package(Qt6 QUIET COMPONENTS Widgets DBus) + find_package(Qt6 QUIET COMPONENTS Widgets DBus Svg) if(Qt6_FOUND) set(TRAY_QT_VERSION 6) else() - find_package(Qt5 REQUIRED COMPONENTS Widgets DBus) + find_package(Qt5 REQUIRED COMPONENTS Widgets DBus Svg) set(TRAY_QT_VERSION 5) endif() set(CMAKE_AUTOMOC ON) @@ -74,9 +76,9 @@ else() else() list(APPEND TRAY_DEFINITIONS TRAY_QT=1) if(TRAY_QT_VERSION EQUAL 6) - list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus Qt6::Svg) else() - list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus Qt5::Svg) endif() endif() endif() @@ -89,6 +91,7 @@ target_link_libraries(tray_example tray::tray) configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico" "${CMAKE_CURRENT_BINARY_DIR}/icon.ico" COPYONLY) configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png" "${CMAKE_CURRENT_BINARY_DIR}/icon.png" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg" "${CMAKE_CURRENT_BINARY_DIR}/icon.svg" COPYONLY) INSTALL(TARGETS tray tray DESTINATION lib) diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..393f916 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 96f795d..2592c8a 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -11,6 +11,7 @@ // Qt includes #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -69,6 +71,39 @@ namespace { uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup void (*g_log_cb)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + bool is_wayland_session() { + const QString platform = QGuiApplication::platformName().toLower(); + if (platform.contains(QStringLiteral("wayland"))) { + return true; + } + return !qgetenv("WAYLAND_DISPLAY").isEmpty(); + } + + 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()); + } + + // Some compositors report no reserved panel area; top-right is a safer fallback than (0, 0). + return avail.topRight(); + } + /** * @brief Qt message handler that forwards to the registered log callback. * @param type The Qt message type. @@ -114,7 +149,7 @@ namespace { * * @return The point at which to show the context menu. */ - QPoint calculateMenuPosition() { + QPoint calculateMenuPosition(const QPoint &preferred_pos = QPoint()) { if (g_tray_icon != nullptr) { const QRect iconGeo = g_tray_icon->geometry(); if (iconGeo.isValid()) { @@ -122,40 +157,116 @@ namespace { } } + 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. - const bool wayland_session = !qgetenv("WAYLAND_DISPLAY").isEmpty(); + const bool wayland_session = is_wayland_session(); if (!wayland_session) { // Pure Xorg: QCursor::pos() is accurate. return QCursor::pos(); } - // Wayland session fallback: infer the panel edge from available vs full screen - // geometry and anchor the menu to that edge. popup() keeps the menu on-screen. - QScreen *screen = QGuiApplication::primaryScreen(); - if (screen != nullptr) { - const QRect full = screen->geometry(); - const QRect avail = screen->availableGeometry(); - if (avail.top() > full.top()) { - // Panel at top (e.g., GNOME default): anchor below the panel at the right edge. - return QPoint(avail.right(), avail.top()); + const QPoint cursor_pos = QCursor::pos(); + if (!cursor_pos.isNull()) { + QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos); + if (cursor_screen != nullptr) { + return cursor_pos; } - if (avail.bottom() < full.bottom()) { - // Panel at the bottom (e.g., KDE Plasma default): popup() flips upward automatically. - return QPoint(avail.right(), avail.bottom()); + } + + // Wayland session fallback: infer panel anchor from the relevant screen. + QScreen *screen = QGuiApplication::screenAt(cursor_pos); + if (screen == nullptr) { + screen = QGuiApplication::primaryScreen(); + } + const QPoint anchored = screen_anchor_point(screen); + if (!anchored.isNull()) { + return anchored; + } + + return cursor_pos; + } + + QIcon icon_from_source(const QString &icon_source) { + if (icon_source.isEmpty()) { + return QIcon(); + } + + const QFileInfo icon_fi(icon_source); + if (icon_fi.exists()) { + const QString file_path = icon_fi.absoluteFilePath(); + const QIcon file_icon(file_path); + if (!file_icon.isNull()) { + return file_icon; } - if (avail.left() > full.left()) { - // Panel on the left. - return QPoint(avail.left(), avail.bottom()); + + const QPixmap pixmap(file_path); + if (!pixmap.isNull()) { + QIcon icon; + icon.addPixmap(pixmap); + return icon; } - if (avail.right() < full.right()) { - // Panel on the right. - return QPoint(avail.right(), avail.bottom()); + } + + const QIcon themed = QIcon::fromTheme(icon_source); + if (!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; } } - return QCursor::pos(); + 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() { @@ -260,28 +371,43 @@ extern "C" { return -1; } + if (QCoreApplication::applicationName().isEmpty()) { + QCoreApplication::setApplicationName(QStringLiteral("tray")); + } + if (QCoreApplication::applicationDisplayName().isEmpty()) { + const QString display_name = + (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray"); + QCoreApplication::setApplicationDisplayName(display_name); + } + if (QGuiApplication::desktopFileName().isEmpty()) { + QString desktop_name = QCoreApplication::applicationName(); + if (!desktop_name.endsWith(QStringLiteral(".desktop"))) { + desktop_name += QStringLiteral(".desktop"); + } + QGuiApplication::setDesktopFileName(desktop_name); + } + // 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 to the next - // event-loop iteration via QTimer::singleShot(0). Deferring allows any + // 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. // activateWindow() gives the menu window X11 focus so that the subsequent // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg. QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::Trigger) { - const QPoint pos = calculateMenuPosition(); - QTimer::singleShot(0, g_tray_icon, [pos]() { - if (g_tray_icon != nullptr) { - QMenu *menu = g_tray_icon->contextMenu(); - if (menu != nullptr && !menu->isVisible()) { - menu->activateWindow(); - menu->popup(pos); - } - } - }); + const bool left_click_activation = + (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context); + + if (!left_click_activation) { + return; } + + const QPoint click_pos = QCursor::pos(); + QTimer::singleShot(30, g_tray_icon, [click_pos]() { + popup_menu_for_activation(click_pos); + }); }); // Defer D-Bus ActionInvoked handler setup to the first event-loop iteration. @@ -336,26 +462,18 @@ extern "C" { return; } - const QString icon_str = QString::fromUtf8(tray->icon); - QIcon icon; - const QFileInfo icon_fi(icon_str); - if (icon_fi.exists()) { - // Explicitly load via QPixmap so that the icon engine has pixmap data and - // availableSizes() is populated immediately. QIcon(filename) lazy-loads the - // pixmap, which leaves availableSizes() empty; Qt6's SNI tray backend then - // sees no sizes and sends no icon data, causing the tray icon to be blank. - const QPixmap pixmap(icon_fi.absoluteFilePath()); - if (!pixmap.isNull()) { - icon.addPixmap(pixmap); - } - } else { - icon = QIcon::fromTheme(icon_str); + 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); } // Only update the icon when the resolved icon is valid. Setting a null icon // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter // about QIcon::fromTheme when the name is not found in the active theme). - if (!icon.isNull()) { - g_tray_icon->setIcon(icon); + if (!tray_icon.isNull()) { + g_tray_icon->setIcon(tray_icon); } if (tray->tooltip != nullptr) { @@ -447,7 +565,7 @@ extern "C" { if (g_tray_icon != nullptr) { QMenu *menu = g_tray_icon->contextMenu(); if (menu != nullptr) { - menu->popup(calculateMenuPosition()); + popup_menu_for_activation(QPoint()); QApplication::processEvents(); } } diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index c2f9fe9..4d600cf 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -29,6 +29,7 @@ #if TRAY_QT constexpr const char *TRAY_ICON1 = "icon.png"; constexpr const char *TRAY_ICON2 = "icon.png"; +constexpr const char *TRAY_ICON_SVG = "icon.svg"; constexpr const char *TRAY_ICON_THEMED = "mail-message-new"; #elif TRAY_APPKIT constexpr const char *TRAY_ICON1 = "icon.png"; @@ -162,26 +163,33 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Ensure icon files exist in test binary directory std::filesystem::path projectRoot = testBinaryDir.parent_path(); - std::filesystem::path iconSource; - - if (std::filesystem::exists(projectRoot / "icons" / TRAY_ICON1)) { - iconSource = projectRoot / "icons" / TRAY_ICON1; - } else if (std::filesystem::exists(projectRoot / TRAY_ICON1)) { - iconSource = projectRoot / TRAY_ICON1; - } else if (std::filesystem::exists(std::filesystem::path(TRAY_ICON1))) { - iconSource = std::filesystem::path(TRAY_ICON1); - } + auto ensureIconInTestDir = [projectRoot, this](const char *iconName) { + std::filesystem::path iconSource; + + if (std::filesystem::exists(projectRoot / "icons" / iconName)) { + iconSource = projectRoot / "icons" / iconName; + } else if (std::filesystem::exists(projectRoot / iconName)) { + iconSource = projectRoot / iconName; + } else if (std::filesystem::exists(std::filesystem::path(iconName))) { + iconSource = std::filesystem::path(iconName); + } - if (!iconSource.empty()) { - std::filesystem::path iconDest = testBinaryDir / TRAY_ICON1; - if (!std::filesystem::exists(iconDest)) { - std::error_code ec; - std::filesystem::copy_file(iconSource, iconDest, ec); - if (ec) { - std::cout << "Warning: Failed to copy icon file: " << ec.message() << std::endl; + if (!iconSource.empty()) { + std::filesystem::path iconDest = testBinaryDir / iconName; + if (!std::filesystem::exists(iconDest)) { + std::error_code ec; + std::filesystem::copy_file(iconSource, iconDest, ec); + if (ec) { + std::cout << "Warning: Failed to copy icon file: " << ec.message() << std::endl; + } } } - } + }; + + ensureIconInTestDir(TRAY_ICON1); +#if defined(TRAY_QT) + ensureIconInTestDir(TRAY_ICON_SVG); +#endif trayRunning = false; testTray.icon = TRAY_ICON1; @@ -610,6 +618,16 @@ TEST_F(TrayTest, TestTrayIconThemed) { testTray.icon = TRAY_ICON1; } +TEST_F(TrayTest, TestTrayIconSvgFile) { + testTray.icon = TRAY_ICON_SVG; + int result = tray_init(&testTray); + trayRunning = (result == 0); + ASSERT_EQ(result, 0); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_svg")); + testTray.icon = TRAY_ICON1; +} + TEST_F(TrayTest, TestNotificationWithThemedIcon) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); From e79887d0f04da09b8b2da367f5268f6163644c20 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:33:39 -0400 Subject: [PATCH 12/23] CI: add Qt5/Qt6 matrix and conditional deps Add a matrix dimension for Qt version and run builds for both Qt5 and Qt6 on Ubuntu. The build job name now includes the Qt version, and qt_version is set per matrix entry. Linux dependency installation is made conditional on QT_VERSION (installing Qt5 or Qt6 packages accordingly) and QT_VERSION is exported into the job environment. Split CMake configure and ninja build into separate steps, make test environment/platformtheme conditional, and append Qt version to artifact names and codecov flags. Update README to document Qt5 and Qt6 package installation commands for supported distros. --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++----------- README.md | 21 ++++++++++++--- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d293e1..1c199fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} build: - name: Build (${{ matrix.os }}) + name: Build (${{ matrix.os }}${{ matrix.qt_version && format(', Qt{0}', matrix.qt_version) || '' }}) defaults: run: shell: ${{ matrix.shell }} @@ -49,10 +49,16 @@ jobs: include: - os: macos-latest shell: "bash" + qt_version: '' - os: ubuntu-latest shell: "bash" + qt_version: '5' + - os: ubuntu-latest + shell: "bash" + qt_version: '6' - os: windows-latest shell: "msys2 {0}" + qt_version: '' steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -61,17 +67,36 @@ jobs: - name: Setup Dependencies Linux if: runner.os == 'Linux' + env: + QT_VERSION: ${{ matrix.qt_version }} run: | + dependencies=( + "adwaita-icon-theme" + "build-essential" + "cmake" + "imagemagick" + "ninja-build" + "xvfb" + ) + + if [[ "${QT_VERSION}" == "5" ]]; then + dependencies+=( + "libqt5svg5-dev" + "qt5-gtk-platformtheme" + "qtbase5-dev" + ) + elif [[ "${QT_VERSION}" == "6" ]]; then + dependencies+=( + "qt6-base-dev" + "qt6-svg-dev" + ) + else + echo "Unsupported QT_VERSION: ${QT_VERSION}" + exit 1 + fi + sudo apt-get update - sudo apt-get install -y \ - adwaita-icon-theme \ - build-essential \ - cmake \ - imagemagick \ - ninja-build \ - qt5-gtk-platformtheme \ - qtbase5-dev \ - xvfb + sudo apt-get install -y "${dependencies[@]}" - name: Setup virtual desktop if: runner.os == 'Linux' @@ -162,7 +187,7 @@ jobs: echo "python-path=${python_path}" echo "python-path=${python_path}" >> "${GITHUB_OUTPUT}" - - name: Build + - name: Configure run: | mkdir -p build @@ -179,7 +204,9 @@ jobs: -B build \ -G Ninja \ -S . - ninja -C build + + - name: Build + run: ninja -C build - name: Init tray icon (Windows) if: runner.os == 'Windows' @@ -222,7 +249,7 @@ jobs: timeout-minutes: 3 working-directory: build/tests env: - QT_QPA_PLATFORMTHEME: gtk3 + QT_QPA_PLATFORMTHEME: ${{ runner.os == 'Linux' && matrix.qt_version == '5' && 'gtk3' || '' }} run: ./test_tray --gtest_color=yes --gtest_output=xml:test_results.xml - name: Upload screenshots @@ -231,7 +258,7 @@ jobs: (steps.test.outcome == 'success' || steps.test.outcome == 'failure') uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: tray-screenshots-${{ runner.os }} + name: tray-screenshots-${{ runner.os }}${{ matrix.qt_version && format('-qt{0}', matrix.qt_version) || '' }} path: build/tests/screenshots if-no-files-found: error @@ -258,7 +285,7 @@ jobs: - name: Set codecov flags id: codecov_flags run: | - flags="${{ runner.os }}" + flags="${{ runner.os }}${{ matrix.qt_version && format('-qt{0}', matrix.qt_version) || '' }}" echo "flags=${flags}" >> "${GITHUB_OUTPUT}" - name: Upload coverage diff --git a/README.md b/README.md index b104bb8..374478a 100644 --- a/README.md +++ b/README.md @@ -43,21 +43,36 @@ 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. +
- Arch ```bash - sudo pacman -S qt6-base + # Qt6 + sudo pacman -S qt6-base qt6-svg + + # Qt5 + sudo pacman -S qt5-base qt5-svg ``` - Debian/Ubuntu ```bash - sudo apt install qtbase5-dev + # Qt6 + sudo apt install qt6-base-dev qt6-svg-dev + + # Qt5 + sudo apt install qtbase5-dev libqt5svg5-dev ``` - Fedora ```bash - sudo dnf install qt6-qtbase-devel + # Qt6 + sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel + + # Qt5 + sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel ```
From a1f0a56d6671ff62cb89a3d1f2307200c0e385c7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:58:56 -0400 Subject: [PATCH 13/23] Use QGuiApplication for applicationDisplayName Replace QCoreApplication::applicationDisplayName and setApplicationDisplayName with QGuiApplication equivalents in src/tray_linux.cpp. This ensures the GUI-specific API is used when initializing the tray's display name (from tooltip or default), avoiding misuse of QCoreApplication for GUI-only display name operations. --- src/tray_linux.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 2592c8a..7b794b3 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -374,10 +374,10 @@ extern "C" { if (QCoreApplication::applicationName().isEmpty()) { QCoreApplication::setApplicationName(QStringLiteral("tray")); } - if (QCoreApplication::applicationDisplayName().isEmpty()) { + if (QGuiApplication::applicationDisplayName().isEmpty()) { const QString display_name = (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray"); - QCoreApplication::setApplicationDisplayName(display_name); + QGuiApplication::setApplicationDisplayName(display_name); } if (QGuiApplication::desktopFileName().isEmpty()) { QString desktop_name = QCoreApplication::applicationName(); From 21f70b5d74572b6bff59d54a6c4eb55773e045ab Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:18:14 -0400 Subject: [PATCH 14/23] Refactor CMake for subproject and icons Make CMakeLists usable as a subproject: detect top-level build (TRAY_IS_TOP_LEVEL) and gate BUILD_DOCS/BUILD_TESTS/BUILD_EXAMPLE on it. Don't override parent CMAKE_MODULE_PATH; append module path relative to current source dir. Centralize icon paths into TRAY_ICON_* variables, add tray_copy_default_icons() to copy icons at post-build, and install icons/headers. Use target_include_directories and expose compile definitions publicly for the library. Adjust Qt/Qt-version caching and other target properties. Update tests/CMakeLists.txt to use CMAKE_CURRENT_SOURCE_DIR paths, reference the googletest directory relatively, force shared CRT on Windows earlier, link the test target against tray::tray, add icon copying for the test binary, and register the test with add_test. --- CMakeLists.txt | 76 ++++++++++++++++++++++++++++++-------------- src/example.c | 21 +++--------- src/tray.h | 4 +-- src/tray_windows.c | 6 ++++ tests/CMakeLists.txt | 43 +++++++++++++------------ 5 files changed, 88 insertions(+), 62 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e2f2fa4..2feac35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,21 +9,25 @@ project(tray VERSION 0.0.0 set(PROJECT_LICENSE "MIT") +set(TRAY_IS_TOP_LEVEL OFF) +if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) + set(TRAY_IS_TOP_LEVEL ON) +endif() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) message(STATUS "Setting build type to 'Release' as none was specified.") set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE) endif() -# Add our custom CMake modules to the global path -set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") +# Add our custom CMake modules without overriding parent project settings. +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") # # Project optional configuration # -if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) - option(BUILD_DOCS "Build documentation" ON) - option(BUILD_TESTS "Build tests" ON) -endif() +option(BUILD_DOCS "Build documentation" ${TRAY_IS_TOP_LEVEL}) +option(BUILD_TESTS "Build tests" ${TRAY_IS_TOP_LEVEL}) +option(BUILD_EXAMPLE "Build example app" ${TRAY_IS_TOP_LEVEL}) # Generate 'compile_commands.json' for clang_complete set(CMAKE_COLOR_MAKEFILE ON) @@ -33,11 +37,36 @@ find_package(PkgConfig) file(GLOB TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" - "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.ico" - "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png" - "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.svg" ) +set(TRAY_ICON_ICO "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico") +set(TRAY_ICON_PNG "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png") +set(TRAY_ICON_SVG "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg") +set(TRAY_ICON_FILES + "${TRAY_ICON_ICO}" + "${TRAY_ICON_PNG}" + "${TRAY_ICON_SVG}" +) + +set(_TRAY_ICON_ICO "${TRAY_ICON_ICO}" CACHE INTERNAL "Default tray ICO icon path") +set(_TRAY_ICON_PNG "${TRAY_ICON_PNG}" CACHE INTERNAL "Default tray PNG icon path") +set(_TRAY_ICON_SVG "${TRAY_ICON_SVG}" CACHE INTERNAL "Default tray SVG icon path") + +# Copy default tray icon files into the output directory of the specified target. +function(tray_copy_default_icons target_name) + if(NOT TARGET "${target_name}") + message(FATAL_ERROR "tray_copy_default_icons expected an existing target: ${target_name}") + endif() + + foreach(icon_file IN LISTS TRAY_ICON_FILES) + add_custom_command(TARGET "${target_name}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${icon_file}" + "$" + COMMENT "Copying ${icon_file} to $") + endforeach() +endfunction() + if(WIN32) list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c") else() @@ -53,6 +82,10 @@ else() find_package(Qt5 REQUIRED COMPONENTS Widgets DBus Svg) set(TRAY_QT_VERSION 5) endif() + set(TRAY_QT_VERSION # cmake-lint: disable=C0103 + "${TRAY_QT_VERSION}" + CACHE INTERNAL "Qt major version selected by tray" + ) set(CMAKE_AUTOMOC ON) list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_linux.cpp") endif() @@ -62,19 +95,20 @@ endif() add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 14) +target_include_directories(${PROJECT_NAME} + PUBLIC + $ + $) if(WIN32) - list(APPEND TRAY_DEFINITIONS TRAY_WINAPI=1 WIN32_LEAN_AND_MEAN NOMINMAX) if(MSVC) list(APPEND TRAY_COMPILE_OPTIONS "/MT$<$:d>") endif() else() if(UNIX) if(APPLE) - list(APPEND TRAY_DEFINITIONS TRAY_APPKIT=1) list(APPEND TRAY_EXTERNAL_LIBRARIES ${COCOA}) else() - list(APPEND TRAY_DEFINITIONS TRAY_QT=1) if(TRAY_QT_VERSION EQUAL 6) list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus Qt6::Svg) else() @@ -86,20 +120,16 @@ endif() add_library(tray::tray ALIAS ${PROJECT_NAME}) -add_executable(tray_example "${CMAKE_CURRENT_SOURCE_DIR}/src/example.c") -target_link_libraries(tray_example tray::tray) - -configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico" "${CMAKE_CURRENT_BINARY_DIR}/icon.ico" COPYONLY) -configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png" "${CMAKE_CURRENT_BINARY_DIR}/icon.png" COPYONLY) -configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg" "${CMAKE_CURRENT_BINARY_DIR}/icon.svg" COPYONLY) +if(BUILD_EXAMPLE) + add_executable(tray_example "${CMAKE_CURRENT_SOURCE_DIR}/src/example.c") + target_link_libraries(tray_example tray::tray) + tray_copy_default_icons(tray_example) +endif() INSTALL(TARGETS tray tray DESTINATION lib) +INSTALL(FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray.h" DESTINATION include) +INSTALL(FILES ${TRAY_ICON_FILES} DESTINATION share/tray/icons) -IF(NOT WIN32) - INSTALL(FILES tray.h DESTINATION include) -ENDIF() - -target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_DEFINITIONS}) target_compile_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS}) target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) target_link_libraries(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_LIBRARIES}) diff --git a/src/example.c b/src/example.c index 7198593..411b5a9 100644 --- a/src/example.c +++ b/src/example.c @@ -6,26 +6,15 @@ #include #include -#if defined(_WIN32) || defined(_WIN64) - #define TRAY_WINAPI 1 ///< Use WinAPI. -#elif defined(__linux__) || defined(linux) || defined(__linux) - #define TRAY_QT 1 -#elif defined(__APPLE__) || defined(__MACH__) - #define TRAY_APPKIT 1 -#endif - // local includes #include "tray.h" -#if TRAY_QT - #define TRAY_ICON1 "icon.png" - #define TRAY_ICON2 "icon.png" -#elif TRAY_APPKIT - #define TRAY_ICON1 "icon.png" - #define TRAY_ICON2 "icon.png" -#elif TRAY_WINAPI +#if defined(_WIN32) #define TRAY_ICON1 "icon.ico" ///< Path to first icon. #define TRAY_ICON2 "icon.ico" ///< Path to second icon. +#else + #define TRAY_ICON1 "icon.png" + #define TRAY_ICON2 "icon.png" #endif static struct tray tray; @@ -62,9 +51,7 @@ static void submenu_cb(struct tray_menu *item) { // Test tray init static struct tray tray = { .icon = TRAY_ICON1, -#if TRAY_WINAPI .tooltip = "Tray", -#endif .menu = (struct tray_menu[]) { {.text = "Hello", .cb = hello_cb}, diff --git a/src/tray.h b/src/tray.h index 52d02a8..b1f5967 100644 --- a/src/tray.h +++ b/src/tray.h @@ -5,7 +5,7 @@ #ifndef TRAY_H #define TRAY_H -#if defined(TRAY_WINAPI) +#if defined(_WIN32) #include #endif @@ -98,7 +98,7 @@ extern "C" { */ void tray_set_log_callback(void (*cb)(int level, const char *msg)); -#if defined(TRAY_WINAPI) +#if defined(_WIN32) /** * @brief Get the tray window handle. * @return The window handle. diff --git a/src/tray_windows.c b/src/tray_windows.c index bcc7eec..cf2b2a6 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -3,6 +3,12 @@ * @brief System tray implementation for Windows. */ // standard includes +#ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN ///< Excludes rarely used APIs from Windows headers. +#endif +#ifndef NOMINMAX + #define NOMINMAX ///< Prevents Windows.h from defining min and max macros. +#endif #include // clang-format off // build fails if shellapi.h is included before Windows.h diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f12b98a..b48cac6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,21 +3,19 @@ cmake_minimum_required(VERSION 3.13) project(test_tray) -include_directories("${CMAKE_SOURCE_DIR}") - # Add GoogleTest directory to the project -set(GTEST_SOURCE_DIR "${CMAKE_SOURCE_DIR}/third-party/googletest") +set(GTEST_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third-party/googletest") + +# For Windows: prevent overriding parent compiler/linker runtime settings. +if(WIN32) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 +endif() + set(INSTALL_GTEST OFF) set(INSTALL_GMOCK OFF) add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest") include_directories("${GTEST_SOURCE_DIR}/googletest/include" "${GTEST_SOURCE_DIR}") -# if windows -if (WIN32) - # For Windows: Prevent overriding the parent project's compiler/linker settings - set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 -endif () - # extra libraries for tests if (APPLE) set(TEST_LIBS "-framework Cocoa") @@ -26,23 +24,28 @@ elseif (WIN32) endif() file(GLOB_RECURSE TEST_SOURCES - ${CMAKE_SOURCE_DIR}/tests/conftest.cpp - ${CMAKE_SOURCE_DIR}/tests/utils.cpp - ${CMAKE_SOURCE_DIR}/tests/screenshot_utils.cpp - ${CMAKE_SOURCE_DIR}/tests/test_*.cpp) + "${CMAKE_CURRENT_SOURCE_DIR}/conftest.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/utils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/screenshot_utils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_*.cpp" +) add_executable(${PROJECT_NAME} ${TEST_SOURCES} - ${TRAY_SOURCES}) +) set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) -target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) +target_include_directories(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/..") target_link_libraries(${PROJECT_NAME} ${TEST_LIBS} - ${TRAY_EXTERNAL_LIBRARIES} + tray::tray gtest - gtest_main) # if we use this we don't need our own main function -target_compile_definitions(${PROJECT_NAME} PUBLIC ${TRAY_DEFINITIONS} ${TEST_DEFINITIONS}) -target_compile_options(${PROJECT_NAME} PRIVATE $<$:${TRAY_COMPILE_OPTIONS}>) + gtest_main # if we use this we don't need our own main function +) +target_compile_definitions(${PROJECT_NAME} PUBLIC ${TEST_DEFINITIONS}) target_link_options(${PROJECT_NAME} PRIVATE) -add_test(NAME ${PROJECT_NAME} COMMAND tray_test) +tray_copy_default_icons(${PROJECT_NAME}) + +add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) From 437250060b78e5d54b93d7d1fd45e899b6a564f4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:20:07 -0400 Subject: [PATCH 15/23] Add tray_set_app_info and Qt thread fixes Add a new API tray_set_app_info to provide application metadata (name, display name, desktop file) used by the Linux/Qt backend and add no-op stubs for macOS and Windows. Improve Qt integration on Linux: add run_on_qt_thread helper, make g_exit_pending atomic, ensure all Qt GUI and teardown operations run on the Qt thread, and handle external event loops safely. Update notification/D-Bus handling and move tray updates, menu popup and simulated notification clicks onto the Qt thread to avoid cross-thread warnings. Also remove the QUIET flag from find_package(Qt6) in CMake and add necessary includes (atomic, utility, QThread). --- CMakeLists.txt | 2 +- src/tray.h | 15 ++ src/tray_darwin.m | 7 + src/tray_linux.cpp | 439 +++++++++++++++++++++++++++------------------ src/tray_windows.c | 7 + 5 files changed, 290 insertions(+), 180 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2feac35..959a780 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,7 +75,7 @@ else() find_library(COCOA Cocoa REQUIRED) list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m") else() - find_package(Qt6 QUIET COMPONENTS Widgets DBus Svg) + find_package(Qt6 COMPONENTS Widgets DBus Svg) if(Qt6_FOUND) set(TRAY_QT_VERSION 6) else() diff --git a/src/tray.h b/src/tray.h index b1f5967..29a82a1 100644 --- a/src/tray.h +++ b/src/tray.h @@ -98,6 +98,21 @@ extern "C" { */ void tray_set_log_callback(void (*cb)(int level, const char *msg)); + /** + * @brief Set application metadata used by the tray library. + * + * Must be called before tray_init(). On Linux (Qt), sets the Qt application + * name, display name, and desktop file name used for D-Bus registration. On + * other platforms this function is a no-op. + * + * @param app_name Application name. NULL uses the default ("tray"). + * @param app_display_name Display name shown in notifications. NULL derives + * from the tray tooltip or falls back to app_name. + * @param desktop_name Desktop file name for D-Bus. NULL appends ".desktop" + * to app_name. + */ + void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name); + #if defined(_WIN32) /** * @brief Get the tray window handle. diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 2fe9b35..7320a4f 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -140,6 +140,13 @@ void tray_set_log_callback(void (*cb)(int level, const char *msg)) { (void) cb; } +void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) { + // Application metadata is not applicable on macOS. + (void) app_name; + (void) app_display_name; + (void) desktop_name; +} + void tray_exit(void) { // Remove the status item from the status bar on the main thread // NSStatusBar operations must be performed on the main thread diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 7b794b3..aac8faa 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -3,8 +3,10 @@ * @brief System tray implementation for Linux using Qt. */ // standard includes +#include #include #include +#include // local includes #include "tray.h" @@ -24,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -67,9 +70,36 @@ namespace { 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 - bool g_exit_pending = 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 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 + + /** + * @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. + */ + template + void run_on_qt_thread(Func f) { + QCoreApplication *app = QCoreApplication::instance(); + if (app == nullptr || QThread::currentThread() == app->thread()) { + f(); + return; + } + QMetaObject::invokeMethod(app, std::move(f), Qt::BlockingQueuedConnection); + } bool is_wayland_session() { const QString platform = QGuiApplication::platformName().toLower(); @@ -360,237 +390,282 @@ extern "C" { g_app_owned = true; } - 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()) { + int result = 0; + run_on_qt_thread([tray, &result]() { destroy_tray(); - return -1; - } + g_loop_result = 0; + g_exit_pending = false; - if (QCoreApplication::applicationName().isEmpty()) { - QCoreApplication::setApplicationName(QStringLiteral("tray")); - } - if (QGuiApplication::applicationDisplayName().isEmpty()) { - const QString display_name = - (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray"); - QGuiApplication::setApplicationDisplayName(display_name); - } - if (QGuiApplication::desktopFileName().isEmpty()) { - QString desktop_name = QCoreApplication::applicationName(); - if (!desktop_name.endsWith(QStringLiteral(".desktop"))) { - desktop_name += QStringLiteral(".desktop"); - } - QGuiApplication::setDesktopFileName(desktop_name); - } - - // 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. - // activateWindow() gives the menu window X11 focus so that the subsequent - // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg. - QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { - const bool left_click_activation = - (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context); - - if (!left_click_activation) { + g_tray_icon = new QSystemTrayIcon(); // NOSONAR(cpp:S5025) - raw pointer; deleted in destroy_tray() before QApplication + + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + destroy_tray(); + result = -1; return; } - const QPoint click_pos = QCursor::pos(); - QTimer::singleShot(30, g_tray_icon, [click_pos]() { - popup_menu_for_activation(click_pos); - }); - }); + 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); + } + } + if (QGuiApplication::desktopFileName().isEmpty()) { + if (!g_desktop_name.isEmpty()) { + QGuiApplication::setDesktopFileName(g_desktop_name); + } else { + QString desktop_name = QCoreApplication::applicationName(); + if (!desktop_name.endsWith(QStringLiteral(".desktop"))) { + desktop_name += QStringLiteral(".desktop"); + } + QGuiApplication::setDesktopFileName(desktop_name); + } + } - // 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. Deferring via QTimer::singleShot - // ensures the socket notifiers are created while the event dispatcher is active. - if (g_notification_handler == nullptr) { - g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app() - QTimer::singleShot(0, g_notification_handler, []() { - if (g_notification_handler != nullptr) { - QDBusConnection::sessionBus().connect( - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("/org/freedesktop/Notifications"), - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("ActionInvoked"), - g_notification_handler, - SLOT(onActionInvoked(uint, QString)) - ); + // 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. + // activateWindow() gives the menu window X11 focus so that the subsequent + // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg. + QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { + const bool left_click_activation = + (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context); + + if (!left_click_activation) { + return; } + + const QPoint click_pos = QCursor::pos(); + QTimer::singleShot(30, g_tray_icon, [click_pos]() { + popup_menu_for_activation(click_pos); + }); }); - } - tray_update(tray); - g_tray_icon->show(); - return 0; + // 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. Deferring via QTimer::singleShot + // ensures the socket notifiers are created while the event dispatcher is active. + if (g_notification_handler == nullptr) { + g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app() + QTimer::singleShot(0, g_notification_handler, []() { + if (g_notification_handler != nullptr) { + QDBusConnection::sessionBus().connect( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("ActionInvoked"), + g_notification_handler, + SLOT(onActionInvoked(uint, QString)) + ); + } + }); + } + + tray_update(tray); + g_tray_icon->show(); + }); + return result; } int tray_loop(int blocking) { if (g_exit_pending) { g_exit_pending = false; - destroy_tray(); - destroy_app(); + run_on_qt_thread([]() { + destroy_tray(); + destroy_app(); + }); return g_loop_result; } if (blocking) { - QApplication::exec(); - if (g_exit_pending) { + 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; - destroy_tray(); - destroy_app(); + run_on_qt_thread([]() { + destroy_tray(); + destroy_app(); + }); } } else { - QApplication::processEvents(); + if (g_app_owned) { + QApplication::processEvents(); + } else { + 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) { - if (g_tray_icon == nullptr) { - return; - } + run_on_qt_thread([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); - } - // Only update the icon when the resolved icon is valid. Setting a null icon - // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter - // about QIcon::fromTheme when the name is not found in the active theme). - if (!tray_icon.isNull()) { - g_tray_icon->setIcon(tray_icon); - } + 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); + } + // Only update the icon when the resolved icon is valid. Setting a null icon + // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter + // about QIcon::fromTheme when the name is not found in the active theme). + if (!tray_icon.isNull()) { + g_tray_icon->setIcon(tray_icon); + } - if (tray->tooltip != nullptr) { - g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); - } + if (tray->tooltip != nullptr) { + g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); + } - if (tray->menu != nullptr) { - // setContextMenu does not take ownership; delete the old menu before replacing it. - QMenu *old_menu = g_tray_icon->contextMenu(); - QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update - g_tray_icon->setContextMenu(new_menu); - delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this - } + if (tray->menu != nullptr) { + // setContextMenu does not take ownership; delete the old menu before replacing it. + QMenu *old_menu = g_tray_icon->contextMenu(); + QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update + g_tray_icon->setContextMenu(new_menu); + delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this + } - const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); + const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); - // Reset previous notification state before setting up the new one. - if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = 0; - g_notification_handler->cb = nullptr; - } - QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); - close_notification(); - - if (!text.isEmpty()) { - const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); - const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; - QString icon; - if (icon_path != nullptr) { - QFileInfo fi(QString::fromUtf8(icon_path)); - icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path); - } - QVariantMap hints; - if (!icon.isEmpty()) { - hints[QStringLiteral("image-path")] = icon; + // Reset previous notification state before setting up the new one. + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = 0; + g_notification_handler->cb = nullptr; } - - QDBusInterface iface( - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("/org/freedesktop/Notifications"), - QStringLiteral("org.freedesktop.Notifications") - ); - if (iface.isValid()) { - // Include the "default" action so that clicking the notification body fires ActionInvoked. - // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications, - // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead. - QStringList actions; - if (tray->notification_cb != nullptr) { - actions << QStringLiteral("default") << QString(); + QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); + close_notification(); + + if (!text.isEmpty()) { + const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); + const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; + QString icon; + if (icon_path != nullptr) { + QFileInfo fi(QString::fromUtf8(icon_path)); + icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path); } - // Store the callback before calling Notify so tray_simulate_notification_click works - // even when the notification daemon is unavailable and the D-Bus reply is invalid. - if (g_notification_handler != nullptr) { - g_notification_handler->cb = tray->notification_cb; + QVariantMap hints; + if (!icon.isEmpty()) { + hints[QStringLiteral("image-path")] = icon; } - QDBusReply reply = iface.call( - QStringLiteral("Notify"), - QStringLiteral("tray"), - static_cast(0), - icon, - title, - text, - actions, - hints, - 5000 + + QDBusInterface iface( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications") ); - if (reply.isValid()) { - g_notification_id = reply.value(); + if (iface.isValid()) { + // Include the "default" action so that clicking the notification body fires ActionInvoked. + // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications, + // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead. + QStringList actions; + if (tray->notification_cb != nullptr) { + actions << QStringLiteral("default") << QString(); + } + // Store the callback before calling Notify so tray_simulate_notification_click works + // even when the notification daemon is unavailable and the D-Bus reply is invalid. if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = g_notification_id; + g_notification_handler->cb = tray->notification_cb; } - } - } else { - // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal. - 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) { - g_notification_handler->cb(); + QDBusReply reply = iface.call( + QStringLiteral("Notify"), + QStringLiteral("tray"), + static_cast(0), + icon, + title, + text, + actions, + hints, + 5000 + ); + if (reply.isValid()) { + g_notification_id = reply.value(); + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = g_notification_id; } - }); + } + } else { + // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal. + 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) { + g_notification_handler->cb(); + } + }); + } + g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); } - g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); } - } + }); } void tray_show_menu(void) { - if (g_tray_icon != nullptr) { - QMenu *menu = g_tray_icon->contextMenu(); - if (menu != nullptr) { - popup_menu_for_activation(QPoint()); - QApplication::processEvents(); + run_on_qt_thread([]() { + if (g_tray_icon != nullptr) { + QMenu *menu = g_tray_icon->contextMenu(); + if (menu != nullptr) { + popup_menu_for_activation(QPoint()); + QApplication::processEvents(); + } } - } + }); } void tray_simulate_notification_click(void) { - 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(); + 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_exit(void) { g_loop_result = -1; g_exit_pending = true; - if (g_app_owned && QApplication::instance() != nullptr) { - QApplication::quit(); + if (g_app_owned) { + run_on_qt_thread([]() { + if (QApplication::instance() != nullptr) { + QApplication::quit(); + } + }); } } @@ -603,6 +678,12 @@ extern "C" { } } + 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 diff --git a/src/tray_windows.c b/src/tray_windows.c index cf2b2a6..87451b1 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -347,6 +347,13 @@ void tray_set_log_callback(void (*cb)(int level, const char *msg)) { (void) cb; } +void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) { + // Application metadata is not applicable on Windows. + (void) app_name; + (void) app_display_name; + (void) desktop_name; +} + void tray_exit(void) { Shell_NotifyIconW(NIM_DELETE, &nid); SendMessage(hwnd, WM_CLOSE, 0, 0); From 214ea00120fcd7cbe0a68e994a53d7318ee33643 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:22:35 -0400 Subject: [PATCH 16/23] Fix tray callback issues --- src/tray.h | 19 ++++- src/tray_darwin.m | 5 ++ src/tray_linux.cpp | 156 +++++++++++++++++++++++++++++++++------ src/tray_windows.c | 5 ++ tests/unit/test_tray.cpp | 38 ++++++++++ 5 files changed, 197 insertions(+), 26 deletions(-) diff --git a/src/tray.h b/src/tray.h index 29a82a1..513ffc3 100644 --- a/src/tray.h +++ b/src/tray.h @@ -81,6 +81,17 @@ extern "C" { */ void tray_simulate_notification_click(void); + /** + * @brief Simulate clicking a top-level menu item by index (for testing purposes). + * + * On Linux (Qt): triggers the QAction associated with the given top-level menu + * index (separators and submenus are ignored). + * On other platforms: no-op. + * + * @param index Zero-based index in the top-level tray menu. + */ + void tray_simulate_menu_item_click(int index); + /** * @brief Terminate UI loop. */ @@ -105,9 +116,11 @@ extern "C" { * name, display name, and desktop file name used for D-Bus registration. On * other platforms this function is a no-op. * - * @param app_name Application name. NULL uses the default ("tray"). - * @param app_display_name Display name shown in notifications. NULL derives - * from the tray tooltip or falls back to app_name. + * @param app_name Application name used as a technical identifier (e.g., for + * D-Bus registration). Converted to lowercase automatically. NULL uses the + * default ("tray"). + * @param app_display_name Human-readable name shown in notifications and UI. + * NULL derives from the tray tooltip or falls back to app_name. * @param desktop_name Desktop file name for D-Bus. NULL appends ".desktop" * to app_name. */ diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 7320a4f..ca811cf 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -135,6 +135,11 @@ void tray_simulate_notification_click(void) { // Simulation is not supported here. } +void tray_simulate_menu_item_click(int index) { + // Programmatic menu-item simulation is not supported here. + (void) index; +} + void tray_set_log_callback(void (*cb)(int level, const char *msg)) { // Qt is not used on macOS; log routing is not applicable. (void) cb; diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index aac8faa..b26533e 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -12,12 +12,15 @@ #include "tray.h" // Qt includes +#include #include #include #include #include #include -#include +#include +#include +#include #include #include #include @@ -303,15 +306,16 @@ namespace { if (g_notification_id == 0) { return; } + const uint id_to_close = g_notification_id; + g_notification_id = 0; QDBusInterface iface( QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("/org/freedesktop/Notifications"), QStringLiteral("org.freedesktop.Notifications") ); if (iface.isValid()) { - iface.call(QStringLiteral("CloseNotification"), g_notification_id); + iface.asyncCall(QStringLiteral("CloseNotification"), id_to_close); } - g_notification_id = 0; } QMenu *build_menu(struct tray_menu *m, QWidget *parent) { @@ -322,7 +326,8 @@ namespace { } else if (m->submenu != nullptr) { QMenu *sub = build_menu(m->submenu, menu); sub->setTitle(QString::fromUtf8(m->text)); - menu->addMenu(sub); + 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); @@ -330,17 +335,79 @@ namespace { action->setCheckable(true); action->setChecked(m->checked != 0); } - if (m->cb != nullptr) { - struct tray_menu *item = m; - QObject::connect(action, &QAction::triggered, [item]() { + action->setData(QVariant::fromValue(reinterpret_cast(m))); + QObject::connect(action, &QAction::triggered, menu, [action]() { + auto *item = reinterpret_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; + } + + QAction *action = actions.at(action_index++); + if (std::strcmp(item->text, "-") == 0) { + if (!action->isSeparator()) { + return false; + } + continue; + } + + if (item->submenu != nullptr) { + 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(); + } + + void update_menu_state(QMenu *menu, struct tray_menu *items) { + if (menu == nullptr || items == nullptr) { + return; + } + + const QList actions = menu->actions(); + int action_index = 0; + for (struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) { + QAction *action = actions.at(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(reinterpret_cast(item))); + } + } + void destroy_tray() { close_notification(); if (g_notification_handler != nullptr) { @@ -353,7 +420,10 @@ namespace { g_tray_icon->setContextMenu(nullptr); delete g_tray_icon; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication is destroyed g_tray_icon = nullptr; - delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu + if (menu != nullptr) { + menu->hide(); + delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu + } } } @@ -547,11 +617,21 @@ extern "C" { } if (tray->menu != nullptr) { - // setContextMenu does not take ownership; delete the old menu before replacing it. - QMenu *old_menu = g_tray_icon->contextMenu(); - QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update - g_tray_icon->setContextMenu(new_menu); - delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this + 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); + } else { + // 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 old_menu path or on next update + g_tray_icon->setContextMenu(new_menu); + if (existing_menu != nullptr) { + // 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 + } + } } const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); @@ -590,14 +670,18 @@ extern "C" { if (tray->notification_cb != nullptr) { actions << QStringLiteral("default") << QString(); } - // Store the callback before calling Notify so tray_simulate_notification_click works + // Store the callback before the async Notify so tray_simulate_notification_click works // even when the notification daemon is unavailable and the D-Bus reply is invalid. if (g_notification_handler != nullptr) { g_notification_handler->cb = tray->notification_cb; } - QDBusReply reply = iface.call( + // Use asyncCall to avoid entering a nested Qt event loop while waiting for the D-Bus + // reply. A synchronous call here would allow re-entrant tray_update dispatches from + // other threads (via BlockingQueuedConnection), which can delete and replace the context + // QMenu mid-build, breaking all QAction signal connections. + QDBusPendingCall pending = iface.asyncCall( QStringLiteral("Notify"), - QStringLiteral("tray"), + QGuiApplication::applicationDisplayName(), static_cast(0), icon, title, @@ -606,12 +690,17 @@ extern "C" { hints, 5000 ); - if (reply.isValid()) { - g_notification_id = reply.value(); - if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = g_notification_id; + auto *watcher = new QDBusPendingCallWatcher(pending); // NOSONAR(cpp:S5025) - deleted via deleteLater in finished handler + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [watcher]() { + const QDBusPendingReply reply = *watcher; + if (reply.isValid() && g_tray_icon != nullptr) { + g_notification_id = reply.value(); + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = g_notification_id; + } } - } + watcher->deleteLater(); + }); } else { // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal. if (tray->notification_cb != nullptr && g_notification_handler != nullptr) { @@ -657,6 +746,27 @@ extern "C" { }); } + void tray_simulate_menu_item_click(int index) { + run_on_qt_thread([index]() { + if (g_tray_icon == nullptr || index < 0) { + return; + } + 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; diff --git a/src/tray_windows.c b/src/tray_windows.c index 87451b1..bae5684 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -342,6 +342,11 @@ void tray_simulate_notification_click(void) { // Simulating this from outside the message pump is not supported here. } +void tray_simulate_menu_item_click(int index) { + // Programmatic menu-item simulation is not supported here. + (void) index; +} + void tray_set_log_callback(void (*cb)(int level, const char *msg)) { // Qt is not used on Windows; log routing is not applicable. (void) cb; diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 4d600cf..b4aebc2 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -692,4 +692,42 @@ TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { tray_update(&testTray); } +TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { + static int callbackCount = 0; + callbackCount = 0; + + auto first_item_callback = [](struct tray_menu *item) { // NOSONAR(cpp:S1172) - unused param required by tray_menu.cb function pointer type + callbackCount++; + (void) item; + }; + + void (*original_cb)(struct tray_menu *) = testTray.menu[0].cb; + testTray.menu[0].cb = first_item_callback; + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + tray_simulate_menu_item_click(0); + tray_loop(0); + EXPECT_EQ(callbackCount, 1); + + testTray.notification_title = "Menu Callback Regression"; + testTray.notification_text = "Notification update should not break menu callbacks"; + testTray.notification_icon = TRAY_ICON1; + tray_update(&testTray); + WaitForTrayReady(); + + tray_simulate_menu_item_click(0); + tray_loop(0); + EXPECT_EQ(callbackCount, 2); + + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + tray_update(&testTray); + + testTray.menu[0].cb = original_cb; +} + #endif // TRAY_QT From 144dad5123dd329425f10bc6b9050ca914a09911 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:42:20 -0400 Subject: [PATCH 17/23] style: sonar fixes --- CMakeLists.txt | 2 +- src/tray_linux.cpp | 517 +++++++++++++++++++++------------------ tests/unit/test_tray.cpp | 2 +- 3 files changed, 281 insertions(+), 240 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 959a780..e1beb35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,7 +94,7 @@ endif() add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) -set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 14) +set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 17) target_include_directories(${PROJECT_NAME} PUBLIC $ diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index b26533e..faa13b0 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include /** @@ -60,7 +61,7 @@ public slots: * @param id The notification ID. * @param action_key The action key that was triggered. */ - void onActionInvoked(uint id, const QString &action_key) { + void onActionInvoked(uint id, const QString &action_key) const { if (id == notification_id && cb != nullptr && action_key == QLatin1String("default")) { cb(); } @@ -105,8 +106,8 @@ namespace { } bool is_wayland_session() { - const QString platform = QGuiApplication::platformName().toLower(); - if (platform.contains(QStringLiteral("wayland"))) { + if (const QString platform = QGuiApplication::platformName().toLower(); + platform.contains(QStringLiteral("wayland"))) { return true; } return !qgetenv("WAYLAND_DISPLAY").isEmpty(); @@ -197,27 +198,25 @@ namespace { // 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. - const bool wayland_session = is_wayland_session(); - if (!wayland_session) { + 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()) { - QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos); + 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. - QScreen *screen = QGuiApplication::screenAt(cursor_pos); + const QScreen *screen = QGuiApplication::screenAt(cursor_pos); if (screen == nullptr) { screen = QGuiApplication::primaryScreen(); } - const QPoint anchored = screen_anchor_point(screen); - if (!anchored.isNull()) { + if (const QPoint anchored = screen_anchor_point(screen); !anchored.isNull()) { return anchored; } @@ -229,11 +228,9 @@ namespace { return QIcon(); } - const QFileInfo icon_fi(icon_source); - if (icon_fi.exists()) { + if (const QFileInfo icon_fi(icon_source); icon_fi.exists()) { const QString file_path = icon_fi.absoluteFilePath(); - const QIcon file_icon(file_path); - if (!file_icon.isNull()) { + if (const QIcon file_icon(file_path); !file_icon.isNull()) { return file_icon; } @@ -245,8 +242,7 @@ namespace { } } - const QIcon themed = QIcon::fromTheme(icon_source); - if (!themed.isNull()) { + if (const QIcon themed = QIcon::fromTheme(icon_source); !themed.isNull()) { return themed; } @@ -335,9 +331,9 @@ namespace { action->setCheckable(true); action->setChecked(m->checked != 0); } - action->setData(QVariant::fromValue(reinterpret_cast(m))); + action->setData(QVariant::fromValue(static_cast(m))); QObject::connect(action, &QAction::triggered, menu, [action]() { - auto *item = reinterpret_cast(action->data().value()); + auto *item = static_cast(action->data().value()); if (item != nullptr && item->cb != nullptr) { item->cb(item); } @@ -359,7 +355,9 @@ namespace { return false; } - QAction *action = actions.at(action_index++); + 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; @@ -368,7 +366,7 @@ namespace { } if (item->submenu != nullptr) { - QMenu *submenu = action->menu(); + const QMenu *submenu = action->menu(); if (submenu == nullptr || !menu_layout_matches(submenu, item->submenu)) { return false; } @@ -380,7 +378,7 @@ namespace { return action_index == actions.size(); } - void update_menu_state(QMenu *menu, struct tray_menu *items) { + void update_menu_state(const QMenu *menu, struct tray_menu *items) { if (menu == nullptr || items == nullptr) { return; } @@ -388,7 +386,9 @@ namespace { const QList actions = menu->actions(); int action_index = 0; for (struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) { - QAction *action = actions.at(action_index++); + const int current_action_index = action_index; + action_index++; + QAction *action = actions.at(current_action_index); if (std::strcmp(item->text, "-") == 0) { continue; } @@ -404,10 +404,260 @@ namespace { if (item->checkbox != 0) { action->setChecked(item->checked != 0); } - action->setData(QVariant::fromValue(reinterpret_cast(item))); + action->setData(QVariant::fromValue(static_cast(item))); } } + 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); + } + } + + if (!QGuiApplication::desktopFileName().isEmpty()) { + return; + } + + if (!g_desktop_name.isEmpty()) { + QGuiApplication::setDesktopFileName(g_desktop_name); + return; + } + + QString desktop_name = QCoreApplication::applicationName(); + if (!desktop_name.endsWith(QStringLiteral(".desktop"))) { + desktop_name += QStringLiteral(".desktop"); + } + QGuiApplication::setDesktopFileName(desktop_name); + } + + 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; + } + + 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)) + ); + }); + } + + 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) { + 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 + } + + void reset_notification_state() { + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = 0; + g_notification_handler->cb = 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 QDBusPendingReply reply = *watcher; + if (reply.isValid() && g_tray_icon != nullptr) { + g_notification_id = reply.value(); + 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) { + 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, [](QDBusPendingCallWatcher *finished) { + handle_notification_reply(finished); + }); + return true; + } + + 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(); + }); + } + 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()) { + return; + } + + 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)) { + // D-Bus may be unavailable; fall back to Qt's built-in balloon. + send_qt_notification_fallback(tray, title, text); + } + } + + 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; + return; + } + + configure_app_metadata(tray); + connect_activation_handler(); + ensure_notification_handler_connected(); + update_tray_state(tray); + g_tray_icon->show(); + } + void destroy_tray() { close_notification(); if (g_notification_handler != nullptr) { @@ -429,14 +679,6 @@ namespace { void destroy_app() { if (g_notification_handler != nullptr) { - QDBusConnection::sessionBus().disconnect( - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("/org/freedesktop/Notifications"), - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("ActionInvoked"), - g_notification_handler, - SLOT(onActionInvoked(uint, QString)) - ); delete g_notification_handler; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication g_notification_handler = nullptr; } @@ -462,89 +704,7 @@ extern "C" { int result = 0; run_on_qt_thread([tray, &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; - return; - } - - 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); - } - } - if (QGuiApplication::desktopFileName().isEmpty()) { - if (!g_desktop_name.isEmpty()) { - QGuiApplication::setDesktopFileName(g_desktop_name); - } else { - QString desktop_name = QCoreApplication::applicationName(); - if (!desktop_name.endsWith(QStringLiteral(".desktop"))) { - desktop_name += QStringLiteral(".desktop"); - } - QGuiApplication::setDesktopFileName(desktop_name); - } - } - - // 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. - // activateWindow() gives the menu window X11 focus so that the subsequent - // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg. - QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { - const bool left_click_activation = - (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context); - - if (!left_click_activation) { - return; - } - - const QPoint click_pos = QCursor::pos(); - QTimer::singleShot(30, g_tray_icon, [click_pos]() { - popup_menu_for_activation(click_pos); - }); - }); - - // 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. Deferring via QTimer::singleShot - // ensures the socket notifiers are created while the event dispatcher is active. - if (g_notification_handler == nullptr) { - g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app() - QTimer::singleShot(0, g_notification_handler, []() { - if (g_notification_handler != nullptr) { - QDBusConnection::sessionBus().connect( - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("/org/freedesktop/Notifications"), - QStringLiteral("org.freedesktop.Notifications"), - QStringLiteral("ActionInvoked"), - g_notification_handler, - SLOT(onActionInvoked(uint, QString)) - ); - } - }); - } - - tray_update(tray); - g_tray_icon->show(); + initialize_tray(tray, &result); }); return result; } @@ -582,7 +742,7 @@ extern "C" { if (g_app_owned) { QApplication::processEvents(); } else { - QCoreApplication *app_inst = QCoreApplication::instance(); + const QCoreApplication *app_inst = QCoreApplication::instance(); if (app_inst != nullptr && QThread::currentThread() == app_inst->thread()) { QApplication::processEvents(); } @@ -592,135 +752,16 @@ extern "C" { return g_loop_result; } - void tray_update(struct tray *tray) { + void tray_update(struct tray *tray) { // NOSONAR(cpp:S995) - C API requires this exact mutable-pointer signature run_on_qt_thread([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); - } - // Only update the icon when the resolved icon is valid. Setting a null icon - // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter - // about QIcon::fromTheme when the name is not found in the active theme). - if (!tray_icon.isNull()) { - g_tray_icon->setIcon(tray_icon); - } - - if (tray->tooltip != nullptr) { - g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); - } - - if (tray->menu != nullptr) { - 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); - } else { - // 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 old_menu path or on next update - g_tray_icon->setContextMenu(new_menu); - if (existing_menu != nullptr) { - // 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 - } - } - } - - const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); - - // Reset previous notification state before setting up the new one. - if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = 0; - g_notification_handler->cb = nullptr; - } - QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); - close_notification(); - - if (!text.isEmpty()) { - const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); - const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; - QString icon; - if (icon_path != nullptr) { - QFileInfo fi(QString::fromUtf8(icon_path)); - icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path); - } - 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()) { - // Include the "default" action so that clicking the notification body fires ActionInvoked. - // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications, - // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead. - QStringList actions; - if (tray->notification_cb != nullptr) { - actions << QStringLiteral("default") << QString(); - } - // Store the callback before the async Notify so tray_simulate_notification_click works - // even when the notification daemon is unavailable and the D-Bus reply is invalid. - if (g_notification_handler != nullptr) { - g_notification_handler->cb = tray->notification_cb; - } - // Use asyncCall to avoid entering a nested Qt event loop while waiting for the D-Bus - // reply. A synchronous call here would allow re-entrant tray_update dispatches from - // other threads (via BlockingQueuedConnection), which can delete and replace the context - // QMenu mid-build, breaking all QAction signal connections. - 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, [watcher]() { - const QDBusPendingReply reply = *watcher; - if (reply.isValid() && g_tray_icon != nullptr) { - g_notification_id = reply.value(); - if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = g_notification_id; - } - } - watcher->deleteLater(); - }); - } else { - // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal. - 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) { - g_notification_handler->cb(); - } - }); - } - g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); - } - } + update_tray_state(tray); }); } void tray_show_menu(void) { run_on_qt_thread([]() { if (g_tray_icon != nullptr) { - QMenu *menu = g_tray_icon->contextMenu(); + const QMenu *menu = g_tray_icon->contextMenu(); if (menu != nullptr) { popup_menu_for_activation(QPoint()); QApplication::processEvents(); @@ -751,7 +792,7 @@ extern "C" { if (g_tray_icon == nullptr || index < 0) { return; } - QMenu *menu = g_tray_icon->contextMenu(); + const QMenu *menu = g_tray_icon->contextMenu(); if (menu == nullptr) { return; } @@ -779,7 +820,7 @@ extern "C" { } } - void tray_set_log_callback(void (*cb)(int level, const char *msg)) { + 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); diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index b4aebc2..0db948a 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -163,7 +163,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Ensure icon files exist in test binary directory std::filesystem::path projectRoot = testBinaryDir.parent_path(); - auto ensureIconInTestDir = [projectRoot, this](const char *iconName) { + auto ensureIconInTestDir = [&projectRoot, this](const char *iconName) { std::filesystem::path iconSource; if (std::filesystem::exists(projectRoot / "icons" / iconName)) { From e8bc13af7befcbc6916ade663fdac4a1107f4636 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:22:32 -0400 Subject: [PATCH 18/23] test: improve linux test coverage --- tests/unit/test_tray_linux.cpp | 275 +++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/unit/test_tray_linux.cpp diff --git a/tests/unit/test_tray_linux.cpp b/tests/unit/test_tray_linux.cpp new file mode 100644 index 0000000..47d3774 --- /dev/null +++ b/tests/unit/test_tray_linux.cpp @@ -0,0 +1,275 @@ +// test includes +#include "tests/conftest.cpp" + +// local includes +#include "src/tray.h" + +#if defined(__linux__) || defined(linux) || defined(__linux) + + // standard includes + #include + #include + #include + #include + +namespace { + int g_menu_callback_count = 0; + int g_notification_callback_count = 0; + int g_log_callback_count = 0; + + void menu_item_cb([[maybe_unused]] struct tray_menu *item) { + g_menu_callback_count++; + } + + void notification_cb() { + g_notification_callback_count++; + } + + void log_cb([[maybe_unused]] int level, [[maybe_unused]] const char *msg) { + g_log_callback_count++; + } +} // namespace + +class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture members/methods are accessed by TEST_F-generated subclasses +protected: // NOSONAR(cpp:S3656) - TEST_F requires protected fixture visibility + void SetUp() override { + LinuxTest::SetUp(); + + tray_set_log_callback(nullptr); + tray_set_app_info(nullptr, nullptr, nullptr); + + g_menu_callback_count = 0; + g_notification_callback_count = 0; + g_log_callback_count = 0; + + submenuItems = {{{.text = "Nested", .cb = menu_item_cb}, {.text = nullptr}}}; + + menuItems = {{{.text = "Clickable", .cb = menu_item_cb}, {.text = "-"}, {.text = "Submenu", .submenu = submenuItems.data()}, {.text = "Disabled", .disabled = 1, .cb = menu_item_cb}, {.text = "Second Clickable", .cb = menu_item_cb}, {.text = nullptr}}}; + + trayData.icon = "icon.png"; + trayData.tooltip = "Linux Tray Coverage"; + trayData.notification_icon = nullptr; + trayData.notification_text = nullptr; + trayData.notification_title = nullptr; + trayData.notification_cb = nullptr; + trayData.menu = menuItems.data(); + } + + void TearDown() override { + if (trayRunning) { + tray_exit(); + tray_loop(0); + trayRunning = false; + } + + tray_set_log_callback(nullptr); + LinuxTest::TearDown(); + } + + void InitTray() { + const int initResult = tray_init(&trayData); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + } + + void PumpEvents(int iterations = 20) { + for (int i = 0; i < iterations; i++) { + tray_loop(0); + } + } + + bool trayRunning {false}; + std::array menuItems {}; + std::array submenuItems {}; + struct tray trayData {}; +}; + +TEST_F(TrayLinuxCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { + InitTray(); + + tray_simulate_menu_item_click(-1); + tray_simulate_menu_item_click(99); + tray_simulate_menu_item_click(1); + tray_simulate_menu_item_click(2); + tray_simulate_menu_item_click(3); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 0); + + tray_simulate_menu_item_click(0); + tray_simulate_menu_item_click(4); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 2); +} + +TEST_F(TrayLinuxCoverageTest, ApiCallsAreNoOpsBeforeInit) { + tray_update(&trayData); + tray_show_menu(); + tray_simulate_menu_item_click(0); + tray_simulate_notification_click(); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 0); + EXPECT_EQ(g_notification_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { + trayData.menu = nullptr; + InitTray(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, SetAppInfoAppliesExplicitMetadata) { + tray_set_app_info("tray-linux-tests", "Tray Linux Tests", "tray-linux-tests.desktop"); + InitTray(); + + // Trigger an update to exercise metadata-dependent notification/tray code paths. + trayData.notification_title = "Metadata Test"; + trayData.notification_text = "Using explicit metadata"; + tray_update(&trayData); + PumpEvents(); +} + +TEST_F(TrayLinuxCoverageTest, SetAppInfoDefaultsUseFallbackValues) { + tray_set_app_info(nullptr, nullptr, nullptr); + trayData.tooltip = "Tooltip Display Name"; + InitTray(); + + trayData.notification_title = "Default Metadata Test"; + trayData.notification_text = "Using fallback metadata"; + tray_update(&trayData); + PumpEvents(); +} + +TEST_F(TrayLinuxCoverageTest, LogCallbackCanBeSetAndReset) { + InitTray(); + tray_set_log_callback(log_cb); + + // The callback is currently installed; this update path should remain stable. + trayData.tooltip = "Log callback installed"; + tray_update(&trayData); + PumpEvents(); + + EXPECT_EQ(g_log_callback_count, 0); + + tray_set_log_callback(nullptr); + + trayData.tooltip = "Log callback removed"; + tray_update(&trayData); + PumpEvents(); + + EXPECT_EQ(g_log_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, TrayExitCausesLoopToReturnExitCode) { + InitTray(); + + tray_exit(); + const int loopResult = tray_loop(0); + trayRunning = false; + + EXPECT_EQ(loopResult, -1); +} + +TEST_F(TrayLinuxCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { + InitTray(); + + menuItems[0].text = "Clickable Renamed"; + menuItems[0].disabled = 1; + tray_update(&trayData); + PumpEvents(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(g_menu_callback_count, 0); + + menuItems[0].disabled = 0; + tray_update(&trayData); + PumpEvents(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(g_menu_callback_count, 1); +} + +TEST_F(TrayLinuxCoverageTest, ResolveTrayIconFromIconPathArray) { + // Build a tray struct with iconPathCount/allIconPaths to exercise fallback icon resolution. + const size_t iconCount = 2; + const size_t bufSize = sizeof(struct tray) + iconCount * sizeof(const char *); + std::vector buf(bufSize, std::byte {0}); + auto *iconPathTray = reinterpret_cast(buf.data()); // NOSONAR(cpp:S3630) - reinterpret_cast is required to map a C flexible-array struct over raw storage + + iconPathTray->icon = "missing-icon-name"; + iconPathTray->tooltip = "Icon path fallback"; + iconPathTray->notification_icon = nullptr; + iconPathTray->notification_text = nullptr; + iconPathTray->notification_title = nullptr; + iconPathTray->notification_cb = nullptr; + iconPathTray->menu = menuItems.data(); + + const int countVal = static_cast(iconCount); + std::memcpy(const_cast(&iconPathTray->iconPathCount), &countVal, sizeof(countVal)); // NOSONAR(cpp:S859) - const member initialization is required for this C interop allocation pattern + const char *badIcon = "missing-icon-name"; + const char *goodIcon = "icon.png"; + std::memcpy(const_cast(&iconPathTray->allIconPaths[0]), &badIcon, sizeof(badIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries + std::memcpy(const_cast(&iconPathTray->allIconPaths[1]), &goodIcon, sizeof(goodIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries + + const int initResult = tray_init(iconPathTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + tray_update(iconPathTray); + PumpEvents(); +} + +TEST_F(TrayLinuxCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) { + InitTray(); + + trayData.notification_title = "No callback notification"; + trayData.notification_text = "Should not invoke callback"; + trayData.notification_icon = "icon.png"; + trayData.notification_cb = nullptr; + + tray_update(&trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + + EXPECT_EQ(g_notification_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { + InitTray(); + + trayData.notification_title = "Linux Notification"; + trayData.notification_text = "Notification body"; + trayData.notification_icon = "mail-message-new"; + trayData.notification_cb = notification_cb; + + tray_update(&trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + EXPECT_EQ(g_notification_callback_count, 1); + + trayData.notification_title = nullptr; + trayData.notification_text = nullptr; + trayData.notification_icon = nullptr; + trayData.notification_cb = nullptr; + + tray_update(&trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + EXPECT_EQ(g_notification_callback_count, 1); +} + +#endif From 0e634d518634da88a18290dbb99f6d139ca1939e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:59:54 -0400 Subject: [PATCH 19/23] Wrap installs in TRAY_IS_TOP_LEVEL check Only perform install steps when the project is top-level by wrapping install commands in if(TRAY_IS_TOP_LEVEL). Also normalize to lowercase install() and remove the duplicated target name (was INSTALL(TARGETS tray tray ...)). This prevents unintended installs when the project is used as a subdirectory while preserving header and icon installations. --- CMakeLists.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e1beb35..c7a2b4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,9 +126,11 @@ if(BUILD_EXAMPLE) tray_copy_default_icons(tray_example) endif() -INSTALL(TARGETS tray tray DESTINATION lib) -INSTALL(FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray.h" DESTINATION include) -INSTALL(FILES ${TRAY_ICON_FILES} DESTINATION share/tray/icons) +if(TRAY_IS_TOP_LEVEL) + install(TARGETS tray DESTINATION lib) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray.h" DESTINATION include) + install(FILES ${TRAY_ICON_FILES} DESTINATION share/tray/icons) +endif() target_compile_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS}) target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) From bf002e200ee608fd77097a8bb627aa59741ca804 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:19:03 -0400 Subject: [PATCH 20/23] fix(linux): reset notifications during tray tray destruction --- src/tray_linux.cpp | 74 ++++++++++++++++++++++++++++------------ tests/unit/test_tray.cpp | 16 +++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index faa13b0..0ce2df9 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -4,6 +4,7 @@ */ // standard includes #include +#include #include #include #include @@ -76,6 +77,8 @@ namespace { 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 @@ -298,22 +301,29 @@ namespace { } } - void close_notification() { - if (g_notification_id == 0) { + void close_notification_id(uint notification_id) { + if (notification_id == 0) { return; } - const uint id_to_close = g_notification_id; - g_notification_id = 0; QDBusInterface iface( QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("/org/freedesktop/Notifications"), QStringLiteral("org.freedesktop.Notifications") ); if (iface.isValid()) { - iface.asyncCall(QStringLiteral("CloseNotification"), id_to_close); + 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++) { @@ -512,11 +522,15 @@ namespace { } 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; } - QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); + if (g_tray_icon != nullptr) { + QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); + } close_notification(); } @@ -534,18 +548,37 @@ namespace { void destroy_tray(); - void handle_notification_reply(QDBusPendingCallWatcher *watcher) { + void handle_notification_reply(QDBusPendingCallWatcher *watcher, const std::uint64_t notification_generation) { const QDBusPendingReply reply = *watcher; - if (reply.isValid() && g_tray_icon != nullptr) { - g_notification_id = reply.value(); - if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = g_notification_id; - } + if (!reply.isValid() || g_tray_icon == nullptr) { + watcher->deleteLater(); + return; + } + + 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(); + 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) { + 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; @@ -580,8 +613,8 @@ namespace { 5000 ); auto *watcher = new QDBusPendingCallWatcher(pending); // NOSONAR(cpp:S5025) - deleted via deleteLater in finished handler - QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [](QDBusPendingCallWatcher *finished) { - handle_notification_reply(finished); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [notification_generation](QDBusPendingCallWatcher *finished) { + handle_notification_reply(finished, notification_generation); }); return true; } @@ -606,10 +639,13 @@ namespace { 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)) { + 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); } @@ -659,11 +695,7 @@ namespace { } void destroy_tray() { - close_notification(); - if (g_notification_handler != nullptr) { - g_notification_handler->notification_id = 0; - g_notification_handler->cb = nullptr; - } + reset_notification_state(); if (g_tray_icon != nullptr) { g_tray_icon->hide(); QMenu *menu = g_tray_icon->contextMenu(); diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 0db948a..4a91562 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -80,6 +80,18 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must if (!trayRunning) { return; } + + // Ensure per-test notification state is cleared before teardown so + // screenshot tests do not inherit prior notification popups. + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; + tray_update(&testTray); + for (int i = 0; i < 20; ++i) { + tray_loop(0); + } + tray_exit(); tray_loop(0); trayRunning = false; @@ -194,6 +206,10 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must trayRunning = false; testTray.icon = TRAY_ICON1; testTray.tooltip = "TestTray"; + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; testTray.menu = g_submenu; g_submenu[1].checked = 1; } From 3e57b845fdfbe6cd2cdddea6c5c8f3f93cc764c6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:46:13 -0400 Subject: [PATCH 21/23] Force minimal QPA when no display endpoint Add detection for reachable Wayland/X11 endpoints and fall back to the minimal QPA if none are present. Introduce QDir include and helper functions has_wayland_display_endpoint(), has_x11_display_endpoint(), and should_force_headless_qpa_fallback() to check WAYLAND_DISPLAY and DISPLAY (including XDG_RUNTIME_DIR sockets and /tmp/.X11-unix/X sockets, as well as remote/TCP displays). If QT_QPA_PLATFORM is unset and no local display endpoints are found, set QT_QPA_PLATFORM=minimal before creating QApplication and emit a log via g_log_cb. This prevents trying to use GUI QPAs when no local display is reachable. --- src/tray_linux.cpp | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index 0ce2df9..c796d2f 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -116,6 +117,75 @@ namespace { return !qgetenv("WAYLAND_DISPLAY").isEmpty(); } + 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; + } + + const QString socket_path = QDir(QString::fromLocal8Bit(runtime_dir)).filePath(display_name); + return QFileInfo::exists(socket_path); + } + + 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; + } + + const QString socket_path = QStringLiteral("/tmp/.X11-unix/X%1").arg(display_number); + return QFileInfo::exists(socket_path); + } + + bool should_force_headless_qpa_fallback() { + if (!qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + return false; + } + return !has_wayland_display_endpoint() && !has_x11_display_endpoint(); + } + QPoint screen_anchor_point(const QScreen *screen) { if (screen == nullptr) { return QPoint(); @@ -729,6 +799,12 @@ extern "C" { int tray_init(struct tray *tray) { if (QApplication::instance() == nullptr) { + 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; From ff76d0ec99c3b9247bc4ff50985042008127d7ee Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:35:42 -0400 Subject: [PATCH 22/23] Autodiscover WAYLAND_DISPLAY from XDG_RUNTIME_DIR Add automatic discovery of a Wayland display socket when WAYLAND_DISPLAY is not set. Introduce discover_wayland_display_name() to scan XDG_RUNTIME_DIR for wayland-* entries (preferring wayland-0) and try_autodiscover_wayland_display() to export the found name via qputenv. Call the autodiscovery at tray initialization and emit a log message on success. Also include QStringList header required for the new code. --- src/tray_linux.cpp | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp index c796d2f..a9facdf 100644 --- a/src/tray_linux.cpp +++ b/src/tray_linux.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -141,6 +142,58 @@ namespace { 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(); + } + + QString selected; + for (const QString &entry : entries) { + if (const QString candidate_path = runtime_dir.filePath(entry); !QFileInfo::exists(candidate_path)) { + continue; + } + if (entry == QStringLiteral("wayland-0")) { + return entry; + } + if (selected.isEmpty()) { + selected = entry; + } + } + return selected; + } + + bool try_autodiscover_wayland_display() { + const QString discovered = discover_wayland_display_name(); + if (discovered.isEmpty()) { + return false; + } + return qputenv("WAYLAND_DISPLAY", discovered.toLocal8Bit()); + } + bool has_x11_display_endpoint() { const QByteArray display_env = qgetenv("DISPLAY"); if (display_env.isEmpty()) { @@ -799,6 +852,9 @@ 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) { From f207e17341a83280d1021c5b7971c7c57ade5405 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:32:53 -0400 Subject: [PATCH 23/23] Include in tray_windows.c Add the missing standard library header to src/tray_windows.c to ensure declarations for libc functions used in the file are available and to prevent implicit-declaration warnings or build errors. --- src/tray_windows.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tray_windows.c b/src/tray_windows.c index bae5684..cf959e9 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -13,6 +13,7 @@ // clang-format off // build fails if shellapi.h is included before Windows.h #include +#include // clang-format on // local includes