diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index 1130ed9750..cff6237bda 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -94,8 +94,16 @@ if(LIBDRM_FOUND AND LIBCAP_FOUND) include_directories(SYSTEM ${LIBCAP_INCLUDE_DIRS}) list(APPEND PLATFORM_LIBRARIES ${LIBCAP_LIBRARIES}) list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/src/platform/linux/kmsgrab.cpp") + "${CMAKE_SOURCE_DIR}/src/platform/linux/kmsgrab.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/linux/virtual_display.h" + "${CMAKE_SOURCE_DIR}/src/platform/linux/virtual_display.cpp") list(APPEND SUNSHINE_DEFINITIONS EGL_NO_X11=1) + + # Privileged helper for virtual display debugfs writes (cap_dac_override) + add_executable(apollo-vdisplay-helper + "${CMAKE_SOURCE_DIR}/src/platform/linux/vdisplay_helper.cpp") + set_target_properties(apollo-vdisplay-helper PROPERTIES CXX_STANDARD 17) + install(TARGETS apollo-vdisplay-helper RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") endif() # evdev diff --git a/cmake/packaging/linux.cmake b/cmake/packaging/linux.cmake index e59663bf97..03d9d618b8 100644 --- a/cmake/packaging/linux.cmake +++ b/cmake/packaging/linux.cmake @@ -31,6 +31,9 @@ else() DESTINATION "${SYSTEMD_USER_UNIT_INSTALL_DIR}") install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/linux/misc/60-sunshine.conf" DESTINATION "${SYSTEMD_MODULES_LOAD_DIR}") + install(FILES "${SUNSHINE_SOURCE_ASSETS_DIR}/linux/misc/apollo-sysusers.conf" + DESTINATION "${CMAKE_INSTALL_PREFIX}/lib/sysusers.d" + RENAME "apollo.conf") endif() endif() @@ -40,7 +43,9 @@ set(CPACK_RPM_POST_INSTALL_SCRIPT_FILE "${SUNSHINE_SOURCE_ASSETS_DIR}/linux/misc # Apply setcap for RPM # https://github.com/coreos/rpm-ostree/discussions/5036#discussioncomment-10291071 -set(CPACK_RPM_USER_FILELIST "%caps(cap_sys_admin+p) ${SUNSHINE_EXECUTABLE_PATH}") +set(CPACK_RPM_USER_FILELIST + "%caps(cap_sys_admin+p) ${SUNSHINE_EXECUTABLE_PATH}" + "%attr(0750,root,apollo) %caps(cap_dac_override+ep) %{_bindir}/apollo-vdisplay-helper") # Dependencies set(CPACK_DEB_COMPONENT_INSTALL ON) diff --git a/packaging/linux/Arch/sunshine.install b/packaging/linux/Arch/sunshine.install index d7b87737fc..db9b33c942 100644 --- a/packaging/linux/Arch/sunshine.install +++ b/packaging/linux/Arch/sunshine.install @@ -2,6 +2,17 @@ do_setcap() { setcap cap_sys_admin+p $(readlink -f usr/bin/sunshine) } +do_vdisplay_helper() { + local helper=$(readlink -f usr/bin/apollo-vdisplay-helper 2>/dev/null) + if [ -x "$helper" ]; then + if getent group apollo >/dev/null 2>&1; then + chown root:apollo "$helper" + chmod 0750 "$helper" + fi + setcap cap_dac_override+ep "$helper" + fi +} + do_udev_reload() { udevadm control --reload-rules udevadm trigger --property-match=DEVNAME=/dev/uinput @@ -12,12 +23,14 @@ do_udev_reload() { post_install() { do_setcap + do_vdisplay_helper do_udev_reload modprobe uhid } post_upgrade() { do_setcap + do_vdisplay_helper do_udev_reload modprobe uhid } diff --git a/packaging/linux/fedora/Sunshine.spec b/packaging/linux/fedora/Sunshine.spec index 44cf39ae9e..b5ecb0fa21 100644 --- a/packaging/linux/fedora/Sunshine.spec +++ b/packaging/linux/fedora/Sunshine.spec @@ -212,7 +212,7 @@ cd %{_builddir}/Sunshine/build %make_install %post -# Note: this is copied from the postinst script +# Note: this should be kept in sync with src_assets/linux/misc/postinst # Load uhid (DS5 emulation) echo "Loading uhid kernel module for DS5 emulation." @@ -222,6 +222,30 @@ modprobe uhid if [ ! -x "$(command -v rpm-ostree)" ]; then echo "Not in an rpm-ostree environment, proceeding with post install steps." + # Create apollo system group if it doesn't exist + if ! getent group apollo >/dev/null 2>&1; then + if [ -x "$(command -v groupadd)" ]; then + groupadd --system apollo + echo "Created 'apollo' system group." + fi + fi + + # Add the installing user to the apollo group + if [ -n "$SUDO_USER" ] && [ "$SUDO_USER" != "root" ]; then + target_user="$SUDO_USER" + elif [ -n "$PKEXEC_UID" ]; then + target_user=$(id -nu "$PKEXEC_UID" 2>/dev/null) + else + target_user="" + fi + + if [ -n "$target_user" ]; then + if ! id -nG "$target_user" 2>/dev/null | grep -qw apollo; then + usermod -aG apollo "$target_user" + echo "Added '$target_user' to 'apollo' group. Log out and back in for group to take effect." + fi + fi + # Trigger udev rule reload for /dev/uinput and /dev/uhid path_to_udevadm=$(which udevadm) if [ -x "$path_to_udevadm" ]; then @@ -240,7 +264,7 @@ fi %files # Executables %caps(cap_sys_admin+p) %{_bindir}/sunshine -%caps(cap_sys_admin+p) %{_bindir}/sunshine-* +%attr(0750,root,apollo) %caps(cap_dac_override+ep) %{_bindir}/apollo-vdisplay-helper # Systemd unit file for user services %{_userunitdir}/sunshine.service diff --git a/src/config.cpp b/src/config.cpp index 6892e3029c..f08849bff3 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -516,6 +516,8 @@ namespace config { "1920x1080x60", // fallback_mode false, // isolated Display false, // ignore_encoder_probe_failure + false, // linux_virtual_display_experimental + {}, // vdisplay_connector }; audio_t audio { @@ -1209,6 +1211,8 @@ namespace config { string_f(vars, "fallback_mode", video.fallback_mode); bool_f(vars, "isolated_virtual_display_option", video.isolated_virtual_display_option); bool_f(vars, "ignore_encoder_probe_failure", video.ignore_encoder_probe_failure); + bool_f(vars, "linux_virtual_display_experimental", video.linux_virtual_display_experimental); + string_f(vars, "vdisplay_connector", video.vdisplay_connector); path_f(vars, "pkey", nvhttp.pkey); path_f(vars, "cert", nvhttp.cert); diff --git a/src/config.h b/src/config.h index a6d7c83386..77a3989308 100644 --- a/src/config.h +++ b/src/config.h @@ -149,6 +149,8 @@ namespace config { std::string fallback_mode; bool isolated_virtual_display_option; bool ignore_encoder_probe_failure; + bool linux_virtual_display_experimental; + std::string vdisplay_connector; }; struct audio_t { diff --git a/src/main.cpp b/src/main.cpp index 73209c7288..b4ce89611d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,8 @@ #ifdef _WIN32 #include "platform/windows/misc.h" #include "platform/windows/virtual_display.h" +#elif defined(__linux__) && defined(SUNSHINE_BUILD_DRM) + #include "platform/linux/virtual_display.h" #endif #define PROBE_DISPLAY_UUID "38F72B96-B00C-4F21-8B6C-E1BFF1602B0E" @@ -206,6 +208,12 @@ int main(int argc, char *argv[]) { BOOST_LOG(error) << "Display device session failed to initialize"sv; } +#if defined(__linux__) && defined(SUNSHINE_BUILD_DRM) + if (config::video.linux_virtual_display_experimental) { + linux_vdisplay::recover_crash_state(); + } +#endif + #ifdef _WIN32 // Modify relevant NVIDIA control panel settings if the system has corresponding gpu if (nvprefs_instance.load()) { diff --git a/src/platform/linux/vdisplay_helper.cpp b/src/platform/linux/vdisplay_helper.cpp new file mode 100644 index 0000000000..aa13b91d6b --- /dev/null +++ b/src/platform/linux/vdisplay_helper.cpp @@ -0,0 +1,156 @@ +/** + * @file src/platform/linux/vdisplay_helper.cpp + * @brief Privileged helper for virtual display debugfs/sysfs writes. + * + * Installed with cap_dac_override+ep so the main apollo process + * does not need CAP_DAC_OVERRIDE in its capability set. + */ +#include +#include +#include +#include +#include +#include +#include + +static bool + validate_path(const char *path) { + if (strcmp(path, "/sys/kernel/debug/dri") == 0) { + return true; + } + + static const char *patterns[] = { + "^/sys/kernel/debug/dri/[0-9]+/[A-Za-z]+(-[A-Za-z]+)*-[0-9]+/edid_override$", + "^/sys/kernel/debug/dri/[0-9]+/[A-Za-z]+(-[A-Za-z]+)*-[0-9]+/trigger_hotplug$", + "^/sys/class/drm/card[0-9]+-[A-Za-z]+(-[A-Za-z]+)*-[0-9]+/status$", + }; + + for (auto pattern : patterns) { + regex_t re; + if (regcomp(&re, pattern, REG_EXTENDED | REG_NOSUB) != 0) { + continue; + } + int match = regexec(&re, path, 0, nullptr, 0); + regfree(&re); + if (match == 0) { + return true; + } + } + + return false; +} + +static int + safe_open(const char *path) { + return open(path, O_WRONLY | O_NOFOLLOW); +} + +static int + cmd_write_edid(const char *path) { + uint8_t buf[256]; + ssize_t total = 0; + while (total < static_cast(sizeof(buf))) { + ssize_t n = read(STDIN_FILENO, buf + total, sizeof(buf) - total); + if (n <= 0) { + break; + } + total += n; + } + if (total != 128 && total != 256) { + fprintf(stderr, "Invalid EDID size: %zd\n", total); + return 3; + } + int fd = safe_open(path); + if (fd < 0) { + perror("open"); + return 4; + } + ssize_t written = write(fd, buf, total); + close(fd); + if (written != total) { + perror("write"); + return 5; + } + return 0; +} + +static int + cmd_clear_edid(const char *path) { + int fd = safe_open(path); + if (fd < 0) { + perror("open"); + return 4; + } + ssize_t n = write(fd, "reset", 5); + if (n < 0) { + lseek(fd, 0, SEEK_SET); + write(fd, "", 0); + } + close(fd); + return 0; +} + +static int + cmd_write_string(const char *path, const char *value) { + int fd = safe_open(path); + if (fd < 0) { + perror("open"); + return 4; + } + ssize_t n = write(fd, value, strlen(value)); + close(fd); + if (n < 0) { + perror("write"); + return 5; + } + return 0; +} + +int main(int argc, char *argv[]) { + if (argc < 3) { + fprintf(stderr, "Usage: %s [value]\n", argv[0]); + return 1; + } + + const char *command = argv[1]; + const char *path = argv[2]; + + if (!validate_path(path)) { + fprintf(stderr, "Path rejected: %s\n", path); + return 2; + } + + if (strcmp(command, "write-edid") == 0) { + return cmd_write_edid(path); + } + if (strcmp(command, "clear-edid") == 0) { + return cmd_clear_edid(path); + } + if (strcmp(command, "write-string") == 0) { + if (argc < 4) { + fprintf(stderr, "write-string requires a value argument\n"); + return 1; + } + const char *value = argv[3]; + static const char *allowed_values[] = {"1", "on", "off", "detect"}; + bool valid = false; + for (auto v : allowed_values) { + if (strcmp(value, v) == 0) { + valid = true; + break; + } + } + if (!valid) { + fprintf(stderr, "Value rejected: %s\n", value); + return 2; + } + return cmd_write_string(path, value); + } + if (strcmp(command, "check-path") == 0) { + struct stat st; + return stat(path, &st) == 0 ? 0 : 1; + } + + fprintf(stderr, "Unknown command: %s\n", command); + return 1; +} diff --git a/src/platform/linux/virtual_display.cpp b/src/platform/linux/virtual_display.cpp new file mode 100644 index 0000000000..d70590b6ad --- /dev/null +++ b/src/platform/linux/virtual_display.cpp @@ -0,0 +1,1024 @@ +/** + * @file src/platform/linux/virtual_display.cpp + * @brief [Experimental] Linux virtual display via DRM EDID override. + */ +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include +#include +#include +#include + +// platform includes +#include +#include + +// local includes +#include "misc.h" +#include "src/config.h" +#include "src/logging.h" +#include "src/platform/common.h" +#include "src/utility.h" +#include "virtual_display.h" + +using namespace std::literals; +namespace bp = boost::process::v1; +namespace fs = std::filesystem; + +namespace linux_vdisplay { + + struct cvt_timing_t { + uint32_t pixel_clock_khz; + uint16_t h_active, h_blanking, h_front_porch, h_sync_width; + uint16_t v_active, v_blanking, v_front_porch, v_sync_width; + uint16_t h_total, v_total; + }; + + // CVT spec according to VESA for vsync width + static int + cvt_vsync_width(int width, int height) { + int g = std::gcd(width, height); + int aw = width / g, ah = height / g; + if (aw == 4 && ah == 3) { + return 4; + } + if (aw == 16 && ah == 9) { + return 5; + } + if (aw == 16 && ah == 10) { + return 6; + } + if (aw == 5 && ah == 4) { + return 7; + } + if (aw == 15 && ah == 9) { + return 7; + } + return 10; + } + + static cvt_timing_t + calculate_cvt_rb(int width, int height, int refresh_hz) { + // Fixed constants from VESA CVT Reduced Blanking v1 spec + constexpr int RB_H_BLANK = 160; + constexpr int RB_H_SYNC = 32; + constexpr int RB_H_FRONT_PORCH = 48; + constexpr int RB_V_FRONT_PORCH = 3; + constexpr double RB_MIN_VBLANK_US = 460.0; + + cvt_timing_t t {}; + t.h_active = static_cast(width & ~7); // EDID DTD requires multiple of 8 + t.h_blanking = RB_H_BLANK; + t.h_front_porch = RB_H_FRONT_PORCH; + t.h_sync_width = RB_H_SYNC; + t.h_total = t.h_active + RB_H_BLANK; + + t.v_active = static_cast(height); + t.v_front_porch = RB_V_FRONT_PORCH; + t.v_sync_width = static_cast(cvt_vsync_width(width, height)); + + double h_period_us = (1e6 / refresh_hz - RB_MIN_VBLANK_US) / (height + RB_V_FRONT_PORCH); + int vbi_lines = static_cast(std::ceil(RB_MIN_VBLANK_US / h_period_us)) + 1; + int min_vbi = t.v_sync_width + t.v_front_porch + 1; + if (vbi_lines < min_vbi) { + vbi_lines = min_vbi; + } + + t.v_blanking = static_cast(vbi_lines); + t.v_total = t.v_active + t.v_blanking; + t.pixel_clock_khz = static_cast( + static_cast(t.h_total) * t.v_total * refresh_hz / 1000 + ); + + return t; + } + + // Pack timing into EDID 1.4 Detailed Timing Descriptor (18 bytes) + static void + build_dtd(uint8_t dtd[18], const cvt_timing_t &t, int h_mm, int v_mm) { + uint16_t pclk_10khz = static_cast(t.pixel_clock_khz / 10); + dtd[0] = pclk_10khz & 0xFF; + dtd[1] = (pclk_10khz >> 8) & 0xFF; + dtd[2] = t.h_active & 0xFF; + dtd[3] = t.h_blanking & 0xFF; + dtd[4] = static_cast(((t.h_active >> 8) & 0x0F) << 4 | ((t.h_blanking >> 8) & 0x0F)); + dtd[5] = t.v_active & 0xFF; + dtd[6] = t.v_blanking & 0xFF; + dtd[7] = static_cast(((t.v_active >> 8) & 0x0F) << 4 | ((t.v_blanking >> 8) & 0x0F)); + dtd[8] = t.h_front_porch & 0xFF; + dtd[9] = t.h_sync_width & 0xFF; + dtd[10] = static_cast(((t.v_front_porch & 0x0F) << 4) | (t.v_sync_width & 0x0F)); + dtd[11] = static_cast( + ((t.h_front_porch >> 8) & 0x03) << 6 | + ((t.h_sync_width >> 8) & 0x03) << 4 | + ((t.v_front_porch >> 4) & 0x03) << 2 | + ((t.v_sync_width >> 4) & 0x03) + ); + dtd[12] = h_mm & 0xFF; + dtd[13] = v_mm & 0xFF; + dtd[14] = static_cast(((h_mm >> 8) & 0x0F) << 4 | ((v_mm >> 8) & 0x0F)); + dtd[15] = 0; + dtd[16] = 0; + // Non-interlaced, digital separate, H+ V- (CVT-RB) + dtd[17] = 0x1A; + } + + static uint8_t + edid_checksum(const uint8_t *block, size_t len) { + uint8_t sum = 0; + for (size_t i = 0; i < len - 1; i++) { + sum += block[i]; + } + return static_cast((256 - sum) % 256); + } + + static void + build_monitor_name(uint8_t desc[18], int width, int height) { + desc[0] = desc[1] = desc[2] = 0x00; + desc[3] = 0xFC; // EDID descriptor tag: monitor name + desc[4] = 0x00; + + char name[14]; + int n = snprintf(name, sizeof(name), "APL %dx%d", width, height); + if (n < 0 || n >= 14) { + snprintf(name, sizeof(name), "APL Virtual"); + } + + int i = 0; + for (; i < 13 && name[i]; i++) { + desc[5 + i] = static_cast(name[i]); + } + if (i < 13) { + desc[5 + i] = 0x0A; // EDID requires LF terminator after name + i++; + } + for (; i < 13; i++) { + desc[5 + i] = 0x20; // pad with spaces per EDID spec + } + } + + static void + build_range_limits(uint8_t desc[18], int min_v, int max_v, int max_pclk_mhz, const cvt_timing_t &t) { + desc[0] = desc[1] = desc[2] = 0x00; + desc[3] = 0xFD; // EDID descriptor tag: monitor range limits + desc[4] = 0x00; + desc[5] = static_cast(std::clamp(min_v, 1, 255)); + desc[6] = static_cast(std::clamp(max_v, 1, 255)); + + int h_rate_khz = static_cast(t.pixel_clock_khz / t.h_total); + desc[7] = static_cast(std::clamp(h_rate_khz - 1, 1, 255)); + desc[8] = static_cast(std::clamp(h_rate_khz + 1, 1, 255)); + + desc[9] = static_cast(std::clamp((max_pclk_mhz + 9) / 10, 1, 255)); + desc[10] = 0x01; // default GTF timing type + desc[11] = 0x0A; // LF terminator + for (int i = 12; i < 18; i++) { + desc[i] = 0x20; // space padding + } + } + + // We need to give each client a unique hash ID for the EDID serial. + // This lets compositors actually remember the vdisplay settings per client. + // Uses FNV-1a. + static uint32_t + hash_client_id(const std::string &client_id) { + uint32_t hash = 0x811c9dc5; + for (auto c : client_id) { + hash ^= static_cast(c); + hash *= 0x01000193; + } + return hash; + } + + static std::array + generate_edid(int width, int height, int refresh_hz, const std::string &client_id) { + std::array edid {}; + + auto t = calculate_cvt_rb(width, height, refresh_hz); + + // DTD pixel clock field is 16-bit (units of 10 kHz), clamp refresh if it overflows + uint32_t pclk_10khz = t.pixel_clock_khz / 10; + if (pclk_10khz > 65535) { + int max_refresh = static_cast(65535ULL * 10000 / (static_cast(t.h_total) * t.v_total)); + BOOST_LOG(warning) << "[Experimental] Pixel clock overflow for "sv << width << "x"sv << height + << "@"sv << refresh_hz << "Hz, clamping to "sv << max_refresh << "Hz"sv; + refresh_hz = max_refresh; + t = calculate_cvt_rb(width, height, refresh_hz); + } + + // Physical size in mm since EDID must report it. + int h_mm = static_cast(width * 25.4 / 96.0 + 0.5); + int v_mm = static_cast(height * 25.4 / 96.0 + 0.5); + + // --- Base block (bytes 0-127) --- + // Header + edid[0] = 0x00; + edid[1] = edid[2] = edid[3] = edid[4] = edid[5] = edid[6] = 0xFF; + edid[7] = 0x00; + + // Manufacturer "APL": A=1, P=16, L=12 → ((1<<10)|(16<<5)|12) = 0x060C + edid[8] = 0x06; + edid[9] = 0x0C; + // Product code + edid[10] = 0x01; + edid[11] = 0x00; + // Serial — hash of client ID for per-client display identity + uint32_t serial = client_id.empty() ? 1 : hash_client_id(client_id); + edid[12] = static_cast(serial & 0xFF); + edid[13] = static_cast((serial >> 8) & 0xFF); + edid[14] = static_cast((serial >> 16) & 0xFF); + edid[15] = static_cast((serial >> 24) & 0xFF); + // Week 1, year 2024 (2024-1990=34) + edid[16] = 0x01; + edid[17] = 0x22; + // EDID 1.4 + edid[18] = 0x01; + edid[19] = 0x04; + // Digital, 8-bit, DisplayPort + edid[20] = 0xA5; + // Physical size in cm + edid[21] = static_cast(std::clamp(h_mm / 10, 1, 255)); + edid[22] = static_cast(std::clamp(v_mm / 10, 1, 255)); + // Gamma 2.2 — encoded as (2.2 * 100) - 100 = 120 = 0x78 + edid[23] = 0x78; + // Features bitmap: RGB color, preferred timing is native, continuous frequency support + edid[24] = 0x3B; + + // Standard sRGB chromaticity coordinates (CIE 1931 color primaries) + static constexpr uint8_t SRGB_CHROMA[] = { + 0x0C, + 0xC1, + 0xAF, + 0x4F, + 0x40, + 0xAB, + 0x25, + 0x14, + 0x50, + 0x54 + }; + std::copy(std::begin(SRGB_CHROMA), std::end(SRGB_CHROMA), &edid[25]); + + // Established timings — none + edid[35] = edid[36] = edid[37] = 0x00; + + // Standard timings — 0x0101 is the EDID "unused slot" marker + for (int i = 38; i < 54; i += 2) { + edid[i] = 0x01; + edid[i + 1] = 0x01; + } + + // DTD1: preferred mode (target resolution) + build_dtd(&edid[54], t, h_mm, v_mm); + + // DTD2: 1080p@60 fallback (if primary isn't already 1080p@60) + if (width != 1920 || height != 1080 || refresh_hz != 60) { + auto t_fallback = calculate_cvt_rb(1920, 1080, 60); + int fb_h_mm = static_cast(1920 * 25.4 / 96.0 + 0.5); + int fb_v_mm = static_cast(1080 * 25.4 / 96.0 + 0.5); + build_dtd(&edid[72], t_fallback, fb_h_mm, fb_v_mm); + } else { + // Serial number descriptor as placeholder + edid[72] = edid[73] = edid[74] = 0x00; + edid[75] = 0xFF; + edid[76] = 0x00; + edid[77] = '0'; + edid[78] = '0'; + edid[79] = '0'; + edid[80] = '1'; + edid[81] = 0x0A; + for (int i = 82; i < 90; i++) { + edid[i] = 0x20; + } + } + + // Monitor name + build_monitor_name(&edid[90], width, height); + + // Range limits + int min_v = std::min(refresh_hz - 1, 24); + int max_v = std::max(refresh_hz, 144); + int max_pclk_mhz = static_cast(t.pixel_clock_khz / 1000) + 10; + build_range_limits(&edid[108], min_v, max_v, max_pclk_mhz, t); + + // Extension count + edid[126] = 0x01; + // Checksum + edid[127] = edid_checksum(&edid[0], 128); + + // --- CTA extension block (bytes 128-255) --- + edid[128] = 0x02; // CTA extension tag + edid[129] = 0x03; // CTA revision 3 + + // Data blocks start at byte 132 (offset 4 within extension) + int cta_offset = 4; + + // Video Data Block: tag 0x40 | length + // VIC 16 = 1080p60, VIC 4 = 720p60, VIC 1 = 640x480p60 + edid[128 + cta_offset] = 0x43; // tag 2, length 3 + edid[128 + cta_offset + 1] = 0x90; // VIC 16 with native bit set + edid[128 + cta_offset + 2] = 0x04; // VIC 4 + edid[128 + cta_offset + 3] = 0x01; // VIC 1 + cta_offset += 4; + + // Audio Data Block: 2ch LPCM, 48kHz, 16-bit + edid[128 + cta_offset] = 0x23; // tag 1, length 3 + edid[128 + cta_offset + 1] = 0x09; // LPCM, 2 channels + edid[128 + cta_offset + 2] = 0x07; // 32/44.1/48 kHz + edid[128 + cta_offset + 3] = 0x01; // 16-bit + cta_offset += 4; + + // Speaker Allocation: FL + FR + edid[128 + cta_offset] = 0x83; // tag 4, length 3 + edid[128 + cta_offset + 1] = 0x01; // FL + FR + edid[128 + cta_offset + 2] = 0x00; + edid[128 + cta_offset + 3] = 0x00; + cta_offset += 4; + + // DTD offset in CTA block + edid[130] = static_cast(cta_offset); + // CTA features: underscan supported (0x80) + basic audio (0x40), no YCbCr + edid[131] = 0xC0; + + // DTD in CTA block: same as primary + if (cta_offset + 18 <= 127) { + build_dtd(&edid[128 + cta_offset], t, h_mm, v_mm); + cta_offset += 18; + } + + // Pad remainder with zeros (already zero from array init) + + // CTA checksum + edid[255] = edid_checksum(&edid[128], 128); + + return edid; + } + + struct active_state_t { + connector_info_t connector; + bool edid_written = false; + bool reprobed = false; + }; + + static std::optional active_state; + + static fs::path + state_file_path() { + return platf::appdata() / "vdisplay.state"; + } + + static void + save_state(const connector_info_t &conn) { + std::error_code ec; + std::ofstream f(state_file_path(), std::ios::trunc); + if (f) { + f << conn.debugfs_edid_path << '\n' + << conn.sysfs_status_path << '\n' + << conn.debugfs_hotplug_path << '\n' + << conn.sysfs_name << '\n'; + } + } + + static void + clear_state() { + std::error_code ec; + fs::remove(state_file_path(), ec); + } + + using version_ptr = util::safe_ptr; + using res_ptr = util::safe_ptr; + using conn_ptr = util::safe_ptr; + using enc_ptr = util::safe_ptr; + + static std::string + connector_type_name(std::uint32_t type) { + switch (type) { + case DRM_MODE_CONNECTOR_VGA: + return "VGA"; + case DRM_MODE_CONNECTOR_DVII: + return "DVI-I"; + case DRM_MODE_CONNECTOR_DVID: + return "DVI-D"; + case DRM_MODE_CONNECTOR_DVIA: + return "DVI-A"; + case DRM_MODE_CONNECTOR_Composite: + return "Composite"; + case DRM_MODE_CONNECTOR_SVIDEO: + return "SVIDEO"; + case DRM_MODE_CONNECTOR_LVDS: + return "LVDS"; + case DRM_MODE_CONNECTOR_Component: + return "Component"; + case DRM_MODE_CONNECTOR_9PinDIN: + return "DIN"; + case DRM_MODE_CONNECTOR_DisplayPort: + return "DP"; + case DRM_MODE_CONNECTOR_HDMIA: + return "HDMI-A"; + case DRM_MODE_CONNECTOR_HDMIB: + return "HDMI-B"; + case DRM_MODE_CONNECTOR_TV: + return "TV"; + case DRM_MODE_CONNECTOR_eDP: + return "eDP"; + case DRM_MODE_CONNECTOR_VIRTUAL: + return "Virtual"; + case DRM_MODE_CONNECTOR_DSI: + return "DSI"; + case DRM_MODE_CONNECTOR_DPI: + return "DPI"; + case DRM_MODE_CONNECTOR_WRITEBACK: + return "Writeback"; + case DRM_MODE_CONNECTOR_SPI: + return "SPI"; +#ifdef DRM_MODE_CONNECTOR_USB + case DRM_MODE_CONNECTOR_USB: + return "USB"; +#endif + default: + return "Unknown" + std::to_string(type); + } + } + + static bool + card_is_nvidia(int fd) { + version_ptr ver {drmGetVersion(fd)}; + return ver && ver->name && strncmp(ver->name, "nvidia-drm", 10) == 0; + } + + // Arbitrary but deterministic ordering for auto-selection when multiple connectors are available + static int + connector_type_priority(std::uint32_t type) { + switch (type) { + case DRM_MODE_CONNECTOR_HDMIA: + return 0; + case DRM_MODE_CONNECTOR_DisplayPort: + return 1; + case DRM_MODE_CONNECTOR_HDMIB: + return 2; + case DRM_MODE_CONNECTOR_DVID: + return 3; + case DRM_MODE_CONNECTOR_DVII: + return 4; + case DRM_MODE_CONNECTOR_VGA: + return 5; + default: + return 99; + } + } + + static std::vector + find_disconnected_connectors() { + std::vector result; + + if (!fs::exists("/dev/dri"sv)) { + return result; + } + + for (auto &entry : fs::directory_iterator {"/dev/dri"sv}) { + auto filename = entry.path().filename().string(); + if (filename.substr(0, 4) != "card"sv) { + continue; + } + + int card_index = -1; + try { + card_index = std::stoi(filename.substr(4)); + } catch (...) { + continue; + } + + int fd = open(entry.path().c_str(), O_RDWR); + if (fd < 0) { + continue; + } + + auto close_fd = util::fail_guard([fd]() { + close(fd); + }); + + // Nvidia not tested but may have issues with EDID override + NVKMS, so log a warning if detected + if (card_is_nvidia(fd)) { + BOOST_LOG(warning) << "[Experimental] NVIDIA card detected ("sv << filename << ") — EDID override may cause issues"sv; + } + + res_ptr resources {drmModeGetResources(fd)}; + if (!resources) { + continue; + } + + // Track per-type index to match kernel sysfs naming + std::map type_count; + + for (int i = 0; i < resources->count_connectors; i++) { + conn_ptr conn {drmModeGetConnector(fd, resources->connectors[i])}; + if (!conn) { + continue; + } + + // Increment before the connected check — kernel numbers ALL connectors of a type, not just disconnected ones + auto index = ++type_count[conn->connector_type]; + + if (conn->connection == DRM_MODE_CONNECTED) { + continue; + } + + auto type_name = connector_type_name(conn->connector_type); + auto connector_name = type_name + "-" + std::to_string(index); + auto sysfs_name = filename + "-" + connector_name; + + auto debugfs_dir = "/sys/kernel/debug/dri/"s + std::to_string(card_index) + "/" + connector_name; + auto debugfs_path = debugfs_dir + "/edid_override"; + auto hotplug_path = debugfs_dir + "/trigger_hotplug"; + auto status_path = "/sys/class/drm/"s + sysfs_name + "/status"; + + std::error_code ec; + if (!fs::exists(status_path, ec) || ec) { + BOOST_LOG(debug) << "[Experimental] sysfs path missing: "sv << status_path; + continue; + } + + result.push_back(connector_info_t { + entry.path().string(), + card_index, + conn->connector_id, + conn->connector_type, + index, + sysfs_name, + debugfs_path, + status_path, + hotplug_path, + }); + + BOOST_LOG(debug) << "[Experimental] Found disconnected connector: "sv << sysfs_name; + } + } + + return result; + } + + static connector_info_t + select_best(const std::vector &candidates) { + auto best = candidates.begin(); + for (auto it = candidates.begin(); it != candidates.end(); ++it) { + if (connector_type_priority(it->connector_type) < connector_type_priority(best->connector_type)) { + best = it; + } + } + return *best; + } + + // Prefer helper binary next to our own executable, fall back to PATH lookup + static boost::filesystem::path + find_helper() { + std::error_code ec; + auto self = fs::read_symlink("/proc/self/exe", ec); + if (!ec) { + auto sibling = self.parent_path() / "apollo-vdisplay-helper"; + if (fs::exists(sibling, ec)) { + return boost::filesystem::path(sibling.string()); + } + } + return bp::search_path("apollo-vdisplay-helper"); + } + + // Instead of doing debugfs edits here which would require cap_dac_override caps + // for the entire process we offload this to a helper bin. + // + // See vdisplay_helper.cpp + static bool + run_helper(const std::vector &args, const uint8_t *stdin_data = nullptr, size_t stdin_len = 0) { + auto helper = find_helper(); + if (helper.empty()) { + BOOST_LOG(error) << "[Experimental] apollo-vdisplay-helper not found"sv; + return false; + } + + try { + if (stdin_data && stdin_len > 0) { + bp::pipe stdin_pipe; + bp::child proc(helper, bp::args(args), bp::std_in bp::null, bp::std_err > bp::null); + stdin_pipe.write(reinterpret_cast(stdin_data), static_cast(stdin_len)); + stdin_pipe.close(); + proc.wait(); + return proc.exit_code() == 0; + } + + bp::child proc(helper, bp::args(args), bp::std_in bp::null, bp::std_err > bp::null); + proc.wait(); + return proc.exit_code() == 0; + } catch (const std::exception &e) { + BOOST_LOG(error) << "[Experimental] Helper exec failed: "sv << e.what(); + return false; + } + } + + static bool + helper_check_path(const std::string &path) { + return run_helper({"check-path", path}); + } + + static bool + write_edid(const connector_info_t &conn, const uint8_t *edid_data, size_t edid_len) { + if (!run_helper({"write-edid", conn.debugfs_edid_path}, edid_data, edid_len)) { + BOOST_LOG(error) << "[Experimental] Helper failed to write EDID to "sv << conn.debugfs_edid_path; + return false; + } + BOOST_LOG(info) << "[Experimental] EDID written to "sv << conn.debugfs_edid_path; + return true; + } + + static bool + clear_edid(const connector_info_t &conn) { + if (!run_helper({"clear-edid", conn.debugfs_edid_path})) { + BOOST_LOG(warning) << "[Experimental] Helper failed to clear EDID on "sv << conn.sysfs_name; + return false; + } + BOOST_LOG(info) << "[Experimental] EDID override cleared on "sv << conn.sysfs_name; + return true; + } + + static bool + force_status(const connector_info_t &conn, const char *value) { + if (!run_helper({"write-string", conn.sysfs_status_path, value})) { + BOOST_LOG(error) << "[Experimental] Helper failed to set status on "sv << conn.sysfs_name; + return false; + } + BOOST_LOG(info) << "[Experimental] Connector status set to '"sv << value << "' on "sv << conn.sysfs_name; + return true; + } + + static bool + trigger_hotplug(const connector_info_t &conn) { + if (conn.debugfs_hotplug_path.empty()) { + return false; + } + if (!run_helper({"write-string", conn.debugfs_hotplug_path, "1"})) { + BOOST_LOG(warning) << "[Experimental] Helper failed to trigger hotplug on "sv << conn.sysfs_name; + return false; + } + BOOST_LOG(info) << "[Experimental] Hotplug triggered on "sv << conn.sysfs_name; + return true; + } + + // Poll until the compositor assigns a CRTC to the connector (meaning it recognized the new display) + static bool + wait_for_connector_active(const connector_info_t &conn, int timeout_ms) { + int fd = open(conn.card_path.c_str(), O_RDWR); + if (fd < 0) { + return false; + } + auto close_fd = util::fail_guard([fd]() { + close(fd); + }); + + constexpr int poll_interval_ms = 200; + int elapsed = 0; + + while (elapsed < timeout_ms) { + std::this_thread::sleep_for(std::chrono::milliseconds(poll_interval_ms)); + elapsed += poll_interval_ms; + + conn_ptr drm_conn {drmModeGetConnector(fd, conn.connector_id)}; + if (!drm_conn) { + continue; + } + + if (drm_conn->connection != DRM_MODE_CONNECTED) { + BOOST_LOG(debug) << "[Experimental] Waiting for connector... ("sv << elapsed << "ms)"sv; + continue; + } + + // Connected — check if a CRTC is assigned (compositor picked it up) + if (drm_conn->encoder_id) { + enc_ptr enc {drmModeGetEncoder(fd, drm_conn->encoder_id)}; + if (enc && enc->crtc_id) { + BOOST_LOG(info) << "[Experimental] Connector active with CRTC after "sv << elapsed << "ms"sv; + return true; + } + } + + // Connected but no CRTC yet — compositor may still be processing + BOOST_LOG(debug) << "[Experimental] Connector connected, waiting for CRTC assignment... ("sv << elapsed << "ms)"sv; + } + + BOOST_LOG(warning) << "[Experimental] Timed out waiting for compositor to pick up virtual display ("sv << timeout_ms << "ms)"sv; + return false; + } + + // X11 may needs an explicit xrandr call to activate the new output; Wayland compositors do it automatically (untested) + static void + configure_xrandr(const connector_info_t &conn) { + auto type_name = connector_type_name(conn.connector_type); + auto output_name = type_name + "-" + std::to_string(conn.connector_index); + + auto cmd = "xrandr --output "s + output_name + " --auto 2>&1"; + BOOST_LOG(info) << "[Experimental] Configuring X11 output: "sv << cmd; + + auto ret = system(cmd.c_str()); + if (ret != 0) { + BOOST_LOG(warning) << "[Experimental] xrandr returned "sv << ret; + } + } + + // Walk DRM planes the same way kmsgrab does so the index we return matches what it captures + static int + find_kms_index(const connector_info_t &conn) { + int fd = open(conn.card_path.c_str(), O_RDWR); + if (fd < 0) { + return -1; + } + auto close_fd = util::fail_guard([fd]() { + close(fd); + }); + + // Find which CRTC is assigned to our connector + res_ptr resources {drmModeGetResources(fd)}; + if (!resources) { + return -1; + } + + std::uint32_t target_crtc = 0; + for (int i = 0; i < resources->count_connectors; i++) { + conn_ptr c {drmModeGetConnector(fd, resources->connectors[i])}; + if (!c || c->connector_id != conn.connector_id) { + continue; + } + if (c->encoder_id) { + enc_ptr enc {drmModeGetEncoder(fd, c->encoder_id)}; + if (enc) { + target_crtc = enc->crtc_id; + } + } + break; + } + + if (!target_crtc) { + return -1; + } + + // Iterate planes same way kmsgrab does to find the index + drmSetClientCap(fd, DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1); + auto plane_res = drmModeGetPlaneResources(fd); + if (!plane_res) { + return -1; + } + + int monitor = 0; + for (std::uint32_t i = 0; i < plane_res->count_planes; i++) { + auto plane = drmModeGetPlane(fd, plane_res->planes[i]); + if (!plane) { + continue; + } + + bool has_fb = plane->fb_id != 0; + auto plane_crtc = plane->crtc_id; + drmModeFreePlane(plane); + + if (!has_fb) { + continue; + } + + // Skip cursor planes + auto props = drmModeObjectGetProperties(fd, plane_res->planes[i], DRM_MODE_OBJECT_PLANE); + if (props) { + bool is_cursor = false; + for (std::uint32_t j = 0; j < props->count_props; j++) { + auto prop = drmModeGetProperty(fd, props->props[j]); + if (prop) { + if (std::string_view(prop->name) == "type"sv && props->prop_values[j] == DRM_PLANE_TYPE_CURSOR) { + is_cursor = true; + } + drmModeFreeProperty(prop); + } + if (is_cursor) { + break; + } + } + drmModeFreeObjectProperties(props); + if (is_cursor) { + continue; + } + } + + if (plane_crtc == target_crtc) { + drmModeFreePlaneResources(plane_res); + BOOST_LOG(info) << "[Experimental] Virtual display is at KMS monitor index "sv << monitor; + return monitor; + } + ++monitor; + } + + drmModeFreePlaneResources(plane_res); + return -1; + } + + bool + is_supported() { + std::error_code ec; + + if (!helper_check_path("/sys/kernel/debug/dri")) { + BOOST_LOG(debug) << "[Experimental] debugfs DRI not available"sv; + return false; + } + + if (!fs::exists("/dev/dri"sv, ec) || ec) { + return false; + } + + for (auto &entry : fs::directory_iterator {"/dev/dri"sv, ec}) { + auto filename = entry.path().filename().string(); + if (filename.substr(0, 4) != "card"sv) { + continue; + } + + int fd = open(entry.path().c_str(), O_RDWR); + if (fd < 0) { + continue; + } + + close(fd); + return true; + } + + return false; + } + + void + recover_crash_state() { + auto path = state_file_path(); + std::error_code ec; + if (!fs::exists(path, ec) || ec) { + return; + } + + BOOST_LOG(warning) << "[Experimental] Found stale vdisplay state file — recovering from previous crash"sv; + + std::ifstream f(path); + std::string edid_path, status_path, hotplug_path, sysfs_name; + if (!std::getline(f, edid_path) || !std::getline(f, status_path) || !std::getline(f, hotplug_path) || !std::getline(f, sysfs_name)) { + BOOST_LOG(warning) << "[Experimental] Corrupt state file, removing"sv; + clear_state(); + return; + } + + connector_info_t conn {}; + conn.debugfs_edid_path = edid_path; + conn.sysfs_status_path = status_path; + conn.debugfs_hotplug_path = hotplug_path; + conn.sysfs_name = sysfs_name; + + clear_edid(conn); + force_status(conn, "detect"); + trigger_hotplug(conn); + + clear_state(); + BOOST_LOG(info) << "[Experimental] Crash recovery complete — cleared stale EDID on "sv << sysfs_name; + } + + static status_e + create_impl(std::string &out_display_name, int width, int height, int fps_millihertz, const std::string &client_id) { + if (!is_supported()) { + BOOST_LOG(info) << "[Experimental] Virtual display not supported on this system"sv; + return status_e::NOT_SUPPORTED; + } + + if (active_state) { + remove(); + } + + auto candidates = find_disconnected_connectors(); + if (candidates.empty()) { + BOOST_LOG(warning) << "[Experimental] No disconnected GPU connectors found"sv; + return status_e::NO_CONNECTOR; + } + + connector_info_t conn; + const auto &preferred = config::video.vdisplay_connector; + if (!preferred.empty()) { + auto it = std::find_if(candidates.begin(), candidates.end(), [&](const connector_info_t &c) { + return c.sysfs_name == preferred; + }); + if (it != candidates.end()) { + conn = *it; + BOOST_LOG(info) << "[Experimental] Using configured connector: "sv << conn.sysfs_name; + } else { + BOOST_LOG(warning) << "[Experimental] Configured connector '"sv << preferred << "' not available, falling back to auto-select"sv; + conn = select_best(candidates); + } + } else { + conn = select_best(candidates); + } + BOOST_LOG(info) << "[Experimental] Selected connector: "sv << conn.sysfs_name; + + active_state = active_state_t {conn, false, false}; + + if (width <= 0 || height <= 0 || fps_millihertz <= 0) { + BOOST_LOG(error) << "[Experimental] Missing resolution/refresh parameters for virtual display"sv; + active_state.reset(); + return status_e::EDID_WRITE_FAILED; + } + + int refresh_hz = std::clamp((fps_millihertz + 500) / 1000, 24, 360); + auto edid = generate_edid(width, height, refresh_hz, client_id); + + if (!write_edid(conn, edid.data(), edid.size())) { + active_state.reset(); + return status_e::EDID_WRITE_FAILED; + } + active_state->edid_written = true; + + BOOST_LOG(info) << "[Experimental] Generated EDID for "sv << width << "x"sv << height << "@"sv << refresh_hz << "Hz"sv + << (client_id.empty() ? ""sv : " (client: "sv) << client_id << (client_id.empty() ? ""sv : ")"sv); + + if (!force_status(conn, "on")) { + clear_edid(conn); + active_state.reset(); + return status_e::REPROBE_FAILED; + } + active_state->reprobed = true; + + trigger_hotplug(conn); + + if (!wait_for_connector_active(conn, 5000)) { + BOOST_LOG(warning) << "[Experimental] Proceeding despite compositor timeout"sv; + } + + if (window_system == window_system_e::X11) { + configure_xrandr(conn); + } + + save_state(conn); + out_display_name = conn.sysfs_name; + return status_e::OK; + } + + status_e + create(std::string &out_display_name, int width, int height, int fps_millihertz, const std::string &client_id) { + try { + return create_impl(out_display_name, width, height, fps_millihertz, client_id); + } catch (const std::exception &e) { + BOOST_LOG(error) << "[Experimental] Virtual display creation threw: "sv << e.what(); + active_state.reset(); + return status_e::NOT_SUPPORTED; + } + } + + status_e + remove() { + if (!active_state) { + return status_e::OK; + } + + auto conn = active_state->connector; + + if (active_state->edid_written) { + clear_edid(conn); + } + + force_status(conn, "detect"); + trigger_hotplug(conn); + std::this_thread::sleep_for(500ms); + + if (window_system == window_system_e::X11) { + auto type_name = connector_type_name(conn.connector_type); + auto output_name = type_name + "-" + std::to_string(conn.connector_index); + auto cmd = "xrandr --output "s + output_name + " --off 2>&1"; + if (system(cmd.c_str()) != 0) { + BOOST_LOG(debug) << "[Experimental] xrandr --off returned non-zero"sv; + } + } + + active_state.reset(); + clear_state(); + BOOST_LOG(info) << "[Experimental] Virtual display removed"sv; + return status_e::OK; + } + + bool + is_active() { + return active_state.has_value(); + } + + int get_kms_index() { + if (!active_state) { + return -1; + } + return find_kms_index(active_state->connector); + } + +} // namespace linux_vdisplay diff --git a/src/platform/linux/virtual_display.h b/src/platform/linux/virtual_display.h new file mode 100644 index 0000000000..4d9128d9cd --- /dev/null +++ b/src/platform/linux/virtual_display.h @@ -0,0 +1,41 @@ +/** + * @file src/platform/linux/virtual_display.h + * @brief [Experimental] Linux virtual display via DRM EDID override. + */ +#pragma once + +// standard includes +#include +#include + +namespace linux_vdisplay { + + enum class status_e { + OK = 0, + NOT_SUPPORTED = -1, + NO_CONNECTOR = -2, + EDID_WRITE_FAILED = -3, + REPROBE_FAILED = -4, + COMPOSITOR_TIMEOUT = -5, + }; + + struct connector_info_t { + std::string card_path; + int card_index; + std::uint32_t connector_id; + std::uint32_t connector_type; + std::uint32_t connector_index; + std::string sysfs_name; + std::string debugfs_edid_path; + std::string sysfs_status_path; + std::string debugfs_hotplug_path; + }; + + bool is_supported(); + void recover_crash_state(); + status_e create(std::string &out_display_name, int width, int height, int fps_millihertz, const std::string &client_id); + status_e remove(); + bool is_active(); + int get_kms_index(); + +} // namespace linux_vdisplay diff --git a/src/process.cpp b/src/process.cpp index d7d4af4ba5..d14b1dbc81 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -28,14 +28,14 @@ #include "crypto.h" #include "display_device.h" #include "file_handler.h" +#include "httpcommon.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" +#include "video.h" #ifdef _WIN32 // from_utf8() string conversion function @@ -342,6 +342,31 @@ namespace proc { display_device::reset_persistence(); } +#elif defined(__linux__) && defined(SUNSHINE_BUILD_DRM) + + if ( + config::video.linux_virtual_display_experimental && + (config::video.headless_mode || launch_session->virtual_display || _app.virtual_display) + ) { + std::string vdisplay_name; + auto status = linux_vdisplay::create(vdisplay_name, render_width, render_height, launch_session->fps, launch_session->unique_id); + if (status == linux_vdisplay::status_e::OK) { + BOOST_LOG(info) << "[Experimental] Linux virtual display created: "sv << vdisplay_name; + this->virtual_display = true; + + auto kms_idx = linux_vdisplay::get_kms_index(); + if (kms_idx >= 0) { + this->original_output_name = config::video.output_name; + config::video.output_name = std::to_string(kms_idx); + BOOST_LOG(info) << "[Experimental] Capturing virtual display at KMS index "sv << kms_idx; + } + } else { + BOOST_LOG(warning) << "[Experimental] Linux virtual display failed (status "sv << static_cast(status) << ')'; + } + } + + display_device::configure_display(config::video, *launch_session); + #else display_device::configure_display(config::video, *launch_session); @@ -775,6 +800,21 @@ namespace proc { } else { display_device::revert_configuration(); } +#elif defined(__linux__) && defined(SUNSHINE_BUILD_DRM) + if (this->virtual_display && linux_vdisplay::is_active()) { + linux_vdisplay::remove(); + BOOST_LOG(info) << "[Experimental] Linux virtual display removed"sv; + if (!this->original_output_name.empty()) { + config::video.output_name = this->original_output_name; + BOOST_LOG(info) << "[Experimental] Restored output_name to "sv << this->original_output_name; + } + } + if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { + if (this->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(); @@ -1438,7 +1478,8 @@ namespace proc { 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 { + } + else { // Fallback to include index on collision ctx.id = std::get<1>(possible_ids); } @@ -1471,8 +1512,7 @@ namespace proc { 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 { + } else { // Fallback to include index on collision ctx.id = std::get<1>(possible_ids); } diff --git a/src/process.h b/src/process.h index 2f52d7ff18..25cf8146b3 100644 --- a/src/process.h +++ b/src/process.h @@ -18,8 +18,8 @@ // lib includes #include -#include #include +#include #include #include #include @@ -32,6 +32,8 @@ #ifdef _WIN32 #include "platform/windows/virtual_display.h" +#elif defined(__linux__) && defined(SUNSHINE_BUILD_DRM) + #include "platform/linux/virtual_display.h" #endif #define VIRTUAL_DISPLAY_UUID "8902CB19-674A-403D-A587-41B092E900BA" @@ -109,6 +111,7 @@ namespace proc { std::string mode_changed_display; bool initial_hdr = false; bool virtual_display = false; + std::string original_output_name; bool allow_client_commands = false; proc_t( diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index 41eeab11ad..342668e9b9 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -176,6 +176,26 @@ const validateFallbackMode = (event) => {
Please ensure SudoVDA driver is installed to the latest version and enabled properly.
+ + + + + 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 6a2dfef819..3fefe45817 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -305,6 +305,11 @@ "ignore_encoder_probe_failure": "Ignore Encoder Probe Failure", "ignore_encoder_probe_failure_desc": "Allow streaming to continue even if probing for encoders fails. This may result in streaming failure if no encoder is available.", "install_steam_audio_drivers": "Install Steam Audio Drivers", + "linux_virtual_display_experimental": "Virtual Display (Experimental)", + "linux_virtual_display_experimental_desc": "Enable experimental virtual display support using DRM EDID override. Requires a GPU with an available disconnected connector. NVIDIA GPUs may have limited compatibility. When enabled, streaming sessions can use a virtual display instead of an existing monitor.", + "vdisplay_connector": "Virtual Display Connector", + "vdisplay_connector_desc": "Specify which GPU connector to use for the virtual display (e.g. card0-HDMI-A-2). Leave empty to auto-select the first available disconnected connector.", + "vdisplay_connector_placeholder": "Auto (first available)", "install_steam_audio_drivers_desc": "If Steam is installed, this will automatically install the Steam Streaming Speakers driver to support 5.1/7.1 surround sound and muting host audio.", "isolated_virtual_display_option": "Move the Virtual Display to the bottom right-most corner of the display layout", "isolated_virtual_display_option_desc": "This makes the display isolated from all other display and contains mouse movements to the virtual screen. This reorganizes the displays such that the all other displays are to the left of the virtual display.", diff --git a/src_assets/linux/misc/apollo-sysusers.conf b/src_assets/linux/misc/apollo-sysusers.conf new file mode 100644 index 0000000000..b897427b45 --- /dev/null +++ b/src_assets/linux/misc/apollo-sysusers.conf @@ -0,0 +1 @@ +g apollo - - diff --git a/src_assets/linux/misc/postinst b/src_assets/linux/misc/postinst index 4783c2ec20..e5d8e86383 100644 --- a/src_assets/linux/misc/postinst +++ b/src_assets/linux/misc/postinst @@ -8,14 +8,49 @@ modprobe uhid if [ ! -x "$(command -v rpm-ostree)" ]; then echo "Not in an rpm-ostree environment, proceeding with post install steps." + # Create apollo system group if it doesn't exist + if ! getent group apollo >/dev/null 2>&1; then + if [ -x "$(command -v groupadd)" ]; then + groupadd --system apollo + echo "Created 'apollo' system group." + fi + fi + + # Add the installing user to the apollo group + if [ -n "$SUDO_USER" ] && [ "$SUDO_USER" != "root" ]; then + target_user="$SUDO_USER" + elif [ -n "$PKEXEC_UID" ]; then + target_user=$(id -nu "$PKEXEC_UID" 2>/dev/null) + else + target_user="" + fi + + if [ -n "$target_user" ]; then + if ! id -nG "$target_user" 2>/dev/null | grep -qw apollo; then + usermod -aG apollo "$target_user" + echo "Added '$target_user' to 'apollo' group. Log out and back in for group to take effect." + fi + fi + # Ensure Sunshine can grab images from KMS path_to_setcap=$(which setcap) path_to_sunshine=$(readlink -f "$(which sunshine)") + path_to_vdisplay_helper=$(readlink -f "$(which apollo-vdisplay-helper)" 2>/dev/null) if [ -x "$path_to_setcap" ] ; then echo "Setting CAP_SYS_ADMIN capability on Sunshine binary." echo "$path_to_setcap cap_sys_admin+p $path_to_sunshine" $path_to_setcap cap_sys_admin+p $path_to_sunshine echo "CAP_SYS_ADMIN capability set on Sunshine binary." + if [ -n "$path_to_vdisplay_helper" ] ; then + if getent group apollo >/dev/null 2>&1; then + chown root:apollo "$path_to_vdisplay_helper" + chmod 0750 "$path_to_vdisplay_helper" + echo "Set apollo-vdisplay-helper ownership to root:apollo (0750)." + fi + echo "Setting CAP_DAC_OVERRIDE capability on virtual display helper." + $path_to_setcap cap_dac_override+ep $path_to_vdisplay_helper + echo "CAP_DAC_OVERRIDE capability set on virtual display helper." + fi else echo "error: setcap not found or not executable." fi