From 56f15d451eec363b6980581c4806e68a81418548 Mon Sep 17 00:00:00 2001 From: fatebug <1339524041@qq.com> Date: Thu, 14 May 2026 17:49:35 +0800 Subject: [PATCH 01/10] feat(vdd): add virtual display management --- cmake/compile_definitions/common.cmake | 3 + docs/configuration.md | 71 ++ src/config.cpp | 14 + src/config.h | 8 + src/confighttp.cpp | 220 +++++++ src/main.cpp | 38 ++ src/system_tray.cpp | 41 +- src/vdd_control.cpp | 610 ++++++++++++++++++ src/vdd_control.h | 115 ++++ src_assets/common/assets/web/config.html | 12 +- .../assets/web/configs/tabs/AudioVideo.vue | 7 + .../web/configs/tabs/VirtualDisplay.vue | 305 +++++++++ .../assets/web/public/assets/locale/en.json | 41 +- .../assets/web/public/assets/locale/zh.json | 41 +- .../web/public/assets/locale/zh_TW.json | 41 +- third-party/parsec-vdd/parsec-vdd.h | 356 ++++++++++ 16 files changed, 1918 insertions(+), 5 deletions(-) create mode 100644 src/vdd_control.cpp create mode 100644 src/vdd_control.h create mode 100644 src_assets/common/assets/web/configs/tabs/VirtualDisplay.vue create mode 100644 third-party/parsec-vdd/parsec-vdd.h diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 73cfdae755c..19d662c268c 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -108,6 +108,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/network.cpp" "${CMAKE_SOURCE_DIR}/src/network.h" "${CMAKE_SOURCE_DIR}/src/move_by_copy.h" + "${CMAKE_SOURCE_DIR}/src/vdd_control.h" + "${CMAKE_SOURCE_DIR}/src/vdd_control.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.h" "${CMAKE_SOURCE_DIR}/src/task_pool.h" @@ -139,6 +141,7 @@ include_directories( BEFORE SYSTEM "${CMAKE_SOURCE_DIR}/third-party" + "${CMAKE_SOURCE_DIR}/third-party/parsec-vdd" "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/enet/include" "${CMAKE_SOURCE_DIR}/third-party/nanors" "${CMAKE_SOURCE_DIR}/third-party/nanors/deps/obl" diff --git a/docs/configuration.md b/docs/configuration.md index 5ef85dc92a3..ce368334fab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1396,6 +1396,77 @@ editing the `conf` file in a text editor. Use the examples as reference. + + +### vdd_enabled + + + + + + + + + + +
DescriptionAutomatically create a virtual display when no physical display is detected.
Default@code{}disabled@endcode
+ +### vdd_width + + + + + + + + + + +
DescriptionWidth of the virtual display in pixels.
Default@code{}1920@endcode
+ +### vdd_height + + + + + + + + + + +
DescriptionHeight of the virtual display in pixels.
Default@code{}1080@endcode
+ +### vdd_refresh_rate + + + + + + + + + + +
DescriptionRefresh rate of the virtual display in Hz.
Default@code{}144@endcode
+ +### vdd_display_count + + + + + + + + + + +
DescriptionNumber of virtual displays to restore on startup. This value is set automatically when adding or removing displays via the tray or web UI.
Default@code{}0@endcode
+ ### max_bitrate diff --git a/src/config.cpp b/src/config.cpp index a79f7d88151..1f998bafa5d 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -524,6 +524,14 @@ namespace config { {} // wa }, // display_device + { + false, // virtual_display_enabled + 1920, // virtual_display_width + 1080, // virtual_display_height + 144, // virtual_display_refresh_rate + 0, // virtual_display_count + }, // vdd + 0, // max_bitrate 0 // minimum_fps_target (0 = framerate) }; @@ -1190,6 +1198,12 @@ namespace config { } bool_f(vars, "dd_config_revert_on_disconnect", video.dd.config_revert_on_disconnect); generic_f(vars, "dd_mode_remapping", video.dd.mode_remapping, dd::mode_remapping_from_view); + + bool_f(vars, "vdd_enabled", video.vdd.virtual_display_enabled); + int_f(vars, "vdd_width", video.vdd.virtual_display_width); + int_f(vars, "vdd_height", video.vdd.virtual_display_height); + int_f(vars, "vdd_refresh_rate", video.vdd.virtual_display_refresh_rate); + int_f(vars, "vdd_display_count", video.vdd.virtual_display_count); { int value = 0; int_between_f(vars, "dd_wa_hdr_toggle_delay", value, {0, 3000}); diff --git a/src/config.h b/src/config.h index eb778a3ac68..f2bd3d1e7c7 100644 --- a/src/config.h +++ b/src/config.h @@ -152,6 +152,14 @@ namespace config { workarounds_t wa; } dd; + struct { + bool virtual_display_enabled; ///< Enable virtual display creation when no display detected + int virtual_display_width; ///< Virtual display width + int virtual_display_height; ///< Virtual display height + int virtual_display_refresh_rate; ///< Virtual display refresh rate + int virtual_display_count; ///< Number of persisted virtual displays to restore on startup + } vdd; + int max_bitrate; // Maximum bitrate, sets ceiling in kbps for bitrate requested from client double minimum_fps_target; ///< Lowest framerate that will be used when streaming. Range 0-1000, 0 = half of client's requested framerate. }; diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 32f504ee699..e043a8e93b8 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -38,6 +38,10 @@ #include "httpcommon.h" #include "logging.h" #include "network.h" + +#ifdef _WIN32 + #include "vdd_control.h" +#endif #include "nvhttp.h" #include "platform/common.h" #include "process.h" @@ -1531,6 +1535,215 @@ namespace confighttp { send_response(response, output_tree); } +#ifdef _WIN32 + /** + * @brief Get the VDD virtual display status. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/vdd/status| GET| null} + */ + void getVddStatus(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + // Auto-initialize if needed to report accurate driver status + if (!vdd::is_initialized()) { + vdd::init(); + } + + nlohmann::json output_tree; + output_tree["status"] = true; + output_tree["initialized"] = vdd::is_initialized(); + output_tree["driver_ok"] = vdd::get_driver_status() == vdd::DriverStatus::OK; + output_tree["driver_version"] = vdd::get_driver_version(); + + auto displays = vdd::get_displays(); + auto display_list = nlohmann::json::array(); + for (const auto &d : displays) { + display_list.push_back({ + {"index", d.index}, + {"identifier", d.identifier}, + {"width", d.width}, + {"height", d.height}, + {"hz", d.hz}, + {"device_name", d.device_name} + }); + } + output_tree["displays"] = display_list; + output_tree["display_count"] = display_list.size(); + + send_response(response, output_tree); + } + + /** + * @brief Add a virtual display. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/vdd/add| POST| {"width": 1920, "height": 1080, "hz": 144}} + */ + void addVddDisplay(const resp_https_t &response, const req_https_t &request) { + if (!check_content_type(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::string client_id = get_client_id(request); + if (!validate_csrf_token(response, request, client_id)) { + return; + } + + nlohmann::json output_tree; + + if (!vdd::is_initialized()) { + if (!vdd::init()) { + output_tree["status"] = false; + output_tree["error"] = "VDD driver not available"; + send_response(response, output_tree); + return; + } + } + + try { + auto input = nlohmann::json::parse(request->content); + int width = input.value("width", 1920); + int height = input.value("height", 1080); + int hz = input.value("hz", 144); + + // Validate ranges + if (width < 320 || width > 7680) { + output_tree["status"] = false; + output_tree["error"] = "Width must be between 320 and 7680"; + send_response(response, output_tree); + return; + } + if (height < 240 || height > 4320) { + output_tree["status"] = false; + output_tree["error"] = "Height must be between 240 and 4320"; + send_response(response, output_tree); + return; + } + if (hz < 30 || hz > 240) { + output_tree["status"] = false; + output_tree["error"] = "Refresh rate must be between 30 and 240"; + send_response(response, output_tree); + return; + } + + int idx = vdd::add_display(width, height, hz); + output_tree["status"] = idx >= 0; + if (idx >= 0) { + output_tree["success"] = true; + output_tree["index"] = idx; + } else { + output_tree["success"] = false; + output_tree["error"] = "VDD driver returned error (idx=" + std::to_string(idx) + "). Check Sunshine logs for details."; + } + } catch (const std::exception &e) { + output_tree["status"] = false; + output_tree["error"] = e.what(); + } + + send_response(response, output_tree); + } + + /** + * @brief Remove a virtual display. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/vdd/remove| POST| {"index": 0}} + */ + void removeVddDisplay(const resp_https_t &response, const req_https_t &request) { + if (!check_content_type(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::string client_id = get_client_id(request); + if (!validate_csrf_token(response, request, client_id)) { + return; + } + + nlohmann::json output_tree; + + // Auto-initialize if needed + if (!vdd::is_initialized()) { + vdd::init(); + } + + try { + auto input = nlohmann::json::parse(request->content); + int index = input.value("index", -1); + + bool result = false; + if (index >= 0) { + result = vdd::remove_display(index); + } else { + result = vdd::remove_last_display(); + } + + output_tree["status"] = result; + output_tree["success"] = result; + } catch (const std::exception &e) { + output_tree["status"] = false; + output_tree["error"] = e.what(); + } + + send_response(response, output_tree); + } + + /** + * @brief Remove all virtual displays. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/vdd/remove-all| POST| null} + */ + void removeAllVddDisplays(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::string client_id = get_client_id(request); + if (!validate_csrf_token(response, request, client_id)) { + return; + } + + nlohmann::json output_tree; + + // Auto-initialize if needed + if (!vdd::is_initialized()) { + vdd::init(); + } + + if (!vdd::is_initialized()) { + output_tree["status"] = false; + output_tree["error"] = "VDD driver not available"; + } else { + vdd::remove_all_displays(); + output_tree["status"] = true; + output_tree["success"] = true; + } + + send_response(response, output_tree); + } +#endif + /** * @brief Checks whether a directory entry qualifies as an executable file. * @param entry The directory entry to check. @@ -1784,6 +1997,13 @@ namespace confighttp { server.resource["^/api/vigembus/status$"]["GET"] = getViGEmBusStatus; server.resource["^/api/vigembus/install$"]["POST"] = installViGEmBus; +#ifdef _WIN32 + server.resource["^/api/vdd/status$"]["GET"] = getVddStatus; + server.resource["^/api/vdd/add$"]["POST"] = addVddDisplay; + server.resource["^/api/vdd/remove$"]["POST"] = removeVddDisplay; + server.resource["^/api/vdd/remove-all$"]["POST"] = removeAllVddDisplays; +#endif + // static/dynamic resources server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage; diff --git a/src/main.cpp b/src/main.cpp index 13df678403f..c4dd471e984 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "process.h" #include "system_tray.h" #include "upnp.h" +#include "vdd_control.h" #include "video.h" extern "C" { @@ -315,6 +316,9 @@ int main(int argc, char *argv[]) { system_tray::end_tray(); } +#ifdef _WIN32 + vdd::destroy(); +#endif display_device_deinit_guard = nullptr; }); @@ -335,6 +339,9 @@ int main(int argc, char *argv[]) { system_tray::end_tray(); } +#ifdef _WIN32 + vdd::destroy(); +#endif display_device_deinit_guard = nullptr; }); @@ -353,6 +360,37 @@ int main(int argc, char *argv[]) { BOOST_LOG(error) << "Platform failed to initialize"sv; } +#ifdef _WIN32 + // Initialize VDD (Parsec Virtual Display Driver) if enabled + if (config::video.vdd.virtual_display_enabled) { + if (vdd::init()) { + if (vdd::need_virtual_display()) { + BOOST_LOG(info) << "No physical display detected - creating virtual display"sv; + vdd::add_display( + config::video.vdd.virtual_display_width, + config::video.vdd.virtual_display_height, + config::video.vdd.virtual_display_refresh_rate); + } else { + // Restore persisted virtual displays + int count = config::video.vdd.virtual_display_count; + if (count > 0) { + BOOST_LOG(info) << "Restoring "sv << count << " persisted virtual display(s)"sv; + for (int i = 0; i < count; ++i) { + vdd::add_display( + config::video.vdd.virtual_display_width, + config::video.vdd.virtual_display_height, + config::video.vdd.virtual_display_refresh_rate); + } + } else { + BOOST_LOG(info) << "Physical display detected, VDD ready for manual use"sv; + } + } + } else { + BOOST_LOG(warning) << "VDD driver not available - virtual displays disabled"sv; + } + } +#endif + auto proc_deinit_guard = proc::init(); if (!proc_deinit_guard) { BOOST_LOG(error) << "Proc failed to initialize"sv; diff --git a/src/system_tray.cpp b/src/system_tray.cpp index 1799b2c668d..c12e65ae271 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -48,6 +48,7 @@ #include "platform/common.h" #include "process.h" #include "src/entry_handler.h" + #include "vdd_control.h" using namespace std::literals; @@ -99,6 +100,36 @@ namespace system_tray { lifetime::exit_sunshine(0, true); } +#ifdef _WIN32 + static void ensure_vdd_initialized() { + if (!vdd::is_initialized()) { + if (!vdd::init()) { + BOOST_LOG(warning) << "VDD: Failed to initialize driver from tray"sv; + } + } + } + + void tray_vdd_add_cb([[maybe_unused]] struct tray_menu *item) { + ensure_vdd_initialized(); + BOOST_LOG(info) << "VDD: Adding virtual display from tray"sv; + vdd::add_display(config::video.vdd.virtual_display_width, + config::video.vdd.virtual_display_height, + config::video.vdd.virtual_display_refresh_rate); + } + + void tray_vdd_remove_cb([[maybe_unused]] struct tray_menu *item) { + ensure_vdd_initialized(); + BOOST_LOG(info) << "VDD: Removing last virtual display from tray"sv; + vdd::remove_last_display(); + } + + void tray_vdd_remove_all_cb([[maybe_unused]] struct tray_menu *item) { + ensure_vdd_initialized(); + BOOST_LOG(info) << "VDD: Removing all virtual displays from tray"sv; + vdd::remove_all_displays(); + } +#endif + // Tray menu static struct tray tray = { .icon = TRAY_ICON, @@ -117,8 +148,16 @@ namespace system_tray { {.text = nullptr} }}, {.text = "-"}, - // Currently display device settings are only supported on Windows + // Currently display device settings and VDD are only supported on Windows #ifdef _WIN32 + {.text = "Virtual Display", + .submenu = + (struct tray_menu[]) { + {.text = "Add Virtual Display", .cb = tray_vdd_add_cb}, + {.text = "Remove Last", .cb = tray_vdd_remove_cb}, + {.text = "Remove All", .cb = tray_vdd_remove_all_cb}, + {.text = nullptr} + }}, {.text = "Reset Display Device Config", .cb = tray_reset_display_device_config_cb}, #endif {.text = "Restart", .cb = tray_restart_cb}, diff --git a/src/vdd_control.cpp b/src/vdd_control.cpp new file mode 100644 index 00000000000..20627d334ae --- /dev/null +++ b/src/vdd_control.cpp @@ -0,0 +1,610 @@ +/** + * @file src/vdd_control.cpp + * @brief Definitions for Parsec Virtual Display Driver control. + */ + +#ifdef _WIN32 + +// header include +#include "vdd_control.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include + +// local includes +#include "config.h" +#include "file_handler.h" +#include "logging.h" +#include "platform/common.h" + +// parsec-vdd core header +#include + +using namespace std::literals; +using namespace parsec_vdd; + +namespace vdd { + + // Forward declaration — used by enumerate_displays in the anonymous namespace + static bool is_vdd_display(const DISPLAY_DEVICEA &dd); + + namespace { + + // Driver constants + constexpr auto KEEPALIVE_INTERVAL = 50ms; + + // State — intentionally mutable globals, scoped to this translation unit + HANDLE g_vdd_handle = INVALID_HANDLE_VALUE; // NOSONAR -- reassigned at runtime + std::mutex g_handle_mutex; // NOSONAR -- mutex must be mutable + std::unique_ptr g_keepalive_thread; // NOSONAR -- reassigned at runtime + std::atomic g_keepalive_running{false}; // NOSONAR -- runtime state flag + std::atomic g_initialized{false}; // NOSONAR -- runtime state flag + + // Track VDD driver indices and device names for displays created by this session + std::vector g_vdd_indices; // NOSONAR -- runtime collection + std::vector g_vdd_device_names; // NOSONAR -- runtime collection + struct VddDisplayConfig { int width; int height; int hz; }; + std::vector g_vdd_configs; // NOSONAR -- runtime collection + + /** + * @brief Persist the current display count to the config file. + */ + void persist_display_count() { + auto content = file_handler::read_file(config::sunshine.config_file.c_str()); + auto new_value = std::to_string(g_vdd_indices.size()); + + // Update the vdd_display_count line in-place to preserve + // comments, blank lines, and option ordering. + std::string line; + std::stringstream result; + bool found = false; + + for (size_t i = 0; i <= content.size(); ++i) { + bool at_end = (i == content.size()); + if (!at_end && content[i] != '\n') { + line += content[i]; + continue; + } + if (!line.empty() && line.rfind("vdd_display_count", 0) == 0) { + result << "vdd_display_count = "sv << new_value << '\n'; + found = true; + } else { + result << line; + if (!at_end) result << '\n'; + } + line.clear(); + } + + if (!found) { + result << "vdd_display_count = "sv << new_value << '\n'; + } + + file_handler::write_file(config::sunshine.config_file.c_str(), result.str()); + } + + /** + * @brief Enumerate displays via EnumDisplayDevices. + * @param vdd_only If true, only count VDD displays. + * @return Number of displays matching the criteria. + */ + int enumerate_displays(bool vdd_only) { + int count = 0; + + DISPLAY_DEVICEA dd; + ZeroMemory(&dd, sizeof(dd)); + dd.cb = sizeof(dd); + + for (DWORD i = 0; EnumDisplayDevicesA(nullptr, i, &dd, 0); ++i) { + bool is_vdd = is_vdd_display(dd); + if (vdd_only == is_vdd) { + ++count; + } + } + + return count; + } + + } // anonymous namespace + + /** + * @brief Check if a display device is a VDD display. + * Uses multiple detection methods for robustness. + */ + static bool is_vdd_display(const DISPLAY_DEVICEA &dd) { + // Method 1: Check DeviceID for PSCCDD0 (hardware ID) + if (dd.DeviceID[0] != '\0' && std::string_view(dd.DeviceID).contains("PSCCDD0")) { + return true; + } + // Method 2: Check DeviceString for Parsec adapter name + if (std::string_view(dd.DeviceString).contains("Parsec")) { + return true; + } + return false; + } + + // Forward declaration for internal helpers used by locked functions + std::vector get_displays_internal(); + + bool init() { + std::scoped_lock lock(g_handle_mutex); + + if (g_initialized) { + return true; + } + + // Check if driver is installed + auto status = QueryDeviceStatus(&VDD_CLASS_GUID, VDD_HARDWARE_ID); + if (status != DEVICE_OK) { + BOOST_LOG(warning) << "VDD: Driver not ready (status="sv << (int)status << ')' << std::endl; + } + + // Try to open handle regardless - driver might be usable + HANDLE handle = OpenDeviceHandle(&VDD_ADAPTER_GUID); + if (handle == INVALID_HANDLE_VALUE || handle == nullptr) { + BOOST_LOG(warning) << "VDD: Failed to open device handle - driver may not be installed"sv << std::endl; + return false; + } + + g_vdd_handle = handle; + g_initialized = true; + + BOOST_LOG(info) << "VDD: Initialized successfully (handle="sv << (void *)handle << ')' << std::endl; + return true; + } + + void destroy() { + stop_keepalive(); + + { + std::scoped_lock lock(g_handle_mutex); + + if (!g_initialized) { + return; + } + + // Remove only displays tracked by this session + for (auto it = g_vdd_indices.rbegin(); it != g_vdd_indices.rend(); ++it) { + VddRemoveDisplay(g_vdd_handle, *it); + } + g_vdd_indices.clear(); + g_vdd_device_names.clear(); + g_vdd_configs.clear(); + BOOST_LOG(info) << "VDD: Cleaned up displays on shutdown"sv << std::endl; + + if (g_vdd_handle != INVALID_HANDLE_VALUE && g_vdd_handle != nullptr) { + CloseDeviceHandle(g_vdd_handle); + g_vdd_handle = INVALID_HANDLE_VALUE; + } + + g_initialized = false; + } + + BOOST_LOG(info) << "VDD: Destroyed"sv << std::endl; + } + + bool is_initialized() { + return g_initialized; + } + + DriverStatus get_driver_status() { + using enum DriverStatus; + auto status = QueryDeviceStatus(&VDD_CLASS_GUID, VDD_HARDWARE_ID); + switch (status) { + case DEVICE_OK: + return OK; + case DEVICE_NOT_INSTALLED: + return NOT_INSTALLED; + case DEVICE_DISABLED: + case DEVICE_DISABLED_SERVICE: + return DISABLED; + case DEVICE_RESTART_REQUIRED: + return RESTART_REQUIRED; + case DEVICE_INACCESSIBLE: + return INACCESSIBLE; + default: + return UNKNOWN; + } + } + + std::string get_driver_version() { + std::scoped_lock lock(g_handle_mutex); + if (!g_initialized || g_vdd_handle == INVALID_HANDLE_VALUE) { + return "(not connected)"s; + } + + int minor = VddVersion(g_vdd_handle); + if (minor < 0) { + return "(unknown)"s; + } + + // The version IOCTL returns (major << 16) | minor + int major = (minor >> 16) & 0xFFFF; + int minor_ver = minor & 0xFFFF; + return std::format("{}.{}", major, minor_ver); + } + + bool need_virtual_display() { + // Count physical (non-VDD) DXGI outputs + int physical_count = enumerate_displays(false); + BOOST_LOG(info) << "VDD: Physical displays detected: "sv << physical_count << std::endl; + return physical_count == 0; + } + + namespace { + + /** + * @brief Find the newly added VDD display by diffing against known names. + * Enumeration order does not match creation order, so we must diff. + */ + std::string find_new_display_name(const std::set> &known_names) { + DISPLAY_DEVICEA dd; + ZeroMemory(&dd, sizeof(dd)); + dd.cb = sizeof(dd); + + for (DWORD i = 0; EnumDisplayDevicesA(nullptr, i, &dd, EDD_GET_DEVICE_INTERFACE_NAME); ++i) { + if (is_vdd_display(dd) && !known_names.contains(dd.DeviceName)) { + return dd.DeviceName; + } + } + return {}; + } + + /** + * @brief Apply a display mode to the named device with fallback enumeration. + * @return true if a mode was successfully applied. + */ + bool apply_display_mode(const std::string &device_name, int width, int height, int hz) { + DEVMODEA dm; + ZeroMemory(&dm, sizeof(dm)); + dm.dmSize = sizeof(dm); + dm.dmPelsWidth = width; + dm.dmPelsHeight = height; + dm.dmDisplayFrequency = hz; + dm.dmBitsPerPel = 32; + dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_BITSPERPEL; + + LONG ret = ChangeDisplaySettingsExA( + device_name.c_str(), &dm, nullptr, + CDS_UPDATEREGISTRY, nullptr); + + if (ret == DISP_CHANGE_SUCCESSFUL) { + BOOST_LOG(info) << "VDD: Set display "sv << device_name + << " to "sv << width << 'x' << height << '@' << hz << "Hz"sv << std::endl; + return true; + } + + BOOST_LOG(warning) << "VDD: Requested mode "sv << width << 'x' << height << '@' << hz + << "Hz not accepted (error="sv << ret << "), enumerating supported modes"sv << std::endl; + + // Fallback: enumerate supported modes and pick the closest match. + int best_idx = -1; + int best_score = 99999999; + int actual_w = 0; + int actual_h = 0; + int actual_hz = 0; + + ZeroMemory(&dm, sizeof(dm)); + dm.dmSize = sizeof(dm); + + for (int i = 0; EnumDisplaySettingsA(device_name.c_str(), i, &dm); ++i) { + int w_diff = std::abs((int) dm.dmPelsWidth - width); + int h_diff = std::abs((int) dm.dmPelsHeight - height); + int hz_diff = std::abs((int) dm.dmDisplayFrequency - hz); + int score = w_diff * 10000 + h_diff * 100 + hz_diff; + + if (score < best_score) { + best_score = score; + best_idx = i; + actual_w = dm.dmPelsWidth; + actual_h = dm.dmPelsHeight; + actual_hz = dm.dmDisplayFrequency; + } + } + + if (best_idx < 0) { + BOOST_LOG(warning) << "VDD: No supported display modes found for "sv << device_name << std::endl; + return false; + } + + ZeroMemory(&dm, sizeof(dm)); + dm.dmSize = sizeof(dm); + if (!EnumDisplaySettingsA(device_name.c_str(), best_idx, &dm)) { + return false; + } + + ret = ChangeDisplaySettingsExA( + device_name.c_str(), &dm, nullptr, + CDS_UPDATEREGISTRY, nullptr); + + if (ret == DISP_CHANGE_SUCCESSFUL) { + BOOST_LOG(info) << "VDD: Set display "sv << device_name + << " to closest supported mode "sv << actual_w << 'x' << actual_h << '@' << actual_hz << "Hz"sv << std::endl; + + // Update stored config with the mode that was actually applied + std::scoped_lock lock(g_handle_mutex); + if (!g_vdd_configs.empty()) { + g_vdd_configs.back() = {actual_w, actual_h, actual_hz}; + } + } else { + BOOST_LOG(warning) << "VDD: Failed to set closest mode (error="sv << ret << ')' << std::endl; + } + + return ret == DISP_CHANGE_SUCCESSFUL; + } + + } // anonymous namespace + + int add_display(int width, int height, int hz) { + int idx; + + { + std::scoped_lock lock(g_handle_mutex); + if (!g_initialized || g_vdd_handle == INVALID_HANDLE_VALUE) { + BOOST_LOG(error) << "VDD: Cannot add display - not initialized"sv << std::endl; + return -1; + } + + idx = VddAddDisplay(g_vdd_handle); + if (idx < 0) { + BOOST_LOG(error) << "VDD: VddAddDisplay failed (returned "sv << idx + << ", handle="sv << (void *)g_vdd_handle + << ", GetLastError="sv << GetLastError() << ')' << std::endl; + return -1; + } + } + + BOOST_LOG(info) << "VDD: Added display #"sv << idx << " ("sv << width << 'x' << height << '@' << hz << "Hz)"sv << std::endl; + + // After VDD adds the display, wait briefly for Windows to detect it + std::this_thread::sleep_for(500ms); + + // Find the newly added VDD display by diffing against tracked device names. + std::set> known_names; + { + std::scoped_lock lock(g_handle_mutex); + for (const auto &name : g_vdd_device_names) { + known_names.insert(name); + } + } + + std::string new_device_name = find_new_display_name(known_names); + + if (new_device_name.empty()) { + BOOST_LOG(warning) << "VDD: Could not find newly created display"sv << std::endl; + VddRemoveDisplay(g_vdd_handle, idx); + return -1; + } + + // Only now track the driver index, device name, and persist — + // the display is confirmed visible in Windows. + { + std::scoped_lock lock(g_handle_mutex); + g_vdd_indices.push_back(idx); + g_vdd_device_names.push_back(new_device_name); + g_vdd_configs.push_back({width, height, hz}); + } + + persist_display_count(); + + // Apply the requested display mode (with fallback enumeration) + apply_display_mode(new_device_name, width, height, hz); + + // Re-apply stored resolutions to all previously created displays. + // VddAddDisplay + VddUpdate can reset existing VDD displays to the + // driver's default EDID mode, so we must restore them. + { + std::scoped_lock lock(g_handle_mutex); + for (size_t n = 0; n + 1 < g_vdd_device_names.size(); ++n) { + if (n >= g_vdd_configs.size()) continue; + + auto &cfg = g_vdd_configs[n]; + DEVMODEA check_dm; + ZeroMemory(&check_dm, sizeof(check_dm)); + check_dm.dmSize = sizeof(check_dm); + + if (EnumDisplaySettingsA(g_vdd_device_names[n].c_str(), ENUM_CURRENT_SETTINGS, &check_dm)) { + if ((int) check_dm.dmPelsWidth != cfg.width || + (int) check_dm.dmPelsHeight != cfg.height || + (int) check_dm.dmDisplayFrequency != cfg.hz) { + BOOST_LOG(info) << "VDD: Restoring display "sv << g_vdd_device_names[n] + << " to "sv << cfg.width << 'x' << cfg.height << '@' << cfg.hz << "Hz"sv << std::endl; + + check_dm.dmPelsWidth = cfg.width; + check_dm.dmPelsHeight = cfg.height; + check_dm.dmDisplayFrequency = cfg.hz; + check_dm.dmBitsPerPel = 32; + check_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_BITSPERPEL; + + ChangeDisplaySettingsExA( + g_vdd_device_names[n].c_str(), &check_dm, nullptr, + CDS_UPDATEREGISTRY, nullptr); + } + } + } + } + + // Ensure keepalive is running for the new display + start_keepalive(); + + return idx; + } + + bool remove_display(int index) { + std::scoped_lock lock(g_handle_mutex); + if (!g_initialized || g_vdd_handle == INVALID_HANDLE_VALUE) { + return false; + } + + // Map from our enumeration index to the VDD driver index + if (index < 0 || index >= static_cast(g_vdd_indices.size())) { + BOOST_LOG(warning) << "VDD: Invalid display index "sv << index << std::endl; + return false; + } + + int vdd_idx = g_vdd_indices[index]; + VddRemoveDisplay(g_vdd_handle, vdd_idx); + g_vdd_indices.erase(g_vdd_indices.begin() + index); + g_vdd_device_names.erase(g_vdd_device_names.begin() + index); + g_vdd_configs.erase(g_vdd_configs.begin() + index); + BOOST_LOG(info) << "VDD: Removed display #"sv << index << " (vdd_idx="sv << vdd_idx << ')' << std::endl; + + persist_display_count(); + return true; + } + + bool remove_last_display() { + int vdd_idx; + + { + std::scoped_lock lock(g_handle_mutex); + if (!g_initialized || g_vdd_handle == INVALID_HANDLE_VALUE || g_vdd_indices.empty()) { + return false; + } + + vdd_idx = g_vdd_indices.back(); + g_vdd_indices.pop_back(); + g_vdd_device_names.pop_back(); + g_vdd_configs.pop_back(); + VddRemoveDisplay(g_vdd_handle, vdd_idx); + } + + persist_display_count(); + BOOST_LOG(info) << "VDD: Removed last display (vdd_idx="sv << vdd_idx << ')' << std::endl; + return true; + } + + void remove_all_displays() { + { + std::scoped_lock lock(g_handle_mutex); + if (!g_initialized || g_vdd_handle == INVALID_HANDLE_VALUE) { + return; + } + + for (auto it = g_vdd_indices.rbegin(); it != g_vdd_indices.rend(); ++it) { + VddRemoveDisplay(g_vdd_handle, *it); + } + + if (!g_vdd_indices.empty()) { + BOOST_LOG(info) << "VDD: Removed all "sv << g_vdd_indices.size() << " display(s)"sv << std::endl; + g_vdd_indices.clear(); + g_vdd_device_names.clear(); + g_vdd_configs.clear(); + } + } + + persist_display_count(); + } + + /** + * @brief Internal display enumeration (caller must hold g_handle_mutex). + */ + std::vector get_displays_internal() { + std::vector result; + + // Only return displays created by this session + for (size_t n = 0; n < g_vdd_device_names.size(); ++n) { + const auto &name = g_vdd_device_names[n]; + + DisplayInfo display_info{}; + display_info.index = static_cast(n); + display_info.device_name = name; + + // Try to read actual display settings first; fall back to stored config + DEVMODEA dm; + ZeroMemory(&dm, sizeof(dm)); + dm.dmSize = sizeof(dm); + if (EnumDisplaySettingsA(name.c_str(), ENUM_CURRENT_SETTINGS, &dm)) { + display_info.width = dm.dmPelsWidth; + display_info.height = dm.dmPelsHeight; + display_info.hz = dm.dmDisplayFrequency; + } else if (n < g_vdd_configs.size()) { + display_info.width = g_vdd_configs[n].width; + display_info.height = g_vdd_configs[n].height; + display_info.hz = g_vdd_configs[n].hz; + } + + // Parse identifier from device name + auto pos = display_info.device_name.rfind("DISPLAY"); + if (pos != std::string::npos) { + try { + display_info.identifier = std::stoi(display_info.device_name.substr(pos + 7)); + } catch (const std::exception &) { + display_info.identifier = static_cast(n) + 1; + } + } else { + display_info.identifier = static_cast(n) + 1; + } + + result.push_back(display_info); + } + + BOOST_LOG(info) << "VDD: Found "sv << result.size() << " virtual display(s)"sv << std::endl; + return result; + } + + std::vector get_displays() { + std::scoped_lock lock(g_handle_mutex); + if (!g_initialized) { + return {}; + } + return get_displays_internal(); + } + + int get_display_count() { + return static_cast(get_displays().size()); + } + + void start_keepalive() { + bool expected = false; + if (!g_keepalive_running.compare_exchange_strong(expected, true)) { + return; + } + + g_keepalive_thread = std::make_unique([]() { + platf::set_thread_name("vdd_keepalive"); + BOOST_LOG(info) << "VDD: Keepalive thread started"sv << std::endl; + + while (g_keepalive_running) { + { + std::scoped_lock lock(g_handle_mutex); + if (g_initialized && g_vdd_handle != INVALID_HANDLE_VALUE) { + VddUpdate(g_vdd_handle); + } + } + + std::this_thread::sleep_for(KEEPALIVE_INTERVAL); + } + + BOOST_LOG(info) << "VDD: Keepalive thread stopped"sv << std::endl; + }); + + BOOST_LOG(info) << "VDD: Keepalive started"sv << std::endl; + } + + void stop_keepalive() { + if (g_keepalive_running) { + g_keepalive_running = false; + + if (g_keepalive_thread && g_keepalive_thread->joinable()) { + g_keepalive_thread->join(); + } + + g_keepalive_thread.reset(); + } + } + +} // namespace vdd + +#endif // _WIN32 diff --git a/src/vdd_control.h b/src/vdd_control.h new file mode 100644 index 00000000000..c51f12c4cce --- /dev/null +++ b/src/vdd_control.h @@ -0,0 +1,115 @@ +/** + * @file src/vdd_control.h + * @brief Declarations for Parsec Virtual Display Driver control. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include + +namespace vdd { + + /** + * @brief Information about a single virtual display. + */ + struct DisplayInfo { + int index; ///< Display index (0-based) + int identifier; ///< Display identifier + int width; ///< Current width + int height; ///< Current height + int hz; ///< Current refresh rate + std::string device_name; ///< Windows device name (e.g. \\.\\DISPLAY1) + }; + + /** + * @brief Status of the VDD driver. + */ + enum class DriverStatus { + OK, ///< Driver is ready + NOT_INSTALLED, ///< Parsec VDD driver is not installed + DISABLED, ///< Driver device is disabled + RESTART_REQUIRED, ///< System restart is required + INACCESSIBLE, ///< Driver cannot be accessed + UNKNOWN ///< Unknown driver status + }; + + /** + * @brief Initialize the VDD driver connection. + * @return true if initialization was successful. + */ + bool init(); + + /** + * @brief Clean up and close the VDD driver handle. + */ + void destroy(); + + /** + * @brief Check whether we are initialized. + */ + bool is_initialized(); + + /** + * @brief Get the current driver status. + */ + DriverStatus get_driver_status(); + + /** + * @brief Get the driver version. + * @return Version string, e.g. "0.45". + */ + std::string get_driver_version(); + + /** + * @brief Check if there are any physical (non-VDD) displays attached. + * @return true if only VDD displays exist or no displays at all. + */ + bool need_virtual_display(); + + /** + * @brief Add a virtual display. + * @return The display index, or -1 on failure. + */ + int add_display(int width, int height, int hz); + + /** + * @brief Remove a virtual display by index. + */ + bool remove_display(int index); + + /** + * @brief Remove the last added virtual display. + */ + bool remove_last_display(); + + /** + * @brief Remove all virtual displays. + */ + void remove_all_displays(); + + /** + * @brief Get a list of currently active virtual displays. + */ + std::vector get_displays(); + + /** + * @brief Get the number of currently active virtual displays. + */ + int get_display_count(); + + /** + * @brief Start the keepalive thread that periodically pings the driver. + */ + void start_keepalive(); + + /** + * @brief Stop the keepalive thread. + */ + void stop_keepalive(); + +} // namespace vdd diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 513932a2cf9..7312602504f 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -235,6 +235,11 @@

