From 9319613d5e44e198baee30f7db7b661ea0fb41d8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:22:47 -0500 Subject: [PATCH 01/10] test: add screenshots --- .github/copilot-instructions.md | 11 + .github/workflows/ci.yml | 66 ++- .github/workflows/publish-screenshots.yml | 58 +++ docs/Doxyfile | 1 + src/tray.h | 17 + src/tray_darwin.m | 28 +- src/tray_linux.c | 54 ++- src/tray_windows.c | 13 +- tests/CMakeLists.txt | 9 + tests/conftest.cpp | 55 ++- tests/screenshot_utils.cpp | 248 +++++++++++ tests/screenshot_utils.h | 19 + tests/unit/test_tray.cpp | 504 +++++++++++++++++++++- 13 files changed, 1059 insertions(+), 24 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/publish-screenshots.yml create mode 100644 tests/screenshot_utils.cpp create mode 100644 tests/screenshot_utils.h diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6b2c3ad --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +On Windows we use msys2 and ucrt64 to compile. +You need to prefix commands with `C:\msys64\msys2_shell.cmd -defterm -here -no-start -ucrt64 -c`. + +Prefix build directories with `cmake-build-`. + +The test executable is named `test_tray` and will be located inside the `tests` directory within +the build directory. + +The project uses gtest as a test framework. + +Always follow the style guidelines defined in .clang-format for c/c++ code. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f1ee03..82b6dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,18 @@ jobs: build-essential \ cmake \ ${{ matrix.appindicator }} \ + imagemagick \ libglib2.0-dev \ libnotify-dev \ ninja-build \ xvfb + - name: Setup virtual desktop + if: runner.os == 'Linux' + uses: LizardByte/actions/actions/setup_virtual_desktop@feat/actions/linux-display # todo: pin version + with: + environment: mate + - name: Setup Dependencies macOS if: runner.os == 'macOS' run: | @@ -67,6 +74,7 @@ jobs: cmake \ doxygen \ graphviz \ + imagemagick \ ninja \ node @@ -81,6 +89,7 @@ jobs: mingw-w64-ucrt-x86_64-binutils mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-graphviz + mingw-w64-ucrt-x86_64-imagemagick mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-nodejs mingw-w64-ucrt-x86_64-toolchain @@ -103,7 +112,7 @@ jobs: # step output echo "python-path=${python_path}" - echo "python-path=${python_path}" >> $GITHUB_OUTPUT + echo "python-path=${python_path}" >> "${GITHUB_OUTPUT}" - name: Build run: | @@ -124,18 +133,57 @@ jobs: -S . ninja -C build + - name: Init tray icon (Windows) + if: runner.os == 'Windows' + working-directory: build/tests + run: ./test_tray --gtest_color=yes --gtest_filter=TrayTest.TestTrayInit + + - name: Configure Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + echo "::group::Enable all tray icons" + Invoke-WebRequest ` + -Uri "https://raw.githubusercontent.com/paulmann/windows-show-all-tray-icons/main/Enable-AllTrayIcons.ps1" ` + -OutFile "Enable-AllTrayIcons.ps1" + .\Enable-AllTrayIcons.ps1 -Action Enable -Force # Enable with comprehensive method (resets ALL icon settings) + echo "::endgroup::" + + echo "::group::Disable Do Not Disturb" + Add-Type -AssemblyName System.Windows.Forms + Start-Process "ms-settings:notifications" + Start-Sleep -Seconds 2 + [System.Windows.Forms.SendKeys]::SendWait("{TAB}") + [System.Windows.Forms.SendKeys]::SendWait("{TAB}") + [System.Windows.Forms.SendKeys]::SendWait(" ") + echo "::endgroup::" + + echo "::group::Minimize all windows" + $shell = New-Object -ComObject Shell.Application + $shell.MinimizeAll() + echo "::endgroup::" + + echo "::group::Set Date - Hack for Quiet Time" + $newDate = (Get-Date).AddHours(2) + Set-Date -Date $newDate + echo "::endgroup::" + - name: Run tests id: test # TODO: tests randomly hang on Linux, https://github.com/LizardByte/tray/issues/45 - timeout-minutes: 1 + timeout-minutes: 3 working-directory: build/tests - run: | - if [ "${{ runner.os }}" = "Linux" ]; then - export DISPLAY=:1 - Xvfb ${DISPLAY} -screen 0 1024x768x24 & - fi + run: ./test_tray --gtest_color=yes --gtest_output=xml:test_results.xml - ./test_tray --gtest_color=yes --gtest_output=xml:test_results.xml + - name: Upload screenshots + if: >- + always() && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@v6 + with: + name: tray-screenshots-${{ runner.os }}${{ matrix.appindicator && format('-{0}', matrix.appindicator) || '' }} + path: build/tests/screenshots + if-no-files-found: error - name: Generate gcov report id: test_report @@ -164,7 +212,7 @@ jobs: if [ -n "${{ matrix.appindicator }}" ]; then flags="${flags},${{ matrix.appindicator }}" fi - echo "flags=${flags}" >> $GITHUB_OUTPUT + echo "flags=${flags}" >> "${GITHUB_OUTPUT}" - name: Upload coverage # any except canceled or skipped diff --git a/.github/workflows/publish-screenshots.yml b/.github/workflows/publish-screenshots.yml new file mode 100644 index 0000000..efa0678 --- /dev/null +++ b/.github/workflows/publish-screenshots.yml @@ -0,0 +1,58 @@ +--- +name: Publish Screenshots + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + contents: write + pull-requests: write + +jobs: + publish: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Download Artifacts + uses: actions/download-artifact@v7 + with: + path: screenshots + pattern: tray-screenshots-* + run-id: ${{ github.event.workflow_run.id }} + + - name: Debug screenshots + run: ls -R screenshots + + - name: Determine Branch and Path + id: determine + env: + PULL_REQUEST_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + if [ -n "${PULL_REQUEST_NUMBER}" ]; then + PR_NUMBER=${PULL_REQUEST_NUMBER} + BRANCH_PATH="PR-${PULL_REQUEST_NUMBER}" + is_pr=true + else + BRANCH_NAME=$(echo "${HEAD_BRANCH}" | sed 's/\//-/g') + BRANCH_PATH="${BRANCH_NAME}" + is_pr=false + fi + + { + echo "branch_path=${BRANCH_PATH}" + echo "is_pr=${is_pr}" + echo "pr_number=${PR_NUMBER}" + } >> "${GITHUB_OUTPUT}" + + # debug outputs + cat "${GITHUB_OUTPUT}" + + - name: Checkout Screenshots Branch + uses: actions/checkout@v6 + with: + ref: screenshots + path: screenshots-repo diff --git a/docs/Doxyfile b/docs/Doxyfile index 77aaefb..3f004c6 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -31,6 +31,7 @@ PROJECT_NAME = tray DOT_GRAPH_MAX_NODES = 50 IMAGE_PATH = ../docs/images INCLUDE_PATH = +PREDEFINED += TRAY_WINAPI # files and directories to process USE_MDFILE_AS_MAINPAGE = ../README.md diff --git a/src/tray.h b/src/tray.h index ce28ef1..164a436 100644 --- a/src/tray.h +++ b/src/tray.h @@ -5,6 +5,10 @@ #ifndef TRAY_H #define TRAY_H +#if defined(TRAY_WINAPI) + #include +#endif + #ifdef __cplusplus extern "C" { #endif @@ -64,11 +68,24 @@ extern "C" { */ void tray_update(struct tray *tray); + /** + * @brief Force show the tray menu (for testing purposes). + */ + void tray_show_menu(void); + /** * @brief Terminate UI loop. */ void tray_exit(void); +#if defined(TRAY_WINAPI) + /** + * @brief Get the tray window handle. + * @return The window handle. + */ + HWND tray_get_hwnd(void); +#endif + #ifdef __cplusplus } // extern "C" #endif diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 4c644d2..b376c2d 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -39,9 +39,26 @@ - (IBAction)menuCallback:(id)sender { static NSApplication *app; static NSStatusBar *statusBar; static NSStatusItem *statusItem; +static int loopResult = 0; #define QUIT_EVENT_SUBTYPE 0x0DED ///< NSEvent subtype used to signal exit. +static void drain_quit_events(void) { + while (YES) { + NSEvent *event = [app nextEventMatchingMask:ULONG_MAX + untilDate:[NSDate distantPast] + inMode:[NSString stringWithUTF8String:"kCFRunLoopDefaultMode"] + dequeue:TRUE]; + if (event == nil) { + break; + } + if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { + continue; + } + [app sendEvent:event]; + } +} + static NSMenu *_tray_menu(struct tray_menu *m) { NSMenu *menu = [[NSMenu alloc] init]; [menu setAutoenablesItems:FALSE]; @@ -67,6 +84,7 @@ - (IBAction)menuCallback:(id)sender { } int tray_init(struct tray *tray) { + loopResult = 0; AppDelegate *delegate = [[AppDelegate alloc] init]; app = [NSApplication sharedApplication]; [app setDelegate:delegate]; @@ -74,6 +92,7 @@ int tray_init(struct tray *tray) { statusItem = [statusBar statusItemWithLength:NSVariableStatusItemLength]; tray_update(tray); [app activateIgnoringOtherApps:TRUE]; + drain_quit_events(); return 0; } @@ -85,12 +104,13 @@ int tray_loop(int blocking) { dequeue:TRUE]; if (event) { if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { - return -1; + loopResult = -1; + return loopResult; } [app sendEvent:event]; } - return 0; + return loopResult; } void tray_update(struct tray *tray) { @@ -101,6 +121,10 @@ void tray_update(struct tray *tray) { [statusItem setMenu:_tray_menu(tray->menu)]; } +void tray_show_menu(void) { + [statusItem popUpStatusItemMenu:statusItem.menu]; +} + void tray_exit(void) { NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined location:NSMakePoint(0, 0) diff --git a/src/tray_linux.c b/src/tray_linux.c index 4678c5e..3efc7eb 100644 --- a/src/tray_linux.c +++ b/src/tray_linux.c @@ -5,7 +5,9 @@ // standard includes #include #include +#include #include +#include // lib includes #ifdef TRAY_AYATANA_APPINDICATOR @@ -17,7 +19,10 @@ #define IS_APP_INDICATOR APP_IS_INDICATOR ///< Define IS_APP_INDICATOR for app-indicator compatibility. #endif #include -#define TRAY_APPINDICATOR_ID "tray-id" ///< Tray appindicator ID. + +// Use a per-process AppIndicator id to avoid DBus collisions when tests create multiple +// tray instances in the same desktop/session. +static unsigned long tray_appindicator_seq = 0; // local includes #include "tray.h" @@ -29,6 +34,7 @@ static pthread_mutex_t async_update_mutex = PTHREAD_MUTEX_INITIALIZER; static AppIndicator *indicator = NULL; static int loop_result = 0; static NotifyNotification *currentNotification = NULL; +static GtkMenu *current_menu = NULL; static void _tray_menu_cb(GtkMenuItem *item, gpointer data) { (void) item; @@ -67,8 +73,23 @@ int tray_init(struct tray *tray) { if (gtk_init_check(0, NULL) == FALSE) { return -1; } + + // If a previous tray instance wasn't fully torn down (common in unit tests), + // drop our references before creating a new indicator. + if (indicator != NULL) { + g_object_unref(G_OBJECT(indicator)); + indicator = NULL; + } + loop_result = 0; notify_init("tray-icon"); - indicator = app_indicator_new(TRAY_APPINDICATOR_ID, tray->icon, APP_INDICATOR_CATEGORY_APPLICATION_STATUS); + // The id is used as part of the exported DBus object path. + // Make it unique per *tray instance* to prevent collisions inside a single test process. + // Avoid underscores and other characters that may be normalized/stripped. + char appindicator_id[64]; + tray_appindicator_seq++; + snprintf(appindicator_id, sizeof(appindicator_id), "trayid%ld%lu", (long) getpid(), tray_appindicator_seq); + + indicator = app_indicator_new(appindicator_id, tray->icon, APP_INDICATOR_CATEGORY_APPLICATION_STATUS); if (indicator == NULL || !IS_APP_INDICATOR(indicator)) { return -1; } @@ -89,7 +110,13 @@ static gboolean tray_update_internal(gpointer user_data) { app_indicator_set_icon_full(indicator, tray->icon, tray->icon); // GTK is all about reference counting, so previous menu should be destroyed // here - app_indicator_set_menu(indicator, GTK_MENU(_tray_menu(tray->menu))); + GtkMenu *menu = GTK_MENU(_tray_menu(tray->menu)); + app_indicator_set_menu(indicator, menu); + if (current_menu != NULL) { + g_object_unref(current_menu); + } + current_menu = menu; + g_object_ref(current_menu); // Keep a reference for showing } if (tray->notification_text != 0 && strlen(tray->notification_text) > 0 && notify_is_initted()) { if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { @@ -144,12 +171,33 @@ void tray_update(struct tray *tray) { } } +void tray_show_menu(void) { + if (current_menu != NULL) { + gtk_menu_popup(current_menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + } +} + static gboolean tray_exit_internal(gpointer user_data) { + (void) user_data; + if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { int v = notify_notification_close(currentNotification, NULL); if (v == TRUE) { g_object_unref(G_OBJECT(currentNotification)); } + currentNotification = NULL; + } + + if (current_menu != NULL) { + g_object_unref(current_menu); + current_menu = NULL; + } + + if (indicator != NULL) { + // Make the indicator passive before unref to encourage a clean DBus unexport. + app_indicator_set_status(indicator, APP_INDICATOR_STATUS_PASSIVE); + g_object_unref(G_OBJECT(indicator)); + indicator = NULL; } notify_uninit(); return G_SOURCE_REMOVE; diff --git a/src/tray_windows.c b/src/tray_windows.c index 60de5a8..1f66588 100644 --- a/src/tray_windows.c +++ b/src/tray_windows.c @@ -3,9 +3,9 @@ * @brief System tray implementation for Windows. */ // standard includes -#include +#include // clang-format off -// build fails if shellapi.h is included before windows.h +// build fails if shellapi.h is included before Windows.h #include // clang-format on @@ -315,6 +315,11 @@ void tray_update(struct tray *tray) { } } +void tray_show_menu(void) { + // Simulate a right-click on the tray icon to show the menu + SendMessage(hwnd, WM_TRAY_CALLBACK_MESSAGE, 0, WM_RBUTTONUP); +} + void tray_exit(void) { Shell_NotifyIconW(NIM_DELETE, &nid); SendMessage(hwnd, WM_CLOSE, 0, 0); @@ -324,3 +329,7 @@ void tray_exit(void) { } UnregisterClass(WC_TRAY_CLASS_NAME, GetModuleHandle(NULL)); } + +HWND tray_get_hwnd(void) { + return hwnd; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4474708..f12b98a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,9 +18,17 @@ if (WIN32) 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") +elseif (WIN32) + set(TEST_LIBS gdi32 gdiplus) +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) add_executable(${PROJECT_NAME} @@ -29,6 +37,7 @@ add_executable(${PROJECT_NAME} set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) target_link_libraries(${PROJECT_NAME} + ${TEST_LIBS} ${TRAY_EXTERNAL_LIBRARIES} gtest gtest_main) # if we use this we don't need our own main function diff --git a/tests/conftest.cpp b/tests/conftest.cpp index 6f51eac..64ad934 100644 --- a/tests/conftest.cpp +++ b/tests/conftest.cpp @@ -1,11 +1,13 @@ // standard includes #include #include +#include // lib includes #include // test includes +#include "tests/screenshot_utils.h" #include "tests/utils.h" // Undefine the original TEST macro @@ -34,7 +36,8 @@ class BaseTest: public ::testing::Test { BaseTest(): sbuf {nullptr}, pipe_stdout {nullptr}, - pipe_stderr {nullptr} { + pipe_stderr {nullptr}, + screenshotsReady {false} { // intentionally empty } @@ -59,6 +62,8 @@ class BaseTest: public ::testing::Test { testBinaryDir = std::filesystem::current_path(); } + initializeScreenshotsOnce(); + sbuf = std::cout.rdbuf(); // save cout buffer (std::cout) std::cout.rdbuf(cout_buffer.rdbuf()); // redirect cout to buffer (std::cout) } @@ -102,6 +107,19 @@ class BaseTest: public ::testing::Test { std::streambuf *sbuf; FILE *pipe_stdout; FILE *pipe_stderr; + bool screenshotsReady; + + void initializeScreenshotsOnce() { + static std::once_flag screenshotInitFlag; + std::call_once(screenshotInitFlag, [this]() { + auto root = testBinaryDir; + if (!root.empty()) { + std::error_code ec; + std::filesystem::remove_all(root / "screenshots", ec); + } + screenshot::initialize(root); + }); + } int exec(const char *cmd) { std::array buffer {}; @@ -124,6 +142,41 @@ class BaseTest: public ::testing::Test { } return returnCode; } + + bool ensureScreenshotReady() { + if (screenshotsReady) { + return true; + } + std::string reason; + if (!screenshot::is_available(&reason)) { + screenshotUnavailableReason = reason; + return false; + } + auto root = screenshot::output_root(); + if (root.empty()) { + screenshotUnavailableReason = "Screenshot output directory not initialized"; + return false; + } + screenshotsReady = true; + return true; + } + + bool captureScreenshot(const std::string &name) { + if (!screenshotsReady) { + return false; + } + bool ok = screenshot::capture(name); + if (!ok) { + std::cout << "Failed to capture screenshot: " << name << std::endl; + } + return ok; + } + + std::filesystem::path screenshotsRoot() const { + return screenshot::output_root(); + } + + std::string screenshotUnavailableReason; }; class LinuxTest: public BaseTest { diff --git a/tests/screenshot_utils.cpp b/tests/screenshot_utils.cpp new file mode 100644 index 0000000..cbed19c --- /dev/null +++ b/tests/screenshot_utils.cpp @@ -0,0 +1,248 @@ +// test includes +#include "screenshot_utils.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +// clang-format off + // build fails if PropIdl.h and gdiplus.h are included before Windows.h + #include + #include +// clang-format on +#endif + +namespace { + std::filesystem::path g_outputRoot; + + std::string quote_shell_path(const std::filesystem::path &path) { + std::string input = path.string(); + std::string output; + output.reserve(input.size() + 2); + output.push_back('"'); + for (char ch : input) { + if (ch == '"') { + output.append("\\\""); + } else { + output.push_back(ch); + } + } + output.push_back('"'); + return output; + } + +#ifdef _WIN32 + std::once_flag gdiplusInitFlag; + bool gdiplusReady = false; + ULONG_PTR gdiplusToken = 0; + std::once_flag dpiFlag; + bool dpiAware = false; + + bool ensure_gdiplus() { + std::call_once(gdiplusInitFlag, []() { + Gdiplus::GdiplusStartupInput input; + gdiplusReady = Gdiplus::GdiplusStartup(&gdiplusToken, &input, nullptr) == Gdiplus::Ok; + }); + return gdiplusReady; + } + + bool ensure_dpi_awareness() { + std::call_once(dpiFlag, []() { + auto setDPIAware = reinterpret_cast(GetProcAddress(GetModuleHandleA("user32.dll"), "SetProcessDPIAware")); + dpiAware = setDPIAware == nullptr || setDPIAware() == TRUE; + }); + return dpiAware; + } + + bool png_encoder_clsid(CLSID *clsid) { + UINT num = 0; + UINT size = 0; + if (Gdiplus::GetImageEncodersSize(&num, &size) != Gdiplus::Ok || size == 0) { + return false; + } + std::vector buffer(size); + auto info = reinterpret_cast(buffer.data()); + if (Gdiplus::GetImageEncoders(num, size, info) != Gdiplus::Ok) { + return false; + } + for (UINT i = 0; i < num; ++i) { + if (wcscmp(info[i].MimeType, L"image/png") == 0) { + *clsid = info[i].Clsid; + return true; + } + } + return false; + } + +#endif +} // namespace + +namespace screenshot { + + void initialize(const std::filesystem::path &rootDir) { + g_outputRoot = rootDir / "screenshots"; + std::error_code ec; + std::filesystem::create_directories(g_outputRoot, ec); + } + + std::filesystem::path output_root() { + return g_outputRoot; + } + +#ifdef __APPLE__ + static bool capture_macos(const std::filesystem::path &file, const Options &) { + std::string cmd = "screencapture -x " + quote_shell_path(file); + return std::system(cmd.c_str()) == 0; + } +#endif + +#ifdef __linux__ + static bool capture_linux(const std::filesystem::path &file, const Options &) { + std::string target = quote_shell_path(file); + if (std::system("which import > /dev/null 2>&1") == 0) { + std::string cmd = "import -window root " + target; + if (std::system(cmd.c_str()) == 0) { + return true; + } + } + std::string cmd = "gnome-screenshot -f " + target; + return std::system(cmd.c_str()) == 0; + } +#endif + +#ifdef _WIN32 + static bool capture_windows(const std::filesystem::path &file, const Options &) { + if (!ensure_dpi_awareness()) { + std::cerr << "Failed to enable DPI awareness" << std::endl; + return false; + } + if (!ensure_gdiplus()) { + std::cerr << "GDI+ initialization failed" << std::endl; + return false; + } + + int left = GetSystemMetrics(SM_XVIRTUALSCREEN); + int top = GetSystemMetrics(SM_YVIRTUALSCREEN); + int width = GetSystemMetrics(SM_CXVIRTUALSCREEN); + int height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + + HWND desktop = GetDesktopWindow(); + if ((width <= 0 || height <= 0) && desktop != nullptr) { + RECT rect {}; + if (GetWindowRect(desktop, &rect)) { + left = rect.left; + top = rect.top; + width = rect.right - rect.left; + height = rect.bottom - rect.top; + } + } + if (width <= 0 || height <= 0) { + std::cerr << "Desktop dimensions invalid" << std::endl; + return false; + } + + HDC hdcScreen = GetDC(nullptr); + if (hdcScreen == nullptr) { + std::cerr << "GetDC(nullptr) failed" << std::endl; + return false; + } + HDC hdcMem = CreateCompatibleDC(hdcScreen); + if (hdcMem == nullptr) { + std::cerr << "CreateCompatibleDC failed" << std::endl; + ReleaseDC(nullptr, hdcScreen); + return false; + } + HBITMAP hbm = CreateCompatibleBitmap(hdcScreen, width, height); + if (hbm == nullptr) { + std::cerr << "CreateCompatibleBitmap failed" << std::endl; + DeleteDC(hdcMem); + ReleaseDC(nullptr, hdcScreen); + return false; + } + HGDIOBJ old = SelectObject(hdcMem, hbm); + BOOL ok = BitBlt(hdcMem, 0, 0, width, height, hdcScreen, left, top, SRCCOPY | CAPTUREBLT); + SelectObject(hdcMem, old); + DeleteDC(hdcMem); + ReleaseDC(nullptr, hdcScreen); + if (!ok) { + std::cerr << "BitBlt failed with error " << GetLastError() << std::endl; + DeleteObject(hbm); + return false; + } + + Gdiplus::Bitmap bitmap(hbm, nullptr); + DeleteObject(hbm); + + CLSID pngClsid; + if (!png_encoder_clsid(&pngClsid)) { + std::cerr << "PNG encoder CLSID not found" << std::endl; + return false; + } + std::wstring widePath = file.wstring(); + if (bitmap.Save(widePath.c_str(), &pngClsid, nullptr) != Gdiplus::Ok) { + std::cerr << "GDI+ failed to write " << file << std::endl; + return false; + } + return true; + } +#endif + + bool is_available(std::string *reason) { +#ifdef __APPLE__ + return true; +#elif defined(__linux__) + if (std::system("which import > /dev/null 2>&1") == 0 || std::system("which gnome-screenshot > /dev/null 2>&1") == 0) { + return true; + } + if (reason) { + *reason = "Neither ImageMagick 'import' nor gnome-screenshot found"; + } + return false; +#elif defined(_WIN32) + if (ensure_gdiplus()) { + return true; + } + if (reason) { + *reason = "Failed to initialize GDI+"; + } + return false; +#else + if (reason) { + *reason = "Unsupported platform"; + } + return false; +#endif + } + + bool capture(const std::string &name, const Options &options) { + // Add a delay to allow UI elements to render before capturing + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + if (g_outputRoot.empty()) { + return false; + } + auto file = g_outputRoot / (name + ".png"); + +#ifdef __APPLE__ + return capture_macos(file, options); +#elif defined(__linux__) + return capture_linux(file, options); +#elif defined(_WIN32) + return capture_windows(file, options); +#else + return false; +#endif + } + +} // namespace screenshot diff --git a/tests/screenshot_utils.h b/tests/screenshot_utils.h new file mode 100644 index 0000000..df98641 --- /dev/null +++ b/tests/screenshot_utils.h @@ -0,0 +1,19 @@ +#pragma once + +// standard includes +#include +#include +#include + +namespace screenshot { + + struct Options { + std::optional region; // reserved for future ROI support + }; + + void initialize(const std::filesystem::path &rootDir); + bool is_available(std::string *reason = nullptr); + bool capture(const std::string &name, const Options &options = {}); + std::filesystem::path output_root(); + +} // namespace screenshot diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index ab9560f..509db28 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -1,16 +1,27 @@ // test includes #include "tests/conftest.cpp" +// standard includes +#include +#include + #if defined(_WIN32) || defined(_WIN64) + #include +// clang-format off + // build fails if shellapi.h is included before Windows.h + #include + // clang-format on #define TRAY_WINAPI 1 #elif defined(__linux__) || defined(linux) || defined(__linux) #define TRAY_APPINDICATOR 1 #elif defined(__APPLE__) || defined(__MACH__) + #include #define TRAY_APPKIT 1 #endif // local includes #include "src/tray.h" +#include "tests/screenshot_utils.h" #if TRAY_APPINDICATOR #define TRAY_ICON1 "mail-message-new" @@ -26,6 +37,9 @@ class TrayTest: public BaseTest { protected: static struct tray testTray; + bool trayRunning; + std::string iconPath1; + std::string iconPath2; // Static arrays for submenus static struct tray_menu submenu7_8[]; @@ -53,13 +67,81 @@ class TrayTest: public BaseTest { } void SetUp() override { + BaseTest::SetUp(); + + // Skip tests if screenshot tooling is not available + if (!ensureScreenshotReady()) { + GTEST_SKIP() << "Screenshot tooling missing: " << screenshotUnavailableReason; + } + if (screenshot::output_root().empty()) { + GTEST_SKIP() << "Screenshot output path not initialized"; + } + +#if defined(TRAY_WINAPI) || defined(TRAY_APPKIT) + // Ensure icon files exist in test binary directory + // Look for icons in project root or cmake build directory + std::filesystem::path projectRoot = testBinaryDir.parent_path(); + std::filesystem::path iconSource; + + // Try icons directory first + if (std::filesystem::exists(projectRoot / "icons" / TRAY_ICON1)) { + iconSource = projectRoot / "icons" / TRAY_ICON1; + } + // Try project root + else if (std::filesystem::exists(projectRoot / TRAY_ICON1)) { + iconSource = projectRoot / TRAY_ICON1; + } + // Try current directory + else if (std::filesystem::exists(std::filesystem::path(TRAY_ICON1))) { + iconSource = std::filesystem::path(TRAY_ICON1); + } + + // Copy icon to test binary directory if not already there + 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; + } + } + } +#endif + + trayRunning = false; testTray.icon = TRAY_ICON1; testTray.tooltip = "TestTray"; testTray.menu = submenu; + submenu[1].checked = 1; // Reset checkbox state to initial value } void TearDown() override { - // Clean up any resources if needed + ShutdownTray(); + BaseTest::TearDown(); + } + + void ShutdownTray() { + if (!trayRunning) { + return; + } + tray_exit(); + tray_loop(0); + trayRunning = false; + } + + // Process pending GTK events to allow AppIndicator to register + // Call this ONLY before screenshots to ensure the icon is visible + void WaitForTrayReady() { +#if defined(TRAY_APPINDICATOR) + // On Linux with AppIndicator, process GTK events to allow D-Bus registration + // This ensures the icon actually appears in the system tray before screenshots + // Use shorter iterations to avoid interfering with event loop state + for (int i = 0; i < 100; i++) { + tray_loop(0); // Non-blocking - process pending events + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +#endif } }; @@ -98,25 +180,35 @@ struct tray TrayTest::testTray = { TEST_F(TrayTest, TestTrayInit) { int result = tray_init(&testTray); - EXPECT_EQ(result, 0); // make sure return value is 0 + trayRunning = (result == 0); + EXPECT_EQ(result, 0); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_initial")); } TEST_F(TrayTest, TestTrayLoop) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); int result = tray_loop(1); - EXPECT_EQ(result, 0); // make sure return value is 0 + EXPECT_EQ(result, 0); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_loop_iteration")); } TEST_F(TrayTest, TestTrayUpdate) { - // check the initial values + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); EXPECT_EQ(testTray.icon, TRAY_ICON1); - EXPECT_EQ(testTray.tooltip, "TestTray"); // update the values testTray.icon = TRAY_ICON2; testTray.tooltip = "TestTray2"; tray_update(&testTray); EXPECT_EQ(testTray.icon, TRAY_ICON2); - EXPECT_EQ(testTray.tooltip, "TestTray2"); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_updated")); // put back the original values testTray.icon = TRAY_ICON1; @@ -127,12 +219,410 @@ TEST_F(TrayTest, TestTrayUpdate) { } TEST_F(TrayTest, TestToggleCallback) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); bool initialCheckedState = testTray.menu[1].checked; toggle_cb(&testTray.menu[1]); EXPECT_EQ(testTray.menu[1].checked, !initialCheckedState); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_menu_toggle")); +} + +TEST_F(TrayTest, TestMenuItemCallback) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Test hello callback - it should work without crashing + ASSERT_NE(testTray.menu[0].cb, nullptr); + testTray.menu[0].cb(&testTray.menu[0]); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_menu_callback_hello")); +} + +TEST_F(TrayTest, TestDisabledMenuItem) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify disabled menu item + EXPECT_EQ(testTray.menu[2].disabled, 1); + EXPECT_STREQ(testTray.menu[2].text, "Disabled"); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_menu_disabled_item")); +} + +TEST_F(TrayTest, TestMenuSeparator) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify separator exists + EXPECT_STREQ(testTray.menu[3].text, "-"); + EXPECT_EQ(testTray.menu[3].cb, nullptr); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_menu_with_separator")); +} + +TEST_F(TrayTest, TestSubmenuStructure) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify submenu structure + EXPECT_STREQ(testTray.menu[4].text, "SubMenu"); + ASSERT_NE(testTray.menu[4].submenu, nullptr); + + // Verify nested submenu levels + EXPECT_STREQ(testTray.menu[4].submenu[0].text, "THIRD"); + ASSERT_NE(testTray.menu[4].submenu[0].submenu, nullptr); + EXPECT_STREQ(testTray.menu[4].submenu[0].submenu[0].text, "7"); + + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_submenu_structure")); +} + +TEST_F(TrayTest, TestSubmenuCallback) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Test submenu callback + ASSERT_NE(testTray.menu[4].submenu[0].submenu[0].cb, nullptr); + testTray.menu[4].submenu[0].submenu[0].cb(&testTray.menu[4].submenu[0].submenu[0]); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_submenu_callback_executed")); +} + +TEST_F(TrayTest, TestNotificationDisplay) { +#if !(defined(_WIN32) || defined(__linux__) || defined(__APPLE__)) + GTEST_SKIP() << "Notifications only supported on desktop platforms"; +#endif + +#if defined(_WIN32) + QUERY_USER_NOTIFICATION_STATE notification_state; + HRESULT ns = SHQueryUserNotificationState(¬ification_state); + if (ns != S_OK || notification_state != QUNS_ACCEPTS_NOTIFICATIONS) { + GTEST_SKIP() << "Notifications not accepted in this environment. SHQueryUserNotificationState result: " << ns << ", state: " << notification_state; + } +#endif + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Set notification properties + testTray.notification_title = "Test Notification"; + testTray.notification_text = "This is a test notification message"; + testTray.notification_icon = TRAY_ICON1; + + tray_update(&testTray); + tray_loop(1); + + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_notification_displayed")); + + // Clear notification + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + tray_update(&testTray); +} + +TEST_F(TrayTest, TestNotificationCallback) { +#if !(defined(_WIN32) || defined(__linux__) || defined(__APPLE__)) + GTEST_SKIP() << "Notifications only supported on desktop platforms"; +#endif + +#if defined(_WIN32) + QUERY_USER_NOTIFICATION_STATE notification_state; + HRESULT ns = SHQueryUserNotificationState(¬ification_state); + if (ns != S_OK || notification_state != QUNS_ACCEPTS_NOTIFICATIONS) { + GTEST_SKIP() << "Notifications not accepted in this environment. SHQueryUserNotificationState result: " << ns << ", state: " << notification_state; + } +#endif + + static bool callbackInvoked = false; + auto notification_callback = []() { + callbackInvoked = true; + }; + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Set notification with callback + testTray.notification_title = "Clickable Notification"; + testTray.notification_text = "Click this notification to test callback"; + testTray.notification_icon = TRAY_ICON1; + testTray.notification_cb = notification_callback; + + tray_update(&testTray); + tray_loop(1); + + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_notification_with_callback")); + + // Note: callback would be invoked by user interaction in real scenario + // In test environment, we verify it's set correctly + EXPECT_NE(testTray.notification_cb, nullptr); + + // Clear notification + testTray.notification_title = nullptr; + testTray.notification_text = nullptr; + testTray.notification_icon = nullptr; + testTray.notification_cb = nullptr; + tray_update(&testTray); +} + +TEST_F(TrayTest, TestTooltipUpdate) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Test initial tooltip + EXPECT_STREQ(testTray.tooltip, "TestTray"); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_tooltip_initial")); + + // Update tooltip + testTray.tooltip = "Updated Tooltip Text"; + tray_update(&testTray); + EXPECT_STREQ(testTray.tooltip, "Updated Tooltip Text"); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_tooltip_updated")); + + // Restore original tooltip + testTray.tooltip = "TestTray"; + tray_update(&testTray); +} + +TEST_F(TrayTest, TestMenuItemContext) { + static int contextValue = 42; + static bool contextCallbackInvoked = false; + + auto context_callback = [](struct tray_menu *item) { + if (item->context != nullptr) { + int *value = static_cast(item->context); + contextCallbackInvoked = (*value == 42); + } + }; + + // Create menu with context + struct tray_menu context_menu[] = { + {.text = "Context Item", .cb = context_callback, .context = &contextValue}, + {.text = nullptr} + }; + + testTray.menu = context_menu; + + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify context is set + EXPECT_EQ(testTray.menu[0].context, &contextValue); + + // Invoke callback with context + testTray.menu[0].cb(&testTray.menu[0]); + EXPECT_TRUE(contextCallbackInvoked); + + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_menu_with_context")); + + // Restore original menu + testTray.menu = submenu; +} + +TEST_F(TrayTest, TestCheckboxStates) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Test checkbox item + EXPECT_EQ(testTray.menu[1].checkbox, 1); + EXPECT_EQ(testTray.menu[1].checked, 1); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_checkbox_checked")); + + // Toggle checkbox + testTray.menu[1].checked = 0; + tray_update(&testTray); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_checkbox_unchecked")); + + // Toggle back + testTray.menu[1].checked = 1; + tray_update(&testTray); +} + +TEST_F(TrayTest, TestMultipleIconUpdates) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Capture initial icon + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_state1")); + + // Update icon multiple times + testTray.icon = TRAY_ICON2; + tray_update(&testTray); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_state2")); + + testTray.icon = TRAY_ICON1; + tray_update(&testTray); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_state3")); +} + +TEST_F(TrayTest, TestCompleteMenuHierarchy) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify complete menu structure + int menuCount = 0; + for (struct tray_menu *m = testTray.menu; m->text != nullptr; m++) { + menuCount++; + } + EXPECT_EQ(menuCount, 7); // Hello, Checked, Disabled, Sep, SubMenu, Sep, Quit + + // Verify all nested submenus + ASSERT_NE(testTray.menu[4].submenu, nullptr); + ASSERT_NE(testTray.menu[4].submenu[0].submenu, nullptr); + ASSERT_NE(testTray.menu[4].submenu[1].submenu, nullptr); + + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_complete_menu_hierarchy")); +} + +TEST_F(TrayTest, TestIconPathArray) { +#if defined(TRAY_WINAPI) + // Test icon path array caching (Windows-specific feature) + // Allocate memory for tray struct with flexible array member + const size_t icon_count = 2; + struct tray *iconCacheTray = (struct tray *) malloc( + sizeof(struct tray) + icon_count * sizeof(const char *) + ); + ASSERT_NE(iconCacheTray, nullptr); + + // Initialize the tray structure + iconCacheTray->icon = TRAY_ICON1; + iconCacheTray->tooltip = "Icon Cache Test"; + iconCacheTray->notification_icon = nullptr; + iconCacheTray->notification_text = nullptr; + iconCacheTray->notification_title = nullptr; + iconCacheTray->notification_cb = nullptr; + iconCacheTray->menu = submenu; + *const_cast(&iconCacheTray->iconPathCount) = icon_count; + *const_cast(&iconCacheTray->allIconPaths[0]) = TRAY_ICON1; + *const_cast(&iconCacheTray->allIconPaths[1]) = TRAY_ICON2; + + int initResult = tray_init(iconCacheTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify initial icon + EXPECT_EQ(iconCacheTray->icon, TRAY_ICON1); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_cache_initial")); + + // Switch to cached icon + iconCacheTray->icon = TRAY_ICON2; + tray_update(iconCacheTray); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_cache_updated")); + free(iconCacheTray); +#else + // On non-Windows platforms, just test basic icon switching + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + EXPECT_EQ(testTray.icon, TRAY_ICON1); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_cache_initial")); + + testTray.icon = TRAY_ICON2; + tray_update(&testTray); + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_cache_updated")); +#endif +} + +TEST_F(TrayTest, TestQuitCallback) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify quit callback exists + ASSERT_NE(testTray.menu[6].cb, nullptr); + EXPECT_STREQ(testTray.menu[6].text, "Quit"); + + tray_loop(1); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_before_quit")); + + // Note: Actually calling quit_cb would terminate the tray, + // which is tested separately in TestTrayExit +} + +TEST_F(TrayTest, TestTrayShowMenu) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Start a thread to capture screenshot and exit + std::thread capture_thread([this]() { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_menu_shown")); +#if defined(TRAY_WINAPI) + // Cancel the menu + PostMessage(tray_get_hwnd(), WM_CANCELMODE, 0, 0); + // Wait for menu to close + std::this_thread::sleep_for(std::chrono::milliseconds(100)); +#elif defined(TRAY_APPKIT) + // Simulate ESC key to dismiss menu + CGEventRef event = CGEventCreateKeyboardEvent(NULL, kVK_Escape, true); + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + CGEventRef event2 = CGEventCreateKeyboardEvent(NULL, kVK_Escape, false); + CGEventPost(kCGHIDEventTap, event2); + CFRelease(event2); + // Wait for menu to close + std::this_thread::sleep_for(std::chrono::milliseconds(100)); +#endif + // Exit the tray + tray_exit(); + }); + + // Show the menu programmatically + tray_show_menu(); + + tray_loop(1); + + capture_thread.join(); } TEST_F(TrayTest, TestTrayExit) { tray_exit(); - // TODO: Check the state after tray_exit } From be900e8627684340b7e4dbdb9f91bcd079e37585 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:12:53 -0500 Subject: [PATCH 02/10] Improve tray menu popup handling on Linux Adds logic to create an invisible toplevel window as an anchor for the tray menu, ensuring compatibility with AppIndicator and headless environments. Uses modern GTK API when available and falls back to legacy methods as needed. --- src/tray_linux.c | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/tray_linux.c b/src/tray_linux.c index 3efc7eb..b40b6c8 100644 --- a/src/tray_linux.c +++ b/src/tray_linux.c @@ -173,7 +173,48 @@ void tray_update(struct tray *tray) { void tray_show_menu(void) { if (current_menu != NULL) { - gtk_menu_popup(current_menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + // For AppIndicator, we need to show the menu manually since the indicator + // is managed by the system. In headless environments, we need to create + // a proper toplevel window for the menu to attach to. + + // Create an invisible toplevel window as an anchor + GtkWidget *anchor_window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + if (anchor_window != NULL) { + gtk_window_set_type_hint(GTK_WINDOW(anchor_window), GDK_WINDOW_TYPE_HINT_POPUP_MENU); + gtk_window_set_decorated(GTK_WINDOW(anchor_window), FALSE); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(anchor_window), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(anchor_window), TRUE); + gtk_window_move(GTK_WINDOW(anchor_window), 100, 100); + gtk_window_resize(GTK_WINDOW(anchor_window), 1, 1); + gtk_widget_show(anchor_window); + + // Give the window time to appear + while (gtk_events_pending()) { + gtk_main_iteration(); + } + + if (gtk_check_version(3, 22, 0) == NULL) { + // GTK 3.22+ - use modern API with the anchor window + GdkWindow *gdk_window = gtk_widget_get_window(anchor_window); + if (gdk_window != NULL) { + GdkRectangle rect = {0, 0, 1, 1}; + gtk_menu_popup_at_rect(current_menu, gdk_window, &rect, + GDK_GRAVITY_NORTH_WEST, GDK_GRAVITY_NORTH_WEST, NULL); + } else { + // Fallback + gtk_menu_popup(current_menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + } + } else { + // Older GTK - use deprecated API + gtk_menu_popup(current_menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + } + + // Note: We don't destroy anchor_window here as the menu needs it to stay visible + // It will be cleaned up when the application exits + } else { + // Fallback if window creation fails + gtk_menu_popup(current_menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + } } } From 204f4f91eb8d33332eb165ff56d0b4e44e32d4ec Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:15:05 -0500 Subject: [PATCH 03/10] Add tooltip support to tray on macOS Tray items on macOS now display a tooltip if the 'tooltip' field is set. This improves user experience by providing additional context when hovering over the tray icon. --- src/tray_darwin.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tray_darwin.m b/src/tray_darwin.m index b376c2d..7b4908a 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -119,6 +119,11 @@ void tray_update(struct tray *tray) { [image setSize:NSMakeSize(16, 16)]; statusItem.button.image = image; [statusItem setMenu:_tray_menu(tray->menu)]; + + // Set tooltip if provided + if (tray->tooltip != NULL) { + statusItem.button.toolTip = [NSString stringWithUTF8String:tray->tooltip]; + } } void tray_show_menu(void) { From 194c7a241c61e9665382849c32044ba59faa38ce Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:23:48 -0500 Subject: [PATCH 04/10] Remove redundant tray_loop calls from tray tests Eliminated unnecessary tray_loop(1) calls in unit tests for tooltip, checkbox, and icon updates. WaitForTrayReady is sufficient for synchronization, simplifying the test logic. --- tests/unit/test_tray.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 509db28..fbbcf63 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -388,7 +388,6 @@ TEST_F(TrayTest, TestTooltipUpdate) { // Test initial tooltip EXPECT_STREQ(testTray.tooltip, "TestTray"); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_tooltip_initial")); @@ -396,7 +395,6 @@ TEST_F(TrayTest, TestTooltipUpdate) { testTray.tooltip = "Updated Tooltip Text"; tray_update(&testTray); EXPECT_STREQ(testTray.tooltip, "Updated Tooltip Text"); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_tooltip_updated")); @@ -451,14 +449,12 @@ TEST_F(TrayTest, TestCheckboxStates) { // Test checkbox item EXPECT_EQ(testTray.menu[1].checkbox, 1); EXPECT_EQ(testTray.menu[1].checked, 1); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_checkbox_checked")); // Toggle checkbox testTray.menu[1].checked = 0; tray_update(&testTray); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_checkbox_unchecked")); @@ -538,14 +534,12 @@ TEST_F(TrayTest, TestIconPathArray) { // Verify initial icon EXPECT_EQ(iconCacheTray->icon, TRAY_ICON1); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_icon_cache_initial")); // Switch to cached icon iconCacheTray->icon = TRAY_ICON2; tray_update(iconCacheTray); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_icon_cache_updated")); free(iconCacheTray); @@ -556,13 +550,11 @@ TEST_F(TrayTest, TestIconPathArray) { ASSERT_EQ(initResult, 0); EXPECT_EQ(testTray.icon, TRAY_ICON1); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_icon_cache_initial")); testTray.icon = TRAY_ICON2; tray_update(&testTray); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_icon_cache_updated")); #endif From 07074755cd4fd41074fbd160868098c7ede4f183 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:54:22 -0500 Subject: [PATCH 05/10] Ensure status item is removed on main thread in tray_exit Updated tray_exit to remove the status item from the status bar using dispatch_async on the main thread, ensuring thread safety for NSStatusBar operations. --- src/tray_darwin.m | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tray_darwin.m b/src/tray_darwin.m index 7b4908a..b4f3761 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -131,6 +131,18 @@ void tray_show_menu(void) { } 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 + if (statusItem != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (statusItem != nil) { + [statusBar removeStatusItem:statusItem]; + statusItem = nil; + } + }); + } + + // Post exit event NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined location:NSMakePoint(0, 0) modifierFlags:0 From c0d388fa1726ae47a6cd8a91c73bdb3f94293364 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:19:20 -0500 Subject: [PATCH 06/10] Remove blocking tray_loop calls from tray tests Replaces or removes calls to tray_loop(1) in unit tests to prevent hanging during test execution. The TestTrayLoop now uses tray_loop(0) for non-blocking behavior, and other tests no longer invoke tray_loop, improving test reliability and speed. --- tests/unit/test_tray.cpp | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index fbbcf63..eee7ab5 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -190,7 +190,8 @@ TEST_F(TrayTest, TestTrayLoop) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); - int result = tray_loop(1); + // Test non-blocking loop (blocking=0) since blocking would hang without events + int result = tray_loop(0); EXPECT_EQ(result, 0); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_loop_iteration")); @@ -237,7 +238,6 @@ TEST_F(TrayTest, TestMenuItemCallback) { // Test hello callback - it should work without crashing ASSERT_NE(testTray.menu[0].cb, nullptr); testTray.menu[0].cb(&testTray.menu[0]); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_menu_callback_hello")); } @@ -250,7 +250,6 @@ TEST_F(TrayTest, TestDisabledMenuItem) { // Verify disabled menu item EXPECT_EQ(testTray.menu[2].disabled, 1); EXPECT_STREQ(testTray.menu[2].text, "Disabled"); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_menu_disabled_item")); } @@ -263,7 +262,6 @@ TEST_F(TrayTest, TestMenuSeparator) { // Verify separator exists EXPECT_STREQ(testTray.menu[3].text, "-"); EXPECT_EQ(testTray.menu[3].cb, nullptr); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_menu_with_separator")); } @@ -282,7 +280,6 @@ TEST_F(TrayTest, TestSubmenuStructure) { ASSERT_NE(testTray.menu[4].submenu[0].submenu, nullptr); EXPECT_STREQ(testTray.menu[4].submenu[0].submenu[0].text, "7"); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_submenu_structure")); } @@ -295,7 +292,6 @@ TEST_F(TrayTest, TestSubmenuCallback) { // Test submenu callback ASSERT_NE(testTray.menu[4].submenu[0].submenu[0].cb, nullptr); testTray.menu[4].submenu[0].submenu[0].cb(&testTray.menu[4].submenu[0].submenu[0]); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_submenu_callback_executed")); } @@ -323,7 +319,6 @@ TEST_F(TrayTest, TestNotificationDisplay) { testTray.notification_icon = TRAY_ICON1; tray_update(&testTray); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_notification_displayed")); @@ -364,7 +359,6 @@ TEST_F(TrayTest, TestNotificationCallback) { testTray.notification_cb = notification_callback; tray_update(&testTray); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_notification_with_callback")); @@ -433,7 +427,6 @@ TEST_F(TrayTest, TestMenuItemContext) { testTray.menu[0].cb(&testTray.menu[0]); EXPECT_TRUE(contextCallbackInvoked); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_menu_with_context")); @@ -501,7 +494,6 @@ TEST_F(TrayTest, TestCompleteMenuHierarchy) { ASSERT_NE(testTray.menu[4].submenu[0].submenu, nullptr); ASSERT_NE(testTray.menu[4].submenu[1].submenu, nullptr); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_complete_menu_hierarchy")); } @@ -569,7 +561,6 @@ TEST_F(TrayTest, TestQuitCallback) { ASSERT_NE(testTray.menu[6].cb, nullptr); EXPECT_STREQ(testTray.menu[6].text, "Quit"); - tray_loop(1); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_before_quit")); From ad2f6f09957a7de64e9e542c7be64457c2edfec2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:24:46 -0500 Subject: [PATCH 07/10] Fix status item removal to ensure main thread execution Updated tray_exit to check if the current thread is the main thread before removing the status item. If not on the main thread, the removal is dispatched synchronously to the main thread to ensure thread safety. --- src/tray_darwin.m | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/tray_darwin.m b/src/tray_darwin.m index b4f3761..2cebb17 100644 --- a/src/tray_darwin.m +++ b/src/tray_darwin.m @@ -134,12 +134,19 @@ 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 if (statusItem != nil) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (statusItem != nil) { - [statusBar removeStatusItem:statusItem]; - statusItem = nil; - } - }); + if ([NSThread isMainThread]) { + // Already on main thread, remove directly + [statusBar removeStatusItem:statusItem]; + statusItem = nil; + } else { + // On background thread, dispatch synchronously to main thread + dispatch_sync(dispatch_get_main_queue(), ^{ + if (statusItem != nil) { + [statusBar removeStatusItem:statusItem]; + statusItem = nil; + } + }); + } } // Post exit event From 77cbb4d1c6d51506214c0afbf31ae86921118fb6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:35:45 -0500 Subject: [PATCH 08/10] Improve WaitForTrayReady for macOS and Linux Enhanced the WaitForTrayReady method to handle macOS (AppKit) by adding a delay, ensuring the tray icon appears before screenshots. Updated comments for clarity and maintained Linux (AppIndicator) event processing. --- tests/unit/test_tray.cpp | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index eee7ab5..d04ad23 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -130,17 +130,29 @@ class TrayTest: public BaseTest { trayRunning = false; } - // Process pending GTK events to allow AppIndicator to register + // Process pending events to allow tray icon to appear // Call this ONLY before screenshots to ensure the icon is visible void WaitForTrayReady() { #if defined(TRAY_APPINDICATOR) - // On Linux with AppIndicator, process GTK events to allow D-Bus registration - // This ensures the icon actually appears in the system tray before screenshots - // Use shorter iterations to avoid interfering with event loop state + // On Linux: process GTK events to allow AppIndicator D-Bus registration for (int i = 0; i < 100; i++) { tray_loop(0); // Non-blocking - process pending events std::this_thread::sleep_for(std::chrono::milliseconds(5)); } +#elif defined(TRAY_APPKIT) + // On macOS: process events if on main thread, otherwise just sleep + // NSApp event loop must only be called from main thread + static std::thread::id main_thread_id = std::this_thread::get_id(); + if (std::this_thread::get_id() == main_thread_id) { + // Main thread - safe to call tray_loop + for (int i = 0; i < 100; i++) { + tray_loop(0); // Non-blocking - process pending events + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + } else { + // Background thread - just wait longer + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + } #endif } }; From bc45f667bdf0649d9d182ec7b27a196725c63639 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:22:55 -0500 Subject: [PATCH 09/10] Add macOS screen recording permission fix in CI Introduces a step to grant screen recording permissions on macOS runners by modifying the TCC database, preventing popup dialogs during CI runs. This ensures smoother automated workflows and addresses issues with screen capture access in GitHub Actions. --- .github/workflows/ci.yml | 71 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82b6dfc..f291140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,77 @@ jobs: ninja \ node + - name: Fix macOS screen recording permissions + if: runner.os == 'macOS' + run: | + # Grant screen recording permissions to prevent popup dialogs in CI + # This modifies the TCC (Transparency, Consent, and Control) database + + # https://apple.stackexchange.com/questions/362865/macos-list-apps-authorized-for-full-disk-access + # https://github.com/actions/runner-images/issues/9529 + # https://github.com/actions/runner-images/pull/9530 + + # Get macOS version + os_version=$(sw_vers -productVersion | cut -d '.' -f 1) + echo "macOS version: $os_version" + + # function to execute sql query for each value + function execute_sql_query { + local value=$1 + local dbPath=$2 + echo "Executing SQL query for value: $value" + sudo sqlite3 "$dbPath" "INSERT OR IGNORE INTO access VALUES($value);" + } + + # Find all provisioner paths and store them in an array + declare -a app_paths=() + while IFS= read -r line; do + app_paths+=("$line") + done < <(sudo find /opt /usr -name bash 2>/dev/null) + echo "Provisioner paths: ${app_paths[@]}" + + # Create an empty array + declare -a values=() + + # Loop through the provisioner paths and add them to the values array + for p_path in "${app_paths[@]}"; do + # Adjust the service name and other parameters as needed + values+=("'kTCCServiceAccessibility','${p_path}',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,NULL,1592919552") + values+=("'kTCCServiceScreenCapture','${p_path}',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,1687786159") + done + echo "Values: ${values[@]}" + + # Adjust for Sonoma (macOS 14+) which has extra columns + if [[ "$os_version" -ge 14 ]]; then + echo "Adjusting for macOS Sonoma or later (extra TCC columns)" + # TCC access table in Sonoma has extra 4 columns: pid, pid_version, boot_uuid, last_reminded + for i in "${!values[@]}"; do + values[$i]="${values[$i]},NULL,NULL,'UNUSED',${values[$i]##*,}" + done + fi + + # System and user TCC databases + dbPaths=( + "/Library/Application Support/com.apple.TCC/TCC.db" + "$HOME/Library/Application Support/com.apple.TCC/TCC.db" + ) + + # Execute SQL queries + for value in "${values[@]}"; do + for dbPath in "${dbPaths[@]}"; do + echo "Column names for $dbPath" + echo "-------------------" + sudo sqlite3 "$dbPath" "PRAGMA table_info(access);" + echo "Current permissions for $dbPath" + echo "-------------------" + sudo sqlite3 "$dbPath" "SELECT * FROM access WHERE service='kTCCServiceScreenCapture';" + execute_sql_query "$value" "$dbPath" + echo "Updated permissions for $dbPath" + echo "-------------------" + sudo sqlite3 "$dbPath" "SELECT * FROM access WHERE service='kTCCServiceScreenCapture';" + done + done + - name: Setup Dependencies Windows if: runner.os == 'Windows' uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2.30.0 From 2b9cd61e079b700a23caf7bb4946db581d48e84c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:19:39 -0500 Subject: [PATCH 10/10] Add appindicator_type to CI matrix and setup Introduces an appindicator_type variable to the CI matrix for distinguishing between 'ayatana' and 'legacy' appindicator packages. Passes this variable to the setup_virtual_desktop action to allow for more flexible environment configuration. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f291140..9231357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,11 @@ jobs: shell: "bash" - os: ubuntu-latest appindicator: "libayatana-appindicator3-dev" + appindicator_type: "ayatana" shell: "bash" - os: ubuntu-latest appindicator: "libappindicator3-dev" + appindicator_type: "legacy" shell: "bash" - os: windows-latest shell: "msys2 {0}" @@ -64,6 +66,7 @@ jobs: if: runner.os == 'Linux' uses: LizardByte/actions/actions/setup_virtual_desktop@feat/actions/linux-display # todo: pin version with: + appindicator-version: ${{ matrix.appindicator_type }} environment: mate - name: Setup Dependencies macOS