Skip to content

Commit ea38a63

Browse files
test: add mouse positioning helpers for tray icon (#169)
1 parent cedbffd commit ea38a63

7 files changed

Lines changed: 175 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ jobs:
112112
if: runner.os == 'macOS'
113113
run: |
114114
dependencies=(
115+
"cliclick"
115116
"cmake"
116117
"doxygen"
117118
"graphviz"
@@ -123,11 +124,13 @@ jobs:
123124
)
124125
brew install "${dependencies[@]}"
125126
126-
- name: Fix macOS screen recording permissions
127+
- name: Configure macOS screen recording
127128
if: runner.os == 'macOS'
128129
run: |
129130
set -euo pipefail
130131
132+
clickTool="$(command -v cliclick)"
133+
131134
configure_system_tccdb() {
132135
local values=$1
133136
local dbPath="/Library/Application Support/com.apple.TCC/TCC.db"
@@ -144,19 +147,29 @@ jobs:
144147
145148
systemValuesArray=(
146149
"'kTCCServiceScreenCapture','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148"
150+
"'kTCCServicePostEvent','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148"
151+
"'kTCCServicePostEvent','$clickTool',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148"
147152
)
148153
for values in "${systemValuesArray[@]}"; do
149154
configure_system_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}"
150155
done
151156
152157
userValuesArray=(
153158
"'kTCCServiceScreenCapture','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993"
159+
"'kTCCServicePostEvent','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993"
160+
"'kTCCServicePostEvent','$clickTool',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993"
154161
)
155162
for values in "${userValuesArray[@]}"; do
156163
configure_user_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}"
157164
done
158165
159-
echo "macOS TCC permissions configured."
166+
preflightScreenshot="$RUNNER_TEMP/screen-capture-preflight.png"
167+
screencapture -x "$preflightScreenshot"
168+
sleep 1
169+
"$clickTool" kp:return
170+
sleep 1
171+
172+
echo "macOS screen recording configured."
160173
161174
- name: Setup Dependencies Windows
162175
if: runner.os == 'Windows'

src/QtTrayMenu.cpp

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
* @brief Definitions for Qt tray menu implemenation
44
*/
55
// standard includes
6+
#include <chrono>
67
#include <filesystem>
8+
#include <thread>
79

810
// qt includes
911
#include <QApplication>
1012
#include <QCursor>
1113
#include <QDebug>
1214
#include <QMouseEvent>
15+
#include <QScreen>
1316
#include <QStyle>
1417

1518
// local includes
@@ -19,6 +22,61 @@
1922
#include "WindowsAppearance.h"
2023
#endif
2124

