diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b460a66..c1c01b5 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 1bcf5e3..4b37a0e 100644 --- a/src/QtTrayMenu.cpp +++ b/src/QtTrayMenu.cpp @@ -3,13 +3,16 @@ * @brief Definitions for Qt tray menu implemenation */ // standard includes +#include #include +#include // qt includes #include #include #include #include +#include #include // local includes @@ -19,6 +22,61 @@ #include "WindowsAppearance.h" #endif +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) { + 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) { }; @@ -342,3 +400,45 @@ void QtTrayMenu::clickMessage() const { void QtTrayMenu::clearMessageCallback() const { notificationCallback = nullptr; } + +bool QtTrayMenu::positionMouseOverIcon() { + if (!trayIcon) { + return false; + } + + const QRect iconGeometry = trayIcon->geometry(); + 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; + } + QCursor::setPos(targetPosition); + const bool positioned = waitForCursorPosition(targetPosition, iconGeometry); + 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); + const bool restored = waitForCursorPosition(savedMousePosition); + mousePositionSaved = false; + if (!restored) { + qWarning("QtTrayMenu: could not restore the saved mouse position"); + } + return restored; +} diff --git a/src/QtTrayMenu.h b/src/QtTrayMenu.h index 781cef6..96218e7 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 c4fa387..6a63a46 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 8877dbd..eb1c9ea 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 0afdf72..8a9fd51 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -126,6 +126,14 @@ 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(); + positionMouseResult = tray_position_mouse_over_icon(); + EXPECT_EQ(positionMouseResult, 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 +149,12 @@ class TrayTest: public BaseTest { std::this_thread::sleep_for(std::chrono::milliseconds(10)); } capture_thread.join(); + if (positionMouse) { + const int restoreMouseResult = tray_restore_mouse_position(); + if (positionMouseResult == 0) { + EXPECT_EQ(restoreMouseResult, 0); + } + } } static void hello_cb(struct tray_menu *) { @@ -222,6 +236,7 @@ class TrayTest: public BaseTest { void TearDown() override { ShutdownTray(); + tray_restore_mouse_position(); BaseTest::TearDown(); } @@ -236,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); diff --git a/tests/unit/test_tray_qt.cpp b/tests/unit/test_tray_qt.cpp index 1b1d976..3e25e92 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();