diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index ea46429cc73..f24f31ef1d1 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -74,6 +74,8 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/win_dark_mode.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/win_dark_mode.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/clipboard.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/clipboard.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/input.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_mouse.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_mouse.cpp" @@ -140,4 +142,4 @@ list(PREPEND PLATFORM_LIBRARIES if(SUNSHINE_ENABLE_TRAY) list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") -endif() \ No newline at end of file +endif() diff --git a/src/config.cpp b/src/config.cpp index 3022d62f39f..e5076073701 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -545,7 +545,9 @@ namespace config { true, // always send scancodes true, // high resolution scrolling true, // native pen/touch support + true, // clipboard sync true, // virtual mouse (use driver if available) + false, // draw mouse cursor in AMF capture }; sunshine_t sunshine { @@ -1370,6 +1372,7 @@ namespace config { bool_f(vars, "high_resolution_scrolling", input.high_resolution_scrolling); bool_f(vars, "native_pen_touch", input.native_pen_touch); + bool_f(vars, "clipboard_sync", input.clipboard_sync); bool_f(vars, "virtual_mouse", input.virtual_mouse); bool_f(vars, "amf_draw_mouse_cursor", input.amf_draw_mouse_cursor); @@ -1788,4 +1791,4 @@ namespace config { return false; } } -} // namespace config \ No newline at end of file +} // namespace config diff --git a/src/config.h b/src/config.h index a3bcdb15195..7b43e652467 100644 --- a/src/config.h +++ b/src/config.h @@ -197,6 +197,7 @@ namespace config { bool high_resolution_scrolling; bool native_pen_touch; + bool clipboard_sync; bool virtual_mouse; bool amf_draw_mouse_cursor; }; @@ -272,4 +273,4 @@ namespace config { bool update_full_config(const std::map &fullConfig); -} // namespace config \ No newline at end of file +} // namespace config diff --git a/src/platform/common.h b/src/platform/common.h index 1951fa900d0..affea851e6a 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -290,12 +290,14 @@ namespace platf { int width, height; }; - // These values must match Limelight-internal.h's SS_FF_* constants! + // These are Sunshine-specific platform capability flags. namespace platform_caps { typedef uint32_t caps_t; constexpr caps_t pen_touch = 0x01; // Pen and touch events constexpr caps_t controller_touch = 0x02; // Controller touch events + constexpr caps_t clipboard_text = 0x04; // Clipboard text sync + constexpr caps_t clipboard_image = 0x08; // Clipboard image sync }; // namespace platform_caps struct gamepad_state_t { @@ -1075,4 +1077,4 @@ namespace platf { std::unique_ptr create_high_precision_timer(); -} // namespace platf \ No newline at end of file +} // namespace platf diff --git a/src/platform/windows/clipboard.cpp b/src/platform/windows/clipboard.cpp new file mode 100644 index 00000000000..c55d5a1ff08 --- /dev/null +++ b/src/platform/windows/clipboard.cpp @@ -0,0 +1,872 @@ +/** + * @file src/platform/windows/clipboard.cpp + * @brief Windows clipboard backend for Sunshine clipboard sync. + */ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +#include "clipboard.h" + +#include "src/platform/common.h" +#include "src/logging.h" +#include "src/stb_image.h" +#include "src/stb_image_write.h" + +namespace platf::clipboard { + namespace { + constexpr std::uint64_t fnv_offset_basis = 14695981039346656037ull; + constexpr std::uint64_t fnv_prime = 1099511628211ull; + constexpr auto clipboard_retry_delay = std::chrono::milliseconds(8); + constexpr int clipboard_retry_count = 8; + constexpr std::size_t max_decoded_image_bytes = 64U * 1024U * 1024U; + constexpr std::size_t max_text_clipboard_bytes = 1U * 1024U * 1024U; + + constexpr wchar_t clipboard_owner_window_class[] = L"SunshineClipboardOwnerWindow"; + std::mutex clipboard_owner_window_mutex; + HWND clipboard_owner_window = nullptr; + + bool + ensure_clipboard_owner_window() { + std::lock_guard lock(clipboard_owner_window_mutex); + if (clipboard_owner_window != nullptr && IsWindow(clipboard_owner_window)) { + return true; + } + + const HINSTANCE instance = GetModuleHandleW(nullptr); + WNDCLASSEXW wnd_class {}; + wnd_class.cbSize = sizeof(wnd_class); + wnd_class.lpfnWndProc = DefWindowProcW; + wnd_class.hInstance = instance; + wnd_class.lpszClassName = clipboard_owner_window_class; + + if (RegisterClassExW(&wnd_class) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + clipboard_owner_window = nullptr; + return false; + } + + clipboard_owner_window = CreateWindowExW(0, + clipboard_owner_window_class, + L"Sunshine Clipboard Owner Window", + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + nullptr, + instance, + nullptr); + return clipboard_owner_window != nullptr; + } + + enum class clipboard_open_mode { + read, + write, + }; + + struct clipboard_guard_t { + bool open = false; + + explicit clipboard_guard_t(clipboard_open_mode mode = clipboard_open_mode::read) { + HWND owner = nullptr; + if (mode == clipboard_open_mode::write) { + if (!ensure_clipboard_owner_window()) { + return; + } + + owner = clipboard_owner_window; + } + + for (int attempt = 0; attempt < clipboard_retry_count; ++attempt) { + if (OpenClipboard(owner)) { + open = true; + break; + } + + Sleep(static_cast(clipboard_retry_delay.count())); + } + } + + ~clipboard_guard_t() { + if (open) { + CloseClipboard(); + } + } + + explicit operator bool() const { + return open; + } + }; + + std::wstring + utf8_to_wide(const std::string_view value); + + std::string + normalize_newlines_to_lf(const std::string_view value); + + std::string + wide_to_utf8(const std::wstring &value) { + if (value.empty()) { + return {}; + } + + const int required = + WideCharToMultiByte(CP_UTF8, 0, value.c_str(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (required <= 0) { + return {}; + } + + std::string result(static_cast(required), '\0'); + WideCharToMultiByte(CP_UTF8, + 0, + value.c_str(), + static_cast(value.size()), + result.data(), + required, + nullptr, + nullptr); + return result; + } + + std::wstring + utf8_to_wide(const std::string_view value) { + if (value.empty()) { + return {}; + } + + const int required = + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0); + if (required <= 0) { + return {}; + } + + std::wstring result(static_cast(required), L'\0'); + MultiByteToWideChar(CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + required); + return result; + } + + std::string + normalize_newlines_to_lf(const std::string_view value) { + std::string normalized; + normalized.reserve(value.size()); + + for (std::size_t i = 0; i < value.size(); ++i) { + const char ch = value[i]; + if (ch == '\r') { + if (i + 1 < value.size() && value[i + 1] == '\n') { + ++i; + } + normalized.push_back('\n'); + } + else { + normalized.push_back(ch); + } + } + + return normalized; + } + + std::wstring + normalize_newlines_to_crlf(const std::string_view value) { + const std::wstring wide = utf8_to_wide(normalize_newlines_to_lf(value)); + std::wstring normalized; + normalized.reserve(wide.size() * 2); + + for (wchar_t ch: wide) { + if (ch == L'\n') { + normalized.push_back(L'\r'); + normalized.push_back(L'\n'); + } + else { + normalized.push_back(ch); + } + } + + return normalized; + } + + std::uint64_t + fnv1a_append(std::uint64_t hash, const void *data, std::size_t length) { + const auto *bytes = static_cast(data); + for (std::size_t i = 0; i < length; ++i) { + hash ^= bytes[i]; + hash *= fnv_prime; + } + return hash; + } + + std::uint64_t + compute_item_hash(std::uint8_t type, + const std::vector &data, + const std::string_view &name = {}) { + std::uint64_t hash = fnv_offset_basis; + hash = fnv1a_append(hash, &type, sizeof(type)); + if (!data.empty()) { + hash = fnv1a_append(hash, data.data(), data.size()); + } + if (!name.empty()) { + hash = fnv1a_append(hash, name.data(), name.size()); + } + return hash; + } + + bool + read_hglobal_bytes(HANDLE handle, std::vector &bytes) { + if (handle == nullptr) { + return false; + } + + const auto size = GlobalSize(handle); + if (size == 0) { + bytes.clear(); + return true; + } + + const void *locked = GlobalLock(handle); + if (locked == nullptr) { + return false; + } + + bytes.assign(static_cast(locked), + static_cast(locked) + size); + GlobalUnlock(handle); + return true; + } + + bool + write_hglobal_bytes(UINT format, const std::vector &bytes) { + HGLOBAL mem = GlobalAlloc(GMEM_MOVEABLE, bytes.size()); + if (mem == nullptr) { + return false; + } + + void *locked = GlobalLock(mem); + if (locked == nullptr) { + GlobalFree(mem); + return false; + } + + if (!bytes.empty()) { + std::memcpy(locked, bytes.data(), bytes.size()); + } + GlobalUnlock(mem); + + if (SetClipboardData(format, mem) == nullptr) { + GlobalFree(mem); + return false; + } + + return true; + } + + bool + checked_mul(std::size_t lhs, std::size_t rhs, std::size_t &result) { + if (lhs != 0 && rhs > std::numeric_limits::max() / lhs) { + return false; + } + + result = lhs * rhs; + return true; + } + + bool + checked_add(std::size_t lhs, std::size_t rhs, std::size_t &result) { + if (rhs > std::numeric_limits::max() - lhs) { + return false; + } + + result = lhs + rhs; + return true; + } + + bool + decoded_image_size_valid(int width, + int height, + std::size_t bytes_per_pixel, + std::size_t &pixel_bytes) { + if (width <= 0 || height <= 0 || bytes_per_pixel == 0) { + return false; + } + + std::size_t area = 0; + if (!checked_mul(static_cast(width), + static_cast(height), + area) || + !checked_mul(area, bytes_per_pixel, pixel_bytes) || + pixel_bytes > max_decoded_image_bytes) { + return false; + } + + return true; + } + + bool + png_decoded_size_valid(const std::vector &png_bytes, + std::size_t &pixel_bytes) { + if (png_bytes.empty() || + png_bytes.size() > static_cast(std::numeric_limits::max())) { + return false; + } + + int width = 0; + int height = 0; + int components = 0; + if (stbi_info_from_memory(png_bytes.data(), + static_cast(png_bytes.size()), + &width, + &height, + &components) == 0) { + return false; + } + + return decoded_image_size_valid(width, height, 4, pixel_bytes); + } + + bool + dib_stride_valid(int width, int bit_count, std::size_t &stride) { + if (width <= 0 || bit_count <= 0) { + return false; + } + + std::size_t bits_per_row = 0; + if (!checked_mul(static_cast(width), + static_cast(bit_count), + bits_per_row)) { + return false; + } + + if (!checked_add(bits_per_row, 31, bits_per_row)) { + return false; + } + bits_per_row /= 32; + return checked_mul(bits_per_row, 4, stride); + } + + + bool + encode_png_from_rgba(int width, + int height, + const std::vector &rgba, + std::vector &png_bytes) { + if (width <= 0 || height <= 0) { + return false; + } + + png_bytes.clear(); + auto writer = [](void *context, void *data, int size) { + auto *buffer = static_cast *>(context); + const auto *bytes = static_cast(data); + buffer->insert(buffer->end(), bytes, bytes + size); + }; + + return stbi_write_png_to_func(writer, + &png_bytes, + width, + height, + 4, + rgba.data(), + width * 4) != 0; + } + + bool + decode_dib_to_png(HANDLE dib_handle, std::vector &png_bytes) { + if (dib_handle == nullptr) { + return false; + } + + const auto total_size = GlobalSize(dib_handle); + if (total_size < sizeof(BITMAPINFOHEADER)) { + return false; + } + + const auto *header = + static_cast(GlobalLock(dib_handle)); + if (header == nullptr) { + return false; + } + + const BITMAPINFOHEADER info = *header; + if (info.biWidth <= 0 || info.biHeight == 0) { + GlobalUnlock(dib_handle); + return false; + } + if (info.biSize < sizeof(BITMAPINFOHEADER) || info.biSize > total_size) { + GlobalUnlock(dib_handle); + return false; + } + + const int width = info.biWidth; + const auto raw_height = static_cast(info.biHeight); + const auto absolute_height = raw_height > 0 ? raw_height : -raw_height; + if (absolute_height > std::numeric_limits::max()) { + GlobalUnlock(dib_handle); + return false; + } + const int height = static_cast(absolute_height); + const bool top_down = info.biHeight < 0; + const int bit_count = info.biBitCount; + const DWORD compression = info.biCompression; + + if (!((bit_count == 32 && (compression == BI_RGB || compression == BI_BITFIELDS)) || + (bit_count == 24 && compression == BI_RGB))) { + GlobalUnlock(dib_handle); + return false; + } + + std::size_t pixel_offset = info.biSize; + if (compression == BI_BITFIELDS) { + if (!checked_add(pixel_offset, 3 * sizeof(DWORD), pixel_offset)) { + GlobalUnlock(dib_handle); + return false; + } + } + + std::size_t stride = 0; + std::size_t pixel_data_bytes = 0; + std::size_t required_size = 0; + std::size_t rgba_bytes = 0; + if (!dib_stride_valid(width, bit_count, stride) || + !checked_mul(stride, static_cast(height), pixel_data_bytes) || + !checked_add(pixel_offset, pixel_data_bytes, required_size) || + required_size > total_size || + !decoded_image_size_valid(width, height, 4, rgba_bytes)) { + GlobalUnlock(dib_handle); + return false; + } + + const auto *pixels = reinterpret_cast(header) + pixel_offset; + std::vector rgba(rgba_bytes); + bool has_non_zero_alpha = false; + + for (int y = 0; y < height; ++y) { + const int src_y = top_down ? y : (height - 1 - y); + const auto *src = pixels + stride * static_cast(src_y); + auto *dst = rgba.data() + static_cast(y) * static_cast(width) * 4; + + for (int x = 0; x < width; ++x) { + if (bit_count == 32) { + dst[x * 4 + 0] = src[x * 4 + 2]; + dst[x * 4 + 1] = src[x * 4 + 1]; + dst[x * 4 + 2] = src[x * 4 + 0]; + dst[x * 4 + 3] = src[x * 4 + 3]; + has_non_zero_alpha = has_non_zero_alpha || src[x * 4 + 3] != 0; + } + else { + dst[x * 4 + 0] = src[x * 3 + 2]; + dst[x * 4 + 1] = src[x * 3 + 1]; + dst[x * 4 + 2] = src[x * 3 + 0]; + dst[x * 4 + 3] = 0xFF; + } + } + } + + if (bit_count == 32 && !has_non_zero_alpha) { + for (std::size_t i = 3; i < rgba.size(); i += 4) { + rgba[i] = 0xFF; + } + } + + GlobalUnlock(dib_handle); + return encode_png_from_rgba(width, height, rgba, png_bytes); + } + + bool + read_text_item(item_t &item, std::string *reason) { + HANDLE handle = GetClipboardData(CF_UNICODETEXT); + if (handle == nullptr) { + if (reason) { + *reason = "CF_UNICODETEXT unavailable"; + } + return false; + } + + const auto *wide = static_cast(GlobalLock(handle)); + if (wide == nullptr) { + if (reason) { + *reason = "CF_UNICODETEXT lock failed"; + } + return false; + } + + const auto size_bytes = GlobalSize(handle); + const auto max_chars = size_bytes / sizeof(wchar_t); + std::size_t length = 0; + while (length < max_chars && wide[length] != L'\0') { + ++length; + } + if (length > static_cast(std::numeric_limits::max())) { + GlobalUnlock(handle); + if (reason) { + *reason = "CF_UNICODETEXT exceeded supported length"; + } + item.type = LI_CLIPBOARD_ITEM_TYPE_NONE; + return true; + } + const int utf8_length = WideCharToMultiByte(CP_UTF8, + 0, + wide, + static_cast(length), + nullptr, + 0, + nullptr, + nullptr); + if ((length != 0 && utf8_length <= 0) || + static_cast(utf8_length) > max_text_clipboard_bytes) { + GlobalUnlock(handle); + if (reason) { + *reason = "CF_UNICODETEXT exceeded clipboard text size limit"; + } + item.type = LI_CLIPBOARD_ITEM_TYPE_NONE; + return true; + } + + std::wstring text(wide, length); + GlobalUnlock(handle); + + const std::string utf8 = normalize_newlines_to_lf(wide_to_utf8(text)); + item.type = LI_CLIPBOARD_ITEM_TYPE_TEXT; + item.mime_type = "text/plain;charset=utf-8"; + item.name.clear(); + item.data.assign(utf8.begin(), utf8.end()); + item.content_hash = compute_item_hash(item.type, item.data); + return true; + } + + bool + read_image_item(item_t &item, std::string *reason) { + const UINT png_format = RegisterClipboardFormatW(L"PNG"); + HANDLE handle = png_format != 0 ? GetClipboardData(png_format) : nullptr; + std::vector png_bytes; + + if (handle != nullptr && read_hglobal_bytes(handle, png_bytes) && !png_bytes.empty()) { + if (png_bytes.size() > image_size_limit) { + if (reason) { + *reason = "PNG clipboard item exceeded image size limit"; + } + item.type = LI_CLIPBOARD_ITEM_TYPE_NONE; + return true; + } + std::size_t pixel_bytes = 0; + if (!png_decoded_size_valid(png_bytes, pixel_bytes)) { + if (reason) { + *reason = "PNG clipboard item exceeded decoded image size limit"; + } + item.type = LI_CLIPBOARD_ITEM_TYPE_NONE; + return true; + } + + item.type = LI_CLIPBOARD_ITEM_TYPE_IMAGE; + item.mime_type = "image/png"; + item.name.clear(); + item.data = std::move(png_bytes); + item.content_hash = compute_item_hash(item.type, item.data); + return true; + } + + handle = GetClipboardData(CF_DIBV5); + if (handle == nullptr) { + handle = GetClipboardData(CF_DIB); + } + + if (handle == nullptr) { + if (reason) { + *reason = "No PNG/DIB clipboard image available"; + } + return false; + } + + if (!decode_dib_to_png(handle, png_bytes)) { + if (reason) { + *reason = "Failed to convert DIB clipboard image to PNG"; + } + return false; + } + + if (png_bytes.size() > image_size_limit) { + if (reason) { + *reason = "DIB clipboard image exceeded image size limit after PNG normalization"; + } + item.type = LI_CLIPBOARD_ITEM_TYPE_NONE; + return true; + } + + item.type = LI_CLIPBOARD_ITEM_TYPE_IMAGE; + item.mime_type = "image/png"; + item.name.clear(); + item.data = std::move(png_bytes); + item.content_hash = compute_item_hash(item.type, item.data); + return true; + } + + bool + write_unicode_text(const item_t &item, std::string *reason) { + if (item.data.size() > max_text_clipboard_bytes || + item.data.size() > static_cast(std::numeric_limits::max())) { + if (reason) { + *reason = "Text clipboard payload exceeded size limit"; + } + return false; + } + + const std::wstring text = normalize_newlines_to_crlf(std::string_view { + reinterpret_cast(item.data.data()), + item.data.size(), + }); + const std::size_t bytes = (text.size() + 1) * sizeof(wchar_t); + HGLOBAL mem = GlobalAlloc(GMEM_MOVEABLE, bytes); + if (mem == nullptr) { + if (reason) { + *reason = "GlobalAlloc failed for CF_UNICODETEXT"; + } + return false; + } + + void *locked = GlobalLock(mem); + if (locked == nullptr) { + GlobalFree(mem); + if (reason) { + *reason = "GlobalLock failed for CF_UNICODETEXT"; + } + return false; + } + + std::memcpy(locked, text.c_str(), bytes); + GlobalUnlock(mem); + + if (!EmptyClipboard()) { + GlobalFree(mem); + if (reason) { + *reason = "EmptyClipboard failed"; + } + return false; + } + + if (SetClipboardData(CF_UNICODETEXT, mem) == nullptr) { + GlobalFree(mem); + if (reason) { + *reason = "SetClipboardData(CF_UNICODETEXT) failed"; + } + return false; + } + + return true; + } + + bool + write_png_image(const item_t &item, std::string *reason) { + if (item.data.size() > image_size_limit) { + if (reason) { + *reason = "PNG clipboard payload exceeded encoded image size limit"; + } + return false; + } + std::size_t expected_pixel_bytes = 0; + if (!png_decoded_size_valid(item.data, expected_pixel_bytes)) { + if (reason) { + *reason = "PNG clipboard payload exceeded decoded image size limit"; + } + return false; + } + + int width = 0; + int height = 0; + int components = 0; + unsigned char *rgba = + stbi_load_from_memory(item.data.data(), + static_cast(item.data.size()), + &width, + &height, + &components, + 4); + if (rgba == nullptr || width <= 0 || height <= 0) { + if (reason) { + *reason = "Failed to decode PNG clipboard payload"; + } + if (rgba != nullptr) { + stbi_image_free(rgba); + } + return false; + } + + std::size_t pixel_bytes = 0; + std::size_t dib_size = 0; + if (!decoded_image_size_valid(width, height, 4, pixel_bytes) || + !checked_add(sizeof(BITMAPV5HEADER), pixel_bytes, dib_size)) { + stbi_image_free(rgba); + if (reason) { + *reason = "PNG clipboard payload exceeded decoded image size limit"; + } + return false; + } + + HGLOBAL mem = GlobalAlloc(GMEM_MOVEABLE, dib_size); + if (mem == nullptr) { + stbi_image_free(rgba); + if (reason) { + *reason = "GlobalAlloc failed for CF_DIBV5"; + } + return false; + } + + auto *header = static_cast(GlobalLock(mem)); + if (header == nullptr) { + GlobalFree(mem); + stbi_image_free(rgba); + if (reason) { + *reason = "GlobalLock failed for CF_DIBV5"; + } + return false; + } + + std::memset(header, 0, sizeof(BITMAPV5HEADER)); + header->bV5Size = sizeof(BITMAPV5HEADER); + header->bV5Width = width; + header->bV5Height = -height; + header->bV5Planes = 1; + header->bV5BitCount = 32; + header->bV5Compression = BI_BITFIELDS; + header->bV5RedMask = 0x00FF0000; + header->bV5GreenMask = 0x0000FF00; + header->bV5BlueMask = 0x000000FF; + header->bV5AlphaMask = 0xFF000000; + header->bV5CSType = LCS_sRGB; + header->bV5SizeImage = static_cast(pixel_bytes); + + auto *pixels = reinterpret_cast(header + 1); + for (std::size_t i = 0; i < pixel_bytes; i += 4) { + pixels[i + 0] = rgba[i + 2]; + pixels[i + 1] = rgba[i + 1]; + pixels[i + 2] = rgba[i + 0]; + pixels[i + 3] = rgba[i + 3]; + } + + GlobalUnlock(mem); + stbi_image_free(rgba); + + if (!EmptyClipboard()) { + GlobalFree(mem); + if (reason) { + *reason = "EmptyClipboard failed"; + } + return false; + } + + if (SetClipboardData(CF_DIBV5, mem) == nullptr) { + GlobalFree(mem); + if (reason) { + *reason = "SetClipboardData(CF_DIBV5) failed"; + } + return false; + } + + const UINT png_format = RegisterClipboardFormatW(L"PNG"); + if (png_format != 0 && !write_hglobal_bytes(png_format, item.data)) { + BOOST_LOG(debug) << "Failed to set registered PNG clipboard format"; + } + + return true; + } + + } // namespace + + bool + is_backend_available() { + return true; + } + + std::uint32_t + supported_capabilities() { + if (!is_backend_available() || !config::input.clipboard_sync) { + return 0; + } + + return platform_caps::clipboard_text | + platform_caps::clipboard_image; + } + + std::uint32_t + current_sequence_number() { + return GetClipboardSequenceNumber(); + } + + bool + read_current_item(item_t &item, std::string *reason) { + item = {}; + + clipboard_guard_t clipboard; + if (!clipboard) { + if (reason) { + *reason = "OpenClipboard failed"; + } + return false; + } + + const UINT png_format = RegisterClipboardFormatW(L"PNG"); + if ((png_format != 0 && IsClipboardFormatAvailable(png_format)) || + IsClipboardFormatAvailable(CF_DIBV5) || + IsClipboardFormatAvailable(CF_DIB)) { + return read_image_item(item, reason); + } + + if (IsClipboardFormatAvailable(CF_UNICODETEXT)) { + return read_text_item(item, reason); + } + + if (reason) { + *reason = "Clipboard did not contain a supported item"; + } + item.type = LI_CLIPBOARD_ITEM_TYPE_NONE; + return true; + } + + bool + write_item(const item_t &item, std::string *reason) { + clipboard_guard_t clipboard { clipboard_open_mode::write }; + if (!clipboard) { + if (reason) { + *reason = "OpenClipboard failed"; + } + return false; + } + + switch (item.type) { + case LI_CLIPBOARD_ITEM_TYPE_TEXT: + return write_unicode_text(item, reason); + case LI_CLIPBOARD_ITEM_TYPE_IMAGE: + return write_png_image(item, reason); + case LI_CLIPBOARD_ITEM_TYPE_NONE: + if (!EmptyClipboard()) { + if (reason) { + *reason = "EmptyClipboard failed"; + } + return false; + } + return true; + default: + if (reason) { + *reason = "Unsupported clipboard item type"; + } + return false; + } + } +} // namespace platf::clipboard diff --git a/src/platform/windows/clipboard.h b/src/platform/windows/clipboard.h new file mode 100644 index 00000000000..98a4853ebf8 --- /dev/null +++ b/src/platform/windows/clipboard.h @@ -0,0 +1,37 @@ +/** + * @file src/platform/windows/clipboard.h + * @brief Windows clipboard helpers for Sunshine clipboard sync. + */ +#pragma once + +#include +#include +#include +#include + +namespace platf::clipboard { + constexpr std::size_t image_size_limit = 4 * 1024 * 1024; + + struct item_t { + std::uint8_t type = 0; + std::vector data; + std::string mime_type; + std::string name; + std::uint64_t content_hash = 0; + }; + + bool + is_backend_available(); + + std::uint32_t + supported_capabilities(); + + std::uint32_t + current_sequence_number(); + + bool + read_current_item(item_t &item, std::string *reason = nullptr); + + bool + write_item(const item_t &item, std::string *reason = nullptr); +} // namespace platf::clipboard diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index 9f2908225ae..9f73c5ac8d5 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -13,6 +13,7 @@ #include #include "dsu_server.h" +#include "clipboard.h" #include "keylayout.h" #include "misc.h" #include "virtual_mouse.h" @@ -1931,6 +1932,8 @@ namespace platf { BOOST_LOG(warning) << "Touch input requires Windows 10 1809 or later"sv; } + caps |= clipboard::supported_capabilities(); + return caps; } } // namespace platf diff --git a/src/stream.cpp b/src/stream.cpp index ab21ece8328..1733bae0127 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,6 +4,8 @@ */ #include "process.h" +#include +#include #include #include #include @@ -48,6 +50,10 @@ extern "C" { #include "platform/common.h" +#ifdef _WIN32 + #include "platform/windows/clipboard.h" +#endif + #define IDX_START_A 0 #define IDX_START_B 1 #define IDX_INVALIDATE_REF_FRAMES 2 @@ -67,6 +73,7 @@ extern "C" { #define IDX_MIC_CONFIG 17 #define IDX_DYNAMIC_PARAM_CHANGE 18 // 统一动态参数调整消息类型(支持码率、分辨率等) #define IDX_RESOLUTION_CHANGE 19 // 分辨率变化通知 +#define IDX_CLIPBOARD 20 // Clipboard sync (Sunshine protocol extension) static const short packetTypes[] = { 0x0305, // Start A @@ -89,6 +96,7 @@ static const short packetTypes[] = { 0x5505, // Microphone config (Sunshine protocol extension) 0x5506, // Dynamic parameter change (Sunshine protocol extension) - 统一动态参数调整 0x5507, // Resolution change (Sunshine protocol extension) - 分辨率变化通知 + SS_CLIPBOARD_PTYPE, // Clipboard sync (Sunshine protocol extension) }; namespace asio = boost::asio; @@ -501,6 +509,23 @@ namespace stream { platf::feedback_queue_t feedback_queue; safe::mail_raw_t::event_t hdr_queue; safe::mail_raw_t::event_t> resolution_change_queue; // width, height + struct { + bool bound { false }; + bool transfer_active { false }; + uint8_t item_type { LI_CLIPBOARD_ITEM_TYPE_NONE }; + uint8_t transfer_flags { 0 }; + std::uint64_t item_id { 0 }; + std::uint64_t content_hash { 0 }; + std::uint32_t total_length { 0 }; + std::uint32_t received_length { 0 }; + std::string mime_type; + std::string name; + std::vector data; + std::uint32_t last_host_sequence { 0 }; + std::uint64_t last_sent_hash { 0 }; + bool suppress_next_host_echo { false }; + std::uint64_t suppressed_host_hash { 0 }; + } clipboard; } control; std::uint32_t launch_session_id; @@ -583,6 +608,160 @@ namespace stream { return std::string_view { (char *) tagged_cipher.data(), packet_length + sizeof(control_encrypted_t) - sizeof(control_encrypted_t::seq) }; } + static inline std::string_view + encode_control(session_t *session, const std::string_view &plaintext, std::vector &tagged_cipher) { + if (session->config.controlProtocolType != 13) { + return plaintext; + } + + const auto minimum_size = + sizeof(control_encrypted_t) + + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + + crypto::cipher::tag_size; + if (tagged_cipher.size() < minimum_size) { + return {}; + } + + auto seq = session->control.seq++; + + auto &iv = session->control.outgoing_iv; + if (session->config.encryptionFlagsEnabled & SS_ENC_CONTROL_V2) { + iv.resize(12); + std::copy_n((uint8_t *) &seq, sizeof(seq), std::begin(iv)); + iv[10] = 'H'; + iv[11] = 'C'; + } + else { + iv.resize(16); + iv[0] = (std::uint8_t) seq; + } + + auto packet = (control_encrypted_p) tagged_cipher.data(); + auto bytes = session->control.cipher.encrypt(plaintext, packet->payload(), &iv); + if (bytes <= 0) { + BOOST_LOG(error) << "Couldn't encrypt control data"sv; + return {}; + } + + std::uint16_t packet_length = bytes + crypto::cipher::tag_size + sizeof(control_encrypted_t::seq); + packet->encryptedHeaderType = util::endian::little(0x0001); + packet->length = util::endian::little(packet_length); + packet->seq = util::endian::little(seq); + + return std::string_view { + (char *) tagged_cipher.data(), + packet_length + sizeof(control_encrypted_t) - sizeof(control_encrypted_t::seq), + }; + } + + namespace clipboard_payload { + std::vector + build_item_start(uint8_t transfer_flags, + uint8_t item_type, + std::uint64_t item_id, + std::uint64_t content_hash, + std::uint32_t total_length, + const std::string_view &mime_type, + const std::string_view &name) { + std::vector payload; + payload.reserve(1 + 1 + 1 + 1 + sizeof(std::uint64_t) + sizeof(std::uint64_t) + + sizeof(std::uint32_t) + sizeof(std::uint16_t) + sizeof(std::uint16_t) + + mime_type.size() + name.size()); + + auto append_bytes = [&payload](const auto &value) { + const auto *bytes = reinterpret_cast(&value); + payload.insert(payload.end(), bytes, bytes + sizeof(value)); + }; + + payload.push_back(LI_CLIPBOARD_MSG_ITEM_START); + payload.push_back(transfer_flags); + payload.push_back(item_type); + payload.push_back(0); + + const auto little_item_id = util::endian::little(item_id); + const auto little_content_hash = util::endian::little(content_hash); + const auto little_total_length = util::endian::little(total_length); + const auto little_mime_length = util::endian::little(static_cast(mime_type.size())); + const auto little_name_length = util::endian::little(static_cast(name.size())); + + append_bytes(little_item_id); + append_bytes(little_content_hash); + append_bytes(little_total_length); + append_bytes(little_mime_length); + append_bytes(little_name_length); + payload.insert(payload.end(), mime_type.begin(), mime_type.end()); + payload.insert(payload.end(), name.begin(), name.end()); + return payload; + } + + std::vector + build_item_chunk(std::uint64_t item_id, + std::uint32_t chunk_offset, + const std::string_view &chunk) { + std::vector payload; + payload.reserve(1 + 1 + sizeof(std::uint16_t) + sizeof(std::uint64_t) + sizeof(std::uint32_t) + chunk.size()); + + const auto little_chunk_length = util::endian::little(static_cast(chunk.size())); + const auto little_item_id = util::endian::little(item_id); + const auto little_chunk_offset = util::endian::little(chunk_offset); + + payload.push_back(LI_CLIPBOARD_MSG_ITEM_CHUNK); + payload.push_back(0); + payload.insert(payload.end(), + reinterpret_cast(&little_chunk_length), + reinterpret_cast(&little_chunk_length) + sizeof(little_chunk_length)); + payload.insert(payload.end(), + reinterpret_cast(&little_item_id), + reinterpret_cast(&little_item_id) + sizeof(little_item_id)); + payload.insert(payload.end(), + reinterpret_cast(&little_chunk_offset), + reinterpret_cast(&little_chunk_offset) + sizeof(little_chunk_offset)); + payload.insert(payload.end(), chunk.begin(), chunk.end()); + return payload; + } + + std::array + build_item_end(std::uint64_t item_id) { + std::array payload {}; + payload[0] = LI_CLIPBOARD_MSG_ITEM_END; + const auto little_item_id = util::endian::little(item_id); + std::memcpy(payload.data() + 1, &little_item_id, sizeof(little_item_id)); + return payload; + } + } // namespace clipboard_payload + + constexpr std::uint32_t max_clipboard_text_size = 1U * 1024U * 1024U; + + bool + clipboard_transfer_length_valid(uint8_t item_type, std::uint32_t total_length) { + switch (item_type) { + case LI_CLIPBOARD_ITEM_TYPE_NONE: + return total_length == 0; + case LI_CLIPBOARD_ITEM_TYPE_IMAGE: + return total_length <= LI_CLIPBOARD_MAX_IMAGE_SIZE; + case LI_CLIPBOARD_ITEM_TYPE_TEXT: + return total_length <= max_clipboard_text_size; + default: + return false; + } + } + + bool + clipboard_transfer_chunk_next_length(std::uint32_t received_length, + std::uint32_t total_length, + std::uint32_t chunk_offset, + std::uint16_t chunk_length, + std::uint32_t &next_received_length) { + if (chunk_offset != received_length || + chunk_offset > total_length || + chunk_length > total_length - chunk_offset) { + return false; + } + + next_received_length = received_length + static_cast(chunk_length); + return true; + } + /** * @brief 确保麦克风 socket 处于打开状态。 * 如果 socket 已关闭(上次会话结束时被关闭),则重新 open + bind。 @@ -1188,8 +1367,243 @@ namespace stream { return 0; } + int + send_clipboard_payload(session_t *session, const std::string_view &clipboard_payload) { + constexpr std::size_t max_clipboard_control_payload = 0xFFFFu; + + if (!session->control.peer) { + BOOST_LOG(warning) << "Couldn't send clipboard payload, still waiting for PING from Moonlight"sv; + return -1; + } + if (clipboard_payload.size() > max_clipboard_control_payload) { + BOOST_LOG(error) << "Clipboard control payload too large: " << clipboard_payload.size(); + return -1; + } + + std::vector plaintext(sizeof(control_header_v2) + clipboard_payload.size()); + auto *header = reinterpret_cast(plaintext.data()); + header->type = packetTypes[IDX_CLIPBOARD]; + header->payloadLength = static_cast(clipboard_payload.size()); + if (!clipboard_payload.empty()) { + std::memcpy(header->payload(), clipboard_payload.data(), clipboard_payload.size()); + } + + std::vector encrypted_payload( + sizeof(control_encrypted_t) + + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + + crypto::cipher::tag_size); + + auto payload = encode_control(session, + std::string_view { + reinterpret_cast(plaintext.data()), + plaintext.size(), + }, + encrypted_payload); + if (payload.empty()) { + BOOST_LOG(error) << "Couldn't encode clipboard control payload"; + return -1; + } + + if (session->broadcast_ref->control_server.send(payload, session->control.peer)) { + TUPLE_2D(port, addr, platf::from_sockaddr_ex((sockaddr *) &session->control.peer->address.address)); + BOOST_LOG(warning) << "Couldn't send clipboard payload to ["sv << addr << ':' << port << ']'; + return -1; + } + + return 0; + } + + int + send_small_clipboard_payload(session_t *session, const std::string_view &clipboard_payload) { + constexpr std::size_t max_clipboard_plaintext_payload = 128; + if (clipboard_payload.size() > max_clipboard_plaintext_payload) { + BOOST_LOG(error) << "Clipboard payload too large for small-payload helper: " << clipboard_payload.size(); + return -1; + } + return send_clipboard_payload(session, clipboard_payload); + } + + int + send_clipboard_item(session_t *session, + uint8_t transfer_flags, + uint8_t item_type, + const std::string_view &mime_type, + const std::string_view &name, + const std::vector &data, + std::uint64_t content_hash) { + const auto item_id = static_cast( + std::chrono::steady_clock::now().time_since_epoch().count()); + + const auto start_payload = clipboard_payload::build_item_start(transfer_flags, + item_type, + item_id, + content_hash, + static_cast(data.size()), + mime_type, + name); + if (send_clipboard_payload(session, + std::string_view { + reinterpret_cast(start_payload.data()), + start_payload.size(), + }) != 0) { + return -1; + } + + for (std::size_t offset = 0; offset < data.size(); offset += LI_CLIPBOARD_MAX_CHUNK_SIZE) { + const auto chunk_length = std::min(LI_CLIPBOARD_MAX_CHUNK_SIZE, data.size() - offset); + const auto chunk_payload = clipboard_payload::build_item_chunk(item_id, + static_cast(offset), + std::string_view { + reinterpret_cast(data.data() + offset), + chunk_length, + }); + if (send_clipboard_payload(session, + std::string_view { + reinterpret_cast(chunk_payload.data()), + chunk_payload.size(), + }) != 0) { + return -1; + } + } + + const auto end_payload = clipboard_payload::build_item_end(item_id); + return send_small_clipboard_payload(session, + std::string_view { + reinterpret_cast(end_payload.data()), + end_payload.size(), + }); + } + + int + send_empty_clipboard_snapshot(session_t *session) { + return send_clipboard_item(session, + LI_CLIPBOARD_TRANSFER_FLAG_SNAPSHOT, + LI_CLIPBOARD_ITEM_TYPE_NONE, + {}, + {}, + {}, + 0); + } + void controlBroadcastThread(control_server_t *server) { + auto reset_clipboard_transfer = [](session_t *session) { + session->control.clipboard.transfer_active = false; + session->control.clipboard.item_type = LI_CLIPBOARD_ITEM_TYPE_NONE; + session->control.clipboard.transfer_flags = 0; + session->control.clipboard.item_id = 0; + session->control.clipboard.content_hash = 0; + session->control.clipboard.total_length = 0; + session->control.clipboard.received_length = 0; + session->control.clipboard.mime_type.clear(); + session->control.clipboard.name.clear(); + std::vector().swap(session->control.clipboard.data); + }; + +#ifdef _WIN32 + auto send_host_clipboard_snapshot = [](session_t *session, + uint8_t transfer_flags, + bool update_sequence_tracking) { + platf::clipboard::item_t item; + std::string reason; + const auto sequence = platf::clipboard::current_sequence_number(); + + if (!platf::clipboard::read_current_item(item, &reason)) { + BOOST_LOG(warning) << "Failed to read Windows clipboard: " << reason; + return -1; + } + + if (item.type == LI_CLIPBOARD_ITEM_TYPE_NONE) { + if (update_sequence_tracking) { + session->control.clipboard.last_host_sequence = sequence; + session->control.clipboard.last_sent_hash = 0; + } + + if ((transfer_flags & LI_CLIPBOARD_TRANSFER_FLAG_SNAPSHOT) != 0) { + return send_empty_clipboard_snapshot(session); + } + + if (reason.find("size limit") != std::string::npos) { + BOOST_LOG(info) << "Skipping clipboard update: " << reason; + } + else { + BOOST_LOG(debug) << "Skipping empty/unsupported clipboard update: " << reason; + } + return 0; + } + + if (session->control.clipboard.suppress_next_host_echo && + session->control.clipboard.suppressed_host_hash == item.content_hash) { + session->control.clipboard.suppress_next_host_echo = false; + session->control.clipboard.suppressed_host_hash = 0; + if (update_sequence_tracking) { + session->control.clipboard.last_host_sequence = sequence; + session->control.clipboard.last_sent_hash = item.content_hash; + } + BOOST_LOG(debug) << "Suppressed echoed host clipboard item hash=" << item.content_hash; + return 0; + } + + if (send_clipboard_item(session, + transfer_flags, + item.type, + item.mime_type, + item.name, + item.data, + item.content_hash) != 0) { + BOOST_LOG(warning) << "Failed to send host clipboard item to client " << session->client_name; + return -1; + } + + if (update_sequence_tracking) { + session->control.clipboard.last_host_sequence = sequence; + session->control.clipboard.last_sent_hash = item.content_hash; + } + + BOOST_LOG(info) << "Sent host clipboard item to client " << session->client_name + << " type=" << static_cast(item.type) + << " length=" << item.data.size() + << " flags=0x" << std::hex << static_cast(transfer_flags) << std::dec; + return 0; + }; + + auto maybe_send_host_clipboard_update = [send_host_clipboard_snapshot](session_t *session) { + if (!config::input.clipboard_sync || + !session->control.clipboard.bound || + !platf::clipboard::is_backend_available()) { + return; + } + + const auto host_sequence = platf::clipboard::current_sequence_number(); + if (host_sequence != 0 && + host_sequence != session->control.clipboard.last_host_sequence) { + if (send_host_clipboard_snapshot(session, 0, true) != 0) { + BOOST_LOG(warning) << "Failed to send clipboard change update to client " << session->client_name; + } + } + }; +#else + auto maybe_send_host_clipboard_update = [](session_t *) {}; +#endif + + auto read_u16_le = [](const char *ptr) -> std::uint16_t { + std::uint16_t value {}; + std::memcpy(&value, ptr, sizeof(value)); + return util::endian::little(value); + }; + + auto read_u32_le = [](const char *ptr) -> std::uint32_t { + std::uint32_t value {}; + std::memcpy(&value, ptr, sizeof(value)); + return util::endian::little(value); + }; + + auto read_u64_le = [](const char *ptr) -> std::uint64_t { + std::uint64_t value {}; + std::memcpy(&value, ptr, sizeof(value)); + return util::endian::little(value); + }; + server->map(packetTypes[IDX_PERIODIC_PING], [](session_t *session, const std::string_view &payload) { BOOST_LOG(verbose) << "type [IDX_PERIODIC_PING]"sv; }); @@ -1202,6 +1616,269 @@ namespace stream { BOOST_LOG(debug) << "type [IDX_START_B]"sv; }); + server->map(packetTypes[IDX_CLIPBOARD], [&, reset_clipboard_transfer, read_u16_le, read_u32_le, read_u64_le](session_t *session, const std::string_view &payload) { + BOOST_LOG(debug) << "type [IDX_CLIPBOARD]"sv; + +#ifdef _WIN32 + if (!config::input.clipboard_sync || !platf::clipboard::is_backend_available()) { + session->control.clipboard.bound = false; + reset_clipboard_transfer(session); + BOOST_LOG(debug) << "Ignoring clipboard control packet because clipboard sync is disabled"sv; + return; + } +#endif + + if (payload.empty()) { + BOOST_LOG(warning) << "Clipboard payload was empty"; + return; + } + + const auto *bytes = reinterpret_cast(payload.data()); + std::size_t pos = 1; + const auto kind = bytes[0]; + + switch (kind) { + case LI_CLIPBOARD_MSG_BIND: { + auto sessions_lock = server->_sessions.lock(); + for (auto *other: *server->_sessions) { + if (other != session) { + other->control.clipboard.bound = false; + other->control.clipboard.last_host_sequence = 0; + other->control.clipboard.last_sent_hash = 0; + other->control.clipboard.suppress_next_host_echo = false; + other->control.clipboard.suppressed_host_hash = 0; + reset_clipboard_transfer(other); + } + } + reset_clipboard_transfer(session); + session->control.clipboard.bound = true; + session->control.clipboard.last_host_sequence = 0; + session->control.clipboard.last_sent_hash = 0; + session->control.clipboard.suppress_next_host_echo = false; + session->control.clipboard.suppressed_host_hash = 0; + BOOST_LOG(info) << "Clipboard session bound for client " << session->client_name; + break; + } + case LI_CLIPBOARD_MSG_UNBIND: + session->control.clipboard.bound = false; + session->control.clipboard.last_host_sequence = 0; + session->control.clipboard.last_sent_hash = 0; + session->control.clipboard.suppress_next_host_echo = false; + session->control.clipboard.suppressed_host_hash = 0; + reset_clipboard_transfer(session); + BOOST_LOG(info) << "Clipboard session unbound for client " << session->client_name; + break; + case LI_CLIPBOARD_MSG_SNAPSHOT_REQUEST: + if (!session->control.clipboard.bound) { + BOOST_LOG(warning) << "Ignoring clipboard snapshot request from unbound client " << session->client_name; + break; + } + BOOST_LOG(info) << "Clipboard snapshot requested by client " << session->client_name; +#ifdef _WIN32 + if (platf::clipboard::is_backend_available()) { + if (send_host_clipboard_snapshot(session, LI_CLIPBOARD_TRANSFER_FLAG_SNAPSHOT, true) != 0) { + BOOST_LOG(warning) << "Failed to send clipboard snapshot to client " << session->client_name; + } + break; + } +#endif + if (send_empty_clipboard_snapshot(session) != 0) { + BOOST_LOG(warning) << "Failed to send empty clipboard snapshot to client " << session->client_name; + } + break; + case LI_CLIPBOARD_MSG_ITEM_START: { + constexpr std::size_t header_size = + 1 + 1 + 1 + 1 + sizeof(std::uint64_t) + sizeof(std::uint64_t) + + sizeof(std::uint32_t) + sizeof(std::uint16_t) + sizeof(std::uint16_t); + if (!session->control.clipboard.bound) { + reset_clipboard_transfer(session); + BOOST_LOG(warning) << "Ignoring clipboard item start from unbound client " << session->client_name; + return; + } + if (payload.size() < header_size) { + BOOST_LOG(warning) << "Clipboard ITEM_START was truncated"; + return; + } + + const auto transfer_flags = bytes[pos++]; + const auto item_type = bytes[pos++]; + pos++; // reserved + const auto item_id = read_u64_le(payload.data() + pos); + pos += sizeof(std::uint64_t); + const auto content_hash = read_u64_le(payload.data() + pos); + pos += sizeof(std::uint64_t); + const auto total_length = read_u32_le(payload.data() + pos); + pos += sizeof(std::uint32_t); + const auto mime_length = read_u16_le(payload.data() + pos); + pos += sizeof(std::uint16_t); + const auto name_length = read_u16_le(payload.data() + pos); + pos += sizeof(std::uint16_t); + + if (payload.size() < pos + mime_length + name_length) { + BOOST_LOG(warning) << "Clipboard ITEM_START metadata exceeded payload size"; + return; + } + + if (!clipboard_transfer_length_valid(item_type, total_length)) { + BOOST_LOG(warning) << "Clipboard ITEM_START exceeded size limits for client " + << session->client_name + << " type=" << static_cast(item_type) + << " length=" << total_length; + return; + } + + reset_clipboard_transfer(session); + session->control.clipboard.transfer_active = true; + session->control.clipboard.transfer_flags = transfer_flags; + session->control.clipboard.item_type = item_type; + session->control.clipboard.item_id = item_id; + session->control.clipboard.content_hash = content_hash; + session->control.clipboard.total_length = total_length; + session->control.clipboard.received_length = 0; + session->control.clipboard.mime_type.assign(payload.substr(pos, mime_length)); + pos += mime_length; + session->control.clipboard.name.assign(payload.substr(pos, name_length)); + pos += name_length; + session->control.clipboard.data.assign(total_length, 0); + BOOST_LOG(info) << "Clipboard ITEM_START from client " << session->client_name + << " type=" << static_cast(item_type) + << " length=" << total_length; + break; + } + case LI_CLIPBOARD_MSG_ITEM_CHUNK: { + constexpr std::size_t header_size = + 1 + 1 + sizeof(std::uint16_t) + sizeof(std::uint64_t) + sizeof(std::uint32_t); + if (!session->control.clipboard.bound) { + reset_clipboard_transfer(session); + BOOST_LOG(warning) << "Ignoring clipboard item chunk from unbound client " << session->client_name; + return; + } + if (payload.size() < header_size) { + BOOST_LOG(warning) << "Clipboard ITEM_CHUNK was truncated"; + return; + } + + pos++; // reserved + const auto chunk_length = read_u16_le(payload.data() + pos); + pos += sizeof(std::uint16_t); + const auto item_id = read_u64_le(payload.data() + pos); + pos += sizeof(std::uint64_t); + const auto chunk_offset = read_u32_le(payload.data() + pos); + pos += sizeof(std::uint32_t); + + if (!session->control.clipboard.transfer_active || + session->control.clipboard.item_id != item_id) { + BOOST_LOG(warning) << "Clipboard ITEM_CHUNK had no active transfer"; + return; + } + + if (payload.size() < pos + chunk_length) { + BOOST_LOG(warning) << "Clipboard ITEM_CHUNK bounds were invalid"; + reset_clipboard_transfer(session); + return; + } + + std::uint32_t next_received_length = 0; + if (!clipboard_transfer_chunk_next_length(session->control.clipboard.received_length, + session->control.clipboard.total_length, + chunk_offset, + chunk_length, + next_received_length)) { + BOOST_LOG(warning) << "Clipboard ITEM_CHUNK was out of order or invalid"; + reset_clipboard_transfer(session); + return; + } + + if (chunk_length != 0) { + std::memcpy(session->control.clipboard.data.data() + chunk_offset, + payload.data() + pos, + chunk_length); + } + session->control.clipboard.received_length = next_received_length; + break; + } + case LI_CLIPBOARD_MSG_ITEM_END: { + constexpr std::size_t header_size = 1 + sizeof(std::uint64_t); + if (!session->control.clipboard.bound) { + reset_clipboard_transfer(session); + BOOST_LOG(warning) << "Ignoring clipboard item end from unbound client " << session->client_name; + return; + } + if (payload.size() < header_size) { + BOOST_LOG(warning) << "Clipboard ITEM_END was truncated"; + return; + } + + const auto item_id = read_u64_le(payload.data() + pos); + if (!session->control.clipboard.transfer_active || + session->control.clipboard.item_id != item_id) { + BOOST_LOG(warning) << "Clipboard ITEM_END had no matching transfer"; + return; + } + + if (session->control.clipboard.received_length != + session->control.clipboard.total_length) { + BOOST_LOG(warning) << "Clipboard ITEM_END before transfer completion"; + reset_clipboard_transfer(session); + return; + } + + BOOST_LOG(info) << "Clipboard item fully received from client " + << session->client_name + << " type=" << static_cast(session->control.clipboard.item_type) + << " length=" << session->control.clipboard.total_length + << " mime=" << session->control.clipboard.mime_type + << " name=" << session->control.clipboard.name; +#ifdef _WIN32 + if (platf::clipboard::is_backend_available()) { + platf::clipboard::item_t item; + item.type = session->control.clipboard.item_type; + item.data = std::move(session->control.clipboard.data); + item.mime_type = std::move(session->control.clipboard.mime_type); + item.name = std::move(session->control.clipboard.name); + item.content_hash = session->control.clipboard.content_hash; + + std::string reason; + if (platf::clipboard::write_item(item, &reason)) { + session->control.clipboard.suppress_next_host_echo = item.content_hash != 0; + session->control.clipboard.suppressed_host_hash = item.content_hash; + BOOST_LOG(info) << "Applied client clipboard item to Windows clipboard" + << " type=" << static_cast(item.type) + << " length=" << item.data.size(); + } + else { + BOOST_LOG(warning) << "Failed to apply client clipboard item to Windows clipboard: " << reason; + } + } +#endif + reset_clipboard_transfer(session); + break; + } + case LI_CLIPBOARD_MSG_ITEM_CANCEL: { + constexpr std::size_t header_size = 1 + sizeof(std::uint64_t); + if (payload.size() < header_size) { + BOOST_LOG(warning) << "Clipboard ITEM_CANCEL was truncated"; + return; + } + + if (!session->control.clipboard.bound) { + reset_clipboard_transfer(session); + BOOST_LOG(warning) << "Ignoring clipboard item cancel from unbound client " << session->client_name; + return; + } + + const auto item_id = read_u64_le(payload.data() + pos); + if (item_id == 0 || session->control.clipboard.item_id == item_id) { + reset_clipboard_transfer(session); + } + break; + } + default: + BOOST_LOG(warning) << "Unknown clipboard control message kind: " << static_cast(kind); + break; + } + }); + server->map(packetTypes[IDX_LOSS_STATS], [&](session_t *session, const std::string_view &payload) { int32_t *stats = (int32_t *) payload.data(); auto count = stats[0]; @@ -1609,6 +2286,8 @@ namespace stream { send_resolution_change(session, resolution->first, resolution->second); } } + + maybe_send_host_clipboard_update(session); } ++pos; diff --git a/src_assets/common/assets/web/composables/useConfig.js b/src_assets/common/assets/web/composables/useConfig.js index 789545cf0c4..bf8b929cd08 100644 --- a/src_assets/common/assets/web/composables/useConfig.js +++ b/src_assets/common/assets/web/composables/useConfig.js @@ -42,6 +42,7 @@ const DEFAULT_TABS = [ mouse: 'enabled', high_resolution_scrolling: 'enabled', native_pen_touch: 'enabled', + clipboard_sync: 'enabled', keybindings: '[0x10,0xA0,0x11,0xA2,0x12,0xA4]', }, }, diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 4d2e7e5a488..0a86436a033 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -243,6 +243,19 @@ const vmouseStatusLabel = computed(() => {
{{ $t('config.key_rightalt_to_key_win_desc') }}
+ +
+
+
+ + +
+
{{ $t('config.clipboard_sync_desc') }}
+
+
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 b4a91533de2..cc00f936227 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -225,6 +225,8 @@ "back_button_timeout_desc": "If the Back/Select button is held down for the specified number of milliseconds, a Home/Guide button press is emulated. If set to a value < 0 (default), holding the Back/Select button will not emulate the Home/Guide button.", "bind_address": "Bind address (test feature)", "bind_address_desc": "Set the specific IP address Sunshine will bind to. If left blank, Sunshine will bind to all available addresses.", + "clipboard_sync": "Clipboard Sync", + "clipboard_sync_desc": "Enable bidirectional clipboard sync for compatible Moonlight clients. Currently supports text and single-image items.", "capture": "Force a Specific Capture Method", "capture_desc": "On automatic mode Sunshine will use the first one that works. NvFBC requires patched nvidia drivers.", "amd_capture_no_virtual_display": "AMD Display Capture does not support virtual display drivers (e.g. IddCx/VDD). If you are using a virtual display, please use WGC or Desktop Duplication API instead.", diff --git a/src_assets/common/assets/web/public/assets/locale/zh.json b/src_assets/common/assets/web/public/assets/locale/zh.json index d14735c7a66..8a587f0ebe7 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh.json +++ b/src_assets/common/assets/web/public/assets/locale/zh.json @@ -225,6 +225,8 @@ "back_button_timeout_desc": "按住\"返回/选择\"按钮达到指定毫秒数后,将模拟按下\"主页/导航\"按钮。若设置值小于0(默认值),则不会触发此功能。", "bind_address": "绑定地址(测试功能)", "bind_address_desc": "设置 Sunshine 绑定的 IP 地址。如果留空,Sunshine 将绑定到所有可用接口(0.0.0.0 用于 IPv4 或 :: 用于 IPv6)。", + "clipboard_sync": "剪贴板同步", + "clipboard_sync_desc": "为兼容的 Moonlight 客户端启用双向剪贴板同步。当前支持文本与单张图片。", "capture": "强制指定捕获方法", "capture_desc": "自动模式下,Sunshine将优先使用第一个可正常工作的捕获模式。NvFBC需要已打补丁的NVIDIA驱动程序。", "amd_capture_no_virtual_display": "AMD Display Capture 不支持虚拟显示驱动(如 IddCx/VDD)。如果你正在使用虚拟显示器,请改用 WGC 或 Desktop Duplication API。", diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index b3e19bcc2df..ad8dc293418 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -11,6 +11,16 @@ namespace stream { std::vector concat_and_insert(uint64_t insert_size, uint64_t slice_size, const std::string_view &data1, const std::string_view &data2); + + bool + clipboard_transfer_length_valid(uint8_t item_type, std::uint32_t total_length); + + bool + clipboard_transfer_chunk_next_length(std::uint32_t received_length, + std::uint32_t total_length, + std::uint32_t chunk_offset, + std::uint16_t chunk_length, + std::uint32_t &next_received_length); } #include "../tests_common.h" @@ -38,3 +48,30 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) { auto expected = std::vector { 0, 'a', 0, 'b', 0, 'c', 0, 'd', 0, 'e' }; ASSERT_EQ(res, expected); } + +TEST(ClipboardTransferValidationTests, RejectsOversizedTextAndUnknownTypes) { + constexpr std::uint32_t one_megabyte = 1024U * 1024U; + + EXPECT_TRUE(stream::clipboard_transfer_length_valid(LI_CLIPBOARD_ITEM_TYPE_NONE, 0)); + EXPECT_FALSE(stream::clipboard_transfer_length_valid(LI_CLIPBOARD_ITEM_TYPE_NONE, 1)); + EXPECT_TRUE(stream::clipboard_transfer_length_valid(LI_CLIPBOARD_ITEM_TYPE_IMAGE, LI_CLIPBOARD_MAX_IMAGE_SIZE)); + EXPECT_FALSE(stream::clipboard_transfer_length_valid(LI_CLIPBOARD_ITEM_TYPE_IMAGE, LI_CLIPBOARD_MAX_IMAGE_SIZE + 1)); + EXPECT_TRUE(stream::clipboard_transfer_length_valid(LI_CLIPBOARD_ITEM_TYPE_TEXT, one_megabyte)); + EXPECT_FALSE(stream::clipboard_transfer_length_valid(LI_CLIPBOARD_ITEM_TYPE_TEXT, one_megabyte + 1)); + EXPECT_FALSE(stream::clipboard_transfer_length_valid(0xFE, 16)); +} + +TEST(ClipboardTransferValidationTests, AcceptsSequentialChunksOnly) { + std::uint32_t next_received_length = 0; + + EXPECT_TRUE(stream::clipboard_transfer_chunk_next_length(0, 10, 0, 4, next_received_length)); + EXPECT_EQ(next_received_length, 4U); + EXPECT_TRUE(stream::clipboard_transfer_chunk_next_length(4, 10, 4, 6, next_received_length)); + EXPECT_EQ(next_received_length, 10U); + EXPECT_TRUE(stream::clipboard_transfer_chunk_next_length(10, 10, 10, 0, next_received_length)); + EXPECT_EQ(next_received_length, 10U); + + EXPECT_FALSE(stream::clipboard_transfer_chunk_next_length(4, 10, 6, 2, next_received_length)); + EXPECT_FALSE(stream::clipboard_transfer_chunk_next_length(4, 10, 2, 2, next_received_length)); + EXPECT_FALSE(stream::clipboard_transfer_chunk_next_length(8, 10, 8, 3, next_received_length)); +} diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index 7ed14144d1a..6d5f3341449 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit 7ed14144d1aef1d6d234ea98b17eedc083a5ac36 +Subproject commit 6d5f334144976d098063606e6de2065a2c492c62