25+
namespace {
26+
constexpr int DEFAULT_PANEL_THICKNESS = 24;
27+
constexpr int CURSOR_POSITION_POLL_INTERVAL_MS = 10;
28+
constexpr int CURSOR_POSITION_TIMEOUT_MS = 500;
29+
constexpr int CURSOR_POSITION_TOLERANCE = 2;
30+
31+
bool positionsAreClose(const QPoint &first, const QPoint &second) {
32+
return (first - second).manhattanLength() <= CURSOR_POSITION_TOLERANCE;
33+
}
34+
35+
bool waitForCursorPosition(const QPoint &targetPosition, const QRect &targetGeometry = {}) {
36+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(CURSOR_POSITION_TIMEOUT_MS);
37+
do {
38+
if (const QPoint currentPosition = QCursor::pos(); targetGeometry.isValid() ? targetGeometry.contains(currentPosition) : positionsAreClose(currentPosition, targetPosition)) {
39+
return true;
40+
}
41+
std::this_thread::sleep_for(std::chrono::milliseconds(CURSOR_POSITION_POLL_INTERVAL_MS));
42+
} while (std::chrono::steady_clock::now() < deadline);
43+
44+
const QPoint currentPosition = QCursor::pos();
45+
return targetGeometry.isValid() ? targetGeometry.contains(currentPosition) : positionsAreClose(currentPosition, targetPosition);
46+
}
47+
48+
bool fallbackTrayIconPosition(QPoint *position) {
49+
const QScreen *screen = QGuiApplication::primaryScreen();
50+
if (screen == nullptr) {
51+
return false;
52+
}
53+
54+
const QRect screenGeometry = screen->geometry();
55+
const QRect availableGeometry = screen->availableGeometry();
56+
const int topInset = availableGeometry.top() - screenGeometry.top();
57+
const int bottomInset = screenGeometry.bottom() - availableGeometry.bottom();
58+
const int rightInset = screenGeometry.right() - availableGeometry.right();
59+
const int leftInset = availableGeometry.left() - screenGeometry.left();
60+
61+
if (topInset > 0) {
62+
*position = QPoint(screenGeometry.right() - (topInset / 2), screenGeometry.top() + (topInset / 2));
63+
} else if (bottomInset > 0) {
64+
*position = QPoint(screenGeometry.right() - (bottomInset / 2), screenGeometry.bottom() - (bottomInset / 2));
65+
} else if (rightInset > 0) {
66+
*position = QPoint(screenGeometry.right() - (rightInset / 2), screenGeometry.bottom() - (rightInset / 2));
67+
} else if (leftInset > 0) {
68+
*position = QPoint(screenGeometry.left() + (leftInset / 2), screenGeometry.bottom() - (leftInset / 2));
69+
} else {
70+
#if defined(_WIN32)
71+
*position = QPoint(screenGeometry.right() - (DEFAULT_PANEL_THICKNESS / 2), screenGeometry.bottom() - (DEFAULT_PANEL_THICKNESS / 2));
72+
#else
73+
*position = QPoint(screenGeometry.right() - (DEFAULT_PANEL_THICKNESS / 2), screenGeometry.top() + (DEFAULT_PANEL_THICKNESS / 2));
74+
#endif
75+
}
76+
return true;
77+
}
78+
} // namespace
79+
2280
QtTrayMenu::QtTrayMenu(QObject *parent, const bool debug):
2381
QtTrayMenu(-1, nullptr, parent, debug) {
2482
};
@@ -342,3 +400,45 @@ void QtTrayMenu::clickMessage() const {
342400
void QtTrayMenu::clearMessageCallback() const {
343401
notificationCallback = nullptr;
344402
}
403+
404+
bool QtTrayMenu::positionMouseOverIcon() {
405+
if (!trayIcon) {
406+
return false;
407+
}
408+
409+
const QRect iconGeometry = trayIcon->geometry();
410+
QPoint targetPosition;
411+
if (iconGeometry.isValid()) {
412+
targetPosition = iconGeometry.center();
413+
} else if (!fallbackTrayIconPosition(&targetPosition)) {
414+
qWarning("QtTrayMenu: tray icon geometry and screen-edge fallback are unavailable");
415+
return false;
416+
} else {
417+
qWarning("QtTrayMenu: tray icon geometry is unavailable; using the system panel edge");
418+
}
419+
420+
if (!mousePositionSaved) {
421+
savedMousePosition = QCursor::pos();
422+
mousePositionSaved = true;
423+
}
424+
QCursor::setPos(targetPosition);
425+
const bool positioned = waitForCursorPosition(targetPosition, iconGeometry);
426+
if (!positioned) {
427+
qWarning("QtTrayMenu: could not position the mouse over the tray icon");
428+
}
429+
return positioned;
430+
}
431+
432+
bool QtTrayMenu::restoreMousePosition() {
433+
if (!mousePositionSaved) {
434+
return false;
435+
}
436+
437+
QCursor::setPos(savedMousePosition);
438+
const bool restored = waitForCursorPosition(savedMousePosition);
439+
mousePositionSaved = false;
440+
if (!restored) {
441+
qWarning("QtTrayMenu: could not restore the saved mouse position");
442+
}
443+
return restored;
444+
}

src/QtTrayMenu.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// qt includes
1313
#include <QMenu>
1414
#include <QObject>
15+
#include <QPoint>
1516
#include <QString>
1617
#include <QSystemTrayIcon>
1718

@@ -110,6 +111,18 @@ class QtTrayMenu: public QObject {
110111
*/
111112
void clearMessageCallback() const;
112113

114+
/**
115+
* @brief Move the mouse cursor to the center of the tray icon.
116+
* @return true if the tray icon has valid screen geometry and the cursor was moved
117+
*/
118+
bool positionMouseOverIcon();
119+
120+
/**
121+
* @brief Restore the mouse cursor position saved by positionMouseOverIcon().
122+
* @return true if a saved position existed and the cursor was restored
123+
*/
124+
bool restoreMousePosition();
125+
113126
/**
114127
* @brief Check if QtTrayMenu supports messages
115128
* @return true if messages can be shown
@@ -150,6 +163,8 @@ class QtTrayMenu: public QObject {
150163
bool blockingEventLoop = false;
151164
struct tray_menu *getTrayMenuItem(const QAction *action);
152165
mutable std::function<void()> notificationCallback = nullptr;
166+
QPoint savedMousePosition;
167+
bool mousePositionSaved = false;
153168

154169
private slots:
155170
void onExitRequested();

src/tray.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ extern "C" {
7070
*/
7171
void tray_show_menu(void);
7272

73+
/**
74+
* @brief Position the mouse over the tray icon (for testing purposes).
75+
* @return 0 on success, -1 if the tray icon geometry is unavailable.
76+
*/
77+
int tray_position_mouse_over_icon(void);
78+
79+
/**
80+
* @brief Restore the mouse position saved by tray_position_mouse_over_icon().
81+
* @return 0 on success, -1 if no saved position exists or the cursor could not be restored.
82+
*/
83+
int tray_restore_mouse_position(void);
84+
7385
/**
7486
* @brief Simulate a notification click, invoking the notification callback (for testing purposes).
7587
*

src/tray_qt.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,20 @@ extern "C" {
221221
tray_qt::state().trayMenu->showMenu();
222222
}
223223

224+
int tray_position_mouse_over_icon(void) {
225+
if (tray_qt::state().trayMenu == nullptr) {
226+
return -1;
227+
}
228+
return tray_qt::state().trayMenu->positionMouseOverIcon() ? 0 : -1;
229+
}
230+
231+
int tray_restore_mouse_position(void) {
232+
if (tray_qt::state().trayMenu == nullptr) {
233+
return -1;
234+
}
235+
return tray_qt::state().trayMenu->restoreMousePosition() ? 0 : -1;
236+
}
237+
224238
void tray_simulate_menu_item_click(int index) {
225239
if (tray_qt::state().trayMenu == nullptr) {
226240
return;

tests/unit/test_tray.cpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ class TrayTest: public BaseTest {
126126

127127
// Capture a screenshot while the tray menu is open, then dismiss and exit.
128128
void captureMenuStateAndExit(const char *screenshotName) const {
129+
const bool positionMouse = lizardbyte::common::is_github_actions();
130+
int positionMouseResult = -1;
131+
if (positionMouse) {
132+
WaitForTrayReady();
133+
positionMouseResult = tray_position_mouse_over_icon();
134+
EXPECT_EQ(positionMouseResult, 0);
135+
}
136+
129137
std::atomic_bool exitRequested {false};
130138
std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168): C++17 has no std::jthread and this thread is explicitly joined
131139
EXPECT_TRUE(captureScreenshot(screenshotName));
@@ -141,6 +149,12 @@ class TrayTest: public BaseTest {
141149
std::this_thread::sleep_for(std::chrono::milliseconds(10));
142150
}
143151
capture_thread.join();
152+
if (positionMouse) {
153+
const int restoreMouseResult = tray_restore_mouse_position();
154+
if (positionMouseResult == 0) {
155+
EXPECT_EQ(restoreMouseResult, 0);
156+
}
157+
}
144158
}
145159

146160
static void hello_cb(struct tray_menu *) {
@@ -222,6 +236,7 @@ class TrayTest: public BaseTest {
222236

223237
void TearDown() override {
224238
ShutdownTray();
239+
tray_restore_mouse_position();
225240
BaseTest::TearDown();
226241
}
227242

@@ -236,7 +251,7 @@ class TrayTest: public BaseTest {
236251

237252
void WaitForNotificationReady() const {
238253
WaitForTrayReady();
239-
#if defined(_WIN32)
254+
#if defined(_WIN32) || defined(__APPLE__)
240255
if (lizardbyte::common::is_github_actions()) {
241256
for (int i = 0; i < 40; i++) {
242257
tray_loop(0);

tests/unit/test_tray_qt.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class TrayQtCoverageTest: public BaseTest {
100100
trayRunning = false;
101101
}
102102

103+
tray_restore_mouse_position();
103104
tray_set_log_callback(nullptr);
104105
BaseTest::TearDown();
105106
}
@@ -163,6 +164,8 @@ TEST_F(TrayQtCoverageTest, SimulateMenuClickSkipsNonTriggerableActions) {
163164
TEST_F(TrayQtCoverageTest, ApiCallsAreNoOpsBeforeInit) {
164165
tray_update(trayData);
165166
tray_show_menu();
167+
EXPECT_EQ(tray_position_mouse_over_icon(), -1);
168+
EXPECT_EQ(tray_restore_mouse_position(), -1);
166169
tray_simulate_menu_item_click(0);
167170
tray_simulate_notification_click();
168171
PumpEvents();

0 commit comments

Comments
 (0)