/** * @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 #include // Only test the system tray if it is enabled. #if defined(SUNSHINE_TRAY) && SUNSHINE_TRAY >= 1 // lib includes #include #ifdef _WIN32 #include #endif // 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 #ifdef _WIN32 /** * @brief Verify the persistent Virtual HID Driver benefits submenu. * * @param benefits_menu Benefits submenu to verify. */ void verify_virtualhid_benefits_menu(const struct tray_menu *benefits_menu) { ASSERT_NE(benefits_menu, nullptr); EXPECT_STREQ(benefits_menu[0].text, "Xbox One, Xbox Series, DualSense (DS5), Switch Pro, and Generic"); EXPECT_EQ(benefits_menu[0].disabled, 1); EXPECT_STREQ(benefits_menu[1].text, "Motion, touchpads, LEDs, and adaptive triggers where supported"); EXPECT_EQ(benefits_menu[1].disabled, 1); EXPECT_STREQ(benefits_menu[2].text, "Actively developed and supported by LizardByte"); EXPECT_EQ(benefits_menu[2].disabled, 1); EXPECT_STREQ(benefits_menu[3].text, "-"); EXPECT_STREQ(benefits_menu[4].text, "Open License Settings"); EXPECT_NE(benefits_menu[4].cb, nullptr); EXPECT_EQ(benefits_menu[5].text, nullptr); } /** * @brief Verify the shared action entries in a populated Virtual HID Driver menu. * * @param license_menu License submenu to verify. * @param benefits_index Index of the shared benefits action. */ void verify_virtualhid_actions_menu(const struct tray_menu *license_menu, std::size_t benefits_index) { ASSERT_NE(license_menu, nullptr); EXPECT_STREQ(license_menu[benefits_index].text, "Benefits over ViGEmBus"); EXPECT_EQ(license_menu[benefits_index].cb, nullptr); verify_virtualhid_benefits_menu(license_menu[benefits_index].submenu); EXPECT_STREQ(license_menu[benefits_index + 1U].text, "Download Virtual HID Driver"); EXPECT_NE(license_menu[benefits_index + 1U].cb, nullptr); EXPECT_EQ(license_menu[benefits_index + 2U].text, nullptr); } #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); #ifdef _WIN32 EXPECT_STREQ(tray_data.menu[2].text, "Virtual HID Driver"); ASSERT_NE(tray_data.menu[2].submenu, nullptr); EXPECT_STREQ(tray_data.menu[2].submenu[0].text, "Status: Checking"); EXPECT_EQ(tray_data.menu[2].submenu[0].disabled, 1); EXPECT_STREQ(tray_data.menu[2].submenu[1].text, "-"); EXPECT_STREQ(tray_data.menu[2].submenu[2].text, "Open License Settings"); EXPECT_NE(tray_data.menu[2].submenu[2].cb, nullptr); EXPECT_STREQ(tray_data.menu[2].submenu[3].text, "Benefits over ViGEmBus"); EXPECT_EQ(tray_data.menu[2].submenu[3].cb, nullptr); verify_virtualhid_benefits_menu(tray_data.menu[2].submenu[3].submenu); EXPECT_STREQ(tray_data.menu[2].submenu[4].text, "Download Virtual HID Driver"); EXPECT_NE(tray_data.menu[2].submenu[4].cb, nullptr); EXPECT_EQ(tray_data.menu[2].submenu[5].text, nullptr); EXPECT_STREQ(tray_data.menu[3].text, "-"); EXPECT_STREQ(tray_data.menu[4].text, "Donate"); ASSERT_NE(tray_data.menu[4].submenu, nullptr); EXPECT_STREQ(tray_data.menu[4].submenu[0].text, "GitHub Sponsors"); EXPECT_STREQ(tray_data.menu[4].submenu[1].text, "Patreon"); EXPECT_STREQ(tray_data.menu[4].submenu[2].text, "PayPal"); EXPECT_EQ(tray_data.menu[4].submenu[3].text, nullptr); EXPECT_STREQ(tray_data.menu[5].text, "-"); EXPECT_STREQ(tray_data.menu[6].text, "Reset Display Device Config"); EXPECT_NE(tray_data.menu[6].cb, nullptr); EXPECT_STREQ(tray_data.menu[7].text, "Restart"); EXPECT_NE(tray_data.menu[7].cb, nullptr); EXPECT_STREQ(tray_data.menu[8].text, "Quit"); EXPECT_NE(tray_data.menu[8].cb, nullptr); EXPECT_EQ(tray_data.menu[9].text, nullptr); #else 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, "-"); 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); } #ifdef _WIN32 TEST_F(SystemTrayTest, ResolvesDevelopmentTrayIconsFromExecutableDirectory) { EXPECT_EQ(system_tray::resource_path_for_testing(nullptr), nullptr); EXPECT_EQ(system_tray::resource_path_for_testing(""), nullptr); const auto *sunshine_icon = system_tray::resource_path_for_testing("test_assets/web/images/logo-sunshine.svg"); ASSERT_NE(sunshine_icon, nullptr); EXPECT_TRUE(std::filesystem::path {sunshine_icon}.is_absolute()); EXPECT_TRUE(std::filesystem::exists(sunshine_icon)); EXPECT_EQ(system_tray::resource_path_for_testing("test_assets/web/images/logo-sunshine.svg"), sunshine_icon); const auto *virtualhid_icon = system_tray::resource_path_for_testing("test_assets/web/images/logo-libvirtualhid.svg"); ASSERT_NE(virtualhid_icon, nullptr); EXPECT_TRUE(std::filesystem::path {virtualhid_icon}.is_absolute()); EXPECT_TRUE(std::filesystem::exists(virtualhid_icon)); } TEST_F(SystemTrayTest, PreparesVirtualHidMenuFromCurrentLicenseStatus) { system_tray::prepare_tray_virtualhid_license(); const auto &tray_data = system_tray::tray_data_for_testing(); ASSERT_NE(tray_data.menu[2].submenu, nullptr); ASSERT_NE(tray_data.menu[2].submenu[0].text, nullptr); EXPECT_STRNE(tray_data.menu[2].submenu[0].text, "Status: Checking"); } TEST_F(SystemTrayTest, PreparesLicensedVirtualHidMenuBeforeInitialization) { lvh::LicenseStatus license; license.service_available = true; license.state = lvh::LicenseState::licensed; license.plan_name = "Yearly"; license.customer_email = "customer@example.com"; license.activation_usage = 2; license.activation_limit = 5; system_tray::update_tray_virtualhid_license(license, true); const auto &tray_data = system_tray::tray_data_for_testing(); const auto *license_menu = tray_data.menu[2].submenu; ASSERT_NE(license_menu, nullptr); EXPECT_STREQ(license_menu[0].text, "Status: Licensed"); EXPECT_STREQ(license_menu[1].text, "Plan: Yearly"); EXPECT_STREQ(license_menu[2].text, "Customer: customer@example.com"); EXPECT_STREQ(license_menu[3].text, "Machine activations: 2 / 5"); EXPECT_STREQ(license_menu[4].text, "-"); EXPECT_STREQ(license_menu[5].text, "View License Details"); EXPECT_NE(license_menu[5].cb, nullptr); EXPECT_STREQ(license_menu[6].text, "Manage License"); EXPECT_NE(license_menu[6].cb, nullptr); verify_virtualhid_actions_menu(license_menu, 7U); EXPECT_EQ(tray_data.notification_title, nullptr); EXPECT_EQ(tray_data.notification_text, nullptr); EXPECT_EQ(tray_data.notification_cb, nullptr); license.plan_name.clear(); license.customer_email.clear(); license.activation_usage = 0; license.activation_limit = 0; system_tray::update_tray_virtualhid_license(license, false); EXPECT_STREQ(license_menu[1].text, "This machine is activated"); EXPECT_STREQ(license_menu[2].text, "Customer: Not reported"); EXPECT_STREQ(license_menu[3].text, "Machine activations: Not reported"); } /** * @brief License-state fixture for the Windows Virtual HID Driver tray menu. */ class UnlicensedVirtualHidTrayTest: public SystemTrayTest, public testing::WithParamInterface> {}; TEST_P(UnlicensedVirtualHidTrayTest, PreparesMenuAndStartupNotification) { const auto &[state, state_label, state_detail, service_available] = GetParam(); lvh::LicenseStatus license; license.service_available = service_available; license.state = state; system_tray::update_tray_virtualhid_license(license, true); const auto &tray_data = system_tray::tray_data_for_testing(); const auto *license_menu = tray_data.menu[2].submenu; ASSERT_NE(license_menu, nullptr); EXPECT_STREQ(license_menu[0].text, std::format("Status: {}", state_label).c_str()); EXPECT_STREQ(license_menu[1].text, state_detail); EXPECT_STREQ(license_menu[2].text, "Full virtual gamepad support is locked"); EXPECT_STREQ(license_menu[3].text, service_available ? "License service: Available" : "License service: Unavailable"); EXPECT_STREQ(license_menu[4].text, "Activate this machine to use Virtual HID Driver"); EXPECT_STREQ(license_menu[5].text, "-"); EXPECT_STREQ(license_menu[6].text, "Activate License"); EXPECT_NE(license_menu[6].cb, nullptr); EXPECT_STREQ(license_menu[7].text, "Buy License"); EXPECT_NE(license_menu[7].cb, nullptr); verify_virtualhid_actions_menu(license_menu, 8U); EXPECT_STREQ(tray_data.notification_title, "Activate Virtual HID Driver"); EXPECT_STREQ( tray_data.notification_text, "Adds Xbox One/Series, DualSense (DS5), Switch Pro, and Generic gamepads beyond ViGEmBus. Actively maintained by LizardByte. Click to activate or buy a license; details remain in the tray menu." ); EXPECT_STREQ(tray_data.notification_icon, tray_data.allIconPaths[4]); EXPECT_NE(tray_data.notification_cb, nullptr); system_tray::resolve_tray_icon_paths_for_testing(); EXPECT_STREQ(tray_data.notification_icon, tray_data.allIconPaths[4]); system_tray::update_tray_virtualhid_license(license, false); EXPECT_EQ(tray_data.notification_title, nullptr); EXPECT_EQ(tray_data.notification_text, nullptr); EXPECT_EQ(tray_data.notification_cb, nullptr); } INSTANTIATE_TEST_SUITE_P( LicenseStates, UnlicensedVirtualHidTrayTest, testing::Values( std::tuple {lvh::LicenseState::unavailable, "Unavailable", "The local license service is unavailable", false}, std::tuple {lvh::LicenseState::unlicensed, "Not Activated", "No license is active on this machine", true}, std::tuple {lvh::LicenseState::expired, "Expired", "The license on this machine has expired", true}, std::tuple {lvh::LicenseState::disabled, "Disabled", "The license on this machine is disabled", true}, std::tuple {lvh::LicenseState::invalid, "Invalid", "The license on this machine is invalid", true} ) ); #endif #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) { std::string configure_tray_icons; if (!lizardbyte::common::get_env("SUNSHINE_CONFIGURE_TRAY_ICONS", configure_tray_icons)) { 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(); #ifdef _WIN32 ASSERT_EQ(tray_data.iconPathCount, 5); #else ASSERT_EQ(tray_data.iconPathCount, 4); #endif 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