From db03817cd3d40696682eb3f1e9e9343d1cfedfa1 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:59:31 -0400 Subject: [PATCH 1/4] test: add mouse positioning helpers for tray icon Add tray_position_mouse_over_icon() and tray_restore_mouse_position() C API functions, backed by QtTrayMenu methods, to move and restore the cursor during testing. Use these in tests on GitHub Actions where mouse position matters for tray menu screenshots. --- src/QtTrayMenu.cpp | 38 +++++++++++++++++++++++++++++++++++++ src/QtTrayMenu.h | 15 +++++++++++++++ src/tray.h | 12 ++++++++++++ src/tray_qt.cpp | 14 ++++++++++++++ tests/unit/test_tray.cpp | 10 ++++++++++ tests/unit/test_tray_qt.cpp | 3 +++ 6 files changed, 92 insertions(+) diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index 1bcf5e36..4d84f697 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -342,3 +342,41 @@ void QtTrayMenu::clickMessage() const { void QtTrayMenu::clearMessageCallback() const { notificationCallback = nullptr; } + +bool QtTrayMenu::positionMouseOverIcon() { + if (!trayIcon) { + return false; + } + + const QRect iconGeometry = trayIcon->geometry(); + if (!iconGeometry.isValid()) { + qWarning("QtTrayMenu: tray icon geometry is unavailable"); + return false; + } + + if (!mousePositionSaved) { + savedMousePosition = QCursor::pos(); + mousePositionSaved = true; + } + const QPoint targetPosition = iconGeometry.center(); + QCursor::setPos(targetPosition); + const bool positioned = QCursor::pos() == targetPosition; + if (!positioned) { + qWarning("QtTrayMenu: could not position the mouse over the tray icon"); + } + return positioned; +} + +bool QtTrayMenu::restoreMousePosition() { + if (!mousePositionSaved) { + return false; + } + + QCursor::setPos(savedMousePosition); + mousePositionSaved = false; + const bool restored = QCursor::pos() == savedMousePosition; + if (!restored) { + qWarning("QtTrayMenu: could not restore the saved mouse position"); + } + return restored; +} diff --git a/src/QtTrayMenu.h b/src/QtTrayMenu.h index 781cef6f..96218e72 100644 --- a/src/QtTrayMenu.h +++ b/src/QtTrayMenu.h @@ -12,6 +12,7 @@ // qt includes #include #include +#include #include #include @@ -110,6 +111,18 @@ class QtTrayMenu: public QObject { */ void clearMessageCallback() const; + /** + * @brief Move the mouse cursor to the center of the tray icon. + * @return true if the tray icon has valid screen geometry and the cursor was moved + */ + bool positionMouseOverIcon(); + + /** + * @brief Restore the mouse cursor position saved by positionMouseOverIcon(). + * @return true if a saved position existed and the cursor was restored + */ + bool restoreMousePosition(); + /** * @brief Check if QtTrayMenu supports messages * @return true if messages can be shown @@ -150,6 +163,8 @@ class QtTrayMenu: public QObject { bool blockingEventLoop = false; struct tray_menu *getTrayMenuItem(const QAction *action); mutable std::function notificationCallback = nullptr; + QPoint savedMousePosition; + bool mousePositionSaved = false; private slots: void onExitRequested(); diff --git a/src/tray.h b/src/tray.h index c4fa3871..6a63a462 100644 --- a/src/tray.h +++ b/src/tray.h @@ -70,6 +70,18 @@ extern "C" { */ void tray_show_menu(void); + /** + * @brief Position the mouse over the tray icon (for testing purposes). + * @return 0 on success, -1 if the tray icon geometry is unavailable. + */ + int tray_position_mouse_over_icon(void); + + /** + * @brief Restore the mouse position saved by tray_position_mouse_over_icon(). + * @return 0 on success, -1 if no saved position exists or the cursor could not be restored. + */ + int tray_restore_mouse_position(void); + /** * @brief Simulate a notification click, invoking the notification callback (for testing purposes). * diff --git a/src/tray_qt.cpp b/src/tray_qt.cpp index 8877dbd4..eb1c9ea2 100644 --- a/src/tray_qt.cpp +++ b/src/tray_qt.cpp @@ -221,6 +221,20 @@ extern "C" { tray_qt::state().trayMenu->showMenu(); } + int tray_position_mouse_over_icon(void) { + if (tray_qt::state().trayMenu == nullptr) { + return -1; + } + return tray_qt::state().trayMenu->positionMouseOverIcon() ? 0 : -1; + } + + int tray_restore_mouse_position(void) { + if (tray_qt::state().trayMenu == nullptr) { + return -1; + } + return tray_qt::state().trayMenu->restoreMousePosition() ? 0 : -1; + } + void tray_simulate_menu_item_click(int index) { if (tray_qt::state().trayMenu == nullptr) { return; diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 0afdf72c..e1dd98b1 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -126,6 +126,12 @@ class TrayTest: public BaseTest { // Capture a screenshot while the tray menu is open, then dismiss and exit. void captureMenuStateAndExit(const char *screenshotName) const { + const bool positionMouse = lizardbyte::common::is_github_actions(); + if (positionMouse) { + WaitForTrayReady(); + ASSERT_EQ(tray_position_mouse_over_icon(), 0); + } + std::atomic_bool exitRequested {false}; std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168): C++17 has no std::jthread and this thread is explicitly joined EXPECT_TRUE(captureScreenshot(screenshotName)); @@ -141,6 +147,9 @@ class TrayTest: public BaseTest { std::this_thread::sleep_for(std::chrono::milliseconds(10)); } capture_thread.join(); + if (positionMouse) { + EXPECT_EQ(tray_restore_mouse_position(), 0); + } } static void hello_cb(struct tray_menu *) { @@ -222,6 +231,7 @@ class TrayTest: public BaseTest { void TearDown() override { ShutdownTray(); + tray_restore_mouse_position(); BaseTest::TearDown(); } diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index 1b1d9761..3e25e922 100644 --- a/tests/unit/test_tray_qt.cpp +++ b/tests/unit/test_tray_qt.cpp @@ -100,6 +100,7 @@ class TrayQtCoverageTest: public BaseTest { trayRunning = false; } + tray_restore_mouse_position(); tray_set_log_callback(nullptr); BaseTest::TearDown(); } @@ -163,6 +164,8 @@ TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) { TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) { tray_update(trayData); tray_show_menu(); + EXPECT_EQ(tray_position_mouse_over_icon(), -1); + EXPECT_EQ(tray_restore_mouse_position(), -1); tray_simulate_menu_item_click(0); tray_simulate_notification_click(); PumpEvents(); From 23c129fa02a0c79c62fa71ffd89bbf530b6bdfed Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:08 -0400 Subject: [PATCH 2/4] Add tray icon position fallback and tolerant checks Improve mouse positioning for tray interactions when tray icon geometry is unavailable by deriving a fallback target from screen/panel edges, with platform-specific defaults. Also make cursor position/restore validation tolerant to small movement offsets instead of exact pixel matches. Unit tests were updated to avoid hard-failing on CI cursor positioning and only require restore success when positioning succeeded. --- src/QtTrayMenu.cpp | 56 ++++++++++++++++++++++++++++++++++++---- tests/unit/test_tray.cpp | 9 +++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index 4d84f697..ad8be5fc 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include // local includes @@ -19,6 +20,46 @@ #include "WindowsAppearance.h" #endif +namespace { + constexpr int DEFAULT_PANEL_THICKNESS = 24; + constexpr int CURSOR_POSITION_TOLERANCE = 2; + + bool positionsAreClose(const QPoint &first, const QPoint &second) { + return (first - second).manhattanLength() <= CURSOR_POSITION_TOLERANCE; + } + + bool fallbackTrayIconPosition(QPoint *position) { + const QScreen *screen = QGuiApplication::primaryScreen(); + if (screen == nullptr) { + return false; + } + + const QRect screenGeometry = screen->geometry(); + const QRect availableGeometry = screen->availableGeometry(); + const int topInset = availableGeometry.top() - screenGeometry.top(); + const int bottomInset = screenGeometry.bottom() - availableGeometry.bottom(); + const int rightInset = screenGeometry.right() - availableGeometry.right(); + const int leftInset = availableGeometry.left() - screenGeometry.left(); + + if (topInset > 0) { + *position = QPoint(screenGeometry.right() - (topInset / 2), screenGeometry.top() + (topInset / 2)); + } else if (bottomInset > 0) { + *position = QPoint(screenGeometry.right() - (bottomInset / 2), screenGeometry.bottom() - (bottomInset / 2)); + } else if (rightInset > 0) { + *position = QPoint(screenGeometry.right() - (rightInset / 2), screenGeometry.bottom() - (rightInset / 2)); + } else if (leftInset > 0) { + *position = QPoint(screenGeometry.left() + (leftInset / 2), screenGeometry.bottom() - (leftInset / 2)); + } else { +#if defined(_WIN32) + *position = QPoint(screenGeometry.right() - (DEFAULT_PANEL_THICKNESS / 2), screenGeometry.bottom() - (DEFAULT_PANEL_THICKNESS / 2)); +#else + *position = QPoint(screenGeometry.right() - (DEFAULT_PANEL_THICKNESS / 2), screenGeometry.top() + (DEFAULT_PANEL_THICKNESS / 2)); +#endif + } + return true; + } +} // namespace + QtTrayMenu::QtTrayMenu(QObject *parent, const bool debug): QtTrayMenu(-1, nullptr, parent, debug) { }; @@ -349,18 +390,23 @@ bool QtTrayMenu::positionMouseOverIcon() { } const QRect iconGeometry = trayIcon->geometry(); - if (!iconGeometry.isValid()) { - qWarning("QtTrayMenu: tray icon geometry is unavailable"); + QPoint targetPosition; + if (iconGeometry.isValid()) { + targetPosition = iconGeometry.center(); + } else if (!fallbackTrayIconPosition(&targetPosition)) { + qWarning("QtTrayMenu: tray icon geometry and screen-edge fallback are unavailable"); return false; + } else { + qWarning("QtTrayMenu: tray icon geometry is unavailable; using the system panel edge"); } if (!mousePositionSaved) { savedMousePosition = QCursor::pos(); mousePositionSaved = true; } - const QPoint targetPosition = iconGeometry.center(); QCursor::setPos(targetPosition); - const bool positioned = QCursor::pos() == targetPosition; + const QPoint currentPosition = QCursor::pos(); + const bool positioned = iconGeometry.isValid() ? iconGeometry.contains(currentPosition) : positionsAreClose(currentPosition, targetPosition); if (!positioned) { qWarning("QtTrayMenu: could not position the mouse over the tray icon"); } @@ -374,7 +420,7 @@ bool QtTrayMenu::restoreMousePosition() { QCursor::setPos(savedMousePosition); mousePositionSaved = false; - const bool restored = QCursor::pos() == savedMousePosition; + const bool restored = positionsAreClose(QCursor::pos(), savedMousePosition); if (!restored) { qWarning("QtTrayMenu: could not restore the saved mouse position"); } diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index e1dd98b1..6f20f52d 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -127,9 +127,11 @@ class TrayTest: public BaseTest { // Capture a screenshot while the tray menu is open, then dismiss and exit. void captureMenuStateAndExit(const char *screenshotName) const { const bool positionMouse = lizardbyte::common::is_github_actions(); + int positionMouseResult = -1; if (positionMouse) { WaitForTrayReady(); - ASSERT_EQ(tray_position_mouse_over_icon(), 0); + positionMouseResult = tray_position_mouse_over_icon(); + EXPECT_EQ(positionMouseResult, 0); } std::atomic_bool exitRequested {false}; @@ -148,7 +150,10 @@ class TrayTest: public BaseTest { } capture_thread.join(); if (positionMouse) { - EXPECT_EQ(tray_restore_mouse_position(), 0); + const int restoreMouseResult = tray_restore_mouse_position(); + if (positionMouseResult == 0) { + EXPECT_EQ(restoreMouseResult, 0); + } } } From 00c7b4385294cecd9a75f91f1c1a9fe71ff46386 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:54:33 -0400 Subject: [PATCH 3/4] Harden macOS capture setup and cursor moves Updates CI on macOS to fully configure screen recording by writing replayd approval data, restarting replayd, and clarifying step messaging. In QtTrayMenu, adds a bounded cursor-position wait loop with polling and timeout so mouse move/restore checks are more reliable instead of relying on a single immediate position read. --- .github/workflows/ci.yml | 17 +++++++++++++++-- src/QtTrayMenu.cpp | 22 +++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b460a66d..c1c01b5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,7 @@ jobs: if: runner.os == 'macOS' run: | dependencies=( + "cliclick" "cmake" "doxygen" "graphviz" @@ -123,11 +124,13 @@ jobs: ) brew install "${dependencies[@]}" - - name: Fix macOS screen recording permissions + - name: Configure macOS screen recording if: runner.os == 'macOS' run: | set -euo pipefail + clickTool="$(command -v cliclick)" + configure_system_tccdb() { local values=$1 local dbPath="/Library/Application Support/com.apple.TCC/TCC.db" @@ -144,6 +147,8 @@ jobs: systemValuesArray=( "'kTCCServiceScreenCapture','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148" + "'kTCCServicePostEvent','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148" + "'kTCCServicePostEvent','$clickTool',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148" ) for values in "${systemValuesArray[@]}"; do configure_system_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}" @@ -151,12 +156,20 @@ jobs: userValuesArray=( "'kTCCServiceScreenCapture','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993" + "'kTCCServicePostEvent','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993" + "'kTCCServicePostEvent','$clickTool',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993" ) for values in "${userValuesArray[@]}"; do configure_user_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}" done - echo "macOS TCC permissions configured." + preflightScreenshot="$RUNNER_TEMP/screen-capture-preflight.png" + screencapture -x "$preflightScreenshot" + sleep 1 + "$clickTool" kp:return + sleep 1 + + echo "macOS screen recording configured." - name: Setup Dependencies Windows if: runner.os == 'Windows' diff --git a/src/QtTrayMenu.cpp b/src/QtTrayMenu.cpp index ad8be5fc..4b37a0ed 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -3,7 +3,9 @@ * @brief Definitions for Qt tray menu implemenation */ // standard includes +#include #include +#include // qt includes #include @@ -22,12 +24,27 @@ namespace { constexpr int DEFAULT_PANEL_THICKNESS = 24; + constexpr int CURSOR_POSITION_POLL_INTERVAL_MS = 10; + constexpr int CURSOR_POSITION_TIMEOUT_MS = 500; constexpr int CURSOR_POSITION_TOLERANCE = 2; bool positionsAreClose(const QPoint &first, const QPoint &second) { return (first - second).manhattanLength() <= CURSOR_POSITION_TOLERANCE; } + bool waitForCursorPosition(const QPoint &targetPosition, const QRect &targetGeometry = {}) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(CURSOR_POSITION_TIMEOUT_MS); + do { + if (const QPoint currentPosition = QCursor::pos(); targetGeometry.isValid() ? targetGeometry.contains(currentPosition) : positionsAreClose(currentPosition, targetPosition)) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(CURSOR_POSITION_POLL_INTERVAL_MS)); + } while (std::chrono::steady_clock::now() < deadline); + + const QPoint currentPosition = QCursor::pos(); + return targetGeometry.isValid() ? targetGeometry.contains(currentPosition) : positionsAreClose(currentPosition, targetPosition); + } + bool fallbackTrayIconPosition(QPoint *position) { const QScreen *screen = QGuiApplication::primaryScreen(); if (screen == nullptr) { @@ -405,8 +422,7 @@ bool QtTrayMenu::positionMouseOverIcon() { mousePositionSaved = true; } QCursor::setPos(targetPosition); - const QPoint currentPosition = QCursor::pos(); - const bool positioned = iconGeometry.isValid() ? iconGeometry.contains(currentPosition) : positionsAreClose(currentPosition, targetPosition); + const bool positioned = waitForCursorPosition(targetPosition, iconGeometry); if (!positioned) { qWarning("QtTrayMenu: could not position the mouse over the tray icon"); } @@ -419,8 +435,8 @@ bool QtTrayMenu::restoreMousePosition() { } QCursor::setPos(savedMousePosition); + const bool restored = waitForCursorPosition(savedMousePosition); mousePositionSaved = false; - const bool restored = positionsAreClose(QCursor::pos(), savedMousePosition); if (!restored) { qWarning("QtTrayMenu: could not restore the saved mouse position"); } From 61946bba876c014664ad2ff3c16c2a090af68e5e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:08:15 -0400 Subject: [PATCH 4/4] Fix notification wait loop for macOS in CI Extend the GitHub Actions notification readiness workaround to also apply on macOS, not just Windows. --- tests/unit/test_tray.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 6f20f52d..8a9fd513 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -251,7 +251,7 @@ class TrayTest: public BaseTest { void WaitForNotificationReady() const { WaitForTrayReady(); -#if defined(_WIN32) +#if defined(_WIN32) || defined(__APPLE__) if (lizardbyte::common::is_github_actions()) { for (int i = 0; i < 40; i++) { tray_loop(0);