From 2e854763428737237a1c27053fcc9159cd55e33c 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/19] 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 | 1 - src/QtTrayMenu.cpp | 34 +- src/QtTrayMenu.h | 7 +- src/example.c | 9 +- src/tray.h | 30 +- src/tray_darwin.m | 194 --------- 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, 331 insertions(+), 915 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 1fede4eb..b460a66d 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -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 dd136bda..0730e2e9 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,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. @@ -51,18 +51,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.
@@ -93,6 +95,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 37c4eaf2..a4c89eee 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -33,7 +33,6 @@ PROJECT_NAME = tray 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 f6a8fb9e..00000000 --- a/src/tray_darwin.m +++ /dev/null @@ -1,194 +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) { - // This must run on the main thread - void (^updateBlock)(void) = ^{ - 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]; - } - }; - - if ([NSThread isMainThread]) { - updateBlock(); - } else { - dispatch_sync(dispatch_get_main_queue(), updateBlock); - } -} - -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 c7ad21fd..ac36e6b9 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() const { -#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() const { -#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 0e46f7ee950cb324d8949dce11afeebe34499855 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/19] 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 e92cfd632419b52f46b2f209d78709004a021f6f 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/19] 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 ++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index ac36e6b9..0ad4903f 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(); } From e22eccbd3b5a19555230fbaaacbe15cede364bc1 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/19] 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 67d1579bdf18ced3b022d0fd12151acf9ce86bed 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/19] 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. --- src/tray_qt.cpp | 4 ++-- tests/unit/test_tray.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 0ad4903f..a35aa13b 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) const { 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 8e79e8e6cc8acb0b56073ea128f8829f0967b160 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/19] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0730e2e9..8b5a90c7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,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 @@ -51,9 +52,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 f5299ac814d02f9ad749a149979bf9066f99ab26 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/19] 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 164899a51b7116d8675c8ffd91be859247a76b3f 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/19] 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 a35aa13b..3542d8e8 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 f0eeb3c78aa227e5bb3bf3a7f87e9528f19a392a 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/19] 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 3542d8e8..9b246405 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 7eb79da77a8f9907da9f41e9ab6e9cd5550c4639 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/19] 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 8b5a90c7..3d193acd 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,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 69c1eb3a638f78eb71c42fbbcb0a1b8577ebb2d7 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/19] 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 9b246405..1f2450dc 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 d4159e4ee8a740ba177a6dc3e8340aa844b83007 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/19] 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 +- 5 files changed, 25 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 3d193acd..bc9e405d 100644 --- a/README.md +++ b/README.md @@ -65,35 +65,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 @@ -152,14 +152,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 1f2450dc..a7e2ad33 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; From b89a67c89e91430739539ada71a6e882b6d27efc 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/19] 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 a7e2ad33..afc65ed8 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 86a5738036fc3521103da7911867b959c36a2aac 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/19] 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 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index afc65ed8..c4eaf679 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)); From 480039894d0da728b2565e9b5f17bf76638ba621 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/19] 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 bc9e405d..0a3927d5 100644 --- a/README.md +++ b/README.md @@ -152,10 +152,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. From f1ff1447b15ed6125a86f2434200f19e950ddba8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:32:02 -0400 Subject: [PATCH 16/19] Add shared test notification wait helpers Introduce `tests/notification_utils.{h,cpp}` and include it in the test build so native notification cleanup/wait behavior is centralized across platforms (Linux DBus dismissal, Windows CI timeout handling). Update tray tests to use `lizardbyte::common::is_github_actions()` and pull in the new helper, with a `lizardbyte-common` submodule bump to support the shared env check. --- tests/CMakeLists.txt | 1 + tests/notification_utils.cpp | 53 +++++++++++++++++++++++++++++++++++ tests/notification_utils.h | 9 ++++++ tests/unit/test_tray.cpp | 10 +++++-- tests/unit/test_tray_qt.cpp | 3 +- third-party/lizardbyte-common | 2 +- 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/notification_utils.cpp create mode 100644 tests/notification_utils.h diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 95669192..22193683 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,7 @@ endif() file(GLOB_RECURSE TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/conftest.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/notification_utils.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/screenshot_utils.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_*.cpp" ) diff --git a/tests/notification_utils.cpp b/tests/notification_utils.cpp new file mode 100644 index 00000000..0d32a712 --- /dev/null +++ b/tests/notification_utils.cpp @@ -0,0 +1,53 @@ +/** + * @file notification_utils.cpp + * @brief Native notification helper definitions for tests. + */ + +// test includes +#include "notification_utils.h" + +// standard includes +#include +#include +#include + +// lib includes +#include + +#if defined(__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 + +void dismissNativeNotifications() { +#if defined(__linux__) + closeFreedesktopNotifications(); + constexpr auto wait_timeout = std::chrono::milliseconds(500); + std::this_thread::sleep_for(wait_timeout); +#endif +} + +void waitForNativeNotificationTimeout() { +#if defined(_WIN32) + if (!lizardbyte::common::is_github_actions()) { + return; + } + + constexpr auto wait_timeout = std::chrono::milliseconds(6000); + std::this_thread::sleep_for(wait_timeout); +#elif defined(__linux__) + dismissNativeNotifications(); +#endif +} diff --git a/tests/notification_utils.h b/tests/notification_utils.h new file mode 100644 index 00000000..f9f82e26 --- /dev/null +++ b/tests/notification_utils.h @@ -0,0 +1,9 @@ +/** + * @file notification_utils.h + * @brief Native notification helpers for tests. + */ +#pragma once + +void dismissNativeNotifications(); + +void waitForNativeNotificationTimeout(); diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index c4eaf679..35db24d7 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -9,12 +9,18 @@ #include #include +// lib includes +#include + #if defined(_WIN32) || defined(_WIN64) #include #endif // local includes #include "src/tray.h" + +// test includes +#include "tests/notification_utils.h" #include "tests/screenshot_utils.h" constexpr const char *TRAY_ICON_ICO = "icon.ico"; @@ -247,10 +253,10 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } } - void WaitForNotificationReady() { + void WaitForNotificationReady() const { WaitForTrayReady(); #if defined(_WIN32) - if (isGitHubActions()) { + if (lizardbyte::common::is_github_actions()) { for (int i = 0; i < 40; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(50)); diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index 46d7af34..53bd3571 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -1,5 +1,6 @@ // test includes #include "tests/conftest.cpp" +#include "tests/notification_utils.h" // local includes #include "src/tray.h" @@ -86,7 +87,7 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem ASSERT_EQ(initResult, 0); } - void PumpEvents(int iterations = 20) { + void PumpEvents(int iterations = 20) const { for (int i = 0; i < iterations; i++) { tray_loop(0); } diff --git a/third-party/lizardbyte-common b/third-party/lizardbyte-common index 06cd442b..c01334f8 160000 --- a/third-party/lizardbyte-common +++ b/third-party/lizardbyte-common @@ -1 +1 @@ -Subproject commit 06cd442b808f02f1674f3192a84d25b9a503c482 +Subproject commit c01334f8acfdb389720e1afc0150b2200237c2a0 From 894a17e0451512610ead9b12f5d47340ffcc8544 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:15:33 -0400 Subject: [PATCH 17/19] Make tray_update thread-safe via Qt invoke tray_update now marshals calls from non-Qt threads to the GUI thread using QMetaObject::invokeMethod with BlockingQueuedConnection, keeping the C API synchronous. A new test verifies that a worker-thread call blocks until the main thread processes the update. --- src/tray_qt.cpp | 18 ++++++++++++++++-- tests/unit/test_tray_qt.cpp | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/tray_qt.cpp b/src/tray_qt.cpp index dd293f57..079b3cc8 100644 --- a/src/tray_qt.cpp +++ b/src/tray_qt.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include // local includes #include "QtTrayMenu.h" @@ -181,8 +183,20 @@ extern "C" { if (tray_qt::qt_tray_menu == nullptr) { return; } - tray_qt::qt_tray_menu->update(tray, false); - tray_qt::notify(tray); + + auto *const tray_menu = tray_qt::qt_tray_menu.get(); + const auto apply_update = [tray_menu, tray]() { + tray_menu->update(tray, false); + tray_qt::notify(tray); + }; + + if (QThread::currentThread() == tray_menu->thread()) { + apply_update(); + return; + } + + // Keep the C API synchronous so callers can safely reuse or release tray data after this function returns. + (void) QMetaObject::invokeMethod(tray_menu, apply_update, Qt::BlockingQueuedConnection); } void tray_exit(void) { diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index 53bd3571..ad319fdd 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -7,8 +7,11 @@ // standard includes #include +#include +#include #include #include +#include #include namespace { @@ -130,6 +133,41 @@ TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { EXPECT_EQ(notification_callback_count(), 0); } +TEST_F(TrayQtCoverageTest, UpdateFromWorkerThreadWaitsForApplicationThread) { + InitTray(); + + std::array workerMenu = {{{.text = "Worker item", .cb = menu_item_cb}, {.text = nullptr}}}; + std::array emptyMenu = {{{.text = nullptr}}}; + trayData->menu = workerMenu.data(); + + std::atomic workerStarted {false}; + std::atomic updateReturned {false}; + std::thread worker([&]() { + workerStarted.store(true, std::memory_order_release); + tray_update(trayData); + trayData->menu = emptyMenu.data(); + updateReturned.store(true, std::memory_order_release); + }); + + while (!workerStarted.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + EXPECT_FALSE(updateReturned.load(std::memory_order_acquire)); + + while (!updateReturned.load(std::memory_order_acquire)) { + PumpEvents(1); + } + worker.join(); + + // Process any asynchronously queued update before checking the resulting menu. + PumpEvents(); + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(menu_callback_count(), 1); +} + TEST_F(TrayQtCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { trayData->menu = nullptr; InitTray(); From ee1440ea4d92f3d5bf3e20dc9873a42f3dbce087 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:35:08 -0400 Subject: [PATCH 18/19] Match Windows tray appearance in Qt Add a Windows-only appearance helper that reads the interactive user's AppsUseLightTheme setting, applies the corresponding Qt color scheme, and enables the Qt Windows 11 style when available. Wire it into QtTrayMenu startup, add the required Windows libraries/sources to CMake, and cover the mapping and integration behavior with unit tests. --- CMakeLists.txt | 9 ++ src/QtTrayMenu.cpp | 12 +++ src/WindowsAppearance.cpp | 159 ++++++++++++++++++++++++++++++++++++ src/WindowsAppearance.h | 70 ++++++++++++++++ tests/unit/test_tray_qt.cpp | 31 +++++++ 5 files changed, 281 insertions(+) create mode 100644 src/WindowsAppearance.cpp create mode 100644 src/WindowsAppearance.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 3861e97f..a487c529 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,12 @@ list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_qt.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/QtTrayMenu.cpp" ) +if(WIN32) + list(APPEND TRAY_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/WindowsAppearance.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/WindowsAppearance.h" + ) +endif() add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) @@ -103,6 +109,9 @@ if(TRAY_QT_VERSION EQUAL 6) else() list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::Svg) endif() +if(WIN32) + list(APPEND TRAY_EXTERNAL_LIBRARIES advapi32 Wtsapi32) +endif() if(TRAY_COMPILE_DEFINITIONS) target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_DEFINITIONS}) diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index 8122bd13..cb35cf55 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -15,6 +15,10 @@ // local includes #include "QtTrayMenu.h" +#if defined(_WIN32) + #include "WindowsAppearance.h" +#endif + namespace { int defaultArgc = 1; // NOSONAR(cpp:S5421): This is required for QApplication's argc/argv constructor char defaultArgv0[] = "TrayMenuApp"; // NOSONAR(cpp:S5421): This is required for QApplication's argc/argv constructor @@ -41,6 +45,9 @@ QtTrayMenu::QtTrayMenu(int argc, char **argv, QObject *parent, const bool debug) app = new QApplication(argc, argv); // NOSONAR(cpp:S5025) - Qt has its own integrated memory management } } +#if defined(_WIN32) + tray_qt::windows::configure_appearance(app); +#endif if (debug) { app->installEventFilter(this); } @@ -161,6 +168,11 @@ void QtTrayMenu::onExitRequested() { void QtTrayMenu::updateMenu(struct tray_menu *items) { // Create and setup new tray menu instance const auto newTrayTopMenu = new QMenu(); // NOSONAR(cpp:S5025) - Qt has its own integrated memory management +#if defined(_WIN32) + connect(newTrayTopMenu, &QMenu::aboutToShow, this, []() { + tray_qt::windows::sync_color_scheme(); + }); +#endif trayIcon->setContextMenu(newTrayTopMenu); // Fill new tray menu instance createMenu(items, newTrayTopMenu); diff --git a/src/WindowsAppearance.cpp b/src/WindowsAppearance.cpp new file mode 100644 index 00000000..ebc2a152 --- /dev/null +++ b/src/WindowsAppearance.cpp @@ -0,0 +1,159 @@ +/** + * @file src/WindowsAppearance.cpp + * @brief Definitions for Windows system tray appearance handling. + */ + +/** + * @def WIN32_LEAN_AND_MEAN + * @brief Exclude rarely used Windows declarations. + */ +#define WIN32_LEAN_AND_MEAN + +// standard includes +#include +#include +#include + +// platform includes +#include +#include + +// qt includes +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "WindowsAppearance.h" + +namespace tray_qt::windows { + color_scheme_e color_scheme_from_apps_use_light_theme(std::optional apps_use_light_theme) { + if (!apps_use_light_theme) { + return color_scheme_e::unknown; + } + + return *apps_use_light_theme == 0 ? color_scheme_e::dark : color_scheme_e::light; + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + /** + * @brief Read AppsUseLightTheme from the user currently being impersonated. + * + * @return The registry value, or no value when it cannot be read. + */ + static std::optional current_user_apps_use_light_theme() { + HKEY user_key = nullptr; + const auto open_error = RegOpenCurrentUser(KEY_QUERY_VALUE, &user_key); + if (open_error != ERROR_SUCCESS) { + return std::nullopt; + } + + DWORD apps_use_light_theme = 0; + DWORD value_size = sizeof(apps_use_light_theme); + const auto query_error = RegGetValueW( + user_key, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", + RRF_RT_REG_DWORD, + nullptr, + &apps_use_light_theme, + &value_size + ); + RegCloseKey(user_key); + + if (query_error != ERROR_SUCCESS) { + return std::nullopt; + } + + return apps_use_light_theme; + } +#endif + + color_scheme_e interactive_user_color_scheme() { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const auto console_session_id = WTSGetActiveConsoleSessionId(); + HANDLE user_token = nullptr; + if (console_session_id != 0xFFFFFFFF && WTSQueryUserToken(console_session_id, &user_token)) { + if (ImpersonateLoggedOnUser(user_token)) { + const auto apps_use_light_theme = current_user_apps_use_light_theme(); + if (!RevertToSelf()) { + qCritical() << "QtTrayMenu: failed to revert user impersonation after reading the Windows color scheme:" << GetLastError(); + std::terminate(); + } + CloseHandle(user_token); + return color_scheme_from_apps_use_light_theme(apps_use_light_theme); + } + CloseHandle(user_token); + } + + return color_scheme_from_apps_use_light_theme(current_user_apps_use_light_theme()); +#else + return color_scheme_e::unknown; +#endif + } + + bool should_use_windows_11_style() { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + return QOperatingSystemVersion::current() >= QOperatingSystemVersion::Windows11; +#else + return false; +#endif + } + + bool windows_11_style_is_active() { + return QApplication::instance() != nullptr && QApplication::style()->objectName().compare(QStringLiteral("windows11"), Qt::CaseInsensitive) == 0; + } + + color_scheme_e current_color_scheme() { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + switch (QApplication::styleHints()->colorScheme()) { + case Qt::ColorScheme::Light: + return color_scheme_e::light; + case Qt::ColorScheme::Dark: + return color_scheme_e::dark; + default: + return color_scheme_e::unknown; + } +#else + return color_scheme_e::unknown; +#endif + } + + void sync_color_scheme() { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const auto color_scheme = interactive_user_color_scheme(); + if (color_scheme != color_scheme_e::unknown) { + QApplication::styleHints()->setColorScheme(color_scheme == color_scheme_e::dark ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light); + } else { + qWarning("QtTrayMenu: could not read the interactive user's Windows application color scheme"); + } +#endif + } + + void configure_appearance(QApplication *app) { + if (app == nullptr) { + qWarning("QtTrayMenu: cannot configure Windows appearance without a QApplication"); + return; + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + sync_color_scheme(); + + if (should_use_windows_11_style() && !windows_11_style_is_active()) { + if (auto *windows_11_style = QStyleFactory::create(QStringLiteral("windows11"))) { + QApplication::setStyle(windows_11_style); + } else { + qWarning() << "QtTrayMenu: the Qt Windows 11 style is unavailable; using" << app->style()->objectName(); + } + } +#else + qWarning("QtTrayMenu: mirroring the interactive user's color scheme requires Qt 6.8 or newer"); +#endif + + qInfo() << "QtTrayMenu: using Qt style" << app->style()->objectName(); + } +} // namespace tray_qt::windows diff --git a/src/WindowsAppearance.h b/src/WindowsAppearance.h new file mode 100644 index 00000000..e4ef659d --- /dev/null +++ b/src/WindowsAppearance.h @@ -0,0 +1,70 @@ +/** + * @file src/WindowsAppearance.h + * @brief Declarations for Windows system tray appearance handling. + */ +#pragma once + +// standard includes +#include +#include + +class QApplication; + +namespace tray_qt::windows { + /** + * @brief Color scheme requested for the Windows system tray. + */ + enum class color_scheme_e { + unknown, ///< No application color scheme preference is available. + light, ///< Use the light application color scheme. + dark ///< Use the dark application color scheme. + }; + + /** + * @brief Convert the Windows AppsUseLightTheme setting to a tray color scheme. + * + * @param apps_use_light_theme The registry setting, or no value when it is unavailable. + * @return The corresponding tray color scheme. + */ + color_scheme_e color_scheme_from_apps_use_light_theme(std::optional apps_use_light_theme); + + /** + * @brief Read the interactive user's Windows application color scheme. + * + * @return The requested color scheme, or unknown when it cannot be read. + */ + color_scheme_e interactive_user_color_scheme(); + + /** + * @brief Check whether the modern Windows 11 Qt style should be used. + * + * @return true when the operating system and Qt version support the style. + */ + bool should_use_windows_11_style(); + + /** + * @brief Check whether the modern Windows 11 Qt style is active. + * + * @return true when QApplication is using the windows11 style. + */ + bool windows_11_style_is_active(); + + /** + * @brief Read the color scheme currently applied to Qt. + * + * @return The active Qt color scheme, or unknown when the Qt version does not expose it. + */ + color_scheme_e current_color_scheme(); + + /** + * @brief Refresh Qt's color scheme from the interactive user's Windows preference. + */ + void sync_color_scheme(); + + /** + * @brief Apply the interactive user's color scheme and the modern Windows style to Qt. + * + * @param app The tray QApplication instance. + */ + void configure_appearance(QApplication *app); +} // namespace tray_qt::windows diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index ad319fdd..e5f5794a 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -10,10 +10,17 @@ #include #include #include +#include #include +#include #include #include +#if defined(_WIN32) + // local includes + #include "src/WindowsAppearance.h" +#endif + namespace { int &menu_callback_count() { static int count = 0; @@ -103,6 +110,30 @@ class TrayQtCoverageTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture mem struct tray *trayData = nullptr; }; +#if defined(_WIN32) +TEST(WindowsAppearanceTest, AppsUseLightThemeMapsToColorScheme) { + using tray_qt::windows::color_scheme_e; + using tray_qt::windows::color_scheme_from_apps_use_light_theme; + + EXPECT_EQ(color_scheme_from_apps_use_light_theme(std::nullopt), color_scheme_e::unknown); + EXPECT_EQ(color_scheme_from_apps_use_light_theme(std::uint32_t {0}), color_scheme_e::dark); + EXPECT_EQ(color_scheme_from_apps_use_light_theme(std::uint32_t {1}), color_scheme_e::light); + EXPECT_EQ(color_scheme_from_apps_use_light_theme(std::uint32_t {2}), color_scheme_e::light); +} + +TEST_F(TrayQtCoverageTest, MirrorsInteractiveUserWindowsAppearance) { + const auto expected_color_scheme = tray_qt::windows::interactive_user_color_scheme(); + InitTray(); + + if (expected_color_scheme != tray_qt::windows::color_scheme_e::unknown) { + EXPECT_EQ(tray_qt::windows::current_color_scheme(), expected_color_scheme); + } + if (tray_qt::windows::should_use_windows_11_style()) { + EXPECT_TRUE(tray_qt::windows::windows_11_style_is_active()); + } +} +#endif + TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { InitTray(); From 355bf735b8b6b6c3120937d9e61b55c0296c817a Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:59:38 -0400 Subject: [PATCH 19/19] Sonar fixes --- src/WindowsAppearance.cpp | 8 ++++---- src/WindowsAppearance.h | 2 +- tests/unit/test_tray_qt.cpp | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/WindowsAppearance.cpp b/src/WindowsAppearance.cpp index ebc2a152..2687bfe1 100644 --- a/src/WindowsAppearance.cpp +++ b/src/WindowsAppearance.cpp @@ -32,7 +32,7 @@ namespace tray_qt::windows { color_scheme_e color_scheme_from_apps_use_light_theme(std::optional apps_use_light_theme) { - if (!apps_use_light_theme) { + if (!apps_use_light_theme.has_value()) { return color_scheme_e::unknown; } @@ -134,7 +134,7 @@ namespace tray_qt::windows { #endif } - void configure_appearance(QApplication *app) { + void configure_appearance(const QApplication *app) { if (app == nullptr) { qWarning("QtTrayMenu: cannot configure Windows appearance without a QApplication"); return; @@ -147,13 +147,13 @@ namespace tray_qt::windows { if (auto *windows_11_style = QStyleFactory::create(QStringLiteral("windows11"))) { QApplication::setStyle(windows_11_style); } else { - qWarning() << "QtTrayMenu: the Qt Windows 11 style is unavailable; using" << app->style()->objectName(); + qWarning() << "QtTrayMenu: the Qt Windows 11 style is unavailable; using" << QApplication::style()->objectName(); } } #else qWarning("QtTrayMenu: mirroring the interactive user's color scheme requires Qt 6.8 or newer"); #endif - qInfo() << "QtTrayMenu: using Qt style" << app->style()->objectName(); + qInfo() << "QtTrayMenu: using Qt style" << QApplication::style()->objectName(); } } // namespace tray_qt::windows diff --git a/src/WindowsAppearance.h b/src/WindowsAppearance.h index e4ef659d..f83531f4 100644 --- a/src/WindowsAppearance.h +++ b/src/WindowsAppearance.h @@ -66,5 +66,5 @@ namespace tray_qt::windows { * * @param app The tray QApplication instance. */ - void configure_appearance(QApplication *app); + void configure_appearance(const QApplication *app); } // namespace tray_qt::windows diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index e5f5794a..65d11354 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -171,23 +171,23 @@ TEST_F(TrayQtCoverageTest, UpdateFromWorkerThreadWaitsForApplicationThread) { std::array emptyMenu = {{{.text = nullptr}}}; trayData->menu = workerMenu.data(); - std::atomic workerStarted {false}; - std::atomic updateReturned {false}; - std::thread worker([&]() { - workerStarted.store(true, std::memory_order_release); + std::atomic workerStarted {false}; + std::atomic updateReturned {false}; + std::thread worker([this, &emptyMenu, &updateReturned, &workerStarted]() { + workerStarted.store(true); tray_update(trayData); trayData->menu = emptyMenu.data(); - updateReturned.store(true, std::memory_order_release); + updateReturned.store(true); }); - while (!workerStarted.load(std::memory_order_acquire)) { + while (!workerStarted.load()) { std::this_thread::yield(); } std::this_thread::sleep_for(std::chrono::milliseconds(50)); - EXPECT_FALSE(updateReturned.load(std::memory_order_acquire)); + EXPECT_FALSE(updateReturned.load()); - while (!updateReturned.load(std::memory_order_acquire)) { + while (!updateReturned.load()) { PumpEvents(1); } worker.join();