@@ -69,6 +71,39 @@ namespace {
uint g_notification_id = 0; // NOSONAR(cpp:S5421) - tracks last D-Bus notification ID for proper cleanup
void (*g_log_cb)(int, const char *) = nullptr; // NOSONAR(cpp:S5421) - mutable state, not const
+ bool is_wayland_session() {
+ const QString platform = QGuiApplication::platformName().toLower();
+ if (platform.contains(QStringLiteral("wayland"))) {
+ return true;
+ }
+ return !qgetenv("WAYLAND_DISPLAY").isEmpty();
+ }
+
+ 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.
@@ -114,7 +149,7 @@ namespace {
*
* @return The point at which to show the context menu.
*/
- QPoint calculateMenuPosition() {
+ QPoint calculateMenuPosition(const QPoint &preferred_pos = QPoint()) {
if (g_tray_icon != nullptr) {
const QRect iconGeo = g_tray_icon->geometry();
if (iconGeo.isValid()) {
@@ -122,40 +157,116 @@ namespace {
}
}
+ 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.
- const bool wayland_session = !qgetenv("WAYLAND_DISPLAY").isEmpty();
+ const bool wayland_session = is_wayland_session();
if (!wayland_session) {
// Pure Xorg: QCursor::pos() is accurate.
return QCursor::pos();
}
- // Wayland session fallback: infer the panel edge from available vs full screen
- // geometry and anchor the menu to that edge. popup() keeps the menu on-screen.
- QScreen *screen = QGuiApplication::primaryScreen();
- if (screen != nullptr) {
- const QRect full = screen->geometry();
- const QRect avail = screen->availableGeometry();
- if (avail.top() > full.top()) {
- // Panel at top (e.g., GNOME default): anchor below the panel at the right edge.
- return QPoint(avail.right(), avail.top());
+ const QPoint cursor_pos = QCursor::pos();
+ if (!cursor_pos.isNull()) {
+ QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos);
+ if (cursor_screen != nullptr) {
+ return cursor_pos;
}
- if (avail.bottom() < full.bottom()) {
- // Panel at the bottom (e.g., KDE Plasma default): popup() flips upward automatically.
- return QPoint(avail.right(), avail.bottom());
+ }
+
+ // Wayland session fallback: infer panel anchor from the relevant screen.
+ QScreen *screen = QGuiApplication::screenAt(cursor_pos);
+ if (screen == nullptr) {
+ screen = QGuiApplication::primaryScreen();
+ }
+ const QPoint anchored = screen_anchor_point(screen);
+ if (!anchored.isNull()) {
+ return anchored;
+ }
+
+ return cursor_pos;
+ }
+
+ QIcon icon_from_source(const QString &icon_source) {
+ if (icon_source.isEmpty()) {
+ return QIcon();
+ }
+
+ const QFileInfo icon_fi(icon_source);
+ if (icon_fi.exists()) {
+ const QString file_path = icon_fi.absoluteFilePath();
+ const QIcon file_icon(file_path);
+ if (!file_icon.isNull()) {
+ return file_icon;
}
- if (avail.left() > full.left()) {
- // Panel on the left.
- return QPoint(avail.left(), avail.bottom());
+
+ const QPixmap pixmap(file_path);
+ if (!pixmap.isNull()) {
+ QIcon icon;
+ icon.addPixmap(pixmap);
+ return icon;
}
- if (avail.right() < full.right()) {
- // Panel on the right.
- return QPoint(avail.right(), avail.bottom());
+ }
+
+ const QIcon themed = QIcon::fromTheme(icon_source);
+ if (!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;
}
}
- return QCursor::pos();
+ 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() {
@@ -260,28 +371,43 @@ extern "C" {
return -1;
}
+ if (QCoreApplication::applicationName().isEmpty()) {
+ QCoreApplication::setApplicationName(QStringLiteral("tray"));
+ }
+ if (QCoreApplication::applicationDisplayName().isEmpty()) {
+ const QString display_name =
+ (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray");
+ QCoreApplication::setApplicationDisplayName(display_name);
+ }
+ if (QGuiApplication::desktopFileName().isEmpty()) {
+ QString desktop_name = QCoreApplication::applicationName();
+ if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
+ desktop_name += QStringLiteral(".desktop");
+ }
+ QGuiApplication::setDesktopFileName(desktop_name);
+ }
+
// 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 to the next
- // event-loop iteration via QTimer::singleShot(0). Deferring allows any
+ // 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.
// activateWindow() gives the menu window X11 focus so that the subsequent
// XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg.
QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) {
- if (reason == QSystemTrayIcon::Trigger) {
- const QPoint pos = calculateMenuPosition();
- QTimer::singleShot(0, g_tray_icon, [pos]() {
- if (g_tray_icon != nullptr) {
- QMenu *menu = g_tray_icon->contextMenu();
- if (menu != nullptr && !menu->isVisible()) {
- menu->activateWindow();
- menu->popup(pos);
- }
- }
- });
+ const bool left_click_activation =
+ (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context);
+
+ if (!left_click_activation) {
+ return;
}
+
+ const QPoint click_pos = QCursor::pos();
+ QTimer::singleShot(30, g_tray_icon, [click_pos]() {
+ popup_menu_for_activation(click_pos);
+ });
});
// Defer D-Bus ActionInvoked handler setup to the first event-loop iteration.
@@ -336,26 +462,18 @@ extern "C" {
return;
}
- const QString icon_str = QString::fromUtf8(tray->icon);
- QIcon icon;
- const QFileInfo icon_fi(icon_str);
- if (icon_fi.exists()) {
- // Explicitly load via QPixmap so that the icon engine has pixmap data and
- // availableSizes() is populated immediately. QIcon(filename) lazy-loads the
- // pixmap, which leaves availableSizes() empty; Qt6's SNI tray backend then
- // sees no sizes and sends no icon data, causing the tray icon to be blank.
- const QPixmap pixmap(icon_fi.absoluteFilePath());
- if (!pixmap.isNull()) {
- icon.addPixmap(pixmap);
- }
- } else {
- icon = QIcon::fromTheme(icon_str);
+ 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);
}
// Only update the icon when the resolved icon is valid. Setting a null icon
// clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter
// about QIcon::fromTheme when the name is not found in the active theme).
- if (!icon.isNull()) {
- g_tray_icon->setIcon(icon);
+ if (!tray_icon.isNull()) {
+ g_tray_icon->setIcon(tray_icon);
}
if (tray->tooltip != nullptr) {
@@ -447,7 +565,7 @@ extern "C" {
if (g_tray_icon != nullptr) {
QMenu *menu = g_tray_icon->contextMenu();
if (menu != nullptr) {
- menu->popup(calculateMenuPosition());
+ popup_menu_for_activation(QPoint());
QApplication::processEvents();
}
}
diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp
index c2f9fe9..4d600cf 100644
--- a/tests/unit/test_tray.cpp
+++ b/tests/unit/test_tray.cpp
@@ -29,6 +29,7 @@
#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";
@@ -162,26 +163,33 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must
// 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;
@@ -610,6 +618,16 @@ TEST_F(TrayTest, TestTrayIconThemed) {
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);
From e79887d0f04da09b8b2da367f5268f6163644c20 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:33:39 -0400
Subject: [PATCH 12/23] CI: add Qt5/Qt6 matrix and conditional deps
Add a matrix dimension for Qt version and run builds for both Qt5 and Qt6 on Ubuntu. The build job name now includes the Qt version, and qt_version is set per matrix entry. Linux dependency installation is made conditional on QT_VERSION (installing Qt5 or Qt6 packages accordingly) and QT_VERSION is exported into the job environment. Split CMake configure and ninja build into separate steps, make test environment/platformtheme conditional, and append Qt version to artifact names and codecov flags. Update README to document Qt5 and Qt6 package installation commands for supported distros.
---
.github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++-----------
README.md | 21 ++++++++++++---
2 files changed, 60 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1d293e1..1c199fe 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 }})
+ name: Build (${{ matrix.os }}${{ matrix.qt_version && format(', Qt{0}', matrix.qt_version) || '' }})
defaults:
run:
shell: ${{ matrix.shell }}
@@ -49,10 +49,16 @@ jobs:
include:
- os: macos-latest
shell: "bash"
+ qt_version: ''
- os: ubuntu-latest
shell: "bash"
+ qt_version: '5'
+ - os: ubuntu-latest
+ shell: "bash"
+ qt_version: '6'
- os: windows-latest
shell: "msys2 {0}"
+ qt_version: ''
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -61,17 +67,36 @@ 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 \
- adwaita-icon-theme \
- build-essential \
- cmake \
- imagemagick \
- ninja-build \
- qt5-gtk-platformtheme \
- qtbase5-dev \
- xvfb
+ sudo apt-get install -y "${dependencies[@]}"
- name: Setup virtual desktop
if: runner.os == 'Linux'
@@ -162,7 +187,7 @@ jobs:
echo "python-path=${python_path}"
echo "python-path=${python_path}" >> "${GITHUB_OUTPUT}"
- - name: Build
+ - name: Configure
run: |
mkdir -p build
@@ -179,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'
@@ -222,7 +249,7 @@ jobs:
timeout-minutes: 3
working-directory: build/tests
env:
- QT_QPA_PLATFORMTHEME: gtk3
+ 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
@@ -231,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 }}
+ name: tray-screenshots-${{ runner.os }}${{ matrix.qt_version && format('-qt{0}', matrix.qt_version) || '' }}
path: build/tests/screenshots
if-no-files-found: error
@@ -258,7 +285,7 @@ jobs:
- name: Set codecov flags
id: codecov_flags
run: |
- flags="${{ runner.os }}"
+ flags="${{ runner.os }}${{ matrix.qt_version && format('-qt{0}', matrix.qt_version) || '' }}"
echo "flags=${flags}" >> "${GITHUB_OUTPUT}"
- name: Upload coverage
diff --git a/README.md b/README.md
index b104bb8..374478a 100644
--- a/README.md
+++ b/README.md
@@ -43,21 +43,36 @@ This fork adds the following features:
### Linux Dependencies
+Install either Qt6 _or_ Qt5 development packages. The Linux backend requires
+Qt Widgets, DBus, and Svg modules.
+
- Arch
```bash
- sudo pacman -S qt6-base
+ # Qt6
+ sudo pacman -S qt6-base qt6-svg
+
+ # Qt5
+ sudo pacman -S qt5-base qt5-svg
```
- Debian/Ubuntu
```bash
- sudo apt install qtbase5-dev
+ # Qt6
+ sudo apt install qt6-base-dev qt6-svg-dev
+
+ # Qt5
+ sudo apt install qtbase5-dev libqt5svg5-dev
```
- Fedora
```bash
- sudo dnf install qt6-qtbase-devel
+ # Qt6
+ sudo dnf install qt6-qtbase-devel qt6-qtsvg-devel
+
+ # Qt5
+ sudo dnf install qt5-qtbase-devel qt5-qtsvg-devel
```
From a1f0a56d6671ff62cb89a3d1f2307200c0e385c7 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:58:56 -0400
Subject: [PATCH 13/23] Use QGuiApplication for applicationDisplayName
Replace QCoreApplication::applicationDisplayName and setApplicationDisplayName with QGuiApplication equivalents in src/tray_linux.cpp. This ensures the GUI-specific API is used when initializing the tray's display name (from tooltip or default), avoiding misuse of QCoreApplication for GUI-only display name operations.
---
src/tray_linux.cpp | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index 2592c8a..7b794b3 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -374,10 +374,10 @@ extern "C" {
if (QCoreApplication::applicationName().isEmpty()) {
QCoreApplication::setApplicationName(QStringLiteral("tray"));
}
- if (QCoreApplication::applicationDisplayName().isEmpty()) {
+ if (QGuiApplication::applicationDisplayName().isEmpty()) {
const QString display_name =
(tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray");
- QCoreApplication::setApplicationDisplayName(display_name);
+ QGuiApplication::setApplicationDisplayName(display_name);
}
if (QGuiApplication::desktopFileName().isEmpty()) {
QString desktop_name = QCoreApplication::applicationName();
From 21f70b5d74572b6bff59d54a6c4eb55773e045ab Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Thu, 26 Mar 2026 17:18:14 -0400
Subject: [PATCH 14/23] Refactor CMake for subproject and icons
Make CMakeLists usable as a subproject: detect top-level build (TRAY_IS_TOP_LEVEL) and gate BUILD_DOCS/BUILD_TESTS/BUILD_EXAMPLE on it. Don't override parent CMAKE_MODULE_PATH; append module path relative to current source dir. Centralize icon paths into TRAY_ICON_* variables, add tray_copy_default_icons() to copy icons at post-build, and install icons/headers. Use target_include_directories and expose compile definitions publicly for the library. Adjust Qt/Qt-version caching and other target properties.
Update tests/CMakeLists.txt to use CMAKE_CURRENT_SOURCE_DIR paths, reference the googletest directory relatively, force shared CRT on Windows earlier, link the test target against tray::tray, add icon copying for the test binary, and register the test with add_test.
---
CMakeLists.txt | 76 ++++++++++++++++++++++++++++++--------------
src/example.c | 21 +++---------
src/tray.h | 4 +--
src/tray_windows.c | 6 ++++
tests/CMakeLists.txt | 43 +++++++++++++------------
5 files changed, 88 insertions(+), 62 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e2f2fa4..2feac35 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -9,21 +9,25 @@ project(tray VERSION 0.0.0
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)
@@ -33,11 +37,36 @@ find_package(PkgConfig)
file(GLOB TRAY_SOURCES
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.h"
- "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.ico"
- "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.png"
- "${CMAKE_CURRENT_SOURCE_DIR}/icons/*.svg"
)
+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_CURRENT_SOURCE_DIR}/src/tray_windows.c")
else()
@@ -53,6 +82,10 @@ 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()
@@ -62,19 +95,20 @@ 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 14)
+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_DEFINITIONS TRAY_QT=1)
if(TRAY_QT_VERSION EQUAL 6)
list(APPEND TRAY_EXTERNAL_LIBRARIES Qt6::Widgets Qt6::DBus Qt6::Svg)
else()
@@ -86,20 +120,16 @@ endif()
add_library(tray::tray ALIAS ${PROJECT_NAME})
-add_executable(tray_example "${CMAKE_CURRENT_SOURCE_DIR}/src/example.c")
-target_link_libraries(tray_example tray::tray)
-
-configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.ico" "${CMAKE_CURRENT_BINARY_DIR}/icon.ico" COPYONLY)
-configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.png" "${CMAKE_CURRENT_BINARY_DIR}/icon.png" COPYONLY)
-configure_file("${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg" "${CMAKE_CURRENT_BINARY_DIR}/icon.svg" COPYONLY)
+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()
INSTALL(TARGETS tray tray DESTINATION lib)
+INSTALL(FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray.h" DESTINATION include)
+INSTALL(FILES ${TRAY_ICON_FILES} DESTINATION share/tray/icons)
-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})
diff --git a/src/example.c b/src/example.c
index 7198593..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_QT 1
-#elif defined(__APPLE__) || defined(__MACH__)
- #define TRAY_APPKIT 1
-#endif
-
// local includes
#include "tray.h"
-#if TRAY_QT
- #define TRAY_ICON1 "icon.png"
- #define TRAY_ICON2 "icon.png"
-#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 52d02a8..b1f5967 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
@@ -98,7 +98,7 @@ extern "C" {
*/
void tray_set_log_callback(void (*cb)(int level, const char *msg));
-#if defined(TRAY_WINAPI)
+#if defined(_WIN32)
/**
* @brief Get the tray window handle.
* @return The window handle.
diff --git a/src/tray_windows.c b/src/tray_windows.c
index bcc7eec..cf2b2a6 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
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})
From 437250060b78e5d54b93d7d1fd45e899b6a564f4 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Thu, 26 Mar 2026 22:20:07 -0400
Subject: [PATCH 15/23] Add tray_set_app_info and Qt thread fixes
Add a new API tray_set_app_info to provide application metadata (name, display name, desktop file) used by the Linux/Qt backend and add no-op stubs for macOS and Windows. Improve Qt integration on Linux: add run_on_qt_thread helper, make g_exit_pending atomic, ensure all Qt GUI and teardown operations run on the Qt thread, and handle external event loops safely. Update notification/D-Bus handling and move tray updates, menu popup and simulated notification clicks onto the Qt thread to avoid cross-thread warnings. Also remove the QUIET flag from find_package(Qt6) in CMake and add necessary includes (atomic, utility, QThread).
---
CMakeLists.txt | 2 +-
src/tray.h | 15 ++
src/tray_darwin.m | 7 +
src/tray_linux.cpp | 439 +++++++++++++++++++++++++++------------------
src/tray_windows.c | 7 +
5 files changed, 290 insertions(+), 180 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2feac35..959a780 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -75,7 +75,7 @@ else()
find_library(COCOA Cocoa REQUIRED)
list(APPEND TRAY_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray_darwin.m")
else()
- find_package(Qt6 QUIET COMPONENTS Widgets DBus Svg)
+ find_package(Qt6 COMPONENTS Widgets DBus Svg)
if(Qt6_FOUND)
set(TRAY_QT_VERSION 6)
else()
diff --git a/src/tray.h b/src/tray.h
index b1f5967..29a82a1 100644
--- a/src/tray.h
+++ b/src/tray.h
@@ -98,6 +98,21 @@ extern "C" {
*/
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. NULL uses the default ("tray").
+ * @param app_display_name Display name shown in notifications. 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.
diff --git a/src/tray_darwin.m b/src/tray_darwin.m
index 2fe9b35..7320a4f 100644
--- a/src/tray_darwin.m
+++ b/src/tray_darwin.m
@@ -140,6 +140,13 @@ void tray_set_log_callback(void (*cb)(int level, const char *msg)) {
(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.cpp b/src/tray_linux.cpp
index 7b794b3..aac8faa 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -3,8 +3,10 @@
* @brief System tray implementation for Linux using Qt.
*/
// standard includes
+#include
#include
#include
+#include
// local includes
#include "tray.h"
@@ -24,6 +26,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -67,9 +70,36 @@ namespace {
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
- bool g_exit_pending = 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
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() {
const QString platform = QGuiApplication::platformName().toLower();
@@ -360,237 +390,282 @@ extern "C" {
g_app_owned = true;
}
- 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()) {
+ int result = 0;
+ run_on_qt_thread([tray, &result]() {
destroy_tray();
- return -1;
- }
+ g_loop_result = 0;
+ g_exit_pending = false;
- if (QCoreApplication::applicationName().isEmpty()) {
- QCoreApplication::setApplicationName(QStringLiteral("tray"));
- }
- if (QGuiApplication::applicationDisplayName().isEmpty()) {
- const QString display_name =
- (tray != nullptr && tray->tooltip != nullptr) ? QString::fromUtf8(tray->tooltip) : QStringLiteral("tray");
- QGuiApplication::setApplicationDisplayName(display_name);
- }
- if (QGuiApplication::desktopFileName().isEmpty()) {
- QString desktop_name = QCoreApplication::applicationName();
- if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
- desktop_name += QStringLiteral(".desktop");
- }
- QGuiApplication::setDesktopFileName(desktop_name);
- }
-
- // 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.
- // activateWindow() gives the menu window X11 focus so that the subsequent
- // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg.
- QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) {
- const bool left_click_activation =
- (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context);
-
- if (!left_click_activation) {
+ g_tray_icon = new QSystemTrayIcon(); // NOSONAR(cpp:S5025) - raw pointer; deleted in destroy_tray() before QApplication
+
+ if (!QSystemTrayIcon::isSystemTrayAvailable()) {
+ destroy_tray();
+ result = -1;
return;
}
- const QPoint click_pos = QCursor::pos();
- QTimer::singleShot(30, g_tray_icon, [click_pos]() {
- popup_menu_for_activation(click_pos);
- });
- });
+ 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()) {
+ if (!g_desktop_name.isEmpty()) {
+ QGuiApplication::setDesktopFileName(g_desktop_name);
+ } else {
+ QString desktop_name = QCoreApplication::applicationName();
+ if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
+ desktop_name += QStringLiteral(".desktop");
+ }
+ QGuiApplication::setDesktopFileName(desktop_name);
+ }
+ }
- // 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. Deferring via QTimer::singleShot
- // ensures the socket notifiers are created while the event dispatcher is active.
- if (g_notification_handler == nullptr) {
- g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app()
- QTimer::singleShot(0, g_notification_handler, []() {
- if (g_notification_handler != nullptr) {
- QDBusConnection::sessionBus().connect(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("ActionInvoked"),
- g_notification_handler,
- SLOT(onActionInvoked(uint, QString))
- );
+ // 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.
+ // activateWindow() gives the menu window X11 focus so that the subsequent
+ // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg.
+ QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) {
+ const bool left_click_activation =
+ (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context);
+
+ if (!left_click_activation) {
+ return;
}
+
+ const QPoint click_pos = QCursor::pos();
+ QTimer::singleShot(30, g_tray_icon, [click_pos]() {
+ popup_menu_for_activation(click_pos);
+ });
});
- }
- tray_update(tray);
- g_tray_icon->show();
- return 0;
+ // 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. Deferring via QTimer::singleShot
+ // ensures the socket notifiers are created while the event dispatcher is active.
+ if (g_notification_handler == nullptr) {
+ g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app()
+ QTimer::singleShot(0, g_notification_handler, []() {
+ if (g_notification_handler != nullptr) {
+ QDBusConnection::sessionBus().connect(
+ QStringLiteral("org.freedesktop.Notifications"),
+ QStringLiteral("/org/freedesktop/Notifications"),
+ QStringLiteral("org.freedesktop.Notifications"),
+ QStringLiteral("ActionInvoked"),
+ g_notification_handler,
+ SLOT(onActionInvoked(uint, QString))
+ );
+ }
+ });
+ }
+
+ tray_update(tray);
+ g_tray_icon->show();
+ });
+ return result;
}
int tray_loop(int blocking) {
if (g_exit_pending) {
g_exit_pending = false;
- destroy_tray();
- destroy_app();
+ run_on_qt_thread([]() {
+ destroy_tray();
+ destroy_app();
+ });
return g_loop_result;
}
if (blocking) {
- QApplication::exec();
- if (g_exit_pending) {
+ 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;
- destroy_tray();
- destroy_app();
+ run_on_qt_thread([]() {
+ destroy_tray();
+ destroy_app();
+ });
}
} else {
- QApplication::processEvents();
+ if (g_app_owned) {
+ QApplication::processEvents();
+ } else {
+ 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) {
- if (g_tray_icon == nullptr) {
- return;
- }
+ run_on_qt_thread([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);
- }
- // Only update the icon when the resolved icon is valid. Setting a null icon
- // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter
- // about QIcon::fromTheme when the name is not found in the active theme).
- if (!tray_icon.isNull()) {
- g_tray_icon->setIcon(tray_icon);
- }
+ 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);
+ }
+ // Only update the icon when the resolved icon is valid. Setting a null icon
+ // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter
+ // about QIcon::fromTheme when the name is not found in the active theme).
+ if (!tray_icon.isNull()) {
+ g_tray_icon->setIcon(tray_icon);
+ }
- if (tray->tooltip != nullptr) {
- g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip));
- }
+ if (tray->tooltip != nullptr) {
+ g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip));
+ }
- if (tray->menu != nullptr) {
- // setContextMenu does not take ownership; delete the old menu before replacing it.
- QMenu *old_menu = g_tray_icon->contextMenu();
- QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update
- g_tray_icon->setContextMenu(new_menu);
- delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this
- }
+ if (tray->menu != nullptr) {
+ // setContextMenu does not take ownership; delete the old menu before replacing it.
+ QMenu *old_menu = g_tray_icon->contextMenu();
+ QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update
+ g_tray_icon->setContextMenu(new_menu);
+ delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this
+ }
- const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString();
+ const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString();
- // Reset previous notification state before setting up the new one.
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = 0;
- g_notification_handler->cb = nullptr;
- }
- QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr);
- close_notification();
-
- if (!text.isEmpty()) {
- const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString();
- const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon;
- QString icon;
- if (icon_path != nullptr) {
- QFileInfo fi(QString::fromUtf8(icon_path));
- icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path);
- }
- QVariantMap hints;
- if (!icon.isEmpty()) {
- hints[QStringLiteral("image-path")] = icon;
+ // Reset previous notification state before setting up the new one.
+ if (g_notification_handler != nullptr) {
+ g_notification_handler->notification_id = 0;
+ g_notification_handler->cb = nullptr;
}
-
- QDBusInterface iface(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications")
- );
- if (iface.isValid()) {
- // Include the "default" action so that clicking the notification body fires ActionInvoked.
- // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications,
- // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead.
- QStringList actions;
- if (tray->notification_cb != nullptr) {
- actions << QStringLiteral("default") << QString();
+ QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr);
+ close_notification();
+
+ if (!text.isEmpty()) {
+ const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString();
+ const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon;
+ QString icon;
+ if (icon_path != nullptr) {
+ QFileInfo fi(QString::fromUtf8(icon_path));
+ icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path);
}
- // Store the callback before calling Notify so tray_simulate_notification_click works
- // even when the notification daemon is unavailable and the D-Bus reply is invalid.
- if (g_notification_handler != nullptr) {
- g_notification_handler->cb = tray->notification_cb;
+ QVariantMap hints;
+ if (!icon.isEmpty()) {
+ hints[QStringLiteral("image-path")] = icon;
}
- QDBusReply reply = iface.call(
- QStringLiteral("Notify"),
- QStringLiteral("tray"),
- static_cast(0),
- icon,
- title,
- text,
- actions,
- hints,
- 5000
+
+ QDBusInterface iface(
+ QStringLiteral("org.freedesktop.Notifications"),
+ QStringLiteral("/org/freedesktop/Notifications"),
+ QStringLiteral("org.freedesktop.Notifications")
);
- if (reply.isValid()) {
- g_notification_id = reply.value();
+ if (iface.isValid()) {
+ // Include the "default" action so that clicking the notification body fires ActionInvoked.
+ // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications,
+ // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead.
+ QStringList actions;
+ if (tray->notification_cb != nullptr) {
+ actions << QStringLiteral("default") << QString();
+ }
+ // Store the callback before calling Notify so tray_simulate_notification_click works
+ // even when the notification daemon is unavailable and the D-Bus reply is invalid.
if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = g_notification_id;
+ g_notification_handler->cb = tray->notification_cb;
}
- }
- } else {
- // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal.
- 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) {
- g_notification_handler->cb();
+ QDBusReply reply = iface.call(
+ QStringLiteral("Notify"),
+ QStringLiteral("tray"),
+ static_cast(0),
+ icon,
+ title,
+ text,
+ actions,
+ hints,
+ 5000
+ );
+ if (reply.isValid()) {
+ g_notification_id = reply.value();
+ if (g_notification_handler != nullptr) {
+ g_notification_handler->notification_id = g_notification_id;
}
- });
+ }
+ } else {
+ // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal.
+ 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) {
+ g_notification_handler->cb();
+ }
+ });
+ }
+ g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000);
}
- g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000);
}
- }
+ });
}
void tray_show_menu(void) {
- if (g_tray_icon != nullptr) {
- QMenu *menu = g_tray_icon->contextMenu();
- if (menu != nullptr) {
- popup_menu_for_activation(QPoint());
- QApplication::processEvents();
+ run_on_qt_thread([]() {
+ if (g_tray_icon != nullptr) {
+ QMenu *menu = g_tray_icon->contextMenu();
+ if (menu != nullptr) {
+ popup_menu_for_activation(QPoint());
+ QApplication::processEvents();
+ }
}
- }
+ });
}
void tray_simulate_notification_click(void) {
- 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();
+ 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_exit(void) {
g_loop_result = -1;
g_exit_pending = true;
- if (g_app_owned && QApplication::instance() != nullptr) {
- QApplication::quit();
+ if (g_app_owned) {
+ run_on_qt_thread([]() {
+ if (QApplication::instance() != nullptr) {
+ QApplication::quit();
+ }
+ });
}
}
@@ -603,6 +678,12 @@ extern "C" {
}
}
+ 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
diff --git a/src/tray_windows.c b/src/tray_windows.c
index cf2b2a6..87451b1 100644
--- a/src/tray_windows.c
+++ b/src/tray_windows.c
@@ -347,6 +347,13 @@ void tray_set_log_callback(void (*cb)(int level, const char *msg)) {
(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);
From 214ea00120fcd7cbe0a68e994a53d7318ee33643 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Thu, 26 Mar 2026 23:22:35 -0400
Subject: [PATCH 16/23] Fix tray callback issues
---
src/tray.h | 19 ++++-
src/tray_darwin.m | 5 ++
src/tray_linux.cpp | 156 +++++++++++++++++++++++++++++++++------
src/tray_windows.c | 5 ++
tests/unit/test_tray.cpp | 38 ++++++++++
5 files changed, 197 insertions(+), 26 deletions(-)
diff --git a/src/tray.h b/src/tray.h
index 29a82a1..513ffc3 100644
--- a/src/tray.h
+++ b/src/tray.h
@@ -81,6 +81,17 @@ extern "C" {
*/
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.
*/
@@ -105,9 +116,11 @@ extern "C" {
* 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. NULL uses the default ("tray").
- * @param app_display_name Display name shown in notifications. NULL derives
- * from the tray tooltip or falls back to app_name.
+ * @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.
*/
diff --git a/src/tray_darwin.m b/src/tray_darwin.m
index 7320a4f..ca811cf 100644
--- a/src/tray_darwin.m
+++ b/src/tray_darwin.m
@@ -135,6 +135,11 @@ void tray_simulate_notification_click(void) {
// 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;
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index aac8faa..b26533e 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -12,12 +12,15 @@
#include "tray.h"
// Qt includes
+#include
#include
#include
#include
#include
#include
-#include
+#include
+#include
+#include
#include
#include
#include
@@ -303,15 +306,16 @@ namespace {
if (g_notification_id == 0) {
return;
}
+ const uint id_to_close = g_notification_id;
+ g_notification_id = 0;
QDBusInterface iface(
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("/org/freedesktop/Notifications"),
QStringLiteral("org.freedesktop.Notifications")
);
if (iface.isValid()) {
- iface.call(QStringLiteral("CloseNotification"), g_notification_id);
+ iface.asyncCall(QStringLiteral("CloseNotification"), id_to_close);
}
- g_notification_id = 0;
}
QMenu *build_menu(struct tray_menu *m, QWidget *parent) {
@@ -322,7 +326,8 @@ namespace {
} else if (m->submenu != nullptr) {
QMenu *sub = build_menu(m->submenu, menu);
sub->setTitle(QString::fromUtf8(m->text));
- menu->addMenu(sub);
+ 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);
@@ -330,17 +335,79 @@ namespace {
action->setCheckable(true);
action->setChecked(m->checked != 0);
}
- if (m->cb != nullptr) {
- struct tray_menu *item = m;
- QObject::connect(action, &QAction::triggered, [item]() {
+ action->setData(QVariant::fromValue(reinterpret_cast(m)));
+ QObject::connect(action, &QAction::triggered, menu, [action]() {
+ auto *item = reinterpret_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;
+ }
+
+ QAction *action = actions.at(action_index++);
+ if (std::strcmp(item->text, "-") == 0) {
+ if (!action->isSeparator()) {
+ return false;
+ }
+ continue;
+ }
+
+ if (item->submenu != nullptr) {
+ 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(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++) {
+ QAction *action = actions.at(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(reinterpret_cast(item)));
+ }
+ }
+
void destroy_tray() {
close_notification();
if (g_notification_handler != nullptr) {
@@ -353,7 +420,10 @@ namespace {
g_tray_icon->setContextMenu(nullptr);
delete g_tray_icon; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication is destroyed
g_tray_icon = nullptr;
- delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu
+ if (menu != nullptr) {
+ menu->hide();
+ delete menu; // NOSONAR(cpp:S5025) - QSystemTrayIcon does not own the context menu
+ }
}
}
@@ -547,11 +617,21 @@ extern "C" {
}
if (tray->menu != nullptr) {
- // setContextMenu does not take ownership; delete the old menu before replacing it.
- QMenu *old_menu = g_tray_icon->contextMenu();
- QMenu *new_menu = build_menu(tray->menu, nullptr); // NOSONAR(cpp:S5025) - deleted via old_menu path or on next update
- g_tray_icon->setContextMenu(new_menu);
- delete old_menu; // NOSONAR(cpp:S5025) - required; Qt does not own this
+ 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);
+ } else {
+ // 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 old_menu path or on next update
+ g_tray_icon->setContextMenu(new_menu);
+ if (existing_menu != nullptr) {
+ // 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
+ }
+ }
}
const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString();
@@ -590,14 +670,18 @@ extern "C" {
if (tray->notification_cb != nullptr) {
actions << QStringLiteral("default") << QString();
}
- // Store the callback before calling Notify so tray_simulate_notification_click works
+ // Store the callback before the async Notify so tray_simulate_notification_click works
// even when the notification daemon is unavailable and the D-Bus reply is invalid.
if (g_notification_handler != nullptr) {
g_notification_handler->cb = tray->notification_cb;
}
- QDBusReply reply = iface.call(
+ // Use asyncCall to avoid entering a nested Qt event loop while waiting for the D-Bus
+ // reply. A synchronous call here would allow re-entrant tray_update dispatches from
+ // other threads (via BlockingQueuedConnection), which can delete and replace the context
+ // QMenu mid-build, breaking all QAction signal connections.
+ QDBusPendingCall pending = iface.asyncCall(
QStringLiteral("Notify"),
- QStringLiteral("tray"),
+ QGuiApplication::applicationDisplayName(),
static_cast(0),
icon,
title,
@@ -606,12 +690,17 @@ extern "C" {
hints,
5000
);
- if (reply.isValid()) {
- g_notification_id = reply.value();
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = g_notification_id;
+ auto *watcher = new QDBusPendingCallWatcher(pending); // NOSONAR(cpp:S5025) - deleted via deleteLater in finished handler
+ QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [watcher]() {
+ const QDBusPendingReply reply = *watcher;
+ if (reply.isValid() && g_tray_icon != nullptr) {
+ g_notification_id = reply.value();
+ if (g_notification_handler != nullptr) {
+ g_notification_handler->notification_id = g_notification_id;
+ }
}
- }
+ watcher->deleteLater();
+ });
} else {
// D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal.
if (tray->notification_cb != nullptr && g_notification_handler != nullptr) {
@@ -657,6 +746,27 @@ extern "C" {
});
}
+ void tray_simulate_menu_item_click(int index) {
+ run_on_qt_thread([index]() {
+ if (g_tray_icon == nullptr || index < 0) {
+ return;
+ }
+ 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;
diff --git a/src/tray_windows.c b/src/tray_windows.c
index 87451b1..bae5684 100644
--- a/src/tray_windows.c
+++ b/src/tray_windows.c
@@ -342,6 +342,11 @@ void tray_simulate_notification_click(void) {
// 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;
diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp
index 4d600cf..b4aebc2 100644
--- a/tests/unit/test_tray.cpp
+++ b/tests/unit/test_tray.cpp
@@ -692,4 +692,42 @@ TEST_F(TrayTest, TestNotificationCallbackFiredOnClick) {
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
From 144dad5123dd329425f10bc6b9050ca914a09911 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Fri, 27 Mar 2026 11:42:20 -0400
Subject: [PATCH 17/23] style: sonar fixes
---
CMakeLists.txt | 2 +-
src/tray_linux.cpp | 517 +++++++++++++++++++++------------------
tests/unit/test_tray.cpp | 2 +-
3 files changed, 281 insertions(+), 240 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 959a780..e1beb35 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -94,7 +94,7 @@ 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 14)
+set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 17)
target_include_directories(${PROJECT_NAME}
PUBLIC
$
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index b26533e..faa13b0 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -32,6 +32,7 @@
#include
#include
#include
+#include
#include
/**
@@ -60,7 +61,7 @@ public slots:
* @param id The notification ID.
* @param action_key The action key that was triggered.
*/
- void onActionInvoked(uint id, const QString &action_key) {
+ void onActionInvoked(uint id, const QString &action_key) const {
if (id == notification_id && cb != nullptr && action_key == QLatin1String("default")) {
cb();
}
@@ -105,8 +106,8 @@ namespace {
}
bool is_wayland_session() {
- const QString platform = QGuiApplication::platformName().toLower();
- if (platform.contains(QStringLiteral("wayland"))) {
+ if (const QString platform = QGuiApplication::platformName().toLower();
+ platform.contains(QStringLiteral("wayland"))) {
return true;
}
return !qgetenv("WAYLAND_DISPLAY").isEmpty();
@@ -197,27 +198,25 @@ namespace {
// 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.
- const bool wayland_session = is_wayland_session();
- if (!wayland_session) {
+ 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()) {
- QScreen *cursor_screen = QGuiApplication::screenAt(cursor_pos);
+ 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.
- QScreen *screen = QGuiApplication::screenAt(cursor_pos);
+ const QScreen *screen = QGuiApplication::screenAt(cursor_pos);
if (screen == nullptr) {
screen = QGuiApplication::primaryScreen();
}
- const QPoint anchored = screen_anchor_point(screen);
- if (!anchored.isNull()) {
+ if (const QPoint anchored = screen_anchor_point(screen); !anchored.isNull()) {
return anchored;
}
@@ -229,11 +228,9 @@ namespace {
return QIcon();
}
- const QFileInfo icon_fi(icon_source);
- if (icon_fi.exists()) {
+ if (const QFileInfo icon_fi(icon_source); icon_fi.exists()) {
const QString file_path = icon_fi.absoluteFilePath();
- const QIcon file_icon(file_path);
- if (!file_icon.isNull()) {
+ if (const QIcon file_icon(file_path); !file_icon.isNull()) {
return file_icon;
}
@@ -245,8 +242,7 @@ namespace {
}
}
- const QIcon themed = QIcon::fromTheme(icon_source);
- if (!themed.isNull()) {
+ if (const QIcon themed = QIcon::fromTheme(icon_source); !themed.isNull()) {
return themed;
}
@@ -335,9 +331,9 @@ namespace {
action->setCheckable(true);
action->setChecked(m->checked != 0);
}
- action->setData(QVariant::fromValue(reinterpret_cast(m)));
+ action->setData(QVariant::fromValue(static_cast(m)));
QObject::connect(action, &QAction::triggered, menu, [action]() {
- auto *item = reinterpret_cast(action->data().value());
+ auto *item = static_cast(action->data().value());
if (item != nullptr && item->cb != nullptr) {
item->cb(item);
}
@@ -359,7 +355,9 @@ namespace {
return false;
}
- QAction *action = actions.at(action_index++);
+ 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;
@@ -368,7 +366,7 @@ namespace {
}
if (item->submenu != nullptr) {
- QMenu *submenu = action->menu();
+ const QMenu *submenu = action->menu();
if (submenu == nullptr || !menu_layout_matches(submenu, item->submenu)) {
return false;
}
@@ -380,7 +378,7 @@ namespace {
return action_index == actions.size();
}
- void update_menu_state(QMenu *menu, struct tray_menu *items) {
+ void update_menu_state(const QMenu *menu, struct tray_menu *items) {
if (menu == nullptr || items == nullptr) {
return;
}
@@ -388,7 +386,9 @@ namespace {
const QList actions = menu->actions();
int action_index = 0;
for (struct tray_menu *item = items; item != nullptr && item->text != nullptr; item++) {
- QAction *action = actions.at(action_index++);
+ const int current_action_index = action_index;
+ action_index++;
+ QAction *action = actions.at(current_action_index);
if (std::strcmp(item->text, "-") == 0) {
continue;
}
@@ -404,10 +404,260 @@ namespace {
if (item->checkbox != 0) {
action->setChecked(item->checked != 0);
}
- action->setData(QVariant::fromValue(reinterpret_cast(item)));
+ 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() {
+ if (g_notification_handler != nullptr) {
+ g_notification_handler->notification_id = 0;
+ g_notification_handler->cb = 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 QDBusPendingReply reply = *watcher;
+ if (reply.isValid() && g_tray_icon != nullptr) {
+ g_notification_id = reply.value();
+ 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) {
+ 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, [](QDBusPendingCallWatcher *finished) {
+ handle_notification_reply(finished);
+ });
+ 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 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)) {
+ // 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() {
close_notification();
if (g_notification_handler != nullptr) {
@@ -429,14 +679,6 @@ namespace {
void destroy_app() {
if (g_notification_handler != nullptr) {
- QDBusConnection::sessionBus().disconnect(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("ActionInvoked"),
- g_notification_handler,
- SLOT(onActionInvoked(uint, QString))
- );
delete g_notification_handler; // NOSONAR(cpp:S5025) - raw pointer; deleted explicitly before QApplication
g_notification_handler = nullptr;
}
@@ -462,89 +704,7 @@ extern "C" {
int result = 0;
run_on_qt_thread([tray, &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;
- }
-
- 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()) {
- if (!g_desktop_name.isEmpty()) {
- QGuiApplication::setDesktopFileName(g_desktop_name);
- } else {
- QString desktop_name = QCoreApplication::applicationName();
- if (!desktop_name.endsWith(QStringLiteral(".desktop"))) {
- desktop_name += QStringLiteral(".desktop");
- }
- QGuiApplication::setDesktopFileName(desktop_name);
- }
- }
-
- // 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.
- // activateWindow() gives the menu window X11 focus so that the subsequent
- // XGrabPointer inside popup() succeeds, enabling click-outside dismissal on Xorg.
- QObject::connect(g_tray_icon, &QSystemTrayIcon::activated, [](QSystemTrayIcon::ActivationReason reason) {
- const bool left_click_activation =
- (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::Context);
-
- if (!left_click_activation) {
- return;
- }
-
- const QPoint click_pos = QCursor::pos();
- QTimer::singleShot(30, g_tray_icon, [click_pos]() {
- popup_menu_for_activation(click_pos);
- });
- });
-
- // 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. Deferring via QTimer::singleShot
- // ensures the socket notifiers are created while the event dispatcher is active.
- if (g_notification_handler == nullptr) {
- g_notification_handler = new TrayNotificationHandler(); // NOSONAR(cpp:S5025) - deleted in destroy_app()
- QTimer::singleShot(0, g_notification_handler, []() {
- if (g_notification_handler != nullptr) {
- QDBusConnection::sessionBus().connect(
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("/org/freedesktop/Notifications"),
- QStringLiteral("org.freedesktop.Notifications"),
- QStringLiteral("ActionInvoked"),
- g_notification_handler,
- SLOT(onActionInvoked(uint, QString))
- );
- }
- });
- }
-
- tray_update(tray);
- g_tray_icon->show();
+ initialize_tray(tray, &result);
});
return result;
}
@@ -582,7 +742,7 @@ extern "C" {
if (g_app_owned) {
QApplication::processEvents();
} else {
- QCoreApplication *app_inst = QCoreApplication::instance();
+ const QCoreApplication *app_inst = QCoreApplication::instance();
if (app_inst != nullptr && QThread::currentThread() == app_inst->thread()) {
QApplication::processEvents();
}
@@ -592,135 +752,16 @@ extern "C" {
return g_loop_result;
}
- void tray_update(struct tray *tray) {
+ void tray_update(struct tray *tray) { // NOSONAR(cpp:S995) - C API requires this exact mutable-pointer signature
run_on_qt_thread([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);
- }
- // Only update the icon when the resolved icon is valid. Setting a null icon
- // clears the tray icon and triggers "No Icon set" warnings (Qt6 is stricter
- // about QIcon::fromTheme when the name is not found in the active theme).
- if (!tray_icon.isNull()) {
- g_tray_icon->setIcon(tray_icon);
- }
-
- if (tray->tooltip != nullptr) {
- g_tray_icon->setToolTip(QString::fromUtf8(tray->tooltip));
- }
-
- if (tray->menu != nullptr) {
- 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);
- } else {
- // 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 old_menu path or on next update
- g_tray_icon->setContextMenu(new_menu);
- if (existing_menu != nullptr) {
- // 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
- }
- }
- }
-
- const QString text = tray->notification_text != nullptr ? QString::fromUtf8(tray->notification_text) : QString();
-
- // Reset previous notification state before setting up the new one.
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = 0;
- g_notification_handler->cb = nullptr;
- }
- QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr);
- close_notification();
-
- if (!text.isEmpty()) {
- const QString title = tray->notification_title != nullptr ? QString::fromUtf8(tray->notification_title) : QString();
- const char *icon_path = tray->notification_icon != nullptr ? tray->notification_icon : tray->icon;
- QString icon;
- if (icon_path != nullptr) {
- QFileInfo fi(QString::fromUtf8(icon_path));
- icon = fi.exists() ? QUrl::fromLocalFile(fi.absoluteFilePath()).toString() : QString::fromUtf8(icon_path);
- }
- 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()) {
- // Include the "default" action so that clicking the notification body fires ActionInvoked.
- // QSystemTrayIcon::messageClicked is NOT emitted for D-Bus-dispatched notifications,
- // so the callback must be routed through TrayNotificationHandler::onActionInvoked instead.
- QStringList actions;
- if (tray->notification_cb != nullptr) {
- actions << QStringLiteral("default") << QString();
- }
- // Store the callback before the async Notify so tray_simulate_notification_click works
- // even when the notification daemon is unavailable and the D-Bus reply is invalid.
- if (g_notification_handler != nullptr) {
- g_notification_handler->cb = tray->notification_cb;
- }
- // Use asyncCall to avoid entering a nested Qt event loop while waiting for the D-Bus
- // reply. A synchronous call here would allow re-entrant tray_update dispatches from
- // other threads (via BlockingQueuedConnection), which can delete and replace the context
- // QMenu mid-build, breaking all QAction signal connections.
- 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, [watcher]() {
- const QDBusPendingReply reply = *watcher;
- if (reply.isValid() && g_tray_icon != nullptr) {
- g_notification_id = reply.value();
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = g_notification_id;
- }
- }
- watcher->deleteLater();
- });
- } else {
- // D-Bus unavailable: fall back to Qt's built-in balloon and messageClicked signal.
- 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) {
- g_notification_handler->cb();
- }
- });
- }
- g_tray_icon->showMessage(title, text, QSystemTrayIcon::Information, 5000);
- }
- }
+ update_tray_state(tray);
});
}
void tray_show_menu(void) {
run_on_qt_thread([]() {
if (g_tray_icon != nullptr) {
- QMenu *menu = g_tray_icon->contextMenu();
+ const QMenu *menu = g_tray_icon->contextMenu();
if (menu != nullptr) {
popup_menu_for_activation(QPoint());
QApplication::processEvents();
@@ -751,7 +792,7 @@ extern "C" {
if (g_tray_icon == nullptr || index < 0) {
return;
}
- QMenu *menu = g_tray_icon->contextMenu();
+ const QMenu *menu = g_tray_icon->contextMenu();
if (menu == nullptr) {
return;
}
@@ -779,7 +820,7 @@ extern "C" {
}
}
- void tray_set_log_callback(void (*cb)(int level, const char *msg)) {
+ 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);
diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp
index b4aebc2..0db948a 100644
--- a/tests/unit/test_tray.cpp
+++ b/tests/unit/test_tray.cpp
@@ -163,7 +163,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must
// Ensure icon files exist in test binary directory
std::filesystem::path projectRoot = testBinaryDir.parent_path();
- auto ensureIconInTestDir = [projectRoot, this](const char *iconName) {
+ auto ensureIconInTestDir = [&projectRoot, this](const char *iconName) {
std::filesystem::path iconSource;
if (std::filesystem::exists(projectRoot / "icons" / iconName)) {
From e8bc13af7befcbc6916ade663fdac4a1107f4636 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Fri, 27 Mar 2026 13:22:32 -0400
Subject: [PATCH 18/23] test: improve linux test coverage
---
tests/unit/test_tray_linux.cpp | 275 +++++++++++++++++++++++++++++++++
1 file changed, 275 insertions(+)
create mode 100644 tests/unit/test_tray_linux.cpp
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
From 0e634d518634da88a18290dbb99f6d139ca1939e Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Fri, 27 Mar 2026 14:59:54 -0400
Subject: [PATCH 19/23] Wrap installs in TRAY_IS_TOP_LEVEL check
Only perform install steps when the project is top-level by wrapping install commands in if(TRAY_IS_TOP_LEVEL). Also normalize to lowercase install() and remove the duplicated target name (was INSTALL(TARGETS tray tray ...)). This prevents unintended installs when the project is used as a subdirectory while preserving header and icon installations.
---
CMakeLists.txt | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e1beb35..c7a2b4f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -126,9 +126,11 @@ if(BUILD_EXAMPLE)
tray_copy_default_icons(tray_example)
endif()
-INSTALL(TARGETS tray tray DESTINATION lib)
-INSTALL(FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/tray.h" DESTINATION include)
-INSTALL(FILES ${TRAY_ICON_FILES} DESTINATION share/tray/icons)
+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_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS})
target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES})
From bf002e200ee608fd77097a8bb627aa59741ca804 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Fri, 27 Mar 2026 16:19:03 -0400
Subject: [PATCH 20/23] fix(linux): reset notifications during tray tray
destruction
---
src/tray_linux.cpp | 74 ++++++++++++++++++++++++++++------------
tests/unit/test_tray.cpp | 16 +++++++++
2 files changed, 69 insertions(+), 21 deletions(-)
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index faa13b0..0ce2df9 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -4,6 +4,7 @@
*/
// standard includes
#include
+#include
#include
#include
#include
@@ -76,6 +77,8 @@ namespace {
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
@@ -298,22 +301,29 @@ namespace {
}
}
- void close_notification() {
- if (g_notification_id == 0) {
+ void close_notification_id(uint notification_id) {
+ if (notification_id == 0) {
return;
}
- const uint id_to_close = g_notification_id;
- g_notification_id = 0;
QDBusInterface iface(
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("/org/freedesktop/Notifications"),
QStringLiteral("org.freedesktop.Notifications")
);
if (iface.isValid()) {
- iface.asyncCall(QStringLiteral("CloseNotification"), id_to_close);
+ 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++) {
@@ -512,11 +522,15 @@ namespace {
}
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;
}
- QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr);
+ if (g_tray_icon != nullptr) {
+ QObject::disconnect(g_tray_icon, &QSystemTrayIcon::messageClicked, nullptr, nullptr);
+ }
close_notification();
}
@@ -534,18 +548,37 @@ namespace {
void destroy_tray();
- void handle_notification_reply(QDBusPendingCallWatcher *watcher) {
+ void handle_notification_reply(QDBusPendingCallWatcher *watcher, const std::uint64_t notification_generation) {
const QDBusPendingReply reply = *watcher;
- if (reply.isValid() && g_tray_icon != nullptr) {
- g_notification_id = reply.value();
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = g_notification_id;
- }
+ 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) {
+ 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;
@@ -580,8 +613,8 @@ namespace {
5000
);
auto *watcher = new QDBusPendingCallWatcher(pending); // NOSONAR(cpp:S5025) - deleted via deleteLater in finished handler
- QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [](QDBusPendingCallWatcher *finished) {
- handle_notification_reply(finished);
+ QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher, [notification_generation](QDBusPendingCallWatcher *finished) {
+ handle_notification_reply(finished, notification_generation);
});
return true;
}
@@ -606,10 +639,13 @@ namespace {
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)) {
+ 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);
}
@@ -659,11 +695,7 @@ namespace {
}
void destroy_tray() {
- close_notification();
- if (g_notification_handler != nullptr) {
- g_notification_handler->notification_id = 0;
- g_notification_handler->cb = nullptr;
- }
+ reset_notification_state();
if (g_tray_icon != nullptr) {
g_tray_icon->hide();
QMenu *menu = g_tray_icon->contextMenu();
diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp
index 0db948a..4a91562 100644
--- a/tests/unit/test_tray.cpp
+++ b/tests/unit/test_tray.cpp
@@ -80,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;
@@ -194,6 +206,10 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must
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;
}
From 3e57b845fdfbe6cd2cdddea6c5c8f3f93cc764c6 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Fri, 27 Mar 2026 18:46:13 -0400
Subject: [PATCH 21/23] Force minimal QPA when no display endpoint
Add detection for reachable Wayland/X11 endpoints and fall back to the minimal QPA if none are present. Introduce QDir include and helper functions has_wayland_display_endpoint(), has_x11_display_endpoint(), and should_force_headless_qpa_fallback() to check WAYLAND_DISPLAY and DISPLAY (including XDG_RUNTIME_DIR sockets and /tmp/.X11-unix/X sockets, as well as remote/TCP displays). If QT_QPA_PLATFORM is unset and no local display endpoints are found, set QT_QPA_PLATFORM=minimal before creating QApplication and emit a log via g_log_cb. This prevents trying to use GUI QPAs when no local display is reachable.
---
src/tray_linux.cpp | 76 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 76 insertions(+)
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index 0ce2df9..c796d2f 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -22,6 +22,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -116,6 +117,75 @@ namespace {
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);
+ }
+
+ 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();
@@ -729,6 +799,12 @@ extern "C" {
int tray_init(struct tray *tray) {
if (QApplication::instance() == nullptr) {
+ 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;
From ff76d0ec99c3b9247bc4ff50985042008127d7ee Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Fri, 27 Mar 2026 22:35:42 -0400
Subject: [PATCH 22/23] Autodiscover WAYLAND_DISPLAY from XDG_RUNTIME_DIR
Add automatic discovery of a Wayland display socket when WAYLAND_DISPLAY is not set. Introduce discover_wayland_display_name() to scan XDG_RUNTIME_DIR for wayland-* entries (preferring wayland-0) and try_autodiscover_wayland_display() to export the found name via qputenv. Call the autodiscovery at tray initialization and emit a log message on success. Also include QStringList header required for the new code.
---
src/tray_linux.cpp | 56 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/src/tray_linux.cpp b/src/tray_linux.cpp
index c796d2f..a9facdf 100644
--- a/src/tray_linux.cpp
+++ b/src/tray_linux.cpp
@@ -29,6 +29,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -141,6 +142,58 @@ namespace {
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()) {
@@ -799,6 +852,9 @@ 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) {
From f207e17341a83280d1021c5b7971c7c57ade5405 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Sun, 29 Mar 2026 08:32:53 -0400
Subject: [PATCH 23/23] Include in tray_windows.c
Add the missing standard library header to src/tray_windows.c to ensure declarations for libc functions used in the file are available and to prevent implicit-declaration warnings or build errors.
---
src/tray_windows.c | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/tray_windows.c b/src/tray_windows.c
index bae5684..cf959e9 100644
--- a/src/tray_windows.c
+++ b/src/tray_windows.c
@@ -13,6 +13,7 @@
// clang-format off
// build fails if shellapi.h is included before Windows.h
#include
+#include
// clang-format on
// local includes