Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ jobs:
if: runner.os == 'macOS'
run: |
dependencies=(
"cliclick"
"cmake"
"doxygen"
"graphviz"
Expand All @@ -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"
Expand All @@ -144,19 +147,29 @@ 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##*,}"
done

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'
Expand Down
100 changes: 100 additions & 0 deletions src/QtTrayMenu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
* @brief Definitions for Qt tray menu implemenation
*/
// standard includes
#include <chrono>
#include <filesystem>
#include <thread>

// qt includes
#include <QApplication>
#include <QCursor>
#include <QDebug>
#include <QMouseEvent>
#include <QScreen>
#include <QStyle>

// local includes
Expand All @@ -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) {
};
Expand Down Expand Up @@ -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;
}
15 changes: 15 additions & 0 deletions src/QtTrayMenu.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// qt includes
#include <QMenu>
#include <QObject>
#include <QPoint>
#include <QString>
#include <QSystemTrayIcon>

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -150,6 +163,8 @@ class QtTrayMenu: public QObject {
bool blockingEventLoop = false;
struct tray_menu *getTrayMenuItem(const QAction *action);
mutable std::function<void()> notificationCallback = nullptr;
QPoint savedMousePosition;
bool mousePositionSaved = false;

private slots:
void onExitRequested();
Expand Down
12 changes: 12 additions & 0 deletions src/tray.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
14 changes: 14 additions & 0 deletions src/tray_qt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/test_tray.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 *) {
Expand Down Expand Up @@ -222,6 +236,7 @@ class TrayTest: public BaseTest {

void TearDown() override {
ShutdownTray();
tray_restore_mouse_position();
BaseTest::TearDown();
}

Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/test_tray_qt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class TrayQtCoverageTest: public BaseTest {
trayRunning = false;
}

tray_restore_mouse_position();
tray_set_log_callback(nullptr);
BaseTest::TearDown();
}
Expand Down Expand Up @@ -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();
Expand Down
Loading