From 0f557e4cd5fcb914645832031a7ebafdc4943f4e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:08:35 -0400 Subject: [PATCH 01/15] feat: unify backends with QT --- .github/workflows/ci.yml | 9 +- .github/copilot-instructions.md => AGENTS.md | 0 CMakeLists.txt | 92 ++--- README.md | 30 +- docs/Doxyfile | 3 +- src/QtTrayMenu.cpp | 34 +- src/QtTrayMenu.h | 7 +- src/example.c | 9 +- src/tray.h | 30 +- src/tray_darwin.m | 185 --------- src/{tray_linux.cpp => tray_qt.cpp} | 299 +++++++++----- src/tray_windows.c | 375 ------------------ tests/unit/test_tray.cpp | 118 ------ .../{test_tray_linux.cpp => test_tray_qt.cpp} | 48 +-- 14 files changed, 332 insertions(+), 907 deletions(-) rename .github/copilot-instructions.md => AGENTS.md (100%) delete mode 100644 src/tray_darwin.m rename src/{tray_linux.cpp => tray_qt.cpp} (54%) delete mode 100644 src/tray_windows.c rename tests/unit/{test_tray_linux.cpp => test_tray_qt.cpp} (83%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a184a802..8342f247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: include: - os: macos-latest shell: "bash" - qt_version: '' + qt_version: '6' - os: ubuntu-latest shell: "bash" qt_version: '5' @@ -61,7 +61,7 @@ jobs: qt_version: '6' - os: windows-latest shell: "msys2 {0}" - qt_version: '' + qt_version: '6' steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -118,6 +118,8 @@ jobs: "imagemagick" "ninja" "node" + "qtbase" + "qtsvg" ) brew install "${dependencies[@]}" @@ -171,6 +173,8 @@ jobs: mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-nodejs mingw-w64-ucrt-x86_64-toolchain + mingw-w64-ucrt-x86_64-qt6-base + mingw-w64-ucrt-x86_64-qt6-svg - name: Setup python id: setup-python @@ -250,7 +254,6 @@ jobs: - name: Run tests id: test - # TODO: tests randomly hang on Linux, https://github.com/LizardByte/tray/issues/45 timeout-minutes: 3 working-directory: build/tests env: diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 76f5c051..2f161b10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,32 +67,30 @@ function(tray_copy_default_icons target_name) endforeach() endfunction() -if(WIN32) - list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c") +find_package(Qt6 COMPONENTS Widgets Svg) +if(Qt6_FOUND) + set(TRAY_QT_VERSION 6) else() - if(UNIX) - if(APPLE) - find_library(COCOA Cocoa REQUIRED) - list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m") - else() - find_package(LibNotify REQUIRED) - find_package(Qt6 COMPONENTS Widgets Svg) - if(Qt6_FOUND) - set(TRAY_QT_VERSION 6) - else() - find_package(Qt5 REQUIRED COMPONENTS Widgets 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" - "${CMAKE_CURRENT_SOURCE_DIR}/src/QtTrayMenu.cpp" - ) - endif() + find_package(Qt5 REQUIRED COMPONENTS Widgets 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_qt.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/QtTrayMenu.cpp" +) + +if(UNIX AND NOT APPLE) + find_package(LibNotify) + if(LIBNOTIFY_FOUND) + list(APPEND TRAY_COMPILE_DEFINITIONS TRAY_USE_LIBNOTIFY=1) + list(APPEND TRAY_EXTERNAL_LIBRARIES ${LIBNOTIFY_LIBRARIES}) + list(APPEND TRAY_EXTERNAL_DIRECTORIES ${LIBNOTIFY_LIBRARY_DIRS}) + list(APPEND TRAY_EXTERNAL_INCLUDES ${LIBNOTIFY_INCLUDE_DIRS}) endif() endif() @@ -104,27 +102,32 @@ target_include_directories(${PROJECT_NAME} $ $) -if(WIN32) - if(MSVC) - list(APPEND TRAY_COMPILE_OPTIONS "/MT$<$:d>") - endif() +if(WIN32 AND MSVC) + list(APPEND TRAY_COMPILE_OPTIONS "/MT$<$:d>") +endif() + +if(TRAY_QT_VERSION EQUAL 6) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::Svg) else() - if(UNIX) - if(APPLE) - list(APPEND TRAY_EXTERNAL_LIBRARIES ${COCOA}) - else() - if(TRAY_QT_VERSION EQUAL 6) - list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::Svg) - else() - list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::Svg) - endif() - list(APPEND TRAY_LIBNOTIFY=1) - list(APPEND TRAY_EXTERNAL_LIBRARIES ${LIBNOTIFY_LIBRARIES}) - - include_directories(SYSTEM ${LIBNOTIFY_INCLUDE_DIRS}) - link_directories(${LIBNOTIFY_LIBRARY_DIRS}) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::Svg) +endif() + +if(TRAY_EXTERNAL_INCLUDES) + target_include_directories(${PROJECT_NAME} + SYSTEM PRIVATE + ${TRAY_EXTERNAL_INCLUDES}) +endif() + +if(TRAY_COMPILE_DEFINITIONS) + target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_DEFINITIONS}) +endif() + +if(TRAY_EXTERNAL_DIRECTORIES) + foreach(tray_external_directory IN LISTS TRAY_EXTERNAL_DIRECTORIES) + if(tray_external_directory) + target_link_directories(${PROJECT_NAME} PRIVATE "${tray_external_directory}") endif() - endif() + endforeach() endif() add_library(tray::tray ALIAS ${PROJECT_NAME}) @@ -142,7 +145,6 @@ if(TRAY_IS_TOP_LEVEL) endif() 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/README.md b/README.md index 0c9408a4..aabf2536 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ ## About -Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications. +Cross-platform C++17 Qt-backed system tray icon with a popup menu and notifications. -The code is C++ friendly and will compile fine in C++98 and up. This is a fork of +This is a fork of [dmikushin/tray](https://github.com/dmikushin/tray) and is intended to add additional features required for our own [Sunshine](https://github.com/LizardByte/Sunshine) project. @@ -32,18 +32,20 @@ This fork adds the following features: ## Supported platforms -* Linux/Qt (Qt5 or Qt6 Widgets) -* Windows XP or newer (shellapi.h) -* MacOS (Cocoa/AppKit) +* Linux with Qt5 or Qt6 Widgets +* macOS with Qt5 or Qt6 Widgets +* Windows with Qt5 or Qt6 Widgets ## Prerequisites * CMake * [Ninja](https://ninja-build.org/), to have the same build commands on all platforms. +* C++17 compiler +* Qt5 or Qt6 Widgets and Svg modules -### Linux Dependencies +### Platform Dependencies -Install either Qt6 _or_ Qt5 as well as libnotify development packages. The Linux backend requires libnotify and Qt Widgets+Svg modules. +Install either Qt6 _or_ Qt5. Linux builds can also use libnotify when the development package is available.
@@ -74,6 +76,20 @@ Install either Qt6 _or_ Qt5 as well as libnotify development packages. The Linux sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel libnotify-devel ``` +- macOS + ```bash + brew install cmake ninja qtbase qtsvg + ``` + +- Windows (MSYS2 UCRT64) + ```bash + pacman -S mingw-w64-ucrt-x86_64-cmake \ + mingw-w64-ucrt-x86_64-ninja \ + mingw-w64-ucrt-x86_64-toolchain \ + mingw-w64-ucrt-x86_64-qt6-base \ + mingw-w64-ucrt-x86_64-qt6-svg + ``` +
## Building diff --git a/docs/Doxyfile b/docs/Doxyfile index 3f004c67..99c96184 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -24,14 +24,13 @@ # project metadata DOCSET_BUNDLE_ID = dev.lizardbyte.tray DOCSET_PUBLISHER_ID = dev.lizardbyte.tray.documentation -PROJECT_BRIEF = "Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications." +PROJECT_BRIEF = "Cross-platform C++17 Qt-backed system tray icon with a popup menu and notifications." PROJECT_NAME = tray # project specific settings DOT_GRAPH_MAX_NODES = 50 IMAGE_PATH = ../docs/images INCLUDE_PATH = -PREDEFINED += TRAY_WINAPI # files and directories to process USE_MDFILE_AS_MAINPAGE = ../README.md diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index eeb79304..f7711149 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -250,33 +250,35 @@ struct tray_menu *QtTrayMenu::getTrayMenuItem(QAction *action) { // NOSONAR(cpp } void QtTrayMenu::onMessageClicked() const { - if (notificationCallback != nullptr) { - notificationCallback(); + if (notificationCallback == nullptr) { + return; } + + auto callback = std::move(notificationCallback); + notificationCallback = nullptr; + callback(); } void QtTrayMenu::configureAppMetadata(const QString &appName, const QString &appDisplayName, const QString &desktopName) const { const QString effective_name = !appName.isEmpty() ? appName : QStringLiteral("tray"); - if (QApplication::applicationName().isEmpty()) { + if (!appName.isEmpty() || QApplication::applicationName().isEmpty() || QApplication::applicationName() == QStringLiteral("TrayMenuApp")) { QApplication::setApplicationName(effective_name); } - if (QApplication::applicationDisplayName().isEmpty()) { - if (!appDisplayName.isEmpty()) { - QApplication::setApplicationDisplayName(appDisplayName); - } else { - const QString display_name = - (trayStruct && trayStruct->tooltip) ? QString::fromUtf8(trayStruct->tooltip) : effective_name; - QApplication::setApplicationDisplayName(display_name); - } + if (!appDisplayName.isEmpty()) { + QApplication::setApplicationDisplayName(appDisplayName); + } else if (QApplication::applicationDisplayName().isEmpty()) { + const QString display_name = + (trayStruct && trayStruct->tooltip) ? QString::fromUtf8(trayStruct->tooltip) : effective_name; + QApplication::setApplicationDisplayName(display_name); } - if (!QApplication::desktopFileName().isEmpty()) { + if (!desktopName.isEmpty()) { + QApplication::setDesktopFileName(desktopName); return; } - if (!desktopName.isEmpty()) { - QApplication::setDesktopFileName(desktopName); + if (!QApplication::desktopFileName().isEmpty()) { return; } @@ -347,3 +349,7 @@ void QtTrayMenu::clickMessage() const { } emit trayIcon->messageClicked(); } + +void QtTrayMenu::clearMessageCallback() const { + notificationCallback = nullptr; +} diff --git a/src/QtTrayMenu.h b/src/QtTrayMenu.h index 85ec7c82..af5039b8 100644 --- a/src/QtTrayMenu.h +++ b/src/QtTrayMenu.h @@ -101,6 +101,11 @@ class QtTrayMenu: public QObject { */ void clickMessage() const; + /** + * @brief Clear the stored popup message callback + */ + void clearMessageCallback() const; + /** * @brief Check if QtTrayMenu supports messages * @return true if messages can be shown @@ -137,7 +142,7 @@ class QtTrayMenu: public QObject { bool running = false; bool blockingEventLoop = false; struct tray_menu *getTrayMenuItem(QAction *action); - std::function notificationCallback = nullptr; + mutable std::function notificationCallback = nullptr; private slots: void onExitRequested(); diff --git a/src/example.c b/src/example.c index 411b5a9c..081891e2 100644 --- a/src/example.c +++ b/src/example.c @@ -9,13 +9,8 @@ // local includes #include "tray.h" -#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 +#define TRAY_ICON1 "icon.png" ///< Path to first icon. +#define TRAY_ICON2 "icon.png" ///< Path to second icon. static struct tray tray; diff --git a/src/tray.h b/src/tray.h index e13026a6..c4fa3871 100644 --- a/src/tray.h +++ b/src/tray.h @@ -5,10 +5,6 @@ #ifndef TRAY_H #define TRAY_H -#if defined(_WIN32) - #include -#endif - #ifdef __cplusplus extern "C" { #endif @@ -77,17 +73,15 @@ extern "C" { /** * @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. + * Triggers the stored notification callback as if the user clicked the notification. */ 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. + * Triggers the QAction associated with the given top-level menu index + * (separators and submenus are ignored). * * @param index Zero-based index in the top-level tray menu. */ @@ -101,9 +95,8 @@ extern "C" { /** * @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. + * The callback is installed as a Qt message handler so all Qt diagnostic + * output is routed through it. * * @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. @@ -113,9 +106,8 @@ extern "C" { /** * @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. + * Must be called before tray_init(). Sets the Qt application name, display + * name, and desktop file name. * * @param app_name Application name used as a technical identifier (e.g., for * D-Bus registration). Converted to lowercase automatically. NULL uses the @@ -127,14 +119,6 @@ extern "C" { */ 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. - * @return The window handle. - */ - HWND tray_get_hwnd(void); -#endif - #ifdef __cplusplus } // extern "C" #endif diff --git a/src/tray_darwin.m b/src/tray_darwin.m deleted file mode 100644 index ca811cf8..00000000 --- a/src/tray_darwin.m +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @file src/tray_darwin.m - * @brief System tray implementation for macOS. - */ -// standard includes -#include - -// lib includes -#include - -// local includes -#include "tray.h" - -/** - * @class AppDelegate - * @brief The application delegate that handles menu actions. - */ -@interface AppDelegate: NSObject -/** - * @brief Callback function for menu item actions. - * @param sender The object that sent the action message. - * @return void - */ -- (IBAction)menuCallback:(id)sender; -@end - -@implementation AppDelegate { -} - -- (IBAction)menuCallback:(id)sender { - struct tray_menu *m = [[sender representedObject] pointerValue]; - if (m != NULL && m->cb != NULL) { - m->cb(m); - } -} - -@end - -static NSApplication *app; -static NSStatusBar *statusBar; -static NSStatusItem *statusItem; -static int loopResult = 0; - -#define QUIT_EVENT_SUBTYPE 0x0DED ///< NSEvent subtype used to signal exit. - -static void drain_quit_events(void) { - while (YES) { - NSEvent *event = [app nextEventMatchingMask:ULONG_MAX - untilDate:[NSDate distantPast] - inMode:[NSString stringWithUTF8String:"kCFRunLoopDefaultMode"] - dequeue:TRUE]; - if (event == nil) { - break; - } - if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { - continue; - } - [app sendEvent:event]; - } -} - -static NSMenu *_tray_menu(struct tray_menu *m) { - NSMenu *menu = [[NSMenu alloc] init]; - [menu setAutoenablesItems:FALSE]; - - for (; m != NULL && m->text != NULL; m++) { - if (strcmp(m->text, "-") == 0) { - [menu addItem:[NSMenuItem separatorItem]]; - } else { - NSMenuItem *menuItem = [[NSMenuItem alloc] - initWithTitle:[NSString stringWithUTF8String:m->text] - action:@selector(menuCallback:) - keyEquivalent:@""]; - [menuItem setEnabled:(m->disabled ? FALSE : TRUE)]; - [menuItem setState:(m->checked ? 1 : 0)]; - [menuItem setRepresentedObject:[NSValue valueWithPointer:m]]; - [menu addItem:menuItem]; - if (m->submenu != NULL) { - [menu setSubmenu:_tray_menu(m->submenu) forItem:menuItem]; - } - } - } - return menu; -} - -int tray_init(struct tray *tray) { - loopResult = 0; - AppDelegate *delegate = [[AppDelegate alloc] init]; - app = [NSApplication sharedApplication]; - [app setDelegate:delegate]; - statusBar = [NSStatusBar systemStatusBar]; - statusItem = [statusBar statusItemWithLength:NSVariableStatusItemLength]; - tray_update(tray); - [app activateIgnoringOtherApps:TRUE]; - drain_quit_events(); - return 0; -} - -int tray_loop(int blocking) { - NSDate *until = (blocking ? [NSDate distantFuture] : [NSDate distantPast]); - NSEvent *event = [app nextEventMatchingMask:ULONG_MAX - untilDate:until - inMode:[NSString stringWithUTF8String:"kCFRunLoopDefaultMode"] - dequeue:TRUE]; - if (event) { - if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { - loopResult = -1; - return loopResult; - } - - [app sendEvent:event]; - } - return loopResult; -} - -void tray_update(struct tray *tray) { - NSImage *image = [[NSImage alloc] initWithContentsOfFile:[NSString stringWithUTF8String:tray->icon]]; - NSSize size = NSMakeSize(16, 16); - [image setSize:NSMakeSize(16, 16)]; - statusItem.button.image = image; - [statusItem setMenu:_tray_menu(tray->menu)]; - - // Set tooltip if provided - if (tray->tooltip != NULL) { - statusItem.button.toolTip = [NSString stringWithUTF8String:tray->tooltip]; - } -} - -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_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; -} - -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 - if (statusItem != nil) { - if ([NSThread isMainThread]) { - // Already on main thread, remove directly - [statusBar removeStatusItem:statusItem]; - statusItem = nil; - } else { - // On background thread, dispatch synchronously to main thread - dispatch_sync(dispatch_get_main_queue(), ^{ - if (statusItem != nil) { - [statusBar removeStatusItem:statusItem]; - statusItem = nil; - } - }); - } - } - - // Post exit event - NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined - location:NSMakePoint(0, 0) - modifierFlags:0 - timestamp:0 - windowNumber:0 - context:nil - subtype:QUIT_EVENT_SUBTYPE - data1:0 - data2:0]; - [app postEvent:event atStart:FALSE]; -} diff --git a/src/tray_linux.cpp b/src/tray_qt.cpp similarity index 54% rename from src/tray_linux.cpp rename to src/tray_qt.cpp index 48173ffc..3229bc56 100644 --- a/src/tray_linux.cpp +++ b/src/tray_qt.cpp @@ -1,44 +1,51 @@ /** - * @file src/tray_linux.cpp - * @brief System tray implementation for Linux using Qt. + * @file src/tray_qt.cpp + * @brief System tray implementation using Qt. */ // standard includes -#include -#include -#include +#include #include #include #include #include #include -#include +#include +#if defined(TRAY_USE_LIBNOTIFY) // lib includes -#include + #include +#endif + +// qt includes +#include +#include +#include +#include // local includes #include "QtTrayMenu.h" #include "tray.h" -namespace tray_linux { +namespace tray_qt { +#if defined(TRAY_USE_LIBNOTIFY) /** * Notification element struct */ struct notification_data { /** - * @brief Notification object + * Notification object */ NotifyNotification *obj = nullptr; /** - * @brief Notification callback + * Notification callback */ void (*cb)() = nullptr; /** - * @brief Notification shown indicator + * Notification shown indicator */ bool shown = false; /** - * @brief Notification mutex for async thread synchronization + * Notification mutex for async thread synchronization */ std::mutex mutex; }; @@ -51,7 +58,7 @@ namespace tray_linux { * Lock for currently shown notifications vector */ std::mutex notifications_mutex; // NOSONAR(cpp:S5421) - mutable state, not const - +#endif /** * QtTrayMenu instance */ @@ -60,11 +67,37 @@ namespace tray_linux { * Logging callback for qt_message_handler */ void (*log_callback)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + /** + * Explicit Qt application metadata configured through the C API. + */ + bool app_info_configured = false; // NOSONAR(cpp:S5421) - mutable state, not const + /** + * Qt application name configured through the C API. + */ + QString app_name; // NOSONAR(cpp:S5421) - mutable state, not const + /** + * Qt application display name configured through the C API. + */ + QString app_display_name; // NOSONAR(cpp:S5421) - mutable state, not const + /** + * Qt desktop file name configured through the C API. + */ + QString desktop_name; // NOSONAR(cpp:S5421) - mutable state, not const + + /** + * @brief Get the effective application name used for notification backends. + * @return application name as UTF-8 data + */ + QByteArray effective_notify_app_name() { + const QString effective_name = !app_name.isEmpty() ? app_name : QStringLiteral("tray"); + return effective_name.toUtf8(); + } +#if defined(TRAY_USE_LIBNOTIFY) /** - * @brief Acknowledge notification asynchronously with timeout to avoid Dbus lockups - * @param notification - Tray notification to close - * @param timeout - optional timeout for async run in ms (default: 1000) + * @brief Acknowledge notification asynchronously with timeout to avoid D-Bus lockups + * @param notification Tray notification to close + * @param timeout optional timeout for async run in ms */ void async_tray_notification_acknowledge_(const std::shared_ptr ¬ification, int timeout = 1000) { std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards @@ -81,55 +114,97 @@ namespace tray_linux { timeout -= 10; } if (timeout > 0) { - // Finished. Join thread. t.join(); } else { - // Timed out. Detach thread and continue. - t.detach(); // NOSONAR(cpp:S5962) - Keep this running until it times out by itself (usually after 25 seconds due to DBus) + t.detach(); // NOSONAR(cpp:S5962) - Keep this running until it times out by itself, usually after 25 seconds due to D-Bus } } /** - * @brief Acknowledge/click current notifications - * @param run_callback - Run notification callback when acknowledging + * @brief Show notification asynchronously with timeout to avoid D-Bus lockups + * @param notification Tray notification to show + * @param timeout optional timeout for async run in ms + */ + void async_tray_notification_show_(const std::shared_ptr ¬ification, int timeout = 1000) { + std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards + std::scoped_lock lock(notification->mutex); + if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_show(notification->obj, nullptr)) { + notification->shown = true; + } + }); + while (!notification->shown && timeout > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + timeout -= 10; + } + if (timeout > 0) { + t.join(); + } else { + t.detach(); // NOSONAR(cpp:S5962) - Keep this running until it times out by itself, usually after 25 seconds due to D-Bus + } + } + + void acknowledge_notification(bool run_callback); + + /** + * @brief Initialize notifications + * @param app_name application name for notifications + * @return true if successful + */ + bool init_notify(const char *app_name) { + if (!notify_is_initted()) { + if (!notifications.empty()) { + acknowledge_notification(); + } + return notify_init(app_name); + } + return true; // Already initialized, so init was successful + } +#else + /** + * @brief Initialize notifications + * @return false because no native notification backend is compiled in + */ + bool init_notify(const char *) { + return false; + } +#endif + + /** + * @brief Acknowledge/click current notification + * @param run_callback Run notification callback when acknowledging */ - void acknowledge_notifications(bool run_callback = false) { + void acknowledge_notification(const bool run_callback = false) { +#if defined(TRAY_USE_LIBNOTIFY) if (notify_is_initted()) { std::scoped_lock lock(notifications_mutex); - for (auto notification : notifications) { + for (const auto ¬ification : notifications) { if (run_callback && notification->cb != nullptr) { notification->cb(); } async_tray_notification_acknowledge_(notification); } notifications.clear(); - } else if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { + return; + } +#endif + + if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { qt_tray_menu->clickMessage(); } } /** - * @brief Show notification asynchronously with timeout to avoid Dbus lockups - * @param notification - Tray notification to show - * @param timeout - optional timeout for async run in ms (default: 1000) + * @brief Clear current notification state without invoking callbacks. */ - void async_tray_notification_show_(const std::shared_ptr ¬ification, int timeout = 1000) { - std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards - std::scoped_lock lock(notification->mutex); - if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_show(notification->obj, nullptr)) { - notification->shown = true; - } - }); - while (!notification->shown && timeout > 0) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - timeout -= 10; + void clear_notification() { +#if defined(TRAY_USE_LIBNOTIFY) + if (notify_is_initted()) { + acknowledge_notification(); } - if (timeout > 0) { - // Finished. Join thread. - t.join(); - } else { - // Timed out. Detach thread and continue. - t.detach(); // NOSONAR(cpp:S5962) - Keep this running until it times out by itself (usually after 25 seconds due to DBus) +#endif + + if (qt_tray_menu != nullptr) { + qt_tray_menu->clearMessageCallback(); } } @@ -139,12 +214,14 @@ namespace tray_linux { */ void notify(struct tray *tray) { if (tray->notification_text == nullptr || std::string(tray->notification_text).empty()) { + clear_notification(); return; } +#if defined(TRAY_USE_LIBNOTIFY) // Try to notify using libnotify if (notify_is_initted()) { if (!notifications.empty()) { - acknowledge_notifications(); + acknowledge_notification(); } std::scoped_lock lock(notifications_mutex); std::filesystem::path notification_icon = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; @@ -161,47 +238,70 @@ namespace tray_linux { } notifications.emplace_back(notification); async_tray_notification_show_(notification); + return; } - } else if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { - // Fallback to QtTrayMenu notification + } +#endif + // Fallback to QtTrayMenu notification + if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { qt_tray_menu->showMessage(tray->notification_title, tray->notification_text, tray->notification_icon, tray->notification_cb); } } /** - * @brief Initialize notifications - * @param app_name application name for notifications - * @return true if successful + * @brief Uninitialize notifications */ - bool init_notify(const char *app_name) { + void uninit_notify() { +#if defined(TRAY_USE_LIBNOTIFY) + if (notify_is_initted()) { + acknowledge_notification(); + notify_uninit(); + } +#endif + } + + /** + * @brief Update notification app name. + */ + void set_notify_app_info() { +#if defined(TRAY_USE_LIBNOTIFY) if (!notify_is_initted()) { - if (!notifications.empty()) { - acknowledge_notifications(); - } - return notify_init(app_name); + return; } - return true; // Already initialized, so init was successful + + uninit_notify(); + const QByteArray notify_app_name = effective_notify_app_name(); + init_notify(notify_app_name.constData()); +#endif } /** - * @brief Uninitialize notifications + * @brief Apply configured Qt application metadata to the active Qt tray menu. + * @param allow_defaults Whether empty app info values should apply fallback defaults. */ - void uninit_notify() { - if (notify_is_initted()) { - acknowledge_notifications(); - notify_uninit(); + void apply_app_info(const bool allow_defaults = true) { + if (!app_info_configured || qt_tray_menu == nullptr) { + return; + } + if (!allow_defaults && app_name.isEmpty() && app_display_name.isEmpty()) { + return; } + + qt_tray_menu->configureAppMetadata(app_name, app_display_name, desktop_name); } /** - * @brief Update notification app name - * @param app_name the current application name + * @brief Configure Linux headless fallback for Qt. */ - void set_notify_app_info(const char *app_name) { - if (app_name) { - uninit_notify(); - init_notify(app_name); + void configure_platform() { +#if defined(__linux__) + // Check if a (wayland_)display is set or fallback to minimal QPA platform + if (qgetenv("WAYLAND_DISPLAY").isEmpty() && qgetenv("DISPLAY").isEmpty()) { + // Force fallback to QT platform minimal if no (WAYLAND_)DISPLAY was found + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("minimal")); + qWarning("QtTrayMenu: no reachable WAYLAND_DISPLAY or DISPLAY endpoint, forcing QT_QPA_PLATFORM=minimal"); } +#endif } /** @@ -230,98 +330,95 @@ namespace tray_linux { } log_callback(level, msg.toUtf8().constData()); } -} // namespace tray_linux +} // namespace tray_qt extern "C" { void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) { - tray_linux::set_notify_app_info(app_name); + tray_qt::app_info_configured = true; + tray_qt::app_name = app_name != nullptr ? QString::fromUtf8(app_name) : QString(); + tray_qt::app_display_name = app_display_name != nullptr ? QString::fromUtf8(app_display_name) : QString(); + tray_qt::desktop_name = desktop_name != nullptr ? QString::fromUtf8(desktop_name) : QString(); - if (tray_linux::qt_tray_menu == nullptr) { - return; - } - const auto app_name_ = app_name != nullptr ? QString::fromUtf8(app_name) : QString(); - const auto app_display_name_ = app_display_name != nullptr ? QString::fromUtf8(app_display_name) : QString(); - const auto desktop_name_ = desktop_name != nullptr ? QString::fromUtf8(desktop_name) : QString(); - tray_linux::qt_tray_menu->configureAppMetadata(app_name_, app_display_name_, desktop_name_); + tray_qt::set_notify_app_info(); + tray_qt::apply_app_info(); } int tray_init(struct tray *tray) { - if (tray_linux::qt_tray_menu == nullptr) { - // Check if a (wayland_)display is set or fallback to minimal QPA platform - if (qgetenv("WAYLAND_DISPLAY").isEmpty() && qgetenv("DISPLAY").isEmpty()) { - // Force fallback to QT platform minimal if no (WAYLAND_)DISPLAY was found - qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("minimal")); - qWarning("QtTrayMenu: no reachable WAYLAND_DISPLAY or DISPLAY endpoint, forcing QT_QPA_PLATFORM=minimal"); - } + if (tray_qt::qt_tray_menu == nullptr) { + tray_qt::configure_platform(); // Create a new unique pointer to QtTrayMenu instance - tray_linux::qt_tray_menu = std::make_unique(); + tray_qt::qt_tray_menu = std::make_unique(); + tray_qt::apply_app_info(false); } - if (const auto result = tray_linux::qt_tray_menu->init(tray, false); result < 0) { + if (const auto result = tray_qt::qt_tray_menu->init(tray, false); result < 0) { // Tray init failed. Clean up and return error. tray_exit(); return result; } + tray_qt::apply_app_info(); - if (!tray_linux::init_notify("tray") && !QtTrayMenu::supportsMessages()) { + const QByteArray notify_app_name = tray_qt::effective_notify_app_name(); + if (!tray_qt::init_notify(notify_app_name.constData()) && !QtTrayMenu::supportsMessages()) { // Notification init failed. Clean up and return error. tray_exit(); return -1; } // Fire notification if there is one - tray_linux::notify(tray); + tray_qt::notify(tray); return 0; } int tray_loop(int blocking) { - if (tray_linux::qt_tray_menu == nullptr) { + if (tray_qt::qt_tray_menu == nullptr) { return -1; } - return tray_linux::qt_tray_menu->loop(blocking); + return tray_qt::qt_tray_menu->loop(blocking); } void tray_update(struct tray *tray) { // NOSONAR(cpp:S995) - C API requires this exact mutable-pointer signature - if (tray_linux::qt_tray_menu == nullptr) { + if (tray_qt::qt_tray_menu == nullptr) { return; } - tray_linux::qt_tray_menu->update(tray, false); - tray_linux::notify(tray); + tray_qt::qt_tray_menu->update(tray, false); + tray_qt::notify(tray); } void tray_exit(void) { - tray_linux::uninit_notify(); + tray_qt::uninit_notify(); - if (tray_linux::qt_tray_menu == nullptr) { + if (tray_qt::qt_tray_menu == nullptr) { return; } - tray_linux::qt_tray_menu->exit(); + tray_qt::qt_tray_menu->exit(); } void tray_set_log_callback(void (*cb)(int level, const char *msg)) { // NOSONAR(cpp:S5205) - C API requires a plain function pointer callback type - tray_linux::log_callback = cb; + tray_qt::log_callback = cb; if (cb != nullptr) { - qInstallMessageHandler(tray_linux::qt_message_handler); + qInstallMessageHandler(tray_qt::qt_message_handler); } else { qInstallMessageHandler(nullptr); } } void tray_show_menu(void) { - if (tray_linux::qt_tray_menu == nullptr) { + if (tray_qt::qt_tray_menu == nullptr) { return; } - tray_linux::qt_tray_menu->showMenu(); + tray_qt::qt_tray_menu->showMenu(); } void tray_simulate_menu_item_click(int index) { - if (tray_linux::qt_tray_menu == nullptr) { + if (tray_qt::qt_tray_menu == nullptr) { return; } - tray_linux::qt_tray_menu->clickMenuItem(index); + tray_qt::qt_tray_menu->clickMenuItem(index); } void tray_simulate_notification_click(void) { - tray_linux::acknowledge_notifications(true); + tray_qt::acknowledge_notification(true); } + } // extern "C" diff --git a/src/tray_windows.c b/src/tray_windows.c deleted file mode 100644 index cf959e94..00000000 --- a/src/tray_windows.c +++ /dev/null @@ -1,375 +0,0 @@ -/** - * @file src/tray_windows.c - * @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 -#include -#include -// clang-format on - -// local includes -#include "tray.h" - -#define WM_TRAY_CALLBACK_MESSAGE (WM_USER + 1) ///< Tray callback message. -#define WC_TRAY_CLASS_NAME "TRAY" ///< Tray window class name. -#define ID_TRAY_FIRST 1000 ///< First tray identifier. - -/** - * @brief Icon information. - */ -struct icon_info { - const char *path; ///< Path to the icon - HICON icon; ///< Regular icon - HICON large_icon; ///< Large icon - HICON notification_icon; ///< Notification icon -}; - -/** - * @brief Icon type. - */ -enum IconType { - REGULAR = 1, ///< Regular icon - LARGE, ///< Large icon - NOTIFICATION ///< Notification icon -}; - -static WNDCLASSEX wc; -static NOTIFYICONDATAW nid; -static HWND hwnd; -static HMENU hmenu = NULL; -static void (*notification_cb)() = 0; -static UINT wm_taskbarcreated; - -static struct icon_info *icon_infos; -static int icon_info_count; - -static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { - switch (msg) { - case WM_CLOSE: - DestroyWindow(hwnd); - return 0; - case WM_DESTROY: - PostQuitMessage(0); - return 0; - case WM_TRAY_CALLBACK_MESSAGE: - if (lparam == WM_LBUTTONUP || lparam == WM_RBUTTONUP) { - POINT p; - GetCursorPos(&p); - SetForegroundWindow(hwnd); - WORD cmd = TrackPopupMenu(hmenu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, p.x, p.y, 0, hwnd, NULL); - SendMessage(hwnd, WM_COMMAND, cmd, 0); - return 0; - } else if (lparam == NIN_BALLOONUSERCLICK && notification_cb != NULL) { - notification_cb(); - } - break; - case WM_COMMAND: - if (wparam >= ID_TRAY_FIRST) { - MENUITEMINFO item = { - .cbSize = sizeof(MENUITEMINFO), - .fMask = MIIM_ID | MIIM_DATA, - }; - if (GetMenuItemInfo(hmenu, wparam, FALSE, &item)) { - struct tray_menu *menu = (struct tray_menu *) item.dwItemData; - if (menu != NULL && menu->cb != NULL) { - menu->cb(menu); - } - } - return 0; - } - break; - } - - if (msg == wm_taskbarcreated) { - Shell_NotifyIconW(NIM_ADD, &nid); - return 0; - } - - return DefWindowProc(hwnd, msg, wparam, lparam); -} - -static HMENU _tray_menu(struct tray_menu *m, UINT *id) { - HMENU hmenu = CreatePopupMenu(); - for (; m != NULL && m->text != NULL; m++, (*id)++) { - if (strcmp(m->text, "-") == 0) { - InsertMenuW(hmenu, *id, MF_SEPARATOR, TRUE, L""); - } else { - MENUITEMINFOW item; - memset(&item, 0, sizeof(item)); - item.cbSize = sizeof(MENUITEMINFOW); - item.fMask = MIIM_ID | MIIM_TYPE | MIIM_STATE | MIIM_DATA; - item.fType = 0; - item.fState = 0; - if (m->submenu != NULL) { - item.fMask = item.fMask | MIIM_SUBMENU; - item.hSubMenu = _tray_menu(m->submenu, id); - } - if (m->disabled) { - item.fState |= MFS_DISABLED; - } - if (m->checked) { - item.fState |= MFS_CHECKED; - } - item.wID = *id; - - // Convert UTF-8 text to UTF-16 (wide string) - int wide_size = MultiByteToWideChar(CP_UTF8, 0, m->text, -1, NULL, 0); - wchar_t *wide_text = (wchar_t *) malloc(wide_size * sizeof(wchar_t)); - if (wide_text == NULL) { - DestroyMenu(hmenu); - return NULL; - } - MultiByteToWideChar(CP_UTF8, 0, m->text, -1, wide_text, wide_size); - - item.dwTypeData = wide_text; - item.dwItemData = (ULONG_PTR) m; - - InsertMenuItemW(hmenu, *id, TRUE, &item); - // Free the allocated wide string - free(wide_text); - } - } - return hmenu; -} - -/** - * @brief Create icon information. - * @param path Path to the icon. - * @return Icon information. - */ -struct icon_info _create_icon_info(const char *path) { - struct icon_info info; - info.path = strdup(path); - - // These must be separate invocations otherwise Windows may opt to only return large or small icons. - // MSDN does not explicitly state this anywhere, but it has been observed on some machines. - ExtractIconEx(path, 0, &info.large_icon, NULL, 1); - ExtractIconEx(path, 0, NULL, &info.icon, 1); - - info.notification_icon = LoadImageA(NULL, path, IMAGE_ICON, GetSystemMetrics(SM_CXICON) * 2, GetSystemMetrics(SM_CYICON) * 2, LR_LOADFROMFILE); - return info; -} - -/** - * @brief Initialize icon cache. - * @param paths Paths to the icons. - * @param count Number of paths. - */ -void _init_icon_cache(const char **paths, int count) { - icon_info_count = count; - icon_infos = malloc(sizeof(struct icon_info) * icon_info_count); - - for (int i = 0; i < count; ++i) { - icon_infos[i] = _create_icon_info(paths[i]); - } -} - -/** - * @brief Destroy icon cache. - */ -void _destroy_icon_cache() { - for (int i = 0; i < icon_info_count; ++i) { - DestroyIcon(icon_infos[i].icon); - DestroyIcon(icon_infos[i].large_icon); - DestroyIcon(icon_infos[i].notification_icon); - free((void *) icon_infos[i].path); - } - - free(icon_infos); - icon_infos = NULL; - icon_info_count = 0; -} - -/** - * @brief Fetch cached icon. - * @param icon_record Icon record. - * @param icon_type Icon type. - * @return Icon. - */ -HICON _fetch_cached_icon(struct icon_info *icon_record, enum IconType icon_type) { - switch (icon_type) { - case REGULAR: - return icon_record->icon; - case LARGE: - return icon_record->large_icon; - case NOTIFICATION: - return icon_record->notification_icon; - } -} - -/** - * @brief Fetch icon. - * @param path Path to the icon. - * @param icon_type Icon type. - * @return Icon. - */ -HICON _fetch_icon(const char *path, enum IconType icon_type) { - // Find a cached icon by path - for (int i = 0; i < icon_info_count; ++i) { - if (strcmp(icon_infos[i].path, path) == 0) { - return _fetch_cached_icon(&icon_infos[i], icon_type); - } - } - - // Expand cache, fetch, and retry - icon_info_count += 1; - icon_infos = realloc(icon_infos, sizeof(struct icon_info) * icon_info_count); - int index = icon_info_count - 1; - icon_infos[icon_info_count - 1] = _create_icon_info(path); - - return _fetch_cached_icon(&icon_infos[icon_info_count - 1], icon_type); -} - -int tray_init(struct tray *tray) { - wm_taskbarcreated = RegisterWindowMessage("TaskbarCreated"); - - _init_icon_cache(tray->allIconPaths, tray->iconPathCount); - - memset(&wc, 0, sizeof(wc)); - wc.cbSize = sizeof(WNDCLASSEX); - wc.lpfnWndProc = _tray_wnd_proc; - wc.hInstance = GetModuleHandle(NULL); - wc.lpszClassName = WC_TRAY_CLASS_NAME; - if (!RegisterClassEx(&wc)) { - return -1; - } - - hwnd = CreateWindowEx(0, WC_TRAY_CLASS_NAME, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0); - if (hwnd == NULL) { - return -1; - } - UpdateWindow(hwnd); - - memset(&nid, 0, sizeof(nid)); - nid.cbSize = sizeof(NOTIFYICONDATAW); - nid.hWnd = hwnd; - nid.uID = 0; - nid.uFlags = NIF_ICON | NIF_MESSAGE; - nid.uCallbackMessage = WM_TRAY_CALLBACK_MESSAGE; - Shell_NotifyIconW(NIM_ADD, &nid); - - tray_update(tray); - return 0; -} - -int tray_loop(int blocking) { - MSG msg; - int has_message = 0; - - // Use the thread queue and only dispatch when a message was actually retrieved. - // This ensures WM_QUIT is observed and avoids dispatching an uninitialized MSG. - if (blocking) { - has_message = GetMessage(&msg, NULL, 0, 0); - if (has_message <= 0) { - return -1; - } - } else { - has_message = PeekMessage(&msg, NULL, 0, 0, PM_REMOVE); - if (has_message == 0) { - return 0; - } - } - - if (msg.message == WM_QUIT) { - return -1; - } - - TranslateMessage(&msg); - DispatchMessage(&msg); - return 0; -} - -void tray_update(struct tray *tray) { - UINT id = ID_TRAY_FIRST; - HMENU prevmenu = hmenu; - hmenu = _tray_menu(tray->menu, &id); - SendMessage(hwnd, WM_INITMENUPOPUP, (WPARAM) hmenu, 0); - - HICON icon = _fetch_icon(tray->icon, REGULAR); - HICON largeIcon = tray->notification_icon != 0 ? _fetch_icon(tray->notification_icon, NOTIFICATION) : _fetch_icon(tray->icon, LARGE); - - if (icon != NULL) { - nid.hIcon = icon; - } - - if (largeIcon != 0) { - nid.hBalloonIcon = largeIcon; - nid.dwInfoFlags = NIIF_USER | NIIF_LARGE_ICON; - } - if (tray->tooltip != 0 && strlen(tray->tooltip) > 0) { - MultiByteToWideChar(CP_UTF8, 0, tray->tooltip, -1, nid.szTip, sizeof(nid.szTip) / sizeof(wchar_t)); - nid.uFlags |= NIF_TIP; - } - QUERY_USER_NOTIFICATION_STATE notification_state; - HRESULT ns = SHQueryUserNotificationState(¬ification_state); - int can_show_notifications = ns == S_OK && notification_state == QUNS_ACCEPTS_NOTIFICATIONS; - if (can_show_notifications == 1 && tray->notification_title != 0 && strlen(tray->notification_title) > 0) { - MultiByteToWideChar(CP_UTF8, 0, tray->notification_title, -1, nid.szInfoTitle, sizeof(nid.szInfoTitle) / sizeof(wchar_t)); - nid.uFlags |= NIF_INFO; - } else if ((nid.uFlags & NIF_INFO) == NIF_INFO) { - nid.szInfoTitle[0] = L'\0'; - } - if (can_show_notifications == 1 && tray->notification_text != 0 && strlen(tray->notification_text) > 0) { - MultiByteToWideChar(CP_UTF8, 0, tray->notification_text, -1, nid.szInfo, sizeof(nid.szInfo) / sizeof(wchar_t)); - } else if ((nid.uFlags & NIF_INFO) == NIF_INFO) { - nid.szInfo[0] = L'\0'; - } - if (can_show_notifications == 1 && tray->notification_cb != NULL) { - notification_cb = tray->notification_cb; - } - - Shell_NotifyIconW(NIM_MODIFY, &nid); - - if (prevmenu != NULL) { - DestroyMenu(prevmenu); - } -} - -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_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; -} - -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); - _destroy_icon_cache(); - if (hmenu != 0) { - DestroyMenu(hmenu); - } - UnregisterClass(WC_TRAY_CLASS_NAME, GetModuleHandle(NULL)); -} - -HWND tray_get_hwnd(void) { - return hwnd; -} diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 103896e8..49a91375 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -5,40 +5,20 @@ #include #include #include -#include #include -#include #if defined(_WIN32) || defined(_WIN64) #include -// clang-format off - // build fails if shellapi.h is included before Windows.h - #include - // clang-format on - #define TRAY_WINAPI 1 -#elif defined(__linux__) || defined(linux) || defined(__linux) - #define TRAY_QT 1 -#elif defined(__APPLE__) || defined(__MACH__) - #include - #define TRAY_APPKIT 1 #endif // local includes #include "src/tray.h" #include "tests/screenshot_utils.h" -#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"; -constexpr const char *TRAY_ICON2 = "icon.png"; -#elif TRAY_WINAPI -constexpr const char *TRAY_ICON1 = "icon.ico"; -constexpr const char *TRAY_ICON2 = "icon.ico"; -#endif // File-scope tray data shared across all TrayTest instances namespace { @@ -100,18 +80,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Dismisses the open menu from a background thread. void closeMenu() { -#if defined(TRAY_WINAPI) - PostMessage(tray_get_hwnd(), WM_CANCELMODE, 0, 0); - std::this_thread::sleep_for(std::chrono::milliseconds(100)); -#elif defined(TRAY_APPKIT) - CGEventRef event = CGEventCreateKeyboardEvent(NULL, kVK_Escape, true); - CGEventPost(kCGHIDEventTap, event); - CFRelease(event); - CGEventRef event2 = CGEventCreateKeyboardEvent(NULL, kVK_Escape, false); - CGEventPost(kCGHIDEventTap, event2); - CFRelease(event2); std::this_thread::sleep_for(std::chrono::milliseconds(100)); -#endif } // Capture a screenshot while the tray menu is open, then dismiss and exit. @@ -204,9 +173,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must }; ensureIconInTestDir(TRAY_ICON1); -#if defined(TRAY_QT) ensureIconInTestDir(TRAY_ICON_SVG); -#endif trayRunning = false; testTray.icon = TRAY_ICON1; @@ -227,22 +194,10 @@ 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_QT) for (int i = 0; i < 100; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(5)); } -#elif defined(TRAY_APPKIT) - static std::thread::id main_thread_id = std::this_thread::get_id(); - if (std::this_thread::get_id() == main_thread_id) { - for (int i = 0; i < 100; i++) { - tray_loop(0); - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - } - } else { - std::this_thread::sleep_for(std::chrono::milliseconds(1000)); - } -#endif } }; @@ -263,28 +218,6 @@ TEST_F(TrayTest, TestTrayLoop) { EXPECT_EQ(result, 0); } -#if defined(TRAY_WINAPI) -TEST_F(TrayTest, TestTrayLoopHandlesThreadQuitMessage) { - int initResult = tray_init(&testTray); - trayRunning = (initResult == 0); - ASSERT_EQ(initResult, 0); - - // WM_QUIT is posted to the thread queue, not to a specific window. - PostQuitMessage(0); - - bool sawQuit = false; - for (int i = 0; i < 200; ++i) { - if (tray_loop(0) == -1) { - sawQuit = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - - EXPECT_TRUE(sawQuit); -} -#endif - TEST_F(TrayTest, TestTrayUpdate) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); @@ -370,10 +303,6 @@ TEST_F(TrayTest, TestSubmenuCallback) { } TEST_F(TrayTest, TestNotificationDisplay) { -#if !(defined(_WIN32) || defined(__linux__) || defined(__APPLE__)) - GTEST_SKIP() << "Notifications only supported on desktop platforms"; -#endif - #if defined(_WIN32) QUERY_USER_NOTIFICATION_STATE notification_state; if (HRESULT ns = SHQueryUserNotificationState(¬ification_state); @@ -404,10 +333,6 @@ TEST_F(TrayTest, TestNotificationDisplay) { } TEST_F(TrayTest, TestNotificationCallback) { -#if !(defined(_WIN32) || defined(__linux__) || defined(__APPLE__)) - GTEST_SKIP() << "Notifications only supported on desktop platforms"; -#endif - #if defined(_WIN32) QUERY_USER_NOTIFICATION_STATE notification_state; if (HRESULT ns = SHQueryUserNotificationState(¬ification_state); @@ -552,44 +477,6 @@ TEST_F(TrayTest, TestCompleteMenuHierarchy) { } TEST_F(TrayTest, TestIconPathArray) { -#if defined(TRAY_WINAPI) - // Test icon path array caching (Windows-specific feature) - // The tray struct has a flexible array member, so we allocate a raw buffer - // and use memcpy to initialize const fields before the object is used. - const size_t icon_count = 2; - const size_t buf_size = sizeof(struct tray) + icon_count * sizeof(const char *); - std::vector buf(buf_size, std::byte {0}); - auto *iconCacheTray = reinterpret_cast(buf.data()); // NOSONAR(cpp:S3630) - reinterpret_cast required to overlay struct onto raw buffer for flexible array member - - iconCacheTray->icon = TRAY_ICON1; - iconCacheTray->tooltip = "Icon Cache Test"; - iconCacheTray->notification_icon = nullptr; - iconCacheTray->notification_text = nullptr; - iconCacheTray->notification_title = nullptr; - iconCacheTray->notification_cb = nullptr; - iconCacheTray->menu = submenu; - - // Write const fields via memcpy — const_cast is required to initialize const members in a C struct flexible array allocation - auto count_val = static_cast(icon_count); - std::memcpy(const_cast(&iconCacheTray->iconPathCount), &count_val, sizeof(count_val)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer - const char *icon1 = TRAY_ICON1; - const char *icon2 = TRAY_ICON2; - std::memcpy(const_cast(&iconCacheTray->allIconPaths[0]), &icon1, sizeof(icon1)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer - std::memcpy(const_cast(&iconCacheTray->allIconPaths[1]), &icon2, sizeof(icon2)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer - - int initResult = tray_init(iconCacheTray); - trayRunning = (initResult == 0); - ASSERT_EQ(initResult, 0); - - // Verify initial icon - EXPECT_EQ(iconCacheTray->icon, TRAY_ICON1); - - // Switch to cached icon - iconCacheTray->icon = TRAY_ICON2; - tray_update(iconCacheTray); - // buf goes out of scope, no manual free needed -#else - // On non-Windows platforms, just test basic icon switching int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); @@ -598,7 +485,6 @@ TEST_F(TrayTest, TestIconPathArray) { testTray.icon = TRAY_ICON2; tray_update(&testTray); -#endif } TEST_F(TrayTest, TestQuitCallback) { @@ -627,8 +513,6 @@ TEST_F(TrayTest, TestTrayExit) { tray_exit(); } -#if defined(TRAY_QT) - TEST_F(TrayTest, TestTrayIconThemed) { testTray.icon = TRAY_ICON_THEMED; int result = tray_init(&testTray); @@ -750,5 +634,3 @@ TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { testTray.menu[0].cb = original_cb; } - -#endif // TRAY_QT diff --git a/tests/unit/test_tray_linux.cpp b/tests/unit/test_tray_qt.cpp similarity index 83% rename from tests/unit/test_tray_linux.cpp rename to tests/unit/test_tray_qt.cpp index 47d37743..5d0403c6 100644 --- a/tests/unit/test_tray_linux.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -4,13 +4,11 @@ // local includes #include "src/tray.h" -#if defined(__linux__) || defined(linux) || defined(__linux) - - // standard includes - #include - #include - #include - #include +// standard includes +#include +#include +#include +#include namespace { int g_menu_callback_count = 0; @@ -30,10 +28,10 @@ namespace { } } // namespace -class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture members/methods are accessed by TEST_F-generated subclasses +class TrayQtCoverageTest: public BaseTest { // 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(); + BaseTest::SetUp(); tray_set_log_callback(nullptr); tray_set_app_info(nullptr, nullptr, nullptr); @@ -47,7 +45,7 @@ class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture 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.tooltip = "Qt Tray Coverage"; trayData.notification_icon = nullptr; trayData.notification_text = nullptr; trayData.notification_title = nullptr; @@ -63,7 +61,7 @@ class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture } tray_set_log_callback(nullptr); - LinuxTest::TearDown(); + BaseTest::TearDown(); } void InitTray() { @@ -84,7 +82,7 @@ class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture struct tray trayData {}; }; -TEST_F(TrayLinuxCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { +TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { InitTray(); tray_simulate_menu_item_click(-1); @@ -103,7 +101,7 @@ TEST_F(TrayLinuxCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { EXPECT_EQ(g_menu_callback_count, 2); } -TEST_F(TrayLinuxCoverageTest, ApiCallsAreNoOpsBeforeInit) { +TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { tray_update(&trayData); tray_show_menu(); tray_simulate_menu_item_click(0); @@ -114,7 +112,7 @@ TEST_F(TrayLinuxCoverageTest, ApiCallsAreNoOpsBeforeInit) { EXPECT_EQ(g_notification_callback_count, 0); } -TEST_F(TrayLinuxCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { +TEST_F(TrayQtCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { trayData.menu = nullptr; InitTray(); @@ -124,8 +122,8 @@ TEST_F(TrayLinuxCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { 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"); +TEST_F(TrayQtCoverageTest, SetAppInfoAppliesExplicitMetadata) { + tray_set_app_info("tray-qt-tests", "Tray Qt Tests", "tray-qt-tests.desktop"); InitTray(); // Trigger an update to exercise metadata-dependent notification/tray code paths. @@ -135,7 +133,7 @@ TEST_F(TrayLinuxCoverageTest, SetAppInfoAppliesExplicitMetadata) { PumpEvents(); } -TEST_F(TrayLinuxCoverageTest, SetAppInfoDefaultsUseFallbackValues) { +TEST_F(TrayQtCoverageTest, SetAppInfoDefaultsUseFallbackValues) { tray_set_app_info(nullptr, nullptr, nullptr); trayData.tooltip = "Tooltip Display Name"; InitTray(); @@ -146,7 +144,7 @@ TEST_F(TrayLinuxCoverageTest, SetAppInfoDefaultsUseFallbackValues) { PumpEvents(); } -TEST_F(TrayLinuxCoverageTest, LogCallbackCanBeSetAndReset) { +TEST_F(TrayQtCoverageTest, LogCallbackCanBeSetAndReset) { InitTray(); tray_set_log_callback(log_cb); @@ -166,7 +164,7 @@ TEST_F(TrayLinuxCoverageTest, LogCallbackCanBeSetAndReset) { EXPECT_EQ(g_log_callback_count, 0); } -TEST_F(TrayLinuxCoverageTest, TrayExitCausesLoopToReturnExitCode) { +TEST_F(TrayQtCoverageTest, TrayExitCausesLoopToReturnExitCode) { InitTray(); tray_exit(); @@ -176,7 +174,7 @@ TEST_F(TrayLinuxCoverageTest, TrayExitCausesLoopToReturnExitCode) { EXPECT_EQ(loopResult, -1); } -TEST_F(TrayLinuxCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { +TEST_F(TrayQtCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { InitTray(); menuItems[0].text = "Clickable Renamed"; @@ -197,7 +195,7 @@ TEST_F(TrayLinuxCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking EXPECT_EQ(g_menu_callback_count, 1); } -TEST_F(TrayLinuxCoverageTest, ResolveTrayIconFromIconPathArray) { +TEST_F(TrayQtCoverageTest, 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 *); @@ -227,7 +225,7 @@ TEST_F(TrayLinuxCoverageTest, ResolveTrayIconFromIconPathArray) { PumpEvents(); } -TEST_F(TrayLinuxCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) { +TEST_F(TrayQtCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) { InitTray(); trayData.notification_title = "No callback notification"; @@ -244,10 +242,10 @@ TEST_F(TrayLinuxCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulati EXPECT_EQ(g_notification_callback_count, 0); } -TEST_F(TrayLinuxCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { +TEST_F(TrayQtCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { InitTray(); - trayData.notification_title = "Linux Notification"; + trayData.notification_title = "Qt Notification"; trayData.notification_text = "Notification body"; trayData.notification_icon = "mail-message-new"; trayData.notification_cb = notification_cb; @@ -271,5 +269,3 @@ TEST_F(TrayLinuxCoverageTest, ClearingNotificationDisablesSimulatedClickCallback PumpEvents(); EXPECT_EQ(g_notification_callback_count, 1); } - -#endif From d8974e3f40156827f6b12ad516595863432cd8cf Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:43:54 -0400 Subject: [PATCH 02/15] Use unique_ptr for trayData in tests --- tests/unit/test_tray_qt.cpp | 84 ++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index 5d0403c6..ae5be379 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace { @@ -44,13 +45,18 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem 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 = "Qt Tray Coverage"; - trayData.notification_icon = nullptr; - trayData.notification_text = nullptr; - trayData.notification_title = nullptr; - trayData.notification_cb = nullptr; - trayData.menu = menuItems.data(); + trayData = std::unique_ptr( + new tray { + .icon = "icon.png", + .tooltip = "Qt Tray Coverage", + .notification_icon = nullptr, + .notification_text = nullptr, + .notification_title = nullptr, + .notification_cb = nullptr, + .menu = menuItems.data(), + .iconPathCount = 0, + } + ); } void TearDown() override { @@ -65,7 +71,7 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem } void InitTray() { - const int initResult = tray_init(&trayData); + const int initResult = tray_init(trayData.get()); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); } @@ -79,7 +85,7 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem bool trayRunning {false}; std::array menuItems {}; std::array submenuItems {}; - struct tray trayData {}; + std::unique_ptr trayData; }; TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { @@ -102,7 +108,7 @@ TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { } TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { - tray_update(&trayData); + tray_update(trayData.get()); tray_show_menu(); tray_simulate_menu_item_click(0); tray_simulate_notification_click(); @@ -113,7 +119,7 @@ TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { } TEST_F(TrayQtCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { - trayData.menu = nullptr; + trayData->menu = nullptr; InitTray(); tray_simulate_menu_item_click(0); @@ -127,20 +133,20 @@ TEST_F(TrayQtCoverageTest, SetAppInfoAppliesExplicitMetadata) { 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); + trayData->notification_title = "Metadata Test"; + trayData->notification_text = "Using explicit metadata"; + tray_update(trayData.get()); PumpEvents(); } TEST_F(TrayQtCoverageTest, SetAppInfoDefaultsUseFallbackValues) { tray_set_app_info(nullptr, nullptr, nullptr); - trayData.tooltip = "Tooltip Display Name"; + trayData->tooltip = "Tooltip Display Name"; InitTray(); - trayData.notification_title = "Default Metadata Test"; - trayData.notification_text = "Using fallback metadata"; - tray_update(&trayData); + trayData->notification_title = "Default Metadata Test"; + trayData->notification_text = "Using fallback metadata"; + tray_update(trayData.get()); PumpEvents(); } @@ -149,16 +155,16 @@ TEST_F(TrayQtCoverageTest, LogCallbackCanBeSetAndReset) { 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); + trayData->tooltip = "Log callback installed"; + tray_update(trayData.get()); PumpEvents(); EXPECT_EQ(g_log_callback_count, 0); tray_set_log_callback(nullptr); - trayData.tooltip = "Log callback removed"; - tray_update(&trayData); + trayData->tooltip = "Log callback removed"; + tray_update(trayData.get()); PumpEvents(); EXPECT_EQ(g_log_callback_count, 0); @@ -179,7 +185,7 @@ TEST_F(TrayQtCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { menuItems[0].text = "Clickable Renamed"; menuItems[0].disabled = 1; - tray_update(&trayData); + tray_update(trayData.get()); PumpEvents(); tray_simulate_menu_item_click(0); @@ -187,7 +193,7 @@ TEST_F(TrayQtCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { EXPECT_EQ(g_menu_callback_count, 0); menuItems[0].disabled = 0; - tray_update(&trayData); + tray_update(trayData.get()); PumpEvents(); tray_simulate_menu_item_click(0); @@ -228,12 +234,12 @@ TEST_F(TrayQtCoverageTest, ResolveTrayIconFromIconPathArray) { TEST_F(TrayQtCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) { InitTray(); - trayData.notification_title = "No callback notification"; - trayData.notification_text = "Should not invoke callback"; - trayData.notification_icon = "icon.png"; - trayData.notification_cb = nullptr; + 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); + tray_update(trayData.get()); PumpEvents(); tray_simulate_notification_click(); @@ -245,24 +251,24 @@ TEST_F(TrayQtCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) TEST_F(TrayQtCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { InitTray(); - trayData.notification_title = "Qt Notification"; - trayData.notification_text = "Notification body"; - trayData.notification_icon = "mail-message-new"; - trayData.notification_cb = notification_cb; + trayData->notification_title = "Qt Notification"; + trayData->notification_text = "Notification body"; + trayData->notification_icon = "mail-message-new"; + trayData->notification_cb = notification_cb; - tray_update(&trayData); + tray_update(trayData.get()); 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; + trayData->notification_title = nullptr; + trayData->notification_text = nullptr; + trayData->notification_icon = nullptr; + trayData->notification_cb = nullptr; - tray_update(&trayData); + tray_update(trayData.get()); PumpEvents(); tray_simulate_notification_click(); From 9f43a2b9303df35c4ee0bdff7eafafd16ba2d243 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:22:53 -0400 Subject: [PATCH 03/15] Add waitForNativeNotificationTimeout to tests Add waitForNativeNotificationTimeout() (Windows-only sleep) in tests/utils.{cpp,h} and include chrono/thread headers. Update multiple tray unit tests to call this helper after clearing notifications to avoid races with native notification timeouts. Also tweak some Qt tests to use tooltip updates to exercise tray code paths and ensure events are pumped before waiting. --- tests/unit/test_tray.cpp | 5 +++++ tests/unit/test_tray_qt.cpp | 17 ++++++++++++----- tests/utils.cpp | 11 +++++++++++ tests/utils.h | 2 ++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 49a91375..feb0b0aa 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -330,6 +330,7 @@ TEST_F(TrayTest, TestNotificationDisplay) { testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestNotificationCallback) { @@ -368,6 +369,7 @@ TEST_F(TrayTest, TestNotificationCallback) { testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestTooltipUpdate) { @@ -550,6 +552,7 @@ TEST_F(TrayTest, TestNotificationWithThemedIcon) { testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestMenuAppearsOnLeftClick) { @@ -595,6 +598,7 @@ TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { @@ -631,6 +635,7 @@ TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); testTray.menu[0].cb = original_cb; } diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index ae5be379..6c1dae24 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -132,9 +132,8 @@ TEST_F(TrayQtCoverageTest, SetAppInfoAppliesExplicitMetadata) { tray_set_app_info("tray-qt-tests", "Tray Qt Tests", "tray-qt-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"; + // Trigger an update to exercise metadata-dependent tray code paths. + trayData->tooltip = "Explicit metadata update"; tray_update(trayData.get()); PumpEvents(); } @@ -144,8 +143,7 @@ TEST_F(TrayQtCoverageTest, SetAppInfoDefaultsUseFallbackValues) { trayData->tooltip = "Tooltip Display Name"; InitTray(); - trayData->notification_title = "Default Metadata Test"; - trayData->notification_text = "Using fallback metadata"; + trayData->tooltip = "Fallback metadata update"; tray_update(trayData.get()); PumpEvents(); } @@ -246,6 +244,13 @@ TEST_F(TrayQtCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) PumpEvents(); EXPECT_EQ(g_notification_callback_count, 0); + + trayData->notification_title = nullptr; + trayData->notification_text = nullptr; + trayData->notification_icon = nullptr; + tray_update(trayData.get()); + PumpEvents(); + waitForNativeNotificationTimeout(); } TEST_F(TrayQtCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { @@ -274,4 +279,6 @@ TEST_F(TrayQtCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { tray_simulate_notification_click(); PumpEvents(); EXPECT_EQ(g_notification_callback_count, 1); + + waitForNativeNotificationTimeout(); } diff --git a/tests/utils.cpp b/tests/utils.cpp index da5f665b..1963c261 100644 --- a/tests/utils.cpp +++ b/tests/utils.cpp @@ -5,6 +5,10 @@ // test includes #include "utils.h" +// standard includes +#include +#include + /** * @brief Set an environment variable. * @param name Name of the environment variable @@ -18,3 +22,10 @@ int setEnv(const std::string &name, const std::string &value) { return setenv(name.c_str(), value.c_str(), 1); #endif } + +void waitForNativeNotificationTimeout() { +#ifdef _WIN32 + constexpr auto wait_timeout = std::chrono::milliseconds(6000); + std::this_thread::sleep_for(wait_timeout); +#endif +} diff --git a/tests/utils.h b/tests/utils.h index 40812795..24a11da6 100644 --- a/tests/utils.h +++ b/tests/utils.h @@ -8,3 +8,5 @@ #include int setEnv(const std::string &name, const std::string &value); + +void waitForNativeNotificationTimeout(); From bf332f35735b44d16ace4a95c82703d9f14dbfc5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:37:54 -0400 Subject: [PATCH 04/15] Sonar fixes --- src/tray_qt.cpp | 8 +-- tests/unit/test_tray_qt.cpp | 107 ++++++++++++++++++++---------------- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/src/tray_qt.cpp b/src/tray_qt.cpp index 3229bc56..09a7f906 100644 --- a/src/tray_qt.cpp +++ b/src/tray_qt.cpp @@ -147,15 +147,15 @@ namespace tray_qt { /** * @brief Initialize notifications - * @param app_name application name for notifications + * @param notify_app_name application name for notifications * @return true if successful */ - bool init_notify(const char *app_name) { + bool init_notify(const char *notify_app_name) { if (!notify_is_initted()) { if (!notifications.empty()) { - acknowledge_notification(); + acknowledge_notification(false); } - return notify_init(app_name); + return notify_init(notify_app_name); } return true; // Already initialized, so init was successful } diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index 6c1dae24..46d7af34 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -8,24 +8,34 @@ #include #include #include -#include #include namespace { - int g_menu_callback_count = 0; - int g_notification_callback_count = 0; - int g_log_callback_count = 0; + int &menu_callback_count() { + static int count = 0; + return count; + } + + int ¬ification_callback_count() { + static int count = 0; + return count; + } + + int &log_callback_count() { + static int count = 0; + return count; + } void menu_item_cb([[maybe_unused]] struct tray_menu *item) { - g_menu_callback_count++; + menu_callback_count()++; } void notification_cb() { - g_notification_callback_count++; + notification_callback_count()++; } void log_cb([[maybe_unused]] int level, [[maybe_unused]] const char *msg) { - g_log_callback_count++; + log_callback_count()++; } } // namespace @@ -37,26 +47,26 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem 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; + menu_callback_count() = 0; + notification_callback_count() = 0; + 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 = std::unique_ptr( - new tray { - .icon = "icon.png", - .tooltip = "Qt Tray Coverage", - .notification_icon = nullptr, - .notification_text = nullptr, - .notification_title = nullptr, - .notification_cb = nullptr, - .menu = menuItems.data(), - .iconPathCount = 0, - } - ); + trayDataStorage.assign(sizeof(struct tray), std::byte {0}); + trayData = reinterpret_cast(trayDataStorage.data()); // NOSONAR(cpp:S3630) - required to map a C flexible-array struct over raw storage + trayData->icon = "icon.png"; + trayData->tooltip = "Qt Tray Coverage"; + trayData->notification_icon = nullptr; + trayData->notification_text = nullptr; + trayData->notification_title = nullptr; + trayData->notification_cb = nullptr; + trayData->menu = menuItems.data(); + + const int iconPathCount = 0; + std::memcpy(const_cast(&trayData->iconPathCount), &iconPathCount, sizeof(iconPathCount)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer } void TearDown() override { @@ -71,7 +81,7 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem } void InitTray() { - const int initResult = tray_init(trayData.get()); + const int initResult = tray_init(trayData); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); } @@ -85,7 +95,8 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem bool trayRunning {false}; std::array menuItems {}; std::array submenuItems {}; - std::unique_ptr trayData; + std::vector trayDataStorage {}; + struct tray *trayData = nullptr; }; TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { @@ -98,24 +109,24 @@ TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { tray_simulate_menu_item_click(3); PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 0); + EXPECT_EQ(menu_callback_count(), 0); tray_simulate_menu_item_click(0); tray_simulate_menu_item_click(4); PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 2); + EXPECT_EQ(menu_callback_count(), 2); } TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { - tray_update(trayData.get()); + 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); + EXPECT_EQ(menu_callback_count(), 0); + EXPECT_EQ(notification_callback_count(), 0); } TEST_F(TrayQtCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { @@ -125,7 +136,7 @@ TEST_F(TrayQtCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { tray_simulate_menu_item_click(0); PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 0); + EXPECT_EQ(menu_callback_count(), 0); } TEST_F(TrayQtCoverageTest, SetAppInfoAppliesExplicitMetadata) { @@ -134,7 +145,7 @@ TEST_F(TrayQtCoverageTest, SetAppInfoAppliesExplicitMetadata) { // Trigger an update to exercise metadata-dependent tray code paths. trayData->tooltip = "Explicit metadata update"; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); } @@ -144,7 +155,7 @@ TEST_F(TrayQtCoverageTest, SetAppInfoDefaultsUseFallbackValues) { InitTray(); trayData->tooltip = "Fallback metadata update"; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); } @@ -154,18 +165,18 @@ TEST_F(TrayQtCoverageTest, LogCallbackCanBeSetAndReset) { // The callback is currently installed; this update path should remain stable. trayData->tooltip = "Log callback installed"; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); - EXPECT_EQ(g_log_callback_count, 0); + EXPECT_EQ(log_callback_count(), 0); tray_set_log_callback(nullptr); trayData->tooltip = "Log callback removed"; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); - EXPECT_EQ(g_log_callback_count, 0); + EXPECT_EQ(log_callback_count(), 0); } TEST_F(TrayQtCoverageTest, TrayExitCausesLoopToReturnExitCode) { @@ -183,20 +194,20 @@ TEST_F(TrayQtCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { menuItems[0].text = "Clickable Renamed"; menuItems[0].disabled = 1; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); tray_simulate_menu_item_click(0); PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 0); + EXPECT_EQ(menu_callback_count(), 0); menuItems[0].disabled = 0; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); tray_simulate_menu_item_click(0); PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 1); + EXPECT_EQ(menu_callback_count(), 1); } TEST_F(TrayQtCoverageTest, ResolveTrayIconFromIconPathArray) { @@ -214,7 +225,7 @@ TEST_F(TrayQtCoverageTest, ResolveTrayIconFromIconPathArray) { iconPathTray->notification_cb = nullptr; iconPathTray->menu = menuItems.data(); - const int countVal = static_cast(iconCount); + const auto 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"; @@ -237,18 +248,18 @@ TEST_F(TrayQtCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) trayData->notification_icon = "icon.png"; trayData->notification_cb = nullptr; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); tray_simulate_notification_click(); PumpEvents(); - EXPECT_EQ(g_notification_callback_count, 0); + EXPECT_EQ(notification_callback_count(), 0); trayData->notification_title = nullptr; trayData->notification_text = nullptr; trayData->notification_icon = nullptr; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); waitForNativeNotificationTimeout(); } @@ -261,24 +272,24 @@ TEST_F(TrayQtCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { trayData->notification_icon = "mail-message-new"; trayData->notification_cb = notification_cb; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); tray_simulate_notification_click(); PumpEvents(); - EXPECT_EQ(g_notification_callback_count, 1); + EXPECT_EQ(notification_callback_count(), 1); trayData->notification_title = nullptr; trayData->notification_text = nullptr; trayData->notification_icon = nullptr; trayData->notification_cb = nullptr; - tray_update(trayData.get()); + tray_update(trayData); PumpEvents(); tray_simulate_notification_click(); PumpEvents(); - EXPECT_EQ(g_notification_callback_count, 1); + EXPECT_EQ(notification_callback_count(), 1); waitForNativeNotificationTimeout(); } From 3c674d796b79abd2e61141a80df89441f2d395b5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:46:49 -0400 Subject: [PATCH 05/15] Add Sonar config, remove NOSONAR thread tags Add sonar-project.properties to set the Sonar project key and force C++17 for analysis. Remove inline NOSONAR suppressions on std::thread lambda usages in src/tray_qt.cpp and tests/unit/test_tray.cpp; the thread behavior is unchanged, only the comment-based suppressions were removed. --- sonar-project.properties | 3 +++ src/tray_qt.cpp | 4 ++-- tests/unit/test_tray.cpp | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..2631eb3e --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +# Sonar project analysis properties overrides +sonar.projectKey=LizardByte_tray +sonar.cfamily.reportingCppStandardOverride=c++17 diff --git a/src/tray_qt.cpp b/src/tray_qt.cpp index 09a7f906..049e24a0 100644 --- a/src/tray_qt.cpp +++ b/src/tray_qt.cpp @@ -100,7 +100,7 @@ namespace tray_qt { * @param timeout optional timeout for async run in ms */ void async_tray_notification_acknowledge_(const std::shared_ptr ¬ification, int timeout = 1000) { - std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards + std::thread t([notification]() { std::scoped_lock lock(notification->mutex); if (notification->shown && notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_close(notification->obj, nullptr)) { notification->shown = false; @@ -126,7 +126,7 @@ namespace tray_qt { * @param timeout optional timeout for async run in ms */ void async_tray_notification_show_(const std::shared_ptr ¬ification, int timeout = 1000) { - std::thread t([notification]() { // NOSONAR(cpp:S6168) - jthread is only available on C++20 onwards + std::thread t([notification]() { std::scoped_lock lock(notification->mutex); if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_show(notification->obj, nullptr)) { notification->shown = true; diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index feb0b0aa..cca65b91 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -86,7 +86,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Capture a screenshot while the tray menu is open, then dismiss and exit. void captureMenuStateAndExit(const char *screenshotName) { std::atomic_bool exitRequested {false}; - std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168) - std::jthread is unavailable on AppleClang 17/libc++ used in CI + std::thread capture_thread([this, screenshotName, &exitRequested]() { EXPECT_TRUE(captureScreenshot(screenshotName)); closeMenu(); exitRequested.store(true, std::memory_order_release); From ab1a7ffdb0370af493cdc220342b0c6ee4ec4ceb Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:07:08 -0400 Subject: [PATCH 06/15] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aabf2536..e91875e5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This fork adds the following features: - code coverage - refactored code, e.g., moved source code into the `src` directory - doxygen documentation and readthedocs configuration +- all platforms use QT-based implementation ## Screenshots @@ -32,9 +33,9 @@ This fork adds the following features: ## Supported platforms -* Linux with Qt5 or Qt6 Widgets -* macOS with Qt5 or Qt6 Widgets -* Windows with Qt5 or Qt6 Widgets +* Linux +* macOS +* Windows ## Prerequisites From 7e90956df076dc76f85982f696c87b825c74321d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:13:23 -0400 Subject: [PATCH 07/15] Update CMakeLists.txt --- CMakeLists.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f161b10..7b29f6e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,10 +74,6 @@ else() find_package(Qt5 REQUIRED COMPONENTS Widgets 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_qt.cpp" From 6dd53c9017a92b75f3d8007a08cb9a7dbad6a2a7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:20:58 -0400 Subject: [PATCH 08/15] Add second tray icon and update references Add new tray icon assets (icons/icon2.ico, icons/icon2.png, icons/icon2.svg) and update example and tests to use icon2.png as TRAY_ICON2. Also replace/refresh existing icon.ico and icon.png binaries. This enables using a distinct second icon in the example and unit tests. --- icons/icon.ico | Bin 370070 -> 8241 bytes icons/icon.png | Bin 370070 -> 951 bytes icons/icon2.ico | Bin 0 -> 11539 bytes icons/icon2.png | Bin 0 -> 1319 bytes icons/icon2.svg | 6 ++++++ src/example.c | 2 +- tests/unit/test_tray.cpp | 2 +- 7 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 icons/icon2.ico create mode 100644 icons/icon2.png create mode 100644 icons/icon2.svg diff --git a/icons/icon.ico b/icons/icon.ico index bb96b6e4a7e7e33dee0d168811c3cd35a02556b2..13de66b3b6cb87825bc83d3186baf5be8b0b1626 100644 GIT binary patch literal 8241 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 DRxwR0 literal 370070 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 Z{ttl^Rmsd_>U#hH002ovPDHLkV1nL;tMC8- literal 370070 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 literal 0 HcmV?d00001 diff --git a/icons/icon2.png b/icons/icon2.png new file mode 100644 index 0000000000000000000000000000000000000000..6cb59a5ef728f772f644fdd87378fc5042e97f5e GIT binary patch literal 1319 zcmV+?1=#wDP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1iVQ^K~#8N<(o@r z6G0fq_vq21r;R7^B6#p1coFGEF9zMMBE`p{D1uNZg7#DoMbU#^tWYze}?-&G(;~Z@-y#3`3!m)0#Fj z)+sY%-vz0R&2+~5o@U0%rW|W)D!XG5jY%mqN@c7L)3L|tFGw1uW6he5bvTvXxR}Nn zs8n{-a?`Qj(2kOhJD8_8uch(&fgv)vqFPLgw6!4dDF3mg$Ffd z-Q^B;{OLvZ=-W#lnU^=%`hoqz^AvZMXYD2b4ais>^dTKtab_pG^!6@O>E7pOY}L7T zp*@f3*vI+N*M916&-Jj;!Z?jJRFlPN*7@M1(2tMomZgUKuB0Dm27;%W9~@+}j>Gbu zoi2smURKQI*q>t2_gvS*KA}I31BP2Lh#zPJraeFMzsJVd^udE{yrm_Osa?C+uTP&! z&(HmucXI~q7ocD=bg%CCHUE2LgiWN=LeP|n4I9|c*RMuG*yNIDZR@_g`6`|YDbzaH8f z0FFF4>kA0W0qYw{&I=r`$(duveER?hd4%@IlL!L;RI2y93at&mn&uBhsBm6@LsQx=3R)Y0 z4NRCYD^M5EHc`>m00bw~p~w9~n@Z5q08D-UIE)H!1lp!jVl&{{he7&1eJZy!1+u^Y z$+otY5SsxQH54OZ!jaUP3wwoj6jimig1pnM>!_R3v8l~5A{08U}UL<3S)dqIZ9Ma>LQt!FPE zsH#2Ll8J+w830hs$VL{y$*R3dx42QLsQ~~LTP3PyiyMWS8vs;AQjn_I;zpq^2B?Z8 zs%p2mQK*XnKvg7pierl#g}NC4R7Fx;Tihtr)c_zXlH%IpMxpKofS@8N&Mj^f7Q_Hr zB*neOjlzN$0Qe#)?k#Q<7R&&EHD8M@ZWQp3lHchMn+g1{@FV)eW&wi_0|w{^&4QV+ zo*5~}-a|iV7I2%)z||4@LDSIJy4t-pqWhhJTHxLl_^kKPt7M@SU7V3@?q)VK?U dYyYai{{nZ!nl_-O!YBX$002ovPDHLkV1m>qYSaJ# literal 0 HcmV?d00001 diff --git a/icons/icon2.svg b/icons/icon2.svg new file mode 100644 index 00000000..83ad2a31 --- /dev/null +++ b/icons/icon2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/example.c b/src/example.c index 081891e2..a4322553 100644 --- a/src/example.c +++ b/src/example.c @@ -10,7 +10,7 @@ #include "tray.h" #define TRAY_ICON1 "icon.png" ///< Path to first icon. -#define TRAY_ICON2 "icon.png" ///< Path to second icon. +#define TRAY_ICON2 "icon2.png" ///< Path to second icon. static struct tray tray; diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index cca65b91..8994fede 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -16,7 +16,7 @@ #include "tests/screenshot_utils.h" constexpr const char *TRAY_ICON1 = "icon.png"; -constexpr const char *TRAY_ICON2 = "icon.png"; +constexpr const char *TRAY_ICON2 = "icon2.png"; constexpr const char *TRAY_ICON_SVG = "icon.svg"; constexpr const char *TRAY_ICON_THEMED = "mail-message-new"; From 64a3ef2e7ebea6fc6da68ca8b9180a0a0071eddc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:59:39 -0400 Subject: [PATCH 09/15] Parametrize tray tests and top-level icon config CMake: Only define default icon variables and install/copy helper when the project is top-level (TRAY_IS_TOP_LEVEL). Add a second set of default icons (icon2.*) and include them in TRAY_ICON_FILES. Gate the example target copying behind TRAY_IS_TOP_LEVEL as well. Tests: Convert many unit tests to parameterized tests over icon types (svg/ico/png/themed). Add icon constants for .ico and secondary icons, a TrayIconParam struct, helpers to print and name params, and a nativeNotificationSkipReason helper to conditionally skip notification tests on unsupported environments. Ensure test assets are copied only when an extension is present, update screenshot names to include the icon param, and instantiate the parameterized suites. Remove several redundant single-file icon tests and adjust a few tests to explicitly set SVG icons where needed. Overall this makes tests cover multiple icon formats and avoids top-level-only icon behavior when used as a subproject. --- CMakeLists.txt | 62 ++++++++------ tests/unit/test_tray.cpp | 174 +++++++++++++++++++++++++-------------- 2 files changed, 145 insertions(+), 91 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b29f6e9..3be09303 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,33 +39,41 @@ file(GLOB TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" ) -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() +if(TRAY_IS_TOP_LEVEL) + 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_ICON2_ICO "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon2.ico") + set(TRAY_ICON2_PNG "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon2.png") + set(TRAY_ICON2_SVG "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon2.svg") + set(TRAY_ICON_FILES + "${TRAY_ICON_ICO}" + "${TRAY_ICON_PNG}" + "${TRAY_ICON_SVG}" + "${TRAY_ICON2_ICO}" + "${TRAY_ICON2_PNG}" + "${TRAY_ICON2_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() + 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() +endif() find_package(Qt6 COMPONENTS Widgets Svg) if(Qt6_FOUND) @@ -128,7 +136,7 @@ endif() add_library(tray::tray ALIAS ${PROJECT_NAME}) -if(BUILD_EXAMPLE) +if(TRAY_IS_TOP_LEVEL AND 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) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 8994fede..d06c5b68 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #if defined(_WIN32) || defined(_WIN64) @@ -15,13 +17,50 @@ #include "src/tray.h" #include "tests/screenshot_utils.h" -constexpr const char *TRAY_ICON1 = "icon.png"; -constexpr const char *TRAY_ICON2 = "icon2.png"; +constexpr const char *TRAY_ICON_ICO = "icon.ico"; +constexpr const char *TRAY_ICON_PNG = "icon.png"; constexpr const char *TRAY_ICON_SVG = "icon.svg"; +constexpr const char *TRAY_ICON2_ICO = "icon2.ico"; +constexpr const char *TRAY_ICON2_PNG = "icon2.png"; +constexpr const char *TRAY_ICON2_SVG = "icon2.svg"; constexpr const char *TRAY_ICON_THEMED = "mail-message-new"; +constexpr const char *TRAY_ICON1 = TRAY_ICON_PNG; +constexpr const char *TRAY_ICON2 = TRAY_ICON2_PNG; // File-scope tray data shared across all TrayTest instances namespace { + struct TrayIconParam { + const char *name; + const char *icon; + const char *alternateIcon; + }; + + constexpr std::array TRAY_ICON_PARAMS { + {{"svg", TRAY_ICON_SVG, TRAY_ICON2_SVG}, + {"ico", TRAY_ICON_ICO, TRAY_ICON2_ICO}, + {"png", TRAY_ICON_PNG, TRAY_ICON2_PNG}, + {"themed", TRAY_ICON_THEMED, TRAY_ICON_THEMED}} + }; + + std::string trayIconParamName(const ::testing::TestParamInfo &info) { + return info.param.name; + } + + void PrintTo(const TrayIconParam ¶m, std::ostream *os) { + *os << param.name; + } + + std::string nativeNotificationSkipReason() { +#if defined(_WIN32) + QUERY_USER_NOTIFICATION_STATE notification_state; + if (const HRESULT ns = SHQueryUserNotificationState(¬ification_state); ns != S_OK || notification_state != QUNS_ACCEPTS_NOTIFICATIONS) { + return "Notifications not accepted in this environment. SHQueryUserNotificationState result: " + std::to_string(ns) + ", state: " + std::to_string(notification_state); + } +#endif + + return {}; + } + struct tray_menu g_submenu7_8[] = { // NOSONAR(cpp:S5945, cpp:S5421) - C-style array with null sentinel required by tray C API; mutable for runtime callback assignment {.text = "7", .cb = nullptr}, {.text = "-"}, @@ -172,8 +211,16 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } }; - ensureIconInTestDir(TRAY_ICON1); - ensureIconInTestDir(TRAY_ICON_SVG); + auto ensureFileIconInTestDir = [&ensureIconInTestDir](const char *iconName) { + if (std::filesystem::path(iconName).has_extension()) { + ensureIconInTestDir(iconName); + } + }; + + for (const auto &iconParam : TRAY_ICON_PARAMS) { + ensureFileIconInTestDir(iconParam.icon); + ensureFileIconInTestDir(iconParam.alternateIcon); + } trayRunning = false; testTray.icon = TRAY_ICON1; @@ -201,6 +248,14 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } }; +class TrayIconTest: + public TrayTest, + public ::testing::WithParamInterface {}; + +class TrayNotificationIconTest: + public TrayTest, + public ::testing::WithParamInterface {}; + TEST_F(TrayTest, TestTrayInit) { int result = tray_init(&testTray); trayRunning = (result == 0); @@ -209,6 +264,17 @@ TEST_F(TrayTest, TestTrayInit) { EXPECT_TRUE(captureScreenshot("tray_icon_initial")); } +TEST_P(TrayIconTest, TestTrayIconDisplay) { + const auto &iconParam = GetParam(); + testTray.icon = iconParam.icon; + + int result = tray_init(&testTray); + trayRunning = (result == 0); + EXPECT_EQ(result, 0); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot(std::string("tray_icon_") + iconParam.name)); +} + TEST_F(TrayTest, TestTrayLoop) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); @@ -302,14 +368,13 @@ TEST_F(TrayTest, TestSubmenuCallback) { testTray.menu[4].submenu[0].submenu[0].cb(&testTray.menu[4].submenu[0].submenu[0]); } -TEST_F(TrayTest, TestNotificationDisplay) { -#if defined(_WIN32) - QUERY_USER_NOTIFICATION_STATE notification_state; - if (HRESULT ns = SHQueryUserNotificationState(¬ification_state); - ns != S_OK || notification_state != QUNS_ACCEPTS_NOTIFICATIONS) { - GTEST_SKIP() << "Notifications not accepted in this environment. SHQueryUserNotificationState result: " << ns << ", state: " << notification_state; +TEST_P(TrayNotificationIconTest, TestNotificationDisplay) { + if (const std::string skipReason = nativeNotificationSkipReason(); !skipReason.empty()) { + GTEST_SKIP() << skipReason; } -#endif + + const auto &iconParam = GetParam(); + testTray.icon = iconParam.icon; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); @@ -318,12 +383,12 @@ TEST_F(TrayTest, TestNotificationDisplay) { // Set notification properties testTray.notification_title = "Test Notification"; testTray.notification_text = "This is a test notification message"; - testTray.notification_icon = TRAY_ICON1; + testTray.notification_icon = iconParam.icon; tray_update(&testTray); WaitForTrayReady(); - EXPECT_TRUE(captureScreenshot("tray_notification_displayed")); + EXPECT_TRUE(captureScreenshot(std::string("tray_notification_") + iconParam.name + "_icon")); // Clear notification testTray.notification_title = nullptr; @@ -333,14 +398,13 @@ TEST_F(TrayTest, TestNotificationDisplay) { waitForNativeNotificationTimeout(); } -TEST_F(TrayTest, TestNotificationCallback) { -#if defined(_WIN32) - QUERY_USER_NOTIFICATION_STATE notification_state; - if (HRESULT ns = SHQueryUserNotificationState(¬ification_state); - ns != S_OK || notification_state != QUNS_ACCEPTS_NOTIFICATIONS) { - GTEST_SKIP() << "Notifications not accepted in this environment. SHQueryUserNotificationState result: " << ns << ", state: " << notification_state; +TEST_P(TrayNotificationIconTest, TestNotificationCallback) { + if (const std::string skipReason = nativeNotificationSkipReason(); !skipReason.empty()) { + GTEST_SKIP() << skipReason; } -#endif + + const auto &iconParam = GetParam(); + testTray.icon = iconParam.icon; static bool callbackInvoked = false; auto notification_callback = []() { @@ -354,7 +418,7 @@ TEST_F(TrayTest, TestNotificationCallback) { // Set notification with callback testTray.notification_title = "Clickable Notification"; testTray.notification_text = "Click this notification to test callback"; - testTray.notification_icon = TRAY_ICON1; + testTray.notification_icon = iconParam.icon; testTray.notification_cb = notification_callback; tray_update(&testTray); @@ -423,6 +487,8 @@ TEST_F(TrayTest, TestMenuItemContext) { } TEST_F(TrayTest, TestCheckboxStates) { + testTray.icon = TRAY_ICON_SVG; + int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); @@ -503,6 +569,8 @@ TEST_F(TrayTest, TestQuitCallback) { } TEST_F(TrayTest, TestTrayShowMenu) { + testTray.icon = TRAY_ICON_SVG; + int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); @@ -515,50 +583,12 @@ TEST_F(TrayTest, TestTrayExit) { tray_exit(); } -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, 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); - 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); - waitForNativeNotificationTimeout(); -} - 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. + testTray.icon = TRAY_ICON_SVG; + int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); @@ -566,10 +596,12 @@ TEST_F(TrayTest, TestMenuAppearsOnLeftClick) { captureMenuStateAndExit("tray_menu_left_click"); // NOSONAR(cpp:S6168) - helper uses std::thread for AppleClang 17 compatibility } -TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { +TEST_P(TrayNotificationIconTest, 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. + const auto &iconParam = GetParam(); + testTray.icon = iconParam.icon; static bool callbackInvoked = false; callbackInvoked = false; @@ -579,7 +611,7 @@ TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { testTray.notification_title = "Clickable Notification"; testTray.notification_text = "Click to test callback"; - testTray.notification_icon = TRAY_ICON1; + testTray.notification_icon = iconParam.icon; testTray.notification_cb = []() { callbackInvoked = true; }; @@ -623,7 +655,7 @@ TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { testTray.notification_title = "Menu Callback Regression"; testTray.notification_text = "Notification update should not break menu callbacks"; - testTray.notification_icon = TRAY_ICON1; + testTray.notification_icon = TRAY_ICON_SVG; tray_update(&testTray); WaitForTrayReady(); @@ -639,3 +671,17 @@ TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { testTray.menu[0].cb = original_cb; } + +INSTANTIATE_TEST_SUITE_P( + TrayIcons, + TrayIconTest, + ::testing::ValuesIn(TRAY_ICON_PARAMS), + trayIconParamName +); + +INSTANTIATE_TEST_SUITE_P( + TrayNotificationIcons, + TrayNotificationIconTest, + ::testing::ValuesIn(TRAY_ICON_PARAMS), + trayIconParamName +); From 2b7b7f672a06ecd45a1a800245016cf5ac0de7ae Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:42:09 -0400 Subject: [PATCH 10/15] Add icon format docs and use lookupIcon Add an "Icon formats" section to README describing how `icon` and `notification_icon` can be file paths or theme names, listing supported formats per backend and recommending SVG/PNG for cross-platform use (noting libnotify limitations). Replace direct QIcon construction with lookupIcon(trayStruct->icon) in QtTrayMenu to properly resolve theme icon names and paths when setting the tray icon, aligning runtime behavior with the documented expectations. --- README.md | 16 ++++++++++++++++ src/QtTrayMenu.cpp | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e91875e5..c50b1da4 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,22 @@ Execute the `tests` application: ./build/tests/test_tray ``` +## Icon formats + +The `icon` and `notification_icon` fields can be a path to an image file or an icon theme name. Relative file paths +are resolved from the process working directory, so applications should copy or install icon files where the running +process can find them. + +| Component | Backend | Supported inputs | Notes | +|----------------------------------------------------------------------------------------|-------------------------------------------------------|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Tray icon (`icon`) | Qt `QSystemTrayIcon` / `QIcon` on all platforms | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | +| Notification icon (`notification_icon`) on Windows, macOS, and Linux without libnotify | Qt `QSystemTrayIcon::showMessage` / `QIcon` | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | +| Notification icon (`notification_icon`) on Linux with libnotify | libnotify / freedesktop notification server | SVG, PNG, or icon theme name | libnotify accepts an icon theme name or filename, but the notification server and installed image loaders decide which file formats render. Do not rely on ICO for libnotify notifications. | + +For the most predictable cross-platform behavior, use SVG or PNG files for both tray and notification icons. ICO is +supported by the Qt-backed paths tested by this project, but it is not portable for libnotify notifications. +Qt theme icons should be passed as icon name strings, such as `mail-message-new`. + ## API Tray structure defines an icon and a menu. diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index f7711149..8122bd13 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -101,7 +101,7 @@ void QtTrayMenu::onUpdate(struct tray *tray, const bool notify) { return; } this->trayStruct = tray; - if (const auto newIcon = QIcon(trayStruct->icon); !newIcon.isNull()) { + if (const auto newIcon = lookupIcon(trayStruct->icon); !newIcon.isNull()) { trayIcon->setIcon(newIcon); } trayIcon->setToolTip(QString::fromUtf8(trayStruct->tooltip)); From 7e0194bbcd9e68f76aba4b0d9c6b8a75eb6c4a0f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:10:19 -0400 Subject: [PATCH 11/15] Parameterize tray icon update test Convert the multiple-icon update unit test to a parameterized test (TEST_P) using TrayIconTest/GetParam. Set the initial testTray.icon from the parameter, use the parameter's alternateIcon for the first update, add WaitForTrayReady and captureScreenshot using the parameter name, then restore the original icon. This enables running the same test across different icon variants and capturing screenshots for each. --- tests/unit/test_tray.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index d06c5b68..d91cfb5d 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -513,16 +513,21 @@ TEST_F(TrayTest, TestCheckboxStates) { testTray.menu[1].checked = 1; } -TEST_F(TrayTest, TestMultipleIconUpdates) { +TEST_P(TrayIconTest, TestMultipleIconUpdates) { + const auto &iconParam = GetParam(); + testTray.icon = iconParam.icon; + int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Update icon multiple times - testTray.icon = TRAY_ICON2; + testTray.icon = iconParam.alternateIcon; tray_update(&testTray); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot(std::string("tray_icon_update_") + iconParam.name)); - testTray.icon = TRAY_ICON1; + testTray.icon = iconParam.icon; tray_update(&testTray); } From 6fdb6b6c3b8e1b45f82763e41ec7224d8e65da3d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:52:56 -0400 Subject: [PATCH 12/15] Remove libnotify support; use Qt notifications Drop libnotify integration and related build plumbing, simplify notification handling to use Qt only, and update docs/tests accordingly. Changes include: removed cmake/FindLibNotify.cmake and LibNotify detection/definitions from CMakeLists.txt; removed libnotify-dependent code paths, async threads, and notification bookkeeping from src/tray_qt.cpp and unused includes; simplified notify logic to always use QtTrayMenu; updated README to remove libnotify from platform dependency lists and simplified the icons table; and adjusted a unit test comment to reflect the new Qt-based callback behavior. Overall this removes the external libnotify dependency and cleans up associated code and build configuration. --- CMakeLists.txt | 26 ----- README.md | 25 ++--- cmake/FindLibNotify.cmake | 55 ---------- src/tray_qt.cpp | 224 ++------------------------------------ tests/unit/test_tray.cpp | 4 +- tests/utils.cpp | 28 +++++ tests/utils.h | 2 + 7 files changed, 55 insertions(+), 309 deletions(-) delete mode 100644 cmake/FindLibNotify.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 3be09303..3861e97f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,8 +33,6 @@ option(BUILD_EXAMPLE "Build example app" ${TRAY_IS_TOP_LEVEL}) set(CMAKE_COLOR_MAKEFILE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -find_package(PkgConfig) - file(GLOB TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" ) @@ -88,16 +86,6 @@ list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/QtTrayMenu.cpp" ) -if(UNIX AND NOT APPLE) - find_package(LibNotify) - if(LIBNOTIFY_FOUND) - list(APPEND TRAY_COMPILE_DEFINITIONS TRAY_USE_LIBNOTIFY=1) - list(APPEND TRAY_EXTERNAL_LIBRARIES ${LIBNOTIFY_LIBRARIES}) - list(APPEND TRAY_EXTERNAL_DIRECTORIES ${LIBNOTIFY_LIBRARY_DIRS}) - list(APPEND TRAY_EXTERNAL_INCLUDES ${LIBNOTIFY_INCLUDE_DIRS}) - 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 17) @@ -116,24 +104,10 @@ else() list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::Svg) endif() -if(TRAY_EXTERNAL_INCLUDES) - target_include_directories(${PROJECT_NAME} - SYSTEM PRIVATE - ${TRAY_EXTERNAL_INCLUDES}) -endif() - if(TRAY_COMPILE_DEFINITIONS) target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_DEFINITIONS}) endif() -if(TRAY_EXTERNAL_DIRECTORIES) - foreach(tray_external_directory IN LISTS TRAY_EXTERNAL_DIRECTORIES) - if(tray_external_directory) - target_link_directories(${PROJECT_NAME} PRIVATE "${tray_external_directory}") - endif() - endforeach() -endif() - add_library(tray::tray ALIAS ${PROJECT_NAME}) if(TRAY_IS_TOP_LEVEL AND BUILD_EXAMPLE) diff --git a/README.md b/README.md index c50b1da4..d625dc86 100644 --- a/README.md +++ b/README.md @@ -46,35 +46,35 @@ This fork adds the following features: ### Platform Dependencies -Install either Qt6 _or_ Qt5. Linux builds can also use libnotify when the development package is available. +Install either Qt6 _or_ Qt5.
- Arch ```bash # Qt6 - sudo pacman -S qt6-base qt6-svg libnotify + sudo pacman -S qt6-base qt6-svg # Qt5 - sudo pacman -S qt5-base qt5-svg libnotify + sudo pacman -S qt5-base qt5-svg ``` - Debian/Ubuntu ```bash # Qt6 - sudo apt install qt6-base-dev qt6-svg-dev libnotify-dev + sudo apt install qt6-base-dev qt6-svg-dev # Qt5 - sudo apt install qtbase5-dev libqt5svg5-dev libnotify-dev + sudo apt install qtbase5-dev libqt5svg5-dev ``` - Fedora ```bash # Qt6 - sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel libnotify-devel + sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel # Qt5 - sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel libnotify-devel + sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel ``` - macOS @@ -133,14 +133,13 @@ The `icon` and `notification_icon` fields can be a path to an image file or an i are resolved from the process working directory, so applications should copy or install icon files where the running process can find them. -| Component | Backend | Supported inputs | Notes | -|----------------------------------------------------------------------------------------|-------------------------------------------------------|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Tray icon (`icon`) | Qt `QSystemTrayIcon` / `QIcon` on all platforms | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | -| Notification icon (`notification_icon`) on Windows, macOS, and Linux without libnotify | Qt `QSystemTrayIcon::showMessage` / `QIcon` | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | -| Notification icon (`notification_icon`) on Linux with libnotify | libnotify / freedesktop notification server | SVG, PNG, or icon theme name | libnotify accepts an icon theme name or filename, but the notification server and installed image loaders decide which file formats render. Do not rely on ICO for libnotify notifications. | +| Component | Backend | Supported inputs | Notes | +|-----------------------------------------|--------------------------------------------------------------|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| Tray icon (`icon`) | Qt `QSystemTrayIcon` / `QIcon` on all platforms | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | +| Notification icon (`notification_icon`) | Qt `QSystemTrayIcon::showMessage` / `QIcon` on all platforms | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | For the most predictable cross-platform behavior, use SVG or PNG files for both tray and notification icons. ICO is -supported by the Qt-backed paths tested by this project, but it is not portable for libnotify notifications. +supported by the Qt-backed paths tested by this project. Qt theme icons should be passed as icon name strings, such as `mail-message-new`. ## API diff --git a/cmake/FindLibNotify.cmake b/cmake/FindLibNotify.cmake deleted file mode 100644 index e76b199b..00000000 --- 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 -) diff --git a/src/tray_qt.cpp b/src/tray_qt.cpp index 049e24a0..dd293f57 100644 --- a/src/tray_qt.cpp +++ b/src/tray_qt.cpp @@ -3,18 +3,7 @@ * @brief System tray implementation using Qt. */ // standard includes -#include -#include #include -#include -#include -#include -#include - -#if defined(TRAY_USE_LIBNOTIFY) -// lib includes - #include -#endif // qt includes #include @@ -27,38 +16,6 @@ #include "tray.h" namespace tray_qt { -#if defined(TRAY_USE_LIBNOTIFY) - /** - * Notification element struct - */ - struct notification_data { - /** - * Notification object - */ - NotifyNotification *obj = nullptr; - /** - * Notification callback - */ - void (*cb)() = nullptr; - /** - * Notification shown indicator - */ - bool shown = false; - /** - * Notification mutex for async thread synchronization - */ - std::mutex mutex; - }; - - /** - * Currently shown notifications - */ - std::vector> notifications; // NOSONAR(cpp:S5421) - mutable state, not const - /** - * Lock for currently shown notifications vector - */ - std::mutex notifications_mutex; // NOSONAR(cpp:S5421) - mutable state, not const -#endif /** * QtTrayMenu instance */ @@ -85,109 +42,9 @@ namespace tray_qt { QString desktop_name; // NOSONAR(cpp:S5421) - mutable state, not const /** - * @brief Get the effective application name used for notification backends. - * @return application name as UTF-8 data - */ - QByteArray effective_notify_app_name() { - const QString effective_name = !app_name.isEmpty() ? app_name : QStringLiteral("tray"); - return effective_name.toUtf8(); - } - -#if defined(TRAY_USE_LIBNOTIFY) - /** - * @brief Acknowledge notification asynchronously with timeout to avoid D-Bus lockups - * @param notification Tray notification to close - * @param timeout optional timeout for async run in ms - */ - void async_tray_notification_acknowledge_(const std::shared_ptr ¬ification, int timeout = 1000) { - std::thread t([notification]() { - std::scoped_lock lock(notification->mutex); - if (notification->shown && notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_close(notification->obj, nullptr)) { - notification->shown = false; - g_object_unref(G_OBJECT(notification->obj)); - notification->obj = nullptr; - notification->cb = nullptr; - } - }); - while (notification->obj != nullptr && timeout > 0) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - timeout -= 10; - } - if (timeout > 0) { - t.join(); - } else { - t.detach(); // NOSONAR(cpp:S5962) - Keep this running until it times out by itself, usually after 25 seconds due to D-Bus - } - } - - /** - * @brief Show notification asynchronously with timeout to avoid D-Bus lockups - * @param notification Tray notification to show - * @param timeout optional timeout for async run in ms - */ - void async_tray_notification_show_(const std::shared_ptr ¬ification, int timeout = 1000) { - std::thread t([notification]() { - std::scoped_lock lock(notification->mutex); - if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj) && notify_notification_show(notification->obj, nullptr)) { - notification->shown = true; - } - }); - while (!notification->shown && timeout > 0) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - timeout -= 10; - } - if (timeout > 0) { - t.join(); - } else { - t.detach(); // NOSONAR(cpp:S5962) - Keep this running until it times out by itself, usually after 25 seconds due to D-Bus - } - } - - void acknowledge_notification(bool run_callback); - - /** - * @brief Initialize notifications - * @param notify_app_name application name for notifications - * @return true if successful - */ - bool init_notify(const char *notify_app_name) { - if (!notify_is_initted()) { - if (!notifications.empty()) { - acknowledge_notification(false); - } - return notify_init(notify_app_name); - } - return true; // Already initialized, so init was successful - } -#else - /** - * @brief Initialize notifications - * @return false because no native notification backend is compiled in - */ - bool init_notify(const char *) { - return false; - } -#endif - - /** - * @brief Acknowledge/click current notification - * @param run_callback Run notification callback when acknowledging + * @brief Acknowledge/click current notification. */ - void acknowledge_notification(const bool run_callback = false) { -#if defined(TRAY_USE_LIBNOTIFY) - if (notify_is_initted()) { - std::scoped_lock lock(notifications_mutex); - for (const auto ¬ification : notifications) { - if (run_callback && notification->cb != nullptr) { - notification->cb(); - } - async_tray_notification_acknowledge_(notification); - } - notifications.clear(); - return; - } -#endif - + void acknowledge_notification() { if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { qt_tray_menu->clickMessage(); } @@ -197,12 +54,6 @@ namespace tray_qt { * @brief Clear current notification state without invoking callbacks. */ void clear_notification() { -#if defined(TRAY_USE_LIBNOTIFY) - if (notify_is_initted()) { - acknowledge_notification(); - } -#endif - if (qt_tray_menu != nullptr) { qt_tray_menu->clearMessageCallback(); } @@ -213,66 +64,17 @@ namespace tray_qt { * @param tray Tray structure containing notification information */ void notify(struct tray *tray) { - if (tray->notification_text == nullptr || std::string(tray->notification_text).empty()) { + if (tray->notification_text == nullptr || tray->notification_text[0] == '\0') { clear_notification(); return; } -#if defined(TRAY_USE_LIBNOTIFY) - // Try to notify using libnotify - if (notify_is_initted()) { - if (!notifications.empty()) { - acknowledge_notification(); - } - std::scoped_lock lock(notifications_mutex); - std::filesystem::path notification_icon = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; - if (std::filesystem::exists(notification_icon)) { - // Use absolute path for filesystem icon files, not a relative one - notification_icon = std::filesystem::absolute(notification_icon); - } - auto notification = std::make_shared(); - notification->obj = notify_notification_new(tray->notification_title, tray->notification_text, notification_icon.c_str()); - if (notification->obj != nullptr && NOTIFY_IS_NOTIFICATION(notification->obj)) { - if (tray->notification_cb != nullptr) { - notification->cb = tray->notification_cb; - notify_notification_add_action(notification->obj, "default", "Default", NOTIFY_ACTION_CALLBACK(tray->notification_cb), nullptr, nullptr); - } - notifications.emplace_back(notification); - async_tray_notification_show_(notification); - return; - } - } -#endif - // Fallback to QtTrayMenu notification if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { - qt_tray_menu->showMessage(tray->notification_title, tray->notification_text, tray->notification_icon, tray->notification_cb); - } - } - - /** - * @brief Uninitialize notifications - */ - void uninit_notify() { -#if defined(TRAY_USE_LIBNOTIFY) - if (notify_is_initted()) { - acknowledge_notification(); - notify_uninit(); - } -#endif - } - - /** - * @brief Update notification app name. - */ - void set_notify_app_info() { -#if defined(TRAY_USE_LIBNOTIFY) - if (!notify_is_initted()) { - return; + if (tray->notification_icon != nullptr) { + qt_tray_menu->showMessage(tray->notification_title, tray->notification_text, tray->notification_icon, tray->notification_cb); + } else { + qt_tray_menu->showMessage(tray->notification_title, tray->notification_text, tray->notification_cb); + } } - - uninit_notify(); - const QByteArray notify_app_name = effective_notify_app_name(); - init_notify(notify_app_name.constData()); -#endif } /** @@ -339,7 +141,6 @@ extern "C" { tray_qt::app_display_name = app_display_name != nullptr ? QString::fromUtf8(app_display_name) : QString(); tray_qt::desktop_name = desktop_name != nullptr ? QString::fromUtf8(desktop_name) : QString(); - tray_qt::set_notify_app_info(); tray_qt::apply_app_info(); } @@ -358,9 +159,8 @@ extern "C" { } tray_qt::apply_app_info(); - const QByteArray notify_app_name = tray_qt::effective_notify_app_name(); - if (!tray_qt::init_notify(notify_app_name.constData()) && !QtTrayMenu::supportsMessages()) { - // Notification init failed. Clean up and return error. + if (!QtTrayMenu::supportsMessages()) { + // Notification support is unavailable. Clean up and return error. tray_exit(); return -1; } @@ -386,8 +186,6 @@ extern "C" { } void tray_exit(void) { - tray_qt::uninit_notify(); - if (tray_qt::qt_tray_menu == nullptr) { return; } @@ -418,7 +216,7 @@ extern "C" { } void tray_simulate_notification_click(void) { - tray_qt::acknowledge_notification(true); + tray_qt::acknowledge_notification(); } } // extern "C" diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index d91cfb5d..354d9a9d 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -379,6 +379,7 @@ TEST_P(TrayNotificationIconTest, TestNotificationDisplay) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); + dismissNativeNotifications(); // Set notification properties testTray.notification_title = "Test Notification"; @@ -603,8 +604,7 @@ TEST_F(TrayTest, TestMenuAppearsOnLeftClick) { TEST_P(TrayNotificationIconTest, 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. + // The test hook exercises the same stored callback used by Qt's messageClicked signal. const auto &iconParam = GetParam(); testTray.icon = iconParam.icon; static bool callbackInvoked = false; diff --git a/tests/utils.cpp b/tests/utils.cpp index 1963c261..26f21324 100644 --- a/tests/utils.cpp +++ b/tests/utils.cpp @@ -7,8 +7,26 @@ // standard includes #include +#include #include +#ifdef __linux__ +namespace { + void closeFreedesktopNotifications() { + constexpr const char *close_notifications = + "if command -v dbus-send >/dev/null 2>&1; then " + "id=1; while [ \"$id\" -le 128 ]; do " + "dbus-send --session --print-reply=literal --dest=org.freedesktop.Notifications " + "/org/freedesktop/Notifications org.freedesktop.Notifications.CloseNotification uint32:$id " + ">/dev/null 2>&1; " + "id=$((id + 1)); " + "done; " + "fi"; + (void) std::system(close_notifications); // NOSONAR(cpp:S4721) - test-only cleanup of desktop notifications + } +} // namespace +#endif + /** * @brief Set an environment variable. * @param name Name of the environment variable @@ -23,9 +41,19 @@ int setEnv(const std::string &name, const std::string &value) { #endif } +void dismissNativeNotifications() { +#ifdef __linux__ + closeFreedesktopNotifications(); + constexpr auto wait_timeout = std::chrono::milliseconds(500); + std::this_thread::sleep_for(wait_timeout); +#endif +} + void waitForNativeNotificationTimeout() { #ifdef _WIN32 constexpr auto wait_timeout = std::chrono::milliseconds(6000); std::this_thread::sleep_for(wait_timeout); +#elif defined(__linux__) + dismissNativeNotifications(); #endif } diff --git a/tests/utils.h b/tests/utils.h index 24a11da6..f24994a4 100644 --- a/tests/utils.h +++ b/tests/utils.h @@ -9,4 +9,6 @@ int setEnv(const std::string &name, const std::string &value); +void dismissNativeNotifications(); + void waitForNativeNotificationTimeout(); From 700555828da042507d54bfa443b0389c8b14e2c8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:42:13 -0400 Subject: [PATCH 13/15] Add WaitForNotificationReady for Windows CI tests/unit/test_tray.cpp: add include and a new WaitForNotificationReady helper that calls WaitForTrayReady and, on Windows running in GitHub Actions (GITHUB_ACTIONS env var), pumps the tray loop multiple times with short sleeps to stabilize notification display. Replace direct WaitForTrayReady calls in the notification test with the new helper to reduce CI flakiness. --- tests/unit/test_tray.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 354d9a9d..b4a00e00 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -246,6 +247,18 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must std::this_thread::sleep_for(std::chrono::milliseconds(5)); } } + + void WaitForNotificationReady() { + WaitForTrayReady(); +#if defined(_WIN32) + if (std::getenv("GITHUB_ACTIONS") != nullptr) { + for (int i = 0; i < 40; i++) { + tray_loop(0); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } +#endif + } }; class TrayIconTest: @@ -388,7 +401,7 @@ TEST_P(TrayNotificationIconTest, TestNotificationDisplay) { tray_update(&testTray); - WaitForTrayReady(); + WaitForNotificationReady(); EXPECT_TRUE(captureScreenshot(std::string("tray_notification_") + iconParam.name + "_icon")); // Clear notification From 13ba06611e8b40a3b4f8e41b9df0e5a523a96da6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:06:27 -0400 Subject: [PATCH 14/15] Add isGitHubActions helper and use in tests Introduce a cross-platform isGitHubActions() helper (uses _dupenv_s on Windows and getenv elsewhere) and declare it in tests/utils.h. Replace direct getenv usage in tests/unit/test_tray.cpp with the new helper and guard the Windows notification timeout to avoid unnecessary sleeps outside GitHub Actions. Also remove an unused include. --- tests/unit/test_tray.cpp | 3 +-- tests/utils.cpp | 22 ++++++++++++++++++++++ tests/utils.h | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index b4a00e00..621e5cf3 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -251,7 +250,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must void WaitForNotificationReady() { WaitForTrayReady(); #if defined(_WIN32) - if (std::getenv("GITHUB_ACTIONS") != nullptr) { + if (isGitHubActions()) { for (int i = 0; i < 40; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(50)); diff --git a/tests/utils.cpp b/tests/utils.cpp index 26f21324..12ca9a71 100644 --- a/tests/utils.cpp +++ b/tests/utils.cpp @@ -10,6 +10,13 @@ #include #include +#ifdef _WIN32 + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + #ifdef __linux__ namespace { void closeFreedesktopNotifications() { @@ -41,6 +48,18 @@ int setEnv(const std::string &name, const std::string &value) { #endif } +bool isGitHubActions() { +#ifdef _WIN32 + SetLastError(ERROR_SUCCESS); + if (GetEnvironmentVariableA("GITHUB_ACTIONS", nullptr, 0) > 0) { + return true; + } + return GetLastError() == ERROR_SUCCESS; +#else + return std::getenv("GITHUB_ACTIONS") != nullptr; +#endif +} + void dismissNativeNotifications() { #ifdef __linux__ closeFreedesktopNotifications(); @@ -51,6 +70,9 @@ void dismissNativeNotifications() { void waitForNativeNotificationTimeout() { #ifdef _WIN32 + if (!isGitHubActions()) { + return; + } constexpr auto wait_timeout = std::chrono::milliseconds(6000); std::this_thread::sleep_for(wait_timeout); #elif defined(__linux__) diff --git a/tests/utils.h b/tests/utils.h index f24994a4..489c2242 100644 --- a/tests/utils.h +++ b/tests/utils.h @@ -9,6 +9,8 @@ int setEnv(const std::string &name, const std::string &value); +bool isGitHubActions(); + void dismissNativeNotifications(); void waitForNativeNotificationTimeout(); From c7bb9e515bb2de3bb56448b3b070f3e72138868f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:26:56 -0400 Subject: [PATCH 15/15] Simplify icon formats section in README Replace the detailed table describing tray and notification icon backends with a concise line stating that SVG, ICO, PNG, and Qt theme icon names are supported. Keeps the existing recommendation to prefer SVG/PNG for predictable cross-platform behavior and removes redundant/verbose table markup. --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index d625dc86..1e52996c 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,7 @@ The `icon` and `notification_icon` fields can be a path to an image file or an i are resolved from the process working directory, so applications should copy or install icon files where the running process can find them. -| Component | Backend | Supported inputs | Notes | -|-----------------------------------------|--------------------------------------------------------------|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| -| Tray icon (`icon`) | Qt `QSystemTrayIcon` / `QIcon` on all platforms | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | -| Notification icon (`notification_icon`) | Qt `QSystemTrayIcon::showMessage` / `QIcon` on all platforms | SVG, ICO, PNG, Qt theme icon names | Loaded through Qt's `QIcon` path; SVG, ICO, and PNG are tested. Theme icon names are resolved by Qt when the platform/theme supports them. | +SVG, ICO, PNG, and Qt theme icon names are supported. For the most predictable cross-platform behavior, use SVG or PNG files for both tray and notification icons. ICO is supported by the Qt-backed paths tested by this project.