/** * @file src/system_tray.cpp * @brief Definitions for the system tray icon and notification system. */ // macros #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include "platform/windows/utils.h" #include #include #include #define TRAY_ICON WEB_DIR "images/apollo.ico" #define TRAY_ICON_PLAYING WEB_DIR "images/apollo-playing.ico" #define TRAY_ICON_PAUSING WEB_DIR "images/apollo-pausing.ico" #define TRAY_ICON_LOCKED WEB_DIR "images/apollo-locked.ico" #elif defined(__linux__) || defined(linux) || defined(__linux) #define TRAY_ICON SUNSHINE_TRAY_PREFIX "-tray" #define TRAY_ICON_PLAYING SUNSHINE_TRAY_PREFIX "-playing" #define TRAY_ICON_PAUSING SUNSHINE_TRAY_PREFIX "-pausing" #define TRAY_ICON_LOCKED SUNSHINE_TRAY_PREFIX "-locked" #elif defined(__APPLE__) || defined(__MACH__) #define TRAY_ICON WEB_DIR "images/logo-apollo-16.png" #define TRAY_ICON_PLAYING WEB_DIR "images/apollo-playing-16.png" #define TRAY_ICON_PAUSING WEB_DIR "images/apollo-pausing-16.png" #define TRAY_ICON_LOCKED WEB_DIR "images/apollo-locked-16.png" #include #endif #define TRAY_MSG_NO_APP_RUNNING "Reload Apps" // standard includes #include #include #include #include #include #include #include #include #include #include #include // lib includes #include #include // local includes #include "config.h" #include "confighttp.h" #include "logging.h" #include "network.h" #include "platform/common.h" #include "platform/windows/service_constants.h" #include "process.h" #include "src/entry_handler.h" #include "update.h" using namespace std::literals; // system_tray namespace namespace system_tray { static std::atomic tray_initialized = false; static std::atomic tray_thread_running = false; static std::atomic tray_thread_should_exit = false; static std::thread tray_thread; #ifdef _WIN32 static std::mutex tray_action_mutex; static std::condition_variable tray_action_cv; static std::queue> tray_actions; static std::atomic tray_shutdown_requested = false; #endif static int init_tray(); static void tray_log_bridge(enum tray_log_level level, const char *message) { if (!message) { return; } switch (level) { case TRAY_LOG_DEBUG: BOOST_LOG(debug) << message; break; case TRAY_LOG_INFO: BOOST_LOG(info) << message; break; case TRAY_LOG_WARNING: BOOST_LOG(warning) << message; break; case TRAY_LOG_ERROR: BOOST_LOG(error) << message; break; } } void tray_open_ui_cb([[maybe_unused]] struct tray_menu *item) { BOOST_LOG(info) << "Opening UI from system tray"sv; launch_ui(); } void tray_force_stop_cb(struct tray_menu *item) { BOOST_LOG(info) << "Force stop from system tray"sv; proc::proc.terminate(true); } void tray_restart_cb([[maybe_unused]] struct tray_menu *item) { BOOST_LOG(info) << "Restarting from system tray"sv; proc::proc.terminate(); platf::restart(); } void tray_quit_cb([[maybe_unused]] struct tray_menu *item) { BOOST_LOG(info) << "Quitting from system tray"sv; proc::proc.terminate(); #ifdef _WIN32 // If we're running in a service, return a special status to // tell it to terminate too, otherwise it will just respawn us. if (GetEnvironmentVariableW(platf::service_launch::launched_by_service_env_var, nullptr, 0) != 0) { lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); return; } #endif lifetime::exit_sunshine(0, true); } // Tray menu static struct tray tray = { .icon = TRAY_ICON, .tooltip = PROJECT_NAME, .menu = (struct tray_menu[]) { // todo - use boost/locale to translate menu strings {.text = "Open ArtLight Server", .cb = tray_open_ui_cb}, {.text = "-"}, // { .text = "-" }, // { .text = "Donate", // .submenu = // (struct tray_menu[]) { // { .text = "GitHub Sponsors", .cb = tray_donate_github_cb }, // { .text = "MEE6", .cb = tray_donate_mee6_cb }, // { .text = "Patreon", .cb = tray_donate_patreon_cb }, // { .text = "PayPal", .cb = tray_donate_paypal_cb }, // { .text = nullptr } } }, // { .text = "-" }, {.text = TRAY_MSG_NO_APP_RUNNING, .cb = tray_force_stop_cb}, {.text = "Check for Update", .cb = [](tray_menu *) { BOOST_LOG(info) << "Manual update check requested from tray"sv; update::trigger_check(true); }}, {.text = "Restart", .cb = tray_restart_cb}, {.text = "Quit", .cb = tray_quit_cb}, {.text = nullptr} }, .iconPathCount = 4, .allIconPaths = {TRAY_ICON, TRAY_ICON_LOCKED, TRAY_ICON_PLAYING, TRAY_ICON_PAUSING}, }; #ifdef _WIN32 static void clear_pending_quit_messages() { MSG msg {}; while (PeekMessageA(&msg, nullptr, WM_QUIT, WM_QUIT, PM_REMOVE)) {} } static void run_pending_tray_actions() { std::queue> pending_actions; { std::lock_guard lock(tray_action_mutex); pending_actions.swap(tray_actions); } while (!pending_actions.empty()) { auto action = std::move(pending_actions.front()); pending_actions.pop(); action(); } } #endif template static void run_on_tray_thread(Fn &&fn) { if (!tray_initialized.load()) { return; } #ifdef _WIN32 { std::lock_guard lock(tray_action_mutex); tray_actions.emplace(std::forward(fn)); } tray_action_cv.notify_one(); #else fn(); #endif } static int init_tray() { #ifdef _WIN32 // If we're running as SYSTEM, Explorer.exe will not have permission to open our thread handle // to monitor for thread termination. If Explorer fails to open our thread, our tray icon // will persist forever if we terminate unexpectedly. To avoid this, we will modify our thread // DACL to add an ACE that allows SYNCHRONIZE access to Everyone. { PACL old_dacl; PSECURITY_DESCRIPTOR sd; auto error = GetSecurityInfo(GetCurrentThread(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, &old_dacl, nullptr, &sd); if (error != ERROR_SUCCESS) { BOOST_LOG(warning) << "GetSecurityInfo() failed: "sv << error; return 1; } auto free_sd = util::fail_guard([sd]() { LocalFree(sd); }); SID_IDENTIFIER_AUTHORITY sid_authority = SECURITY_WORLD_SID_AUTHORITY; PSID world_sid; if (!AllocateAndInitializeSid(&sid_authority, 1, SECURITY_WORLD_RID, 0, 0, 0, 0, 0, 0, 0, &world_sid)) { error = GetLastError(); BOOST_LOG(warning) << "AllocateAndInitializeSid() failed: "sv << error; return 1; } auto free_sid = util::fail_guard([world_sid]() { FreeSid(world_sid); }); EXPLICIT_ACCESS ea {}; ea.grfAccessPermissions = SYNCHRONIZE; ea.grfAccessMode = GRANT_ACCESS; ea.grfInheritance = NO_INHERITANCE; ea.Trustee.TrusteeForm = TRUSTEE_IS_SID; ea.Trustee.ptstrName = (LPSTR) world_sid; PACL new_dacl; error = SetEntriesInAcl(1, &ea, old_dacl, &new_dacl); if (error != ERROR_SUCCESS) { BOOST_LOG(warning) << "SetEntriesInAcl() failed: "sv << error; return 1; } auto free_new_dacl = util::fail_guard([new_dacl]() { LocalFree(new_dacl); }); error = SetSecurityInfo(GetCurrentThread(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, new_dacl, nullptr); if (error != ERROR_SUCCESS) { BOOST_LOG(warning) << "SetSecurityInfo() failed: "sv << error; return 1; } } // Wait for the shell to be initialized before registering the tray icon. // This ensures the tray icon works reliably after a logoff/logon cycle. while (GetShellWindow() == nullptr) { Sleep(1000); } auto wait_for_default_desktop = []() { constexpr int attempts = 60; for (int attempt = 0; attempt < attempts; ++attempt) { HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS | DESKTOP_ENUMERATE); if (desktop != nullptr) { auto close_desktop = util::fail_guard([desktop]() { CloseDesktop(desktop); }); WCHAR desktop_name[256] = {}; DWORD required_length = 0; if (GetUserObjectInformationW(desktop, UOI_NAME, desktop_name, sizeof(desktop_name), &required_length)) { if (_wcsicmp(desktop_name, L"Default") == 0) { return true; } } } Sleep(1000); } return false; }; if (!wait_for_default_desktop()) { BOOST_LOG(warning) << "Timed out waiting for interactive desktop; system tray may not appear"sv; } else { BOOST_LOG(debug) << "Interactive desktop ready for tray initialization"sv; } #endif int attempt = 0; int tray_init_result = -1; while (tray_init_result < 0 && attempt < 30) { #ifdef _WIN32 if (tray_shutdown_requested.load()) { return 0; } #endif tray_init_result = tray_init(&tray); if (tray_init_result >= 0) { break; } #ifdef _WIN32 clear_pending_quit_messages(); #endif BOOST_LOG(warning) << "Failed to create system tray (attempt "sv << attempt + 1 << ')'; std::this_thread::sleep_for(2s); ++attempt; } if (tray_init_result < 0) { BOOST_LOG(warning) << "Failed to create system tray after retries"sv; return 1; } BOOST_LOG(info) << "System tray created"sv; tray_initialized = true; return 0; } int system_tray() { if (init_tray() != 0) { return 1; } #ifdef _WIN32 while (!tray_shutdown_requested.load()) { run_pending_tray_actions(); if (tray_loop(0) < 0) { break; } std::unique_lock lock(tray_action_mutex); tray_action_cv.wait_for(lock, 50ms, []() { return tray_shutdown_requested.load() || !tray_actions.empty(); }); } tray_exit(); clear_pending_quit_messages(); #else while (tray_loop(1) == 0) { BOOST_LOG(debug) << "System tray loop"sv; } tray_exit(); #endif tray_initialized = false; return 0; } void run_tray() { // create the system tray tray_set_log_callback(&tray_log_bridge); #if defined(__APPLE__) || defined(__MACH__) // macOS requires that UI elements be created on the main thread // creating tray using dispatch queue does not work, although the code doesn't actually throw any (visible) errors // dispatch_async(dispatch_get_main_queue(), ^{ // system_tray(); // }); BOOST_LOG(info) << "system_tray() is not yet implemented for this platform."sv; #else // Windows, Linux if (tray_thread.joinable()) { return; } #ifdef _WIN32 tray_shutdown_requested = false; #endif tray_thread = std::thread(system_tray); #endif } int end_tray() { tray_initialized = false; #ifdef _WIN32 tray_shutdown_requested = true; tray_action_cv.notify_one(); #else if (tray_thread.joinable()) { tray_exit(); } #endif if (tray_thread.joinable()) { // end_tray() can run on the tray thread itself: a tray menu action // (Restart/Quit) calls platf::restart()/lifetime::exit_sunshine(), which // raises SIGINT. The SIGINT handler executes synchronously on the calling // (tray) thread and invokes end_tray(). Joining our own thread throws // std::system_error(resource_deadlock_would_occur); that exception is // uncaught, so std::terminate()/abort() kills the process during shutdown // (FAST_FAIL_FATAL_APP_EXIT). Detach in that case and let the tray loop // unwind on its own once it observes tray_shutdown_requested after we // return to the message pump. The main-thread shutdown path still joins. if (tray_thread.get_id() == std::this_thread::get_id()) { tray_thread.detach(); } else { tray_thread.join(); } } #ifdef _WIN32 std::lock_guard lock(tray_action_mutex); std::queue> empty; tray_actions.swap(empty); #endif return 0; } // Persistent storage for tooltip/notification strings to avoid dangling pointers static std::string s_tooltip; static std::string s_notification_text; static std::string s_last_playing_app; // Client name recorded when a client connects, consumed by the next // update_tray_playing() so stream start shows ONE combined notification // instead of two separate toasts. static std::string s_last_connected_client; void update_tray_playing(std::string app_name) { run_on_tray_thread([app_name = std::move(app_name)]() mutable { if (!app_name.empty() && app_name == s_last_playing_app && tray.icon && std::strcmp(tray.icon, TRAY_ICON_PLAYING) == 0) { // Already playing this app; drop any remembered client name so it // cannot leak into a future notification. s_last_connected_client.clear(); return; } tray.notification_title = nullptr; tray.notification_text = nullptr; tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.icon = TRAY_ICON_PLAYING; char msg[256]; static char force_close_msg[256]; snprintf(msg, std::size(msg), "%s launched.", app_name.c_str()); snprintf(force_close_msg, std::size(force_close_msg), "Force close [%s]", app_name.c_str()); #ifdef _WIN32 auto msg_acp = utf8ToAcp(msg); auto force_msg_acp = utf8ToAcp(force_close_msg); strncpy(msg, msg_acp.c_str(), std::size(msg) - 1); msg[std::size(msg) - 1] = '\0'; strncpy(force_close_msg, force_msg_acp.c_str(), std::size(force_close_msg) - 1); force_close_msg[std::size(force_close_msg) - 1] = '\0'; #endif // Fold the connecting client into this single notification. If a // "Client Connected" toast was about to fire separately, it is // suppressed here in favour of one combined message. if (!s_last_connected_client.empty()) { char client_msg[256]; snprintf(client_msg, std::size(client_msg), "%s launched (%s connected)", app_name.c_str(), s_last_connected_client.c_str()); #ifdef _WIN32 auto client_msg_acp = utf8ToAcp(client_msg); strncpy(client_msg, client_msg_acp.c_str(), std::size(client_msg) - 1); client_msg[std::size(client_msg) - 1] = '\0'; #endif s_notification_text = client_msg; s_last_connected_client.clear(); } else { s_notification_text = msg; } s_tooltip = "Streaming started for " + app_name; tray.notification_title = "App launched"; tray.notification_text = s_notification_text.c_str(); tray.notification_icon = TRAY_ICON_PLAYING; tray.tooltip = s_tooltip.c_str(); tray.menu[2].text = force_close_msg; s_last_playing_app = std::move(app_name); tray_update(&tray); }); } void update_tray_pausing(std::string app_name) { run_on_tray_thread([app_name = std::move(app_name)]() { tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.notification_text = nullptr; tray.notification_title = nullptr; tray.icon = TRAY_ICON_PAUSING; char msg[256]; snprintf(msg, std::size(msg), "Streaming paused for %s", app_name.c_str()); #ifdef _WIN32 auto msg_acp = utf8ToAcp(msg); strncpy(msg, msg_acp.c_str(), std::size(msg) - 1); msg[std::size(msg) - 1] = '\0'; #endif s_notification_text = msg; tray.notification_title = "Stream Paused"; tray.notification_text = s_notification_text.c_str(); tray.notification_icon = TRAY_ICON_PAUSING; tray.tooltip = s_notification_text.c_str(); tray_update(&tray); }); } void update_tray_stopped(std::string app_name) { run_on_tray_thread([app_name = std::move(app_name)]() { tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.notification_text = nullptr; tray.notification_title = nullptr; tray.icon = TRAY_ICON; char msg[256]; snprintf(msg, std::size(msg), "Streaming stopped for %s", app_name.c_str()); #ifdef _WIN32 auto msg_acp = utf8ToAcp(msg); strncpy(msg, msg_acp.c_str(), std::size(msg) - 1); msg[std::size(msg) - 1] = '\0'; #endif s_notification_text = msg; tray.notification_icon = TRAY_ICON; tray.notification_title = "Application Stopped"; tray.notification_text = s_notification_text.c_str(); tray.tooltip = PROJECT_NAME; tray.menu[2].text = TRAY_MSG_NO_APP_RUNNING; s_last_playing_app.clear(); tray_update(&tray); }); } void update_tray_launch_error(std::string app_name, int exit_code) { run_on_tray_thread([app_name = std::move(app_name), exit_code]() { tray.notification_title = nullptr; tray.notification_text = nullptr; tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.icon = TRAY_ICON; char msg[256]; snprintf(msg, std::size(msg), "Application %s exited too fast with code %d. Click here to terminate the stream.", app_name.c_str(), exit_code); #ifdef _WIN32 auto msg_acp = utf8ToAcp(msg); strncpy(msg, msg_acp.c_str(), std::size(msg) - 1); msg[std::size(msg) - 1] = '\0'; #endif s_notification_text = msg; tray.notification_icon = TRAY_ICON; tray.notification_title = "Launch Error"; tray.notification_text = s_notification_text.c_str(); tray.notification_cb = []() { BOOST_LOG(info) << "Force stop from notification"sv; proc::proc.terminate(); }; tray.tooltip = PROJECT_NAME; s_last_playing_app.clear(); tray_update(&tray); }); } void update_tray_require_pin() { run_on_tray_thread([]() { tray.notification_title = nullptr; tray.notification_text = nullptr; tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.icon = TRAY_ICON; tray.notification_title = "Incoming Pairing Request"; tray.notification_text = "Click here to complete the pairing process"; tray.notification_icon = TRAY_ICON_LOCKED; tray.tooltip = PROJECT_NAME; tray.notification_cb = []() { launch_ui("/pair"); }; tray_update(&tray); }); } void update_tray_paired(std::string device_name) { run_on_tray_thread([device_name = std::move(device_name)]() { tray.notification_title = nullptr; tray.notification_text = nullptr; tray.notification_cb = nullptr; tray.notification_icon = nullptr; char msg[256]; snprintf(msg, std::size(msg), "Device %s paired Succesfully. Please make sure you have access to the device.", device_name.c_str()); #ifdef _WIN32 auto msg_acp = utf8ToAcp(msg); strncpy(msg, msg_acp.c_str(), std::size(msg) - 1); msg[std::size(msg) - 1] = '\0'; #endif tray.notification_title = "Device Paired Succesfully"; static std::string s_paired_text; s_paired_text = msg; tray.notification_text = s_paired_text.c_str(); tray.notification_icon = TRAY_ICON; tray.tooltip = PROJECT_NAME; tray_update(&tray); }); } void update_tray_client_connected(std::string client_name) { // Record the client name instead of raising a second toast. The next // update_tray_playing() call folds it into the single "App launched" // notification, so a stream connect produces ONE tray notification. // Deliberately no tray_update() here — nothing visual changes. run_on_tray_thread([client_name = std::move(client_name)]() { s_last_connected_client = client_name; }); } void update_tray_vigem_missing() { run_on_tray_thread([]() { tray.notification_title = nullptr; tray.notification_text = nullptr; tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.icon = TRAY_ICON; tray.notification_title = "Gamepad Input Unavailable"; tray.notification_text = "ViGEm is not installed. Click for setup info"; tray.notification_icon = TRAY_ICON; tray.tooltip = PROJECT_NAME; tray.notification_cb = []() { launch_ui("/"); }; tray_update(&tray); }); } // Threading functions available on all platforms static void tray_thread_worker() { BOOST_LOG(info) << "System tray thread started"sv; // Initialize the tray in this thread if (init_tray() != 0) { BOOST_LOG(error) << "Failed to initialize tray in thread"sv; tray_thread_running = false; return; } tray_thread_running = true; // Run the event loop while (!tray_thread_should_exit) { tray_loop(1); } // Cleanup tray_exit(); tray_initialized = false; tray_thread_running = false; BOOST_LOG(info) << "System tray thread ended"sv; } int init_tray_threaded() { if (tray_thread_running) { BOOST_LOG(warning) << "Tray thread is already running"sv; return 1; } #ifdef _WIN32 std::string tmp_str = "Open ArtLight (" + config::nvhttp.sunshine_name + ":" + std::to_string(net::map_port(confighttp::PORT_HTTPS)) + ")"; static const std::string title_str = utf8ToAcp(tmp_str); #else static const std::string title_str = "Open ArtLight (" + config::nvhttp.sunshine_name + ":" + std::to_string(net::map_port(confighttp::PORT_HTTPS)) + ")"; #endif tray.menu[0].text = title_str.c_str(); if (config::sunshine.hide_tray_controls) { tray.menu[1].text = nullptr; } tray_thread_should_exit = false; try { tray_thread = std::thread(tray_thread_worker); // Wait for the thread to start and initialize const auto start_time = std::chrono::steady_clock::now(); while (!tray_thread_running && !tray_thread_should_exit) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); // Timeout after 10 seconds if (std::chrono::steady_clock::now() - start_time > std::chrono::seconds(10)) { BOOST_LOG(error) << "Tray thread initialization timeout"sv; tray_thread_should_exit = true; if (tray_thread.joinable()) { tray_thread.join(); } return 1; } } if (!tray_thread_running) { BOOST_LOG(error) << "Tray thread failed to start"sv; if (tray_thread.joinable()) { tray_thread.join(); } return 1; } BOOST_LOG(info) << "System tray thread initialized successfully"sv; return 0; } catch (const std::exception &e) { BOOST_LOG(error) << "Failed to create tray thread: " << e.what(); return 1; } } int end_tray_threaded() { if (!tray_thread_running) { return 0; } BOOST_LOG(info) << "Stopping system tray thread"sv; tray_thread_should_exit = true; if (tray_thread.joinable()) { tray_thread.join(); } return 0; } void tray_notify(const char *title, const char *text, void (*cb)()) { std::string title_copy = title ? std::string {title} : std::string {}; std::string text_copy = text ? std::string {text} : std::string {}; run_on_tray_thread([title_copy = std::move(title_copy), text_copy = std::move(text_copy), cb]() { tray.notification_title = nullptr; tray.notification_text = nullptr; tray.notification_cb = nullptr; tray.notification_icon = nullptr; tray.icon = TRAY_ICON; tray_update(&tray); static std::string s_notify_title; s_notify_title = title_copy; s_notification_text = text_copy; tray.icon = TRAY_ICON; tray.notification_title = s_notify_title.empty() ? nullptr : s_notify_title.c_str(); tray.notification_text = s_notification_text.c_str(); tray.notification_icon = TRAY_ICON; tray.tooltip = PROJECT_NAME; tray.notification_cb = cb; tray_update(&tray); }); } } // namespace system_tray #endif