/** * @file src/process.cpp * @brief Definitions for the startup and shutdown of the apps started by a streaming Session. */ #define BOOST_BIND_GLOBAL_PLACEHOLDERS // standard includes #include #include #include #include // lib includes #include #include #include #include #include #include #include #include // local includes #include "config.h" #include "crypto.h" #include "display_device.h" #include "file_handler.h" #include "logging.h" #include "platform/common.h" #include "process.h" #include "httpcommon.h" #include "system_tray.h" #include "utility.h" #include "video.h" #include "uuid.h" #ifdef _WIN32 // from_utf8() string conversion function #include "platform/windows/misc.h" #include "platform/windows/utils.h" // _SH constants for _wfsopen() #include #endif #define DEFAULT_APP_IMAGE_PATH SUNSHINE_ASSETS_DIR "/box.png" #define VIRTUAL_DISPLAY_UUID "8902CB19-674A-403D-A587-41B092E900BA" #define FALLBACK_DESKTOP_UUID "EAAC6159-089A-46A9-9E24-6436885F6610" #define REMOTE_INPUT_UUID "8CB5C136-DA67-4F99-B4A1-F9CD35005CF4" #define TERMINATE_APP_UUID "E16CBE1B-295D-4632-9A76-EC4180C857D3" namespace proc { using namespace std::literals; namespace pt = boost::property_tree; proc_t proc; int input_only_app_id = -1; std::string input_only_app_id_str; int terminate_app_id = -1; std::string terminate_app_id_str; #ifdef _WIN32 VDISPLAY::DRIVER_STATUS vDisplayDriverStatus = VDISPLAY::DRIVER_STATUS::UNKNOWN; void onVDisplayWatchdogFailed() { vDisplayDriverStatus = VDISPLAY::DRIVER_STATUS::WATCHDOG_FAILED; VDISPLAY::closeVDisplayDevice(); } void initVDisplayDriver() { vDisplayDriverStatus = VDISPLAY::openVDisplayDevice(); if (vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK) { if (!VDISPLAY::startPingThread(onVDisplayWatchdogFailed)) { onVDisplayWatchdogFailed(); return; } } } #endif class deinit_t: public platf::deinit_t { public: ~deinit_t() { proc.terminate(); } }; std::unique_ptr init() { return std::make_unique(); } void terminate_process_group(boost::process::v1::child &proc, boost::process::v1::group &group, std::chrono::seconds exit_timeout) { if (group.valid() && platf::process_group_running((std::uintptr_t) group.native_handle())) { if (exit_timeout.count() > 0) { // Request processes in the group to exit gracefully if (platf::request_process_group_exit((std::uintptr_t) group.native_handle())) { // If the request was successful, wait for a little while for them to exit. BOOST_LOG(info) << "Successfully requested the app to exit. Waiting up to "sv << exit_timeout.count() << " seconds for it to close."sv; // group::wait_for() and similar functions are broken and deprecated, so we use a simple polling loop while (platf::process_group_running((std::uintptr_t) group.native_handle()) && (--exit_timeout).count() >= 0) { std::this_thread::sleep_for(1s); } if (exit_timeout.count() < 0) { BOOST_LOG(warning) << "App did not fully exit within the timeout. Terminating the app's remaining processes."sv; } else { BOOST_LOG(info) << "All app processes have successfully exited."sv; } } else { BOOST_LOG(info) << "App did not respond to a graceful termination request. Forcefully terminating the app's processes."sv; } } else { BOOST_LOG(info) << "No graceful exit timeout was specified for this app. Forcefully terminating the app's processes."sv; } // We always call terminate() even if we waited successfully for all processes above. // This ensures the process group state is consistent with the OS in boost. std::error_code ec; group.terminate(ec); group.detach(); } if (proc.valid()) { // avoid zombie process proc.detach(); } } boost::filesystem::path find_working_directory(const std::string &cmd, boost::process::v1::environment &env) { // Parse the raw command string into parts to get the actual command portion #ifdef _WIN32 auto parts = boost::program_options::split_winmain(cmd); #else auto parts = boost::program_options::split_unix(cmd); #endif if (parts.empty()) { BOOST_LOG(error) << "Unable to parse command: "sv << cmd; return boost::filesystem::path(); } BOOST_LOG(debug) << "Parsed target ["sv << parts.at(0) << "] from command ["sv << cmd << ']'; // If the target is a URL, don't parse any further here if (parts.at(0).find("://") != std::string::npos) { return boost::filesystem::path(); } // If the cmd path is not an absolute path, resolve it using our PATH variable boost::filesystem::path cmd_path(parts.at(0)); if (!cmd_path.is_absolute()) { cmd_path = boost::process::v1::search_path(parts.at(0)); if (cmd_path.empty()) { BOOST_LOG(error) << "Unable to find executable ["sv << parts.at(0) << "]. Is it in your PATH?"sv; return boost::filesystem::path(); } } BOOST_LOG(debug) << "Resolved target ["sv << parts.at(0) << "] to path ["sv << cmd_path << ']'; // Now that we have a complete path, we can just use parent_path() return cmd_path.parent_path(); } void proc_t::launch_input_only() { _app_id = input_only_app_id; _app_name = "Remote Input"; allow_client_commands = false; placebo = true; #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_playing(_app_name); #endif } int proc_t::execute(int app_id, const ctx_t& app, std::shared_ptr launch_session) { if (_app_id == input_only_app_id) { terminate(); std::this_thread::sleep_for(1s); } else { // Ensure starting from a clean slate terminate(); } _app = app; _app_id = app_id; _app_name = app.name; _launch_session = launch_session; allow_client_commands = app.allow_client_commands; uint32_t client_width = launch_session->width ? launch_session->width : 1920; uint32_t client_height = launch_session->height ? launch_session->height : 1080; uint32_t render_width = client_width; uint32_t render_height = client_height; int scale_factor = launch_session->scale_factor; if (_app.scale_factor != 100) { scale_factor = _app.scale_factor; } if (scale_factor != 100) { render_width *= ((float)scale_factor / 100); render_height *= ((float)scale_factor / 100); // Chop the last bit to ensure the scaled resolution is even numbered // Most odd resolutions won't work well render_width &= ~1; render_height &= ~1; } launch_session->width = render_width; launch_session->height = render_height; this->initial_display = config::video.output_name; // Executed when returning from function auto fg = util::fail_guard([&]() { // Restore to user defined output name config::video.output_name = this->initial_display; terminate(); display_device::revert_configuration(); }); #ifdef _WIN32 if ( config::video.headless_mode // Headless mode || launch_session->virtual_display // User requested virtual display || _app.virtual_display // App is configured to use virtual display || !video::allow_encoder_probing() // No active display presents ) { if (vDisplayDriverStatus != VDISPLAY::DRIVER_STATUS::OK) { // Try init driver again initVDisplayDriver(); } if (vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK) { // Try set the render adapter matching the capture adapter if user has specified one if (!config::video.adapter_name.empty()) { VDISPLAY::setRenderAdapterByName(platf::from_utf8(config::video.adapter_name)); } std::string device_name; std::string device_uuid_str; uuid_util::uuid_t device_uuid; if (_app.use_app_identity) { device_name = _app.name; if (_app.per_client_app_identity) { device_uuid = uuid_util::uuid_t::parse(launch_session->unique_id); auto app_uuid = uuid_util::uuid_t::parse(_app.uuid); // Use XOR to mix the two UUIDs device_uuid.b64[0] ^= app_uuid.b64[0]; device_uuid.b64[1] ^= app_uuid.b64[1]; device_uuid_str = device_uuid.string(); } else { device_uuid_str = _app.uuid; device_uuid = uuid_util::uuid_t::parse(_app.uuid); } } else { device_name = launch_session->device_name; device_uuid_str = launch_session->unique_id; device_uuid = uuid_util::uuid_t::parse(launch_session->unique_id); } memcpy(&launch_session->display_guid, &device_uuid, sizeof(GUID)); int target_fps = launch_session->fps ? launch_session->fps : 60000; if (target_fps < 1000) { target_fps *= 1000; } std::wstring vdisplayName = VDISPLAY::createVirtualDisplay( device_uuid_str.c_str(), device_name.c_str(), render_width, render_height, target_fps, launch_session->display_guid ); if (config::video.double_refreshrate) { target_fps *= 2; } // No matter we get the display name or not, the virtual display might still be created. // We need to track it properly to remove the display when the session terminates. launch_session->virtual_display = true; if (!vdisplayName.empty()) { BOOST_LOG(info) << "Virtual Display created at " << vdisplayName; // Don't change display settings when no params are given if (launch_session->width && launch_session->height && launch_session->fps) { // Apply display settings VDISPLAY::changeDisplaySettings(vdisplayName.c_str(), render_width, render_height, target_fps); } // Set virtual_display to true when everything went fine this->virtual_display = true; this->display_name = platf::to_utf8(vdisplayName); // When using virtual display, we don't care which display user configured to use. // So we always set output_name to the newly created virtual display as a workaround for // empty name when probing graphics cards. config::video.output_name = display_device::map_display_name(this->display_name); } else { BOOST_LOG(warning) << "Virtual Display creation failed, or cannot get created display name in time!"; } } else { // Driver isn't working so we don't need to track virtual display. launch_session->virtual_display = false; } } display_device::configure_display(config::video, *launch_session); // We should not preserve display state when using virtual display. // It is already handled by Windows properly. if (this->virtual_display) { display_device::reset_persistence(); } #else display_device::configure_display(config::video, *launch_session); #endif // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed // due to hotplugging, driver crash, primary monitor change, // or any number of other factors). if (rtsp_stream::session_count() == 0 && video::probe_encoders()) { return 503; } std::string fps_str; char fps_buf[8]; snprintf(fps_buf, sizeof(fps_buf), "%.3f", (float)launch_session->fps / 1000.0f); fps_str = fps_buf; // Add Stream-specific environment variables // Sunshine Compatibility _env["SUNSHINE_APP_ID"] = _app.id; _env["SUNSHINE_APP_NAME"] = _app.name; _env["SUNSHINE_CLIENT_WIDTH"] = std::to_string(render_width); _env["SUNSHINE_CLIENT_HEIGHT"] = std::to_string(render_height); _env["SUNSHINE_CLIENT_FPS"] = config::sunshine.envvar_compatibility_mode ? std::to_string(std::round((float)launch_session->fps / 1000.0f)) : fps_str; _env["SUNSHINE_CLIENT_HDR"] = launch_session->enable_hdr ? "true" : "false"; _env["SUNSHINE_CLIENT_GCMAP"] = std::to_string(launch_session->gcmap); _env["SUNSHINE_CLIENT_HOST_AUDIO"] = launch_session->host_audio ? "true" : "false"; _env["SUNSHINE_CLIENT_ENABLE_SOPS"] = launch_session->enable_sops ? "true" : "false"; _env["APOLLO_APP_ID"] = _app.id; _env["APOLLO_APP_NAME"] = _app.name; _env["APOLLO_APP_UUID"] = _app.uuid; _env["APOLLO_CLIENT_UUID"] = launch_session->unique_id; _env["APOLLO_CLIENT_NAME"] = launch_session->device_name; _env["APOLLO_CLIENT_WIDTH"] = std::to_string(render_width); _env["APOLLO_CLIENT_HEIGHT"] = std::to_string(render_height); _env["APOLLO_CLIENT_RENDER_WIDTH"] = std::to_string(launch_session->width); _env["APOLLO_CLIENT_RENDER_HEIGHT"] = std::to_string(launch_session->height); _env["APOLLO_CLIENT_SCALE_FACTOR"] = std::to_string(scale_factor); _env["APOLLO_CLIENT_FPS"] = fps_str; _env["APOLLO_CLIENT_HDR"] = launch_session->enable_hdr ? "true" : "false"; _env["APOLLO_CLIENT_GCMAP"] = std::to_string(launch_session->gcmap); _env["APOLLO_CLIENT_HOST_AUDIO"] = launch_session->host_audio ? "true" : "false"; _env["APOLLO_CLIENT_ENABLE_SOPS"] = launch_session->enable_sops ? "true" : "false"; int channelCount = launch_session->surround_info & (65535); switch (channelCount) { case 2: _env["SUNSHINE_CLIENT_AUDIO_CONFIGURATION"] = "2.0"; _env["APOLLO_CLIENT_AUDIO_CONFIGURATION"] = "2.0"; break; case 6: _env["SUNSHINE_CLIENT_AUDIO_CONFIGURATION"] = "5.1"; _env["APOLLO_CLIENT_AUDIO_CONFIGURATION"] = "5.1"; break; case 8: _env["SUNSHINE_CLIENT_AUDIO_CONFIGURATION"] = "7.1"; _env["APOLLO_CLIENT_AUDIO_CONFIGURATION"] = "7.1"; break; } _env["SUNSHINE_CLIENT_AUDIO_SURROUND_PARAMS"] = launch_session->surround_params; _env["APOLLO_CLIENT_AUDIO_SURROUND_PARAMS"] = launch_session->surround_params; if (!_app.output.empty() && _app.output != "null"sv) { #ifdef _WIN32 // fopen() interprets the filename as an ANSI string on Windows, so we must convert it // to UTF-16 and use the wchar_t variants for proper Unicode log file path support. auto woutput = platf::from_utf8(_app.output); // Use _SH_DENYNO to allow us to open this log file again for writing even if it is // still open from a previous execution. This is required to handle the case of a // detached process executing again while the previous process is still running. _pipe.reset(_wfsopen(woutput.c_str(), L"a", _SH_DENYNO)); #else _pipe.reset(fopen(_app.output.c_str(), "a")); #endif } std::error_code ec; _app_prep_begin = std::begin(_app.prep_cmds); _app_prep_it = _app_prep_begin; for (; _app_prep_it != std::end(_app.prep_cmds); ++_app_prep_it) { auto &cmd = *_app_prep_it; // Skip empty commands if (cmd.do_cmd.empty()) { continue; } boost::filesystem::path working_dir = _app.working_dir.empty() ? find_working_directory(cmd.do_cmd, _env) : boost::filesystem::path(_app.working_dir); BOOST_LOG(info) << "Executing Do Cmd: ["sv << cmd.do_cmd << "] elevated: " << cmd.elevated; auto child = platf::run_command(cmd.elevated, true, cmd.do_cmd, working_dir, _env, _pipe.get(), ec, nullptr); if (ec) { BOOST_LOG(error) << "Couldn't run ["sv << cmd.do_cmd << "]: System: "sv << ec.message(); // We don't want any prep commands failing launch of the desktop. // This is to prevent the issue where users reboot their PC and need to log in with Sunshine. // permission_denied is typically returned when the user impersonation fails, which can happen when user is not signed in yet. if (!(_app.cmd.empty() && ec == std::errc::permission_denied)) { return -1; } } child.wait(); auto ret = child.exit_code(); if (ret != 0 && ec != std::errc::permission_denied) { BOOST_LOG(error) << '[' << cmd.do_cmd << "] failed with code ["sv << ret << ']'; return -1; } } for (auto &cmd : _app.detached) { boost::filesystem::path working_dir = _app.working_dir.empty() ? find_working_directory(cmd, _env) : boost::filesystem::path(_app.working_dir); BOOST_LOG(info) << "Spawning ["sv << cmd << "] in ["sv << working_dir << ']'; auto child = platf::run_command(_app.elevated, true, cmd, working_dir, _env, _pipe.get(), ec, nullptr); if (ec) { BOOST_LOG(warning) << "Couldn't spawn ["sv << cmd << "]: System: "sv << ec.message(); } else { child.detach(); } } if (_app.cmd.empty()) { BOOST_LOG(info) << "Executing [Desktop]"sv; placebo = true; } else { boost::filesystem::path working_dir = _app.working_dir.empty() ? find_working_directory(_app.cmd, _env) : boost::filesystem::path(_app.working_dir); BOOST_LOG(info) << "Executing: ["sv << _app.cmd << "] in ["sv << working_dir << ']'; _process = platf::run_command(_app.elevated, true, _app.cmd, working_dir, _env, _pipe.get(), ec, &_process_group); if (ec) { BOOST_LOG(warning) << "Couldn't run ["sv << _app.cmd << "]: System: "sv << ec.message(); return -1; } } _app_launch_time = std::chrono::steady_clock::now(); #ifdef _WIN32 auto resetHDRThread = std::thread([this, enable_hdr = launch_session->enable_hdr]{ // Windows doesn't seem to be able to set HDR correctly when a display is just connected / changed resolution, // so we have tooggle HDR for the virtual display manually after a delay. auto retryInterval = 200ms; while (is_changing_settings_going_to_fail()) { if (retryInterval > 2s) { BOOST_LOG(warning) << "Restoring HDR settings failed due to retry timeout!"; return; } std::this_thread::sleep_for(retryInterval); retryInterval *= 2; } retryInterval = 200ms; while (this->display_name.empty()) { if (retryInterval > 2s) { BOOST_LOG(warning) << "Not getting current display in time! HDR will not be toggled."; return; } std::this_thread::sleep_for(retryInterval); retryInterval *= 2; } // We should have got the actual streaming display by now std::string currentDisplay = this->display_name; auto currentDisplayW = platf::from_utf8(currentDisplay).c_str(); initial_hdr = VDISPLAY::getDisplayHDRByName(currentDisplayW); if (config::video.dd.hdr_option == config::video_t::dd_t::hdr_option_e::automatic) { mode_changed_display = currentDisplay; if (!VDISPLAY::setDisplayHDRByName(currentDisplayW, false)) { return; } if (enable_hdr) { if (VDISPLAY::setDisplayHDRByName(currentDisplayW, true)) { BOOST_LOG(info) << "HDR enabled for display " << currentDisplay; } else { BOOST_LOG(info) << "HDR enable failed for display " << currentDisplay; } } } else if (initial_hdr) { if (VDISPLAY::setDisplayHDRByName(currentDisplayW, false) && VDISPLAY::setDisplayHDRByName(currentDisplayW, true)) { BOOST_LOG(info) << "HDR toggled successfully for display " << currentDisplay; } else { BOOST_LOG(info) << "HDR toggle failed for display " << currentDisplay; } } }); resetHDRThread.detach(); #endif fg.disable(); #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_playing(_app.name); #endif return 0; } int proc_t::running() { #ifndef _WIN32 // On POSIX OSes, we must periodically wait for our children to avoid // them becoming zombies. This must be synchronized carefully with // calls to bp::wait() and platf::process_group_running() which both // invoke waitpid() under the hood. auto reaper = util::fail_guard([]() { while (waitpid(-1, nullptr, WNOHANG) > 0); }); #endif if (placebo) { return _app_id; } else if (_app.wait_all && _process_group && platf::process_group_running((std::uintptr_t) _process_group.native_handle())) { // The app is still running if any process in the group is still running return _app_id; } else if (_process.running()) { // The app is still running only if the initial process launched is still running return _app_id; } else if (_app.auto_detach && std::chrono::steady_clock::now() - _app_launch_time < 5s) { BOOST_LOG(info) << "App exited with code ["sv << _process.native_exit_code() << "] within 5 seconds of launch. Treating the app as a detached command."sv; BOOST_LOG(info) << "Adjust this behavior in the Applications tab or apps.json if this is not what you want."sv; placebo = true; #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 if (_process.native_exit_code() != 0) { system_tray::update_tray_launch_error(proc::proc.get_last_run_app_name(), _process.native_exit_code()); } #endif return _app_id; } // Perform cleanup actions now if needed if (_process) { terminate(); } return 0; } void proc_t::terminate(bool immediate) { std::error_code ec; placebo = false; if (!immediate) { terminate_process_group(_process, _process_group, _app.exit_timeout); } _process = boost::process::v1::child(); _process_group = boost::process::v1::group(); for (; _app_prep_it != _app_prep_begin; --_app_prep_it) { auto &cmd = *(_app_prep_it - 1); if (cmd.undo_cmd.empty()) { continue; } boost::filesystem::path working_dir = _app.working_dir.empty() ? find_working_directory(cmd.undo_cmd, _env) : boost::filesystem::path(_app.working_dir); BOOST_LOG(info) << "Executing Undo Cmd: ["sv << cmd.undo_cmd << ']'; auto child = platf::run_command(cmd.elevated, true, cmd.undo_cmd, working_dir, _env, _pipe.get(), ec, nullptr); if (ec) { BOOST_LOG(warning) << "System: "sv << ec.message(); } child.wait(); auto ret = child.exit_code(); if (ret != 0) { BOOST_LOG(warning) << "Return code ["sv << ret << ']'; } } _pipe.reset(); bool has_run = _app_id > 0; #ifdef _WIN32 // Revert HDR state if (has_run && !mode_changed_display.empty()) { auto displayNameW = platf::from_utf8(mode_changed_display); if (VDISPLAY::setDisplayHDRByName(displayNameW.c_str(), initial_hdr)) { BOOST_LOG(info) << "HDR reverted for display " << mode_changed_display; } else { BOOST_LOG(info) << "HDR revert failed for display " << mode_changed_display; } } bool used_virtual_display = vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK && _launch_session && _launch_session->virtual_display; if (used_virtual_display) { if (VDISPLAY::removeVirtualDisplay(_launch_session->display_guid)) { BOOST_LOG(info) << "Virtual Display removed successfully"; } else if (this->virtual_display) { BOOST_LOG(warning) << "Virtual Display remove failed"; } else { BOOST_LOG(warning) << "Virtual Display remove failed, but it seems it was not created correctly either."; } } // Only show the Stopped notification if we actually have an app to stop // Since terminate() is always run when a new app has started if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { if (used_virtual_display) { display_device::reset_persistence(); } else { display_device::revert_configuration(); } #else if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { display_device::revert_configuration(); #endif #if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_stopped(proc::proc.get_last_run_app_name()); #endif } // Load the configured output_name first // to prevent the value being write to empty when the initial terminate happens if (!has_run && initial_display.empty()) { initial_display = config::video.output_name; } else { // Restore output name to its original value config::video.output_name = initial_display; } _app_id = -1; _app_name.clear(); _app = {}; display_name.clear(); initial_display.clear(); mode_changed_display.clear(); _launch_session.reset(); virtual_display = false; allow_client_commands = false; } const std::vector &proc_t::get_apps() const { return _apps; } std::vector &proc_t::get_apps() { return _apps; } // Gets application image from application list. // Returns image from assets directory if found there. // Returns default image if image configuration is not set. // Returns http content-type header compatible image type. std::string proc_t::get_app_image(int app_id) { auto iter = std::find_if(_apps.begin(), _apps.end(), [&app_id](const auto app) { return app.id == std::to_string(app_id); }); auto app_image_path = iter == _apps.end() ? std::string() : iter->image_path; return validate_app_image_path(app_image_path); } std::string proc_t::get_last_run_app_name() { return _app_name; } std::string proc_t::get_running_app_uuid() { return _app.uuid; } boost::process::environment proc_t::get_env() { return _env; } proc_t::~proc_t() { // It's not safe to call terminate() here because our proc_t is a static variable // that may be destroyed after the Boost loggers have been destroyed. Instead, // we return a deinit_t to main() to handle termination when we're exiting. // Once we reach this point here, termination must have already happened. assert(!placebo); assert(!_process.running()); } std::string_view::iterator find_match(std::string_view::iterator begin, std::string_view::iterator end) { int stack = 0; --begin; do { ++begin; switch (*begin) { case '(': ++stack; break; case ')': --stack; } } while (begin != end && stack != 0); if (begin == end) { throw std::out_of_range("Missing closing bracket \')\'"); } return begin; } std::string parse_env_val(boost::process::v1::native_environment &env, const std::string_view &val_raw) { auto pos = std::begin(val_raw); auto dollar = std::find(pos, std::end(val_raw), '$'); std::stringstream ss; while (dollar != std::end(val_raw)) { auto next = dollar + 1; if (next != std::end(val_raw)) { switch (*next) { case '(': { ss.write(pos, (dollar - pos)); auto var_begin = next + 1; auto var_end = find_match(next, std::end(val_raw)); auto var_name = std::string {var_begin, var_end}; #ifdef _WIN32 // Windows treats environment variable names in a case-insensitive manner, // so we look for a case-insensitive match here. This is critical for // correctly appending to PATH on Windows. auto itr = std::find_if(env.cbegin(), env.cend(), [&](const auto &e) { return boost::iequals(e.get_name(), var_name); }); if (itr != env.cend()) { // Use an existing case-insensitive match var_name = itr->get_name(); } #endif ss << env[var_name].to_string(); pos = var_end + 1; next = var_end; break; } case '$': ss.write(pos, (next - pos)); pos = next + 1; ++next; break; } dollar = std::find(next, std::end(val_raw), '$'); } else { dollar = next; } } ss.write(pos, (dollar - pos)); return ss.str(); } std::string validate_app_image_path(std::string app_image_path) { if (app_image_path.empty()) { return DEFAULT_APP_IMAGE_PATH; } // get the image extension and convert it to lowercase auto image_extension = std::filesystem::path(app_image_path).extension().string(); boost::to_lower(image_extension); // return the default box image if extension is not "png" if (image_extension != ".png") { return DEFAULT_APP_IMAGE_PATH; } // check if image is in assets directory auto full_image_path = std::filesystem::path(SUNSHINE_ASSETS_DIR) / app_image_path; if (std::filesystem::exists(full_image_path)) { return full_image_path.string(); } else if (app_image_path == "./assets/steam.png") { // handle old default steam image definition return SUNSHINE_ASSETS_DIR "/steam.png"; } // check if specified image exists std::error_code code; if (!std::filesystem::exists(app_image_path, code)) { // return default box image if image does not exist BOOST_LOG(warning) << "Couldn't find app image at path ["sv << app_image_path << ']'; return DEFAULT_APP_IMAGE_PATH; } // image is a png, and not in assets directory // return only "content-type" http header compatible image type return app_image_path; } std::optional calculate_sha256(const std::string &filename) { crypto::md_ctx_t ctx {EVP_MD_CTX_create()}; if (!ctx) { return std::nullopt; } if (!EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr)) { return std::nullopt; } // Read file and update calculated SHA char buf[1024 * 16]; std::ifstream file(filename, std::ifstream::binary); while (file.good()) { file.read(buf, sizeof(buf)); if (!EVP_DigestUpdate(ctx.get(), buf, file.gcount())) { return std::nullopt; } } file.close(); unsigned char result[SHA256_DIGEST_LENGTH]; if (!EVP_DigestFinal_ex(ctx.get(), result, nullptr)) { return std::nullopt; } // Transform byte-array to string std::stringstream ss; ss << std::hex << std::setfill('0'); for (const auto &byte : result) { ss << std::setw(2) << (int) byte; } return ss.str(); } uint32_t calculate_crc32(const std::string &input) { boost::crc_32_type result; result.process_bytes(input.data(), input.length()); return result.checksum(); } std::tuple calculate_app_id(const std::string &app_name, std::string app_image_path, int index) { // Generate id by hashing name with image data if present std::vector to_hash; to_hash.push_back(app_name); auto file_path = validate_app_image_path(app_image_path); if (file_path != DEFAULT_APP_IMAGE_PATH) { auto file_hash = calculate_sha256(file_path); if (file_hash) { to_hash.push_back(file_hash.value()); } else { // Fallback to just hashing image path to_hash.push_back(file_path); } } // Create combined strings for hash std::stringstream ss; for_each(to_hash.begin(), to_hash.end(), [&ss](const std::string &s) { ss << s; }); auto input_no_index = ss.str(); ss << index; auto input_with_index = ss.str(); // CRC32 then truncate to signed 32-bit range due to client limitations auto id_no_index = std::to_string(abs((int32_t) calculate_crc32(input_no_index))); auto id_with_index = std::to_string(abs((int32_t) calculate_crc32(input_with_index))); return std::make_tuple(id_no_index, id_with_index); } /** * @brief Migrate the applications stored in the file tree by merging in a new app. * * This function updates the application entries in *fileTree_p* using the data in *inputTree_p*. * If an app in the file tree does not have a UUID, one is generated and inserted. * If an app with the same UUID as the new app is found, it is replaced. * Additionally, empty keys (such as "prep-cmd" or "detached") and keys no longer needed ("launching", "index") * are removed from the input. * * Legacy versions of Sunshine/Apollo stored boolean and integer values as strings. * The following keys are converted: * - Boolean keys: "exclude-global-prep-cmd", "elevated", "auto-detach", "wait-all", * "use-app-identity", "per-client-app-identity", "virtual-display" * - Integer keys: "exit-timeout" * * A migration version is stored in the file tree (under "version") so that future changes can be applied. * * @param fileTree_p Pointer to the JSON object representing the file tree. * @param inputTree_p Pointer to the JSON object representing the new app. */ void migrate_apps(nlohmann::json* fileTree_p, nlohmann::json* inputTree_p) { std::string new_app_uuid; if (inputTree_p) { // If the input contains a non-empty "uuid", use it; otherwise generate one. if (inputTree_p->contains("uuid") && !(*inputTree_p)["uuid"].get().empty()) { new_app_uuid = (*inputTree_p)["uuid"].get(); } else { new_app_uuid = uuid_util::uuid_t::generate().string(); (*inputTree_p)["uuid"] = new_app_uuid; } // Remove "prep-cmd" if empty. if (inputTree_p->contains("prep-cmd") && (*inputTree_p)["prep-cmd"].empty()) { inputTree_p->erase("prep-cmd"); } // Remove "detached" if empty. if (inputTree_p->contains("detached") && (*inputTree_p)["detached"].empty()) { inputTree_p->erase("detached"); } // Remove keys that are no longer needed. inputTree_p->erase("launching"); inputTree_p->erase("index"); } // Get the current apps array; if it doesn't exist, create one. nlohmann::json newApps = nlohmann::json::array(); if (fileTree_p->contains("apps") && (*fileTree_p)["apps"].is_array()) { for (auto &app : (*fileTree_p)["apps"]) { // For apps without a UUID, generate one and remove "launching". if (!app.contains("uuid") || app["uuid"].get().empty()) { app["uuid"] = uuid_util::uuid_t::generate().string(); app.erase("launching"); newApps.push_back(std::move(app)); } else { // If an app with the same UUID as the new app is found, replace it. if (!new_app_uuid.empty() && app["uuid"].get() == new_app_uuid) { newApps.push_back(*inputTree_p); new_app_uuid.clear(); } else { newApps.push_back(std::move(app)); } } } } // If the new app's UUID has not been merged yet, add it. if (!new_app_uuid.empty() && inputTree_p) { newApps.push_back(*inputTree_p); } (*fileTree_p)["apps"] = newApps; } void migration_v2(nlohmann::json& fileTree) { static const int this_version = 2; // Determine the current migration version (default to 1 if not present). int file_version = 1; if (fileTree.contains("version")) { try { file_version = fileTree["version"].get(); } catch (const std::exception& e) { BOOST_LOG(info) << "Cannot parse apps.json version, treating as v1: " << e.what(); } } // If the version is less than this_version, perform legacy conversion. if (file_version < this_version) { BOOST_LOG(info) << "Migrating app list from v1 to v2..."; migrate_apps(&fileTree, nullptr); // List of keys to convert to booleans. std::vector boolean_keys = { "allow-client-commands", "exclude-global-prep-cmd", "elevated", "auto-detach", "wait-all", "use-app-identity", "per-client-app-identity", "virtual-display" }; // List of keys to convert to integers. std::vector integer_keys = { "exit-timeout", "scale-factor" }; // Walk through each app and convert legacy string values. for (auto &app : fileTree["apps"]) { for (const auto &key : boolean_keys) { if (app.contains(key)) { auto& _key = app[key]; if (_key.is_string()) { std::string s = _key.get(); std::transform(s.begin(), s.end(), s.begin(), ::tolower); // Normalize to lowercase for comparison _key = (s == "true" || s == "on" || s == "yes"); } else if (_key.is_array()) { // Check if the array contains at least one item and interpret the first element if (!_key.empty() && _key[0].is_string()) { std::string first = _key[0].get(); std::transform(first.begin(), first.end(), first.begin(), ::tolower); // Normalize if (first == "on" || first == "true" || first == "yes") { _key = true; } else if (first == "off" || first == "false" || first == "no") { _key = false; } else { _key = false; // Default for unknown values } } else { _key = false; // Treat empty arrays or non-string first elements as false } } else { // Fallback: Treat truthy/falsey cases if (_key.is_boolean()) { // Leave booleans as they are } else if (_key.is_number()) { _key = (_key.get() != 0); // Non-zero numbers are truthy } else if (_key.is_null()) { _key = false; // Null is false } else { _key = !_key.empty(); // Non-empty objects/arrays are truthy, empty ones are falsey } } } } for (const auto &key : integer_keys) { if (app.contains(key) && app[key].is_string()) { std::string s = app[key].get(); app[key] = std::stoi(s); } } // For each entry in the "prep-cmd" array, convert "elevated" if necessary. if (app.contains("prep-cmd") && app["prep-cmd"].is_array()) { for (auto &prep : app["prep-cmd"]) { if (prep.contains("elevated") && prep["elevated"].is_string()) { std::string s = prep["elevated"].get(); prep["elevated"] = (s == "true"); } } } } // Update migration version to this_version. fileTree["version"] = this_version; BOOST_LOG(info) << "Migrated app list from v1 to v2."; } } void migrate(nlohmann::json& fileTree, const std::string& fileName) { int last_version = 2; int file_version = 0; if (fileTree.contains("version")) { file_version = fileTree["version"].get(); } if (file_version < last_version) { migration_v2(fileTree); file_handler::write_file(fileName.c_str(), fileTree.dump(4)); } } std::optional parse(const std::string &file_name) { // Prepare environment variables. auto this_env = boost::this_process::environment(); std::set ids; std::vector apps; int i = 0; size_t fail_count = 0; do { // Read the JSON file into a tree. nlohmann::json tree; try { std::string content = file_handler::read_file(file_name.c_str()); tree = nlohmann::json::parse(content); } catch (const std::exception& e) { BOOST_LOG(warning) << "Couldn't read apps.json properly! Apps will not be loaded."sv; break; } try { migrate(tree, file_name); if (tree.contains("env") && tree["env"].is_object()) { for (auto &item : tree["env"].items()) { this_env[item.key()] = parse_env_val(this_env, item.value().get()); } } // Ensure the "apps" array exists. if (!tree.contains("apps") || !tree["apps"].is_array()) { BOOST_LOG(warning) << "No apps were defined in apps.json!!!"sv; break; } // Iterate over each application in the "apps" array. for (auto &app_node : tree["apps"]) { proc::ctx_t ctx; ctx.idx = std::to_string(i); ctx.uuid = app_node.at("uuid"); // Build the list of preparation commands. std::vector prep_cmds; bool exclude_global_prep = app_node.value("exclude-global-prep-cmd", false); if (!exclude_global_prep) { prep_cmds.reserve(config::sunshine.prep_cmds.size()); for (auto &prep_cmd : config::sunshine.prep_cmds) { auto do_cmd = parse_env_val(this_env, prep_cmd.do_cmd); auto undo_cmd = parse_env_val(this_env, prep_cmd.undo_cmd); prep_cmds.emplace_back( std::move(do_cmd), std::move(undo_cmd), std::move(prep_cmd.elevated) ); } } if (app_node.contains("prep-cmd") && app_node["prep-cmd"].is_array()) { for (auto &prep_node : app_node["prep-cmd"]) { std::string do_cmd = parse_env_val(this_env, prep_node.value("do", "")); std::string undo_cmd = parse_env_val(this_env, prep_node.value("undo", "")); bool elevated = prep_node.value("elevated", false); prep_cmds.emplace_back( std::move(do_cmd), std::move(undo_cmd), std::move(elevated) ); } } // Build the list of detached commands. std::vector detached; if (app_node.contains("detached") && app_node["detached"].is_array()) { for (auto &detached_val : app_node["detached"]) { detached.emplace_back(parse_env_val(this_env, detached_val.get())); } } // Process other fields. if (app_node.contains("output")) ctx.output = parse_env_val(this_env, app_node.value("output", "")); std::string name = parse_env_val(this_env, app_node.value("name", "")); if (app_node.contains("cmd")) ctx.cmd = parse_env_val(this_env, app_node.value("cmd", "")); if (app_node.contains("working-dir")) { ctx.working_dir = parse_env_val(this_env, app_node.value("working-dir", "")); #ifdef _WIN32 // The working directory, unlike the command itself, should not be quoted. boost::erase_all(ctx.working_dir, "\""); ctx.working_dir += '\\'; #endif } if (app_node.contains("image-path")) ctx.image_path = parse_env_val(this_env, app_node.value("image-path", "")); ctx.elevated = app_node.value("elevated", false); ctx.auto_detach = app_node.value("auto-detach", true); ctx.wait_all = app_node.value("wait-all", true); ctx.exit_timeout = std::chrono::seconds { app_node.value("exit-timeout", 5) }; ctx.virtual_display = app_node.value("virtual-display", false); ctx.scale_factor = app_node.value("scale-factor", 100); ctx.use_app_identity = app_node.value("use-app-identity", false); ctx.per_client_app_identity = app_node.value("per-client-app-identity", false); ctx.allow_client_commands = app_node.value("allow-client-commands", true); // Calculate a unique application id. auto possible_ids = calculate_app_id(name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { ctx.id = std::get<0>(possible_ids); } else { ctx.id = std::get<1>(possible_ids); } ids.insert(ctx.id); ctx.name = std::move(name); ctx.prep_cmds = std::move(prep_cmds); ctx.detached = std::move(detached); apps.emplace_back(std::move(ctx)); } fail_count = 0; } catch (std::exception &e) { BOOST_LOG(error) << "Error happened during app loading: "sv << e.what(); fail_count += 1; if (fail_count >= 3) { // No hope for recovering BOOST_LOG(warning) << "Couldn't parse/migrate apps.json properly! Apps will not be loaded."sv; break; } BOOST_LOG(warning) << "App format is still invalid! Trying to re-migrate the app list..."sv; // Always try migrating from scratch when error happened tree["version"] = 0; try { migrate(tree, file_name); } catch (std::exception &e) { BOOST_LOG(error) << "Error happened during migration: "sv << e.what(); break; } this_env = boost::this_process::environment(); ids.clear(); apps.clear(); i = 0; continue; } break; } while (fail_count < 3); if (fail_count > 0) { BOOST_LOG(warning) << "No applications configured, adding fallback Desktop entry."; proc::ctx_t ctx; ctx.idx = std::to_string(i); ctx.uuid = FALLBACK_DESKTOP_UUID; // Placeholder UUID ctx.name = "Desktop (fallback)"; ctx.image_path = parse_env_val(this_env, "desktop-alt.png"); ctx.virtual_display = false; ctx.scale_factor = 100; ctx.use_app_identity = false; ctx.per_client_app_identity = false; ctx.allow_client_commands = false; ctx.elevated = false; ctx.auto_detach = true; ctx.wait_all = false; // Desktop doesn't have a specific command to wait for ctx.exit_timeout = 5s; // Calculate unique ID auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { // Avoid using index to generate id if possible ctx.id = std::get<0>(possible_ids); } else { // Fallback to include index on collision ctx.id = std::get<1>(possible_ids); } ids.insert(ctx.id); apps.emplace_back(std::move(ctx)); } // Virtual Display entry #ifdef _WIN32 if (vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK) { proc::ctx_t ctx; ctx.idx = std::to_string(i); ctx.uuid = VIRTUAL_DISPLAY_UUID; ctx.name = "Virtual Display"; ctx.image_path = parse_env_val(this_env, "virtual_desktop.png"); ctx.virtual_display = true; ctx.scale_factor = 100; ctx.use_app_identity = false; ctx.per_client_app_identity = false; ctx.allow_client_commands = false; ctx.elevated = false; ctx.auto_detach = true; ctx.wait_all = false; ctx.exit_timeout = 5s; auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { // Avoid using index to generate id if possible ctx.id = std::get<0>(possible_ids); } else { // Fallback to include index on collision ctx.id = std::get<1>(possible_ids); } ids.insert(ctx.id); BOOST_LOG(info) << "VIRTUAL DISPLAY APP ID::: " << ctx.id; apps.emplace_back(std::move(ctx)); } #endif if (config::input.enable_input_only_mode) { // Input Only entry { proc::ctx_t ctx; ctx.idx = std::to_string(i); ctx.uuid = REMOTE_INPUT_UUID; ctx.name = "Remote Input"; ctx.image_path = parse_env_val(this_env, "input_only.png"); ctx.virtual_display = false; ctx.scale_factor = 100; ctx.use_app_identity = false; ctx.per_client_app_identity = false; ctx.allow_client_commands = false; ctx.elevated = false; ctx.auto_detach = true; ctx.wait_all = true; ctx.exit_timeout = 5s; auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { // Avoid using index to generate id if possible ctx.id = std::get<0>(possible_ids); } else { // Fallback to include index on collision ctx.id = std::get<1>(possible_ids); } ids.insert(ctx.id); input_only_app_id_str = ctx.id; input_only_app_id = util::from_view(ctx.id); apps.emplace_back(std::move(ctx)); } // Terminate entry { proc::ctx_t ctx; ctx.idx = std::to_string(i); ctx.uuid = TERMINATE_APP_UUID; ctx.name = "Terminate"; ctx.image_path = parse_env_val(this_env, "terminate.png"); ctx.virtual_display = false; ctx.scale_factor = 100; ctx.use_app_identity = false; ctx.per_client_app_identity = false; ctx.allow_client_commands = false; ctx.elevated = false; ctx.auto_detach = true; ctx.wait_all = true; ctx.exit_timeout = 5s; auto possible_ids = calculate_app_id(ctx.name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { // Avoid using index to generate id if possible ctx.id = std::get<0>(possible_ids); } else { // Fallback to include index on collision ctx.id = std::get<1>(possible_ids); } // ids.insert(ctx.id); terminate_app_id_str = ctx.id; terminate_app_id = util::from_view(ctx.id); apps.emplace_back(std::move(ctx)); } } return proc::proc_t { std::move(this_env), std::move(apps) }; } void refresh(const std::string &file_name) { proc.terminate(); #ifdef _WIN32 size_t fail_count = 0; while (fail_count < 5 && vDisplayDriverStatus != VDISPLAY::DRIVER_STATUS::OK) { initVDisplayDriver(); if (vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK) { break; } fail_count += 1; std::this_thread::sleep_for(1s); } #endif auto proc_opt = proc::parse(file_name); if (proc_opt) { proc = std::move(*proc_opt); } } } // namespace proc