diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e3933..03a33a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} build: - name: Build (${{ matrix.os }} - ${{ matrix.appindicator || 'default' }}) + name: Build (${{ matrix.os }}${{ matrix.qt_version && format(', Qt{0}', matrix.qt_version) || '' }}) defaults: run: shell: ${{ matrix.shell }} @@ -49,16 +49,16 @@ jobs: include: - os: macos-latest shell: "bash" + qt_version: '' - os: ubuntu-latest - appindicator: "libayatana-appindicator3-dev" - appindicator_type: "ayatana" shell: "bash" + qt_version: '5' - os: ubuntu-latest - appindicator: "libappindicator3-dev" - appindicator_type: "legacy" shell: "bash" + qt_version: '6' - os: windows-latest shell: "msys2 {0}" + qt_version: '' steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -67,23 +67,41 @@ jobs: - name: Setup Dependencies Linux if: runner.os == 'Linux' + env: + QT_VERSION: ${{ matrix.qt_version }} run: | + dependencies=( + "adwaita-icon-theme" + "build-essential" + "cmake" + "imagemagick" + "ninja-build" + "xvfb" + ) + + if [[ "${QT_VERSION}" == "5" ]]; then + dependencies+=( + "libqt5svg5-dev" + "qt5-gtk-platformtheme" + "qtbase5-dev" + ) + elif [[ "${QT_VERSION}" == "6" ]]; then + dependencies+=( + "qt6-base-dev" + "qt6-svg-dev" + ) + else + echo "Unsupported QT_VERSION: ${QT_VERSION}" + exit 1 + fi + sudo apt-get update - sudo apt-get install -y \ - build-essential \ - cmake \ - ${{ matrix.appindicator }} \ - imagemagick \ - libglib2.0-dev \ - libnotify-dev \ - ninja-build \ - xvfb + sudo apt-get install -y "${dependencies[@]}" - name: Setup virtual desktop if: runner.os == 'Linux' uses: LizardByte/actions/actions/virtual_desktop@70bb8d394d1c92f6113aeec6ae9cc959a5763d15 # v2026.227.200013 with: - appindicator-version: ${{ matrix.appindicator_type }} environment: mate - name: Setup Dependencies macOS @@ -169,7 +187,7 @@ jobs: echo "python-path=${python_path}" echo "python-path=${python_path}" >> "${GITHUB_OUTPUT}" - - name: Build + - name: Configure run: | mkdir -p build @@ -186,7 +204,9 @@ jobs: -B build \ -G Ninja \ -S . - ninja -C build + + - name: Build + run: ninja -C build - name: Init tray icon (Windows) if: runner.os == 'Windows' @@ -228,6 +248,8 @@ jobs: # TODO: tests randomly hang on Linux, https://github.com/LizardByte/tray/issues/45 timeout-minutes: 3 working-directory: build/tests + env: + QT_QPA_PLATFORMTHEME: ${{ runner.os == 'Linux' && matrix.qt_version == '5' && 'gtk3' || '' }} run: ./test_tray --gtest_color=yes --gtest_output=xml:test_results.xml - name: Upload screenshots @@ -236,7 +258,7 @@ jobs: (steps.test.outcome == 'success' || steps.test.outcome == 'failure') uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: tray-screenshots-${{ runner.os }}${{ matrix.appindicator && format('-{0}', matrix.appindicator) || '' }} + name: tray-screenshots-${{ runner.os }}${{ matrix.qt_version && format('-qt{0}', matrix.qt_version) || '' }} path: build/tests/screenshots if-no-files-found: error @@ -263,10 +285,7 @@ jobs: - name: Set codecov flags id: codecov_flags run: | - flags="${{ runner.os }}" - if [ -n "${{ matrix.appindicator }}" ]; then - flags="${flags},${{ matrix.appindicator }}" - fi + flags="${{ runner.os }}${{ matrix.qt_version && format('-qt{0}', matrix.qt_version) || '' }}" echo "flags=${flags}" >> "${GITHUB_OUTPUT}" - name: Upload coverage diff --git a/CMakeLists.txt b/CMakeLists.txt index cb263d4..c7a2b4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,99 +5,133 @@ cmake_minimum_required(VERSION 3.13 FATAL_ERROR) # target_link_directories project(tray VERSION 0.0.0 DESCRIPTION "A cross-platform system tray library" HOMEPAGE_URL "https://app.lizardbyte.dev" - LANGUAGES C) + LANGUAGES C CXX) set(PROJECT_LICENSE "MIT") +set(TRAY_IS_TOP_LEVEL OFF) +if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) + set(TRAY_IS_TOP_LEVEL ON) +endif() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) message(STATUS "Setting build type to 'Release' as none was specified.") set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE) endif() -# Add our custom CMake modules to the global path -set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") +# Add our custom CMake modules without overriding parent project settings. +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") # # Project optional configuration # -if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) - option(BUILD_DOCS "Build documentation" ON) - option(BUILD_TESTS "Build tests" ON) -endif() +option(BUILD_DOCS "Build documentation" ${TRAY_IS_TOP_LEVEL}) +option(BUILD_TESTS "Build tests" ${TRAY_IS_TOP_LEVEL}) +option(BUILD_EXAMPLE "Build example app" ${TRAY_IS_TOP_LEVEL}) # Generate 'compile_commands.json' for clang_complete set(CMAKE_COLOR_MAKEFILE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -find_package (PkgConfig REQUIRED) +find_package(PkgConfig) file(GLOB TRAY_SOURCES - "${CMAKE_SOURCE_DIR}/src/*.h" - "${CMAKE_SOURCE_DIR}/icons/*.ico" - "${CMAKE_SOURCE_DIR}/icons/*.png") + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" +) + +set(TRAY_ICON_ICO "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico") +set(TRAY_ICON_PNG "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png") +set(TRAY_ICON_SVG "${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg") +set(TRAY_ICON_FILES + "${TRAY_ICON_ICO}" + "${TRAY_ICON_PNG}" + "${TRAY_ICON_SVG}" +) + +set(_TRAY_ICON_ICO "${TRAY_ICON_ICO}" CACHE INTERNAL "Default tray ICO icon path") +set(_TRAY_ICON_PNG "${TRAY_ICON_PNG}" CACHE INTERNAL "Default tray PNG icon path") +set(_TRAY_ICON_SVG "${TRAY_ICON_SVG}" CACHE INTERNAL "Default tray SVG icon path") + +# Copy default tray icon files into the output directory of the specified target. +function(tray_copy_default_icons target_name) + if(NOT TARGET "${target_name}") + message(FATAL_ERROR "tray_copy_default_icons expected an existing target: ${target_name}") + endif() + + foreach(icon_file IN LISTS TRAY_ICON_FILES) + add_custom_command(TARGET "${target_name}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${icon_file}" + "$" + COMMENT "Copying ${icon_file} to $") + endforeach() +endfunction() if(WIN32) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_windows.c") + list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_windows.c") else() if(UNIX) if(APPLE) find_library(COCOA Cocoa REQUIRED) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_darwin.m") + list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m") else() - find_package(APPINDICATOR REQUIRED) - find_package(LibNotify REQUIRED) - list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_linux.c") + find_package(Qt6 COMPONENTS Widgets DBus Svg) + if(Qt6_FOUND) + set(TRAY_QT_VERSION 6) + else() + find_package(Qt5 REQUIRED COMPONENTS Widgets DBus Svg) + set(TRAY_QT_VERSION 5) + endif() + set(TRAY_QT_VERSION # cmake-lint: disable=C0103 + "${TRAY_QT_VERSION}" + CACHE INTERNAL "Qt major version selected by tray" + ) + set(CMAKE_AUTOMOC ON) + list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_linux.cpp") endif() 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) +target_include_directories(${PROJECT_NAME} + PUBLIC + $ + $) if(WIN32) - list(APPEND TRAY_DEFINITIONS TRAY_WINAPI=1 WIN32_LEAN_AND_MEAN NOMINMAX) if(MSVC) list(APPEND TRAY_COMPILE_OPTIONS "/MT$<$:d>") endif() else() if(UNIX) if(APPLE) - list(APPEND TRAY_DEFINITIONS TRAY_APPKIT=1) list(APPEND TRAY_EXTERNAL_LIBRARIES ${COCOA}) else() - list(APPEND TRAY_COMPILE_OPTIONS ${APPINDICATOR_CFLAGS}) - list(APPEND TRAY_EXTERNAL_DIRECTORIES ${APPINDICATOR_LIBRARY_DIRS}) - list(APPEND TRAY_DEFINITIONS TRAY_APPINDICATOR=1) - if(APPINDICATOR_AYATANA) - list(APPEND TRAY_DEFINITIONS TRAY_AYATANA_APPINDICATOR=1) + if(TRAY_QT_VERSION EQUAL 6) + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus Qt6::Svg) + else() + list(APPEND TRAY_EXTERNAL_LIBRARIES Qt5::Widgets Qt5::DBus Qt5::Svg) endif() - if(APPINDICATOR_LEGACY) - list(APPEND TRAY_DEFINITIONS TRAY_LEGACY_APPINDICATOR=1) - endif() - list(APPEND TRAY_LIBNOTIFY=1) - list(APPEND TRAY_EXTERNAL_LIBRARIES ${APPINDICATOR_LIBRARIES} ${LIBNOTIFY_LIBRARIES}) - - include_directories(SYSTEM ${APPINDICATOR_INCLUDE_DIRS} ${LIBNOTIFY_INCLUDE_DIRS}) - link_directories(${APPINDICATOR_LIBRARY_DIRS} ${LIBNOTIFY_LIBRARY_DIRS}) endif() endif() endif() add_library(tray::tray ALIAS ${PROJECT_NAME}) -add_executable(tray_example "${CMAKE_SOURCE_DIR}/src/example.c") -target_link_libraries(tray_example tray::tray) - -configure_file("${CMAKE_SOURCE_DIR}/icons/icon.ico" "${CMAKE_BINARY_DIR}/icon.ico" COPYONLY) -configure_file("${CMAKE_SOURCE_DIR}/icons/icon.png" "${CMAKE_BINARY_DIR}/icon.png" COPYONLY) - -INSTALL(TARGETS tray tray DESTINATION lib) +if(BUILD_EXAMPLE) + add_executable(tray_example "${CMAKE_CURRENT_SOURCE_DIR}/src/example.c") + target_link_libraries(tray_example tray::tray) + tray_copy_default_icons(tray_example) +endif() -IF(NOT WIN32) - INSTALL(FILES tray.h DESTINATION include) -ENDIF() +if(TRAY_IS_TOP_LEVEL) + install(TARGETS tray DESTINATION lib) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray.h" DESTINATION include) + install(FILES ${TRAY_ICON_FILES} DESTINATION share/tray/icons) +endif() -target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_DEFINITIONS}) target_compile_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS}) target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) target_link_libraries(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_LIBRARIES}) diff --git a/README.md b/README.md index ccbdd38..374478a 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,10 @@ The code is C++ friendly and will compile fine in C++98 and up. This is a fork o This fork adds the following features: - system tray notifications -- support for both linux appindicator versions - unit tests - code coverage -- refactored code, e.g. moved source code into the `src` directory -- doxygen documentation, and readthedocs configuration +- refactored code, e.g., moved source code into the `src` directory +- doxygen documentation and readthedocs configuration ## Screenshots @@ -33,32 +32,47 @@ This fork adds the following features: ## Supported platforms -* Linux/Gtk (libayatana-appindicator3 or libappindicator3) +* Linux/Qt (Qt5 or Qt6 Widgets) * Windows XP or newer (shellapi.h) * MacOS (Cocoa/AppKit) ## Prerequisites * CMake -* [Ninja](https://ninja-build.org/), in order to have the same build commands on all platforms +* [Ninja](https://ninja-build.org/), to have the same build commands on all platforms. ### Linux Dependencies +Install either Qt6 _or_ Qt5 development packages. The Linux backend requires +Qt Widgets, DBus, and Svg modules. +
- Arch ```bash - sudo pacman -S libayatana-appindicator + # Qt6 + sudo pacman -S qt6-base qt6-svg + + # Qt5 + sudo pacman -S qt5-base qt5-svg ``` - Debian/Ubuntu ```bash - sudo apt install libappindicator3-dev + # Qt6 + sudo apt install qt6-base-dev qt6-svg-dev + + # Qt5 + sudo apt install qtbase5-dev libqt5svg5-dev ``` - Fedora ```bash - sudo dnf install libappindicator-gtk3-devel + # Qt6 + sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel + + # Qt5 + sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel ```
diff --git a/cmake/FindAPPINDICATOR.cmake b/cmake/FindAPPINDICATOR.cmake deleted file mode 100644 index 94f6203..0000000 --- a/cmake/FindAPPINDICATOR.cmake +++ /dev/null @@ -1,34 +0,0 @@ -# Remmina - The GTK+ Remote Desktop Client -# -# Copyright (C) 2011 Marc-Andre Moreau -# Copyright (C) 2014-2015 Antenore Gatta, Fabio Castelli, Giovanni Panozzo -# Copyright (C) 2016-2023 Antenore Gatta, Giovanni Panozzo -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, -# Boston, MA 02110-1301, USA. - -include(FindPackageHandleStandardArgs) - -pkg_check_modules(APPINDICATOR ayatana-appindicator3-0.1) -if(APPINDICATOR_FOUND) - SET(APPINDICATOR_AYATANA 1) -else() - pkg_check_modules(APPINDICATOR appindicator3-0.1) - if(APPINDICATOR_FOUND) - SET(APPINDICATOR_LEGACY 1) - endif() -endif() - -mark_as_advanced(APPINDICATOR_INCLUDE_DIR APPINDICATOR_LIBRARY) diff --git a/cmake/FindLibNotify.cmake b/cmake/FindLibNotify.cmake deleted file mode 100644 index e76b199..0000000 --- a/cmake/FindLibNotify.cmake +++ /dev/null @@ -1,55 +0,0 @@ -# - Try to find LibNotify -# This module defines the following variables: -# -# LIBNOTIFY_FOUND - LibNotify was found -# LIBNOTIFY_INCLUDE_DIRS - the LibNotify include directories -# LIBNOTIFY_LIBRARIES - link these to use LibNotify -# -# Copyright (C) 2012 Raphael Kubo da Costa -# Copyright (C) 2014 Collabora Ltd. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND ITS CONTRIBUTORS ``AS -# IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, -# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR ITS -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; -# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR -# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF -# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -find_package(PkgConfig) -pkg_check_modules(LIBNOTIFY QUIET libnotify) - -find_path(LIBNOTIFY_INCLUDE_DIRS - NAMES notify.h - HINTS ${LIBNOTIFY_INCLUDEDIR} - ${LIBNOTIFY_INCLUDE_DIRS} - PATH_SUFFIXES libnotify -) - -find_library(LIBNOTIFY_LIBRARIES - NAMES notify - HINTS ${LIBNOTIFY_LIBDIR} - ${LIBNOTIFY_LIBRARY_DIRS} -) - -include(FindPackageHandleStandardArgs) -FIND_PACKAGE_HANDLE_STANDARD_ARGS(LibNotify REQUIRED_VARS LIBNOTIFY_INCLUDE_DIRS LIBNOTIFY_LIBRARIES - VERSION_VAR LIBNOTIFY_VERSION) - -mark_as_advanced( - LIBNOTIFY_INCLUDE_DIRS - LIBNOTIFY_LIBRARIES -) diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..393f916 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/example.c b/src/example.c index bf9e19a..411b5a9 100644 --- a/src/example.c +++ b/src/example.c @@ -6,26 +6,15 @@ #include #include -#if defined(_WIN32) || defined(_WIN64) - #define TRAY_WINAPI 1 ///< Use WinAPI. -#elif defined(__linux__) || defined(linux) || defined(__linux) - #define TRAY_APPINDICATOR 1 -#elif defined(__APPLE__) || defined(__MACH__) - #define TRAY_APPKIT 1 -#endif - // local includes #include "tray.h" -#if TRAY_APPINDICATOR - #define TRAY_ICON1 "mail-message-new" - #define TRAY_ICON2 "mail-message-new" -#elif TRAY_APPKIT - #define TRAY_ICON1 "icon.png" - #define TRAY_ICON2 "icon.png" -#elif TRAY_WINAPI +#if defined(_WIN32) #define TRAY_ICON1 "icon.ico" ///< Path to first icon. #define TRAY_ICON2 "icon.ico" ///< Path to second icon. +#else + #define TRAY_ICON1 "icon.png" + #define TRAY_ICON2 "icon.png" #endif static struct tray tray; @@ -62,9 +51,7 @@ static void submenu_cb(struct tray_menu *item) { // Test tray init static struct tray tray = { .icon = TRAY_ICON1, -#if TRAY_WINAPI .tooltip = "Tray", -#endif .menu = (struct tray_menu[]) { {.text = "Hello", .cb = hello_cb}, diff --git a/src/tray.h b/src/tray.h index 164a436..513ffc3 100644 --- a/src/tray.h +++ b/src/tray.h @@ -5,7 +5,7 @@ #ifndef TRAY_H #define TRAY_H -#if defined(TRAY_WINAPI) +#if defined(_WIN32) #include #endif @@ -73,12 +73,60 @@ extern "C" { */ void tray_show_menu(void); + /** + * @brief Simulate a notification click, invoking the notification callback (for testing purposes). + * + * On Linux (Qt): triggers the stored notification callback as if the user clicked the notification. + * On other platforms: no-op. + */ + void tray_simulate_notification_click(void); + + /** + * @brief Simulate clicking a top-level menu item by index (for testing purposes). + * + * On Linux (Qt): triggers the QAction associated with the given top-level menu + * index (separators and submenus are ignored). + * On other platforms: no-op. + * + * @param index Zero-based index in the top-level tray menu. + */ + void tray_simulate_menu_item_click(int index); + /** * @brief Terminate UI loop. */ void tray_exit(void); -#if defined(TRAY_WINAPI) + /** + * @brief Set a callback for log messages produced by the tray library. + * + * On Linux the callback is installed as a Qt message handler so all Qt + * diagnostic output is routed through it. On other platforms this function + * is a no-op. + * + * @param cb Callback invoked with level (0=debug, 1=info, 2=warning, 3=error) + * and the message string. Pass NULL to restore the default logging behaviour. + */ + void tray_set_log_callback(void (*cb)(int level, const char *msg)); + + /** + * @brief Set application metadata used by the tray library. + * + * Must be called before tray_init(). On Linux (Qt), sets the Qt application + * name, display name, and desktop file name used for D-Bus registration. On + * other platforms this function is a no-op. + * + * @param app_name Application name used as a technical identifier (e.g., for + * D-Bus registration). Converted to lowercase automatically. NULL uses the + * default ("tray"). + * @param app_display_name Human-readable name shown in notifications and UI. + * NULL derives from the tray tooltip or falls back to app_name. + * @param desktop_name Desktop file name for D-Bus. NULL appends ".desktop" + * to app_name. + */ + 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. diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 2cebb17..ca811cf 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -130,6 +130,28 @@ 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 diff --git a/src/tray_linux.c b/src/tray_linux.c deleted file mode 100644 index 3b9c678..0000000 --- a/src/tray_linux.c +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @file src/tray_linux.c - * @brief System tray implementation for Linux. - */ -// standard includes -#include -#include -#include -#include -#include - -// local includes -#include "tray.h" - -// lib includes -#ifdef TRAY_AYATANA_APPINDICATOR - #include -#elif TRAY_LEGACY_APPINDICATOR - #include -#endif -#ifndef IS_APP_INDICATOR - #define IS_APP_INDICATOR APP_IS_INDICATOR ///< Define IS_APP_INDICATOR for app-indicator compatibility. -#endif -#include - -// Use a per-process AppIndicator id to avoid DBus collisions when tests create multiple -// tray instances in the same desktop/session. -static unsigned long tray_appindicator_seq = 0; - -static bool async_update_pending = false; -static pthread_cond_t async_update_cv = PTHREAD_COND_INITIALIZER; -static pthread_mutex_t async_update_mutex = PTHREAD_MUTEX_INITIALIZER; - -static AppIndicator *indicator = NULL; -static int loop_result = 0; -static NotifyNotification *currentNotification = NULL; -static GtkMenu *current_menu = NULL; -static GtkMenu *current_popup = NULL; -static GtkWidget *menu_anchor_window = NULL; - -static void _tray_menu_cb(GtkMenuItem *item, gpointer data) { - (void) item; - struct tray_menu *m = (struct tray_menu *) data; - m->cb(m); -} - -static GtkMenuShell *_tray_menu(struct tray_menu *m) { - GtkMenuShell *menu = (GtkMenuShell *) gtk_menu_new(); - for (; m != NULL && m->text != NULL; m++) { - GtkWidget *item; - if (strcmp(m->text, "-") == 0) { - item = gtk_separator_menu_item_new(); - } else { - if (m->submenu != NULL) { - item = gtk_menu_item_new_with_label(m->text); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(item), GTK_WIDGET(_tray_menu(m->submenu))); - } else if (m->checkbox) { - item = gtk_check_menu_item_new_with_label(m->text); - gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), !!m->checked); - } else { - item = gtk_menu_item_new_with_label(m->text); - } - gtk_widget_set_sensitive(item, !m->disabled); - if (m->cb != NULL) { - g_signal_connect(item, "activate", G_CALLBACK(_tray_menu_cb), m); - } - } - gtk_widget_show(item); - gtk_menu_shell_append(menu, item); - } - return menu; -} - -int tray_init(struct tray *tray) { - if (gtk_init_check(0, NULL) == FALSE) { - return -1; - } - - // If a previous tray instance wasn't fully torn down (common in unit tests), - // drop our references before creating a new indicator. - if (indicator != NULL) { - g_object_unref(G_OBJECT(indicator)); - indicator = NULL; - } - loop_result = 0; - notify_init("tray-icon"); - // The id is used as part of the exported DBus object path. - // Make it unique per *tray instance* to prevent collisions inside a single test process. - // Avoid underscores and other characters that may be normalized/stripped. - char appindicator_id[64]; - tray_appindicator_seq++; - snprintf(appindicator_id, sizeof(appindicator_id), "trayid%ld%lu", (long) getpid(), tray_appindicator_seq); - - indicator = app_indicator_new(appindicator_id, tray->icon, APP_INDICATOR_CATEGORY_APPLICATION_STATUS); - if (indicator == NULL || !IS_APP_INDICATOR(indicator)) { - return -1; - } - app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE); - tray_update(tray); - return 0; -} - -int tray_loop(int blocking) { - gtk_main_iteration_do(blocking); - return loop_result; -} - -static gboolean tray_update_internal(gpointer user_data) { - struct tray *tray = user_data; - - if (indicator != NULL && IS_APP_INDICATOR(indicator)) { - app_indicator_set_icon_full(indicator, tray->icon, tray->icon); - // GTK is all about reference counting, so previous menu should be destroyed - // here - GtkMenu *menu = GTK_MENU(_tray_menu(tray->menu)); - app_indicator_set_menu(indicator, menu); - if (current_menu != NULL) { - g_object_unref(current_menu); - } - current_menu = menu; - g_object_ref(current_menu); // Keep a reference for showing - } - if (tray->notification_text != 0 && strlen(tray->notification_text) > 0 && notify_is_initted()) { - if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { - notify_notification_close(currentNotification, NULL); - g_object_unref(G_OBJECT(currentNotification)); - } - const char *notification_icon = tray->notification_icon != NULL ? tray->notification_icon : tray->icon; - currentNotification = notify_notification_new(tray->notification_title, tray->notification_text, notification_icon); - if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { - if (tray->notification_cb != NULL) { - notify_notification_add_action(currentNotification, "default", "Default", NOTIFY_ACTION_CALLBACK(tray->notification_cb), NULL, NULL); - } - notify_notification_show(currentNotification, NULL); - } - } - - // Unwait any pending tray_update() calls - pthread_mutex_lock(&async_update_mutex); - async_update_pending = false; - pthread_cond_broadcast(&async_update_cv); - pthread_mutex_unlock(&async_update_mutex); - return G_SOURCE_REMOVE; -} - -void tray_update(struct tray *tray) { - // Perform the tray update on the tray loop thread, but block - // in this thread to ensure none of the strings stored in the - // tray icon struct go out of scope before the callback runs. - - if (g_main_context_is_owner(g_main_context_default())) { - // Invoke the callback directly if we're on the loop thread - tray_update_internal(tray); - } else { - // If there's already an update pending, wait for it to complete - // and claim the next pending update slot. - pthread_mutex_lock(&async_update_mutex); - while (async_update_pending) { - pthread_cond_wait(&async_update_cv, &async_update_mutex); - } - async_update_pending = true; - pthread_mutex_unlock(&async_update_mutex); - - // Queue the update callback to the tray thread - g_main_context_invoke(NULL, tray_update_internal, tray); - - // Wait for the callback to run - pthread_mutex_lock(&async_update_mutex); - while (async_update_pending) { - pthread_cond_wait(&async_update_cv, &async_update_mutex); - } - pthread_mutex_unlock(&async_update_mutex); - } -} - -static void _tray_popup(GtkMenu *menu) { - if (menu == NULL) { - return; - } - - // Dismiss any previously shown popup - if (current_popup != NULL) { - gtk_menu_popdown(current_popup); - current_popup = NULL; - } - if (menu_anchor_window != NULL) { - gtk_widget_destroy(menu_anchor_window); - menu_anchor_window = NULL; - } - - GtkWidget *anchor_window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - if (anchor_window != NULL) { - gtk_window_set_type_hint(GTK_WINDOW(anchor_window), GDK_WINDOW_TYPE_HINT_POPUP_MENU); - gtk_window_set_decorated(GTK_WINDOW(anchor_window), FALSE); - gtk_window_set_skip_taskbar_hint(GTK_WINDOW(anchor_window), TRUE); - gtk_window_set_skip_pager_hint(GTK_WINDOW(anchor_window), TRUE); - gtk_window_move(GTK_WINDOW(anchor_window), 100, 100); - gtk_window_resize(GTK_WINDOW(anchor_window), 1, 1); - gtk_widget_show(anchor_window); - menu_anchor_window = anchor_window; - - while (gtk_events_pending()) { - gtk_main_iteration(); - } - - if (gtk_check_version(3, 22, 0) == NULL) { - GdkWindow *gdk_window = gtk_widget_get_window(anchor_window); - if (gdk_window != NULL) { - GdkRectangle rect = {0, 0, 1, 1}; - gtk_menu_popup_at_rect(menu, gdk_window, &rect, GDK_GRAVITY_NORTH_WEST, GDK_GRAVITY_NORTH_WEST, NULL); - } else { - gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); - } - } else { - gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); - } - current_popup = menu; - } else { - gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); - current_popup = menu; - } -} - -void tray_show_menu(void) { - _tray_popup(current_menu); -} - -static gboolean tray_exit_internal(gpointer user_data) { - (void) user_data; - - if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { - int v = notify_notification_close(currentNotification, NULL); - if (v == TRUE) { - g_object_unref(G_OBJECT(currentNotification)); - } - currentNotification = NULL; - } - - if (current_popup != NULL) { - gtk_menu_popdown(current_popup); - current_popup = NULL; - } - - if (current_menu != NULL) { - g_object_unref(current_menu); - current_menu = NULL; - } - - if (menu_anchor_window != NULL) { - gtk_widget_destroy(menu_anchor_window); - menu_anchor_window = NULL; - } - - if (indicator != NULL) { - // Make the indicator passive before unref to encourage a clean DBus unexport. - app_indicator_set_status(indicator, APP_INDICATOR_STATUS_PASSIVE); - g_object_unref(G_OBJECT(indicator)); - indicator = NULL; - } - notify_uninit(); - return G_SOURCE_REMOVE; -} - -void tray_exit(void) { - // Wait for any pending callbacks to complete - pthread_mutex_lock(&async_update_mutex); - while (async_update_pending) { - pthread_cond_wait(&async_update_cv, &async_update_mutex); - } - pthread_mutex_unlock(&async_update_mutex); - - // Perform cleanup on the main thread - loop_result = -1; - g_main_context_invoke(NULL, tray_exit_internal, NULL); -} diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp new file mode 100644 index 0000000..a9facdf --- /dev/null +++ b/src/tray_linux.cpp @@ -0,0 +1,1007 @@ +/** + * @file src/tray_linux.cpp + * @brief System tray implementation for Linux using Qt. + */ +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "tray.h" + +// Qt includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Handles D-Bus notification action signals. + * + * Receives the org.freedesktop.Notifications ActionInvoked signal so that + * notification click callbacks work when notifications are sent via D-Bus + * rather than Qt's built-in balloon (QSystemTrayIcon::showMessage). + * + * Defined in tray_linux.cpp rather than a separate header to keep the moc + * output self-contained via the inline `#include "tray_linux.moc"` at the + * bottom of this file. Any CMake target that compiles tray_linux.cpp with + * AUTOMOC ON will automatically generate and inline the moc output. + */ +class TrayNotificationHandler: public QObject { + Q_OBJECT + +public: + uint notification_id = 0; ///< ID of the most recently sent D-Bus notification. + void (*cb)() = nullptr; ///< Callback to invoke when the notification is activated. + +public slots: + + /** + * @brief Invoked when a D-Bus notification action is triggered. + * @param id The notification ID. + * @param action_key The action key that was triggered. + */ + void onActionInvoked(uint id, const QString &action_key) const { + if (id == notification_id && cb != nullptr && action_key == QLatin1String("default")) { + cb(); + } + } +}; + +namespace { + std::unique_ptr g_app; // NOSONAR(cpp:S5421) - mutable state, not const + QSystemTrayIcon *g_tray_icon = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + TrayNotificationHandler *g_notification_handler = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + int g_loop_result = 0; // NOSONAR(cpp:S5421) - mutable state, not const + bool g_app_owned = false; // NOSONAR(cpp:S5421) - mutable state, not const + std::atomic g_exit_pending {false}; // NOSONAR(cpp:S5421) - written from any thread, read from tray_loop + uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup + std::uint64_t g_notification_generation = 0; // NOSONAR(cpp:S5421) - invalidates stale async Notify replies + std::uint64_t g_notification_active_generation = 0; // NOSONAR(cpp:S5421) - generation currently allowed to own notification_id + void (*g_log_cb)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const + QString g_app_name; // NOSONAR(cpp:S5421) - set via tray_set_app_info before tray_init + QString g_app_display_name; // NOSONAR(cpp:S5421) - set via tray_set_app_info before tray_init + QString g_desktop_name; // NOSONAR(cpp:S5421) - set via tray_set_app_info before tray_init + + /** + * @brief Invoke @p f on the Qt application's thread. + * + * When the caller is already on the Qt thread (or there is no QApplication), + * @p f is called directly. When called from any other thread, + * QMetaObject::invokeMethod with Qt::BlockingQueuedConnection is used so that + * the caller blocks until the Qt thread finishes executing @p f. This ensures + * all Qt GUI operations happen on the thread that owns the QApplication, + * preventing cross-thread Qt object access that causes D-Bus relay warnings. + * + * Requires Qt 5.10+. + * + * @param f Callable to execute on the Qt thread. + */ + template + void run_on_qt_thread(Func f) { + QCoreApplication *app = QCoreApplication::instance(); + if (app == nullptr || QThread::currentThread() == app->thread()) { + f(); + return; + } + QMetaObject::invokeMethod(app, std::move(f), Qt::BlockingQueuedConnection); + } + + bool is_wayland_session() { + if (const QString platform = QGuiApplication::platformName().toLower(); + platform.contains(QStringLiteral("wayland"))) { + return true; + } + return !qgetenv("WAYLAND_DISPLAY").isEmpty(); + } + + bool has_wayland_display_endpoint() { + const QByteArray wayland_display = qgetenv("WAYLAND_DISPLAY"); + if (wayland_display.isEmpty()) { + return false; + } + + const QString display_name = QString::fromLocal8Bit(wayland_display).trimmed(); + if (display_name.isEmpty()) { + return false; + } + + if (const QFileInfo direct_path(display_name); direct_path.exists()) { + return true; + } + + const QByteArray runtime_dir = qgetenv("XDG_RUNTIME_DIR"); + if (runtime_dir.isEmpty()) { + return false; + } + + const QString socket_path = QDir(QString::fromLocal8Bit(runtime_dir)).filePath(display_name); + return QFileInfo::exists(socket_path); + } + + QString discover_wayland_display_name() { + if (!qEnvironmentVariableIsEmpty("WAYLAND_DISPLAY")) { + return QString(); + } + + const QByteArray runtime_dir_env = qgetenv("XDG_RUNTIME_DIR"); + if (runtime_dir_env.isEmpty()) { + return QString(); + } + + const QString runtime_dir_path = QString::fromLocal8Bit(runtime_dir_env).trimmed(); + if (runtime_dir_path.isEmpty()) { + return QString(); + } + + const QDir runtime_dir(runtime_dir_path); + if (!runtime_dir.exists()) { + return QString(); + } + + const QStringList entries = runtime_dir.entryList( + QStringList() << QStringLiteral("wayland-*"), + QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System, + QDir::Name + ); + if (entries.isEmpty()) { + return QString(); + } + + QString selected; + for (const QString &entry : entries) { + if (const QString candidate_path = runtime_dir.filePath(entry); !QFileInfo::exists(candidate_path)) { + continue; + } + if (entry == QStringLiteral("wayland-0")) { + return entry; + } + if (selected.isEmpty()) { + selected = entry; + } + } + return selected; + } + + bool try_autodiscover_wayland_display() { + const QString discovered = discover_wayland_display_name(); + if (discovered.isEmpty()) { + return false; + } + return qputenv("WAYLAND_DISPLAY", discovered.toLocal8Bit()); + } + + bool has_x11_display_endpoint() { + const QByteArray display_env = qgetenv("DISPLAY"); + if (display_env.isEmpty()) { + return false; + } + + const QString display = QString::fromLocal8Bit(display_env).trimmed(); + if (display.isEmpty()) { + return false; + } + + if (display.startsWith('/')) { + return QFileInfo::exists(display); + } + + if (!display.startsWith(':')) { + // Remote/TCP displays are not locally discoverable; treat as potentially usable. + return true; + } + + int digit_end = 1; + while (digit_end < display.size() && display.at(digit_end).isDigit()) { + digit_end++; + } + if (digit_end == 1) { + return true; + } + + bool ok = false; + const int display_number = display.mid(1, digit_end - 1).toInt(&ok); + if (!ok) { + return true; + } + + const QString socket_path = QStringLiteral("/tmp/.X11-unix/X%1").arg(display_number); + return QFileInfo::exists(socket_path); + } + + bool should_force_headless_qpa_fallback() { + if (!qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + return false; + } + return !has_wayland_display_endpoint() && !has_x11_display_endpoint(); + } + + QPoint screen_anchor_point(const QScreen *screen) { + if (screen == nullptr) { + return QPoint(); + } + + const QRect full = screen->geometry(); + const QRect avail = screen->availableGeometry(); + + if (avail.top() > full.top()) { + return QPoint(avail.right(), avail.top()); + } + if (avail.bottom() < full.bottom()) { + return QPoint(avail.right(), avail.bottom()); + } + if (avail.left() > full.left()) { + return QPoint(avail.left(), avail.bottom()); + } + if (avail.right() < full.right()) { + return QPoint(avail.right(), avail.bottom()); + } + + // Some compositors report no reserved panel area; top-right is a safer fallback than (0, 0). + return avail.topRight(); + } + + /** + * @brief Qt message handler that forwards to the registered log callback. + * @param type The Qt message type. + * @param msg The message string. + */ + void qt_message_handler(QtMsgType type, const QMessageLogContext &, const QString &msg) { + if (g_log_cb == nullptr) { + return; + } + int level; + switch (type) { + case QtDebugMsg: + level = 0; + break; + case QtInfoMsg: + level = 1; + break; + case QtWarningMsg: + level = 2; + break; + default: + level = 3; + break; + } + g_log_cb(level, msg.toUtf8().constData()); + } + + /** + * @brief Calculate the best position to show the context menu. + * + * Priority: + * 1. Tray icon geometry (reliable on X11/XEmbed, sometimes on SNI). + * 2. On a pure Xorg session, QCursor::pos() is accurate. + * 3. On a Wayland session (detected via WAYLAND_DISPLAY), QCursor::pos() goes + * through XWayland and reflects the last X11 cursor position, which is NOT + * updated when the pointer interacts with Wayland-native surfaces such as the + * GNOME Shell top bar. A screen-geometry heuristic is used instead: the panel + * edge is inferred from the difference between the screen's full and available + * geometries. + * + * Qt's QMenu::popup() will adjust the final position to keep the menu fully + * on-screen, including flipping it above the anchor point when needed. + * + * @return The point at which to show the context menu. + */ + QPoint calculateMenuPosition(const QPoint &preferred_pos = QPoint()) { + if (g_tray_icon != nullptr) { + const QRect iconGeo = g_tray_icon->geometry(); + if (iconGeo.isValid()) { + return iconGeo.bottomLeft(); + } + } + + if (!preferred_pos.isNull() && !is_wayland_session()) { + return preferred_pos; + } + + // When running under a Wayland compositor, XWayland cursor coordinates are stale + // for events originating from Wayland-native surfaces (e.g., the GNOME top bar). + // Detect a Wayland session regardless of the Qt platform plugin in use. + if (const bool wayland_session = is_wayland_session(); !wayland_session) { + // Pure Xorg: QCursor::pos() is accurate. + return QCursor::pos(); + } + + const QPoint cursor_pos = QCursor::pos(); + if (!cursor_pos.isNull()) { + const QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos); + if (cursor_screen != nullptr) { + return cursor_pos; + } + } + + // Wayland session fallback: infer panel anchor from the relevant screen. + const QScreen *screen = QGuiApplication::screenAt(cursor_pos); + if (screen == nullptr) { + screen = QGuiApplication::primaryScreen(); + } + if (const QPoint anchored = screen_anchor_point(screen); !anchored.isNull()) { + return anchored; + } + + return cursor_pos; + } + + QIcon icon_from_source(const QString &icon_source) { + if (icon_source.isEmpty()) { + return QIcon(); + } + + if (const QFileInfo icon_fi(icon_source); icon_fi.exists()) { + const QString file_path = icon_fi.absoluteFilePath(); + if (const QIcon file_icon(file_path); !file_icon.isNull()) { + return file_icon; + } + + const QPixmap pixmap(file_path); + if (!pixmap.isNull()) { + QIcon icon; + icon.addPixmap(pixmap); + return icon; + } + } + + if (const QIcon themed = QIcon::fromTheme(icon_source); !themed.isNull()) { + return themed; + } + + return QIcon(); + } + + QIcon resolve_tray_icon(const struct tray *tray_data) { + if (tray_data == nullptr) { + return QIcon(); + } + + if (tray_data->icon != nullptr) { + const QIcon icon = icon_from_source(QString::fromUtf8(tray_data->icon)); + if (!icon.isNull()) { + return icon; + } + } + + if (tray_data->iconPathCount > 0 && tray_data->iconPathCount < 64) { + for (int i = 0; i < tray_data->iconPathCount; i++) { + if (tray_data->allIconPaths[i] == nullptr) { + continue; + } + const QIcon icon = icon_from_source(QString::fromUtf8(tray_data->allIconPaths[i])); + if (!icon.isNull()) { + return icon; + } + } + } + + return QIcon(); + } + + void popup_menu_for_activation(const QPoint &preferred_pos, int retries_left = 3) { + if (g_tray_icon == nullptr) { + return; + } + + QMenu *menu = g_tray_icon->contextMenu(); + if (menu == nullptr || menu->isVisible()) { + return; + } + + menu->activateWindow(); + menu->setWindowFlag(Qt::Popup, true); + menu->popup(calculateMenuPosition(preferred_pos)); + menu->setFocus(Qt::PopupFocusReason); + + if (!menu->isVisible() && retries_left > 0) { + QTimer::singleShot(30, g_tray_icon, [preferred_pos, retries_left]() { + popup_menu_for_activation(preferred_pos, retries_left - 1); + }); + } + } + + void close_notification_id(uint notification_id) { + if (notification_id == 0) { + return; + } + QDBusInterface iface( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications") + ); + if (iface.isValid()) { + iface.asyncCall(QStringLiteral("CloseNotification"), notification_id); + } + } + + void close_notification() { + if (g_notification_id == 0) { + return; + } + const uint id_to_close = g_notification_id; + g_notification_id = 0; + close_notification_id(id_to_close); + } + + QMenu *build_menu(struct tray_menu *m, QWidget *parent) { + auto *menu = new QMenu(parent); // NOSONAR(cpp:S5025) - submenus owned by parent via Qt; top-level deleted manually + for (; m != nullptr && m->text != nullptr; m++) { + if (std::strcmp(m->text, "-") == 0) { + menu->addSeparator(); + } else if (m->submenu != nullptr) { + QMenu *sub = build_menu(m->submenu, menu); + sub->setTitle(QString::fromUtf8(m->text)); + QAction *sub_action = menu->addMenu(sub); + sub_action->setEnabled(m->disabled == 0); + } else { + auto *action = menu->addAction(QString::fromUtf8(m->text)); + action->setEnabled(m->disabled == 0); + if (m->checkbox) { + action->setCheckable(true); + action->setChecked(m->checked != 0); + } + action->setData(QVariant::fromValue(static_cast(m))); + QObject::connect(action, &QAction::triggered, menu, [action]() { + auto *item = static_cast(action->data().value()); + if (item != nullptr && item->cb != nullptr) { + item->cb(item); + } + }); + } + } + return menu; + } + + bool menu_layout_matches(const QMenu *menu, const struct tray_menu *items) { + if (menu == nullptr) { + return false; + } + + const QList actions = menu->actions(); + int action_index = 0; + for (const struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) { + if (action_index >= actions.size()) { + return false; + } + + const int current_action_index = action_index; + action_index++; + const QAction *action = actions.at(current_action_index); + if (std::strcmp(item->text, "-") == 0) { + if (!action->isSeparator()) { + return false; + } + continue; + } + + if (item->submenu != nullptr) { + const QMenu *submenu = action->menu(); + if (submenu == nullptr || !menu_layout_matches(submenu, item->submenu)) { + return false; + } + } else if (action->isSeparator() || action->menu() != nullptr) { + return false; + } + } + + return action_index == actions.size(); + } + + void update_menu_state(const QMenu *menu, struct tray_menu *items) { + if (menu == nullptr || items == nullptr) { + return; + } + + const QList actions = menu->actions(); + int action_index = 0; + for (struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) { + const int current_action_index = action_index; + action_index++; + QAction *action = actions.at(current_action_index); + if (std::strcmp(item->text, "-") == 0) { + continue; + } + + action->setText(QString::fromUtf8(item->text)); + action->setEnabled(item->disabled == 0); + if (item->submenu != nullptr) { + update_menu_state(action->menu(), item->submenu); + continue; + } + + action->setCheckable(item->checkbox != 0); + if (item->checkbox != 0) { + action->setChecked(item->checked != 0); + } + action->setData(QVariant::fromValue(static_cast(item))); + } + } + + void configure_app_metadata(const struct tray *tray) { + const QString effective_name = !g_app_name.isEmpty() ? g_app_name : QStringLiteral("tray"); + if (QCoreApplication::applicationName().isEmpty()) { + QCoreApplication::setApplicationName(effective_name); + } + + if (QGuiApplication::applicationDisplayName().isEmpty()) { + if (!g_app_display_name.isEmpty()) { + QGuiApplication::setApplicationDisplayName(g_app_display_name); + } else { + const QString display_name = + (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : effective_name; + QGuiApplication::setApplicationDisplayName(display_name); + } + } + + if (!QGuiApplication::desktopFileName().isEmpty()) { + return; + } + + if (!g_desktop_name.isEmpty()) { + QGuiApplication::setDesktopFileName(g_desktop_name); + return; + } + + QString desktop_name = QCoreApplication::applicationName(); + if (!desktop_name.endsWith(QStringLiteral(".desktop"))) { + desktop_name += QStringLiteral(".desktop"); + } + QGuiApplication::setDesktopFileName(desktop_name); + } + + void connect_activation_handler() { + // Show the context menu on left-click (Trigger). + // Qt handles right-click natively via setContextMenu on both X11/XEmbed and + // SNI (Wayland/AppIndicators), so we do not handle Context here. + // The menu position is captured immediately before deferring by a short timer. + // Deferring allows any platform pointer grab from the tray click to be released + // before the menu establishes its own grab. + QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) { + if (const bool left_click_activation = + (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context); + !left_click_activation) { + return; + } + + const QPoint click_pos = QCursor::pos(); + QTimer::singleShot(30, g_tray_icon, [click_pos]() { + popup_menu_for_activation(click_pos); + }); + }); + } + + void ensure_notification_handler_connected() { + if (g_notification_handler != nullptr) { + return; + } + + g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app() + // Defer D-Bus ActionInvoked handler setup to the first event-loop iteration. + // Creating QDBusConnection socket notifiers before the event loop starts can + // trigger a "QSocketNotifier: Can only be used with threads started with QThread" + // warning when the tray runs in a std::thread. + QTimer::singleShot(0, g_notification_handler, []() { + if (g_notification_handler == nullptr) { + return; + } + QDBusConnection::sessionBus().connect( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("ActionInvoked"), + g_notification_handler, + SLOT(onActionInvoked(uint, QString)) + ); + }); + } + + void update_context_menu(const struct tray *tray) { + if (tray->menu == nullptr) { + return; + } + + QMenu *existing_menu = g_tray_icon->contextMenu(); + if (existing_menu != nullptr && menu_layout_matches(existing_menu, tray->menu)) { + update_menu_state(existing_menu, tray->menu); + return; + } + + // setContextMenu does not take ownership; delete the old menu before replacing it. + QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via existing_menu path or on next update + g_tray_icon->setContextMenu(new_menu); + if (existing_menu == nullptr) { + return; + } + + // hide() before delete releases any X11 pointer grab held by the popup. + // Skipping this leaves the grab active, causing future popup menus to appear + // but receive no pointer events, so QAction::triggered is never emitted. + existing_menu->hide(); + delete existing_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this + } + + void reset_notification_state() { + g_notification_generation++; + g_notification_active_generation = 0; + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = 0; + g_notification_handler->cb = nullptr; + } + if (g_tray_icon != nullptr) { + QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr); + } + close_notification(); + } + + QString resolve_notification_icon(const struct tray *tray) { + const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon; + if (icon_path == nullptr) { + return QString(); + } + + if (const QFileInfo fi(QString::fromUtf8(icon_path)); fi.exists()) { + return QUrl::fromLocalFile(fi.absoluteFilePath()).toString(); + } + return QString::fromUtf8(icon_path); + } + + void destroy_tray(); + + void handle_notification_reply(QDBusPendingCallWatcher *watcher, const std::uint64_t notification_generation) { + const QDBusPendingReply reply = *watcher; + if (!reply.isValid() || g_tray_icon == nullptr) { + watcher->deleteLater(); + return; + } + + const uint reply_id = reply.value(); + const bool stale_reply = + notification_generation != g_notification_active_generation || g_notification_active_generation == 0; + if (stale_reply) { + // The request was cleared or superseded before Notify returned; close it immediately. + close_notification_id(reply_id); + watcher->deleteLater(); + return; + } + + g_notification_id = reply_id; + if (g_notification_handler != nullptr) { + g_notification_handler->notification_id = g_notification_id; + } + watcher->deleteLater(); + } + + bool send_dbus_notification( + const struct tray *tray, + const QString &title, + const QString &text, + const QString &icon, + const std::uint64_t notification_generation + ) { + QVariantMap hints; + if (!icon.isEmpty()) { + hints[QStringLiteral("image-path")] = icon; + } + + QDBusInterface iface( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications") + ); + if (!iface.isValid()) { + return false; + } + + QStringList actions; + if (tray->notification_cb != nullptr) { + actions << QStringLiteral("default") << QString(); + } + if (g_notification_handler != nullptr) { + g_notification_handler->cb = tray->notification_cb; + } + + QDBusPendingCall pending = iface.asyncCall( + QStringLiteral("Notify"), + QGuiApplication::applicationDisplayName(), + static_cast(0), + icon, + title, + text, + actions, + hints, + 5000 + ); + auto *watcher = new QDBusPendingCallWatcher(pending); // NOSONAR(cpp:S5025) - deleted via deleteLater in finished handler + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [notification_generation](QDBusPendingCallWatcher *finished) { + handle_notification_reply(finished, notification_generation); + }); + return true; + } + + void send_qt_notification_fallback(const struct tray *tray, const QString &title, const QString &text) { + if (tray->notification_cb != nullptr && g_notification_handler != nullptr) { + g_notification_handler->cb = tray->notification_cb; + QObject::connect(g_tray_icon, &QSystemTrayIcon::messageClicked, []() { + if (g_notification_handler == nullptr || g_notification_handler->cb == nullptr) { + return; + } + g_notification_handler->cb(); + }); + } + g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000); + } + + void update_notification(const struct tray *tray) { + const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString(); + reset_notification_state(); + if (text.isEmpty()) { + return; + } + + const std::uint64_t notification_generation = g_notification_generation; + g_notification_active_generation = notification_generation; + + const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString(); + const QString icon = resolve_notification_icon(tray); + + if (!send_dbus_notification(tray, title, text, icon, notification_generation)) { + // D-Bus may be unavailable; fall back to Qt's built-in balloon. + send_qt_notification_fallback(tray, title, text); + } + } + + void update_tray_state(const struct tray *tray) { + if (g_tray_icon == nullptr) { + return; + } + + QIcon tray_icon = resolve_tray_icon(tray); + if (tray_icon.isNull() && !g_tray_icon->icon().isNull()) { + tray_icon = g_tray_icon->icon(); + } + if (tray_icon.isNull()) { + tray_icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon); + } + if (!tray_icon.isNull()) { + g_tray_icon->setIcon(tray_icon); + } + + if (tray->tooltip != nullptr) { + g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip)); + } + + update_context_menu(tray); + update_notification(tray); + } + + void initialize_tray(struct tray *tray, int *result) { + destroy_tray(); + g_loop_result = 0; + g_exit_pending = false; + + g_tray_icon = new QSystemTrayIcon(); // NOSONAR(cpp:S5025) - raw pointer; deleted in destroy_tray() before QApplication + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + destroy_tray(); + *result = -1; + return; + } + + configure_app_metadata(tray); + connect_activation_handler(); + ensure_notification_handler_connected(); + update_tray_state(tray); + g_tray_icon->show(); + } + + void destroy_tray() { + reset_notification_state(); + if (g_tray_icon != nullptr) { + g_tray_icon->hide(); + QMenu *menu = g_tray_icon->contextMenu(); + g_tray_icon->setContextMenu(nullptr); + delete g_tray_icon; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication is destroyed + g_tray_icon = nullptr; + if (menu != nullptr) { + menu->hide(); + delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu + } + } + } + + void destroy_app() { + if (g_notification_handler != nullptr) { + delete g_notification_handler; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication + g_notification_handler = nullptr; + } + if (g_app_owned && g_app) { + // Destroy QApplication here (during active program execution) rather than letting + // the unique_ptr destructor run at static-destruction time. At static-destruction + // time, Qt's lazily-initialized D-Bus statics have already been destroyed (LIFO + // order), so calling QApplication::~QApplication() then would crash. + g_app.reset(); + g_app_owned = false; + } + } +} // namespace + +extern "C" { + + int tray_init(struct tray *tray) { + if (QApplication::instance() == nullptr) { + if (try_autodiscover_wayland_display() && g_log_cb != nullptr) { + g_log_cb(1, "Qt tray: auto-discovered WAYLAND_DISPLAY from XDG_RUNTIME_DIR"); + } + if (should_force_headless_qpa_fallback()) { + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("minimal")); + if (g_log_cb != nullptr) { + g_log_cb(2, "Qt tray: no reachable WAYLAND_DISPLAY or DISPLAY endpoint, forcing QT_QPA_PLATFORM=minimal"); + } + } + static int argc = 0; + g_app = std::make_unique(argc, nullptr); + g_app_owned = true; + } + + int result = 0; + run_on_qt_thread([tray, &result]() { + initialize_tray(tray, &result); + }); + return result; + } + + int tray_loop(int blocking) { + if (g_exit_pending) { + g_exit_pending = false; + run_on_qt_thread([]() { + destroy_tray(); + destroy_app(); + }); + return g_loop_result; + } + + if (blocking) { + if (g_app_owned) { + QApplication::exec(); + if (g_exit_pending) { + g_exit_pending = false; + destroy_tray(); + destroy_app(); + } + } else { + // An external event loop owns Qt processing; block until tray_exit() fires. + while (!g_exit_pending) { + QThread::msleep(10); + } + g_exit_pending = false; + run_on_qt_thread([]() { + destroy_tray(); + destroy_app(); + }); + } + } else { + if (g_app_owned) { + QApplication::processEvents(); + } else { + const QCoreApplication *app_inst = QCoreApplication::instance(); + if (app_inst != nullptr && QThread::currentThread() == app_inst->thread()) { + QApplication::processEvents(); + } + // On a non-Qt thread with an external app the external event loop handles processing. + } + } + return g_loop_result; + } + + void tray_update(struct tray *tray) { // NOSONAR(cpp:S995) - C API requires this exact mutable-pointer signature + run_on_qt_thread([tray]() { + update_tray_state(tray); + }); + } + + void tray_show_menu(void) { + run_on_qt_thread([]() { + if (g_tray_icon != nullptr) { + const QMenu *menu = g_tray_icon->contextMenu(); + if (menu != nullptr) { + popup_menu_for_activation(QPoint()); + QApplication::processEvents(); + } + } + }); + } + + void tray_simulate_notification_click(void) { + run_on_qt_thread([]() { + if (g_notification_handler != nullptr && g_notification_handler->cb != nullptr) { + if (g_notification_handler->notification_id != 0) { + // Simulate the D-Bus ActionInvoked signal for the current notification. + g_notification_handler->onActionInvoked( + g_notification_handler->notification_id, + QStringLiteral("default") + ); + } else { + // Fallback path (no D-Bus): invoke the callback directly. + g_notification_handler->cb(); + } + } + }); + } + + void tray_simulate_menu_item_click(int index) { + run_on_qt_thread([index]() { + if (g_tray_icon == nullptr || index < 0) { + return; + } + const QMenu *menu = g_tray_icon->contextMenu(); + if (menu == nullptr) { + return; + } + const QList actions = menu->actions(); + if (index >= actions.size()) { + return; + } + QAction *action = actions.at(index); + if (action == nullptr || action->isSeparator() || action->menu() != nullptr || !action->isEnabled()) { + return; + } + action->trigger(); + }); + } + + void tray_exit(void) { + g_loop_result = -1; + g_exit_pending = true; + if (g_app_owned) { + run_on_qt_thread([]() { + if (QApplication::instance() != nullptr) { + QApplication::quit(); + } + }); + } + } + + void tray_set_log_callback(void (*cb)(int level, const char *msg)) { // NOSONAR(cpp:S5205) - C API requires a plain function pointer callback type + g_log_cb = cb; + if (cb != nullptr) { + qInstallMessageHandler(qt_message_handler); + } else { + qInstallMessageHandler(nullptr); + } + } + + void tray_set_app_info(const char *app_name, const char *app_display_name, const char *desktop_name) { + g_app_name = app_name != nullptr ? QString::fromUtf8(app_name) : QString(); + g_app_display_name = app_display_name != nullptr ? QString::fromUtf8(app_display_name) : QString(); + g_desktop_name = desktop_name != nullptr ? QString::fromUtf8(desktop_name) : QString(); + } + +} // extern "C" + +// Must be included at the end of a .cpp file when Q_OBJECT classes are defined +// in that .cpp (not in a header). AUTOMOC sees this directive and generates +// tray_linux.moc, which is then inlined here at compile time. +#include "tray_linux.moc" diff --git a/src/tray_windows.c b/src/tray_windows.c index 9f0d14e..bae5684 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -3,6 +3,12 @@ * @brief System tray implementation for Windows. */ // standard includes +#ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN ///< Excludes rarely used APIs from Windows headers. +#endif +#ifndef NOMINMAX + #define NOMINMAX ///< Prevents Windows.h from defining min and max macros. +#endif #include // clang-format off // build fails if shellapi.h is included before Windows.h @@ -331,6 +337,28 @@ 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); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f12b98a..b48cac6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,21 +3,19 @@ cmake_minimum_required(VERSION 3.13) project(test_tray) -include_directories("${CMAKE_SOURCE_DIR}") - # Add GoogleTest directory to the project -set(GTEST_SOURCE_DIR "${CMAKE_SOURCE_DIR}/third-party/googletest") +set(GTEST_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third-party/googletest") + +# For Windows: prevent overriding parent compiler/linker runtime settings. +if(WIN32) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 +endif() + set(INSTALL_GTEST OFF) set(INSTALL_GMOCK OFF) add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest") include_directories("${GTEST_SOURCE_DIR}/googletest/include" "${GTEST_SOURCE_DIR}") -# if windows -if (WIN32) - # For Windows: Prevent overriding the parent project's compiler/linker settings - set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 -endif () - # extra libraries for tests if (APPLE) set(TEST_LIBS "-framework Cocoa") @@ -26,23 +24,28 @@ elseif (WIN32) endif() file(GLOB_RECURSE TEST_SOURCES - ${CMAKE_SOURCE_DIR}/tests/conftest.cpp - ${CMAKE_SOURCE_DIR}/tests/utils.cpp - ${CMAKE_SOURCE_DIR}/tests/screenshot_utils.cpp - ${CMAKE_SOURCE_DIR}/tests/test_*.cpp) + "${CMAKE_CURRENT_SOURCE_DIR}/conftest.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/utils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/screenshot_utils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_*.cpp" +) add_executable(${PROJECT_NAME} ${TEST_SOURCES} - ${TRAY_SOURCES}) +) set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) -target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) +target_include_directories(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/..") target_link_libraries(${PROJECT_NAME} ${TEST_LIBS} - ${TRAY_EXTERNAL_LIBRARIES} + tray::tray gtest - gtest_main) # if we use this we don't need our own main function -target_compile_definitions(${PROJECT_NAME} PUBLIC ${TRAY_DEFINITIONS} ${TEST_DEFINITIONS}) -target_compile_options(${PROJECT_NAME} PRIVATE $<$:${TRAY_COMPILE_OPTIONS}>) + gtest_main # if we use this we don't need our own main function +) +target_compile_definitions(${PROJECT_NAME} PUBLIC ${TEST_DEFINITIONS}) target_link_options(${PROJECT_NAME} PRIVATE) -add_test(NAME ${PROJECT_NAME} COMMAND tray_test) +tray_copy_default_icons(${PROJECT_NAME}) + +add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 5bd0792..4a91562 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -16,7 +16,7 @@ // clang-format on #define TRAY_WINAPI 1 #elif defined(__linux__) || defined(linux) || defined(__linux) - #define TRAY_APPINDICATOR 1 + #define TRAY_QT 1 #elif defined(__APPLE__) || defined(__MACH__) #include #define TRAY_APPKIT 1 @@ -26,9 +26,11 @@ #include "src/tray.h" #include "tests/screenshot_utils.h" -#if TRAY_APPINDICATOR -constexpr const char *TRAY_ICON1 = "mail-message-new"; -constexpr const char *TRAY_ICON2 = "mail-message-new"; +#if TRAY_QT +constexpr const char *TRAY_ICON1 = "icon.png"; +constexpr const char *TRAY_ICON2 = "icon.png"; +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"; @@ -78,6 +80,18 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must if (!trayRunning) { return; } + + // Ensure per-test notification state is cleared before teardown so + // screenshot tests do not inherit prior notification popups. + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; + tray_update(&testTray); + for (int i = 0; i < 20; ++i) { + tray_loop(0); + } + tray_exit(); tray_loop(0); trayRunning = false; @@ -159,34 +173,43 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must GTEST_SKIP() << "Screenshot output path not initialized"; } -#if defined(TRAY_WINAPI) || defined(TRAY_APPKIT) // Ensure icon files exist in test binary directory std::filesystem::path projectRoot = testBinaryDir.parent_path(); - std::filesystem::path iconSource; - - if (std::filesystem::exists(projectRoot / "icons" / TRAY_ICON1)) { - iconSource = projectRoot / "icons" / TRAY_ICON1; - } else if (std::filesystem::exists(projectRoot / TRAY_ICON1)) { - iconSource = projectRoot / TRAY_ICON1; - } else if (std::filesystem::exists(std::filesystem::path(TRAY_ICON1))) { - iconSource = std::filesystem::path(TRAY_ICON1); - } + auto ensureIconInTestDir = [&projectRoot, this](const char *iconName) { + std::filesystem::path iconSource; + + if (std::filesystem::exists(projectRoot / "icons" / iconName)) { + iconSource = projectRoot / "icons" / iconName; + } else if (std::filesystem::exists(projectRoot / iconName)) { + iconSource = projectRoot / iconName; + } else if (std::filesystem::exists(std::filesystem::path(iconName))) { + iconSource = std::filesystem::path(iconName); + } - if (!iconSource.empty()) { - std::filesystem::path iconDest = testBinaryDir / TRAY_ICON1; - if (!std::filesystem::exists(iconDest)) { - std::error_code ec; - std::filesystem::copy_file(iconSource, iconDest, ec); - if (ec) { - std::cout << "Warning: Failed to copy icon file: " << ec.message() << std::endl; + if (!iconSource.empty()) { + std::filesystem::path iconDest = testBinaryDir / iconName; + if (!std::filesystem::exists(iconDest)) { + std::error_code ec; + std::filesystem::copy_file(iconSource, iconDest, ec); + if (ec) { + std::cout << "Warning: Failed to copy icon file: " << ec.message() << std::endl; + } } } - } + }; + + ensureIconInTestDir(TRAY_ICON1); +#if defined(TRAY_QT) + ensureIconInTestDir(TRAY_ICON_SVG); #endif trayRunning = false; testTray.icon = TRAY_ICON1; testTray.tooltip = "TestTray"; + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; testTray.menu = g_submenu; g_submenu[1].checked = 1; } @@ -199,7 +222,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Process pending events to allow tray icon to appear. // Call this ONLY before screenshots to ensure the icon is visible. void WaitForTrayReady() { -#if defined(TRAY_APPINDICATOR) +#if defined(TRAY_QT) for (int i = 0; i < 100; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(5)); @@ -405,7 +428,7 @@ TEST_F(TrayTest, TestNotificationCallback) { tray_update(&testTray); - // Note: callback would be invoked by user interaction in real scenario + // Note: callback would be invoked by user interaction in a real scenario // In test environment, we verify it's set correctly EXPECT_NE(testTray.notification_cb, nullptr); @@ -598,3 +621,129 @@ TEST_F(TrayTest, TestTrayShowMenu) { TEST_F(TrayTest, TestTrayExit) { tray_exit(); } + +#if defined(TRAY_QT) + +TEST_F(TrayTest, TestTrayIconThemed) { + testTray.icon = TRAY_ICON_THEMED; + int result = tray_init(&testTray); + trayRunning = (result == 0); + ASSERT_EQ(result, 0); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_themed")); + testTray.icon = TRAY_ICON1; +} + +TEST_F(TrayTest, 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. + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + captureMenuStateAndExit("tray_menu_left_click"); // NOSONAR(cpp:S6168) - helper uses std::thread for AppleClang 17 compatibility +} + +TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) { + // Regression test for: clicking a notification did not invoke the callback. + // On the D-Bus path, QSystemTrayIcon::messageClicked is never emitted; the + // callback must be routed through TrayNotificationHandler::onActionInvoked. + static bool callbackInvoked = false; + callbackInvoked = false; + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + testTray.notification_title = "Clickable Notification"; + testTray.notification_text = "Click to test callback"; + testTray.notification_icon = TRAY_ICON1; + testTray.notification_cb = []() { + callbackInvoked = true; + }; + tray_update(&testTray); + + // Allow the notification to be sent before simulating the click. + WaitForTrayReady(); + + tray_simulate_notification_click(); + tray_loop(0); + + EXPECT_TRUE(callbackInvoked); + + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; + tray_update(&testTray); +} + +TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { + static int callbackCount = 0; + callbackCount = 0; + + auto first_item_callback = [](struct tray_menu *item) { // NOSONAR(cpp:S1172) - unused param required by tray_menu.cb function pointer type + callbackCount++; + (void) item; + }; + + void (*original_cb)(struct tray_menu *) = testTray.menu[0].cb; + testTray.menu[0].cb = first_item_callback; + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + tray_simulate_menu_item_click(0); + tray_loop(0); + EXPECT_EQ(callbackCount, 1); + + testTray.notification_title = "Menu Callback Regression"; + testTray.notification_text = "Notification update should not break menu callbacks"; + testTray.notification_icon = TRAY_ICON1; + tray_update(&testTray); + WaitForTrayReady(); + + tray_simulate_menu_item_click(0); + tray_loop(0); + EXPECT_EQ(callbackCount, 2); + + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + tray_update(&testTray); + + testTray.menu[0].cb = original_cb; +} + +#endif // TRAY_QT diff --git a/tests/unit/test_tray_linux.cpp b/tests/unit/test_tray_linux.cpp new file mode 100644 index 0000000..47d3774 --- /dev/null +++ b/tests/unit/test_tray_linux.cpp @@ -0,0 +1,275 @@ +// test includes +#include "tests/conftest.cpp" + +// local includes +#include "src/tray.h" + +#if defined(__linux__) || defined(linux) || defined(__linux) + + // standard includes + #include + #include + #include + #include + +namespace { + int g_menu_callback_count = 0; + int g_notification_callback_count = 0; + int g_log_callback_count = 0; + + void menu_item_cb([[maybe_unused]] struct tray_menu *item) { + g_menu_callback_count++; + } + + void notification_cb() { + g_notification_callback_count++; + } + + void log_cb([[maybe_unused]] int level, [[maybe_unused]] const char *msg) { + g_log_callback_count++; + } +} // namespace + +class TrayLinuxCoverageTest: public LinuxTest { // NOSONAR(cpp:S3656) - fixture members/methods are accessed by TEST_F-generated subclasses +protected: // NOSONAR(cpp:S3656) - TEST_F requires protected fixture visibility + void SetUp() override { + LinuxTest::SetUp(); + + tray_set_log_callback(nullptr); + tray_set_app_info(nullptr, nullptr, nullptr); + + g_menu_callback_count = 0; + g_notification_callback_count = 0; + g_log_callback_count = 0; + + submenuItems = {{{.text = "Nested", .cb = menu_item_cb}, {.text = nullptr}}}; + + menuItems = {{{.text = "Clickable", .cb = menu_item_cb}, {.text = "-"}, {.text = "Submenu", .submenu = submenuItems.data()}, {.text = "Disabled", .disabled = 1, .cb = menu_item_cb}, {.text = "Second Clickable", .cb = menu_item_cb}, {.text = nullptr}}}; + + trayData.icon = "icon.png"; + trayData.tooltip = "Linux Tray Coverage"; + trayData.notification_icon = nullptr; + trayData.notification_text = nullptr; + trayData.notification_title = nullptr; + trayData.notification_cb = nullptr; + trayData.menu = menuItems.data(); + } + + void TearDown() override { + if (trayRunning) { + tray_exit(); + tray_loop(0); + trayRunning = false; + } + + tray_set_log_callback(nullptr); + LinuxTest::TearDown(); + } + + void InitTray() { + const int initResult = tray_init(&trayData); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + } + + void PumpEvents(int iterations = 20) { + for (int i = 0; i < iterations; i++) { + tray_loop(0); + } + } + + bool trayRunning {false}; + std::array menuItems {}; + std::array submenuItems {}; + struct tray trayData {}; +}; + +TEST_F(TrayLinuxCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { + InitTray(); + + tray_simulate_menu_item_click(-1); + tray_simulate_menu_item_click(99); + tray_simulate_menu_item_click(1); + tray_simulate_menu_item_click(2); + tray_simulate_menu_item_click(3); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 0); + + tray_simulate_menu_item_click(0); + tray_simulate_menu_item_click(4); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 2); +} + +TEST_F(TrayLinuxCoverageTest, ApiCallsAreNoOpsBeforeInit) { + tray_update(&trayData); + tray_show_menu(); + tray_simulate_menu_item_click(0); + tray_simulate_notification_click(); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 0); + EXPECT_EQ(g_notification_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, SimulateMenuClickWithNullMenuDoesNothing) { + trayData.menu = nullptr; + InitTray(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + + EXPECT_EQ(g_menu_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, SetAppInfoAppliesExplicitMetadata) { + tray_set_app_info("tray-linux-tests", "Tray Linux Tests", "tray-linux-tests.desktop"); + InitTray(); + + // Trigger an update to exercise metadata-dependent notification/tray code paths. + trayData.notification_title = "Metadata Test"; + trayData.notification_text = "Using explicit metadata"; + tray_update(&trayData); + PumpEvents(); +} + +TEST_F(TrayLinuxCoverageTest, SetAppInfoDefaultsUseFallbackValues) { + tray_set_app_info(nullptr, nullptr, nullptr); + trayData.tooltip = "Tooltip Display Name"; + InitTray(); + + trayData.notification_title = "Default Metadata Test"; + trayData.notification_text = "Using fallback metadata"; + tray_update(&trayData); + PumpEvents(); +} + +TEST_F(TrayLinuxCoverageTest, LogCallbackCanBeSetAndReset) { + InitTray(); + tray_set_log_callback(log_cb); + + // The callback is currently installed; this update path should remain stable. + trayData.tooltip = "Log callback installed"; + tray_update(&trayData); + PumpEvents(); + + EXPECT_EQ(g_log_callback_count, 0); + + tray_set_log_callback(nullptr); + + trayData.tooltip = "Log callback removed"; + tray_update(&trayData); + PumpEvents(); + + EXPECT_EQ(g_log_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, TrayExitCausesLoopToReturnExitCode) { + InitTray(); + + tray_exit(); + const int loopResult = tray_loop(0); + trayRunning = false; + + EXPECT_EQ(loopResult, -1); +} + +TEST_F(TrayLinuxCoverageTest, UpdateMenuStateWithSameLayoutKeepsCallbacksWorking) { + InitTray(); + + menuItems[0].text = "Clickable Renamed"; + menuItems[0].disabled = 1; + tray_update(&trayData); + PumpEvents(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(g_menu_callback_count, 0); + + menuItems[0].disabled = 0; + tray_update(&trayData); + PumpEvents(); + + tray_simulate_menu_item_click(0); + PumpEvents(); + EXPECT_EQ(g_menu_callback_count, 1); +} + +TEST_F(TrayLinuxCoverageTest, ResolveTrayIconFromIconPathArray) { + // Build a tray struct with iconPathCount/allIconPaths to exercise fallback icon resolution. + const size_t iconCount = 2; + const size_t bufSize = sizeof(struct tray) + iconCount * sizeof(const char *); + std::vector buf(bufSize, std::byte {0}); + auto *iconPathTray = reinterpret_cast(buf.data()); // NOSONAR(cpp:S3630) - reinterpret_cast is required to map a C flexible-array struct over raw storage + + iconPathTray->icon = "missing-icon-name"; + iconPathTray->tooltip = "Icon path fallback"; + iconPathTray->notification_icon = nullptr; + iconPathTray->notification_text = nullptr; + iconPathTray->notification_title = nullptr; + iconPathTray->notification_cb = nullptr; + iconPathTray->menu = menuItems.data(); + + const int countVal = static_cast(iconCount); + std::memcpy(const_cast(&iconPathTray->iconPathCount), &countVal, sizeof(countVal)); // NOSONAR(cpp:S859) - const member initialization is required for this C interop allocation pattern + const char *badIcon = "missing-icon-name"; + const char *goodIcon = "icon.png"; + std::memcpy(const_cast(&iconPathTray->allIconPaths[0]), &badIcon, sizeof(badIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries + std::memcpy(const_cast(&iconPathTray->allIconPaths[1]), &goodIcon, sizeof(goodIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries + + const int initResult = tray_init(iconPathTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + tray_update(iconPathTray); + PumpEvents(); +} + +TEST_F(TrayLinuxCoverageTest, NotificationWithoutCallbackDoesNotInvokeOnSimulation) { + InitTray(); + + trayData.notification_title = "No callback notification"; + trayData.notification_text = "Should not invoke callback"; + trayData.notification_icon = "icon.png"; + trayData.notification_cb = nullptr; + + tray_update(&trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + + EXPECT_EQ(g_notification_callback_count, 0); +} + +TEST_F(TrayLinuxCoverageTest, ClearingNotificationDisablesSimulatedClickCallback) { + InitTray(); + + trayData.notification_title = "Linux Notification"; + trayData.notification_text = "Notification body"; + trayData.notification_icon = "mail-message-new"; + trayData.notification_cb = notification_cb; + + tray_update(&trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + EXPECT_EQ(g_notification_callback_count, 1); + + trayData.notification_title = nullptr; + trayData.notification_text = nullptr; + trayData.notification_icon = nullptr; + trayData.notification_cb = nullptr; + + tray_update(&trayData); + PumpEvents(); + + tray_simulate_notification_click(); + PumpEvents(); + EXPECT_EQ(g_notification_callback_count, 1); +} + +#endif