diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a184a802..8342f247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: include: - os: macos-latest shell: "bash" - qt_version: '' + qt_version: '6' - os: ubuntu-latest shell: "bash" qt_version: '5' @@ -61,7 +61,7 @@ jobs: qt_version: '6' - os: windows-latest shell: "msys2 {0}" - qt_version: '' + qt_version: '6' steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -118,6 +118,8 @@ jobs: "imagemagick" "ninja" "node" + "qtbase" + "qtsvg" ) brew install "${dependencies[@]}" @@ -171,6 +173,8 @@ jobs: mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-nodejs mingw-w64-ucrt-x86_64-toolchain + mingw-w64-ucrt-x86_64-qt6-base + mingw-w64-ucrt-x86_64-qt6-svg - name: Setup python id: setup-python @@ -250,7 +254,6 @@ jobs: - name: Run tests id: test - # TODO: tests randomly hang on Linux, https://github.com/LizardByte/tray/issues/45 timeout-minutes: 3 working-directory: build/tests env: diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 76f5c051..3861e97f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,68 +33,58 @@ 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" ) -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") +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() -# 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() +endif() - foreach(icon_file IN LISTS TRAY_ICON_FILES) - add_custom_command(TARGET "${target_name}" POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${icon_file}" - "$" - COMMENT "Copying ${icon_file} to $") - endforeach() -endfunction() - -if(WIN32) - list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c") +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() - endif() + find_package(Qt5 REQUIRED COMPONENTS Widgets Svg) + set(TRAY_QT_VERSION 5) endif() +set(CMAKE_AUTOMOC ON) +list(APPEND TRAY_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_qt.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/QtTrayMenu.cpp" +) add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) @@ -104,32 +94,23 @@ 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}) - endif() - endif() + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::Svg) +endif() + +if(TRAY_COMPILE_DEFINITIONS) + target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_DEFINITIONS}) 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) @@ -142,7 +123,6 @@ if(TRAY_IS_TOP_LEVEL) endif() target_compile_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS}) -target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) target_link_libraries(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_LIBRARIES}) # diff --git a/README.md b/README.md index 0c9408a4..1e52996c 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ ## About -Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications. +Cross-platform C++17 Qt-backed system tray icon with a popup menu and notifications. -The code is C++ friendly and will compile fine in C++98 and up. This is a fork of +This is a fork of [dmikushin/tray](https://github.com/dmikushin/tray) and is intended to add additional features required for our own [Sunshine](https://github.com/LizardByte/Sunshine) project. @@ -19,6 +19,7 @@ This fork adds the following features: - code coverage - refactored code, e.g., moved source code into the `src` directory - doxygen documentation and readthedocs configuration +- all platforms use QT-based implementation ## Screenshots @@ -32,46 +33,62 @@ This fork adds the following features: ## Supported platforms -* Linux/Qt (Qt5 or Qt6 Widgets) -* Windows XP or newer (shellapi.h) -* MacOS (Cocoa/AppKit) +* Linux +* macOS +* Windows ## 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.
- 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 + ```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 ```
@@ -110,6 +127,18 @@ 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. + +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. +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/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/docs/Doxyfile b/docs/Doxyfile index 3f004c67..99c96184 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -24,14 +24,13 @@ # project metadata DOCSET_BUNDLE_ID = dev.lizardbyte.tray DOCSET_PUBLISHER_ID = dev.lizardbyte.tray.documentation -PROJECT_BRIEF = "Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications." +PROJECT_BRIEF = "Cross-platform C++17 Qt-backed system tray icon with a popup menu and notifications." PROJECT_NAME = tray # project specific settings DOT_GRAPH_MAX_NODES = 50 IMAGE_PATH = ../docs/images INCLUDE_PATH = -PREDEFINED += TRAY_WINAPI # files and directories to process USE_MDFILE_AS_MAINPAGE = ../README.md diff --git a/icons/icon.ico b/icons/icon.ico index bb96b6e4..13de66b3 100644 Binary files a/icons/icon.ico and b/icons/icon.ico differ diff --git a/icons/icon.png b/icons/icon.png index bb96b6e4..770ec22c 100644 Binary files a/icons/icon.png and b/icons/icon.png differ diff --git a/icons/icon2.ico b/icons/icon2.ico new file mode 100644 index 00000000..44fc526c Binary files /dev/null and b/icons/icon2.ico differ diff --git a/icons/icon2.png b/icons/icon2.png new file mode 100644 index 00000000..6cb59a5e Binary files /dev/null and b/icons/icon2.png differ 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/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..2631eb3e --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +# Sonar project analysis properties overrides +sonar.projectKey=LizardByte_tray +sonar.cfamily.reportingCppStandardOverride=c++17 diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index eeb79304..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)); @@ -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..a4322553 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 "icon2.png" ///< Path to second icon. static struct tray tray; diff --git a/src/tray.h b/src/tray.h index e13026a6..c4fa3871 100644 --- a/src/tray.h +++ b/src/tray.h @@ -5,10 +5,6 @@ #ifndef TRAY_H #define TRAY_H -#if defined(_WIN32) - #include -#endif - #ifdef __cplusplus extern "C" { #endif @@ -77,17 +73,15 @@ extern "C" { /** * @brief Simulate a notification click, invoking the notification callback (for testing purposes). * - * On Linux (Qt): triggers the stored notification callback as if the user clicked the notification. - * On other platforms: no-op. + * Triggers the stored notification callback as if the user clicked the notification. */ void tray_simulate_notification_click(void); /** * @brief Simulate clicking a top-level menu item by index (for testing purposes). * - * On Linux (Qt): triggers the QAction associated with the given top-level menu - * index (separators and submenus are ignored). - * On other platforms: no-op. + * Triggers the QAction associated with the given top-level menu index + * (separators and submenus are ignored). * * @param index Zero-based index in the top-level tray menu. */ @@ -101,9 +95,8 @@ extern "C" { /** * @brief Set a callback for log messages produced by the tray library. * - * On Linux the callback is installed as a Qt message handler so all Qt - * diagnostic output is routed through it. On other platforms this function - * is a no-op. + * The callback is installed as a Qt message handler so all Qt diagnostic + * output is routed through it. * * @param cb Callback invoked with level (0=debug, 1=info, 2=warning, 3=error) * and the message string. Pass NULL to restore the default logging behaviour. @@ -113,9 +106,8 @@ extern "C" { /** * @brief Set application metadata used by the tray library. * - * Must be called before tray_init(). On Linux (Qt), sets the Qt application - * name, display name, and desktop file name used for D-Bus registration. On - * other platforms this function is a no-op. + * Must be called before tray_init(). Sets the Qt application name, display + * name, and desktop file name. * * @param app_name Application name used as a technical identifier (e.g., for * D-Bus registration). Converted to lowercase automatically. NULL uses the @@ -127,14 +119,6 @@ extern "C" { */ void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name); -#if defined(_WIN32) - /** - * @brief Get the tray window handle. - * @return The window handle. - */ - HWND tray_get_hwnd(void); -#endif - #ifdef __cplusplus } // extern "C" #endif diff --git a/src/tray_darwin.m b/src/tray_darwin.m deleted file mode 100644 index ca811cf8..00000000 --- a/src/tray_darwin.m +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @file src/tray_darwin.m - * @brief System tray implementation for macOS. - */ -// standard includes -#include - -// lib includes -#include - -// local includes -#include "tray.h" - -/** - * @class AppDelegate - * @brief The application delegate that handles menu actions. - */ -@interface AppDelegate: NSObject -/** - * @brief Callback function for menu item actions. - * @param sender The object that sent the action message. - * @return void - */ -- (IBAction)menuCallback:(id)sender; -@end - -@implementation AppDelegate { -} - -- (IBAction)menuCallback:(id)sender { - struct tray_menu *m = [[sender representedObject] pointerValue]; - if (m != NULL && m->cb != NULL) { - m->cb(m); - } -} - -@end - -static NSApplication *app; -static NSStatusBar *statusBar; -static NSStatusItem *statusItem; -static int loopResult = 0; - -#define QUIT_EVENT_SUBTYPE 0x0DED ///< NSEvent subtype used to signal exit. - -static void drain_quit_events(void) { - while (YES) { - NSEvent *event = [app nextEventMatchingMask:ULONG_MAX - untilDate:[NSDate distantPast] - inMode:[NSString stringWithUTF8String:"kCFRunLoopDefaultMode"] - dequeue:TRUE]; - if (event == nil) { - break; - } - if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { - continue; - } - [app sendEvent:event]; - } -} - -static NSMenu *_tray_menu(struct tray_menu *m) { - NSMenu *menu = [[NSMenu alloc] init]; - [menu setAutoenablesItems:FALSE]; - - for (; m != NULL && m->text != NULL; m++) { - if (strcmp(m->text, "-") == 0) { - [menu addItem:[NSMenuItem separatorItem]]; - } else { - NSMenuItem *menuItem = [[NSMenuItem alloc] - initWithTitle:[NSString stringWithUTF8String:m->text] - action:@selector(menuCallback:) - keyEquivalent:@""]; - [menuItem setEnabled:(m->disabled ? FALSE : TRUE)]; - [menuItem setState:(m->checked ? 1 : 0)]; - [menuItem setRepresentedObject:[NSValue valueWithPointer:m]]; - [menu addItem:menuItem]; - if (m->submenu != NULL) { - [menu setSubmenu:_tray_menu(m->submenu) forItem:menuItem]; - } - } - } - return menu; -} - -int tray_init(struct tray *tray) { - loopResult = 0; - AppDelegate *delegate = [[AppDelegate alloc] init]; - app = [NSApplication sharedApplication]; - [app setDelegate:delegate]; - statusBar = [NSStatusBar systemStatusBar]; - statusItem = [statusBar statusItemWithLength:NSVariableStatusItemLength]; - tray_update(tray); - [app activateIgnoringOtherApps:TRUE]; - drain_quit_events(); - return 0; -} - -int tray_loop(int blocking) { - NSDate *until = (blocking ? [NSDate distantFuture] : [NSDate distantPast]); - NSEvent *event = [app nextEventMatchingMask:ULONG_MAX - untilDate:until - inMode:[NSString stringWithUTF8String:"kCFRunLoopDefaultMode"] - dequeue:TRUE]; - if (event) { - if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { - loopResult = -1; - return loopResult; - } - - [app sendEvent:event]; - } - return loopResult; -} - -void tray_update(struct tray *tray) { - NSImage *image = [[NSImage alloc] initWithContentsOfFile:[NSString stringWithUTF8String:tray->icon]]; - NSSize size = NSMakeSize(16, 16); - [image setSize:NSMakeSize(16, 16)]; - statusItem.button.image = image; - [statusItem setMenu:_tray_menu(tray->menu)]; - - // Set tooltip if provided - if (tray->tooltip != NULL) { - statusItem.button.toolTip = [NSString stringWithUTF8String:tray->tooltip]; - } -} - -void tray_show_menu(void) { - [statusItem popUpStatusItemMenu:statusItem.menu]; -} - -void tray_simulate_notification_click(void) { - // macOS notification clicks are handled by the OS notification center. - // Simulation is not supported here. -} - -void tray_simulate_menu_item_click(int index) { - // Programmatic menu-item simulation is not supported here. - (void) index; -} - -void tray_set_log_callback(void (*cb)(int level, const char *msg)) { - // Qt is not used on macOS; log routing is not applicable. - (void) cb; -} - -void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) { - // Application metadata is not applicable on macOS. - (void) app_name; - (void) app_display_name; - (void) desktop_name; -} - -void tray_exit(void) { - // Remove the status item from the status bar on the main thread - // NSStatusBar operations must be performed on the main thread - if (statusItem != nil) { - if ([NSThread isMainThread]) { - // Already on main thread, remove directly - [statusBar removeStatusItem:statusItem]; - statusItem = nil; - } else { - // On background thread, dispatch synchronously to main thread - dispatch_sync(dispatch_get_main_queue(), ^{ - if (statusItem != nil) { - [statusBar removeStatusItem:statusItem]; - statusItem = nil; - } - }); - } - } - - // Post exit event - NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined - location:NSMakePoint(0, 0) - modifierFlags:0 - timestamp:0 - windowNumber:0 - context:nil - subtype:QUIT_EVENT_SUBTYPE - data1:0 - data2:0]; - [app postEvent:event atStart:FALSE]; -} diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp deleted file mode 100644 index 48173ffc..00000000 --- a/src/tray_linux.cpp +++ /dev/null @@ -1,327 +0,0 @@ -/** - * @file src/tray_linux.cpp - * @brief System tray implementation for Linux using Qt. - */ -// standard includes -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// lib includes -#include - -// local includes -#include "QtTrayMenu.h" -#include "tray.h" - -namespace tray_linux { - /** - * Notification element struct - */ - struct notification_data { - /** - * @brief Notification object - */ - NotifyNotification *obj = nullptr; - /** - * @brief Notification callback - */ - void (*cb)() = nullptr; - /** - * @brief Notification shown indicator - */ - bool shown = false; - /** - * @brief Notification mutex for async thread synchronization - */ - std::mutex mutex; - }; - - /** - * 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 - - /** - * QtTrayMenu instance - */ - std::unique_ptr qt_tray_menu = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const - /** - * Logging callback for qt_message_handler - */ - void (*log_callback)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const - - /** - * @brief 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) - */ - 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::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) { - // 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) - } - } - - /** - * @brief Acknowledge/click current notifications - * @param run_callback - Run notification callback when acknowledging - */ - void acknowledge_notifications(bool run_callback = false) { - if (notify_is_initted()) { - std::scoped_lock lock(notifications_mutex); - for (auto notification : notifications) { - if (run_callback && notification->cb != nullptr) { - notification->cb(); - } - async_tray_notification_acknowledge_(notification); - } - notifications.clear(); - } else if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { - qt_tray_menu->clickMessage(); - } - } - - /** - * @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) - */ - 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) { - // 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) - } - } - - /** - * @brief Show tray notification via desktop-independent interface - * @param tray Tray structure containing notification information - */ - void notify(struct tray *tray) { - if (tray->notification_text == nullptr || std::string(tray->notification_text).empty()) { - return; - } - // Try to notify using libnotify - if (notify_is_initted()) { - if (!notifications.empty()) { - acknowledge_notifications(); - } - 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); - } - } else if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { - // Fallback to QtTrayMenu notification - 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 - */ - bool init_notify(const char *app_name) { - if (!notify_is_initted()) { - if (!notifications.empty()) { - acknowledge_notifications(); - } - return notify_init(app_name); - } - return true; // Already initialized, so init was successful - } - - /** - * @brief Uninitialize notifications - */ - void uninit_notify() { - if (notify_is_initted()) { - acknowledge_notifications(); - notify_uninit(); - } - } - - /** - * @brief Update notification app name - * @param app_name the current application name - */ - void set_notify_app_info(const char *app_name) { - if (app_name) { - uninit_notify(); - init_notify(app_name); - } - } - - /** - * @brief Qt message handler that forwards to the registered log callback. - * @param type The Qt message type. - * @param msg The message string. - */ - void qt_message_handler(QtMsgType type, const QMessageLogContext &, const QString &msg) { - if (log_callback == nullptr) { - return; - } - int level; - switch (type) { - case QtDebugMsg: - level = 0; - break; - case QtInfoMsg: - level = 1; - break; - case QtWarningMsg: - level = 2; - break; - default: - level = 3; - break; - } - log_callback(level, msg.toUtf8().constData()); - } -} // namespace tray_linux - -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); - - 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_); - } - - 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"); - } - // Create a new unique pointer to QtTrayMenu instance - tray_linux::qt_tray_menu = std::make_unique(); - } - - if (const auto result = tray_linux::qt_tray_menu->init(tray, false); result < 0) { - // Tray init failed. Clean up and return error. - tray_exit(); - return result; - } - - if (!tray_linux::init_notify("tray") && !QtTrayMenu::supportsMessages()) { - // Notification init failed. Clean up and return error. - tray_exit(); - return -1; - } - - // Fire notification if there is one - tray_linux::notify(tray); - return 0; - } - - int tray_loop(int blocking) { - if (tray_linux::qt_tray_menu == nullptr) { - return -1; - } - return tray_linux::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) { - return; - } - tray_linux::qt_tray_menu->update(tray, false); - tray_linux::notify(tray); - } - - void tray_exit(void) { - tray_linux::uninit_notify(); - - if (tray_linux::qt_tray_menu == nullptr) { - return; - } - tray_linux::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; - if (cb != nullptr) { - qInstallMessageHandler(tray_linux::qt_message_handler); - } else { - qInstallMessageHandler(nullptr); - } - } - - void tray_show_menu(void) { - if (tray_linux::qt_tray_menu == nullptr) { - return; - } - tray_linux::qt_tray_menu->showMenu(); - } - - void tray_simulate_menu_item_click(int index) { - if (tray_linux::qt_tray_menu == nullptr) { - return; - } - tray_linux::qt_tray_menu->clickMenuItem(index); - } - - void tray_simulate_notification_click(void) { - tray_linux::acknowledge_notifications(true); - } -} // extern "C" diff --git a/src/tray_qt.cpp b/src/tray_qt.cpp new file mode 100644 index 00000000..dd293f57 --- /dev/null +++ b/src/tray_qt.cpp @@ -0,0 +1,222 @@ +/** + * @file src/tray_qt.cpp + * @brief System tray implementation using Qt. + */ +// standard includes +#include + +// qt includes +#include +#include +#include +#include + +// local includes +#include "QtTrayMenu.h" +#include "tray.h" + +namespace tray_qt { + /** + * QtTrayMenu instance + */ + std::unique_ptr qt_tray_menu = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + /** + * Logging callback for qt_message_handler + */ + void (*log_callback)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + /** + * 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 Acknowledge/click current notification. + */ + void acknowledge_notification() { + if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { + qt_tray_menu->clickMessage(); + } + } + + /** + * @brief Clear current notification state without invoking callbacks. + */ + void clear_notification() { + if (qt_tray_menu != nullptr) { + qt_tray_menu->clearMessageCallback(); + } + } + + /** + * @brief Show tray notification via desktop-independent interface + * @param tray Tray structure containing notification information + */ + void notify(struct tray *tray) { + if (tray->notification_text == nullptr || tray->notification_text[0] == '\0') { + clear_notification(); + return; + } + if (qt_tray_menu != nullptr && QtTrayMenu::supportsMessages()) { + 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); + } + } + } + + /** + * @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 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 Configure Linux headless fallback for Qt. + */ + 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 + } + + /** + * @brief Qt message handler that forwards to the registered log callback. + * @param type The Qt message type. + * @param msg The message string. + */ + void qt_message_handler(QtMsgType type, const QMessageLogContext &, const QString &msg) { + if (log_callback == nullptr) { + return; + } + int level; + switch (type) { + case QtDebugMsg: + level = 0; + break; + case QtInfoMsg: + level = 1; + break; + case QtWarningMsg: + level = 2; + break; + default: + level = 3; + break; + } + log_callback(level, msg.toUtf8().constData()); + } +} // namespace tray_qt + +extern "C" { + void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_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(); + + tray_qt::apply_app_info(); + } + + int tray_init(struct tray *tray) { + if (tray_qt::qt_tray_menu == nullptr) { + tray_qt::configure_platform(); + // Create a new unique pointer to QtTrayMenu instance + tray_qt::qt_tray_menu = std::make_unique(); + tray_qt::apply_app_info(false); + } + + 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 (!QtTrayMenu::supportsMessages()) { + // Notification support is unavailable. Clean up and return error. + tray_exit(); + return -1; + } + + // Fire notification if there is one + tray_qt::notify(tray); + return 0; + } + + int tray_loop(int blocking) { + if (tray_qt::qt_tray_menu == nullptr) { + return -1; + } + 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_qt::qt_tray_menu == nullptr) { + return; + } + tray_qt::qt_tray_menu->update(tray, false); + tray_qt::notify(tray); + } + + void tray_exit(void) { + if (tray_qt::qt_tray_menu == nullptr) { + return; + } + 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_qt::log_callback = cb; + if (cb != nullptr) { + qInstallMessageHandler(tray_qt::qt_message_handler); + } else { + qInstallMessageHandler(nullptr); + } + } + + void tray_show_menu(void) { + if (tray_qt::qt_tray_menu == nullptr) { + return; + } + tray_qt::qt_tray_menu->showMenu(); + } + + void tray_simulate_menu_item_click(int index) { + if (tray_qt::qt_tray_menu == nullptr) { + return; + } + tray_qt::qt_tray_menu->clickMenuItem(index); + } + + void tray_simulate_notification_click(void) { + tray_qt::acknowledge_notification(); + } + +} // extern "C" diff --git a/src/tray_windows.c b/src/tray_windows.c deleted file mode 100644 index cf959e94..00000000 --- a/src/tray_windows.c +++ /dev/null @@ -1,375 +0,0 @@ -/** - * @file src/tray_windows.c - * @brief System tray implementation for Windows. - */ -// standard includes -#ifndef WIN32_LEAN_AND_MEAN - #define WIN32_LEAN_AND_MEAN ///< Excludes rarely used APIs from Windows headers. -#endif -#ifndef NOMINMAX - #define NOMINMAX ///< Prevents Windows.h from defining min and max macros. -#endif -#include -// clang-format off -// build fails if shellapi.h is included before Windows.h -#include -#include -// clang-format on - -// local includes -#include "tray.h" - -#define WM_TRAY_CALLBACK_MESSAGE (WM_USER + 1) ///< Tray callback message. -#define WC_TRAY_CLASS_NAME "TRAY" ///< Tray window class name. -#define ID_TRAY_FIRST 1000 ///< First tray identifier. - -/** - * @brief Icon information. - */ -struct icon_info { - const char *path; ///< Path to the icon - HICON icon; ///< Regular icon - HICON large_icon; ///< Large icon - HICON notification_icon; ///< Notification icon -}; - -/** - * @brief Icon type. - */ -enum IconType { - REGULAR = 1, ///< Regular icon - LARGE, ///< Large icon - NOTIFICATION ///< Notification icon -}; - -static WNDCLASSEX wc; -static NOTIFYICONDATAW nid; -static HWND hwnd; -static HMENU hmenu = NULL; -static void (*notification_cb)() = 0; -static UINT wm_taskbarcreated; - -static struct icon_info *icon_infos; -static int icon_info_count; - -static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { - switch (msg) { - case WM_CLOSE: - DestroyWindow(hwnd); - return 0; - case WM_DESTROY: - PostQuitMessage(0); - return 0; - case WM_TRAY_CALLBACK_MESSAGE: - if (lparam == WM_LBUTTONUP || lparam == WM_RBUTTONUP) { - POINT p; - GetCursorPos(&p); - SetForegroundWindow(hwnd); - WORD cmd = TrackPopupMenu(hmenu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, p.x, p.y, 0, hwnd, NULL); - SendMessage(hwnd, WM_COMMAND, cmd, 0); - return 0; - } else if (lparam == NIN_BALLOONUSERCLICK && notification_cb != NULL) { - notification_cb(); - } - break; - case WM_COMMAND: - if (wparam >= ID_TRAY_FIRST) { - MENUITEMINFO item = { - .cbSize = sizeof(MENUITEMINFO), - .fMask = MIIM_ID | MIIM_DATA, - }; - if (GetMenuItemInfo(hmenu, wparam, FALSE, &item)) { - struct tray_menu *menu = (struct tray_menu *) item.dwItemData; - if (menu != NULL && menu->cb != NULL) { - menu->cb(menu); - } - } - return 0; - } - break; - } - - if (msg == wm_taskbarcreated) { - Shell_NotifyIconW(NIM_ADD, &nid); - return 0; - } - - return DefWindowProc(hwnd, msg, wparam, lparam); -} - -static HMENU _tray_menu(struct tray_menu *m, UINT *id) { - HMENU hmenu = CreatePopupMenu(); - for (; m != NULL && m->text != NULL; m++, (*id)++) { - if (strcmp(m->text, "-") == 0) { - InsertMenuW(hmenu, *id, MF_SEPARATOR, TRUE, L""); - } else { - MENUITEMINFOW item; - memset(&item, 0, sizeof(item)); - item.cbSize = sizeof(MENUITEMINFOW); - item.fMask = MIIM_ID | MIIM_TYPE | MIIM_STATE | MIIM_DATA; - item.fType = 0; - item.fState = 0; - if (m->submenu != NULL) { - item.fMask = item.fMask | MIIM_SUBMENU; - item.hSubMenu = _tray_menu(m->submenu, id); - } - if (m->disabled) { - item.fState |= MFS_DISABLED; - } - if (m->checked) { - item.fState |= MFS_CHECKED; - } - item.wID = *id; - - // Convert UTF-8 text to UTF-16 (wide string) - int wide_size = MultiByteToWideChar(CP_UTF8, 0, m->text, -1, NULL, 0); - wchar_t *wide_text = (wchar_t *) malloc(wide_size * sizeof(wchar_t)); - if (wide_text == NULL) { - DestroyMenu(hmenu); - return NULL; - } - MultiByteToWideChar(CP_UTF8, 0, m->text, -1, wide_text, wide_size); - - item.dwTypeData = wide_text; - item.dwItemData = (ULONG_PTR) m; - - InsertMenuItemW(hmenu, *id, TRUE, &item); - // Free the allocated wide string - free(wide_text); - } - } - return hmenu; -} - -/** - * @brief Create icon information. - * @param path Path to the icon. - * @return Icon information. - */ -struct icon_info _create_icon_info(const char *path) { - struct icon_info info; - info.path = strdup(path); - - // These must be separate invocations otherwise Windows may opt to only return large or small icons. - // MSDN does not explicitly state this anywhere, but it has been observed on some machines. - ExtractIconEx(path, 0, &info.large_icon, NULL, 1); - ExtractIconEx(path, 0, NULL, &info.icon, 1); - - info.notification_icon = LoadImageA(NULL, path, IMAGE_ICON, GetSystemMetrics(SM_CXICON) * 2, GetSystemMetrics(SM_CYICON) * 2, LR_LOADFROMFILE); - return info; -} - -/** - * @brief Initialize icon cache. - * @param paths Paths to the icons. - * @param count Number of paths. - */ -void _init_icon_cache(const char **paths, int count) { - icon_info_count = count; - icon_infos = malloc(sizeof(struct icon_info) * icon_info_count); - - for (int i = 0; i < count; ++i) { - icon_infos[i] = _create_icon_info(paths[i]); - } -} - -/** - * @brief Destroy icon cache. - */ -void _destroy_icon_cache() { - for (int i = 0; i < icon_info_count; ++i) { - DestroyIcon(icon_infos[i].icon); - DestroyIcon(icon_infos[i].large_icon); - DestroyIcon(icon_infos[i].notification_icon); - free((void *) icon_infos[i].path); - } - - free(icon_infos); - icon_infos = NULL; - icon_info_count = 0; -} - -/** - * @brief Fetch cached icon. - * @param icon_record Icon record. - * @param icon_type Icon type. - * @return Icon. - */ -HICON _fetch_cached_icon(struct icon_info *icon_record, enum IconType icon_type) { - switch (icon_type) { - case REGULAR: - return icon_record->icon; - case LARGE: - return icon_record->large_icon; - case NOTIFICATION: - return icon_record->notification_icon; - } -} - -/** - * @brief Fetch icon. - * @param path Path to the icon. - * @param icon_type Icon type. - * @return Icon. - */ -HICON _fetch_icon(const char *path, enum IconType icon_type) { - // Find a cached icon by path - for (int i = 0; i < icon_info_count; ++i) { - if (strcmp(icon_infos[i].path, path) == 0) { - return _fetch_cached_icon(&icon_infos[i], icon_type); - } - } - - // Expand cache, fetch, and retry - icon_info_count += 1; - icon_infos = realloc(icon_infos, sizeof(struct icon_info) * icon_info_count); - int index = icon_info_count - 1; - icon_infos[icon_info_count - 1] = _create_icon_info(path); - - return _fetch_cached_icon(&icon_infos[icon_info_count - 1], icon_type); -} - -int tray_init(struct tray *tray) { - wm_taskbarcreated = RegisterWindowMessage("TaskbarCreated"); - - _init_icon_cache(tray->allIconPaths, tray->iconPathCount); - - memset(&wc, 0, sizeof(wc)); - wc.cbSize = sizeof(WNDCLASSEX); - wc.lpfnWndProc = _tray_wnd_proc; - wc.hInstance = GetModuleHandle(NULL); - wc.lpszClassName = WC_TRAY_CLASS_NAME; - if (!RegisterClassEx(&wc)) { - return -1; - } - - hwnd = CreateWindowEx(0, WC_TRAY_CLASS_NAME, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0); - if (hwnd == NULL) { - return -1; - } - UpdateWindow(hwnd); - - memset(&nid, 0, sizeof(nid)); - nid.cbSize = sizeof(NOTIFYICONDATAW); - nid.hWnd = hwnd; - nid.uID = 0; - nid.uFlags = NIF_ICON | NIF_MESSAGE; - nid.uCallbackMessage = WM_TRAY_CALLBACK_MESSAGE; - Shell_NotifyIconW(NIM_ADD, &nid); - - tray_update(tray); - return 0; -} - -int tray_loop(int blocking) { - MSG msg; - int has_message = 0; - - // Use the thread queue and only dispatch when a message was actually retrieved. - // This ensures WM_QUIT is observed and avoids dispatching an uninitialized MSG. - if (blocking) { - has_message = GetMessage(&msg, NULL, 0, 0); - if (has_message <= 0) { - return -1; - } - } else { - has_message = PeekMessage(&msg, NULL, 0, 0, PM_REMOVE); - if (has_message == 0) { - return 0; - } - } - - if (msg.message == WM_QUIT) { - return -1; - } - - TranslateMessage(&msg); - DispatchMessage(&msg); - return 0; -} - -void tray_update(struct tray *tray) { - UINT id = ID_TRAY_FIRST; - HMENU prevmenu = hmenu; - hmenu = _tray_menu(tray->menu, &id); - SendMessage(hwnd, WM_INITMENUPOPUP, (WPARAM) hmenu, 0); - - HICON icon = _fetch_icon(tray->icon, REGULAR); - HICON largeIcon = tray->notification_icon != 0 ? _fetch_icon(tray->notification_icon, NOTIFICATION) : _fetch_icon(tray->icon, LARGE); - - if (icon != NULL) { - nid.hIcon = icon; - } - - if (largeIcon != 0) { - nid.hBalloonIcon = largeIcon; - nid.dwInfoFlags = NIIF_USER | NIIF_LARGE_ICON; - } - if (tray->tooltip != 0 && strlen(tray->tooltip) > 0) { - MultiByteToWideChar(CP_UTF8, 0, tray->tooltip, -1, nid.szTip, sizeof(nid.szTip) / sizeof(wchar_t)); - nid.uFlags |= NIF_TIP; - } - QUERY_USER_NOTIFICATION_STATE notification_state; - HRESULT ns = SHQueryUserNotificationState(¬ification_state); - int can_show_notifications = ns == S_OK && notification_state == QUNS_ACCEPTS_NOTIFICATIONS; - if (can_show_notifications == 1 && tray->notification_title != 0 && strlen(tray->notification_title) > 0) { - MultiByteToWideChar(CP_UTF8, 0, tray->notification_title, -1, nid.szInfoTitle, sizeof(nid.szInfoTitle) / sizeof(wchar_t)); - nid.uFlags |= NIF_INFO; - } else if ((nid.uFlags & NIF_INFO) == NIF_INFO) { - nid.szInfoTitle[0] = L'\0'; - } - if (can_show_notifications == 1 && tray->notification_text != 0 && strlen(tray->notification_text) > 0) { - MultiByteToWideChar(CP_UTF8, 0, tray->notification_text, -1, nid.szInfo, sizeof(nid.szInfo) / sizeof(wchar_t)); - } else if ((nid.uFlags & NIF_INFO) == NIF_INFO) { - nid.szInfo[0] = L'\0'; - } - if (can_show_notifications == 1 && tray->notification_cb != NULL) { - notification_cb = tray->notification_cb; - } - - Shell_NotifyIconW(NIM_MODIFY, &nid); - - if (prevmenu != NULL) { - DestroyMenu(prevmenu); - } -} - -void tray_show_menu(void) { - PostMessage(hwnd, WM_TRAY_CALLBACK_MESSAGE, 0, WM_RBUTTONUP); -} - -void tray_simulate_notification_click(void) { - // Windows handles notification clicks via NIN_BALLOONUSERCLICK in the window proc. - // Simulating this from outside the message pump is not supported here. -} - -void tray_simulate_menu_item_click(int index) { - // Programmatic menu-item simulation is not supported here. - (void) index; -} - -void tray_set_log_callback(void (*cb)(int level, const char *msg)) { - // Qt is not used on Windows; log routing is not applicable. - (void) cb; -} - -void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) { - // Application metadata is not applicable on Windows. - (void) app_name; - (void) app_display_name; - (void) desktop_name; -} - -void tray_exit(void) { - Shell_NotifyIconW(NIM_DELETE, &nid); - SendMessage(hwnd, WM_CLOSE, 0, 0); - _destroy_icon_cache(); - if (hmenu != 0) { - DestroyMenu(hmenu); - } - UnregisterClass(WC_TRAY_CLASS_NAME, GetModuleHandle(NULL)); -} - -HWND tray_get_hwnd(void) { - return hwnd; -} diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 103896e8..621e5cf3 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -5,43 +5,62 @@ #include #include #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_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"; -#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 +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 = "-"}, @@ -100,24 +119,13 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Dismisses the open menu from a background thread. void closeMenu() { -#if defined(TRAY_WINAPI) - PostMessage(tray_get_hwnd(), WM_CANCELMODE, 0, 0); std::this_thread::sleep_for(std::chrono::milliseconds(100)); -#elif defined(TRAY_APPKIT) - CGEventRef event = CGEventCreateKeyboardEvent(NULL, kVK_Escape, true); - CGEventPost(kCGHIDEventTap, event); - CFRelease(event); - CGEventRef event2 = CGEventCreateKeyboardEvent(NULL, kVK_Escape, false); - CGEventPost(kCGHIDEventTap, event2); - CFRelease(event2); - std::this_thread::sleep_for(std::chrono::milliseconds(100)); -#endif } // Capture a screenshot while the tray menu is open, then dismiss and exit. void captureMenuStateAndExit(const char *screenshotName) { std::atomic_bool exitRequested {false}; - std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168) - std::jthread is unavailable on AppleClang 17/libc++ used in CI + std::thread capture_thread([this, screenshotName, &exitRequested]() { EXPECT_TRUE(captureScreenshot(screenshotName)); closeMenu(); exitRequested.store(true, std::memory_order_release); @@ -203,10 +211,16 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } }; - ensureIconInTestDir(TRAY_ICON1); -#if defined(TRAY_QT) - ensureIconInTestDir(TRAY_ICON_SVG); -#endif + 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; @@ -227,25 +241,33 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Process pending events to allow tray icon to appear. // Call this ONLY before screenshots to ensure the icon is visible. void WaitForTrayReady() { -#if defined(TRAY_QT) for (int i = 0; i < 100; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(5)); } -#elif defined(TRAY_APPKIT) - static std::thread::id main_thread_id = std::this_thread::get_id(); - if (std::this_thread::get_id() == main_thread_id) { - for (int i = 0; i < 100; i++) { + } + + void WaitForNotificationReady() { + WaitForTrayReady(); +#if defined(_WIN32) + if (isGitHubActions()) { + for (int i = 0; i < 40; i++) { tray_loop(0); - std::this_thread::sleep_for(std::chrono::milliseconds(5)); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); } - } else { - std::this_thread::sleep_for(std::chrono::milliseconds(1000)); } #endif } }; +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); @@ -254,6 +276,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); @@ -263,28 +296,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); @@ -369,52 +380,44 @@ 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) || 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); - 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); ASSERT_EQ(initResult, 0); + dismissNativeNotifications(); // 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")); + WaitForNotificationReady(); + EXPECT_TRUE(captureScreenshot(std::string("tray_notification_") + iconParam.name + "_icon")); // Clear notification testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } -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); - 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 = []() { @@ -428,7 +431,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); @@ -443,6 +446,7 @@ TEST_F(TrayTest, TestNotificationCallback) { testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestTooltipUpdate) { @@ -496,6 +500,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); @@ -520,16 +526,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); } @@ -552,44 +563,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 +571,6 @@ TEST_F(TrayTest, TestIconPathArray) { testTray.icon = TRAY_ICON2; tray_update(&testTray); -#endif } TEST_F(TrayTest, TestQuitCallback) { @@ -615,6 +587,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); @@ -627,51 +601,12 @@ TEST_F(TrayTest, TestTrayExit) { tray_exit(); } -#if defined(TRAY_QT) - -TEST_F(TrayTest, TestTrayIconThemed) { - testTray.icon = TRAY_ICON_THEMED; - int result = tray_init(&testTray); - trayRunning = (result == 0); - ASSERT_EQ(result, 0); - WaitForTrayReady(); - EXPECT_TRUE(captureScreenshot("tray_icon_themed")); - testTray.icon = TRAY_ICON1; -} - -TEST_F(TrayTest, 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); -} - 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); @@ -679,10 +614,11 @@ 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. + // 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; callbackInvoked = false; @@ -692,7 +628,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; }; @@ -711,6 +647,7 @@ TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { @@ -735,7 +672,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(); @@ -747,8 +684,21 @@ TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); + waitForNativeNotificationTimeout(); testTray.menu[0].cb = original_cb; } -#endif // TRAY_QT +INSTANTIATE_TEST_SUITE_P( + TrayIcons, + TrayIconTest, + ::testing::ValuesIn(TRAY_ICON_PARAMS), + trayIconParamName +); + +INSTANTIATE_TEST_SUITE_P( + TrayNotificationIcons, + TrayNotificationIconTest, + ::testing::ValuesIn(TRAY_ICON_PARAMS), + trayIconParamName +); diff --git a/tests/unit/test_tray_linux.cpp b/tests/unit/test_tray_linux.cpp deleted file mode 100644 index 47d37743..00000000 --- a/tests/unit/test_tray_linux.cpp +++ /dev/null @@ -1,275 +0,0 @@ -// test includes -#include "tests/conftest.cpp" - -// local includes -#include "src/tray.h" - -#if defined(__linux__) || defined(linux) || defined(__linux) - - // standard includes - #include - #include - #include - #include - -namespace { - int g_menu_callback_count = 0; - int g_notification_callback_count = 0; - int g_log_callback_count = 0; - - void menu_item_cb([[maybe_unused]] struct tray_menu *item) { - g_menu_callback_count++; - } - - void notification_cb() { - g_notification_callback_count++; - } - - void log_cb([[maybe_unused]] int level, [[maybe_unused]] const char *msg) { - g_log_callback_count++; - } -} // namespace - -class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture members/methods are accessed by TEST_F-generated subclasses -protected: // NOSONAR(cpp:S3656) - TEST_F requires protected fixture visibility - void SetUp() override { - LinuxTest::SetUp(); - - tray_set_log_callback(nullptr); - tray_set_app_info(nullptr, nullptr, nullptr); - - g_menu_callback_count = 0; - g_notification_callback_count = 0; - g_log_callback_count = 0; - - submenuItems = {{{.text = "Nested", .cb = menu_item_cb}, {.text = nullptr}}}; - - menuItems = {{{.text = "Clickable", .cb = menu_item_cb}, {.text = "-"}, {.text = "Submenu", .submenu = submenuItems.data()}, {.text = "Disabled", .disabled = 1, .cb = menu_item_cb}, {.text = "Second Clickable", .cb = menu_item_cb}, {.text = nullptr}}}; - - trayData.icon = "icon.png"; - trayData.tooltip = "Linux Tray Coverage"; - trayData.notification_icon = nullptr; - trayData.notification_text = nullptr; - trayData.notification_title = nullptr; - trayData.notification_cb = nullptr; - trayData.menu = menuItems.data(); - } - - void TearDown() override { - if (trayRunning) { - tray_exit(); - tray_loop(0); - trayRunning = false; - } - - tray_set_log_callback(nullptr); - LinuxTest::TearDown(); - } - - void InitTray() { - const int initResult = tray_init(&trayData); - trayRunning = (initResult == 0); - ASSERT_EQ(initResult, 0); - } - - void PumpEvents(int iterations = 20) { - for (int i = 0; i < iterations; i++) { - tray_loop(0); - } - } - - bool trayRunning {false}; - std::array menuItems {}; - std::array submenuItems {}; - struct tray trayData {}; -}; - -TEST_F(TrayLinuxCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { - InitTray(); - - tray_simulate_menu_item_click(-1); - tray_simulate_menu_item_click(99); - tray_simulate_menu_item_click(1); - tray_simulate_menu_item_click(2); - tray_simulate_menu_item_click(3); - PumpEvents(); - - EXPECT_EQ(g_menu_callback_count, 0); - - tray_simulate_menu_item_click(0); - tray_simulate_menu_item_click(4); - PumpEvents(); - - EXPECT_EQ(g_menu_callback_count, 2); -} - -TEST_F(TrayLinuxCoverageTest, ApiCallsAreNoOpsBeforeInit) { - tray_update(&trayData); - tray_show_menu(); - tray_simulate_menu_item_click(0); - tray_simulate_notification_click(); - PumpEvents(); - - EXPECT_EQ(g_menu_callback_count, 0); - EXPECT_EQ(g_notification_callback_count, 0); -} - -TEST_F(TrayLinuxCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { - trayData.menu = nullptr; - InitTray(); - - tray_simulate_menu_item_click(0); - PumpEvents(); - - EXPECT_EQ(g_menu_callback_count, 0); -} - -TEST_F(TrayLinuxCoverageTest, SetAppInfoAppliesExplicitMetadata) { - tray_set_app_info("tray-linux-tests", "Tray Linux Tests", "tray-linux-tests.desktop"); - InitTray(); - - // Trigger an update to exercise metadata-dependent notification/tray code paths. - trayData.notification_title = "Metadata Test"; - trayData.notification_text = "Using explicit metadata"; - tray_update(&trayData); - PumpEvents(); -} - -TEST_F(TrayLinuxCoverageTest, SetAppInfoDefaultsUseFallbackValues) { - tray_set_app_info(nullptr, nullptr, nullptr); - trayData.tooltip = "Tooltip Display Name"; - InitTray(); - - trayData.notification_title = "Default Metadata Test"; - trayData.notification_text = "Using fallback metadata"; - tray_update(&trayData); - PumpEvents(); -} - -TEST_F(TrayLinuxCoverageTest, LogCallbackCanBeSetAndReset) { - InitTray(); - tray_set_log_callback(log_cb); - - // The callback is currently installed; this update path should remain stable. - trayData.tooltip = "Log callback installed"; - tray_update(&trayData); - PumpEvents(); - - EXPECT_EQ(g_log_callback_count, 0); - - tray_set_log_callback(nullptr); - - trayData.tooltip = "Log callback removed"; - tray_update(&trayData); - PumpEvents(); - - EXPECT_EQ(g_log_callback_count, 0); -} - -TEST_F(TrayLinuxCoverageTest, TrayExitCausesLoopToReturnExitCode) { - InitTray(); - - tray_exit(); - const int loopResult = tray_loop(0); - trayRunning = false; - - EXPECT_EQ(loopResult, -1); -} - -TEST_F(TrayLinuxCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { - InitTray(); - - menuItems[0].text = "Clickable Renamed"; - menuItems[0].disabled = 1; - tray_update(&trayData); - PumpEvents(); - - tray_simulate_menu_item_click(0); - PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 0); - - menuItems[0].disabled = 0; - tray_update(&trayData); - PumpEvents(); - - tray_simulate_menu_item_click(0); - PumpEvents(); - EXPECT_EQ(g_menu_callback_count, 1); -} - -TEST_F(TrayLinuxCoverageTest, ResolveTrayIconFromIconPathArray) { - // Build a tray struct with iconPathCount/allIconPaths to exercise fallback icon resolution. - const size_t iconCount = 2; - const size_t bufSize = sizeof(struct tray) + iconCount * sizeof(const char *); - std::vector buf(bufSize, std::byte {0}); - auto *iconPathTray = reinterpret_cast(buf.data()); // NOSONAR(cpp:S3630) - reinterpret_cast is required to map a C flexible-array struct over raw storage - - iconPathTray->icon = "missing-icon-name"; - iconPathTray->tooltip = "Icon path fallback"; - iconPathTray->notification_icon = nullptr; - iconPathTray->notification_text = nullptr; - iconPathTray->notification_title = nullptr; - iconPathTray->notification_cb = nullptr; - iconPathTray->menu = menuItems.data(); - - const int countVal = static_cast(iconCount); - std::memcpy(const_cast(&iconPathTray->iconPathCount), &countVal, sizeof(countVal)); // NOSONAR(cpp:S859) - const member initialization is required for this C interop allocation pattern - const char *badIcon = "missing-icon-name"; - const char *goodIcon = "icon.png"; - std::memcpy(const_cast(&iconPathTray->allIconPaths[0]), &badIcon, sizeof(badIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries - std::memcpy(const_cast(&iconPathTray->allIconPaths[1]), &goodIcon, sizeof(goodIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries - - const int initResult = tray_init(iconPathTray); - trayRunning = (initResult == 0); - ASSERT_EQ(initResult, 0); - - tray_update(iconPathTray); - PumpEvents(); -} - -TEST_F(TrayLinuxCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) { - InitTray(); - - trayData.notification_title = "No callback notification"; - trayData.notification_text = "Should not invoke callback"; - trayData.notification_icon = "icon.png"; - trayData.notification_cb = nullptr; - - tray_update(&trayData); - PumpEvents(); - - tray_simulate_notification_click(); - PumpEvents(); - - EXPECT_EQ(g_notification_callback_count, 0); -} - -TEST_F(TrayLinuxCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { - InitTray(); - - trayData.notification_title = "Linux Notification"; - trayData.notification_text = "Notification body"; - trayData.notification_icon = "mail-message-new"; - trayData.notification_cb = notification_cb; - - tray_update(&trayData); - PumpEvents(); - - tray_simulate_notification_click(); - PumpEvents(); - EXPECT_EQ(g_notification_callback_count, 1); - - trayData.notification_title = nullptr; - trayData.notification_text = nullptr; - trayData.notification_icon = nullptr; - trayData.notification_cb = nullptr; - - tray_update(&trayData); - PumpEvents(); - - tray_simulate_notification_click(); - PumpEvents(); - EXPECT_EQ(g_notification_callback_count, 1); -} - -#endif diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp new file mode 100644 index 00000000..46d7af34 --- /dev/null +++ b/tests/unit/test_tray_qt.cpp @@ -0,0 +1,295 @@ +// test includes +#include "tests/conftest.cpp" + +// local includes +#include "src/tray.h" + +// standard includes +#include +#include +#include +#include + +namespace { + 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) { + menu_callback_count()++; + } + + void notification_cb() { + notification_callback_count()++; + } + + void log_cb([[maybe_unused]] int level, [[maybe_unused]] const char *msg) { + log_callback_count()++; + } +} // namespace + +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 { + BaseTest::SetUp(); + + tray_set_log_callback(nullptr); + tray_set_app_info(nullptr, nullptr, nullptr); + + 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}}}; + + 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 { + if (trayRunning) { + tray_exit(); + tray_loop(0); + trayRunning = false; + } + + tray_set_log_callback(nullptr); + BaseTest::TearDown(); + } + + void InitTray() { + const int initResult = tray_init(trayData); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + } + + void PumpEvents(int iterations = 20) { + for (int i = 0; i < iterations; i++) { + tray_loop(0); + } + } + + bool trayRunning {false}; + std::array menuItems {}; + std::array submenuItems {}; + std::vector trayDataStorage {}; + struct tray *trayData = nullptr; +}; + +TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { + InitTray(); + + tray_simulate_menu_item_click(-1); + tray_simulate_menu_item_click(99); + tray_simulate_menu_item_click(1); + tray_simulate_menu_item_click(2); + tray_simulate_menu_item_click(3); + PumpEvents(); + + EXPECT_EQ(menu_callback_count(), 0); + + tray_simulate_menu_item_click(0); + tray_simulate_menu_item_click(4); + PumpEvents(); + + EXPECT_EQ(menu_callback_count(), 2); +} + +TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { + tray_update(trayData); + tray_show_menu(); + tray_simulate_menu_item_click(0); + tray_simulate_notification_click(); + PumpEvents(); + + EXPECT_EQ(menu_callback_count(), 0); + EXPECT_EQ(notification_callback_count(), 0); +} + +TEST_F(TrayQtCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { + trayData->menu = nullptr; + InitTray(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + + EXPECT_EQ(menu_callback_count(), 0); +} + +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 tray code paths. + trayData->tooltip = "Explicit metadata update"; + tray_update(trayData); + PumpEvents(); +} + +TEST_F(TrayQtCoverageTest, SetAppInfoDefaultsUseFallbackValues) { + tray_set_app_info(nullptr, nullptr, nullptr); + trayData->tooltip = "Tooltip Display Name"; + InitTray(); + + trayData->tooltip = "Fallback metadata update"; + tray_update(trayData); + PumpEvents(); +} + +TEST_F(TrayQtCoverageTest, LogCallbackCanBeSetAndReset) { + InitTray(); + tray_set_log_callback(log_cb); + + // The callback is currently installed; this update path should remain stable. + trayData->tooltip = "Log callback installed"; + tray_update(trayData); + PumpEvents(); + + EXPECT_EQ(log_callback_count(), 0); + + tray_set_log_callback(nullptr); + + trayData->tooltip = "Log callback removed"; + tray_update(trayData); + PumpEvents(); + + EXPECT_EQ(log_callback_count(), 0); +} + +TEST_F(TrayQtCoverageTest, TrayExitCausesLoopToReturnExitCode) { + InitTray(); + + tray_exit(); + const int loopResult = tray_loop(0); + trayRunning = false; + + EXPECT_EQ(loopResult, -1); +} + +TEST_F(TrayQtCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { + InitTray(); + + menuItems[0].text = "Clickable Renamed"; + menuItems[0].disabled = 1; + tray_update(trayData); + PumpEvents(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(menu_callback_count(), 0); + + menuItems[0].disabled = 0; + tray_update(trayData); + PumpEvents(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(menu_callback_count(), 1); +} + +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 *); + std::vector buf(bufSize, std::byte {0}); + auto *iconPathTray = reinterpret_cast(buf.data()); // NOSONAR(cpp:S3630) - reinterpret_cast is required to map a C flexible-array struct over raw storage + + iconPathTray->icon = "missing-icon-name"; + iconPathTray->tooltip = "Icon path fallback"; + iconPathTray->notification_icon = nullptr; + iconPathTray->notification_text = nullptr; + iconPathTray->notification_title = nullptr; + iconPathTray->notification_cb = nullptr; + iconPathTray->menu = menuItems.data(); + + const 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"; + std::memcpy(const_cast(&iconPathTray->allIconPaths[0]), &badIcon, sizeof(badIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries + std::memcpy(const_cast(&iconPathTray->allIconPaths[1]), &goodIcon, sizeof(goodIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries + + const int initResult = tray_init(iconPathTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + tray_update(iconPathTray); + PumpEvents(); +} + +TEST_F(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; + + tray_update(trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + + EXPECT_EQ(notification_callback_count(), 0); + + trayData->notification_title = nullptr; + trayData->notification_text = nullptr; + trayData->notification_icon = nullptr; + tray_update(trayData); + PumpEvents(); + waitForNativeNotificationTimeout(); +} + +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; + + tray_update(trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + 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); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + EXPECT_EQ(notification_callback_count(), 1); + + waitForNativeNotificationTimeout(); +} diff --git a/tests/utils.cpp b/tests/utils.cpp index da5f665b..12ca9a71 100644 --- a/tests/utils.cpp +++ b/tests/utils.cpp @@ -5,6 +5,35 @@ // test includes #include "utils.h" +// standard includes +#include +#include +#include + +#ifdef _WIN32 + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#ifdef __linux__ +namespace { + void closeFreedesktopNotifications() { + constexpr const char *close_notifications = + "if command -v dbus-send >/dev/null 2>&1; then " + "id=1; while [ \"$id\" -le 128 ]; do " + "dbus-send --session --print-reply=literal --dest=org.freedesktop.Notifications " + "/org/freedesktop/Notifications org.freedesktop.Notifications.CloseNotification uint32:$id " + ">/dev/null 2>&1; " + "id=$((id + 1)); " + "done; " + "fi"; + (void) std::system(close_notifications); // NOSONAR(cpp:S4721) - test-only cleanup of desktop notifications + } +} // namespace +#endif + /** * @brief Set an environment variable. * @param name Name of the environment variable @@ -18,3 +47,35 @@ int setEnv(const std::string &name, const std::string &value) { return setenv(name.c_str(), value.c_str(), 1); #endif } + +bool isGitHubActions() { +#ifdef _WIN32 + SetLastError(ERROR_SUCCESS); + if (GetEnvironmentVariableA("GITHUB_ACTIONS", nullptr, 0) > 0) { + return true; + } + return GetLastError() == ERROR_SUCCESS; +#else + return std::getenv("GITHUB_ACTIONS") != nullptr; +#endif +} + +void dismissNativeNotifications() { +#ifdef __linux__ + closeFreedesktopNotifications(); + constexpr auto wait_timeout = std::chrono::milliseconds(500); + std::this_thread::sleep_for(wait_timeout); +#endif +} + +void waitForNativeNotificationTimeout() { +#ifdef _WIN32 + if (!isGitHubActions()) { + return; + } + constexpr auto wait_timeout = std::chrono::milliseconds(6000); + std::this_thread::sleep_for(wait_timeout); +#elif defined(__linux__) + dismissNativeNotifications(); +#endif +} diff --git a/tests/utils.h b/tests/utils.h index 40812795..489c2242 100644 --- a/tests/utils.h +++ b/tests/utils.h @@ -8,3 +8,9 @@ #include int setEnv(const std::string &name, const std::string &value); + +bool isGitHubActions(); + +void dismissNativeNotifications(); + +void waitForNativeNotificationTimeout();