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
| 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)
+
diff --git a/third-party/plasma-wayland-protocols b/third-party/plasma-wayland-protocols
new file mode 160000
index 000000000..18afc45c0
--- /dev/null
+++ b/third-party/plasma-wayland-protocols
@@ -0,0 +1 @@
+Subproject commit 18afc45c092857e998c890afce1e7e71f808d819
|