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..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}" @@ -54,11 +56,19 @@ 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: + appindicator-version: ${{ matrix.appindicator_type }} + environment: mate + - name: Setup Dependencies macOS if: runner.os == 'macOS' run: | @@ -67,9 +77,81 @@ jobs: cmake \ doxygen \ graphviz \ + imagemagick \ 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 @@ -81,6 +163,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 +186,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 +207,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 +286,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..2cebb17 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) { @@ -99,9 +119,37 @@ 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) { + [statusItem popUpStatusItemMenu:statusItem.menu]; } 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) { + 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 NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined location:NSMakePoint(0, 0) modifierFlags:0 diff --git a/src/tray_linux.c b/src/tray_linux.c index 4678c5e..b40b6c8 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,74 @@ void tray_update(struct tray *tray) { } } +void tray_show_menu(void) { + if (current_menu != NULL) { + // 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()); + } + } +} + 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..d04ad23 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,93 @@ 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 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: 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 } }; @@ -98,25 +192,36 @@ 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 result = tray_loop(1); - EXPECT_EQ(result, 0); // make sure return value is 0 + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + // 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")); } 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 +232,392 @@ 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]); + 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"); + 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); + 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"); + + 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]); + 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); + + 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); + + 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"); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_tooltip_initial")); + + // Update tooltip + testTray.tooltip = "Updated Tooltip Text"; + tray_update(&testTray); + EXPECT_STREQ(testTray.tooltip, "Updated Tooltip Text"); + 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); + + 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); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_checkbox_checked")); + + // Toggle checkbox + testTray.menu[1].checked = 0; + tray_update(&testTray); + 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); + + 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); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_cache_initial")); + + // Switch to cached icon + iconCacheTray->icon = TRAY_ICON2; + tray_update(iconCacheTray); + 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); + WaitForTrayReady(); + EXPECT_TRUE(captureScreenshot("tray_icon_cache_initial")); + + testTray.icon = TRAY_ICON2; + tray_update(&testTray); + 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"); + + 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 }