fix(linux): migrate to qt tray (#104)

This commit is contained in:
David Lane authored and GitHub committed 2026-03-29 13:57:38 -04:00
1 parent 6fa152fcec
commit d05166f3bb
15 files changed
+1725 -498

No files matched your search

+41 -22
View File
@@ -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@0affa4f7bcb27562658960eee840eff8ff844578 # v2026.328.161128
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
+76 -42
View File
@@ -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}"
"$<TARGET_FILE_DIR:${target_name}>"
COMMENT "Copying ${icon_file} to $<TARGET_FILE_DIR:${target_name}>")
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
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>
$<INSTALL_INTERFACE:include>)
if(WIN32)
list(APPEND TRAY_DEFINITIONS TRAY_WINAPI=1 WIN32_LEAN_AND_MEAN NOMINMAX)
if(MSVC)
list(APPEND TRAY_COMPILE_OPTIONS "/MT$<$<CONFIG:Debug>: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)
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()
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)
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()
INSTALL(TARGETS tray tray DESTINATION lib)
IF(NOT WIN32)
INSTALL(FILES tray.h DESTINATION include)
ENDIF()
target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_DEFINITIONS})
target_compile_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS})
target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES})
target_link_libraries(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_LIBRARIES})
+22 -8
View File
@@ -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.
<div class="tabbed">
- <b class="tab-title">Arch</b>
```bash
sudo pacman -S libayatana-appindicator
# Qt6
sudo pacman -S qt6-base qt6-svg
# Qt5
sudo pacman -S qt5-base qt5-svg
```
- <b class="tab-title">Debian/Ubuntu</b>
```bash
sudo apt install libappindicator3-dev
# Qt6
sudo apt install qt6-base-dev qt6-svg-dev
# Qt5
sudo apt install qtbase5-dev libqt5svg5-dev
```
- <b class="tab-title">Fedora</b>
```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
```
</div>
-34
View File
@@ -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)
-55
View File
@@ -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 <rakuco@webkit.org>
# 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
)
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#2f6fed"/>
<path d="M18 34h8l6 8 14-20h8L32 52z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

+4 -17
View File
@@ -6,26 +6,15 @@
#include <stdio.h>
#include <string.h>
#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},
+50 -2
View File
@@ -5,7 +5,7 @@
#ifndef TRAY_H
#define TRAY_H
#if defined(TRAY_WINAPI)
#if defined(_WIN32)
#include <Windows.h>
#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.
+22
View File
@@ -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
-275
View File
@@ -1,275 +0,0 @@
/**
* @file src/tray_linux.c
* @brief System tray implementation for Linux.
*/
// standard includes
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
// local includes
#include "tray.h"
// lib includes
#ifdef TRAY_AYATANA_APPINDICATOR
#include <libayatana-appindicator/app-indicator.h>
#elif TRAY_LEGACY_APPINDICATOR
#include <libappindicator/app-indicator.h>
#endif
#ifndef IS_APP_INDICATOR
#define IS_APP_INDICATOR APP_IS_INDICATOR ///< Define IS_APP_INDICATOR for app-indicator compatibility.
#endif
#include <libnotify/notify.h>
// 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);
}
+1007
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -3,10 +3,17 @@
* @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 <Windows.h>
// clang-format off
// build fails if shellapi.h is included before Windows.h
#include <shellapi.h>
#include <stdlib.h>
// clang-format on
// local includes
@@ -331,6 +338,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);
+23 -20
View File
@@ -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 $<$<COMPILE_LANGUAGE:CXX>:${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})
+172 -23
View File
@@ -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 <Carbon/Carbon.h>
#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 @@ protected: // NOSONAR(cpp:S3656) - TEST_F generates subclasses that need access
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 @@ protected: // NOSONAR(cpp:S3656) - TEST_F generates subclasses that need access
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;
auto ensureIconInTestDir = [&projectRoot, this](const char *iconName) {
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);
}
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 @@ protected: // NOSONAR(cpp:S3656) - TEST_F generates subclasses that need access
// 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
+275
View File
@@ -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 <array>
#include <cstddef>
#include <cstring>
#include <vector>
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<struct tray_menu, 6> menuItems {};
std::array<struct tray_menu, 2> 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<std::byte> buf(bufSize, std::byte {0});
auto *iconPathTray = reinterpret_cast<struct tray *>(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<int>(iconCount);
std::memcpy(const_cast<int *>(&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<char **>(&iconPathTray->allIconPaths[0]), &badIcon, sizeof(badIcon)); // NOSONAR(cpp:S859) - required to initialize const flexible-array entries
std::memcpy(const_cast<char **>(&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