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 62bfbc5..e099f0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,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}" @@ -49,11 +51,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/virtual_desktop@70bb8d394d1c92f6113aeec6ae9cc959a5763d15 # v2026.227.200013 + with: + appindicator-version: ${{ matrix.appindicator_type }} + environment: mate + - name: Setup Dependencies macOS if: runner.os == 'macOS' run: | @@ -62,9 +72,45 @@ jobs: cmake \ doxygen \ graphviz \ + imagemagick \ ninja \ node + - name: Fix macOS screen recording permissions + if: runner.os == 'macOS' + run: | + set -euo pipefail + + configure_system_tccdb() { + local values=$1 + local dbPath="/Library/Application Support/com.apple.TCC/TCC.db" + local sqlQuery="INSERT OR IGNORE INTO access VALUES($values);" + sudo sqlite3 "$dbPath" "$sqlQuery" + } + + configure_user_tccdb() { + local values=$1 + local dbPath="$HOME/Library/Application Support/com.apple.TCC/TCC.db" + local sqlQuery="INSERT OR IGNORE INTO access VALUES($values);" + sqlite3 "$dbPath" "$sqlQuery" + } + + systemValuesArray=( + "'kTCCServiceScreenCapture','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1599831148" + ) + for values in "${systemValuesArray[@]}"; do + configure_system_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}" + done + + userValuesArray=( + "'kTCCServiceScreenCapture','/bin/bash',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1583997993" + ) + for values in "${userValuesArray[@]}"; do + configure_user_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}" + done + + echo "macOS TCC permissions configured." + - name: Setup Dependencies Windows if: runner.os == 'Windows' uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2.30.0 @@ -76,6 +122,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 @@ -98,7 +145,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: | @@ -119,18 +166,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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + 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 @@ -159,7 +245,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..c54d843 --- /dev/null +++ b/.github/workflows/publish-screenshots.yml @@ -0,0 +1,258 @@ +--- +name: Publish Screenshots + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + actions: read + 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + path: run-screenshots + pattern: tray-screenshots-* + + - name: Prepare Matrix Screenshot Directories + run: | + mkdir -p prepared + + shopt -s nullglob + for artifact_dir in run-screenshots/tray-screenshots-*; do + [ -d "${artifact_dir}" ] || continue + matrix_name="${artifact_dir##*/tray-screenshots-}" + mkdir -p "prepared/${matrix_name}" + cp -R "${artifact_dir}/." "prepared/${matrix_name}/" + done + + if [ -z "$(find prepared -mindepth 1 -print -quit)" ]; then + echo "No screenshots were downloaded from CI artifacts." + exit 1 + fi + + echo "Prepared screenshot files:" + find prepared -type f | sort + + - name: Determine Context + id: context + env: + WORKFLOW_EVENT: ${{ github.event.workflow_run.event }} + WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + event_name="${WORKFLOW_EVENT}" + head_branch="${WORKFLOW_HEAD_BRANCH}" + pr_number="$(jq -r '.workflow_run.pull_requests[0].number // empty' "${GITHUB_EVENT_PATH}")" + + if [ -n "${pr_number}" ] && ! [[ "${pr_number}" =~ ^[0-9]+$ ]]; then + echo "Invalid pr_number value: ${pr_number}" + exit 1 + fi + + is_pr=false + if [ "${event_name}" = "pull_request" ] && [ -n "${pr_number}" ]; then + is_pr=true + fi + + is_master_push=false + if [ "${event_name}" = "push" ] && [ "${head_branch}" = "master" ]; then + is_master_push=true + fi + + { + echo "event_name=${event_name}" + echo "head_branch=${head_branch}" + echo "is_pr=${is_pr}" + echo "pr_number=${pr_number}" + echo "is_master_push=${is_master_push}" + } >> "${GITHUB_OUTPUT}" + + - name: Checkout Screenshots Branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: screenshots + path: screenshots-repo + + - name: Sync Screenshot Content + id: sync + if: steps.context.outputs.is_master_push == 'true' || steps.context.outputs.is_pr == 'true' + env: + PR_NUMBER: ${{ steps.context.outputs.pr_number }} + run: | + target_dir="" + + if [ "${{ steps.context.outputs.is_master_push }}" = "true" ]; then + target_dir="screenshots-repo/baseline" + elif [ "${{ steps.context.outputs.is_pr }}" = "true" ]; then + if ! [[ "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then + echo "Invalid PR number: ${PR_NUMBER}" + exit 1 + fi + target_dir="screenshots-repo/pull-requests/PR-${PR_NUMBER}" + else + echo "Unsupported workflow context for screenshot sync." + exit 1 + fi + + mkdir -p "${target_dir}" + + # Mirror the prepared set and delete files removed from CI output. + rsync -a --delete prepared/ "${target_dir}/" + + echo "target_dir=${target_dir}" >> "${GITHUB_OUTPUT}" + + - name: Build PR Screenshot Comparison Comment + if: steps.context.outputs.is_pr == 'true' + env: + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ steps.context.outputs.pr_number }} + BASELINE_ROOT: screenshots-repo/baseline + PR_ROOT: prepared + run: | + raw_base="https://raw.githubusercontent.com/${REPOSITORY}/screenshots" + baseline_root="${BASELINE_ROOT}" + pr_root="${PR_ROOT}" + + { + echo "## Screenshot Comparison" + echo + printf "PR #%s screenshots vs \`screenshots\` baseline.\n\n" "${PR_NUMBER}" + } > pr-comment.md + + tmp_baseline="$(mktemp)" + tmp_pr="$(mktemp)" + tmp_matrices="$(mktemp)" + + if [ -d "${baseline_root}" ]; then + find "${baseline_root}" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' >> "${tmp_matrices}" + fi + if [ -d "${pr_root}" ]; then + find "${pr_root}" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' >> "${tmp_matrices}" + fi + + if [ ! -s "${tmp_matrices}" ]; then + echo "No matrix screenshots were found in baseline or PR artifacts." >> pr-comment.md + rm -f "${tmp_baseline}" "${tmp_pr}" "${tmp_matrices}" + exit 0 + fi + + while IFS= read -r matrix; do + [ -n "${matrix}" ] || continue + + baseline_matrix="${baseline_root}/${matrix}" + pr_matrix="${pr_root}/${matrix}" + + : > "${tmp_baseline}" + : > "${tmp_pr}" + + if [ -d "${baseline_matrix}" ]; then + ( + cd "${baseline_matrix}" + find . -type f | sed 's#^\./##' | LC_ALL=C sort + ) > "${tmp_baseline}" + fi + + if [ -d "${pr_matrix}" ]; then + ( + cd "${pr_matrix}" + find . -type f | sed 's#^\./##' | LC_ALL=C sort + ) > "${tmp_pr}" + fi + + { + printf "### Matrix: \`%s\`\n\n" "${matrix}" + echo "| Image | Baseline | PR |" + echo "| --- | --- | --- |" + } >> pr-comment.md + + tmp_all="$(mktemp)" + cat "${tmp_baseline}" "${tmp_pr}" | LC_ALL=C sort -u > "${tmp_all}" + + if [ ! -s "${tmp_all}" ]; then + echo "| _(none)_ | | |" >> pr-comment.md + echo >> pr-comment.md + rm -f "${tmp_all}" + continue + fi + + while IFS= read -r rel_file; do + [ -n "${rel_file}" ] || continue + + baseline_cell="" + if grep -Fxq "${rel_file}" "${tmp_baseline}"; then + baseline_cell="" + fi + + pr_cell="" + if grep -Fxq "${rel_file}" "${tmp_pr}"; then + pr_cell="" + fi + + printf "| \`%s\` | %s | %s |\n" "${rel_file}" "${baseline_cell}" "${pr_cell}" >> pr-comment.md + done < "${tmp_all}" + + echo >> pr-comment.md + rm -f "${tmp_all}" + done < <(LC_ALL=C sort -u "${tmp_matrices}") + + rm -f "${tmp_baseline}" "${tmp_pr}" "${tmp_matrices}" + echo "Generated pr-comment.md" + + - name: Commit and Push Screenshot Changes + id: push + if: steps.context.outputs.is_master_push == 'true' || steps.context.outputs.is_pr == 'true' + env: + PR_NUMBER: ${{ steps.context.outputs.pr_number }} + WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + cd screenshots-repo + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + commit_message="" + if [ "${{ steps.context.outputs.is_master_push }}" = "true" ]; then + git add -A baseline + commit_message="chore: update screenshots (${WORKFLOW_HEAD_SHA})" + elif [ "${{ steps.context.outputs.is_pr }}" = "true" ]; then + if ! [[ "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then + echo "Invalid PR number: ${PR_NUMBER}" + exit 1 + fi + git add -A "pull-requests/PR-${PR_NUMBER}" + commit_message="chore: update PR-${PR_NUMBER} screenshots (${WORKFLOW_HEAD_SHA})" + else + echo "Unsupported workflow context for commit/push." + exit 1 + fi + + if git diff --cached --quiet; then + echo "has_changes=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + git commit -m "${commit_message}" + git push origin screenshots + echo "has_changes=true" >> "${GITHUB_OUTPUT}" + + - name: Post PR Comparison Comment + if: steps.context.outputs.is_pr == 'true' + uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue: ${{ steps.context.outputs.pr_number }} + message-path: pr-comment.md + message-id: screenshot-comparison + refresh-message-position: true 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..3b9c678 100644 --- a/src/tray_linux.c +++ b/src/tray_linux.c @@ -5,7 +5,12 @@ // standard includes #include #include +#include #include +#include + +// local includes +#include "tray.h" // lib includes #ifdef TRAY_AYATANA_APPINDICATOR @@ -17,10 +22,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. -// local includes -#include "tray.h" +// 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; static bool async_update_pending = false; static pthread_cond_t async_update_cv = PTHREAD_COND_INITIALIZER; @@ -29,6 +34,9 @@ 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 GtkMenu *current_popup = NULL; +static GtkWidget *menu_anchor_window = NULL; static void _tray_menu_cb(GtkMenuItem *item, gpointer data) { (void) item; @@ -67,8 +75,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 +112,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 +173,89 @@ void tray_update(struct tray *tray) { } } +static void _tray_popup(GtkMenu *menu) { + if (menu == NULL) { + return; + } + + // Dismiss any previously shown popup + if (current_popup != NULL) { + gtk_menu_popdown(current_popup); + current_popup = NULL; + } + if (menu_anchor_window != NULL) { + gtk_widget_destroy(menu_anchor_window); + menu_anchor_window = NULL; + } + + 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); + menu_anchor_window = anchor_window; + + while (gtk_events_pending()) { + gtk_main_iteration(); + } + + if (gtk_check_version(3, 22, 0) == NULL) { + GdkWindow *gdk_window = gtk_widget_get_window(anchor_window); + if (gdk_window != NULL) { + GdkRectangle rect = {0, 0, 1, 1}; + gtk_menu_popup_at_rect(menu, gdk_window, &rect, GDK_GRAVITY_NORTH_WEST, GDK_GRAVITY_NORTH_WEST, NULL); + } else { + gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + } + } else { + gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + } + current_popup = menu; + } else { + gtk_menu_popup(menu, NULL, NULL, NULL, NULL, 0, gtk_get_current_event_time()); + current_popup = menu; + } +} + +void tray_show_menu(void) { + _tray_popup(current_menu); +} + 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_popup != NULL) { + gtk_menu_popdown(current_popup); + current_popup = NULL; + } + + if (current_menu != NULL) { + g_object_unref(current_menu); + current_menu = NULL; + } + + if (menu_anchor_window != NULL) { + gtk_widget_destroy(menu_anchor_window); + menu_anchor_window = 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..79c589f 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,10 @@ void tray_update(struct tray *tray) { } } +void tray_show_menu(void) { + PostMessage(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 +328,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..6ae7c7b 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 @@ -31,12 +33,7 @@ class BaseTest: public ::testing::Test { // we can possibly use some internal googletest functions to capture stdout and stderr, but I have not tested this // https://stackoverflow.com/a/33186201/11214013 - BaseTest(): - sbuf {nullptr}, - pipe_stdout {nullptr}, - pipe_stderr {nullptr} { - // intentionally empty - } + BaseTest() = default; ~BaseTest() override = default; @@ -59,6 +56,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) } @@ -99,9 +98,22 @@ class BaseTest: public ::testing::Test { std::stringstream cout_buffer; // declare cout_buffer std::stringstream stdout_buffer; // declare stdout_buffer std::stringstream stderr_buffer; // declare stderr_buffer - std::streambuf *sbuf; - FILE *pipe_stdout; - FILE *pipe_stderr; + std::streambuf *sbuf {nullptr}; + FILE *pipe_stdout {nullptr}; + FILE *pipe_stderr {nullptr}; + bool screenshotsReady {false}; + + 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 +136,39 @@ class BaseTest: public ::testing::Test { } return returnCode; } + + bool ensureScreenshotReady() { + if (screenshotsReady) { + return true; + } + if (std::string reason; !screenshot::is_available(&reason)) { + screenshotUnavailableReason = reason; + return false; + } + if (const auto root = screenshot::output_root(); 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 { @@ -132,10 +177,7 @@ class LinuxTest: public BaseTest { #ifndef __linux__ GTEST_SKIP_("Skipping, this test is for Linux only."); #endif - } - - void TearDown() override { - BaseTest::TearDown(); + BaseTest::SetUp(); } }; @@ -145,23 +187,16 @@ class MacOSTest: public BaseTest { #if !defined(__APPLE__) || !defined(__MACH__) GTEST_SKIP_("Skipping, this test is for macOS only."); #endif - } - - void TearDown() override { - BaseTest::TearDown(); + BaseTest::SetUp(); } }; class WindowsTest: public BaseTest { protected: - void SetUp() override { + void SetUp() override { // NOSONAR(cpp:S1185) - contains platform skip logic, not a trivial override #ifndef _WIN32 GTEST_SKIP_("Skipping, this test is for Windows only."); #endif BaseTest::SetUp(); } - - void TearDown() override { - BaseTest::TearDown(); - } }; diff --git a/tests/screenshot_utils.cpp b/tests/screenshot_utils.cpp new file mode 100644 index 0000000..859e831 --- /dev/null +++ b/tests/screenshot_utils.cpp @@ -0,0 +1,250 @@ +// test includes +#include "screenshot_utils.h" + +// standard includes +#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 { +#if defined(__linux__) || defined(__APPLE__) + std::string quote_shell_path(const std::filesystem::path &path) { + const std::string input = path.string(); + std::string output; + output.reserve(input.size() + 2); + output.push_back('"'); + for (const char ch : input) { + if (ch == '"') { + output.append(R"(\")"); + } else { + output.push_back(ch); + } + } + output.push_back('"'); + return output; + } +#endif + +#ifdef _WIN32 + bool ensure_gdiplus() { + static std::once_flag gdiplusInitFlag; + static bool gdiplusReady = false; + static ULONG_PTR gdiplusToken = 0; + std::call_once(gdiplusInitFlag, []() { + Gdiplus::GdiplusStartupInput input; + gdiplusReady = Gdiplus::GdiplusStartup(&gdiplusToken, &input, nullptr) == Gdiplus::Ok; + }); + return gdiplusReady; + } + + bool ensure_dpi_awareness() { + static std::once_flag dpiFlag; + static bool dpiAware = false; + std::call_once(dpiFlag, []() { + using SetProcessDPIAwareFn = BOOL(WINAPI *)(); + auto *fn = reinterpret_cast( // NOSONAR(cpp:S3630) - required for GetProcAddress function pointer cast + GetProcAddress(GetModuleHandleA("user32.dll"), "SetProcessDPIAware") + ); + dpiAware = fn == nullptr || fn() == 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 = static_cast(static_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 { + + inline std::filesystem::path &output_root_ref() { + static std::filesystem::path g_outputRoot; // NOSONAR(cpp:S6018) - function-local static is intentional for lazy, TU-local initialization + return g_outputRoot; + } + + void initialize(const std::filesystem::path &rootDir) { + output_root_ref() = rootDir / "screenshots"; + std::error_code ec; + std::filesystem::create_directories(output_root_ref(), ec); + } + + std::filesystem::path output_root() { + return output_root_ref(); + } + +#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); + + if (HWND desktop = GetDesktopWindow(); (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; + } + if (bitmap.Save(file.wstring().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 (output_root_ref().empty()) { + return false; + } + auto file = output_root_ref() / (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..e6b082f 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -1,122 +1,251 @@ // test includes #include "tests/conftest.cpp" +// standard includes +#include +#include +#include +#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" - #define TRAY_ICON2 "mail-message-new" +constexpr const char *TRAY_ICON1 = "mail-message-new"; +constexpr const char *TRAY_ICON2 = "mail-message-new"; #elif TRAY_APPKIT - #define TRAY_ICON1 "icon.png" - #define TRAY_ICON2 "icon.png" +constexpr const char *TRAY_ICON1 = "icon.png"; +constexpr const char *TRAY_ICON2 = "icon.png"; #elif TRAY_WINAPI - #define TRAY_ICON1 "icon.ico" - #define TRAY_ICON2 "icon.ico" +constexpr const char *TRAY_ICON1 = "icon.ico"; +constexpr const char *TRAY_ICON2 = "icon.ico"; +#endif + +// File-scope tray data shared across all TrayTest instances +namespace { + struct tray_menu g_submenu7_8[] = { // NOSONAR(cpp:S5945, cpp:S5421) - C-style array with null sentinel required by tray C API; mutable for runtime callback assignment + {.text = "7", .cb = nullptr}, + {.text = "-"}, + {.text = "8", .cb = nullptr}, + {.text = nullptr} + }; + struct tray_menu g_submenu5_6[] = { // NOSONAR(cpp:S5945, cpp:S5421) - C-style array with null sentinel required by tray C API; mutable for runtime callback assignment + {.text = "5", .cb = nullptr}, + {.text = "6", .cb = nullptr}, + {.text = nullptr} + }; + struct tray_menu g_submenu_second[] = { // NOSONAR(cpp:S5945, cpp:S5421) - C-style array with null sentinel required by tray C API; mutable for runtime callback assignment + {.text = "THIRD", .submenu = g_submenu7_8}, + {.text = "FOUR", .submenu = g_submenu5_6}, + {.text = nullptr} + }; + struct tray_menu g_submenu[] = { // NOSONAR(cpp:S5945, cpp:S5421) - C-style array with null sentinel required by tray C API; mutable for runtime callback assignment + {.text = "Hello", .cb = nullptr}, + {.text = "Checked", .checked = 1, .checkbox = 1, .cb = nullptr}, + {.text = "Disabled", .disabled = 1}, + {.text = "-"}, + {.text = "SubMenu", .submenu = g_submenu_second}, + {.text = "-"}, + {.text = "Quit", .cb = nullptr}, + {.text = nullptr} + }; + struct tray g_testTray = { // NOSONAR(cpp:S5421) - mutable global required for shared tray state across TEST_F instances + .icon = TRAY_ICON1, + .tooltip = "TestTray", + .menu = g_submenu + }; +} // namespace + +class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must be protected for TEST_F-generated subclasses +protected: // NOSONAR(cpp:S3656) - TEST_F generates subclasses that need access to fixture state/methods + void ShutdownTray() { + if (!trayRunning) { + return; + } + tray_exit(); + tray_loop(0); + trayRunning = false; + } + + // Dismisses the open menu and exits the tray event loop from a background thread. + void closeMenuAndExit() { +#if defined(TRAY_WINAPI) + PostMessage(tray_get_hwnd(), WM_CANCELMODE, 0, 0); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); +#elif defined(TRAY_APPKIT) + CGEventRef event = CGEventCreateKeyboardEvent(NULL, kVK_Escape, true); + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + CGEventRef event2 = CGEventCreateKeyboardEvent(NULL, kVK_Escape, false); + CGEventPost(kCGHIDEventTap, event2); + CFRelease(event2); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); #endif + tray_exit(); + } + + // Capture a screenshot while the tray menu is open, then dismiss and exit. + void captureMenuStateAndExit(const char *screenshotName) { + std::thread capture_thread([this, screenshotName]() { // NOSONAR(cpp:S6168) - std::jthread is unavailable on AppleClang 17/libc++ used in CI + EXPECT_TRUE(captureScreenshot(screenshotName)); + closeMenuAndExit(); + }); -class TrayTest: public BaseTest { -protected: - static struct tray testTray; + tray_show_menu(); + while (tray_loop(0) == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + capture_thread.join(); + } - // Static arrays for submenus - static struct tray_menu submenu7_8[]; - static struct tray_menu submenu5_6[]; - static struct tray_menu submenu_second[]; - static struct tray_menu submenu[]; + bool trayRunning {false}; // NOSONAR(cpp:S3656) - protected access required by gtest TEST_F subclass pattern + struct tray &testTray = g_testTray; // NOSONAR(cpp:S3656) - protected access required by gtest TEST_F subclass pattern + struct tray_menu *submenu = g_submenu; // NOSONAR(cpp:S3656) - protected access required by gtest TEST_F subclass pattern + struct tray_menu *submenu7_8 = g_submenu7_8; // NOSONAR(cpp:S3656) - protected access required by gtest TEST_F subclass pattern + struct tray_menu *submenu5_6 = g_submenu5_6; // NOSONAR(cpp:S3656) - protected access required by gtest TEST_F subclass pattern + struct tray_menu *submenu_second = g_submenu_second; // NOSONAR(cpp:S3656) - protected access required by gtest TEST_F subclass pattern - // Non-static member functions - static void hello_cb(struct tray_menu *item) { + static void hello_cb([[maybe_unused]] struct tray_menu *item) { // Mock implementation } - static void toggle_cb(struct tray_menu *item) { - item->checked = !item->checked; - tray_update(&testTray); + static void toggle_cb([[maybe_unused]] struct tray_menu *item) { // NOSONAR(cpp:S1172) - unused param required by tray_menu.cb function pointer type + g_testTray.menu[1].checked = !g_testTray.menu[1].checked; + tray_update(&g_testTray); } - static void quit_cb(struct tray_menu *item) { + static void quit_cb([[maybe_unused]] struct tray_menu *item) { // NOSONAR(cpp:S1172) - unused param required by tray_menu.cb function pointer type tray_exit(); } - static void submenu_cb(struct tray_menu *item) { + static void submenu_cb([[maybe_unused]] struct tray_menu *item) { // NOSONAR(cpp:S1172) - unused param required by tray_menu.cb function pointer type // Mock implementation - tray_update(&testTray); + tray_update(&g_testTray); } void SetUp() override { + BaseTest::SetUp(); + + // Wire up callbacks (file-scope arrays can't use addresses of class statics at init time) + g_submenu[0].cb = hello_cb; + g_submenu[1].cb = toggle_cb; + g_submenu[6].cb = quit_cb; + g_submenu7_8[0].cb = submenu_cb; + g_submenu7_8[2].cb = submenu_cb; + g_submenu5_6[0].cb = submenu_cb; + g_submenu5_6[1].cb = submenu_cb; + + // 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 + std::filesystem::path projectRoot = testBinaryDir.parent_path(); + std::filesystem::path iconSource; + + if (std::filesystem::exists(projectRoot / "icons" / TRAY_ICON1)) { + iconSource = projectRoot / "icons" / TRAY_ICON1; + } else if (std::filesystem::exists(projectRoot / TRAY_ICON1)) { + iconSource = projectRoot / TRAY_ICON1; + } else if (std::filesystem::exists(std::filesystem::path(TRAY_ICON1))) { + iconSource = std::filesystem::path(TRAY_ICON1); + } + + 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; + testTray.menu = g_submenu; + g_submenu[1].checked = 1; } void TearDown() override { - // Clean up any resources if needed + ShutdownTray(); + BaseTest::TearDown(); } -}; -// Define the static arrays -struct tray_menu TrayTest::submenu7_8[] = { - {.text = "7", .cb = submenu_cb}, - {.text = "-"}, - {.text = "8", .cb = submenu_cb}, - {.text = nullptr} -}; -struct tray_menu TrayTest::submenu5_6[] = { - {.text = "5", .cb = submenu_cb}, - {.text = "6", .cb = submenu_cb}, - {.text = nullptr} -}; -struct tray_menu TrayTest::submenu_second[] = { - {.text = "THIRD", .submenu = submenu7_8}, - {.text = "FOUR", .submenu = submenu5_6}, - {.text = nullptr} -}; -struct tray_menu TrayTest::submenu[] = { - {.text = "Hello", .cb = hello_cb}, - {.text = "Checked", .checked = 1, .checkbox = 1, .cb = toggle_cb}, - {.text = "Disabled", .disabled = 1}, - {.text = "-"}, - {.text = "SubMenu", .submenu = submenu_second}, - {.text = "-"}, - {.text = "Quit", .cb = quit_cb}, - {.text = nullptr} -}; -struct tray TrayTest::testTray = { - .icon = TRAY_ICON1, - .tooltip = "TestTray", - .menu = submenu + // 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) + for (int i = 0; i < 100; i++) { + tray_loop(0); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +#elif defined(TRAY_APPKIT) + static std::thread::id main_thread_id = std::this_thread::get_id(); + if (std::this_thread::get_id() == main_thread_id) { + for (int i = 0; i < 100; i++) { + tray_loop(0); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + } +#endif + } }; 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); } 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"); // put back the original values testTray.icon = TRAY_ICON1; @@ -127,12 +256,323 @@ 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); } +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]); +} + +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"); +} + +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); +} + +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"); +} + +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]); +} + +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; + if (HRESULT ns = SHQueryUserNotificationState(¬ification_state); + 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; + if (HRESULT ns = SHQueryUserNotificationState(¬ification_state); + 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); + + // 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"); + + // Update tooltip + testTray.tooltip = "Updated Tooltip Text"; + tray_update(&testTray); + EXPECT_STREQ(testTray.tooltip, "Updated Tooltip Text"); + + // 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) { // NOSONAR(cpp:S995) - must match tray_menu.cb signature void(*)(struct tray_menu*) + if (item->context != nullptr) { + const auto *value = static_cast(item->context); + contextCallbackInvoked = (*value == 42); + } + }; + + // Create menu with context + std::array context_menu_arr = {{{.text = "Context Item", .cb = context_callback, .context = &contextValue}, {.text = nullptr}}}; + struct tray_menu *context_menu = context_menu_arr.data(); + + 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); + + // Restore original menu + testTray.menu = submenu; +} + +TEST_F(TrayTest, TestCheckboxStates) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + EXPECT_EQ(testTray.menu[1].checkbox, 1); + EXPECT_EQ(testTray.menu[1].checked, 1); + + // Show menu open with checkbox in checked state + captureMenuStateAndExit("tray_menu_checkbox_checked"); // NOSONAR(cpp:S6168) - helper uses std::thread for AppleClang 17 compatibility + + // Re-initialize tray with checkbox unchecked + trayRunning = false; + testTray.menu[1].checked = 0; + initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Show menu open with checkbox in unchecked state + captureMenuStateAndExit("tray_menu_checkbox_unchecked"); // NOSONAR(cpp:S6168) - helper uses std::thread for AppleClang 17 compatibility + + // Restore initial checked state + testTray.menu[1].checked = 1; +} + +TEST_F(TrayTest, TestMultipleIconUpdates) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Update icon multiple times + testTray.icon = TRAY_ICON2; + tray_update(&testTray); + + testTray.icon = TRAY_ICON1; + tray_update(&testTray); +} + +TEST_F(TrayTest, TestCompleteMenuHierarchy) { + int initResult = tray_init(&testTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify complete menu structure + int menuCount = 0; + for (const 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); +} + +TEST_F(TrayTest, TestIconPathArray) { +#if defined(TRAY_WINAPI) + // Test icon path array caching (Windows-specific feature) + // The tray struct has a flexible array member, so we allocate a raw buffer + // and use memcpy to initialize const fields before the object is used. + const size_t icon_count = 2; + const size_t buf_size = sizeof(struct tray) + icon_count * sizeof(const char *); + std::vector buf(buf_size, std::byte {0}); + auto *iconCacheTray = reinterpret_cast(buf.data()); // NOSONAR(cpp:S3630) - reinterpret_cast required to overlay struct onto raw buffer for flexible array member + + 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; + + // Write const fields via memcpy — const_cast is required to initialize const members in a C struct flexible array allocation + auto count_val = static_cast(icon_count); + std::memcpy(const_cast(&iconCacheTray->iconPathCount), &count_val, sizeof(count_val)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer + const char *icon1 = TRAY_ICON1; + const char *icon2 = TRAY_ICON2; + std::memcpy(const_cast(&iconCacheTray->allIconPaths[0]), &icon1, sizeof(icon1)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer + std::memcpy(const_cast(&iconCacheTray->allIconPaths[1]), &icon2, sizeof(icon2)); // NOSONAR(cpp:S859) - required to initialize const member in C struct allocated via raw buffer + + int initResult = tray_init(iconCacheTray); + trayRunning = (initResult == 0); + ASSERT_EQ(initResult, 0); + + // Verify initial icon + EXPECT_EQ(iconCacheTray->icon, TRAY_ICON1); + + // Switch to cached icon + iconCacheTray->icon = TRAY_ICON2; + tray_update(iconCacheTray); + // buf goes out of scope, no manual free needed +#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); + + testTray.icon = TRAY_ICON2; + tray_update(&testTray); +#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"); + + // 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); + + // Screenshot shows the full menu open, including the SubMenu entry that leads to nested items + captureMenuStateAndExit("tray_menu_shown"); // NOSONAR(cpp:S6168) - helper uses std::thread for AppleClang 17 compatibility +} + TEST_F(TrayTest, TestTrayExit) { tray_exit(); - // TODO: Check the state after tray_exit }