From 5e9036ea889b98dec7b56bbd22f09c666cbf61fc Mon Sep 17 00:00:00 2001 From: skyhua Date: Thu, 23 Apr 2026 03:03:05 +0800 Subject: [PATCH 1/6] feat(clipboard): add host clipboard sync Add the Windows-side clipboard sync path for text and single-image items, and expose a default-enabled clipboard toggle in the 47990 Web UI Input settings. --- cmake/compile_definitions/windows.cmake | 4 +- src/config.cpp | 5 +- src/config.h | 3 +- src/platform/common.h | 4 +- src/platform/windows/clipboard.cpp | 627 +++++++++++++++++ src/platform/windows/clipboard.h | 36 + src/platform/windows/input.cpp | 3 + src/stream.cpp | 641 ++++++++++++++++++ .../assets/web/composables/useConfig.js | 1 + .../common/assets/web/configs/tabs/Inputs.vue | 13 + .../assets/web/public/assets/locale/en.json | 2 + .../assets/web/public/assets/locale/zh.json | 2 + third-party/moonlight-common-c | 2 +- 13 files changed, 1338 insertions(+), 5 deletions(-) create mode 100644 src/platform/windows/clipboard.cpp create mode 100644 src/platform/windows/clipboard.h 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..e250b25504b 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -296,6 +296,8 @@ namespace platf { 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..4b5dff0ec5d --- /dev/null +++ b/src/platform/windows/clipboard.cpp @@ -0,0 +1,627 @@ +/** + * @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 + +extern "C" { +#include +} + +#include "clipboard.h" + +#include "src/platform/common.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; + + struct clipboard_guard_t { + bool open = false; + + clipboard_guard_t() { + for (int attempt = 0; attempt < clipboard_retry_count; ++attempt) { + if (OpenClipboard(nullptr)) { + 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 + 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; + } + + const int width = info.biWidth; + const int height = info.biHeight > 0 ? info.biHeight : -info.biHeight; + 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) { + pixel_offset += 3 * sizeof(DWORD); + } + + const auto stride = static_cast(((width * bit_count + 31) / 32) * 4); + if (pixel_offset + stride * static_cast(height) > total_size) { + GlobalUnlock(dib_handle); + return false; + } + + const auto *pixels = reinterpret_cast(header) + pixel_offset; + std::vector rgba(static_cast(width) * static_cast(height) * 4); + 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; + } + + std::wstring text(wide); + 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; + } + + 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) { + 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 (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) { + 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; + } + + const std::size_t pixel_bytes = + static_cast(width) * static_cast(height) * 4; + const std::size_t dib_size = sizeof(BITMAPV5HEADER) + pixel_bytes; + 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 (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); + } + + 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; + if (!clipboard) { + if (reason) { + *reason = "OpenClipboard failed"; + } + return false; + } + + if (!EmptyClipboard()) { + if (reason) { + *reason = "EmptyClipboard 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: + 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..6827d63b7ec --- /dev/null +++ b/src/platform/windows/clipboard.h @@ -0,0 +1,36 @@ +/** + * @file src/platform/windows/clipboard.h + * @brief Windows clipboard helpers for Sunshine clipboard sync. + */ +#pragma once + +#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..ba2f5fa0322 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,141 @@ 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 + + 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: + default: + return true; + } + } + /** * @brief 确保麦克风 socket 处于打开状态。 * 如果 socket 已关闭(上次会话结束时被关闭),则重新 open + bind。 @@ -1188,8 +1348,219 @@ namespace stream { return 0; } + int + send_clipboard_payload(session_t *session, const std::string_view &clipboard_payload) { + if (!session->control.peer) { + BOOST_LOG(warning) << "Couldn't send clipboard payload, still waiting for PING from Moonlight"sv; + 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(); + session->control.clipboard.data.clear(); + }; + +#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; + }; +#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 +1573,262 @@ 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 || + chunk_offset > session->control.clipboard.total_length || + chunk_length > session->control.clipboard.total_length - chunk_offset) { + BOOST_LOG(warning) << "Clipboard ITEM_CHUNK bounds were 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 = + std::max(session->control.clipboard.received_length, + chunk_offset + static_cast(chunk_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 = session->control.clipboard.data; + item.mime_type = session->control.clipboard.mime_type; + item.name = 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 +2236,20 @@ namespace stream { send_resolution_change(session, resolution->first, resolution->second); } } + +#ifdef _WIN32 + if (config::input.clipboard_sync && + session->control.clipboard.bound && + platf::clipboard::is_backend_available()) { + 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; + } + } + } +#endif } ++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..758717329a8 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 and is enabled by default for Foundation Sunshine workflows.", "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..e901248b93d 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 客户端启用双向剪贴板同步。当前支持文本与单张图片,并在 Foundation Sunshine 流程中默认开启。", "capture": "强制指定捕获方法", "capture_desc": "自动模式下,Sunshine将优先使用第一个可正常工作的捕获模式。NvFBC需要已打补丁的NVIDIA驱动程序。", "amd_capture_no_virtual_display": "AMD Display Capture 不支持虚拟显示驱动(如 IddCx/VDD)。如果你正在使用虚拟显示器,请改用 WGC 或 Desktop Duplication API。", 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 From 14b0061b0ff53b35bd0a8bf27d71354faa13aa59 Mon Sep 17 00:00:00 2001 From: skyhua Date: Thu, 23 Apr 2026 19:18:51 +0800 Subject: [PATCH 2/6] fix(clipboard): harden clipboard sync path Address the substantive review feedback for the new clipboard sync path without changing the product default. This tightens transfer validation, enforces sequential chunk delivery, adds Windows image size and overflow checks, releases reset transfer buffers, and trims the Web UI clipboard copy to match the current product wording. --- src/platform/common.h | 2 +- src/platform/windows/clipboard.cpp | 135 ++++++++++++++++-- src/platform/windows/clipboard.h | 1 + src/stream.cpp | 74 +++++++--- .../assets/web/public/assets/locale/en.json | 2 +- .../assets/web/public/assets/locale/zh.json | 2 +- tests/unit/test_stream.cpp | 29 ++++ 7 files changed, 207 insertions(+), 38 deletions(-) diff --git a/src/platform/common.h b/src/platform/common.h index e250b25504b..1d3e999938f 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -290,7 +290,7 @@ namespace platf { int width, height; }; - // These values must match Limelight-internal.h's SS_FF_* constants! + // These are Sunshine-specific capability flags for clipboard sync. namespace platform_caps { typedef uint32_t caps_t; diff --git a/src/platform/windows/clipboard.cpp b/src/platform/windows/clipboard.cpp index 4b5dff0ec5d..47c44918d37 100644 --- a/src/platform/windows/clipboard.cpp +++ b/src/platform/windows/clipboard.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ namespace platf::clipboard { 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; struct clipboard_guard_t { bool open = false; @@ -222,6 +224,67 @@ namespace platf::clipboard { 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 + 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, @@ -270,6 +333,10 @@ namespace platf::clipboard { 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 int height = info.biHeight > 0 ? info.biHeight : -info.biHeight; @@ -285,17 +352,27 @@ namespace platf::clipboard { std::size_t pixel_offset = info.biSize; if (compression == BI_BITFIELDS) { - pixel_offset += 3 * sizeof(DWORD); + if (!checked_add(pixel_offset, 3 * sizeof(DWORD), pixel_offset)) { + GlobalUnlock(dib_handle); + return false; + } } - const auto stride = static_cast(((width * bit_count + 31) / 32) * 4); - if (pixel_offset + stride * static_cast(height) > total_size) { + 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(static_cast(width) * static_cast(height) * 4); + std::vector rgba(rgba_bytes); bool has_non_zero_alpha = false; for (int y = 0; y < height; ++y) { @@ -445,6 +522,14 @@ namespace platf::clipboard { 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) { @@ -458,6 +543,13 @@ namespace platf::clipboard { 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; + } + int width = 0; int height = 0; int components = 0; @@ -478,9 +570,17 @@ namespace platf::clipboard { return false; } - const std::size_t pixel_bytes = - static_cast(width) * static_cast(height) * 4; - const std::size_t dib_size = sizeof(BITMAPV5HEADER) + pixel_bytes; + 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); @@ -525,6 +625,14 @@ namespace platf::clipboard { 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) { @@ -603,19 +711,18 @@ namespace platf::clipboard { return false; } - if (!EmptyClipboard()) { - if (reason) { - *reason = "EmptyClipboard 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) { diff --git a/src/platform/windows/clipboard.h b/src/platform/windows/clipboard.h index 6827d63b7ec..98a4853ebf8 100644 --- a/src/platform/windows/clipboard.h +++ b/src/platform/windows/clipboard.h @@ -4,6 +4,7 @@ */ #pragma once +#include #include #include #include diff --git a/src/stream.cpp b/src/stream.cpp index ba2f5fa0322..d150250cb0d 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -730,6 +730,8 @@ namespace stream { } } // 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) { @@ -738,11 +740,28 @@ namespace stream { 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 true; + 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。 @@ -1472,7 +1491,7 @@ namespace stream { session->control.clipboard.received_length = 0; session->control.clipboard.mime_type.clear(); session->control.clipboard.name.clear(); - session->control.clipboard.data.clear(); + std::vector().swap(session->control.clipboard.data); }; #ifdef _WIN32 @@ -1541,6 +1560,24 @@ namespace stream { << " 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 { @@ -1729,22 +1766,29 @@ namespace stream { return; } - if (payload.size() < pos + chunk_length || - chunk_offset > session->control.clipboard.total_length || - chunk_length > session->control.clipboard.total_length - chunk_offset) { + 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 = - std::max(session->control.clipboard.received_length, - chunk_offset + static_cast(chunk_length)); + session->control.clipboard.received_length = next_received_length; break; } case LI_CLIPBOARD_MSG_ITEM_END: { @@ -2237,19 +2281,7 @@ namespace stream { } } -#ifdef _WIN32 - if (config::input.clipboard_sync && - session->control.clipboard.bound && - platf::clipboard::is_backend_available()) { - 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; - } - } - } -#endif + maybe_send_host_clipboard_update(session); } ++pos; 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 758717329a8..cc00f936227 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -226,7 +226,7 @@ "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 and is enabled by default for Foundation Sunshine workflows.", + "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 e901248b93d..8a587f0ebe7 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh.json +++ b/src_assets/common/assets/web/public/assets/locale/zh.json @@ -226,7 +226,7 @@ "bind_address": "绑定地址(测试功能)", "bind_address_desc": "设置 Sunshine 绑定的 IP 地址。如果留空,Sunshine 将绑定到所有可用接口(0.0.0.0 用于 IPv4 或 :: 用于 IPv6)。", "clipboard_sync": "剪贴板同步", - "clipboard_sync_desc": "为兼容的 Moonlight 客户端启用双向剪贴板同步。当前支持文本与单张图片,并在 Foundation Sunshine 流程中默认开启。", + "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..c075a5ed579 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,22 @@ 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_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_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)); +} From 2b5d3d01162df235a0caed9900c50309ed6a3907 Mon Sep 17 00:00:00 2001 From: skyhua Date: Thu, 23 Apr 2026 20:03:05 +0800 Subject: [PATCH 3/6] fix(clipboard): tighten text and payload bounds Bound CF_UNICODETEXT reads by GlobalSize and reject oversized control payloads before serializing clipboard headers. --- src/platform/windows/clipboard.cpp | 9 ++++++++- src/stream.cpp | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/platform/windows/clipboard.cpp b/src/platform/windows/clipboard.cpp index 47c44918d37..69cc6674dbe 100644 --- a/src/platform/windows/clipboard.cpp +++ b/src/platform/windows/clipboard.cpp @@ -425,7 +425,14 @@ namespace platf::clipboard { return false; } - std::wstring text(wide); + 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; + } + + std::wstring text(wide, length); GlobalUnlock(handle); const std::string utf8 = normalize_newlines_to_lf(wide_to_utf8(text)); diff --git a/src/stream.cpp b/src/stream.cpp index d150250cb0d..b0f9b6cf64c 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1369,10 +1369,16 @@ namespace stream { 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()); From 53c999ad845f877efad7e5d1d7d971ef6ebb1e2d Mon Sep 17 00:00:00 2001 From: skyhua Date: Thu, 23 Apr 2026 20:31:41 +0800 Subject: [PATCH 4/6] fix(clipboard): harden clipboard payload guards Preflight decoded PNG sizes, bound CF_UNICODETEXT handling before conversion, avoid signed-height overflow in DIB parsing, move received clipboard buffers into the Windows backend, and extend the stream validation tests. --- src/platform/common.h | 2 +- src/platform/windows/clipboard.cpp | 71 +++++++++++++++++++++++++++++- src/stream.cpp | 6 +-- tests/unit/test_stream.cpp | 8 ++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/platform/common.h b/src/platform/common.h index 1d3e999938f..affea851e6a 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -290,7 +290,7 @@ namespace platf { int width, height; }; - // These are Sunshine-specific capability flags for clipboard sync. + // These are Sunshine-specific platform capability flags. namespace platform_caps { typedef uint32_t caps_t; diff --git a/src/platform/windows/clipboard.cpp b/src/platform/windows/clipboard.cpp index 69cc6674dbe..f0a404dde04 100644 --- a/src/platform/windows/clipboard.cpp +++ b/src/platform/windows/clipboard.cpp @@ -32,6 +32,7 @@ namespace platf::clipboard { 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; struct clipboard_guard_t { bool open = false; @@ -265,6 +266,28 @@ namespace platf::clipboard { 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) { @@ -339,7 +362,13 @@ namespace platf::clipboard { } const int width = info.biWidth; - const int height = info.biHeight > 0 ? info.biHeight : -info.biHeight; + 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; @@ -431,6 +460,31 @@ namespace platf::clipboard { 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); @@ -458,6 +512,14 @@ namespace platf::clipboard { 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"; @@ -556,6 +618,13 @@ namespace platf::clipboard { } 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; diff --git a/src/stream.cpp b/src/stream.cpp index b0f9b6cf64c..1733bae0127 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1833,9 +1833,9 @@ namespace stream { if (platf::clipboard::is_backend_available()) { platf::clipboard::item_t item; item.type = session->control.clipboard.item_type; - item.data = session->control.clipboard.data; - item.mime_type = session->control.clipboard.mime_type; - item.name = session->control.clipboard.name; + 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; diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index c075a5ed579..ad8dc293418 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -52,6 +52,10 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) { 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)); @@ -62,6 +66,10 @@ TEST(ClipboardTransferValidationTests, AcceptsSequentialChunksOnly) { 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)); From 340f92bb2ae2e4333203237b7fbf19880f3bc3e5 Mon Sep 17 00:00:00 2001 From: skyhua Date: Fri, 24 Apr 2026 19:37:59 +0800 Subject: [PATCH 5/6] fix(clipboard): use owner window for clipboard writes --- src/platform/windows/clipboard.cpp | 59 ++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/platform/windows/clipboard.cpp b/src/platform/windows/clipboard.cpp index f0a404dde04..4f0beb8782e 100644 --- a/src/platform/windows/clipboard.cpp +++ b/src/platform/windows/clipboard.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -34,12 +35,64 @@ namespace platf::clipboard { 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; - clipboard_guard_t() { + 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(nullptr)) { + if (OpenClipboard(owner)) { open = true; break; } @@ -779,7 +832,7 @@ namespace platf::clipboard { bool write_item(const item_t &item, std::string *reason) { - clipboard_guard_t clipboard; + clipboard_guard_t clipboard { clipboard_open_mode::write }; if (!clipboard) { if (reason) { *reason = "OpenClipboard failed"; From 452dfa9c4d13ea6c7c1e566551152bb4954fe5f2 Mon Sep 17 00:00:00 2001 From: skyhua Date: Fri, 24 Apr 2026 19:38:36 +0800 Subject: [PATCH 6/6] fix(clipboard): tighten Windows write diagnostics --- src/platform/windows/clipboard.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/platform/windows/clipboard.cpp b/src/platform/windows/clipboard.cpp index 4f0beb8782e..c55d5a1ff08 100644 --- a/src/platform/windows/clipboard.cpp +++ b/src/platform/windows/clipboard.cpp @@ -23,6 +23,7 @@ extern "C" { #include "clipboard.h" #include "src/platform/common.h" +#include "src/logging.h" #include "src/stb_image.h" #include "src/stb_image_write.h" @@ -619,6 +620,14 @@ namespace platf::clipboard { 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(), @@ -771,8 +780,8 @@ namespace platf::clipboard { } const UINT png_format = RegisterClipboardFormatW(L"PNG"); - if (png_format != 0) { - write_hglobal_bytes(png_format, item.data); + if (png_format != 0 && !write_hglobal_bytes(png_format, item.data)) { + BOOST_LOG(debug) << "Failed to set registered PNG clipboard format"; } return true;