diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index dddfeaf4f..dcc9bce00 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -151,17 +151,20 @@ jobs: ./appimagelint-x86_64.AppImage ./artifacts/sunshine.AppImage - - name: Install test deps + - name: Install screenshot dependency run: | sudo apt-get update -y - sudo apt-get install -y \ - x11-xserver-utils \ - xvfb + sudo apt-get install -y imagemagick # clean apt cache sudo apt-get clean sudo rm -rf /var/lib/apt/lists/* + - name: Setup virtual desktop + uses: LizardByte/actions/actions/virtual_desktop@d0ae7f82215a479fe2b74f4088c53ee6460513dd # v2026.728.214955 + with: + environment: mate + - name: Setup python id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -182,12 +185,17 @@ jobs: - name: Run tests id: test working-directory: build/tests - run: | - export DISPLAY=:1 - Xvfb ${DISPLAY} -screen 0 1024x768x24 & - sleep 5 # give Xvfb time to start + run: ./test_sunshine --gtest_color=yes --gtest_output=xml:test_results.xml - ./test_sunshine --gtest_color=yes --gtest_output=xml:test_results.xml + - name: Upload system tray screenshots + if: >- + always() && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sunshine-screenshots-${{ matrix.name }} + path: build/tests/screenshots + if-no-files-found: error - name: Generate gcov report id: test_report diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index e56d81d32..8a8189ae5 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -94,6 +94,9 @@ jobs: qtbase \ qtsvg + - name: Configure macOS screen recording + uses: LizardByte/tray/.github/actions/configure-macos-screen-recording@master + - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -182,6 +185,16 @@ jobs: working-directory: build/tests run: ./test_sunshine --gtest_color=yes --gtest_output=xml:test_results.xml + - name: Upload system tray screenshots + if: >- + always() && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sunshine-screenshots-${{ matrix.name }} + path: build/tests/screenshots + if-no-files-found: error + - name: Generate gcov report id: test_report # any except canceled or skipped diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 36346607a..7544807d6 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -348,12 +348,34 @@ jobs: shell: msys2 {0} run: cat ./build/cpack_artifacts/_CPack_Packages/win64/WIX/wix.log || true + - name: Initialize tray icon + working-directory: build/tests + env: + SUNSHINE_CONFIGURE_TRAY_ICONS: '1' + run: >- + ./test_sunshine.exe + --gtest_color=yes + --gtest_filter=SystemTrayTest.InitializesTrayForWorkflowConfiguration + + - name: Configure Windows tray screenshots + uses: LizardByte/tray/.github/actions/configure-windows-tray-screenshots@master + - name: Run tests id: test shell: msys2 {0} working-directory: build/tests run: ./test_sunshine.exe --gtest_color=yes --gtest_output=xml:test_results.xml + - name: Upload system tray screenshots + if: >- + always() && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sunshine-screenshots-${{ matrix.name }} + path: build/tests/screenshots + if-no-files-found: error + - name: Generate gcov report id: test_report # any except canceled or skipped diff --git a/.github/workflows/publish-screenshots.yml b/.github/workflows/publish-screenshots.yml new file mode 100644 index 000000000..8702995a9 --- /dev/null +++ b/.github/workflows/publish-screenshots.yml @@ -0,0 +1,23 @@ +--- +name: Publish Screenshots + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: {} + +jobs: + publish: + if: github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure' + permissions: + actions: read + contents: write + pull-requests: write + statuses: write + uses: LizardByte/tray/.github/workflows/_publish-screenshots.yml@master + with: + artifact-name-prefix: sunshine-screenshots- + source-run-id: ${{ github.event.workflow_run.id }} diff --git a/cmake/packaging/common.cmake b/cmake/packaging/common.cmake index a094f0034..776266f70 100644 --- a/cmake/packaging/common.cmake +++ b/cmake/packaging/common.cmake @@ -29,6 +29,13 @@ foreach(asset ${ALL_ASSETS}) # Copy assets to build directory, excluding the we DESTINATION "${CMAKE_CURRENT_BINARY_DIR}/assets") endforeach() +# Copy the primary application icon into the built web assets for the system tray. +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/assets/web/images") +configure_file( + "${CMAKE_SOURCE_DIR}/sunshine.svg" + "${CMAKE_CURRENT_BINARY_DIR}/assets/web/images/logo-sunshine.svg" + COPYONLY) + # install built vite assets install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/assets/web" DESTINATION "${SUNSHINE_ASSETS_DIR}") diff --git a/cmake/packaging/linux.cmake b/cmake/packaging/linux.cmake index f6dfcda7b..2a6fad996 100644 --- a/cmake/packaging/linux.cmake +++ b/cmake/packaging/linux.cmake @@ -145,9 +145,6 @@ set(CPACK_RPM_PACKAGE_AUTOREQ ON) install(FILES "${CMAKE_SOURCE_DIR}/sunshine.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/apps" RENAME "${PROJECT_FQDN}.svg") -install(FILES "${CMAKE_SOURCE_DIR}/sunshine.svg" - DESTINATION "${SUNSHINE_ASSETS_DIR}/web/images" - RENAME "logo-sunshine.svg") # tray icon if(${SUNSHINE_TRAY} STREQUAL 1) diff --git a/packaging/linux/Arch/PKGBUILD b/packaging/linux/Arch/PKGBUILD index 11e45bfe2..cbcfd2879 100644 --- a/packaging/linux/Arch/PKGBUILD +++ b/packaging/linux/Arch/PKGBUILD @@ -74,6 +74,7 @@ makedepends=( checkdepends=( 'gcovr' + 'imagemagick' ) optdepends=( diff --git a/packaging/linux/copr/Sunshine.spec b/packaging/linux/copr/Sunshine.spec index 362ddf6c8..214c6d4d9 100644 --- a/packaging/linux/copr/Sunshine.spec +++ b/packaging/linux/copr/Sunshine.spec @@ -74,6 +74,7 @@ BuildRequires: systemd-udev BuildRequires: uv %{?sysusers_requires_compat} # for unit tests +BuildRequires: ImageMagick BuildRequires: xorg-x11-server-Xvfb %endif @@ -99,6 +100,7 @@ BuildRequires: udev BuildRequires: vulkan-devel %endif # for unit tests +BuildRequires: ImageMagick BuildRequires: xvfb-run %endif diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml index fda891544..b302a4868 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml @@ -36,6 +36,7 @@ cleanup: modules: # Test dependencies + - "modules/imagemagick.json" - "modules/xvfb/xvfb.json" # Build dependencies diff --git a/packaging/linux/flatpak/modules/imagemagick.json b/packaging/linux/flatpak/modules/imagemagick.json new file mode 100644 index 000000000..f4ee5aa60 --- /dev/null +++ b/packaging/linux/flatpak/modules/imagemagick.json @@ -0,0 +1,20 @@ +{ + "name": "imagemagick", + "cleanup": [ + "*" + ], + "config-opts": [ + "--disable-docs", + "--disable-static", + "--with-modules=no", + "--with-perl=no", + "--with-x" + ], + "sources": [ + { + "type": "archive", + "url": "https://download.imagemagick.org/archive/releases/ImageMagick-7.1.2-29.tar.xz", + "sha256": "bab096dc4b3089be4c0ebfdb90a232b85b7d982a99f722b30512fb243ea1e4ce" + } + ] +} diff --git a/packaging/sunshine.rb b/packaging/sunshine.rb index 1787e723e..0b8d43c92 100644 --- a/packaging/sunshine.rb +++ b/packaging/sunshine.rb @@ -70,6 +70,7 @@ class Sunshine < Formula depends_on "gcovr" => [:build, :test] depends_on "lizardbyte/homebrew/#{CUDA_FORMULA}" => :build depends_on "python3" => :build + depends_on "imagemagick" => :test depends_on "at-spi2-core" depends_on "avahi" depends_on "cairo" diff --git a/scripts/linux_build.sh b/scripts/linux_build.sh index 947378279..500cf9c8c 100755 --- a/scripts/linux_build.sh +++ b/scripts/linux_build.sh @@ -336,6 +336,7 @@ function add_debian_based_deps() { "git" "glslang-tools" # Vulkan shader compiler "graphviz" + "imagemagick" # necessary for system tray screenshots in unit tests "libcap-dev" # KMS "libcurl4-openssl-dev" "libdrm-dev" # KMS @@ -441,6 +442,7 @@ function add_fedora_deps() { "git" "glslc" "graphviz" + "ImageMagick" # necessary for system tray screenshots in unit tests "libappstream-glib" "libcap-devel" "libcurl-devel" diff --git a/src/system_tray.cpp b/src/system_tray.cpp index d1d0f11e6..5f5287883 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -5,6 +5,27 @@ // macros #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 + /** + * @def TRAY_ICON + * @brief Path to the default system tray icon. + */ + #define TRAY_ICON WEB_DIR "images/logo-sunshine.svg" + /** + * @def TRAY_ICON_PLAYING + * @brief Path to the system tray icon used while streaming. + */ + #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing.svg" + /** + * @def TRAY_ICON_PAUSING + * @brief Path to the system tray icon used while streaming is paused. + */ + #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing.svg" + /** + * @def TRAY_ICON_LOCKED + * @brief Path to the system tray icon used for pairing requests. + */ + #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked.svg" + #if defined(_WIN32) /** * @def WIN32_LEAN_AND_MEAN @@ -13,36 +34,7 @@ #define WIN32_LEAN_AND_MEAN #include #include - /** - * @def TRAY_ICON - * @brief Macro for TRAY ICON. - */ - #define TRAY_ICON WEB_DIR "images/sunshine.ico" - /** - * @def TRAY_ICON_PLAYING - * @brief Macro for TRAY ICON PLAYING. - */ - #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing.ico" - /** - * @def TRAY_ICON_PAUSING - * @brief Macro for TRAY ICON PAUSING. - */ - #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing.ico" - /** - * @def TRAY_ICON_LOCKED - * @brief Macro for TRAY ICON LOCKED. - */ - #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked.ico" - #elif defined(__linux__) || defined(linux) || defined(__linux) || defined(__FreeBSD__) - #define TRAY_ICON WEB_DIR "images/logo-sunshine.svg" - #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing.svg" - #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing.svg" - #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked.svg" #elif defined(__APPLE__) || defined(__MACH__) - #define TRAY_ICON WEB_DIR "images/logo-sunshine-16.png" - #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing-16.png" - #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing-16.png" - #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked-16.png" #include #include #include @@ -176,6 +168,25 @@ namespace system_tray { .allIconPaths = {TRAY_ICON, TRAY_ICON_LOCKED, TRAY_ICON_PLAYING, TRAY_ICON_PAUSING}, }; + #ifdef SUNSHINE_TESTS + const struct tray &tray_data_for_testing() { + return tray; + } + + bool tray_initialized_for_testing() { + return tray_initialized; + } + + void reset_tray_data_for_testing() { + tray.icon = tray.allIconPaths[0]; + tray.tooltip = PROJECT_NAME; + tray.notification_icon = nullptr; + tray.notification_text = nullptr; + tray.notification_title = nullptr; + tray.notification_cb = nullptr; + } + #endif + /** * @brief Get resource path. * diff --git a/src/system_tray.h b/src/system_tray.h index 2a1cd1b43..e79453998 100644 --- a/src/system_tray.h +++ b/src/system_tray.h @@ -96,4 +96,25 @@ namespace system_tray { * @return 0 if initialization was successful, non-zero otherwise. */ int init_tray_threaded(); + +#ifdef SUNSHINE_TESTS + /** + * @brief Get the tray data used by the system tray implementation. + * + * @return Read-only tray data for unit-test assertions. + */ + const struct tray &tray_data_for_testing(); + + /** + * @brief Check whether the system tray is initialized. + * + * @return true if the system tray is initialized; otherwise, false. + */ + bool tray_initialized_for_testing(); + + /** + * @brief Restore the persistent tray data to its initial state between tests. + */ + void reset_tray_data_for_testing(); +#endif } // namespace system_tray diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 56ebd4c5a..6e6fe5f06 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -110,6 +110,58 @@ endif() file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/tests/*.h ${CMAKE_SOURCE_DIR}/tests/*.cpp) +if(SUNSHINE_ENABLE_TRAY AND (WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux")) + set(SUNSHINE_TRAY_TEST_ICON_SOURCE_DIR + "${CMAKE_SOURCE_DIR}/src_assets/common/assets/web/public/images") + set(SUNSHINE_TRAY_TEST_ICON_DESTINATION_DIR + "${CMAKE_CURRENT_BINARY_DIR}/test_assets/web/images") + file(MAKE_DIRECTORY "${SUNSHINE_TRAY_TEST_ICON_DESTINATION_DIR}") + + configure_file( + "${CMAKE_SOURCE_DIR}/sunshine.svg" + "${SUNSHINE_TRAY_TEST_ICON_DESTINATION_DIR}/logo-sunshine.svg" + COPYONLY) + + set(SUNSHINE_TRAY_TEST_ICONS + sunshine-locked.svg + sunshine-playing.svg + sunshine-pausing.svg) + + foreach(icon IN LISTS SUNSHINE_TRAY_TEST_ICONS) + configure_file( + "${SUNSHINE_TRAY_TEST_ICON_SOURCE_DIR}/${icon}" + "${SUNSHINE_TRAY_TEST_ICON_DESTINATION_DIR}/${icon}" + COPYONLY) + endforeach() + + add_library(sunshine_tray_test_helpers STATIC + ${CMAKE_SOURCE_DIR}/third-party/tray/tests/notification_utils.cpp + ${CMAKE_SOURCE_DIR}/third-party/tray/tests/screenshot_utils.cpp) + set_target_properties(sunshine_tray_test_helpers PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON) + target_include_directories(sunshine_tray_test_helpers PRIVATE + ${CMAKE_SOURCE_DIR}/third-party/tray) + target_link_libraries(sunshine_tray_test_helpers PRIVATE + lizardbyte::common) + + if(WIN32) + target_link_libraries(sunshine_tray_test_helpers PRIVATE + gdi32 + gdiplus) + else() + find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Gui) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Gui) + set(SUNSHINE_TRAY_TEST_QT_GUI_TARGET Qt${QT_VERSION_MAJOR}::Gui) + + target_link_libraries(sunshine_tray_test_helpers PRIVATE + ${SUNSHINE_TRAY_TEST_QT_GUI_TARGET}) + if(APPLE) + target_link_libraries(sunshine_tray_test_helpers PRIVATE + "-framework Cocoa") + endif() + endif() +endif() # Add macOS-specific test files only when building tests for macOS if (APPLE) file(GLOB_RECURSE MACOS_TEST_SOURCES CONFIGURE_DEPENDS @@ -173,6 +225,15 @@ set(TEST_LINK_LIBRARIES ${PLATFORM_LIBRARIES} ) +if(SUNSHINE_ENABLE_TRAY AND (WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux")) + list(APPEND TEST_LINK_LIBRARIES + sunshine_tray_test_helpers) + if(SUNSHINE_TRAY_TEST_QT_GUI_TARGET) + list(APPEND TEST_LINK_LIBRARIES + ${SUNSHINE_TRAY_TEST_QT_GUI_TARGET}) + endif() +endif() + # Add the specific libgcov library if found if(GCOV_LINK_LIBRARY) list(APPEND TEST_LINK_LIBRARIES ${GCOV_LINK_LIBRARY}) diff --git a/tests/unit/test_system_tray.cpp b/tests/unit/test_system_tray.cpp new file mode 100644 index 000000000..7f323f5b2 --- /dev/null +++ b/tests/unit/test_system_tray.cpp @@ -0,0 +1,421 @@ +/** + * @file tests/unit/test_system_tray.cpp + * @brief Tests for Sunshine's system tray integration. + */ +#include "../tests_common.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// Only test the system tray if it is enabled. +#if defined(SUNSHINE_TRAY) && SUNSHINE_TRAY >= 1 + + // lib includes + #include + + // local includes + #include + + #if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) + // lib includes + #include + + // test includes + #include "third-party/tray/tests/notification_utils.h" + #include "third-party/tray/tests/screenshot_utils.h" + #endif + +namespace { + using namespace std::chrono_literals; + + #ifndef _WIN32 + /** + * @brief Process pending Qt tray events without blocking. + * + * @param iterations Maximum number of event iterations to process. + * @param delay Delay between event iterations. + */ + void pump_tray_events(const int iterations = 100, const std::chrono::milliseconds delay = 5ms) { + for (int i = 0; i < iterations; ++i) { + if (tray_loop(0) != 0) { + return; + } + std::this_thread::sleep_for(delay); + } + } + #else + + /** + * @brief Wait for the threaded tray worker to finish initialization. + * + * @param timeout Maximum time to wait. + * @return true if the tray initialized before the timeout; otherwise, false. + */ + bool wait_for_tray_initialization(const std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (system_tray::tray_initialized_for_testing()) { + return true; + } + std::this_thread::sleep_for(10ms); + } + return false; + } + #endif + + /** + * @brief Verify the persistent menu exposed by Sunshine. + */ + void verify_menu() { + const auto &tray_data = system_tray::tray_data_for_testing(); + ASSERT_NE(tray_data.menu, nullptr); + + EXPECT_STREQ(tray_data.menu[0].text, "Open Sunshine"); + EXPECT_NE(tray_data.menu[0].cb, nullptr); + EXPECT_STREQ(tray_data.menu[1].text, "-"); + EXPECT_EQ(tray_data.menu[1].cb, nullptr); + EXPECT_STREQ(tray_data.menu[2].text, "Donate"); + ASSERT_NE(tray_data.menu[2].submenu, nullptr); + EXPECT_STREQ(tray_data.menu[2].submenu[0].text, "GitHub Sponsors"); + EXPECT_STREQ(tray_data.menu[2].submenu[1].text, "Patreon"); + EXPECT_STREQ(tray_data.menu[2].submenu[2].text, "PayPal"); + EXPECT_EQ(tray_data.menu[2].submenu[3].text, nullptr); + EXPECT_STREQ(tray_data.menu[3].text, "-"); + + #ifdef _WIN32 + EXPECT_STREQ(tray_data.menu[4].text, "Reset Display Device Config"); + EXPECT_NE(tray_data.menu[4].cb, nullptr); + EXPECT_STREQ(tray_data.menu[5].text, "Restart"); + EXPECT_NE(tray_data.menu[5].cb, nullptr); + EXPECT_STREQ(tray_data.menu[6].text, "Quit"); + EXPECT_NE(tray_data.menu[6].cb, nullptr); + EXPECT_EQ(tray_data.menu[7].text, nullptr); + #else + EXPECT_STREQ(tray_data.menu[4].text, "Restart"); + EXPECT_NE(tray_data.menu[4].cb, nullptr); + EXPECT_STREQ(tray_data.menu[5].text, "Quit"); + EXPECT_NE(tray_data.menu[5].cb, nullptr); + EXPECT_EQ(tray_data.menu[6].text, nullptr); + #endif + } + + /** + * @brief Verify the current user-visible tray state. + * + * @param icon_index Index of the expected tray icon. + * @param tooltip Expected tray tooltip. + * @param notification_title Expected notification title, or nullptr when none is expected. + * @param notification_text Expected notification text, or nullptr when none is expected. + * @param notification_icon_index Index of the expected notification icon, or no value when none is expected. + * @param expects_notification_callback Whether a notification callback is expected. + */ + void verify_state( + const std::size_t icon_index, + const char *tooltip, + const char *notification_title, + const char *notification_text, + const std::optional notification_icon_index, + const bool expects_notification_callback + ) { + const auto &tray_data = system_tray::tray_data_for_testing(); + ASSERT_GE(tray_data.iconPathCount, 4); + + EXPECT_STREQ(tray_data.icon, tray_data.allIconPaths[icon_index]); + EXPECT_STREQ(tray_data.tooltip, tooltip); + EXPECT_STREQ(tray_data.notification_title, notification_title); + EXPECT_STREQ(tray_data.notification_text, notification_text); + if (notification_icon_index.has_value()) { + EXPECT_STREQ(tray_data.notification_icon, tray_data.allIconPaths[*notification_icon_index]); + } else { + EXPECT_EQ(tray_data.notification_icon, nullptr); + } + if (expects_notification_callback) { + EXPECT_NE(tray_data.notification_cb, nullptr); + } else { + EXPECT_EQ(tray_data.notification_cb, nullptr); + } + } + + /** + * @brief Verify every user-visible tray state transition. + */ + #ifndef _WIN32 + void verify_state_transitions() { + verify_state(0, PROJECT_NAME, nullptr, nullptr, std::nullopt, false); + + system_tray::update_tray_playing("Moonlight"); + verify_state(2, "Streaming started for Moonlight", "Stream Started", "Streaming started for Moonlight", 2, false); + + system_tray::update_tray_pausing("Moonlight"); + verify_state(3, "Streaming paused for Moonlight", "Stream Paused", "Streaming paused for Moonlight", 3, false); + + system_tray::update_tray_stopped("Moonlight"); + verify_state(0, PROJECT_NAME, "Application Stopped", "Application Moonlight successfully stopped", 0, false); + + system_tray::update_tray_require_pin(); + verify_state(0, PROJECT_NAME, "Incoming Pairing Request", "Click here to complete the pairing process", 1, true); + } + #endif +} // namespace + +/** + * @brief Fixture that resets the process-wide tray state between tests. + */ +class SystemTrayTest: public testing::Test { +protected: + /** + * @brief Reset any tray state left by an earlier test. + */ + void SetUp() override { + EXPECT_EQ(system_tray::end_tray(), 0); + #ifndef _WIN32 + std::ignore = tray_loop(0); + std::ignore = tray_restore_mouse_position(); + #endif + system_tray::reset_tray_data_for_testing(); + } + + /** + * @brief Shut down and reset the tray after a test. + */ + void TearDown() override { + #if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) + dismissNativeNotifications(); + #endif + EXPECT_EQ(system_tray::end_tray(), 0); + #ifdef _WIN32 + std::this_thread::sleep_for(250ms); + #endif + #ifndef _WIN32 + std::ignore = tray_loop(0); + std::ignore = tray_restore_mouse_position(); + #endif + system_tray::reset_tray_data_for_testing(); + } + + /** + * @brief Initialize the tray and process its initial events. + * + * @return The result returned by system_tray::init_tray(). + */ + int initialize_tray() const { + const int result = system_tray::init_tray(); + #ifndef _WIN32 + if (result == 0) { + pump_tray_events(); + } + #endif + return result; + } +}; + +TEST_F(SystemTrayTest, UpdatesAreIgnoredBeforeInitialization) { + EXPECT_FALSE(system_tray::tray_initialized_for_testing()); + EXPECT_EQ(system_tray::process_tray_events(), 1); + + system_tray::update_tray_playing("Moonlight"); + system_tray::update_tray_pausing("Moonlight"); + system_tray::update_tray_stopped("Moonlight"); + system_tray::update_tray_require_pin(); + + const auto &tray_data = system_tray::tray_data_for_testing(); + EXPECT_STREQ(tray_data.icon, tray_data.allIconPaths[0]); + EXPECT_STREQ(tray_data.tooltip, PROJECT_NAME); + EXPECT_EQ(tray_data.notification_title, nullptr); + EXPECT_EQ(tray_data.notification_text, nullptr); + EXPECT_EQ(tray_data.notification_icon, nullptr); + EXPECT_EQ(tray_data.notification_cb, nullptr); + EXPECT_EQ(system_tray::end_tray(), 0); +} + + #ifndef _WIN32 +TEST_F(SystemTrayTest, LifecycleMenuAndStateTransitions) { + if (const int result = initialize_tray(); result != 0) { + GTEST_SKIP() << "System tray is unavailable in this environment (code " << result << ")"; + } + + EXPECT_TRUE(system_tray::tray_initialized_for_testing()); + verify_menu(); + verify_state_transitions(); + + std::jthread exit_thread([]() { + std::this_thread::sleep_for(100ms); + std::ignore = system_tray::end_tray(); + }); + EXPECT_NE(system_tray::process_tray_events(), 0); + exit_thread.join(); + EXPECT_FALSE(system_tray::tray_initialized_for_testing()); +} + #endif + + #ifdef _WIN32 +TEST_F(SystemTrayTest, InitializesTrayForWorkflowConfiguration) { + if (std::getenv("SUNSHINE_CONFIGURE_TRAY_ICONS") == nullptr) { + GTEST_SKIP() << "Only required while configuring Windows runner tray icon visibility"; + } + + ASSERT_EQ(system_tray::init_tray_threaded(), 0); + ASSERT_TRUE(wait_for_tray_initialization(10s)); + verify_menu(); + EXPECT_EQ(system_tray::end_tray(), 0); +} + #endif + + #if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) +/** + * @brief Fixture that stores visual tray evidence in the test output directory. + */ +class SystemTrayVisualTest: public SystemTrayTest { +protected: + /** + * @brief Initialize a clean screenshot output directory for this test suite. + */ + static void SetUpTestSuite() { + const std::filesystem::path test_binary_dir {SUNSHINE_TEST_BIN_DIR}; + std::error_code remove_error; + std::filesystem::remove_all(test_binary_dir / "screenshots", remove_error); + screenshot::initialize(test_binary_dir); + } + + /** + * @brief Wait for a tray notification to be rendered. + */ + static void wait_for_notification() { + #ifndef _WIN32 + pump_tray_events(); + #endif + #if defined(_WIN32) || defined(__APPLE__) + if (lizardbyte::common::is_github_actions()) { + #ifdef _WIN32 + std::this_thread::sleep_for(2s); + #else + pump_tray_events(40, 50ms); + #endif + } + #endif + std::this_thread::sleep_for(500ms); + } + + /** + * @brief Capture a screenshot and dismiss its native notification. + * + * @param name Screenshot filename without the PNG extension. + */ + static void capture_notification(const std::string &name) { + wait_for_notification(); + EXPECT_TRUE(screenshot::capture(name)); + waitForNativeNotificationTimeout(); + } + + /** + * @brief Initialize the tray on the platform's production execution path. + * + * @return Zero when initialization succeeds; otherwise, a non-zero error code. + */ + int initialize_visual_tray() const { + #ifdef _WIN32 + if (const int result = system_tray::init_tray_threaded(); result != 0) { + return result; + } + return wait_for_tray_initialization(10s) ? 0 : 1; + #else + return initialize_tray(); + #endif + } +}; + +TEST_F(SystemTrayVisualTest, CapturesIconTooltipNotificationsAndMenu) { + std::string unavailable_reason; + if (!screenshot::is_available(&unavailable_reason)) { + GTEST_SKIP() << "Screenshot tooling is unavailable: " << unavailable_reason; + } + + const auto &tray_data = system_tray::tray_data_for_testing(); + ASSERT_EQ(tray_data.iconPathCount, 4); + for (int icon_index = 0; icon_index < tray_data.iconPathCount; ++icon_index) { + ASSERT_NE(tray_data.allIconPaths[icon_index], nullptr); + ASSERT_TRUE(std::filesystem::is_regular_file(tray_data.allIconPaths[icon_index])) + << "Missing system tray test icon: " << tray_data.allIconPaths[icon_index]; + } + + if (const int result = initialize_visual_tray(); result != 0) { + GTEST_SKIP() << "System tray is unavailable in this environment (code " << result << ")"; + } + + verify_menu(); + verify_state(0, PROJECT_NAME, nullptr, nullptr, std::nullopt, false); + EXPECT_TRUE(screenshot::capture("sunshine_tray_initial")); + + const int tooltip_position_result = tray_position_mouse_over_icon(); + if (lizardbyte::common::is_github_actions()) { + ASSERT_EQ(tooltip_position_result, 0); + } + if (tooltip_position_result == 0) { + #ifdef _WIN32 + std::this_thread::sleep_for(1s); + #else + pump_tray_events(20, 50ms); + #endif + EXPECT_TRUE(screenshot::capture("sunshine_tray_tooltip")); + EXPECT_EQ(tray_restore_mouse_position(), 0); + } + + dismissNativeNotifications(); + system_tray::update_tray_playing("Moonlight"); + verify_state(2, "Streaming started for Moonlight", "Stream Started", "Streaming started for Moonlight", 2, false); + capture_notification("sunshine_tray_streaming"); + system_tray::update_tray_pausing("Moonlight"); + verify_state(3, "Streaming paused for Moonlight", "Stream Paused", "Streaming paused for Moonlight", 3, false); + capture_notification("sunshine_tray_paused"); + system_tray::update_tray_stopped("Moonlight"); + verify_state(0, PROJECT_NAME, "Application Stopped", "Application Moonlight successfully stopped", 0, false); + capture_notification("sunshine_tray_stopped"); + system_tray::update_tray_require_pin(); + verify_state(0, PROJECT_NAME, "Incoming Pairing Request", "Click here to complete the pairing process", 1, true); + capture_notification("sunshine_tray_pairing_request"); + + int menu_position_result = -1; + if (lizardbyte::common::is_github_actions()) { + #ifdef _WIN32 + std::this_thread::sleep_for(500ms); + #else + pump_tray_events(); + #endif + menu_position_result = tray_position_mouse_over_icon(); + ASSERT_EQ(menu_position_result, 0); + } + + std::atomic_bool capture_result {false}; + std::jthread capture_thread([&capture_result]() { + capture_result.store(screenshot::capture("sunshine_tray_menu"), std::memory_order_release); + std::ignore = system_tray::end_tray(); + }); + + tray_show_menu(); + if (menu_position_result == 0) { + EXPECT_EQ(tray_restore_mouse_position(), 0); + } + #ifdef _WIN32 + capture_thread.join(); + #else + EXPECT_NE(system_tray::process_tray_events(), 0); + capture_thread.join(); + #endif + EXPECT_TRUE(capture_result.load(std::memory_order_acquire)); +} + #endif + +#else + +TEST(SystemTrayDisabled, ReportsDisabledBuild) { + GTEST_SKIP() << "System tray support is disabled in this build"; +} + +#endif