{{ $t('config.configuration') }}

"dd_config_revert_delay": 3000, "dd_config_revert_on_disconnect": "disabled", "dd_mode_remapping": {"mixed": [], "resolution_only": [], "refresh_rate_only": []}, + "vdd_enabled": "false", + "vdd_width": "1920", + "vdd_height": "1080", + "vdd_refresh_rate": "144", + "vdd_display_count": "0", "max_bitrate": 0, "minimum_fps_target": 0 }, @@ -363,9 +368,14 @@

{{ $t('config.configuration') }}

const options = []; this.tabs.forEach(tab => { Object.keys(tab.options).forEach(key => { + const i18nKey = 'config.' + key; + const translated = this.$t(i18nKey); + const label = translated !== i18nKey + ? translated + : key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase()); options.push({ key: key, - label: key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase()), + label: label, tab: tab.name, tabId: tab.id }); diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index cc6a88110b3..c1ec0316570 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -6,6 +6,7 @@ import AdapterNameSelector from './audiovideo/AdapterNameSelector.vue' import DisplayOutputSelector from './audiovideo/DisplayOutputSelector.vue' import DisplayDeviceOptions from "./audiovideo/DisplayDeviceOptions.vue"; import DisplayModesSettings from "./audiovideo/DisplayModesSettings.vue"; +import VirtualDisplay from "./VirtualDisplay.vue"; import Checkbox from "../../Checkbox.vue"; const props = defineProps([ @@ -90,6 +91,12 @@ const config = ref(props.config) :config="config" /> + + + +import { ref, onMounted, computed, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { Plus, Trash2 } from 'lucide-vue-next' +import PlatformLayout from '../../PlatformLayout.vue' +const { t } = useI18n() + +const props = defineProps({ + platform: String, + config: Object, +}) + +const vddStatus = ref({ + initialized: false, + driver_ok: false, + driver_version: '(unknown)', + displays: [], + display_count: 0, +}) + +const isLoading = ref(false) +const errorMsg = ref('') +const successMsg = ref('') + +const isWin = computed(() => props.platform === 'windows') + +const customWidth = ref(1920) +const customHeight = ref(1080) +const customHz = ref(144) + +const presets = [ + { label: '720p', w: 1280, h: 720 }, + { label: '1080p', w: 1920, h: 1080 }, + { label: '1440p', w: 2560, h: 1440 }, + { label: '4K', w: 3840, h: 2160 }, +] + +const isValidResolution = computed(() => { + return customWidth.value >= 320 && customWidth.value <= 7680 && + customHeight.value >= 240 && customHeight.value <= 4320 && + customHz.value >= 30 && customHz.value <= 240 +}) + +watch(() => props.config, (cfg) => { + if (cfg) { + customWidth.value = parseInt(cfg.vdd_width) || 1920 + customHeight.value = parseInt(cfg.vdd_height) || 1080 + customHz.value = parseInt(cfg.vdd_refresh_rate) || 144 + } +}, { immediate: true }) + +const fetchStatus = async () => { + if (!isWin.value) return + isLoading.value = true + try { + const resp = await fetch('/api/vdd/status') + const data = await resp.json() + if (data.status) { + vddStatus.value = data + } + } catch (e) { + errorMsg.value = t('config.vdd_fetch_error') + } finally { + isLoading.value = false + } +} + +const addDisplay = async () => { + if (!isWin.value) return + errorMsg.value = '' + successMsg.value = '' + isLoading.value = true + + try { + const body = JSON.stringify({ + width: customWidth.value, + height: customHeight.value, + hz: customHz.value, + }) + const resp = await fetch('/api/vdd/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + const data = await resp.json() + if (data.success) { + successMsg.value = `${t('config.vdd_add_display')} (${customWidth.value}x${customHeight.value}@${customHz.value}Hz)` + await fetchStatus() + } else { + errorMsg.value = data.error || t('config.vdd_add_error') + } + } catch (e) { + errorMsg.value = t('config.vdd_add_error') + ': ' + e.message + } finally { + isLoading.value = false + } +} + +const applyPreset = (preset) => { + customWidth.value = preset.w + customHeight.value = preset.h +} + +const removeDisplay = async (index) => { + errorMsg.value = '' + successMsg.value = '' + try { + const resp = await fetch('/api/vdd/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ index }), + }) + const data = await resp.json() + if (data.success) { + successMsg.value = `${t('config.vdd_remove')} #${index}` + await fetchStatus() + } else { + errorMsg.value = data.error || t('config.vdd_remove_error') + } + } catch (e) { + errorMsg.value = t('config.vdd_remove_error') + } +} + +const removeAllDisplays = async () => { + errorMsg.value = '' + successMsg.value = '' + try { + const resp = await fetch('/api/vdd/remove-all', { method: 'POST' }) + const data = await resp.json() + if (data.success) { + successMsg.value = t('config.vdd_remove_all_success') + await fetchStatus() + } else { + errorMsg.value = data.error || t('config.vdd_remove_all_error') + } + } catch (e) { + errorMsg.value = t('config.vdd_remove_all_error') + } +} + +onMounted(() => { + if (isWin.value) { + fetchStatus() + } +}) + + + diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 30289b1eebc..42c2236c55d 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -381,6 +381,13 @@ "upnp_desc": "Automatically configure port forwarding for streaming over the Internet", "vaapi_strict_rc_buffer": "Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs", "vaapi_strict_rc_buffer_desc": "Enabling this option can avoid dropped frames over the network during scene changes, but video quality may be reduced during motion.", + "vdd_display_count": "Persisted Virtual Displays", + "vdd_display_count_desc": "Number of virtual displays to restore on startup (set automatically)", + "vdd_enabled": "Enable Virtual Display", + "vdd_enabled_desc": "Automatically create a virtual display when no physical display is detected", + "vdd_height": "Virtual Display Height", + "vdd_refresh_rate": "Virtual Display Refresh Rate", + "vdd_width": "Virtual Display Width", "vk_rc_cbr": "CBR (Constant Bitrate) (default)", "vk_rc_cqp": "CQP (Constant QP)", "vk_rc_mode": "Rate Control", @@ -402,7 +409,39 @@ "wan_encryption_mode": "WAN Encryption Mode", "wan_encryption_mode_1": "Enabled for supported clients (default)", "wan_encryption_mode_2": "Required for all clients", - "wan_encryption_mode_desc": "This determines when encryption will be used when streaming over the Internet. Encryption can reduce streaming performance, particularly on less powerful hosts and clients." + "wan_encryption_mode_desc": "This determines when encryption will be used when streaming over the Internet. Encryption can reduce streaming performance, particularly on less powerful hosts and clients.", + "vdd_tab_title": "Virtual Display (VDD)", + "vdd_not_initialized": "VDD driver is not initialized. Install the Parsec VDD driver first.", + "vdd_driver_status": "Driver Status", + "vdd_connected": "Connected", + "vdd_not_connected": "Not Connected", + "vdd_active_displays": "Active Displays", + "vdd_refresh": "Refresh", + "vdd_presets": "Presets", + "vdd_hz": "Hz", + "vdd_validation_hint": "Width: 320-7680, Height: 240-4320, Hz: 30-240", + "vdd_add_display": "Add Display", + "vdd_remove_all": "Remove All", + "vdd_virtual_displays": "Virtual Displays", + "vdd_col_index": "#", + "vdd_col_device": "Device", + "vdd_col_resolution": "Resolution", + "vdd_col_refresh": "Refresh", + "vdd_col_action": "Action", + "vdd_remove": "Remove", + "vdd_no_displays": "No virtual displays active. Click \"Add Display\" to create one.", + "vdd_driver_installation": "Driver Installation", + "vdd_driver_install_text": "Download and install the Parsec VDD driver from", + "vdd_requires_admin": "(requires admin)", + "vdd_only_windows": "Virtual Display is only supported on Windows.", + "vdd_fetch_error": "Failed to fetch VDD status", + "vdd_add_error": "Failed to add display", + "vdd_remove_error": "Failed to remove display", + "vdd_remove_all_error": "Failed to remove displays", + "vdd_remove_all_success": "All virtual displays removed", + "vdd_input_width": "Width", + "vdd_input_height": "Height", + "vdd_input_hz": "Hz" }, "index": { "description": "Sunshine is a self-hosted game stream host for Moonlight.", diff --git a/src_assets/common/assets/web/public/assets/locale/zh.json b/src_assets/common/assets/web/public/assets/locale/zh.json index 432ff807df5..9a5a70d94b3 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh.json +++ b/src_assets/common/assets/web/public/assets/locale/zh.json @@ -375,6 +375,13 @@ "upnp_desc": "为公网串流自动配置端口转发", "vaapi_strict_rc_buffer": "在AMD GPU上严格强制执行H.264/HEVC帧比特率限制", "vaapi_strict_rc_buffer_desc": "启用此选项可以在场景更改时避免在网络上放置帧,但在移动时可能会降低视频质量。", + "vdd_display_count": "持久化虚拟显示器数量", + "vdd_display_count_desc": "启动时恢复的虚拟显示器数量(自动设置)", + "vdd_enabled": "启用虚拟显示器", + "vdd_enabled_desc": "未检测到物理显示器时自动创建虚拟显示器", + "vdd_height": "虚拟显示器高度", + "vdd_refresh_rate": "虚拟显示器刷新率", + "vdd_width": "虚拟显示器宽度", "virtual_sink": "虚拟音频输出设备", "virtual_sink_desc": "手动指定要使用的虚拟音频设备。如果未设置,则会自动选择设备。我们强烈建议将此字段留空,以便使用自动设备选择!", "virtual_sink_placeholder": "Steam Streaming Speakers", @@ -386,7 +393,39 @@ "wan_encryption_mode": "公网加密模式", "wan_encryption_mode_1": "为支持的客户端启用(默认)", "wan_encryption_mode_2": "强制所有客户端使用", - "wan_encryption_mode_desc": "这决定了在公网串流时是否加密。加密会降低串流性能,尤其是在性能较弱的主机和客户端上。" + "wan_encryption_mode_desc": "这决定了在公网串流时是否加密。加密会降低串流性能,尤其是在性能较弱的主机和客户端上。", + "vdd_tab_title": "虚拟显示器 (VDD)", + "vdd_not_initialized": "VDD 驱动未初始化。请先安装 Parsec VDD 驱动。", + "vdd_driver_status": "驱动状态", + "vdd_connected": "已连接", + "vdd_not_connected": "未连接", + "vdd_active_displays": "活跃显示器", + "vdd_refresh": "刷新", + "vdd_presets": "预设", + "vdd_hz": "刷新率", + "vdd_validation_hint": "宽度: 320-7680, 高度: 240-4320, 刷新率: 30-240", + "vdd_add_display": "添加显示器", + "vdd_remove_all": "移除全部", + "vdd_virtual_displays": "虚拟显示器", + "vdd_col_index": "#", + "vdd_col_device": "设备", + "vdd_col_resolution": "分辨率", + "vdd_col_refresh": "刷新率", + "vdd_col_action": "操作", + "vdd_remove": "移除", + "vdd_no_displays": "没有活跃的虚拟显示器。点击「添加显示器」创建一个。", + "vdd_driver_installation": "驱动安装", + "vdd_driver_install_text": "从这里下载并安装 Parsec VDD 驱动", + "vdd_requires_admin": "(需要管理员权限)", + "vdd_only_windows": "虚拟显示器仅在 Windows 上受支持。", + "vdd_fetch_error": "获取 VDD 状态失败", + "vdd_add_error": "添加显示器失败", + "vdd_remove_error": "移除显示器失败", + "vdd_remove_all_error": "移除全部显示器失败", + "vdd_remove_all_success": "所有虚拟显示器已移除", + "vdd_input_width": "宽度", + "vdd_input_height": "高度", + "vdd_input_hz": "刷新率" }, "index": { "description": "Sunshine 是供 Moonlight 使用的自建游戏串流服务。", diff --git a/src_assets/common/assets/web/public/assets/locale/zh_TW.json b/src_assets/common/assets/web/public/assets/locale/zh_TW.json index 28534675708..c06f6624bd3 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh_TW.json +++ b/src_assets/common/assets/web/public/assets/locale/zh_TW.json @@ -375,6 +375,13 @@ "upnp_desc": "自動設定透過網際網路串流的連接埠轉發", "vaapi_strict_rc_buffer": "在 AMD GPU 上嚴格執行 H.264/HEVC 的幀位元率限制", "vaapi_strict_rc_buffer_desc": "啟用此選項可以避免場景變換時在網路上發生畫面掉幀,但可能會降低畫面移動時的影像品質。", + "vdd_display_count": "持久化虛擬顯示器數量", + "vdd_display_count_desc": "啟動時恢復的虛擬顯示器數量(自動設定)", + "vdd_enabled": "啟用虛擬顯示器", + "vdd_enabled_desc": "未偵測到實體顯示器時自動建立虛擬顯示器", + "vdd_height": "虛擬顯示器高度", + "vdd_refresh_rate": "虛擬顯示器更新率", + "vdd_width": "虛擬顯示器寬度", "virtual_sink": "虛擬音訊輸出", "virtual_sink_desc": "手動指定要使用的虛擬音訊裝置。如果未設定,則會自動選擇裝置。我們強烈建議將此欄位留空,以使用自動裝置選擇!", "virtual_sink_placeholder": "Steam 串流喇叭", @@ -386,7 +393,39 @@ "wan_encryption_mode": "WAN 加密模式", "wan_encryption_mode_1": "對支援的用戶端啟用(預設)", "wan_encryption_mode_2": "所有用戶端都需要", - "wan_encryption_mode_desc": "這會決定在網際網路上串流時,何時會使用加密。加密可能會降低串流效能,尤其是在效能較低的主機和用戶端上。" + "wan_encryption_mode_desc": "這會決定在網際網路上串流時,何時會使用加密。加密可能會降低串流效能,尤其是在效能較低的主機和用戶端上。", + "vdd_tab_title": "虛擬顯示器 (VDD)", + "vdd_not_initialized": "VDD 驅動未初始化。請先安裝 Parsec VDD 驅動。", + "vdd_driver_status": "驅動狀態", + "vdd_connected": "已連線", + "vdd_not_connected": "未連線", + "vdd_active_displays": "活躍顯示器", + "vdd_refresh": "重新整理", + "vdd_presets": "預設", + "vdd_input_width": "寬度", + "vdd_input_height": "高度", + "vdd_input_hz": "更新率", + "vdd_hz": "更新率", + "vdd_validation_hint": "寬度: 320-7680, 高度: 240-4320, 更新率: 30-240", + "vdd_add_display": "新增顯示器", + "vdd_remove_all": "移除全部", + "vdd_virtual_displays": "虛擬顯示器", + "vdd_col_index": "#", + "vdd_col_device": "裝置", + "vdd_col_resolution": "解析度", + "vdd_col_refresh": "更新率", + "vdd_col_action": "操作", + "vdd_remove": "移除", + "vdd_no_displays": "沒有活躍的虛擬顯示器。點選「新增顯示器」建立一個。", + "vdd_driver_installation": "驅動安裝", + "vdd_driver_install_text": "從這裡下載並安裝 Parsec VDD 驅動", + "vdd_requires_admin": "(需要管理員權限)", + "vdd_only_windows": "虛擬顯示器僅在 Windows 上受支援。", + "vdd_fetch_error": "取得 VDD 狀態失敗", + "vdd_add_error": "新增顯示器失敗", + "vdd_remove_error": "移除顯示器失敗", + "vdd_remove_all_error": "移除全部顯示器失敗", + "vdd_remove_all_success": "所有虛擬顯示器已移除" }, "index": { "description": "Sunshine 是 Moonlight 的自架遊戲串流主機。", diff --git a/third-party/parsec-vdd/parsec-vdd.h b/third-party/parsec-vdd/parsec-vdd.h new file mode 100644 index 00000000000..402b8e0cc54 --- /dev/null +++ b/third-party/parsec-vdd/parsec-vdd.h @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2023, Nguyen Duy All rights reserved. + * GitHub repo: https://github.com/nomi-san/parsec-vdd/ + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + */ + +#ifndef __PARSEC_VDD_H +#define __PARSEC_VDD_H + +#include +#include +#include + +#ifdef _MSC_VER +#pragma comment(lib, "cfgmgr32.lib") +#pragma comment(lib, "setupapi.lib") +#endif + +#ifdef __cplusplus +namespace parsec_vdd +{ +#endif + +// Device helper. +////////////////////////////////////////////////// + +typedef enum { + DEVICE_OK = 0, // Ready to use + DEVICE_INACCESSIBLE, // Inaccessible + DEVICE_UNKNOWN, // Unknown status + DEVICE_UNKNOWN_PROBLEM, // Unknown problem + DEVICE_DISABLED, // Device is disabled + DEVICE_DRIVER_ERROR, // Device encountered error + DEVICE_RESTART_REQUIRED, // Must restart PC to use (could ignore but would have issue) + DEVICE_DISABLED_SERVICE, // Service is disabled + DEVICE_NOT_INSTALLED // Driver is not installed +} DeviceStatus; + +/** +* Query the driver status. +* +* @param classGuid The GUID of the class. +* @param deviceId The device/hardware ID of the driver. +* @return DeviceStatus +*/ +static DeviceStatus QueryDeviceStatus(const GUID *classGuid, const char *deviceId) +{ + DeviceStatus status = DEVICE_INACCESSIBLE; + + SP_DEVINFO_DATA devInfoData; + ZeroMemory(&devInfoData, sizeof(SP_DEVINFO_DATA)); + devInfoData.cbSize = sizeof(SP_DEVINFO_DATA); + + HDEVINFO devInfo = SetupDiGetClassDevsA(classGuid, NULL, NULL, DIGCF_PRESENT); + + if (devInfo != INVALID_HANDLE_VALUE) + { + BOOL foundProp = FALSE; + UINT deviceIndex = 0; + + do + { + if (!SetupDiEnumDeviceInfo(devInfo, deviceIndex, &devInfoData)) + break; + + DWORD requiredSize = 0; + SetupDiGetDeviceRegistryPropertyA(devInfo, &devInfoData, + SPDRP_HARDWAREID, NULL, NULL, 0, &requiredSize); + + if (requiredSize > 0) + { + DWORD regDataType = 0; + LPBYTE propBuffer = (LPBYTE)calloc(1, requiredSize); + + if (SetupDiGetDeviceRegistryPropertyA( + devInfo, + &devInfoData, + SPDRP_HARDWAREID, + ®DataType, + propBuffer, + requiredSize, + &requiredSize)) + { + if (regDataType == REG_SZ || regDataType == REG_MULTI_SZ) + { + for (LPCSTR cp = (LPCSTR)propBuffer; ; cp += lstrlenA(cp) + 1) + { + if (!cp || *cp == 0 || cp >= (LPCSTR)(propBuffer + requiredSize)) + { + status = DEVICE_NOT_INSTALLED; + goto except; + } + + if (lstrcmpA(deviceId, cp) == 0) + break; + } + + foundProp = TRUE; + ULONG devStatus, devProblemNum; + + if (CM_Get_DevNode_Status(&devStatus, &devProblemNum, devInfoData.DevInst, 0) != CR_SUCCESS) + { + status = DEVICE_NOT_INSTALLED; + goto except; + } + + if ((devStatus & (DN_DRIVER_LOADED | DN_STARTED)) != 0) + { + status = DEVICE_OK; + } + else if ((devStatus & DN_HAS_PROBLEM) != 0) + { + switch (devProblemNum) + { + case CM_PROB_NEED_RESTART: + status = DEVICE_RESTART_REQUIRED; + break; + case CM_PROB_DISABLED: + case CM_PROB_HARDWARE_DISABLED: + status = DEVICE_DISABLED; + break; + case CM_PROB_DISABLED_SERVICE: + status = DEVICE_DISABLED_SERVICE; + break; + default: + if (devProblemNum == CM_PROB_FAILED_POST_START) + status = DEVICE_DRIVER_ERROR; + else + status = DEVICE_UNKNOWN_PROBLEM; + break; + } + } + else + { + status = DEVICE_UNKNOWN; + } + } + } + + except: + free(propBuffer); + } + + ++deviceIndex; + } while (!foundProp); + + if (!foundProp && GetLastError() != 0) + status = DEVICE_NOT_INSTALLED; + + SetupDiDestroyDeviceInfoList(devInfo); + } + + return status; +} + +/** +* Obtain the device handle. +* Returns NULL or INVALID_HANDLE_VALUE if fails, otherwise a valid handle. +* Should call CloseDeviceHandle to close this handle after use. +* +* @param interfaceGuid The adapter/interface GUID of the target device. +* @return HANDLE +*/ +static HANDLE OpenDeviceHandle(const GUID *interfaceGuid) +{ + HANDLE handle = INVALID_HANDLE_VALUE; + HDEVINFO devInfo = SetupDiGetClassDevsA(interfaceGuid, + NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + + if (devInfo != INVALID_HANDLE_VALUE) + { + SP_DEVICE_INTERFACE_DATA devInterface; + ZeroMemory(&devInterface, sizeof(SP_DEVICE_INTERFACE_DATA)); + devInterface.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); + + for (DWORD i = 0; SetupDiEnumDeviceInterfaces(devInfo, NULL, interfaceGuid, i, &devInterface); ++i) + { + DWORD detailSize = 0; + SetupDiGetDeviceInterfaceDetailA(devInfo, &devInterface, NULL, 0, &detailSize, NULL); + + SP_DEVICE_INTERFACE_DETAIL_DATA_A *detail = (SP_DEVICE_INTERFACE_DETAIL_DATA_A *)calloc(1, detailSize); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_A); + + if (SetupDiGetDeviceInterfaceDetailA(devInfo, &devInterface, detail, detailSize, &detailSize, NULL)) + { + handle = CreateFileA(detail->DevicePath, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED | FILE_FLAG_WRITE_THROUGH, + NULL); + + if (handle != NULL && handle != INVALID_HANDLE_VALUE) { + free(detail); + break; + } + } + + free(detail); + } + + SetupDiDestroyDeviceInfoList(devInfo); + } + + return handle; +} + +/* Release the device handle */ +static void CloseDeviceHandle(HANDLE handle) +{ + if (handle != NULL && handle != INVALID_HANDLE_VALUE) + CloseHandle(handle); +} + +// Parsec VDD core. +////////////////////////////////////////////////// + +// Display name info. +static const char *VDD_DISPLAY_ID = "PSCCDD0"; // You will see it in registry (HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY) +static const char *VDD_DISPLAY_NAME = "ParsecVDA"; // You will see it in the [Advanced display settings] tab. + +// Apdater GUID to obtain the device handle. +// {00b41627-04c4-429e-a26e-0265cf50c8fa} +static const GUID VDD_ADAPTER_GUID = { 0x00b41627, 0x04c4, 0x429e, { 0xa2, 0x6e, 0x02, 0x65, 0xcf, 0x50, 0xc8, 0xfa } }; +static const char *VDD_ADAPTER_NAME = "Parsec Virtual Display Adapter"; + +// Class and hwid to query device status. +// {4d36e968-e325-11ce-bfc1-08002be10318} +static const GUID VDD_CLASS_GUID = { 0x4d36e968, 0xe325, 0x11ce, { 0xbf, 0xc1, 0x08, 0x00, 0x2b, 0xe1, 0x03, 0x18 } }; +static const char *VDD_HARDWARE_ID = "Root\\Parsec\\VDA"; + +// Actually up to 16 devices could be created per adapter +// so just use a half to avoid plugging lag. +static const int VDD_MAX_DISPLAYS = 8; + +// Core IoControl codes, see usage below. +typedef enum { + VDD_IOCTL_ADD = 0x0022e004, // CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800 + 1, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS) + VDD_IOCTL_REMOVE = 0x0022a008, // CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800 + 2, METHOD_BUFFERED, FILE_WRITE_ACCESS) + VDD_IOCTL_UPDATE = 0x0022a00c, // CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800 + 3, METHOD_BUFFERED, FILE_WRITE_ACCESS) + VDD_IOCTL_VERSION = 0x0022e010 // CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800 + 4, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS) +} VddCtlCode; + +// Generic DeviceIoControl for all IoControl codes. +static DWORD VddIoControl(HANDLE vdd, VddCtlCode code, const void *data, size_t size) +{ + if (vdd == NULL || vdd == INVALID_HANDLE_VALUE) + return -1; + + BYTE InBuffer[32]; + ZeroMemory(InBuffer, sizeof(InBuffer)); + + OVERLAPPED Overlapped; + ZeroMemory(&Overlapped, sizeof(OVERLAPPED)); + + DWORD OutBuffer = 0; + DWORD NumberOfBytesTransferred; + + if (data != NULL && size > 0) + memcpy(InBuffer, data, (size < sizeof(InBuffer)) ? size : sizeof(InBuffer)); + + Overlapped.hEvent = CreateEventA(NULL, TRUE, FALSE, NULL); + DeviceIoControl(vdd, (DWORD)code, InBuffer, sizeof(InBuffer), &OutBuffer, sizeof(DWORD), NULL, &Overlapped); + + if (!GetOverlappedResultEx(vdd, &Overlapped, &NumberOfBytesTransferred, 5000, FALSE)) + { + CloseHandle(Overlapped.hEvent); + return -1; + } + + if (Overlapped.hEvent != NULL) + CloseHandle(Overlapped.hEvent); + + return OutBuffer; +} + +/** +* Query VDD minor version. +* +* @param vdd The device handle of VDD. +* @return The number of minor version. +*/ +static int VddVersion(HANDLE vdd) +{ + int minor = VddIoControl(vdd, VDD_IOCTL_VERSION, NULL, 0); + return minor; +} + +/** +* Update/ping to VDD. +* Should call this function in a side thread for each +* less than 100ms to keep all added virtual displays alive. +* +* @param vdd The device handle of VDD. +*/ +static void VddUpdate(HANDLE vdd) +{ + VddIoControl(vdd, VDD_IOCTL_UPDATE, NULL, 0); +} + +/** +* Add/plug a virtual display. +* +* @param vdd The device handle of VDD. +* @return The index of the added display. +*/ +static int VddAddDisplay(HANDLE vdd) +{ + int idx = VddIoControl(vdd, VDD_IOCTL_ADD, NULL, 0); + VddUpdate(vdd); + + return idx; +} + +/** +* Remove/unplug a virtual display. +* +* @param vdd The device handle of VDD. +* @param index The index of the display will be removed. +*/ +static void VddRemoveDisplay(HANDLE vdd, int index) +{ + // 16-bit BE index + UINT16 indexData = ((index & 0xFF) << 8) | ((index >> 8) & 0xFF); + + VddIoControl(vdd, VDD_IOCTL_REMOVE, &indexData, sizeof(indexData)); + VddUpdate(vdd); +} + +#ifdef __cplusplus +} +#endif + +#endif \ No newline at end of file From 1bb448e38414898a57b3ccadf7173321f182736d Mon Sep 17 00:00:00 2001 From: fatebug <1339524041@qq.com> Date: Thu, 14 May 2026 21:54:57 +0800 Subject: [PATCH 02/10] fix(vdd): resolve SonarCloud code analysis issues --- src/confighttp.cpp | 18 +- src/main.cpp | 31 +- src/vdd_control.cpp | 78 +++-- src_assets/common/assets/web/config.html | 6 +- .../web/configs/tabs/VirtualDisplay.vue | 30 +- third-party/parsec-vdd/parsec-vdd.h | 308 +++++++++++++++--- 6 files changed, 358 insertions(+), 113 deletions(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index e043a8e93b8..06c5ff6057b 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1603,13 +1603,11 @@ namespace confighttp { nlohmann::json output_tree; - if (!vdd::is_initialized()) { - if (!vdd::init()) { - output_tree["status"] = false; - output_tree["error"] = "VDD driver not available"; - send_response(response, output_tree); - return; - } + if (!vdd::is_initialized() && !vdd::init()) { + output_tree["status"] = false; + output_tree["error"] = "VDD driver not available"; + send_response(response, output_tree); + return; } try { @@ -1645,9 +1643,9 @@ namespace confighttp { output_tree["index"] = idx; } else { output_tree["success"] = false; - output_tree["error"] = "VDD driver returned error (idx=" + std::to_string(idx) + "). Check Sunshine logs for details."; + output_tree["error"] = std::format("VDD driver returned error (idx={}). Check Sunshine logs for details.", idx); } - } catch (const std::exception &e) { + } catch (const nlohmann::json::exception &e) { output_tree["status"] = false; output_tree["error"] = e.what(); } @@ -1697,7 +1695,7 @@ namespace confighttp { output_tree["status"] = result; output_tree["success"] = result; - } catch (const std::exception &e) { + } catch (const nlohmann::json::exception &e) { output_tree["status"] = false; output_tree["error"] = e.what(); } diff --git a/src/main.cpp b/src/main.cpp index c4dd471e984..761f9c7d9ac 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -121,6 +121,23 @@ void mainThreadLoop(const std::shared_ptr> &shutdown_event) BOOST_LOG(info) << "Main loop has exited"sv; } +#ifdef _WIN32 + static void restore_persisted_virtual_displays() { + int count = config::video.vdd.virtual_display_count; + if (count > 0) { + BOOST_LOG(info) << "Restoring "sv << count << " persisted virtual display(s)"sv; + for (int i = 0; i < count; ++i) { + vdd::add_display( + config::video.vdd.virtual_display_width, + config::video.vdd.virtual_display_height, + config::video.vdd.virtual_display_refresh_rate); + } + } else { + BOOST_LOG(info) << "Physical display detected, VDD ready for manual use"sv; + } + } +#endif + int main(int argc, char *argv[]) { #ifdef __APPLE__ // Bundle assets are referenced relative to the executable @@ -371,19 +388,7 @@ int main(int argc, char *argv[]) { config::video.vdd.virtual_display_height, config::video.vdd.virtual_display_refresh_rate); } else { - // Restore persisted virtual displays - int count = config::video.vdd.virtual_display_count; - if (count > 0) { - BOOST_LOG(info) << "Restoring "sv << count << " persisted virtual display(s)"sv; - for (int i = 0; i < count; ++i) { - vdd::add_display( - config::video.vdd.virtual_display_width, - config::video.vdd.virtual_display_height, - config::video.vdd.virtual_display_refresh_rate); - } - } else { - BOOST_LOG(info) << "Physical display detected, VDD ready for manual use"sv; - } + restore_persisted_virtual_displays(); } } else { BOOST_LOG(warning) << "VDD driver not available - virtual displays disabled"sv; diff --git a/src/vdd_control.cpp b/src/vdd_control.cpp index 20627d334ae..d9278b8d805 100644 --- a/src/vdd_control.cpp +++ b/src/vdd_control.cpp @@ -1,6 +1,13 @@ /** * @file src/vdd_control.cpp - * @brief Definitions for Parsec Virtual Display Driver control. + * @brief Parsec Virtual Display Driver (VDD) control — wraps the parsec-vdd + * kernel driver to create and manage virtual displays on Windows. + * + * The VDD driver requires a periodic keepalive ping (<= 100 ms) or it tears + * down all virtual displays. Adding a display resets every existing VDD + * display to the driver default EDID, so we re-apply stored resolutions. + * After VddAddDisplay, the new device name is found by diffing the set of + * known names because Windows enumeration order does not match creation order. */ #ifdef _WIN32 @@ -52,7 +59,10 @@ namespace vdd { std::atomic g_keepalive_running{false}; // NOSONAR -- runtime state flag std::atomic g_initialized{false}; // NOSONAR -- runtime state flag - // Track VDD driver indices and device names for displays created by this session + // Three parallel vectors: element N in each refers to the same display. + // g_vdd_indices[N] — VDD driver-level index + // g_vdd_device_names[N] — Windows device name (e.g. \\.\DISPLAY4) + // g_vdd_configs[N] — intended resolution / refresh rate std::vector g_vdd_indices; // NOSONAR -- runtime collection std::vector g_vdd_device_names; // NOSONAR -- runtime collection struct VddDisplayConfig { int width; int height; int hz; }; @@ -60,13 +70,14 @@ namespace vdd { /** * @brief Persist the current display count to the config file. + * + * Edits only the vdd_display_count line in-place; preserves user comments, + * blank lines, and option ordering. */ void persist_display_count() { auto content = file_handler::read_file(config::sunshine.config_file.c_str()); auto new_value = std::to_string(g_vdd_indices.size()); - // Update the vdd_display_count line in-place to preserve - // comments, blank lines, and option ordering. std::string line; std::stringstream result; bool found = false; @@ -87,6 +98,7 @@ namespace vdd { line.clear(); } + // Key not in config yet — append it. if (!found) { result << "vdd_display_count = "sv << new_value << '\n'; } @@ -123,11 +135,11 @@ namespace vdd { * Uses multiple detection methods for robustness. */ static bool is_vdd_display(const DISPLAY_DEVICEA &dd) { - // Method 1: Check DeviceID for PSCCDD0 (hardware ID) + // Dual detection: DeviceID (hardware ID) is most reliable; + // DeviceString (adapter name) catches older driver builds. if (dd.DeviceID[0] != '\0' && std::string_view(dd.DeviceID).contains("PSCCDD0")) { return true; } - // Method 2: Check DeviceString for Parsec adapter name if (std::string_view(dd.DeviceString).contains("Parsec")) { return true; } @@ -144,13 +156,14 @@ namespace vdd { return true; } - // Check if driver is installed + // Query driver status — informational; non-OK is logged but not fatal. auto status = QueryDeviceStatus(&VDD_CLASS_GUID, VDD_HARDWARE_ID); - if (status != DEVICE_OK) { + if (status != DeviceStatus::OK) { BOOST_LOG(warning) << "VDD: Driver not ready (status="sv << (int)status << ')' << std::endl; } - // Try to open handle regardless - driver might be usable + // The real gate: open the device handle. Driver might be usable despite + // a non-OK status above (e.g. restart-required is often still functional). HANDLE handle = OpenDeviceHandle(&VDD_ADAPTER_GUID); if (handle == INVALID_HANDLE_VALUE || handle == nullptr) { BOOST_LOG(warning) << "VDD: Failed to open device handle - driver may not be installed"sv << std::endl; @@ -199,22 +212,21 @@ namespace vdd { } DriverStatus get_driver_status() { - using enum DriverStatus; auto status = QueryDeviceStatus(&VDD_CLASS_GUID, VDD_HARDWARE_ID); switch (status) { - case DEVICE_OK: - return OK; - case DEVICE_NOT_INSTALLED: - return NOT_INSTALLED; - case DEVICE_DISABLED: - case DEVICE_DISABLED_SERVICE: - return DISABLED; - case DEVICE_RESTART_REQUIRED: - return RESTART_REQUIRED; - case DEVICE_INACCESSIBLE: - return INACCESSIBLE; + case DeviceStatus::OK: + return DriverStatus::OK; + case DeviceStatus::NOT_INSTALLED: + return DriverStatus::NOT_INSTALLED; + case DeviceStatus::DISABLED: + case DeviceStatus::DISABLED_SERVICE: + return DriverStatus::DISABLED; + case DeviceStatus::RESTART_REQUIRED: + return DriverStatus::RESTART_REQUIRED; + case DeviceStatus::INACCESSIBLE: + return DriverStatus::INACCESSIBLE; default: - return UNKNOWN; + return DriverStatus::UNKNOWN; } } @@ -229,14 +241,15 @@ namespace vdd { return "(unknown)"s; } - // The version IOCTL returns (major << 16) | minor + // Version packed as (major << 16) | minor, e.g. 0x002D0000 for v0.45. int major = (minor >> 16) & 0xFFFF; int minor_ver = minor & 0xFFFF; return std::format("{}.{}", major, minor_ver); } bool need_virtual_display() { - // Count physical (non-VDD) DXGI outputs + // Used by main() to decide whether to auto-create a fallback virtual + // display when no physical monitor is attached (headless system). int physical_count = enumerate_displays(false); BOOST_LOG(info) << "VDD: Physical displays detected: "sv << physical_count << std::endl; return physical_count == 0; @@ -288,7 +301,8 @@ namespace vdd { BOOST_LOG(warning) << "VDD: Requested mode "sv << width << 'x' << height << '@' << hz << "Hz not accepted (error="sv << ret << "), enumerating supported modes"sv << std::endl; - // Fallback: enumerate supported modes and pick the closest match. + // Requested mode may not be in the EDID list. Enumerate supported + // modes and pick the closest match by weighted distance. int best_idx = -1; int best_score = 99999999; int actual_w = 0; @@ -367,7 +381,8 @@ namespace vdd { BOOST_LOG(info) << "VDD: Added display #"sv << idx << " ("sv << width << 'x' << height << '@' << hz << "Hz)"sv << std::endl; - // After VDD adds the display, wait briefly for Windows to detect it + // Windows needs ~500 ms to enumerate the new display and assign a + // device name; without this the name-diff below will miss it. std::this_thread::sleep_for(500ms); // Find the newly added VDD display by diffing against tracked device names. @@ -401,9 +416,8 @@ namespace vdd { // Apply the requested display mode (with fallback enumeration) apply_display_mode(new_device_name, width, height, hz); - // Re-apply stored resolutions to all previously created displays. - // VddAddDisplay + VddUpdate can reset existing VDD displays to the - // driver's default EDID mode, so we must restore them. + // VddAddDisplay resets all existing VDD displays to the driver default + // EDID. Restore each display's stored resolution if it has changed. { std::scoped_lock lock(g_handle_mutex); for (size_t n = 0; n + 1 < g_vdd_device_names.size(); ++n) { @@ -540,7 +554,9 @@ namespace vdd { if (pos != std::string::npos) { try { display_info.identifier = std::stoi(display_info.device_name.substr(pos + 7)); - } catch (const std::exception &) { + } catch (const std::invalid_argument &) { + display_info.identifier = static_cast(n) + 1; + } catch (const std::out_of_range &) { display_info.identifier = static_cast(n) + 1; } } else { @@ -572,6 +588,8 @@ namespace vdd { return; } + // VDD has a ~100 ms watchdog — without a periodic ping it unplugs + // all displays. 50 ms gives comfortable margin. g_keepalive_thread = std::make_unique([]() { platf::set_thread_name("vdd_keepalive"); BOOST_LOG(info) << "VDD: Keepalive thread started"sv << std::endl; diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 7312602504f..c9e7fde91a1 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -370,9 +370,9 @@

{{ $t('config.configuration') }}

Object.keys(tab.options).forEach(key => { const i18nKey = 'config.' + key; const translated = this.$t(i18nKey); - const label = translated !== i18nKey - ? translated - : key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase()); + const label = translated === i18nKey + ? key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase()) + : translated; options.push({ key: key, label: label, diff --git a/src_assets/common/assets/web/configs/tabs/VirtualDisplay.vue b/src_assets/common/assets/web/configs/tabs/VirtualDisplay.vue index fa4366d91a0..dc162c63d6b 100644 --- a/src_assets/common/assets/web/configs/tabs/VirtualDisplay.vue +++ b/src_assets/common/assets/web/configs/tabs/VirtualDisplay.vue @@ -43,9 +43,9 @@ const isValidResolution = computed(() => { watch(() => props.config, (cfg) => { if (cfg) { - customWidth.value = parseInt(cfg.vdd_width) || 1920 - customHeight.value = parseInt(cfg.vdd_height) || 1080 - customHz.value = parseInt(cfg.vdd_refresh_rate) || 144 + customWidth.value = Number.parseInt(cfg.vdd_width) || 1920 + customHeight.value = Number.parseInt(cfg.vdd_height) || 1080 + customHz.value = Number.parseInt(cfg.vdd_refresh_rate) || 144 } }, { immediate: true }) @@ -59,7 +59,7 @@ const fetchStatus = async () => { vddStatus.value = data } } catch (e) { - errorMsg.value = t('config.vdd_fetch_error') + errorMsg.value = t('config.vdd_fetch_error') + ': ' + e.message } finally { isLoading.value = false } @@ -118,7 +118,7 @@ const removeDisplay = async (index) => { errorMsg.value = data.error || t('config.vdd_remove_error') } } catch (e) { - errorMsg.value = t('config.vdd_remove_error') + errorMsg.value = t('config.vdd_remove_error') + ': ' + e.message } } @@ -135,7 +135,7 @@ const removeAllDisplays = async () => { errorMsg.value = data.error || t('config.vdd_remove_all_error') } } catch (e) { - errorMsg.value = t('config.vdd_remove_all_error') + errorMsg.value = t('config.vdd_remove_all_error') + ': ' + e.message } } @@ -176,7 +176,7 @@ onMounted(() => {
- +

{{ $t('config.vdd_driver_status') }}

{{ vddStatus.driver_ok ? $t('config.vdd_connected') : $t('config.vdd_not_connected') }} @@ -185,7 +185,7 @@ onMounted(() => {

- +

{{ $t('config.vdd_active_displays') }}

{{ vddStatus.display_count }}

+ +
+ + +
+

{{ $t('config.vdd_enabled_desc') }}

+

{{ $t('config.vdd_presets') }}

From ae381759e0e3f058f7579f2ed588e8f8ec60a85a Mon Sep 17 00:00:00 2001 From: fatebug <1339524041@qq.com> Date: Fri, 15 May 2026 10:08:27 +0800 Subject: [PATCH 07/10] test: mark vdd_display_configs as internal config option Add vdd_display_configs to the internal options list in config consistency tests so the test doesn't fail on the new auto-managed VDD persistence config key. --- tests/integration/test_config_consistency.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_config_consistency.cpp b/tests/integration/test_config_consistency.cpp index 821c5923df7..05b4d18743f 100644 --- a/tests/integration/test_config_consistency.cpp +++ b/tests/integration/test_config_consistency.cpp @@ -445,7 +445,8 @@ TEST_F(ConfigConsistencyTest, AllConfigOptionsExistInAllFiles) { // Options that are internal/special and shouldn't be in UI/docs const std::set> internalOptions = { - "flags" // Internal config flags, not user-configurable + "flags", // Internal config flags, not user-configurable + "vdd_display_configs" // Automatically managed by VDD persistence }; std::vector missingFromFiles; @@ -627,7 +628,8 @@ TEST_F(ConfigConsistencyTest, TestFrameworkDetectsMissingOptions) { // Options that are internal/special and shouldn't be in UI/docs std::set> internalOptions = { - "flags" // Internal config flags, not user-configurable + "flags", // Internal config flags, not user-configurable + "vdd_display_configs" // Automatically managed by VDD persistence }; std::vector missingFromFiles; From fff4efb437180abb3dbf5438f0fd3803601fce72 Mon Sep 17 00:00:00 2001 From: fatebug <1339524041@qq.com> Date: Fri, 15 May 2026 11:51:31 +0800 Subject: [PATCH 08/10] fix(confighttp): merge config on save instead of overwrite --- src/confighttp.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 06c5ff6057b..4b8023b06ec 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1057,17 +1057,24 @@ namespace confighttp { ss << request->content.rdbuf(); try { // TODO: Input Validation - std::stringstream config_stream; nlohmann::json output_tree; nlohmann::json input_tree = nlohmann::json::parse(ss); + + // Merge into existing config: read current file, update with POST values, + // write back. This preserves keys (like vdd_display_configs) that are + // managed outside the web UI. + auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); for (const auto &[k, v] : input_tree.items()) { if (v.is_null() || (v.is_string() && v.get().empty())) { + vars.erase(k); continue; } + vars[k] = v.is_string() ? v.get() : v.dump(); + } - // v.dump() will dump valid json, which we do not want for strings in the config, right now - // we should migrate the config file to straight JSON and get rid of all this nonsense - config_stream << k << " = " << (v.is_string() ? v.get() : v.dump()) << std::endl; + std::stringstream config_stream; + for (const auto &[k, v] : vars) { + config_stream << k << " = " << v << '\n'; } file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); output_tree["status"] = true; From 4652fdbd2274d654f1b25d9c6b773fa4391ba8ef Mon Sep 17 00:00:00 2001 From: fatebug <1339524041@qq.com> Date: Fri, 15 May 2026 13:09:42 +0800 Subject: [PATCH 09/10] fix: narrow exception type and minor C++17 cleanup - Catch nlohmann::json::parse_error instead of generic std::exception - Use C++17 init-statement in vdd config parsing loop --- src/main.cpp | 2 +- src/vdd_control.cpp | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0049aa958bd..1d8e6bcfe35 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -148,7 +148,7 @@ void mainThreadLoop(const std::shared_ptr> &shutdown_event) } return; } - } catch (const std::exception &e) { + } catch (const nlohmann::json::parse_error &e) { BOOST_LOG(warning) << "Failed to parse vdd_display_configs: "sv << e.what(); } } diff --git a/src/vdd_control.cpp b/src/vdd_control.cpp index 6bc997792dc..1f1b73b4b2c 100644 --- a/src/vdd_control.cpp +++ b/src/vdd_control.cpp @@ -104,8 +104,7 @@ namespace vdd { // Trim leading whitespace for key matching std::string_view sv(line); - auto start = sv.find_first_not_of(" \t"); - if (start != std::string_view::npos) sv = sv.substr(start); + if (auto start = sv.find_first_not_of(" \t"); start != std::string_view::npos) sv = sv.substr(start); if (sv.starts_with("vdd_display_count =") || sv == "vdd_display_count") { result << "vdd_display_count = "sv << count_str << '\n'; From 23fd2a5e8fd70ada67a6f04f883d9b1762358c54 Mon Sep 17 00:00:00 2001 From: fatebug <1339524041@qq.com> Date: Fri, 15 May 2026 15:47:59 +0800 Subject: [PATCH 10/10] refactor(vdd): move vdd_control to src/platform/windows/ Relocate Windows-only virtual display driver control files to the platform directory. Update include paths and CMake references. --- cmake/compile_definitions/common.cmake | 4 ++-- src/confighttp.cpp | 2 +- src/main.cpp | 2 +- src/{ => platform/windows}/vdd_control.cpp | 10 +++++----- src/{ => platform/windows}/vdd_control.h | 2 +- src/system_tray.cpp | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) rename src/{ => platform/windows}/vdd_control.cpp (99%) rename src/{ => platform/windows}/vdd_control.h (98%) diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 19d662c268c..fd25d89fcd3 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -108,8 +108,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/network.cpp" "${CMAKE_SOURCE_DIR}/src/network.h" "${CMAKE_SOURCE_DIR}/src/move_by_copy.h" - "${CMAKE_SOURCE_DIR}/src/vdd_control.h" - "${CMAKE_SOURCE_DIR}/src/vdd_control.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/vdd_control.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/vdd_control.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.h" "${CMAKE_SOURCE_DIR}/src/task_pool.h" diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 4b8023b06ec..4645434cba7 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -40,7 +40,7 @@ #include "network.h" #ifdef _WIN32 - #include "vdd_control.h" + #include "platform/windows/vdd_control.h" #endif #include "nvhttp.h" #include "platform/common.h" diff --git a/src/main.cpp b/src/main.cpp index 1d8e6bcfe35..46cf0333050 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,7 +28,7 @@ #include "process.h" #include "system_tray.h" #include "upnp.h" -#include "vdd_control.h" +#include "platform/windows/vdd_control.h" #include "video.h" extern "C" { diff --git a/src/vdd_control.cpp b/src/platform/windows/vdd_control.cpp similarity index 99% rename from src/vdd_control.cpp rename to src/platform/windows/vdd_control.cpp index 1f1b73b4b2c..97b7ab571ff 100644 --- a/src/vdd_control.cpp +++ b/src/platform/windows/vdd_control.cpp @@ -1,5 +1,5 @@ /** - * @file src/vdd_control.cpp + * @file src/platform/windows/vdd_control.cpp * @brief Parsec Virtual Display Driver (VDD) control — wraps the parsec-vdd * kernel driver to create and manage virtual displays on Windows. * @@ -32,10 +32,10 @@ #include // local includes -#include "config.h" -#include "file_handler.h" -#include "logging.h" -#include "platform/common.h" +#include "src/config.h" +#include "src/file_handler.h" +#include "src/logging.h" +#include "src/platform/common.h" // parsec-vdd core header #include diff --git a/src/vdd_control.h b/src/platform/windows/vdd_control.h similarity index 98% rename from src/vdd_control.h rename to src/platform/windows/vdd_control.h index c51f12c4cce..6edf75b60fa 100644 --- a/src/vdd_control.h +++ b/src/platform/windows/vdd_control.h @@ -1,5 +1,5 @@ /** - * @file src/vdd_control.h + * @file src/platform/windows/vdd_control.h * @brief Declarations for Parsec Virtual Display Driver control. */ #pragma once diff --git a/src/system_tray.cpp b/src/system_tray.cpp index c12e65ae271..349c271aa5f 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -48,7 +48,7 @@ #include "platform/common.h" #include "process.h" #include "src/entry_handler.h" - #include "vdd_control.h" + #include "platform/windows/vdd_control.h" using namespace std::literals;