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
}