diff --git a/.github/workflows/ci-freebsd.yml b/.github/workflows/ci-freebsd.yml index 7cd1f28b3..2c6eaa19a 100644 --- a/.github/workflows/ci-freebsd.yml +++ b/.github/workflows/ci-freebsd.yml @@ -173,6 +173,7 @@ jobs: -DSUNSHINE_EXECUTABLE_PATH=/usr/local/bin/sunshine \ -DSUNSHINE_ENABLE_CUDA=OFF \ -DSUNSHINE_ENABLE_DRM=OFF \ + -DSUNSHINE_ENABLE_KWIN=ON \ -DSUNSHINE_ENABLE_PORTAL=ON \ -DSUNSHINE_ENABLE_WAYLAND=ON \ -DSUNSHINE_ENABLE_X11=ON \ diff --git a/.gitmodules b/.gitmodules index 33a1a4ed1..873ab27b4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -45,6 +45,10 @@ path = third-party/nvapi url = https://github.com/NVIDIA/nvapi.git branch = main +[submodule "third-party/plasma-wayland-protocols"] + path = third-party/plasma-wayland-protocols + url = https://github.com/KDE/plasma-wayland-protocols.git + branch = master [submodule "third-party/Simple-Web-Server"] path = third-party/Simple-Web-Server url = https://github.com/LizardByte-infrastructure/Simple-Web-Server.git diff --git a/README.md b/README.md index aeaee1205..e9fb250f3 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,13 @@ LizardByte has the full documentation hosted on [Read the Docs](https://docs.liz ➖ ➖ + + KWin Screencast + ✅ + ✅ + ➖ + ➖ + @@ -322,6 +329,13 @@ LizardByte has the full documentation hosted on [Read the Docs](https://docs.liz + + + + + + +
KWin Screencast
**Legend:** ✅ Supported | 🟡 Partial Support | ❌ Not Yet Supported | ➖ Not Applicable diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index aae62ac8f..7079f9e90 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -76,8 +76,8 @@ if(CUDA_FOUND) add_compile_definitions(SUNSHINE_BUILD_CUDA) endif() -# libdrm is required for both DRM (KMS) and Wayland -if(${SUNSHINE_ENABLE_DRM} OR ${SUNSHINE_ENABLE_WAYLAND}) +# libdrm is required for DRM (KMS), KWin ScreenCast and Wayland +if(${SUNSHINE_ENABLE_DRM} OR ${SUNSHINE_ENABLE_KWIN} OR ${SUNSHINE_ENABLE_WAYLAND}) find_package(LIBDRM REQUIRED) else() set(LIBDRM_FOUND OFF) @@ -243,7 +243,7 @@ if(GIO_FOUND) endif() # Pipewire -if(${SUNSHINE_ENABLE_PORTAL}) +if(${SUNSHINE_ENABLE_KWIN} OR ${SUNSHINE_ENABLE_PORTAL}) pkg_check_modules(PIPEWIRE libpipewire-0.3 REQUIRED) else() set(PIPEWIRE_FOUND OFF) @@ -264,13 +264,27 @@ if(PIPEWIRE_FOUND AND GIO_FOUND AND ${SUNSHINE_ENABLE_PORTAL}) "${CMAKE_SOURCE_DIR}/src/platform/linux/portalgrab.cpp") endif() +# KWin ScreenCast (direct Wayland protocol, bypasses portal) +set(KWIN_FOUND OFF) +if(PIPEWIRE_FOUND AND WAYLAND_FOUND AND ${SUNSHINE_ENABLE_KWIN}) + set(KWIN_FOUND ON) + add_compile_definitions(SUNSHINE_BUILD_KWIN) + GEN_WAYLAND("${CMAKE_SOURCE_DIR}/third-party/plasma-wayland-protocols/src/protocols" "" kde-output-order-v1) + GEN_WAYLAND("${CMAKE_SOURCE_DIR}/third-party/plasma-wayland-protocols/src/protocols" "" zkde-screencast-unstable-v1) + list(APPEND PLATFORM_TARGET_FILES + "${CMAKE_SOURCE_DIR}/src/platform/linux/kwingrab.cpp") +elseif(${SUNSHINE_ENABLE_KWIN} AND NOT WAYLAND_FOUND) + message(FATAL_ERROR "SUNSHINE_ENABLE_KWIN requires SUNSHINE_ENABLE_WAYLAND — KWin capture disabled") +endif() + if(NOT ${CUDA_FOUND} - AND NOT ${WAYLAND_FOUND} - AND NOT ${X11_FOUND} - AND NOT ${PORTAL_FOUND} AND NOT (${LIBDRM_FOUND} AND ${LIBCAP_FOUND}) - AND NOT ${LIBVA_FOUND}) - message(FATAL_ERROR "Couldn't find either cuda, libva, pipewire, wayland, x11, or (libdrm and libcap)") + AND NOT ${LIBVA_FOUND} + AND NOT ${KWIN_FOUND} + AND NOT ${PORTAL_FOUND} + AND NOT ${WAYLAND_FOUND} + AND NOT ${X11_FOUND}) + message(FATAL_ERROR "Couldn't find either cuda, (libdrm and libcap), libva, kwin, pipewire, portal, wayland or x11") endif() # tray icon diff --git a/cmake/packaging/linux.cmake b/cmake/packaging/linux.cmake index 1d5e51414..c4a12b37a 100644 --- a/cmake/packaging/linux.cmake +++ b/cmake/packaging/linux.cmake @@ -173,6 +173,10 @@ install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_FQDN}.desktop" if(NOT ${SUNSHINE_BUILD_APPIMAGE} AND NOT ${SUNSHINE_BUILD_FLATPAK}) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_FQDN}.terminal.desktop" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications") + if(${SUNSHINE_ENABLE_KWIN}) + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_FQDN}.kwin.desktop" + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications") + endif() endif() # metadata file diff --git a/cmake/prep/options.cmake b/cmake/prep/options.cmake index 6ce5b1ab0..bdc7f225c 100644 --- a/cmake/prep/options.cmake +++ b/cmake/prep/options.cmake @@ -64,6 +64,8 @@ elseif(UNIX) # Linux "Enable building wayland specific code." ON) option(SUNSHINE_ENABLE_X11 "Enable X11 grab if available." ON) + option(SUNSHINE_ENABLE_KWIN + "Enable KWin ScreenCast grab if available" ON) option(SUNSHINE_ENABLE_PORTAL "Enable XDG portal grab if available" ON) endif() diff --git a/cmake/prep/special_package_configuration.cmake b/cmake/prep/special_package_configuration.cmake index 211ffe14b..01b9f9b6f 100644 --- a/cmake/prep/special_package_configuration.cmake +++ b/cmake/prep/special_package_configuration.cmake @@ -26,6 +26,11 @@ elseif(UNIX) # configure service configure_file(packaging/linux/app-${PROJECT_FQDN}.service.in app-${PROJECT_FQDN}.service @ONLY) + # configure kwin desktop permission file + if (${SUNSHINE_ENABLE_KWIN}) + configure_file(packaging/linux/${PROJECT_FQDN}.kwin.desktop.in ${PROJECT_FQDN}.kwin.desktop @ONLY) + endif() + # configure the arch linux pkgbuild if(${SUNSHINE_CONFIGURE_PKGBUILD}) configure_file(packaging/linux/Arch/PKGBUILD PKGBUILD @ONLY) diff --git a/cmake/targets/common.cmake b/cmake/targets/common.cmake index 4e938f8ba..e9cc890b6 100644 --- a/cmake/targets/common.cmake +++ b/cmake/targets/common.cmake @@ -31,15 +31,7 @@ endif() target_link_libraries(sunshine ${SUNSHINE_EXTERNAL_LIBRARIES} ${EXTRA_LIBS}) target_compile_definitions(sunshine PUBLIC ${SUNSHINE_DEFINITIONS}) -if(APPLE AND NOT SUNSHINE_BUILD_HOMEBREW) - # codesign on Mac won't sign an .app that uses a symlink - set_target_properties(sunshine PROPERTIES CXX_STANDARD 23) -else() - # symlink sunshine -> sunshine-PROJECT_VERSION - set_target_properties(sunshine PROPERTIES CXX_STANDARD 23 - VERSION ${PROJECT_VERSION} - SOVERSION ${PROJECT_VERSION_MAJOR}) -endif() +set_target_properties(sunshine PROPERTIES CXX_STANDARD 23) # CLion complains about unknown flags after running cmake, and cannot add symbols to the index for cuda files if(CUDA_INHERIT_COMPILE_OPTIONS) diff --git a/docs/configuration.md b/docs/configuration.md index 8e59c0ab0..5ef85dc92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2101,6 +2101,11 @@ editing the `conf` file in a text editor. Use the examples as reference. DRM/KMS screen capture from the kernel. This requires that Sunshine has `cap_sys_admin` capability. @note{Applies to Linux only.} + + kwin + Capture with KDE/KWin Wayland compositor via KDE screencasting. + @note{Applies to Linux only.} + x11 Uses XCB. This is the slowest and most CPU intensive so should be avoided if possible. diff --git a/packaging/linux/copr/Sunshine.spec b/packaging/linux/copr/Sunshine.spec index dd8801e0a..35f088da2 100644 --- a/packaging/linux/copr/Sunshine.spec +++ b/packaging/linux/copr/Sunshine.spec @@ -219,6 +219,7 @@ cmake_args=( "-DSUNSHINE_ASSETS_DIR=%{_datadir}/sunshine" "-DSUNSHINE_EXECUTABLE_PATH=%{_bindir}/sunshine" "-DSUNSHINE_ENABLE_DRM=ON" + "-DSUNSHINE_ENABLE_KWIN=ON" "-DSUNSHINE_ENABLE_PORTAL=ON" "-DSUNSHINE_ENABLE_WAYLAND=ON" "-DSUNSHINE_ENABLE_X11=ON" @@ -405,7 +406,6 @@ fi %files # Executables %caps(cap_sys_admin,cap_sys_nice+p) %{_bindir}/sunshine -%caps(cap_sys_admin,cap_sys_nice+p) %{_bindir}/sunshine-* # Systemd unit files for user services %{_userunitdir}/*.service diff --git a/packaging/linux/dev.lizardbyte.app.Sunshine.kwin.desktop.in b/packaging/linux/dev.lizardbyte.app.Sunshine.kwin.desktop.in new file mode 100644 index 000000000..9d6f330cd --- /dev/null +++ b/packaging/linux/dev.lizardbyte.app.Sunshine.kwin.desktop.in @@ -0,0 +1,7 @@ +[Desktop Entry] +Exec=@SUNSHINE_EXECUTABLE_PATH@ +X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1 +Type=Application +Name=@PROJECT_FQDN@-kwin-wayland-permission +Comment=Sunshine KWin screencast permission +NoDisplay=true diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml index 08f030fa2..1decda94c 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml @@ -82,6 +82,7 @@ modules: - -DSUNSHINE_EXECUTABLE_PATH=/app/bin/sunshine - -DSUNSHINE_ENABLE_CUDA=ON - -DSUNSHINE_ENABLE_DRM=ON + - -DSUNSHINE_ENABLE_KWIN=ON - -DSUNSHINE_ENABLE_PORTAL=ON - -DSUNSHINE_ENABLE_WAYLAND=ON - -DSUNSHINE_ENABLE_X11=ON diff --git a/scripts/linux_build.sh b/scripts/linux_build.sh index b0c7eef29..f4a6d2bfe 100755 --- a/scripts/linux_build.sh +++ b/scripts/linux_build.sh @@ -578,6 +578,7 @@ function run_step_cmake() { "-DSUNSHINE_ASSETS_DIR=share/sunshine" "-DSUNSHINE_EXECUTABLE_PATH=/usr/bin/sunshine" "-DSUNSHINE_ENABLE_DRM=ON" + "-DSUNSHINE_ENABLE_KWIN=ON" "-DSUNSHINE_ENABLE_PORTAL=ON" "-DSUNSHINE_ENABLE_WAYLAND=ON" "-DSUNSHINE_ENABLE_X11=ON" diff --git a/src/platform/linux/kwingrab.cpp b/src/platform/linux/kwingrab.cpp new file mode 100644 index 000000000..b6861bbfc --- /dev/null +++ b/src/platform/linux/kwingrab.cpp @@ -0,0 +1,713 @@ +/** + * @file src/platform/linux/kwingrab.cpp + * @brief KWin direct ScreenCast capture via zkde_screencast_unstable_v1 Wayland protocol. + * + * Bypasses xdg-desktop-portal entirely. Sunshine connects directly to KWin's + * Wayland protocol to obtain a PipeWire node_id, then streams frames via PipeWire. + * + * Chain: KWin -> Wayland kde_screencast -> PipeWire -> Sunshine + */ +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include +#include +#include + +// generated protocol header +#include +#include + +// local includes +#include "cuda.h" +#include "graphics.h" +#include "pipewire.cpp" +#include "src/platform/common.h" +#include "src/video.h" + +using namespace std::literals; + +namespace kwin { + /** + * KWin Wayland ScreenCast permissions + * + * To have access to zkde_screencast_unstable_v1 KWin checks for a .desktop file with + * X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1 and the current executable name + * in the Exec= parameter. + */ + class screencast_permission_helper_t { + public: + static bool is_permission_system_deactivated() { + return getenvstr("KWIN_WAYLAND_NO_PERMISSION_CHECKS") == "1"; + } + + static void setup() { + if (initialized) { + return; + } + auto filenameprefix = std::format("{}.kwin", PROJECT_FQDN); + auto executablepath = get_executable_full_path(); + + // System: Check system XDG applications for permission (usually installed with Sunshine) + if (check_kwin_system_permissions(filenameprefix, executablepath)) { + create_file = false; + initialized = true; + return; + } + + // If we do not have a system permission, check if we need a temporary permission via user's application directory + if (is_permission_system_deactivated()) { + BOOST_LOG(info) << "[kwingrab] No permission desktop file necessary. KWin permission system deactivated."; + create_file = false; + initialized = true; + return; + } + + // User: Check and (if necessary) update user's XDG applications for permission + auto user_applications = get_xdg_user_applications_path(); + if (user_applications.empty()) { + BOOST_LOG(error) << "[kwingrab] Failed to determine user application directory. Cannot continue with permission setup."; + return; + } + // Create non-existing application directory so we can write into it + if (!std::filesystem::exists(user_applications) && !std::filesystem::create_directories(user_applications)) { + // In case of failure log and return + BOOST_LOG(error) << "[kwingrab] Failed to create application directory. Cannot continue with permission setup."; + create_file = false; + initialized = true; + return; + } + auto user_filepathprefix = (std::filesystem::path(user_applications) / filenameprefix).string(); + for (const auto &path : std::filesystem::directory_iterator(user_applications)) { + // List existing files for prefix and check if they contain this executable or remove them + const auto entry = path.path().string(); + if (entry.starts_with(user_filepathprefix)) { + auto entry_executablepath = get_executable_from_desktop_file(entry); + if (!entry_executablepath.empty() && entry_executablepath == executablepath) { + // This entry is exactly the one we need + BOOST_LOG(debug) << "[kwingrab] Ignoring current temporary KWin wayland permission file: "sv << entry; + create_file = false; + continue; + } + if (!entry_executablepath.empty() && std::filesystem::exists(entry_executablepath)) { + // This entry is for another sunshine executable that still exists + BOOST_LOG(debug) << "[kwingrab] Ignoring other valid temporary KWin wayland permission file: "sv << entry; + continue; + } + if (std::filesystem::remove(path)) { + BOOST_LOG(info) << "[kwingrab] Removed stale temporary KWin wayland permission file: "sv << entry << " executable: "sv << entry_executablepath; + } else { + BOOST_LOG(warning) << "[kwingrab] Failed to remove stale temporary KWin wayland permission file: "sv << entry << " executable: "sv << entry_executablepath; + } + } + } + if (create_file) { + // Generate a unique file identifier based on current unixtime + auto user_filepathidentifier = std::chrono::system_clock::now().time_since_epoch() / std::chrono::milliseconds(1); + auto user_filepath = std::format("{}{}.desktop", user_filepathprefix, user_filepathidentifier); + // Write new file if necessary + std::ofstream filestream(user_filepath); + if (filestream.is_open()) { + filestream << "[Desktop Entry]" << std::endl + << "Exec=" << executablepath << std::endl + << "X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1" << std::endl + << "Type=Application" << std::endl + << "Name="sv << PROJECT_FQDN << "-kwin-wayland-permission" << std::endl + << "Comment=Sunshine KWin screencast permission" << std::endl + << "NoDisplay=true" << std::endl; + filestream.close(); + // Give KWin time to catch up to the new desktop file + BOOST_LOG(info) << "[kwingrab] Created temporary KWin wayland permission file: "sv << user_filepath << " - Waiting 3 seconds for KDE to pick up new file."; + std::this_thread::sleep_for(std::chrono::milliseconds(3000)); + } else { + BOOST_LOG(warning) << "[kwingrab] Failed to open temporary KWin wayland permission file: "sv << user_filepath; + } + } + + initialized = true; + } + + static bool is_newly_initialized() { + return create_file; + } + + private: + static inline bool initialized = false; + static inline bool create_file = true; + + static std::string getenvstr(std::string const &key) { + char const *val = std::getenv(key.c_str()); + if (!val) { + return ""; + } + return val; + } + + static std::filesystem::path get_home_dir() { + // Check HOME environment variable + if (std::string homedir = getenvstr("HOME"); !homedir.empty()) { + return homedir; + } + // Fall back to home directory from NSS passwd + // Note: This should be thread-safe as we're always accessing the same entry for Sunshine + return getpwuid(geteuid())->pw_dir; + } + + static std::filesystem::path get_xdg_user_applications_path() { + // Follow the XDG base directory specification for user data home: + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + std::filesystem::path xdg_data_home; + if (std::string dir = getenvstr("XDG_DATA_HOME"); !dir.empty()) { + xdg_data_home = std::filesystem::path(dir); + } else { + const auto homedir = get_home_dir(); + if (homedir.empty()) { + return ""; + } + xdg_data_home = std::filesystem::path(homedir) / ".local"sv / "share"sv; + } + return xdg_data_home / "applications"; + } + + static std::string get_executable_full_path() { + // Adapted from https://linuxvox.com/blog/how-do-i-find-the-location-of-the-executable-in-c/ + constexpr auto path_len = PATH_MAX; // PATH_MAX is defined in limits.h (e.g., 4096 on Linux) + auto path_exe = std::make_unique(path_len); + // Read the symlink /proc/self/exe into path_exe + const ssize_t len = readlink("/proc/self/exe", &path_exe[0], path_len - 1); + if (len == -1) { + return ""; + } + // Return path_exe as a proper std::string with len returned by readlink + return std::string(path_exe.get(), len); + } + + static std::string get_executable_from_desktop_file(const std::string &path) { + if (std::ifstream file(path); file.is_open()) { + std::string line; + while (std::getline(file, line)) { + if (line.starts_with("Exec=") && line.length() > 5) { + return line.substr(5); + } + } + } + return ""; + } + + static bool check_kwin_system_permissions(const std::string_view &filenameprefix, const std::string_view &executablepath) { + // Find data dirs to check from XDG_DATA_DIRS + std::vector xdg_data_dirs; + if (const std::string e = getenvstr("XDG_DATA_DIRS"); !e.empty()) { + std::stringstream ss(e); + std::string item; + + while (getline(ss, item, ':')) { // : is likely valid for all OSes supported, if a constant is available it should be used instead + xdg_data_dirs.push_back(item); + } + } + // Use defaults from https://specifications.freedesktop.org/basedir/latest/ if ENV var was empty + if (xdg_data_dirs.empty()) { + xdg_data_dirs.emplace_back("/usr/local/share/"); + xdg_data_dirs.emplace_back("/usr/share/"); + } + // Check for ${filenameprefix}.desktop in each directory + for (auto const &dir : xdg_data_dirs) { + std::string filename = std::format("{0}{1}applications{1}{2}.desktop", dir, boost::filesystem::path::preferred_separator, filenameprefix); + if (std::filesystem::exists(filename)) { + auto file_executablepath = get_executable_from_desktop_file(filename); + if (file_executablepath == executablepath) { + BOOST_LOG(info) << "[kwingrab] Found matching system KWin desktop permission file: "sv << filename; + return true; + } + } + } + return false; + } + }; + + // Output parameters + struct output_parameter_t { + std::string name = ""; + int width = 0; + int height = 0; + int pos_x = 0; + int pos_y = 0; + // order is needed to get a sorted output list and should be updated before sorting to have current values + size_t order = SIZE_MAX; // Use high number to keep monitors with uninitialized order value to the back + }; + + /** + * Wayland KDE ScreenCast session + * + * Owns its own wl_display connection. Binds zkde_screencast_unstable_v1 + * and wl_output from the registry, then calls stream_output() to start + * a ScreenCast. Waits for the created(node_id) event from KWin. + */ + class screencast_t { + public: + screencast_t &operator=(screencast_t &&) = delete; // Do not allow to copying + + ~screencast_t() { + if (kde_screencast_stream_v1_) { + zkde_screencast_stream_unstable_v1_close(kde_screencast_stream_v1_); + kde_screencast_stream_v1_ = nullptr; + } + if (kde_screencast_v1_) { + zkde_screencast_unstable_v1_destroy(kde_screencast_v1_); + kde_screencast_v1_ = nullptr; + } + + if (kde_output_order) { + kde_output_order_v1_destroy(kde_output_order); + kde_output_order = nullptr; + } + + // wl_output is owned by the registry, released on disconnect + for (const auto &out : outputs | std::views::keys) { + wl_output_destroy(out); + } + outputs.clear(); + + if (wl_registry) { + wl_registry_destroy(wl_registry); + wl_registry = nullptr; + } + + if (wl_display) { + wl_display_disconnect(wl_display); + wl_display = nullptr; + } + } + + /** + * @brief Connect to KWin wayland, enumerate outputs. + * @param setup_permissions - Try to setup KWin permissions (default: true) + * @return 0 on success, -1 on failure. On success, node_id and + * output width/height/x/y are populated. + */ + int init(const bool setup_permissions = true) { + if (setup_permissions) { + // Try to set up permissions for zkde_screencast_unstable_v1 + screencast_permission_helper_t::setup(); + } + + const char *wl_name = std::getenv("WAYLAND_DISPLAY"); + if (!wl_name) { + BOOST_LOG(error) << "[kwingrab] WAYLAND_DISPLAY not set"sv; + return -1; + } + + wl_display = wl_display_connect(wl_name); + if (!wl_display) { + BOOST_LOG(error) << "[kwingrab] cannot connect to Wayland display: "sv << wl_name; + return -1; + } + + wl_registry = wl_display_get_registry(wl_display); + wl_registry_add_listener(wl_registry, ®istry_listener, this); + wl_display_roundtrip(wl_display); + + // We need a second roundtrip after binding outputs to get wl_output events + wl_display_roundtrip(wl_display); + + return 0; + } + + /** + * @brief Generate a sorted list of known output names. + * @return List of strings with output names to pass to start() + */ + std::vector get_output_names() { + std::vector> sorted_outputs; + for (const auto &output_parameter : outputs | std::views::values) { + output_parameter->order = get_order_for_output_name(output_parameter->name); + sorted_outputs.emplace_back(output_parameter); + } + std::ranges::sort(sorted_outputs, [](const auto &a, const auto &b) { + return a->order < b->order || a->pos_x < b->pos_x || a->pos_y < b->pos_y; + }); + std::vector output_names; + for (const auto &output_parameter : sorted_outputs) { + BOOST_LOG(info) << "[kwingrab] Found output: "sv << output_parameter->name << " order: "sv << output_parameter->order << " position: "sv << output_parameter->pos_x << "x"sv << output_parameter->pos_y << " resolution: "sv << output_parameter->width << "x"sv << output_parameter->height; + output_names.emplace_back(output_parameter->name); + } + return output_names; + } + + /** + * @brief Check if KWin is available for potential screencasting + * @return True if KWin is detected + */ + bool kwin_available() const { + // Detect KWin using kde_output_order_v1 extension + if (kde_output_order) { + return true; + } + return false; + } + + /** + * @brief Request a screencast stream. + * @param output_name Which wl_output to capture. + * @return 0 on success, -1 on failure. On success, node_id and + * output width/height/x/y are populated. + */ + int start(const std::string_view &output_name) { + // Try find correct output by name + if (outputs.empty()) { + BOOST_LOG(error) << "[kwingrab] no wl_output found"sv; + return -1; + } + struct wl_output *output = nullptr; + if (!output_name.empty()) { + for (auto const &[output_, params_] : outputs) { + if (params_->name == output_name) { + output = output_; + out_params = params_; + } + } + } + // Fall back to first element from the map in case of error + if (!output || !out_params) { + const auto output_ = outputs.begin(); + output = output_->first; + out_params = output_->second; + } + + // Request a stream for the chosen output with embedded cursor + if (kde_screencast_v1_) { + kde_screencast_stream_v1_ = zkde_screencast_unstable_v1_stream_output(kde_screencast_v1_, output, ZKDE_SCREENCAST_UNSTABLE_V1_POINTER_EMBEDDED); + zkde_screencast_stream_unstable_v1_add_listener(kde_screencast_stream_v1_, &stream_listener, this); + } else { + // No screencast protocol found. Output an error based on newly initialized permission file. + if (screencast_permission_helper_t::is_newly_initialized()) { + BOOST_LOG(error) << "[kwingrab] zkde_screencast_unstable_v1 not found in registry. "sv + "A new permission desktop file was automatically created but might now have been recognized yet. "sv + "Try restarting sunshine or set KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 to fully disable permission checks."sv; + } else { + BOOST_LOG(error) << "[kwingrab] zkde_screencast_unstable_v1 not found in registry. Check permission desktop file "sv + "for sunshine binary or set KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 to fully disable permission checks."sv; + } + return -1; + } + + if (wait_for_stream() < 0) { + return -1; + } + + if (stream_failed) { + BOOST_LOG(error) << "[kwingrab] stream_output failed: "sv << stream_error_msg; + return -1; + } + // Check for valid node_id and/or object serial values here, stream_ready is just an internal flag + if (out_node_id == PW_ID_ANY && (out_objectserial & SPA_ID_INVALID) == SPA_ID_INVALID) { + BOOST_LOG(error) << "[kwingrab] timeout waiting for created event"sv; + return -1; + } + + BOOST_LOG(info) << "[kwingrab] stream created, PipeWire node "sv << out_node_id; + + if (out_params->width == 0 || out_params->height == 0) { + BOOST_LOG(error) << "[kwingrab] could not determine output dimensions"sv; + return -1; + } + + BOOST_LOG(info) << "[kwingrab] Screencasting output"sv + << " name "sv << out_params->name + << " position "sv << out_params->pos_x << "x"sv << out_params->pos_y + << " resolution "sv << out_params->width << "x"sv << out_params->height; + return 0; + } + + uint32_t out_node_id = PW_ID_ANY; + uint64_t out_objectserial = SPA_ID_INVALID; + std::shared_ptr out_params = nullptr; + + private: + // Wayland objects + struct wl_display *wl_display = nullptr; + struct wl_registry *wl_registry = nullptr; + struct kde_output_order_v1 *kde_output_order = nullptr; + struct zkde_screencast_unstable_v1 *kde_screencast_v1_ = nullptr; + struct zkde_screencast_stream_unstable_v1 *kde_screencast_stream_v1_ = nullptr; + std::map> outputs; + std::vector output_order; + bool stream_failed = false; + bool stream_ready = false; + std::string stream_error_msg; + + // Misc functions + int wait_for_stream() { + // Dispatch until we get created/failed, with a 5s timeout + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!stream_ready && !stream_failed && std::chrono::steady_clock::now() < deadline) { + wl_display_flush(wl_display); + + struct pollfd pfd = {}; + pfd.fd = wl_display_get_fd(wl_display); + pfd.events = POLLIN; + + auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now() + ); + if (remaining.count() <= 0) { + break; + } + + if (poll(&pfd, 1, remaining.count()) > 0 && (pfd.revents & POLLIN) && wl_display_dispatch(wl_display) < 0) { + BOOST_LOG(error) << "[kwingrab] wl_display_dispatch failed"sv; + return -1; + } + } + return 0; + } + + size_t get_order_for_output_name(const std::string_view &name) const { + for (size_t i = 0; i < output_order.size(); i++) { + if (output_order[i] == name) { + return i; + } + } + // If nothing matches return list size (to ensure highest order) + return output_order.size(); + } + + // Registry listener + static void on_registry_global(void *data, struct wl_registry *reg, const uint32_t name, const char *interface, const uint32_t version) { + auto *self = static_cast(data); + if (!std::strcmp(interface, kde_output_order_v1_interface.name)) { + // Bind version 1 + uint32_t bind_ver = std::min(version, static_cast(1)); + self->kde_output_order = static_cast( + wl_registry_bind(reg, name, &kde_output_order_v1_interface, bind_ver) + ); + kde_output_order_v1_add_listener(self->kde_output_order, &output_order_listener, self); + BOOST_LOG(debug) << "[kwingrab] bound kde_output_order_v1 version "sv << bind_ver; + } else if (!std::strcmp(interface, zkde_screencast_unstable_v1_interface.name)) { + // Bind version 1 to 6 — We use stream_output from v1 for node_id (deprecated but good as a fall-back) + // but also try to get the newer (re-use safe) pipewire objectserial from v6 + uint32_t bind_ver = std::min(version, static_cast(6)); + self->kde_screencast_v1_ = static_cast( + wl_registry_bind(reg, name, &zkde_screencast_unstable_v1_interface, bind_ver) + ); + BOOST_LOG(debug) << "[kwingrab] bound zkde_screencast_unstable_v1 version "sv << bind_ver; + } else if (!std::strcmp(interface, wl_output_interface.name)) { + // Bind version 4 - we need wl_output name for matching + uint32_t bind_ver = std::min(version, static_cast(4)); + auto *output = static_cast( + wl_registry_bind(reg, name, &wl_output_interface, bind_ver) + ); + + const auto [_, inserted] = self->outputs.try_emplace(output, std::make_shared()); + if (inserted) { + wl_output_add_listener(output, &output_listener, self); + BOOST_LOG(debug) << "[kwingrab] bound wl_output version "sv << bind_ver << " instance: "sv << output; + } else { + // If we for some odd reason cannot add the output to the map clean it up and log a warning + BOOST_LOG(warning) << "[kwingrab] Ignoring output "sv << output << " because map emplace failed."sv; + wl_output_destroy(output); + } + } + } + + static void on_registry_global_remove(void *data [[maybe_unused]], struct wl_registry *reg [[maybe_unused]], uint32_t name [[maybe_unused]]) { + // We don't handle output hot-unplug during init + } + + static constexpr struct wl_registry_listener registry_listener = { + .global = on_registry_global, + .global_remove = on_registry_global_remove, + }; + + // wl_output listener (for mode/dimensions/name) + static void on_output_geometry(void *data, struct wl_output *output, int32_t x, int32_t y, int32_t pw [[maybe_unused]], int32_t ph [[maybe_unused]], int32_t subpixel [[maybe_unused]], const char *make [[maybe_unused]], const char *model [[maybe_unused]], int32_t transform [[maybe_unused]]) { + const auto *self = static_cast(data); + const auto output_parameter = self->outputs.at(output); + output_parameter->pos_x = x; + output_parameter->pos_y = y; + } + + static void on_output_mode(void *data, struct wl_output *output, uint32_t flags, int32_t width, int32_t height, int32_t refresh [[maybe_unused]]) { + if (!(flags & WL_OUTPUT_MODE_CURRENT)) { + return; + } + const auto *self = static_cast(data); + const auto output_parameter = self->outputs.at(output); + output_parameter->width = width; + output_parameter->height = height; + } + + static void on_output_done(void *data [[maybe_unused]], struct wl_output *output [[maybe_unused]]) { + // Currently unused + } + + static void on_output_scale(void *data [[maybe_unused]], struct wl_output *output [[maybe_unused]], int32_t factor [[maybe_unused]]) { + // Currently unused + } + + static void on_output_name(void *data, struct wl_output *output, const char *name) { + const auto *self = static_cast(data); + self->outputs.at(output)->name = name; + } + + static void on_output_description(void *data [[maybe_unused]], struct wl_output *output [[maybe_unused]], const char *description [[maybe_unused]]) { + // Currently unused + } + + static constexpr struct wl_output_listener output_listener = { + .geometry = on_output_geometry, + .mode = on_output_mode, + .done = on_output_done, + .scale = on_output_scale, + .name = on_output_name, + .description = on_output_description, + }; + + // Output order listener + static void on_output_order_output(void *data, struct kde_output_order_v1 *kde_output_order_v1 [[maybe_unused]], const char *output_name) { + auto *self = static_cast(data); + self->output_order.emplace_back(output_name); + } + + static void on_output_order_done(void *data [[maybe_unused]], struct kde_output_order_v1 *kde_output_order_v1 [[maybe_unused]]) { + // Currently unused + } + + static constexpr kde_output_order_v1_listener output_order_listener = { + .output = on_output_order_output, + .done = on_output_order_done, + }; + + // ScreenCast v1 stream listener + static void on_stream_closed(void *data, struct zkde_screencast_stream_unstable_v1 *stream [[maybe_unused]]) { + auto *self = static_cast(data); + BOOST_LOG(warning) << "[kwingrab] stream closed by server"sv; + self->stream_failed = false; + self->stream_ready = false; + self->stream_error_msg = "stream closed by server"; + } + + static void on_stream_created(void *data, struct zkde_screencast_stream_unstable_v1 *stream [[maybe_unused]], const uint32_t node) { + auto *self = static_cast(data); + self->out_node_id = node; + self->stream_failed = false; + self->stream_ready = true; + BOOST_LOG(debug) << "[kwingrab] created event, node_id="sv << node; + } + + static void on_stream_failed(void *data, struct zkde_screencast_stream_unstable_v1 *stream [[maybe_unused]], const char *err_msg) { + auto *self = static_cast(data); + self->stream_failed = true; + self->stream_ready = false; + self->stream_error_msg = err_msg ? err_msg : "unknown error"; + BOOST_LOG(error) << "[kwingrab] failed event: "sv << self->stream_error_msg; + } + + static void on_stream_serial(void *data, struct zkde_screencast_stream_unstable_v1 *stream [[maybe_unused]], uint32_t object_serial_hi, uint32_t object_serial_low) { + auto *self = static_cast(data); + self->out_objectserial = static_cast(object_serial_hi) << 32 | object_serial_low; + // serial event always preceded the created event with the node id, so we only set stream_ready in created for v1 + BOOST_LOG(debug) << "[kwingrab] serial event, objectserial="sv << self->out_objectserial; + } + + static constexpr struct zkde_screencast_stream_unstable_v1_listener stream_listener = { + .closed = on_stream_closed, + .created = on_stream_created, + .failed = on_stream_failed, + .serial = on_stream_serial, + }; + }; + + /** + * Display backend + * + * Orchestrates screencast_t and implements pipewire_display_t + */ + class kwin_t: public pipewire::pipewire_display_t { + public: + int configure_stream(const std::string &display_name, int &out_pipewire_fd, uint32_t &out_pipewire_node, uint64_t &out_pipewire_objectserial) override { + screencast = std::make_unique(); + if (screencast->init(true) < 0) { + return -1; + } + if (screencast->start(display_name) < 0) { + return -1; + } + if (screencast->out_params) { + // Return values for pipewire init + out_pipewire_fd = -1; // KWin screencast capture runs on the local pipewire core + out_pipewire_node = screencast->out_node_id; + out_pipewire_objectserial = screencast->out_objectserial; + // Set/update basic stream parameters on display_t + this->offset_x = screencast->out_params->pos_x; + this->offset_y = screencast->out_params->pos_y; + this->width = screencast->out_params->width; + this->height = screencast->out_params->height; + this->logical_width = 0; // Explicitly mark for pipewire_display_t to try to figure this out. + this->logical_height = 0; // Explicitly Mark for pipewire_display_t to try to figure this out. + return 0; + } + return -1; + } + + std::unique_ptr screencast; + }; +} // namespace kwin + +// Public API for misc.cpp +namespace platf { + std::shared_ptr kwin_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config) { + if (!pipewire::pipewire_display_t::init_pipewire_and_check_hwdevice_type(hwdevice_type)) { + BOOST_LOG(error) << "[kwingrab] Could not initialize pipewire-based display with the given hw device type."sv; + return nullptr; + } + + // Drop CAP_SYS_ADMIN so KWin's permission check (if active) can see and match the executable + if (!kwin::screencast_permission_helper_t::is_permission_system_deactivated() && has_elevated_privileges()) { + drop_elevated_privileges(); + } + + auto display = std::make_shared(); + if (display->init(hwdevice_type, display_name, config)) { + return nullptr; + } + + return display; + } + + std::vector kwin_display_names() { + if (!kwin::screencast_permission_helper_t::is_permission_system_deactivated() && has_elevated_privileges()) { + // We're still in the probing phase of Sunshine startup. Dropping portal security early will break KMS. + // Just return a dummy screen for now. Display re-enumeration after encoder probing will yield full result. + std::vector display_names; + display_names.emplace_back(""); + return display_names; + } + + const auto screencast = std::make_unique(); + if (screencast->init() < 0) { + return {}; + } + return screencast->get_output_names(); + } + + bool kwin_available() { + // Init screencast without permission setup (to not cause unneeded logs / temporary desktop files) and check KWin availability + if (const auto screencast = std::make_unique(); screencast->init(false) < 0 || !screencast->kwin_available()) { + return false; + } + return true; + } +} // namespace platf diff --git a/src/platform/linux/misc.cpp b/src/platform/linux/misc.cpp index 4c783ae84..32eb8cbfc 100644 --- a/src/platform/linux/misc.cpp +++ b/src/platform/linux/misc.cpp @@ -963,6 +963,9 @@ namespace platf { #ifdef SUNSHINE_BUILD_X11 X11, ///< X11 #endif +#ifdef SUNSHINE_BUILD_KWIN + KWIN, ///< KWin ScreenCast +#endif #ifdef SUNSHINE_BUILD_PORTAL PORTAL, ///< XDG PORTAL #endif @@ -1017,6 +1020,17 @@ namespace platf { } #endif +#ifdef SUNSHINE_BUILD_KWIN + bool kwin_available(); + std::vector kwin_display_names(); + std::shared_ptr kwin_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config); + + bool verify_kwin() { + // Note: The separate kwin_available check is necessary because with CAP_SYS_ADMIN kwin_display_names is never empty during startup + return window_system == window_system_e::WAYLAND && kwin_available() && !kwin_display_names().empty(); + } +#endif + std::vector display_names(mem_type_e hwdevice_type) { #ifdef SUNSHINE_BUILD_CUDA // display using NvFBC only supports mem_type_e::cuda @@ -1043,6 +1057,11 @@ namespace platf { if (sources[source::PORTAL]) { return portal_display_names(); } +#endif +#ifdef SUNSHINE_BUILD_KWIN + if (sources[source::KWIN]) { + return kwin_display_names(); + } #endif return {}; } @@ -1087,6 +1106,12 @@ namespace platf { return portal_display(hwdevice_type, display_name, config); } #endif +#ifdef SUNSHINE_BUILD_KWIN + if (sources[source::KWIN]) { + BOOST_LOG(info) << "Screencasting with KWin ScreenCast"sv; + return kwin_display(hwdevice_type, display_name, config); + } +#endif return nullptr; } @@ -1145,6 +1170,11 @@ namespace platf { sources[source::PORTAL] = true; } #endif +#ifdef SUNSHINE_BUILD_KWIN + if (((config::video.capture.empty() && sources.none()) || config::video.capture == "kwin") && verify_kwin()) { + sources[source::KWIN] = true; + } +#endif if (sources.none()) { BOOST_LOG(error) << "Unable to initialize capture method"sv; diff --git a/src_assets/common/assets/web/configs/tabs/Advanced.vue b/src_assets/common/assets/web/configs/tabs/Advanced.vue index d0b13adf3..108d2b336 100644 --- a/src_assets/common/assets/web/configs/tabs/Advanced.vue +++ b/src_assets/common/assets/web/configs/tabs/Advanced.vue @@ -74,6 +74,7 @@ const config = ref(props.config) +