From e7841afa027857318e637387136aa817cea60859 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:42:00 +0300 Subject: [PATCH 01/14] feat(linux): add opt-in text clipboard sync --- cmake/compile_definitions/linux.cmake | 9 +- src/globals.h | 1 + src/input.cpp | 41 +++- src/platform/common.h | 25 +++ src/platform/linux/clipboard.cpp | 281 ++++++++++++++++++++++++++ src/platform/macos/input.cpp | 5 + src/platform/windows/input.cpp | 4 + src/stream.cpp | 72 ++++++- third-party/moonlight-common-c | 2 +- 9 files changed, 428 insertions(+), 12 deletions(-) create mode 100644 src/platform/linux/clipboard.cpp diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index 485e419adb5..fad1860163d 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -239,6 +239,12 @@ if(X11_FOUND) add_compile_definitions(SUNSHINE_BUILD_X11) include_directories(SYSTEM ${X11_INCLUDE_DIR}) list(APPEND PLATFORM_LIBRARIES ${X11_LIBRARIES}) + if(NOT X11_Xfixes_LIB) + find_library(X11_Xfixes_LIB Xfixes) + endif() + if(X11_Xfixes_LIB) + list(APPEND PLATFORM_LIBRARIES ${X11_Xfixes_LIB}) + endif() list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/linux/x11grab.h" "${CMAKE_SOURCE_DIR}/src/platform/linux/x11grab.cpp") @@ -320,7 +326,8 @@ list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/linux/graphics.cpp" "${CMAKE_SOURCE_DIR}/src/platform/linux/misc.h" "${CMAKE_SOURCE_DIR}/src/platform/linux/misc.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/linux/audio.cpp") + "${CMAKE_SOURCE_DIR}/src/platform/linux/audio.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/linux/clipboard.cpp") list(APPEND PLATFORM_LIBRARIES dl diff --git a/src/globals.h b/src/globals.h index 2c7ee9b121b..286ed6cb1d5 100644 --- a/src/globals.h +++ b/src/globals.h @@ -58,6 +58,7 @@ namespace mail { MAIL(idr); ///< IDR. MAIL(invalidate_ref_frames); ///< Invalidate ref frames. MAIL(gamepad_feedback); ///< Gamepad feedback. + MAIL(clipboard); ///< Clipboard text. MAIL(hdr); ///< HDR. #undef MAIL diff --git a/src/input.cpp b/src/input.cpp index a12514d4e5a..81aafe5ffc2 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -267,16 +267,19 @@ namespace input { * * @param touch_port_event Event carrying the active touch port. * @param feedback_queue Queue used for controller feedback. + * @param clipboard_queue Queue used for host clipboard updates. */ input_t( safe::mail_raw_t::event_t touch_port_event, - platf::feedback_queue_t feedback_queue + platf::feedback_queue_t feedback_queue, + platf::clipboard_queue_t clipboard_queue ): shortcutFlags {}, gamepads(MAX_GAMEPADS), client_context {platf::allocate_client_input_context(platf_input)}, touch_port_event {std::move(touch_port_event)}, feedback_queue {std::move(feedback_queue)}, + clipboard_queue {std::move(clipboard_queue)}, mouse_left_button_timeout {}, touch_port {{0, 0, 0, 0, 0, 0}, 0, 0, 0.0f, 0.0f, 1.0f, 1.0f, 0, 0}, accumulated_vscroll_delta {}, @@ -295,6 +298,7 @@ namespace input { safe::mail_raw_t::event_t touch_port_event; ///< Touch port event. platf::feedback_queue_t feedback_queue; ///< Queue used to deliver controller feedback to the platform backend. + platf::clipboard_queue_t clipboard_queue; ///< Queue used to deliver host clipboard text to the client. std::list> input_queue; ///< Validated input packets waiting for processing. std::mutex input_queue_lock; ///< Input queue lock. @@ -404,6 +408,7 @@ namespace input { void rebind_input(const std::shared_ptr &input, const safe::mail_t &mail) { input->touch_port_event = mail->event(mail::touch_port); input->feedback_queue = mail->queue(mail::gamepad_feedback); + input->clipboard_queue = mail->queue(mail::clipboard); for (int client_index = 0; client_index < input->gamepads.size(); ++client_index) { auto &gamepad = input->gamepads[client_index]; @@ -1272,6 +1277,29 @@ namespace input { platf::unicode(platf_input, packet->text, size); } + /** + * @brief Apply a client clipboard packet to the host clipboard. + * + * @param input Stream input state. + * @param header Packet header at the start of the raw input bytes. + */ + void passthrough_clipboard(std::shared_ptr &input, const NV_INPUT_HEADER *header) { + auto declared = util::endian::big(header->size); + constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + if (declared < prefix) { + return; + } + + auto text_len = declared - prefix; + platf::clipboard_subscribe(input->clipboard_queue); + if (text_len == 0) { + return; + } + + const auto *bytes = reinterpret_cast(header); + platf::clipboard_set(std::string_view(reinterpret_cast(bytes + sizeof(NV_INPUT_HEADER) + sizeof(std::uint32_t)), text_len)); + } + /** * @brief Allocate a virtual gamepad for a client-relative controller slot. * @@ -1720,6 +1748,11 @@ namespace input { return validate_fixed_input_packet(packet, declared_size); case UTF8_TEXT_EVENT_MAGIC: return declared_size - sizeof(header.magic) <= UTF8_TEXT_EVENT_MAX_COUNT; + case SS_CLIPBOARD_TEXT_MAGIC: + { + constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + return declared_size >= prefix && declared_size - prefix <= SS_CLIPBOARD_TEXT_MAX; + } case MULTI_CONTROLLER_MAGIC_GEN5: return validate_fixed_input_packet(packet, declared_size); case SS_TOUCH_MAGIC: @@ -2092,6 +2125,9 @@ namespace input { case UTF8_TEXT_EVENT_MAGIC: passthrough(static_cast(static_cast(payload))); break; + case SS_CLIPBOARD_TEXT_MAGIC: + passthrough_clipboard(input, payload); + break; case MULTI_CONTROLLER_MAGIC_GEN5: passthrough(input, (PNV_MULTI_CONTROLLER_PACKET) payload); break; @@ -2319,7 +2355,8 @@ namespace input { } else { input = std::make_shared( mail->event(mail::touch_port), - mail->queue(mail::gamepad_feedback) + mail->queue(mail::gamepad_feedback), + mail->queue(mail::clipboard) ); state.inputs.try_emplace(std::move(session_id), input); } diff --git a/src/platform/common.h b/src/platform/common.h index 325f913b30b..85bf5eac48b 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -11,6 +11,7 @@ #include #include #include +#include // lib includes #include @@ -270,6 +271,16 @@ namespace platf { */ using feedback_queue_t = safe::mail_raw_t::queue_t; + /** + * @brief Clipboard text waiting to be sent to the client. + */ + struct clipboard_text_t { + std::uint32_t token; ///< Echo-suppression token for this update. + std::string text; ///< UTF-8 clipboard text. + }; + + using clipboard_queue_t = safe::mail_raw_t::queue_t; + namespace speaker { /** * @brief Enumerates supported speaker options. @@ -1202,6 +1213,20 @@ namespace platf { */ void unicode(input_t &input, const char *utf8, int size); + /** + * @brief Replace the host clipboard with UTF-8 text from the client. + * + * @param text Clipboard text. + */ + void clipboard_set(std::string_view text); + + /** + * @brief Start forwarding host clipboard changes to the client. + * + * @param queue Queue drained by the control stream. + */ + void clipboard_subscribe(clipboard_queue_t queue); + /** * @brief Per-client input context allocated by a platform backend. */ diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp new file mode 100644 index 00000000000..750f413d120 --- /dev/null +++ b/src/platform/linux/clipboard.cpp @@ -0,0 +1,281 @@ +/** + * @file src/platform/linux/clipboard.cpp + * @brief X11 CLIPBOARD sync for a Sunshine session. + */ +#include "src/logging.h" +#include "src/platform/common.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifndef SUNSHINE_BUILD_X11 + +namespace platf { + void clipboard_set(std::string_view) {} + + void clipboard_subscribe(clipboard_queue_t) {} +} // namespace platf + +#else + + #include + #include + #include + #include + #include + #include + +namespace platf { + namespace { + constexpr std::size_t k_max_bytes = 32755; + + struct state_t { + std::mutex mutex; + std::condition_variable ready_cv; + bool ready = false; + bool failed = false; + bool started = false; + bool subscribed = false; + bool has_pending = false; + bool pull = false; + int wake_write = -1; + std::string pending; + std::string last_remote; + clipboard_queue_t queue; + std::thread thread; + }; + + state_t &state() { + static state_t value; + return value; + } + + void publish(const std::string &text) { + clipboard_queue_t queue; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + if (!clipboard.subscribed || text == clipboard.last_remote) { + return; + } + clipboard.last_remote = text; + queue = clipboard.queue; + } + if (!queue) { + return; + } + + std::random_device seed; + auto token = seed(); + if (token == 0) { + token = 1; + } + queue->raise(clipboard_text_t {token, text}); + } + + void serve_selection(Display *display, const std::string &text, XSelectionRequestEvent *request) { + XEvent notify {}; + notify.xselection.type = SelectionNotify; + notify.xselection.display = request->display; + notify.xselection.requestor = request->requestor; + notify.xselection.selection = request->selection; + notify.xselection.target = request->target; + notify.xselection.property = None; + notify.xselection.time = request->time; + + auto utf8 = XInternAtom(display, "UTF8_STRING", False); + auto targets = XInternAtom(display, "TARGETS", False); + if (request->target == targets) { + Atom list[3] = {utf8, XA_STRING, targets}; + XChangeProperty(display, request->requestor, request->property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(list), 3); + notify.xselection.property = request->property; + } else if (request->target == utf8 || request->target == XA_STRING) { + XChangeProperty(display, request->requestor, request->property, request->target, 8, PropModeReplace, reinterpret_cast(text.data()), static_cast(text.size())); + notify.xselection.property = request->property; + } + + XSendEvent(display, request->requestor, False, 0, ¬ify); + } + + void thread_main() { + int wake_read = -1; + int wake_write = -1; + int pipes[2] = {-1, -1}; + if (pipe2(pipes, O_CLOEXEC | O_NONBLOCK) != 0) { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.failed = true; + clipboard.ready_cv.notify_all(); + return; + } + wake_read = pipes[0]; + wake_write = pipes[1]; + + Display *display = XOpenDisplay(nullptr); + if (!display) { + BOOST_LOG(warning) << "Clipboard sync could not open the X display"sv; + close(wake_read); + close(wake_write); + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.failed = true; + clipboard.ready_cv.notify_all(); + return; + } + + int event_base = 0; + int error_base = 0; + if (!XFixesQueryExtension(display, &event_base, &error_base)) { + BOOST_LOG(warning) << "Clipboard sync requires the XFixes extension"sv; + XCloseDisplay(display); + close(wake_read); + close(wake_write); + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.failed = true; + clipboard.ready_cv.notify_all(); + return; + } + + auto root = DefaultRootWindow(display); + auto window = XCreateSimpleWindow(display, root, 0, 0, 1, 1, 0, 0, 0); + auto clipboard_atom = XInternAtom(display, "CLIPBOARD", False); + auto utf8 = XInternAtom(display, "UTF8_STRING", False); + auto property = XInternAtom(display, "SUNSHINE_CLIPBOARD", False); + XFixesSelectSelectionInput(display, window, clipboard_atom, XFixesSetSelectionOwnerNotifyMask); + XFlush(display); + + std::string owned; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.wake_write = wake_write; + clipboard.ready = true; + clipboard.ready_cv.notify_all(); + } + + auto apply_pending = [&]() { + std::string text; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + if (!clipboard.has_pending) { + return; + } + text = std::move(clipboard.pending); + clipboard.has_pending = false; + } + if (text.size() > k_max_bytes) { + text.resize(k_max_bytes); + } + owned = text; + XSetSelectionOwner(display, clipboard_atom, window, CurrentTime); + XFlush(display); + }; + + auto read_selection = [&]() { + Atom actual_type; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char *data = nullptr; + if (XGetWindowProperty(display, window, property, 0, (k_max_bytes + 3) / 4, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success || data == nullptr) { + return; + } + std::string text(reinterpret_cast(data), item_count); + XFree(data); + if (text.size() > k_max_bytes) { + text.resize(k_max_bytes); + } + publish(text); + }; + + int xfd = ConnectionNumber(display); + while (true) { + while (XPending(display)) { + XEvent event; + XNextEvent(display, &event); + if (event.type == SelectionRequest) { + serve_selection(display, owned, &event.xselectionrequest); + XFlush(display); + } else if (event.type == SelectionNotify) { + read_selection(); + } else if (event.type == event_base + XFixesSelectionNotify) { + auto *selection = reinterpret_cast(&event); + if (selection->selection == clipboard_atom && selection->owner != window && selection->owner != None) { + XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); + XFlush(display); + } + } + } + + apply_pending(); + + bool pull = false; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + pull = clipboard.pull; + clipboard.pull = false; + } + if (pull) { + XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); + XFlush(display); + } + + pollfd fds[2] = { + {xfd, POLLIN, 0}, + {wake_read, POLLIN, 0}, + }; + poll(fds, 2, 200); + if (fds[1].revents & POLLIN) { + char buffer[64]; + while (read(wake_read, buffer, sizeof(buffer)) > 0) { + } + } + } + } + } // namespace + + void clipboard_set(std::string_view text) { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + if (clipboard.failed || !clipboard.ready) { + return; + } + clipboard.pending.assign(text.data(), text.size()); + clipboard.last_remote = clipboard.pending; + clipboard.has_pending = true; + if (clipboard.wake_write >= 0) { + char byte = 1; + static_cast(write(clipboard.wake_write, &byte, 1)); + } + } + + void clipboard_subscribe(clipboard_queue_t queue) { + auto &clipboard = state(); + std::unique_lock lock {clipboard.mutex}; + if (!clipboard.started) { + clipboard.started = true; + clipboard.thread = std::thread(thread_main); + clipboard.thread.detach(); + } + clipboard.ready_cv.wait(lock, [&]() { + return clipboard.ready || clipboard.failed; + }); + clipboard.queue = std::move(queue); + clipboard.subscribed = clipboard.ready; + clipboard.pull = clipboard.ready; + if (clipboard.wake_write >= 0) { + char byte = 1; + static_cast(write(clipboard.wake_write, &byte, 1)); + } + } +} // namespace platf + +#endif diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 8b257a40053..37926be5b3d 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -14,6 +14,7 @@ // local includes #include "src/config.h" +#include "src/platform/common.h" #include "src/platform/virtualhid_input.h" namespace platf { @@ -58,4 +59,8 @@ namespace platf { return gamepads; } + void clipboard_set(std::string_view) {} + + void clipboard_subscribe(clipboard_queue_t) {} + } // namespace platf diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index 5eac499ed85..4b0a50d55b3 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -1328,4 +1328,8 @@ namespace platf { return caps; } + + void clipboard_set(std::string_view) {} + + void clipboard_subscribe(clipboard_queue_t) {} } // namespace platf diff --git a/src/stream.cpp b/src/stream.cpp index 1291c9c625e..ab4cc9ad3eb 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -10,6 +10,7 @@ #include #include #include +#include // lib includes #include @@ -18,6 +19,7 @@ extern "C" { // clang-format off +#include #include // clang-format on } @@ -547,6 +549,7 @@ namespace stream { std::uint32_t seq; ///< Sequence number for the next encrypted control message. platf::feedback_queue_t feedback_queue; ///< Queue of controller feedback awaiting control-channel delivery. + platf::clipboard_queue_t clipboard_queue; ///< Queue of clipboard text awaiting control-channel delivery. safe::mail_raw_t::event_t hdr_queue; ///< Queue of HDR metadata awaiting control-channel delivery. } control; ///< Runtime state for the encrypted GameStream control channel. @@ -566,12 +569,10 @@ namespace stream { * returns empty string_view on failure * returns string_view pointing to payload data */ - template - static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::array &tagged_cipher) { - static_assert( - max_payload_size >= sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size), - "max_payload_size >= sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size)" - ); + static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::uint8_t *tagged_cipher, std::size_t tagged_size) { + if (tagged_size < sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size)) { + return {}; + } if (session->config.controlProtocolType != 13) { return plaintext; @@ -600,7 +601,7 @@ namespace stream { iv[0] = (std::uint8_t) seq; } - auto packet = (control_encrypted_p) tagged_cipher.data(); + auto packet = (control_encrypted_p) tagged_cipher; auto bytes = session->control.cipher.encrypt(plaintext, packet->payload(), &iv); if (bytes <= 0) { @@ -614,7 +615,17 @@ namespace stream { 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)}; + return std::string_view {(char *) tagged_cipher, packet_length + sizeof(control_encrypted_t) - sizeof(control_encrypted_t::seq)}; + } + + template + static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::array &tagged_cipher) { + static_assert( + max_payload_size >= sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size), + "max_payload_size >= sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size)" + ); + + return encode_control(session, plaintext, tagged_cipher.data(), tagged_cipher.size()); } /** @@ -1120,6 +1131,44 @@ namespace stream { return 0; } + /** + * @brief Send clipboard text to the client over the control channel. + * + * @param session Active streaming session. + * @param msg Clipboard text and echo token. + * @return 0 when the control message is queued. + */ + int send_clipboard_text(session_t *session, const platf::clipboard_text_t &msg) { + if (!session->control.peer || msg.text.size() > SS_CLIPBOARD_TEXT_MAX) { + return -1; + } + + std::vector plaintext(sizeof(control_header_v2) + sizeof(std::uint32_t) * 2 + msg.text.size()); + auto *header = reinterpret_cast(plaintext.data()); + header->type = SS_CLIPBOARD_CONTROL_PTYPE; + header->payloadLength = static_cast(sizeof(std::uint32_t) * 2 + msg.text.size()); + + auto token = util::endian::little(msg.token); + auto length = util::endian::little(static_cast(msg.text.size())); + auto *payload = plaintext.data() + sizeof(control_header_v2); + std::memcpy(payload, &token, sizeof(token)); + std::memcpy(payload + sizeof(token), &length, sizeof(length)); + std::memcpy(payload + sizeof(token) + sizeof(length), msg.text.data(), msg.text.size()); + + const auto tagged_size = sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + crypto::cipher::tag_size; + std::vector tagged(tagged_size); + auto encoded = encode_control(session, std::string_view(reinterpret_cast(plaintext.data()), plaintext.size()), tagged.data(), tagged.size()); + if (encoded.empty()) { + return -1; + } + + if (session->broadcast_ref->control_server.send(encoded, session->control.peer)) { + return -1; + } + + return 0; + } + /** * @brief Send the selected HDR mode to the connected client over the control channel. * @@ -1361,6 +1410,12 @@ namespace stream { send_feedback_msg(session, *feedback_msg); } + auto &clipboard_queue = session->control.clipboard_queue; + while (session->control.peer && clipboard_queue->peek()) { + auto clipboard_msg = clipboard_queue->pop(); + send_clipboard_text(session, *clipboard_msg); + } + auto &hdr_queue = session->control.hdr_queue; while (session->control.peer && hdr_queue->peek()) { auto hdr_info = hdr_queue->pop(); @@ -2321,6 +2376,7 @@ namespace stream { session->control.connect_data = launch_session.control_connect_data; session->control.feedback_queue = mail->queue(mail::gamepad_feedback); + session->control.clipboard_queue = mail->queue(mail::clipboard); session->control.hdr_queue = mail->event(mail::hdr); session->control.legacy_input_enc_iv = launch_session.iv; session->control.cipher = crypto::cipher::gcm_t { diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index 62e066388f1..d22d24c94b6 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit 62e066388f1a1b133e0bee947b9a374311a3354b +Subproject commit d22d24c94b6786f56c855a610b2f4fcc5a03d44b From 96101935a8bc93d17dc9cf8d69b3f28790257958 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:06:28 +0300 Subject: [PATCH 02/14] docs(platform): document the clipboard queue type --- src/platform/common.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/common.h b/src/platform/common.h index 9efa963d1d9..7775c3330d0 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -281,6 +281,9 @@ namespace platf { std::string text; ///< UTF-8 clipboard text. }; + /** + * @brief Queue used to deliver host clipboard text to the control stream. + */ using clipboard_queue_t = safe::mail_raw_t::queue_t; namespace speaker { From 59f9665cfe4118c463f295ae2ea7a9a2bbd6dd9c Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:06:43 +0300 Subject: [PATCH 03/14] fix(platform): address clipboard review notes --- src/input.cpp | 5 +++-- src/platform/linux/clipboard.cpp | 8 ++++++-- src/platform/macos/input.cpp | 8 ++++++-- src/platform/windows/input.cpp | 8 ++++++-- src/stream.cpp | 14 ++++++++------ 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index 81aafe5ffc2..e2e69b79fab 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -2,6 +2,7 @@ * @file src/input.cpp * @brief Definitions for gamepad, keyboard, and mouse input handling. */ +#include #include extern "C" { #include @@ -1283,7 +1284,7 @@ namespace input { * @param input Stream input state. * @param header Packet header at the start of the raw input bytes. */ - void passthrough_clipboard(std::shared_ptr &input, const NV_INPUT_HEADER *header) { + void passthrough_clipboard(const std::shared_ptr &input, const NV_INPUT_HEADER *header) { auto declared = util::endian::big(header->size); constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); if (declared < prefix) { @@ -1296,7 +1297,7 @@ namespace input { return; } - const auto *bytes = reinterpret_cast(header); + const auto *bytes = reinterpret_cast(header); platf::clipboard_set(std::string_view(reinterpret_cast(bytes + sizeof(NV_INPUT_HEADER) + sizeof(std::uint32_t)), text_len)); } diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 750f413d120..599270d352b 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -16,9 +16,13 @@ #ifndef SUNSHINE_BUILD_X11 namespace platf { - void clipboard_set(std::string_view) {} + void clipboard_set(std::string_view) { + // This build has no X11 display, so the host clipboard cannot be updated. + } - void clipboard_subscribe(clipboard_queue_t) {} + void clipboard_subscribe(clipboard_queue_t) { + // This build has no X11 display, so host clipboard changes cannot be watched. + } } // namespace platf #else diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 37926be5b3d..bc43fa62d1b 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -59,8 +59,12 @@ namespace platf { return gamepads; } - void clipboard_set(std::string_view) {} + void clipboard_set(std::string_view) { + // macOS host clipboard sync is not implemented yet. + } - void clipboard_subscribe(clipboard_queue_t) {} + void clipboard_subscribe(clipboard_queue_t) { + // macOS host clipboard sync is not implemented yet. + } } // namespace platf diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index 4b0a50d55b3..132d5a8fb67 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -1329,7 +1329,11 @@ namespace platf { return caps; } - void clipboard_set(std::string_view) {} + void clipboard_set(std::string_view) { + // Windows host clipboard sync is not implemented yet. + } - void clipboard_subscribe(clipboard_queue_t) {} + void clipboard_subscribe(clipboard_queue_t) { + // Windows host clipboard sync is not implemented yet. + } } // namespace platf diff --git a/src/stream.cpp b/src/stream.cpp index ab4cc9ad3eb..5ecda8720cc 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1143,21 +1143,23 @@ namespace stream { return -1; } - std::vector plaintext(sizeof(control_header_v2) + sizeof(std::uint32_t) * 2 + msg.text.size()); - auto *header = reinterpret_cast(plaintext.data()); - header->type = SS_CLIPBOARD_CONTROL_PTYPE; - header->payloadLength = static_cast(sizeof(std::uint32_t) * 2 + msg.text.size()); + control_header_v2 header {}; + header.type = SS_CLIPBOARD_CONTROL_PTYPE; + header.payloadLength = static_cast(sizeof(std::uint32_t) * 2 + msg.text.size()); + + std::vector plaintext(sizeof(header) + header.payloadLength); + std::memcpy(plaintext.data(), &header, sizeof(header)); auto token = util::endian::little(msg.token); auto length = util::endian::little(static_cast(msg.text.size())); - auto *payload = plaintext.data() + sizeof(control_header_v2); + auto *payload = plaintext.data() + sizeof(header); std::memcpy(payload, &token, sizeof(token)); std::memcpy(payload + sizeof(token), &length, sizeof(length)); std::memcpy(payload + sizeof(token) + sizeof(length), msg.text.data(), msg.text.size()); const auto tagged_size = sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + crypto::cipher::tag_size; std::vector tagged(tagged_size); - auto encoded = encode_control(session, std::string_view(reinterpret_cast(plaintext.data()), plaintext.size()), tagged.data(), tagged.size()); + auto encoded = encode_control(session, util::view(plaintext.data(), plaintext.data() + plaintext.size()), tagged.data(), tagged.size()); if (encoded.empty()) { return -1; } From fe98ee415714e7261e41881cc905b4dbaa4e3d06 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:22:57 +0300 Subject: [PATCH 04/14] fix(input): copy clipboard text without a char cast --- src/input.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index 0b78a633e7d..68dc7ee7c32 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -1332,8 +1332,10 @@ namespace input { return; } - const auto *bytes = reinterpret_cast(header); - platf::clipboard_set(std::string_view(reinterpret_cast(bytes + sizeof(NV_INPUT_HEADER) + sizeof(std::uint32_t)), text_len)); + const auto *text = reinterpret_cast(header) + sizeof(NV_INPUT_HEADER) + sizeof(std::uint32_t); + std::string copy(text_len, '\0'); + std::memcpy(copy.data(), static_cast(text), text_len); + platf::clipboard_set(copy); } /** From 313f095f289ba5ed854284dc8c70d682782eacde Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:57:20 +0300 Subject: [PATCH 05/14] fix(linux): correct X11 clipboard subscriptions and transfers --- src/input.cpp | 3 +- src/platform/clipboard_sync.h | 250 +++++++++++++++++++++++++++++++ src/platform/common.h | 12 +- src/platform/linux/clipboard.cpp | 213 +++++++++++++++++--------- src/platform/macos/input.cpp | 6 +- src/platform/windows/input.cpp | 6 +- tests/unit/test_clipboard.cpp | 198 ++++++++++++++++++++++++ tests/unit/test_input.cpp | 28 ++++ 8 files changed, 640 insertions(+), 76 deletions(-) create mode 100644 src/platform/clipboard_sync.h create mode 100644 tests/unit/test_clipboard.cpp diff --git a/src/input.cpp b/src/input.cpp index 68dc7ee7c32..3e25b724caf 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -1335,7 +1335,7 @@ namespace input { const auto *text = reinterpret_cast(header) + sizeof(NV_INPUT_HEADER) + sizeof(std::uint32_t); std::string copy(text_len, '\0'); std::memcpy(copy.data(), static_cast(text), text_len); - platf::clipboard_set(copy); + platf::clipboard_set(copy, input->clipboard_queue); } /** @@ -2346,6 +2346,7 @@ namespace input { * @brief Reset the object to its initial empty state. */ void reset(std::shared_ptr &input) { + platf::clipboard_unsubscribe(input->clipboard_queue); task_pool.cancel(key_press_repeat_id); task_pool.cancel(input->mouse_left_button_timeout); diff --git a/src/platform/clipboard_sync.h b/src/platform/clipboard_sync.h new file mode 100644 index 00000000000..df9512ba762 --- /dev/null +++ b/src/platform/clipboard_sync.h @@ -0,0 +1,250 @@ +/** + * @file src/platform/clipboard_sync.h + * @brief Clipboard subscriber tracking and X11 selection decoding shared with tests. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/platform/common.h" + +namespace platf { + /** + * @brief Maximum UTF-8 clipboard payload accepted by the protocol. + */ + inline constexpr std::size_t clipboard_max_bytes = 32755; + + /** + * @brief One client waiting for host clipboard updates. + */ + struct clipboard_subscriber_t { + clipboard_queue_t queue; ///< Queue drained by that client's control stream. + std::string last_sent; ///< Last text delivered to this client, or applied from it. + bool has_snapshot = false; ///< Whether this client has received its initial clipboard value. + }; + + /** + * @brief Active clipboard subscriptions for every streaming client. + */ + class clipboard_subscribers_t { + public: + /** + * @brief Register a client queue. + * + * @param queue Queue drained by the client's control stream. + * @return True when the queue is new and still needs the current clipboard snapshot. + */ + bool add(clipboard_queue_t queue) { + if (!queue) { + return false; + } + for (const auto &entry : entries_) { + if (entry.queue == queue) { + return false; + } + } + entries_.push_back(clipboard_subscriber_t {std::move(queue), {}, false}); + return true; + } + + /** + * @brief Drop a client queue when its session stops. + * + * @param queue Queue that should no longer receive clipboard text. + */ + void remove(const clipboard_queue_t &queue) { + std::erase_if(entries_, [&](const auto &entry) { + return entry.queue == queue; + }); + } + + /** + * @brief Deliver host text to every subscriber that does not already have it. + * + * A subscriber that has not received a snapshot gets the text even when an older + * subscriber was already given the same value. + * + * @param text UTF-8 clipboard text. + * @return Queues that should be raised with this text. + */ + std::vector publish(const std::string &text) { + std::vector delivered; + for (auto &entry : entries_) { + if (auto queue = deliver(entry, text)) { + delivered.push_back(std::move(queue)); + } + } + return delivered; + } + + /** + * @brief Record text applied from one client and forward it to the others. + * + * @param origin Queue belonging to the client that supplied the text. + * @param text UTF-8 clipboard text applied on the host. + * @return Queues other than the origin that should be raised with this text. + */ + std::vector note_local(const clipboard_queue_t &origin, const std::string &text) { + std::vector delivered; + for (auto &entry : entries_) { + if (origin && entry.queue == origin) { + entry.last_sent = text; + entry.has_snapshot = true; + continue; + } + if (auto queue = deliver(entry, text)) { + delivered.push_back(std::move(queue)); + } + } + return delivered; + } + + private: + /** + * @brief Send text unless this subscriber already has that snapshot. + * + * @param entry Subscriber being considered. + * @param text UTF-8 clipboard text. + * @return The subscriber queue when a delivery is required. + */ + static clipboard_queue_t deliver(clipboard_subscriber_t &entry, const std::string &text) { + if (entry.has_snapshot && entry.last_sent == text) { + return {}; + } + entry.last_sent = text; + entry.has_snapshot = true; + return entry.queue; + } + + std::vector entries_; ///< Subscribers registered for the current process. + }; + + /** + * @brief Choose the property atom used to answer a selection request. + * + * Older requestors set the property to None. The selection protocol then uses the + * target atom, and writing to property 0 is a BadAtom. + * + * @param requested Property atom from the selection request. Zero means None. + * @param target Target atom from the selection request. + * @param supported Whether Sunshine can convert the requested target. + * @return Property atom for the reply, or zero when the conversion is refused. + */ + inline std::uint64_t selection_reply_property(std::uint64_t requested, std::uint64_t target, bool supported) { + if (!supported) { + return 0; + } + if (requested == 0) { + return target; + } + return requested; + } + + /** + * @brief Incremental X selection transfer assembled from INCR chunks. + */ + class incr_transfer_t { + public: + /** + * @brief Report whether an INCR transfer is waiting for more chunks. + * + * @return True after a size announcement and before the empty chunk. + */ + [[nodiscard]] bool active() const { + return active_; + } + + /** + * @brief Accept one selection property and maybe finish a text value. + * + * An INCR property carries a byte-count announcement, not clipboard text. + * Format-8 chunks are appended until the empty property ends the transfer. + * Any other type is ignored. + * + * @param actual_type Property type atom. + * @param actual_format Property format, in bits. + * @param item_count Number of items of that format. + * @param data Property bytes. May be null when the item count is zero. + * @param utf8_atom Atom for UTF8_STRING. + * @param string_atom Atom for STRING. + * @param incr_atom Atom for INCR. + * @return Completed clipboard text, or empty when the transfer is unfinished or ignored. + */ + std::optional consume( + std::uint64_t actual_type, + int actual_format, + std::size_t item_count, + const void *data, + std::uint64_t utf8_atom, + std::uint64_t string_atom, + std::uint64_t incr_atom + ) { + if (active_) { + if (actual_format == 8 && item_count == 0) { + active_ = false; + auto done = std::move(text_); + text_.clear(); + return done; + } + if (actual_format == 8) { + append_bytes(data, item_count); + } + return std::nullopt; + } + + if (actual_type == incr_atom && actual_format == 32) { + active_ = true; + text_.clear(); + return std::nullopt; + } + + if ((actual_type == utf8_atom || actual_type == string_atom) && actual_format == 8) { + std::string text; + append_to(text, data, item_count); + return text; + } + + return std::nullopt; + } + + private: + /** + * @brief Copy property bytes into a string without passing the protocol cap. + * + * @param text Destination text. + * @param data Property bytes. May be null when the item count is zero. + * @param size Number of bytes available at data. + */ + static void append_to(std::string &text, const void *data, std::size_t size) { + if (data == nullptr || size == 0 || text.size() >= clipboard_max_bytes) { + return; + } + const auto take = std::min(size, clipboard_max_bytes - text.size()); + const auto offset = text.size(); + text.resize(offset + take); + std::memcpy(text.data() + offset, data, take); + } + + /** + * @brief Append one INCR chunk to the transfer buffer. + * + * @param data Chunk bytes. + * @param size Number of bytes in the chunk. + */ + void append_bytes(const void *data, std::size_t size) { + append_to(text_, data, size); + } + + bool active_ = false; ///< Whether the empty INCR chunk has not arrived yet. + std::string text_; ///< UTF-8 text accumulated from INCR chunks. + }; +} // namespace platf diff --git a/src/platform/common.h b/src/platform/common.h index 738c4369deb..2e0e378215d 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -1223,16 +1223,24 @@ namespace platf { * @brief Replace the host clipboard with UTF-8 text from the client. * * @param text Clipboard text. + * @param origin Queue of the client that supplied the text. That client is not sent an echo. */ - void clipboard_set(std::string_view text); + void clipboard_set(std::string_view text, const clipboard_queue_t &origin); /** - * @brief Start forwarding host clipboard changes to the client. + * @brief Start forwarding host clipboard changes to one client. * * @param queue Queue drained by the control stream. */ void clipboard_subscribe(clipboard_queue_t queue); + /** + * @brief Stop forwarding host clipboard changes when a client session ends. + * + * @param queue Queue that should no longer receive clipboard text. + */ + void clipboard_unsubscribe(const clipboard_queue_t &queue); + /** * @brief Per-client input context allocated by a platform backend. */ diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 599270d352b..7728d96a192 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -5,28 +5,33 @@ #include "src/logging.h" #include "src/platform/common.h" -#include #include -#include #include #include #include #include +#include #ifndef SUNSHINE_BUILD_X11 namespace platf { - void clipboard_set(std::string_view) { + void clipboard_set(std::string_view, const clipboard_queue_t &) { // This build has no X11 display, so the host clipboard cannot be updated. } void clipboard_subscribe(clipboard_queue_t) { // This build has no X11 display, so host clipboard changes cannot be watched. } + + void clipboard_unsubscribe(const clipboard_queue_t &) { + // This build has no X11 display, so there is no clipboard subscription to remove. + } } // namespace platf #else + #include "src/platform/clipboard_sync.h" + #include #include #include @@ -36,53 +41,79 @@ namespace platf { namespace platf { namespace { - constexpr std::size_t k_max_bytes = 32755; - + /** + * @brief Process-wide X11 clipboard watcher state. + */ struct state_t { - std::mutex mutex; - std::condition_variable ready_cv; - bool ready = false; - bool failed = false; - bool started = false; - bool subscribed = false; - bool has_pending = false; - bool pull = false; - int wake_write = -1; - std::string pending; - std::string last_remote; - clipboard_queue_t queue; - std::thread thread; + std::mutex mutex; ///< Guards the fields below for the input and X11 threads. + std::condition_variable ready_cv; ///< Wakes subscribers waiting for the display connection. + bool ready = false; ///< Whether the X11 watcher can accept clipboard operations. + bool failed = false; ///< Whether opening the display or XFixes failed. + bool started = false; ///< Whether the watcher thread has been launched. + bool has_pending = false; ///< Whether a client clipboard value is waiting to take selection ownership. + bool pull = false; ///< Whether a new subscriber still needs the current host clipboard. + int wake_write = -1; ///< Write end of the pipe that wakes the watcher thread. + std::string pending; ///< Client clipboard text waiting to become the selection owner. + clipboard_subscribers_t subscribers; ///< One entry for each streaming client that opted in. + std::thread thread; ///< Detached X11 watcher thread. }; + /** + * @brief Return the process-wide clipboard watcher state. + * + * @return Watcher state shared by subscribe, unsubscribe, and the X11 thread. + */ state_t &state() { static state_t value; return value; } - void publish(const std::string &text) { - clipboard_queue_t queue; - { - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - if (!clipboard.subscribed || text == clipboard.last_remote) { - return; - } - clipboard.last_remote = text; - queue = clipboard.queue; - } - if (!queue) { + /** + * @brief Raise clipboard text on each queue that still needs it. + * + * @param queues Subscriber queues selected by the publish or local-set rules. + * @param text UTF-8 clipboard text. + */ + void raise_clipboard(const std::vector &queues, const std::string &text) { + if (queues.empty()) { return; } std::random_device seed; - auto token = seed(); - if (token == 0) { - token = 1; + for (const auto &queue : queues) { + auto token = seed(); + if (token == 0) { + token = 1; + } + queue->raise(clipboard_text_t {token, text}); } - queue->raise(clipboard_text_t {token, text}); } - void serve_selection(Display *display, const std::string &text, XSelectionRequestEvent *request) { + /** + * @brief Publish host clipboard text to every subscriber that does not already have it. + * + * @param text UTF-8 clipboard text. + */ + void publish(const std::string &text) { + std::vector queues; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + queues = clipboard.subscribers.publish(text); + } + raise_clipboard(queues, text); + } + + /** + * @brief Answer one X selection request with the text Sunshine owns. + * + * @param display X display connection owned by the watcher thread. + * @param text UTF-8 text currently owned by Sunshine. + * @param request Selection request from another client. + * @param utf8 UTF8_STRING atom. + * @param targets TARGETS atom. + */ + void serve_selection(Display *display, const std::string &text, XSelectionRequestEvent *request, Atom utf8, Atom targets) { XEvent notify {}; notify.xselection.type = SelectionNotify; notify.xselection.display = request->display; @@ -92,20 +123,31 @@ namespace platf { notify.xselection.property = None; notify.xselection.time = request->time; - auto utf8 = XInternAtom(display, "UTF8_STRING", False); - auto targets = XInternAtom(display, "TARGETS", False); - if (request->target == targets) { + const bool supported = request->target == targets || request->target == utf8 || request->target == XA_STRING; + const auto property = static_cast(selection_reply_property(request->property, request->target, supported)); + notify.xselection.property = property; + if (property != None && request->target == targets) { Atom list[3] = {utf8, XA_STRING, targets}; - XChangeProperty(display, request->requestor, request->property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(list), 3); - notify.xselection.property = request->property; - } else if (request->target == utf8 || request->target == XA_STRING) { - XChangeProperty(display, request->requestor, request->property, request->target, 8, PropModeReplace, reinterpret_cast(text.data()), static_cast(text.size())); - notify.xselection.property = request->property; + XChangeProperty(display, request->requestor, property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(list), 3); + } else if (property != None) { + XChangeProperty( + display, + request->requestor, + property, + request->target, + 8, + PropModeReplace, + reinterpret_cast(text.data()), + static_cast(text.size()) + ); } XSendEvent(display, request->requestor, False, 0, ¬ify); } + /** + * @brief Watch the X11 CLIPBOARD selection until the process exits. + */ void thread_main() { int wake_read = -1; int wake_write = -1; @@ -148,13 +190,17 @@ namespace platf { auto root = DefaultRootWindow(display); auto window = XCreateSimpleWindow(display, root, 0, 0, 1, 1, 0, 0, 0); + XSelectInput(display, window, PropertyChangeMask); auto clipboard_atom = XInternAtom(display, "CLIPBOARD", False); auto utf8 = XInternAtom(display, "UTF8_STRING", False); + auto incr = XInternAtom(display, "INCR", False); + auto targets = XInternAtom(display, "TARGETS", False); auto property = XInternAtom(display, "SUNSHINE_CLIPBOARD", False); XFixesSelectSelectionInput(display, window, clipboard_atom, XFixesSetSelectionOwnerNotifyMask); XFlush(display); std::string owned; + incr_transfer_t transfer; { auto &clipboard = state(); std::lock_guard lock {clipboard.mutex}; @@ -174,29 +220,28 @@ namespace platf { text = std::move(clipboard.pending); clipboard.has_pending = false; } - if (text.size() > k_max_bytes) { - text.resize(k_max_bytes); - } owned = text; XSetSelectionOwner(display, clipboard_atom, window, CurrentTime); XFlush(display); }; - auto read_selection = [&]() { - Atom actual_type; + auto read_property = [&]() { + Atom actual_type = None; int actual_format = 0; unsigned long item_count = 0; unsigned long bytes_after = 0; unsigned char *data = nullptr; - if (XGetWindowProperty(display, window, property, 0, (k_max_bytes + 3) / 4, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success || data == nullptr) { + if (XGetWindowProperty(display, window, property, 0, (clipboard_max_bytes + 3) / 4, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success) { return; } - std::string text(reinterpret_cast(data), item_count); - XFree(data); - if (text.size() > k_max_bytes) { - text.resize(k_max_bytes); + static_cast(bytes_after); + auto text = transfer.consume(actual_type, actual_format, item_count, data, utf8, XA_STRING, incr); + if (data != nullptr) { + XFree(data); + } + if (text) { + publish(*text); } - publish(text); }; int xfd = ConnectionNumber(display); @@ -205,13 +250,18 @@ namespace platf { XEvent event; XNextEvent(display, &event); if (event.type == SelectionRequest) { - serve_selection(display, owned, &event.xselectionrequest); + serve_selection(display, owned, &event.xselectionrequest, utf8, targets); XFlush(display); } else if (event.type == SelectionNotify) { - read_selection(); + if (event.xselection.property != None) { + read_property(); + } + } else if (event.type == PropertyNotify && transfer.active() && event.xproperty.atom == property && event.xproperty.state == PropertyNewValue) { + read_property(); } else if (event.type == event_base + XFixesSelectionNotify) { auto *selection = reinterpret_cast(&event); if (selection->selection == clipboard_atom && selection->owner != window && selection->owner != None) { + transfer = {}; XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); XFlush(display); } @@ -228,6 +278,7 @@ namespace platf { clipboard.pull = false; } if (pull) { + transfer = {}; XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); XFlush(display); } @@ -246,19 +297,30 @@ namespace platf { } } // namespace - void clipboard_set(std::string_view text) { - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - if (clipboard.failed || !clipboard.ready) { - return; + void clipboard_set(std::string_view text, const clipboard_queue_t &origin) { + std::string capped {text}; + if (capped.size() > clipboard_max_bytes) { + capped.resize(clipboard_max_bytes); } - clipboard.pending.assign(text.data(), text.size()); - clipboard.last_remote = clipboard.pending; - clipboard.has_pending = true; - if (clipboard.wake_write >= 0) { - char byte = 1; - static_cast(write(clipboard.wake_write, &byte, 1)); + + std::vector queues; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + if (clipboard.failed || !clipboard.ready) { + return; + } + clipboard.pending = capped; + clipboard.has_pending = true; + queues = clipboard.subscribers.note_local(origin, capped); + if (clipboard.wake_write >= 0) { + char byte = 1; + if (write(clipboard.wake_write, &byte, 1) < 0) { + BOOST_LOG(debug) << "Clipboard watcher wakeup was not queued"sv; + } + } } + raise_clipboard(queues, capped); } void clipboard_subscribe(clipboard_queue_t queue) { @@ -272,14 +334,23 @@ namespace platf { clipboard.ready_cv.wait(lock, [&]() { return clipboard.ready || clipboard.failed; }); - clipboard.queue = std::move(queue); - clipboard.subscribed = clipboard.ready; - clipboard.pull = clipboard.ready; + if (!clipboard.ready || !clipboard.subscribers.add(std::move(queue))) { + return; + } + clipboard.pull = true; if (clipboard.wake_write >= 0) { char byte = 1; - static_cast(write(clipboard.wake_write, &byte, 1)); + if (write(clipboard.wake_write, &byte, 1) < 0) { + BOOST_LOG(debug) << "Clipboard watcher wakeup was not queued"sv; + } } } + + void clipboard_unsubscribe(const clipboard_queue_t &queue) { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.subscribers.remove(queue); + } } // namespace platf #endif diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 2a1d782a604..95f73958d32 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -68,7 +68,7 @@ namespace platf { return gamepads; } - void clipboard_set(std::string_view) { + void clipboard_set(std::string_view, const clipboard_queue_t &) { // macOS host clipboard sync is not implemented yet. } @@ -76,4 +76,8 @@ namespace platf { // macOS host clipboard sync is not implemented yet. } + void clipboard_unsubscribe(const clipboard_queue_t &) { + // macOS host clipboard sync is not implemented yet. + } + } // namespace platf diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index c168eb0dd97..406289d6169 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -1334,11 +1334,15 @@ namespace platf { return caps; } - void clipboard_set(std::string_view) { + void clipboard_set(std::string_view, const clipboard_queue_t &) { // Windows host clipboard sync is not implemented yet. } void clipboard_subscribe(clipboard_queue_t) { // Windows host clipboard sync is not implemented yet. } + + void clipboard_unsubscribe(const clipboard_queue_t &) { + // Windows host clipboard sync is not implemented yet. + } } // namespace platf diff --git a/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp new file mode 100644 index 00000000000..1d7ddd50945 --- /dev/null +++ b/tests/unit/test_clipboard.cpp @@ -0,0 +1,198 @@ +/** + * @file tests/unit/test_clipboard.cpp + * @brief Tests for clipboard subscription lifecycle and X selection decoding. + */ + +// test includes +#include "../tests_common.h" + +// standard includes +#include +#include +#include +#include + +// local includes +#include "src/platform/clipboard_sync.h" +#include "src/thread_safe.h" + +namespace { + /** + * @brief Create a mailbox queue for one clipboard subscriber. + * + * @param mail Mailbox that owns the queue. + * @param id Queue identifier unique within the mailbox. + * @return Queue that records delivered clipboard text. + */ + platf::clipboard_queue_t make_queue(const safe::mail_t &mail, const std::string &id) { + return mail->queue(id); + } + + /** + * @brief Raise text on the queues selected by a subscriber update. + * + * @param queues Queues that should receive the text. + * @param text UTF-8 clipboard text. + */ + void deliver(const std::vector &queues, const std::string &text) { + for (const auto &queue : queues) { + queue->raise(platf::clipboard_text_t {1, text}); + } + } + + /** + * @brief Remove every pending clipboard value from a queue. + * + * @param queue Queue to drain. + * @return Text values in delivery order. + */ + std::vector drain(const platf::clipboard_queue_t &queue) { + std::vector texts; + while (queue->peek()) { + auto item = queue->pop(std::chrono::milliseconds {0}); + if (!item) { + break; + } + texts.push_back(item->text); + } + return texts; + } +} // namespace + +TEST(ClipboardSubscriptionTest, DeliversOneHostChangeToEverySubscriber) { + auto mail = std::make_shared(); + auto first = make_queue(mail, "clipboard-a"); + auto second = make_queue(mail, "clipboard-b"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(first)); + EXPECT_TRUE(subscribers.add(second)); + + deliver(subscribers.publish("host"), "host"); + + EXPECT_EQ(drain(first), (std::vector {"host"})); + EXPECT_EQ(drain(second), (std::vector {"host"})); +} + +TEST(ClipboardSubscriptionTest, SendsTheCurrentClipboardToASubscriptionAddedLater) { + auto mail = std::make_shared(); + auto first = make_queue(mail, "clipboard-a"); + auto second = make_queue(mail, "clipboard-b"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(first)); + deliver(subscribers.publish("same"), "same"); + EXPECT_EQ(drain(first), (std::vector {"same"})); + + EXPECT_TRUE(subscribers.add(second)); + deliver(subscribers.publish("same"), "same"); + + EXPECT_TRUE(drain(first).empty()); + EXPECT_EQ(drain(second), (std::vector {"same"})); +} + +TEST(ClipboardSubscriptionTest, RemovesASubscriberWhenTheSessionStops) { + auto mail = std::make_shared(); + auto first = make_queue(mail, "clipboard-a"); + auto second = make_queue(mail, "clipboard-b"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(first)); + EXPECT_TRUE(subscribers.add(second)); + subscribers.remove(first); + + deliver(subscribers.publish("next"), "next"); + + EXPECT_TRUE(drain(first).empty()); + EXPECT_EQ(drain(second), (std::vector {"next"})); +} + +TEST(ClipboardSubscriptionTest, DoesNotEchoALocalSetToItsOrigin) { + auto mail = std::make_shared(); + auto origin = make_queue(mail, "clipboard-origin"); + auto other = make_queue(mail, "clipboard-other"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(origin)); + EXPECT_TRUE(subscribers.add(other)); + + deliver(subscribers.note_local(origin, "from-origin"), "from-origin"); + EXPECT_TRUE(drain(origin).empty()); + EXPECT_EQ(drain(other), (std::vector {"from-origin"})); + + EXPECT_TRUE(subscribers.publish("from-origin").empty()); +} + +TEST(ClipboardSubscriptionTest, ResubscribingTheSameQueueDoesNotClearItsSnapshot) { + auto mail = std::make_shared(); + auto queue = make_queue(mail, "clipboard-a"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(queue)); + deliver(subscribers.publish("kept"), "kept"); + EXPECT_FALSE(subscribers.add(queue)); + + EXPECT_TRUE(subscribers.publish("kept").empty()); + EXPECT_EQ(drain(queue), (std::vector {"kept"})); +} + +TEST(ClipboardSelectionTest, UsesTheTargetAtomWhenTheSelectionPropertyIsNone) { + EXPECT_EQ(platf::selection_reply_property(0, 44, true), 44); + EXPECT_EQ(platf::selection_reply_property(7, 44, true), 7); +} + +TEST(ClipboardSelectionTest, RefusesAnUnsupportedSelectionTarget) { + EXPECT_EQ(platf::selection_reply_property(0, 44, false), 0); + EXPECT_EQ(platf::selection_reply_property(7, 44, false), 0); +} + +TEST(ClipboardSelectionTest, DoesNotPublishAnIncrSizeAnnouncement) { + constexpr std::uint64_t utf8 = 1; + constexpr std::uint64_t string_atom = 2; + constexpr std::uint64_t incr = 3; + const unsigned char announcement[] = {0x2a, 0x00, 0x00, 0x00}; + platf::incr_transfer_t transfer; + + const auto text = transfer.consume(incr, 32, 1, announcement, utf8, string_atom, incr); + + EXPECT_FALSE(text); + EXPECT_TRUE(transfer.active()); +} + +TEST(ClipboardSelectionTest, PublishesIncrChunksOnlyAfterTheTransferEnds) { + constexpr std::uint64_t utf8 = 1; + constexpr std::uint64_t string_atom = 2; + constexpr std::uint64_t incr = 3; + const unsigned char announcement[] = {0x04, 0x00, 0x00, 0x00}; + const char chunk[] = {'a', 'b', 'c', 'd'}; + platf::incr_transfer_t transfer; + EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement, utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(utf8, 8, sizeof(chunk), chunk, utf8, string_atom, incr)); + + const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); + + ASSERT_TRUE(text); + EXPECT_EQ(*text, "abcd"); + EXPECT_FALSE(transfer.active()); +} + +TEST(ClipboardSelectionTest, IgnoresUnsupportedSelectionPropertyData) { + constexpr std::uint64_t utf8 = 1; + constexpr std::uint64_t string_atom = 2; + constexpr std::uint64_t incr = 3; + const char chunk[] = {'n', 'o', 'p', 'e'}; + platf::incr_transfer_t transfer; + + const auto text = transfer.consume(99, 8, sizeof(chunk), chunk, utf8, string_atom, incr); + + EXPECT_FALSE(text); + EXPECT_FALSE(transfer.active()); +} + +TEST(ClipboardSelectionTest, CapsClipboardTextAtTheProtocolLimit) { + constexpr std::uint64_t utf8 = 1; + constexpr std::uint64_t string_atom = 2; + constexpr std::uint64_t incr = 3; + const std::string oversized(platf::clipboard_max_bytes + 10, 'z'); + platf::incr_transfer_t transfer; + + const auto text = transfer.consume(utf8, 8, oversized.size(), oversized.data(), utf8, string_atom, incr); + + ASSERT_TRUE(text); + EXPECT_EQ(text->size(), platf::clipboard_max_bytes); +} diff --git a/tests/unit/test_input.cpp b/tests/unit/test_input.cpp index 10c5461ac8a..a78e8caf282 100644 --- a/tests/unit/test_input.cpp +++ b/tests/unit/test_input.cpp @@ -168,6 +168,34 @@ TEST(InputPacketValidationTest, ValidatesUnicodePacketBoundsWithoutOverflow) { EXPECT_FALSE(input::testing::is_valid_input_packet(truncated_text)); } +TEST(InputPacketValidationTest, AcceptsEmptyAndMaximumClipboardPackets) { + constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + const auto empty_packet = make_input_packet(SS_CLIPBOARD_TEXT_MAGIC, prefix, sizeof(std::uint32_t) + prefix); + EXPECT_TRUE(input::testing::is_valid_input_packet(empty_packet)); + + constexpr auto maximum_declared_size = static_cast(prefix + SS_CLIPBOARD_TEXT_MAX); + const auto maximum_packet = make_input_packet( + SS_CLIPBOARD_TEXT_MAGIC, + maximum_declared_size, + sizeof(std::uint32_t) + maximum_declared_size + ); + EXPECT_TRUE(input::testing::is_valid_input_packet(maximum_packet)); +} + +TEST(InputPacketValidationTest, RejectsTruncatedAndOversizedClipboardPackets) { + constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + const auto truncated_packet = make_input_packet(SS_CLIPBOARD_TEXT_MAGIC, prefix + 4, sizeof(std::uint32_t) + prefix); + EXPECT_FALSE(input::testing::is_valid_input_packet(truncated_packet)); + + constexpr auto oversized_declared_size = static_cast(prefix + SS_CLIPBOARD_TEXT_MAX + 1); + const auto oversized_packet = make_input_packet( + SS_CLIPBOARD_TEXT_MAGIC, + oversized_declared_size, + sizeof(std::uint32_t) + oversized_declared_size + ); + EXPECT_FALSE(input::testing::is_valid_input_packet(oversized_packet)); +} + TEST(InputPacketValidationTest, EnforcesEveryFixedPacketSize) { constexpr std::array packet_specs { input_packet_spec_t {MOUSE_MOVE_REL_MAGIC_GEN5, sizeof(NV_REL_MOUSE_MOVE_PACKET)}, From 38c82cae1cfdcd2520ea5430b58493057d9e4733 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:18:48 +0300 Subject: [PATCH 06/14] fix(linux): clear the clipboard Sonar findings --- src/platform/clipboard_sync.h | 14 +++++++------- src/platform/linux/clipboard.cpp | 2 +- tests/unit/test_clipboard.cpp | 20 +++++++++++--------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/platform/clipboard_sync.h b/src/platform/clipboard_sync.h index df9512ba762..d3cd5ba148d 100644 --- a/src/platform/clipboard_sync.h +++ b/src/platform/clipboard_sync.h @@ -8,9 +8,9 @@ #include #include #include -#include #include #include +#include #include #include @@ -62,7 +62,7 @@ namespace platf { * @param queue Queue that should no longer receive clipboard text. */ void remove(const clipboard_queue_t &queue) { - std::erase_if(entries_, [&](const auto &entry) { + std::erase_if(entries_, [&queue](const auto &entry) { return entry.queue == queue; }); } @@ -116,7 +116,7 @@ namespace platf { * @param text UTF-8 clipboard text. * @return The subscriber queue when a delivery is required. */ - static clipboard_queue_t deliver(clipboard_subscriber_t &entry, const std::string &text) { + static clipboard_queue_t deliver(clipboard_subscriber_t &entry, std::string_view text) { if (entry.has_snapshot && entry.last_sent == text) { return {}; } @@ -183,7 +183,7 @@ namespace platf { std::uint64_t actual_type, int actual_format, std::size_t item_count, - const void *data, + const std::byte *data, std::uint64_t utf8_atom, std::uint64_t string_atom, std::uint64_t incr_atom @@ -224,14 +224,14 @@ namespace platf { * @param data Property bytes. May be null when the item count is zero. * @param size Number of bytes available at data. */ - static void append_to(std::string &text, const void *data, std::size_t size) { + static void append_to(std::string &text, const std::byte *data, std::size_t size) { if (data == nullptr || size == 0 || text.size() >= clipboard_max_bytes) { return; } const auto take = std::min(size, clipboard_max_bytes - text.size()); const auto offset = text.size(); text.resize(offset + take); - std::memcpy(text.data() + offset, data, take); + std::copy_n(data, take, reinterpret_cast(text.data() + offset)); } /** @@ -240,7 +240,7 @@ namespace platf { * @param data Chunk bytes. * @param size Number of bytes in the chunk. */ - void append_bytes(const void *data, std::size_t size) { + void append_bytes(const std::byte *data, std::size_t size) { append_to(text_, data, size); } diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 7728d96a192..79c4aaeb810 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -235,7 +235,7 @@ namespace platf { return; } static_cast(bytes_after); - auto text = transfer.consume(actual_type, actual_format, item_count, data, utf8, XA_STRING, incr); + auto text = transfer.consume(actual_type, actual_format, item_count, reinterpret_cast(data), utf8, XA_STRING, incr); if (data != nullptr) { XFree(data); } diff --git a/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp index 1d7ddd50945..050aa5f5d7d 100644 --- a/tests/unit/test_clipboard.cpp +++ b/tests/unit/test_clipboard.cpp @@ -7,7 +7,9 @@ #include "../tests_common.h" // standard includes +#include #include +#include #include #include #include @@ -145,10 +147,10 @@ TEST(ClipboardSelectionTest, DoesNotPublishAnIncrSizeAnnouncement) { constexpr std::uint64_t utf8 = 1; constexpr std::uint64_t string_atom = 2; constexpr std::uint64_t incr = 3; - const unsigned char announcement[] = {0x2a, 0x00, 0x00, 0x00}; + constexpr std::array announcement {std::byte {0x2a}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; platf::incr_transfer_t transfer; - const auto text = transfer.consume(incr, 32, 1, announcement, utf8, string_atom, incr); + const auto text = transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr); EXPECT_FALSE(text); EXPECT_TRUE(transfer.active()); @@ -158,11 +160,11 @@ TEST(ClipboardSelectionTest, PublishesIncrChunksOnlyAfterTheTransferEnds) { constexpr std::uint64_t utf8 = 1; constexpr std::uint64_t string_atom = 2; constexpr std::uint64_t incr = 3; - const unsigned char announcement[] = {0x04, 0x00, 0x00, 0x00}; - const char chunk[] = {'a', 'b', 'c', 'd'}; + constexpr std::array announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; + constexpr std::array chunk {std::byte {'a'}, std::byte {'b'}, std::byte {'c'}, std::byte {'d'}}; platf::incr_transfer_t transfer; - EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement, utf8, string_atom, incr)); - EXPECT_FALSE(transfer.consume(utf8, 8, sizeof(chunk), chunk, utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(utf8, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); @@ -175,10 +177,10 @@ TEST(ClipboardSelectionTest, IgnoresUnsupportedSelectionPropertyData) { constexpr std::uint64_t utf8 = 1; constexpr std::uint64_t string_atom = 2; constexpr std::uint64_t incr = 3; - const char chunk[] = {'n', 'o', 'p', 'e'}; + constexpr std::array chunk {std::byte {'n'}, std::byte {'o'}, std::byte {'p'}, std::byte {'e'}}; platf::incr_transfer_t transfer; - const auto text = transfer.consume(99, 8, sizeof(chunk), chunk, utf8, string_atom, incr); + const auto text = transfer.consume(99, 8, chunk.size(), chunk.data(), utf8, string_atom, incr); EXPECT_FALSE(text); EXPECT_FALSE(transfer.active()); @@ -188,7 +190,7 @@ TEST(ClipboardSelectionTest, CapsClipboardTextAtTheProtocolLimit) { constexpr std::uint64_t utf8 = 1; constexpr std::uint64_t string_atom = 2; constexpr std::uint64_t incr = 3; - const std::string oversized(platf::clipboard_max_bytes + 10, 'z'); + const std::vector oversized(platf::clipboard_max_bytes + 10, std::byte {'z'}); platf::incr_transfer_t transfer; const auto text = transfer.consume(utf8, 8, oversized.size(), oversized.data(), utf8, string_atom, incr); From c3b762143cdee1c58dfc242a27a60f771fff0a63 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:40:17 +0300 Subject: [PATCH 07/14] fix(linux): acknowledge oversized INCR clipboard chunks A chunk larger than one X read stayed on the window, so the owner never sent the rest of a large copy. Unsupported INCR data was also published when the terminator arrived. --- src/platform/clipboard_sync.h | 142 ++++++++++++++++++++++++++++++- src/platform/linux/clipboard.cpp | 62 +++++++++++--- tests/unit/test_clipboard.cpp | 130 ++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 14 deletions(-) diff --git a/src/platform/clipboard_sync.h b/src/platform/clipboard_sync.h index d3cd5ba148d..a85ceb5880f 100644 --- a/src/platform/clipboard_sync.h +++ b/src/platform/clipboard_sync.h @@ -149,6 +149,127 @@ namespace platf { return requested; } + /** + * @brief Byte width of one X property item. + * + * @param format Property format in bits. 8, 16, and 32 are the X formats. + * @return Number of bytes in one item. Unknown formats count as one byte. + */ + inline std::size_t property_byte_width(int format) { + if (format == 16) { + return 2; + } + if (format == 32) { + return 4; + } + return 1; + } + + /** + * @brief 32-bit units to advance after one XGetWindowProperty read. + * + * The X offset is in 32-bit units. A zero step with bytes still remaining cannot + * make progress, so the caller deletes the property instead of reading forever. + * + * @param format Property format in bits. + * @param item_count Items returned by the read. + * @return Offset advance, or zero when the read returned no complete 32-bit unit. + */ + inline unsigned long selection_offset_step(int format, std::size_t item_count) { + const auto nbytes = item_count * property_byte_width(format); + return static_cast(nbytes / 4); + } + + /** + * @brief Maximum 32-bit units read from one selection property at a time. + * + * This is the protocol cap rounded up to a 32-bit unit, which is the longest read + * XGetWindowProperty is given. A larger chunk stays on the window until a later read + * or an explicit delete brings bytes_after to zero. + */ + inline constexpr unsigned long selection_property_long_length = (clipboard_max_bytes + 3) / 4; + + /** + * @brief One slice of a selection property, as XGetWindowProperty returns it. + */ + struct selection_fragment_t { + bool ok = false; ///< Whether the property read succeeded. + std::uint64_t type = 0; ///< Actual property type atom. + int format = 0; ///< Actual property format, in bits. + std::size_t item_count = 0; ///< Items stored in data. + std::size_t bytes_after = 0; ///< Bytes still unread. X11 deletes the property only when this is zero. + std::vector data; ///< Bytes returned by this read. + }; + + /** + * @brief Selection property assembled up to the protocol cap. + */ + struct loaded_selection_t { + std::uint64_t type = 0; ///< Property type from the first read. + int format = 0; ///< Property format from the first read. + std::vector data; ///< Property bytes, capped at clipboard_max_bytes. + bool acknowledged = false; ///< Whether the last read had no unread tail, so X11 deleted the property. + }; + + /** + * @brief Copy property bytes without passing the protocol cap. + * + * @param out Destination bytes. + * @param chunk Bytes returned by one property read. + */ + inline void append_property_bytes(std::vector &out, const std::vector &chunk) { + if (chunk.empty() || out.size() >= clipboard_max_bytes) { + return; + } + const auto take = std::min(chunk.size(), clipboard_max_bytes - out.size()); + const auto offset = out.size(); + out.resize(offset + take); + std::copy_n(chunk.data(), take, out.data() + offset); + } + + /** + * @brief Read a selection property until X11 can delete it. + * + * Each fetch uses delete=True and selection_property_long_length. XGetWindowProperty + * deletes the property only when bytes_after is zero, so a chunk larger than the read + * is fetched again at the next 32-bit offset. Stored text still stops at the protocol cap. + * Fetch returns ok=false when the X call fails. The fragment's data must own its bytes. + * + * @tparam Fetch Reads one slice. Called with the 32-bit offset and the maximum 32-bit length. + * @param fetch Property read. It must use delete=True and own the returned bytes. + * @return The capped property, or empty when the first read fails. + */ + template + std::optional load_selection_property(Fetch &&fetch) { + loaded_selection_t loaded; + unsigned long offset = 0; + bool started = false; + while (true) { + const selection_fragment_t fragment = fetch(offset, selection_property_long_length); + if (!fragment.ok) { + if (!started) { + return std::nullopt; + } + return loaded; + } + if (!started) { + loaded.type = fragment.type; + loaded.format = fragment.format; + started = true; + } + append_property_bytes(loaded.data, fragment.data); + if (fragment.bytes_after == 0) { + loaded.acknowledged = true; + return loaded; + } + const auto step = selection_offset_step(fragment.format, fragment.item_count); + if (step == 0) { + return loaded; + } + offset += step; + } + } + /** * @brief Incremental X selection transfer assembled from INCR chunks. */ @@ -167,8 +288,9 @@ namespace platf { * @brief Accept one selection property and maybe finish a text value. * * An INCR property carries a byte-count announcement, not clipboard text. - * Format-8 chunks are appended until the empty property ends the transfer. - * Any other type is ignored. + * Format-8 UTF8_STRING and STRING chunks are appended until the empty property ends the transfer. + * An unsupported type discards the bytes and rejects the transfer. The transfer stays active + * until that empty text property so every chunk can be deleted, and the terminator is not published. * * @param actual_type Property type atom. * @param actual_format Property format, in bits. @@ -189,13 +311,24 @@ namespace platf { std::uint64_t incr_atom ) { if (active_) { - if (actual_format == 8 && item_count == 0) { + const bool text_chunk = actual_format == 8 && (actual_type == utf8_atom || actual_type == string_atom); + if (!text_chunk) { + rejected_ = true; + text_.clear(); + return std::nullopt; + } + if (item_count == 0) { active_ = false; auto done = std::move(text_); text_.clear(); + const bool rejected = rejected_; + rejected_ = false; + if (rejected) { + return std::nullopt; + } return done; } - if (actual_format == 8) { + if (!rejected_) { append_bytes(data, item_count); } return std::nullopt; @@ -245,6 +378,7 @@ namespace platf { } bool active_ = false; ///< Whether the empty INCR chunk has not arrived yet. + bool rejected_ = false; ///< Whether an unsupported chunk discarded this INCR transfer. std::string text_; ///< UTF-8 text accumulated from INCR chunks. }; } // namespace platf diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 79c4aaeb810..69b72dfeaec 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -5,7 +5,9 @@ #include "src/logging.h" #include "src/platform/common.h" +#include #include +#include #include #include #include @@ -226,19 +228,59 @@ namespace platf { }; auto read_property = [&]() { - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char *data = nullptr; - if (XGetWindowProperty(display, window, property, 0, (clipboard_max_bytes + 3) / 4, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success) { + const auto loaded = load_selection_property([display, window, property](unsigned long offset, unsigned long length) { + selection_fragment_t fragment; + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char *data = nullptr; + if (XGetWindowProperty( + display, + window, + property, + offset, + length, + True, + AnyPropertyType, + &actual_type, + &actual_format, + &item_count, + &bytes_after, + &data + ) != Success) { + if (data != nullptr) { + XFree(data); + } + return fragment; + } + fragment.ok = true; + fragment.type = actual_type; + fragment.format = actual_format; + fragment.item_count = item_count; + fragment.bytes_after = bytes_after; + if (data != nullptr) { + const auto limit = static_cast(length) * 4; + const auto reported = item_count * property_byte_width(actual_format); + const auto nbytes = std::min(reported, limit); + fragment.data.resize(nbytes); + if (nbytes > 0) { + std::copy_n(reinterpret_cast(data), nbytes, fragment.data.begin()); + } + XFree(data); + } + return fragment; + }); + if (!loaded) { return; } - static_cast(bytes_after); - auto text = transfer.consume(actual_type, actual_format, item_count, reinterpret_cast(data), utf8, XA_STRING, incr); - if (data != nullptr) { - XFree(data); + if (!loaded->acknowledged) { + XDeleteProperty(display, window, property); } + const auto width = property_byte_width(loaded->format); + const auto items = loaded->data.size() / width; + const auto *bytes = loaded->data.empty() ? nullptr : loaded->data.data(); + auto text = transfer.consume(loaded->type, loaded->format, items, bytes, utf8, XA_STRING, incr); if (text) { publish(*text); } diff --git a/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp index 050aa5f5d7d..96ff1511f7d 100644 --- a/tests/unit/test_clipboard.cpp +++ b/tests/unit/test_clipboard.cpp @@ -198,3 +198,133 @@ TEST(ClipboardSelectionTest, CapsClipboardTextAtTheProtocolLimit) { ASSERT_TRUE(text); EXPECT_EQ(text->size(), platf::clipboard_max_bytes); } + +TEST(ClipboardSelectionTest, DropsAnUnsupportedTypeReceivedDuringAnIncrTransfer) { + constexpr std::uint64_t utf8 = 1; + constexpr std::uint64_t string_atom = 2; + constexpr std::uint64_t incr = 3; + constexpr std::array announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; + constexpr std::array chunk {std::byte {'n'}, std::byte {'o'}, std::byte {'p'}, std::byte {'e'}}; + platf::incr_transfer_t transfer; + EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); + + EXPECT_FALSE(transfer.consume(99, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); + EXPECT_TRUE(transfer.active()); + + const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); + + EXPECT_FALSE(text); + EXPECT_FALSE(transfer.active()); +} + +TEST(ClipboardSelectionTest, AcceptsANewIncrTransferAfterRejectingAnUnsupportedChunk) { + constexpr std::uint64_t utf8 = 1; + constexpr std::uint64_t string_atom = 2; + constexpr std::uint64_t incr = 3; + constexpr std::array announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; + constexpr std::array chunk {std::byte {'a'}, std::byte {'b'}, std::byte {'c'}, std::byte {'d'}}; + platf::incr_transfer_t transfer; + EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(99, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr)); + + EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(utf8, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); + const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); + + ASSERT_TRUE(text); + EXPECT_EQ(*text, "abcd"); +} + +TEST(ClipboardSelectionTest, AcknowledgesAChunkLargerThanTheReadLimit) { + constexpr std::size_t property_bytes = 64 * 1024; + std::vector property(property_bytes, std::byte {'A'}); + property[platf::selection_property_long_length * 4] = std::byte {'B'}; + bool exists = true; + int reads = 0; + + const auto loaded = platf::load_selection_property([&property, &exists, &reads](unsigned long offset, unsigned long length) { + platf::selection_fragment_t fragment; + fragment.ok = true; + fragment.type = 1; + fragment.format = 8; + if (!exists) { + fragment.type = 0; + return fragment; + } + ++reads; + const auto start = static_cast(offset) * 4; + if (start >= property.size()) { + exists = false; + return fragment; + } + const auto take = std::min(static_cast(length) * 4, property.size() - start); + fragment.item_count = take; + fragment.data.assign( + property.begin() + static_cast(start), + property.begin() + static_cast(start + take) + ); + fragment.bytes_after = property.size() - start - take; + if (fragment.bytes_after == 0) { + exists = false; + } + return fragment; + }); + + ASSERT_TRUE(loaded); + EXPECT_FALSE(exists); + EXPECT_GE(reads, 2); + EXPECT_TRUE(loaded->acknowledged); + EXPECT_EQ(loaded->data.size(), platf::clipboard_max_bytes); + EXPECT_EQ(loaded->data.front(), std::byte {'A'}); + EXPECT_EQ(loaded->data.back(), std::byte {'A'}); +} + +TEST(ClipboardSelectionTest, ReadsAShortPropertyInOneCall) { + const std::vector property {std::byte {'h'}, std::byte {'i'}}; + int reads = 0; + const auto loaded = platf::load_selection_property([&property, &reads](unsigned long, unsigned long) { + ++reads; + platf::selection_fragment_t fragment; + fragment.ok = true; + fragment.type = 1; + fragment.format = 8; + fragment.item_count = property.size(); + fragment.data = property; + fragment.bytes_after = 0; + return fragment; + }); + + ASSERT_TRUE(loaded); + EXPECT_EQ(reads, 1); + EXPECT_TRUE(loaded->acknowledged); + EXPECT_EQ(loaded->data, property); +} + +TEST(ClipboardSelectionTest, LeavesAnUnreadableTailForTheCallerToDelete) { + int reads = 0; + const auto loaded = platf::load_selection_property([&reads](unsigned long, unsigned long) { + ++reads; + platf::selection_fragment_t fragment; + fragment.ok = true; + fragment.type = 1; + fragment.format = 8; + fragment.item_count = 1; + fragment.bytes_after = 10; + fragment.data = {std::byte {'x'}}; + return fragment; + }); + + ASSERT_TRUE(loaded); + EXPECT_EQ(reads, 1); + EXPECT_FALSE(loaded->acknowledged); + EXPECT_EQ(loaded->data, (std::vector {std::byte {'x'}})); +} + +TEST(ClipboardSelectionTest, IgnoresAFailedPropertyRead) { + const auto loaded = platf::load_selection_property([](unsigned long, unsigned long) { + return platf::selection_fragment_t {}; + }); + + EXPECT_FALSE(loaded); +} From aef1741ac25cf2debbedf5e91332f433899594e1 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:06:48 +0300 Subject: [PATCH 08/14] fix(linux): clear the remaining clipboard Sonar findings The quality gate failed on the declaration scope, the long test lambda, and an explicit vector type. --- src/platform/clipboard_sync.h | 6 ++- tests/unit/test_clipboard.cpp | 86 +++++++++++++++++++++-------------- 2 files changed, 56 insertions(+), 36 deletions(-) diff --git a/src/platform/clipboard_sync.h b/src/platform/clipboard_sync.h index a85ceb5880f..616f3f1b001 100644 --- a/src/platform/clipboard_sync.h +++ b/src/platform/clipboard_sync.h @@ -311,8 +311,10 @@ namespace platf { std::uint64_t incr_atom ) { if (active_) { - const bool text_chunk = actual_format == 8 && (actual_type == utf8_atom || actual_type == string_atom); - if (!text_chunk) { + if ( + const bool text_chunk = actual_format == 8 && (actual_type == utf8_atom || actual_type == string_atom); + !text_chunk + ) { rejected_ = true; text_.clear(); return std::nullopt; diff --git a/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp index 96ff1511f7d..894d31b2d98 100644 --- a/tests/unit/test_clipboard.cpp +++ b/tests/unit/test_clipboard.cpp @@ -59,6 +59,50 @@ namespace { } return texts; } + + /** + * @brief Selection property that disappears only after its last byte is read. + */ + struct capped_property_t { + std::vector bytes; ///< Full property contents. + bool exists = true; ///< Whether the property is still on the window. + int reads = 0; ///< How many times the property was read. + + /** + * @brief Return one bounded slice of the property. + * + * @param offset Offset in 32-bit units. + * @param length Maximum length in 32-bit units. + * @return The slice a deleting XGetWindowProperty read would return. + */ + platf::selection_fragment_t read(unsigned long offset, unsigned long length) { + platf::selection_fragment_t fragment; + fragment.ok = true; + fragment.type = 1; + fragment.format = 8; + if (!exists) { + fragment.type = 0; + return fragment; + } + ++reads; + const auto start = static_cast(offset) * 4; + if (start >= bytes.size()) { + exists = false; + return fragment; + } + const auto take = std::min(static_cast(length) * 4, bytes.size() - start); + fragment.item_count = take; + fragment.data.assign( + bytes.begin() + static_cast(start), + bytes.begin() + static_cast(start + take) + ); + fragment.bytes_after = bytes.size() - start - take; + if (fragment.bytes_after == 0) { + exists = false; + } + return fragment; + } + }; } // namespace TEST(ClipboardSubscriptionTest, DeliversOneHostChangeToEverySubscriber) { @@ -237,43 +281,17 @@ TEST(ClipboardSelectionTest, AcceptsANewIncrTransferAfterRejectingAnUnsupportedC } TEST(ClipboardSelectionTest, AcknowledgesAChunkLargerThanTheReadLimit) { - constexpr std::size_t property_bytes = 64 * 1024; - std::vector property(property_bytes, std::byte {'A'}); - property[platf::selection_property_long_length * 4] = std::byte {'B'}; - bool exists = true; - int reads = 0; + capped_property_t property; + property.bytes.assign(64 * 1024, std::byte {'A'}); + property.bytes[platf::selection_property_long_length * 4] = std::byte {'B'}; - const auto loaded = platf::load_selection_property([&property, &exists, &reads](unsigned long offset, unsigned long length) { - platf::selection_fragment_t fragment; - fragment.ok = true; - fragment.type = 1; - fragment.format = 8; - if (!exists) { - fragment.type = 0; - return fragment; - } - ++reads; - const auto start = static_cast(offset) * 4; - if (start >= property.size()) { - exists = false; - return fragment; - } - const auto take = std::min(static_cast(length) * 4, property.size() - start); - fragment.item_count = take; - fragment.data.assign( - property.begin() + static_cast(start), - property.begin() + static_cast(start + take) - ); - fragment.bytes_after = property.size() - start - take; - if (fragment.bytes_after == 0) { - exists = false; - } - return fragment; + const auto loaded = platf::load_selection_property([&property](unsigned long offset, unsigned long length) { + return property.read(offset, length); }); ASSERT_TRUE(loaded); - EXPECT_FALSE(exists); - EXPECT_GE(reads, 2); + EXPECT_FALSE(property.exists); + EXPECT_GE(property.reads, 2); EXPECT_TRUE(loaded->acknowledged); EXPECT_EQ(loaded->data.size(), platf::clipboard_max_bytes); EXPECT_EQ(loaded->data.front(), std::byte {'A'}); @@ -281,7 +299,7 @@ TEST(ClipboardSelectionTest, AcknowledgesAChunkLargerThanTheReadLimit) { } TEST(ClipboardSelectionTest, ReadsAShortPropertyInOneCall) { - const std::vector property {std::byte {'h'}, std::byte {'i'}}; + const std::vector property {std::byte {'h'}, std::byte {'i'}}; int reads = 0; const auto loaded = platf::load_selection_property([&property, &reads](unsigned long, unsigned long) { ++reads; From c843d3b58261b0bd7e544c374fb7be581b3f63c8 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:23:20 +0300 Subject: [PATCH 09/14] style(linux): format the clipboard property reader The pinned clang-format check failed on the template keyword and the property read call. --- src/platform/clipboard_sync.h | 2 +- src/platform/linux/clipboard.cpp | 15 +-------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/platform/clipboard_sync.h b/src/platform/clipboard_sync.h index 616f3f1b001..249b9f641a1 100644 --- a/src/platform/clipboard_sync.h +++ b/src/platform/clipboard_sync.h @@ -239,7 +239,7 @@ namespace platf { * @param fetch Property read. It must use delete=True and own the returned bytes. * @return The capped property, or empty when the first read fails. */ - template + template std::optional load_selection_property(Fetch &&fetch) { loaded_selection_t loaded; unsigned long offset = 0; diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 69b72dfeaec..95687833a3c 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -235,20 +235,7 @@ namespace platf { unsigned long item_count = 0; unsigned long bytes_after = 0; unsigned char *data = nullptr; - if (XGetWindowProperty( - display, - window, - property, - offset, - length, - True, - AnyPropertyType, - &actual_type, - &actual_format, - &item_count, - &bytes_after, - &data - ) != Success) { + if (XGetWindowProperty(display, window, property, offset, length, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success) { if (data != nullptr) { XFree(data); } From 40f4b838fa826d2a567a377b42cff04b9749a9e4 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:36:46 +0300 Subject: [PATCH 10/14] fix(linux): drop the unused clipboard echo token The protocol no longer carries a token. Echoes are already ignored by comparing the text. --- src/input.cpp | 6 +++--- src/platform/common.h | 1 - src/platform/linux/clipboard.cpp | 8 +------- src/stream.cpp | 10 ++++------ tests/unit/test_clipboard.cpp | 2 +- tests/unit/test_input.cpp | 4 ++-- third-party/moonlight-common-c | 2 +- 7 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index 3e25b724caf..79469edaa9b 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -1321,7 +1321,7 @@ namespace input { */ void passthrough_clipboard(const std::shared_ptr &input, const NV_INPUT_HEADER *header) { auto declared = util::endian::big(header->size); - constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + constexpr auto prefix = sizeof(std::uint32_t); if (declared < prefix) { return; } @@ -1332,7 +1332,7 @@ namespace input { return; } - const auto *text = reinterpret_cast(header) + sizeof(NV_INPUT_HEADER) + sizeof(std::uint32_t); + const auto *text = reinterpret_cast(header) + sizeof(NV_INPUT_HEADER); std::string copy(text_len, '\0'); std::memcpy(copy.data(), static_cast(text), text_len); platf::clipboard_set(copy, input->clipboard_queue); @@ -1788,7 +1788,7 @@ namespace input { return declared_size - sizeof(header.magic) <= UTF8_TEXT_EVENT_MAX_COUNT; case SS_CLIPBOARD_TEXT_MAGIC: { - constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + constexpr auto prefix = sizeof(std::uint32_t); return declared_size >= prefix && declared_size - prefix <= SS_CLIPBOARD_TEXT_MAX; } case MULTI_CONTROLLER_MAGIC_GEN5: diff --git a/src/platform/common.h b/src/platform/common.h index 2e0e378215d..dbc237595b7 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -277,7 +277,6 @@ namespace platf { * @brief Clipboard text waiting to be sent to the client. */ struct clipboard_text_t { - std::uint32_t token; ///< Echo-suppression token for this update. std::string text; ///< UTF-8 clipboard text. }; diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 95687833a3c..f919af027e3 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -81,13 +80,8 @@ namespace platf { return; } - std::random_device seed; for (const auto &queue : queues) { - auto token = seed(); - if (token == 0) { - token = 1; - } - queue->raise(clipboard_text_t {token, text}); + queue->raise(clipboard_text_t {text}); } } diff --git a/src/stream.cpp b/src/stream.cpp index 5ecda8720cc..364d3f56fc7 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -1135,7 +1135,7 @@ namespace stream { * @brief Send clipboard text to the client over the control channel. * * @param session Active streaming session. - * @param msg Clipboard text and echo token. + * @param msg Clipboard text to send. * @return 0 when the control message is queued. */ int send_clipboard_text(session_t *session, const platf::clipboard_text_t &msg) { @@ -1145,17 +1145,15 @@ namespace stream { control_header_v2 header {}; header.type = SS_CLIPBOARD_CONTROL_PTYPE; - header.payloadLength = static_cast(sizeof(std::uint32_t) * 2 + msg.text.size()); + header.payloadLength = static_cast(sizeof(std::uint32_t) + msg.text.size()); std::vector plaintext(sizeof(header) + header.payloadLength); std::memcpy(plaintext.data(), &header, sizeof(header)); - auto token = util::endian::little(msg.token); auto length = util::endian::little(static_cast(msg.text.size())); auto *payload = plaintext.data() + sizeof(header); - std::memcpy(payload, &token, sizeof(token)); - std::memcpy(payload + sizeof(token), &length, sizeof(length)); - std::memcpy(payload + sizeof(token) + sizeof(length), msg.text.data(), msg.text.size()); + std::memcpy(payload, &length, sizeof(length)); + std::memcpy(payload + sizeof(length), msg.text.data(), msg.text.size()); const auto tagged_size = sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + crypto::cipher::tag_size; std::vector tagged(tagged_size); diff --git a/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp index 894d31b2d98..76368c8a183 100644 --- a/tests/unit/test_clipboard.cpp +++ b/tests/unit/test_clipboard.cpp @@ -38,7 +38,7 @@ namespace { */ void deliver(const std::vector &queues, const std::string &text) { for (const auto &queue : queues) { - queue->raise(platf::clipboard_text_t {1, text}); + queue->raise(platf::clipboard_text_t {text}); } } diff --git a/tests/unit/test_input.cpp b/tests/unit/test_input.cpp index a78e8caf282..1506ab8058d 100644 --- a/tests/unit/test_input.cpp +++ b/tests/unit/test_input.cpp @@ -169,7 +169,7 @@ TEST(InputPacketValidationTest, ValidatesUnicodePacketBoundsWithoutOverflow) { } TEST(InputPacketValidationTest, AcceptsEmptyAndMaximumClipboardPackets) { - constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + constexpr auto prefix = sizeof(std::uint32_t); const auto empty_packet = make_input_packet(SS_CLIPBOARD_TEXT_MAGIC, prefix, sizeof(std::uint32_t) + prefix); EXPECT_TRUE(input::testing::is_valid_input_packet(empty_packet)); @@ -183,7 +183,7 @@ TEST(InputPacketValidationTest, AcceptsEmptyAndMaximumClipboardPackets) { } TEST(InputPacketValidationTest, RejectsTruncatedAndOversizedClipboardPackets) { - constexpr auto prefix = sizeof(std::uint32_t) + sizeof(std::uint32_t); + constexpr auto prefix = sizeof(std::uint32_t); const auto truncated_packet = make_input_packet(SS_CLIPBOARD_TEXT_MAGIC, prefix + 4, sizeof(std::uint32_t) + prefix); EXPECT_FALSE(input::testing::is_valid_input_packet(truncated_packet)); diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index cfd66102822..f197f1d5e46 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit cfd661028220a2c1bb8536cb49b2573f1a317f90 +Subproject commit f197f1d5e46262394c3c094c7d7fcf465dd50ab7 From 75e35801debd3168b20c5d98a402198df6ecdacd Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:26:00 +0300 Subject: [PATCH 11/14] fix(linux): clear the clipboard watcher Sonar findings The watcher is split into smaller functions, and its thread stays joinable for the life of the process. --- src/platform/linux/clipboard.cpp | 375 +++++++++++++++++++------------ 1 file changed, 232 insertions(+), 143 deletions(-) diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index f919af027e3..26740525f7a 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -6,10 +6,13 @@ #include "src/platform/common.h" #include +#include #include #include +#include #include #include +#include #include #include @@ -56,7 +59,7 @@ namespace platf { int wake_write = -1; ///< Write end of the pipe that wakes the watcher thread. std::string pending; ///< Client clipboard text waiting to become the selection owner. clipboard_subscribers_t subscribers; ///< One entry for each streaming client that opted in. - std::thread thread; ///< Detached X11 watcher thread. + std::jthread thread; ///< X11 watcher thread. }; /** @@ -100,6 +103,29 @@ namespace platf { raise_clipboard(queues, text); } + /** + * @brief Mark the watcher failed and release the display connection. + * + * @param wake_read Read end of the wakeup pipe, or -1 when it was not opened. + * @param wake_write Write end of the wakeup pipe, or -1 when it was not opened. + * @param display X display connection, or null when it was not opened. + */ + void fail_watcher(int wake_read, int wake_write, Display *display) { + if (display != nullptr) { + XCloseDisplay(display); + } + if (wake_read >= 0) { + close(wake_read); + } + if (wake_write >= 0) { + close(wake_write); + } + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.failed = true; + clipboard.ready_cv.notify_all(); + } + /** * @brief Answer one X selection request with the text Sunshine owns. * @@ -109,22 +135,23 @@ namespace platf { * @param utf8 UTF8_STRING atom. * @param targets TARGETS atom. */ - void serve_selection(Display *display, const std::string &text, XSelectionRequestEvent *request, Atom utf8, Atom targets) { - XEvent notify {}; - notify.xselection.type = SelectionNotify; - notify.xselection.display = request->display; - notify.xselection.requestor = request->requestor; - notify.xselection.selection = request->selection; - notify.xselection.target = request->target; - notify.xselection.property = None; - notify.xselection.time = request->time; + void serve_selection(Display *display, std::string_view text, const XSelectionRequestEvent *request, Atom utf8, Atom targets) { + XSelectionEvent selection {}; + selection.type = SelectionNotify; + selection.display = request->display; + selection.requestor = request->requestor; + selection.selection = request->selection; + selection.target = request->target; + selection.time = request->time; const bool supported = request->target == targets || request->target == utf8 || request->target == XA_STRING; const auto property = static_cast(selection_reply_property(request->property, request->target, supported)); - notify.xselection.property = property; + selection.property = property; if (property != None && request->target == targets) { - Atom list[3] = {utf8, XA_STRING, targets}; - XChangeProperty(display, request->requestor, property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(list), 3); + const std::array list {utf8, XA_STRING, targets}; + std::array bytes {}; + std::memcpy(bytes.data(), list.data(), bytes.size()); + XChangeProperty(display, request->requestor, property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(bytes.data()), static_cast(list.size())); } else if (property != None) { XChangeProperty( display, @@ -138,35 +165,196 @@ namespace platf { ); } + XEvent notify {}; + notify.xselection = selection; XSendEvent(display, request->requestor, False, 0, ¬ify); } /** - * @brief Watch the X11 CLIPBOARD selection until the process exits. + * @brief Read one slice of the selection property. + * + * @param display X display connection owned by the watcher thread. + * @param window Window that holds the property. + * @param property Property atom being read. + * @param offset Offset in 32-bit units. + * @param length Maximum length in 32-bit units. + * @return The slice. ok is false when the X call fails. */ - void thread_main() { - int wake_read = -1; - int wake_write = -1; - int pipes[2] = {-1, -1}; - if (pipe2(pipes, O_CLOEXEC | O_NONBLOCK) != 0) { + selection_fragment_t read_selection_fragment(Display *display, Window window, Atom property, unsigned long offset, unsigned long length) { + selection_fragment_t fragment; + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char *data = nullptr; + if (XGetWindowProperty(display, window, property, offset, length, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success) { + if (data != nullptr) { + XFree(data); + } + return fragment; + } + fragment.ok = true; + fragment.type = actual_type; + fragment.format = actual_format; + fragment.item_count = item_count; + fragment.bytes_after = bytes_after; + if (data != nullptr) { + const auto limit = static_cast(length) * 4; + const auto reported = item_count * property_byte_width(actual_format); + const auto nbytes = std::min(reported, limit); + fragment.data.resize(nbytes); + if (nbytes > 0) { + std::copy_n(reinterpret_cast(data), nbytes, fragment.data.begin()); + } + XFree(data); + } + return fragment; + } + + /** + * @brief Publish clipboard text from the window property, deleting an unread tail. + * + * @param display X display connection owned by the watcher thread. + * @param window Window that holds the property. + * @param property Property atom being read. + * @param utf8 UTF8_STRING atom. + * @param incr INCR atom. + * @param transfer Incremental transfer assembled across property events. + */ + void publish_property(Display *display, Window window, Atom property, Atom utf8, Atom incr, incr_transfer_t &transfer) { + const auto loaded = load_selection_property([display, window, property](unsigned long offset, unsigned long length) { + return read_selection_fragment(display, window, property, offset, length); + }); + if (!loaded) { + return; + } + if (!loaded->acknowledged) { + XDeleteProperty(display, window, property); + } + const auto width = property_byte_width(loaded->format); + const auto items = loaded->data.size() / width; + const auto *bytes = loaded->data.empty() ? nullptr : loaded->data.data(); + if (const auto text = transfer.consume(loaded->type, loaded->format, items, bytes, utf8, XA_STRING, incr)) { + publish(*text); + } + } + + /** + * @brief Take ownership of the selection when a client clipboard is waiting. + * + * @param display X display connection owned by the watcher thread. + * @param clipboard_atom CLIPBOARD atom. + * @param window Window that becomes the selection owner. + * @param owned Text Sunshine now owns. + */ + void apply_pending(Display *display, Atom clipboard_atom, Window window, std::string &owned) { + std::string text; + { auto &clipboard = state(); std::lock_guard lock {clipboard.mutex}; - clipboard.failed = true; - clipboard.ready_cv.notify_all(); + if (!clipboard.has_pending) { + return; + } + text = std::move(clipboard.pending); + clipboard.has_pending = false; + } + owned = std::move(text); + XSetSelectionOwner(display, clipboard_atom, window, CurrentTime); + XFlush(display); + } + + /** + * @brief Ask the selection owner for its UTF-8 text. + * + * @param display X display connection owned by the watcher thread. + * @param clipboard_atom CLIPBOARD atom. + * @param utf8 UTF8_STRING atom. + * @param property Property that receives the reply. + * @param window Requestor window. + */ + void convert_selection(Display *display, Atom clipboard_atom, Atom utf8, Atom property, Window window) { + XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); + XFlush(display); + } + + /** + * @brief Handle one X event for the clipboard watcher. + * + * @param display X display connection owned by the watcher thread. + * @param event Event just removed from the queue. + * @param event_base First XFixes event code. + * @param window Watcher window. + * @param clipboard_atom CLIPBOARD atom. + * @param utf8 UTF8_STRING atom. + * @param incr INCR atom. + * @param targets TARGETS atom. + * @param property Property used for selection replies. + * @param owned Text Sunshine owns, when it owns the selection. + * @param transfer Incremental transfer assembled across property events. + */ + void handle_event(Display *display, const XEvent &event, int event_base, Window window, Atom clipboard_atom, Atom utf8, Atom incr, Atom targets, Atom property, std::string &owned, incr_transfer_t &transfer) { + if (event.type == SelectionRequest) { + serve_selection(display, owned, &event.xselectionrequest, utf8, targets); + XFlush(display); + } else if (event.type == SelectionNotify) { + if (event.xselection.property != None) { + publish_property(display, window, property, utf8, incr, transfer); + } + } else if (event.type == PropertyNotify && transfer.active() && event.xproperty.atom == property && event.xproperty.state == PropertyNewValue) { + publish_property(display, window, property, utf8, incr, transfer); + } else if (event.type == event_base + XFixesSelectionNotify) { + XFixesSelectionNotifyEvent selection {}; + std::memcpy(&selection, &event, sizeof(selection)); + if (selection.selection == clipboard_atom && selection.owner != window && selection.owner != None) { + transfer = {}; + convert_selection(display, clipboard_atom, utf8, property, window); + } + } + } + + /** + * @brief Take and clear the request for the current host clipboard. + * + * @return True when a subscriber is still waiting for the current selection. + */ + bool consume_pull() { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + const bool pull = clipboard.pull; + clipboard.pull = false; + return pull; + } + + /** + * @brief Discard wakeup bytes that are only used to leave poll. + * + * @param wake_read Read end of the wakeup pipe. + */ + void drain_wake(int wake_read) { + std::array buffer {}; + while (read(wake_read, buffer.data(), buffer.size()) > 0) { + // The pipe only wakes the watcher. The bytes are not a message. + } + } + + /** + * @brief Watch the X11 CLIPBOARD selection until the process exits. + * + * @param stop Stop request delivered when the watcher thread is destroyed. + */ + void thread_main(std::stop_token stop) { + std::array pipes {-1, -1}; + if (pipe2(pipes.data(), O_CLOEXEC | O_NONBLOCK) != 0) { + fail_watcher(-1, -1, nullptr); return; } - wake_read = pipes[0]; - wake_write = pipes[1]; + const int wake_read = pipes[0]; + const int wake_write = pipes[1]; Display *display = XOpenDisplay(nullptr); - if (!display) { + if (display == nullptr) { BOOST_LOG(warning) << "Clipboard sync could not open the X display"sv; - close(wake_read); - close(wake_write); - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - clipboard.failed = true; - clipboard.ready_cv.notify_all(); + fail_watcher(wake_read, wake_write, nullptr); return; } @@ -174,13 +362,7 @@ namespace platf { int error_base = 0; if (!XFixesQueryExtension(display, &event_base, &error_base)) { BOOST_LOG(warning) << "Clipboard sync requires the XFixes extension"sv; - XCloseDisplay(display); - close(wake_read); - close(wake_write); - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - clipboard.failed = true; - clipboard.ready_cv.notify_all(); + fail_watcher(wake_read, wake_write, display); return; } @@ -205,116 +387,24 @@ namespace platf { clipboard.ready_cv.notify_all(); } - auto apply_pending = [&]() { - std::string text; - { - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - if (!clipboard.has_pending) { - return; - } - text = std::move(clipboard.pending); - clipboard.has_pending = false; - } - owned = text; - XSetSelectionOwner(display, clipboard_atom, window, CurrentTime); - XFlush(display); - }; - - auto read_property = [&]() { - const auto loaded = load_selection_property([display, window, property](unsigned long offset, unsigned long length) { - selection_fragment_t fragment; - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char *data = nullptr; - if (XGetWindowProperty(display, window, property, offset, length, True, AnyPropertyType, &actual_type, &actual_format, &item_count, &bytes_after, &data) != Success) { - if (data != nullptr) { - XFree(data); - } - return fragment; - } - fragment.ok = true; - fragment.type = actual_type; - fragment.format = actual_format; - fragment.item_count = item_count; - fragment.bytes_after = bytes_after; - if (data != nullptr) { - const auto limit = static_cast(length) * 4; - const auto reported = item_count * property_byte_width(actual_format); - const auto nbytes = std::min(reported, limit); - fragment.data.resize(nbytes); - if (nbytes > 0) { - std::copy_n(reinterpret_cast(data), nbytes, fragment.data.begin()); - } - XFree(data); - } - return fragment; - }); - if (!loaded) { - return; - } - if (!loaded->acknowledged) { - XDeleteProperty(display, window, property); - } - const auto width = property_byte_width(loaded->format); - const auto items = loaded->data.size() / width; - const auto *bytes = loaded->data.empty() ? nullptr : loaded->data.data(); - auto text = transfer.consume(loaded->type, loaded->format, items, bytes, utf8, XA_STRING, incr); - if (text) { - publish(*text); - } - }; - - int xfd = ConnectionNumber(display); - while (true) { - while (XPending(display)) { + const int xfd = ConnectionNumber(display); + while (!stop.stop_requested()) { + while (XPending(display) != 0) { XEvent event; XNextEvent(display, &event); - if (event.type == SelectionRequest) { - serve_selection(display, owned, &event.xselectionrequest, utf8, targets); - XFlush(display); - } else if (event.type == SelectionNotify) { - if (event.xselection.property != None) { - read_property(); - } - } else if (event.type == PropertyNotify && transfer.active() && event.xproperty.atom == property && event.xproperty.state == PropertyNewValue) { - read_property(); - } else if (event.type == event_base + XFixesSelectionNotify) { - auto *selection = reinterpret_cast(&event); - if (selection->selection == clipboard_atom && selection->owner != window && selection->owner != None) { - transfer = {}; - XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); - XFlush(display); - } - } + handle_event(display, event, event_base, window, clipboard_atom, utf8, incr, targets, property, owned, transfer); } - apply_pending(); - - bool pull = false; - { - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - pull = clipboard.pull; - clipboard.pull = false; - } - if (pull) { + apply_pending(display, clipboard_atom, window, owned); + if (consume_pull()) { transfer = {}; - XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); - XFlush(display); + convert_selection(display, clipboard_atom, utf8, property, window); } - pollfd fds[2] = { - {xfd, POLLIN, 0}, - {wake_read, POLLIN, 0}, - }; - poll(fds, 2, 200); - if (fds[1].revents & POLLIN) { - char buffer[64]; - while (read(wake_read, buffer, sizeof(buffer)) > 0) { - } + std::array fds {pollfd {xfd, POLLIN, 0}, pollfd {wake_read, POLLIN, 0}}; + poll(fds.data(), fds.size(), 200); + if ((fds[1].revents & POLLIN) != 0) { + drain_wake(wake_read); } } } @@ -351,10 +441,9 @@ namespace platf { std::unique_lock lock {clipboard.mutex}; if (!clipboard.started) { clipboard.started = true; - clipboard.thread = std::thread(thread_main); - clipboard.thread.detach(); + clipboard.thread = std::jthread(thread_main); } - clipboard.ready_cv.wait(lock, [&]() { + clipboard.ready_cv.wait(lock, [&clipboard]() { return clipboard.ready || clipboard.failed; }); if (!clipboard.ready || !clipboard.subscribers.add(std::move(queue))) { From 09658578b13110dfc9120fba2c5e03ee44f0d89c Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:54:31 +0300 Subject: [PATCH 12/14] fix(linux): clear the remaining clipboard watcher smells The selection targets are copied into the X buffer, and the event handler takes the watcher state in one argument. --- src/platform/linux/clipboard.cpp | 48 +++++++++++++++++++------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 26740525f7a..6bb89caf707 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -149,9 +149,9 @@ namespace platf { selection.property = property; if (property != None && request->target == targets) { const std::array list {utf8, XA_STRING, targets}; - std::array bytes {}; + std::array bytes {}; std::memcpy(bytes.data(), list.data(), bytes.size()); - XChangeProperty(display, request->requestor, property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(bytes.data()), static_cast(list.size())); + XChangeProperty(display, request->requestor, property, XA_ATOM, 32, PropModeReplace, bytes.data(), static_cast(list.size())); } else if (property != None) { XChangeProperty( display, @@ -277,37 +277,44 @@ namespace platf { XFlush(display); } + /** + * @brief X11 objects used to answer clipboard selection events. + */ + struct clipboard_watch_t { + Display *display; ///< X display connection owned by the watcher thread. + Window window; ///< Watcher window. + int event_base; ///< First XFixes event code. + Atom clipboard; ///< CLIPBOARD atom. + Atom utf8; ///< UTF8_STRING atom. + Atom incr; ///< INCR atom. + Atom targets; ///< TARGETS atom. + Atom property; ///< Property used for selection replies. + }; + /** * @brief Handle one X event for the clipboard watcher. * - * @param display X display connection owned by the watcher thread. + * @param watch Display, window, and atoms for this watcher. * @param event Event just removed from the queue. - * @param event_base First XFixes event code. - * @param window Watcher window. - * @param clipboard_atom CLIPBOARD atom. - * @param utf8 UTF8_STRING atom. - * @param incr INCR atom. - * @param targets TARGETS atom. - * @param property Property used for selection replies. * @param owned Text Sunshine owns, when it owns the selection. * @param transfer Incremental transfer assembled across property events. */ - void handle_event(Display *display, const XEvent &event, int event_base, Window window, Atom clipboard_atom, Atom utf8, Atom incr, Atom targets, Atom property, std::string &owned, incr_transfer_t &transfer) { + void handle_event(const clipboard_watch_t &watch, const XEvent &event, const std::string &owned, incr_transfer_t &transfer) { if (event.type == SelectionRequest) { - serve_selection(display, owned, &event.xselectionrequest, utf8, targets); - XFlush(display); + serve_selection(watch.display, owned, &event.xselectionrequest, watch.utf8, watch.targets); + XFlush(watch.display); } else if (event.type == SelectionNotify) { if (event.xselection.property != None) { - publish_property(display, window, property, utf8, incr, transfer); + publish_property(watch.display, watch.window, watch.property, watch.utf8, watch.incr, transfer); } - } else if (event.type == PropertyNotify && transfer.active() && event.xproperty.atom == property && event.xproperty.state == PropertyNewValue) { - publish_property(display, window, property, utf8, incr, transfer); - } else if (event.type == event_base + XFixesSelectionNotify) { + } else if (event.type == PropertyNotify && transfer.active() && event.xproperty.atom == watch.property && event.xproperty.state == PropertyNewValue) { + publish_property(watch.display, watch.window, watch.property, watch.utf8, watch.incr, transfer); + } else if (event.type == watch.event_base + XFixesSelectionNotify) { XFixesSelectionNotifyEvent selection {}; std::memcpy(&selection, &event, sizeof(selection)); - if (selection.selection == clipboard_atom && selection.owner != window && selection.owner != None) { + if (selection.selection == watch.clipboard && selection.owner != watch.window && selection.owner != None) { transfer = {}; - convert_selection(display, clipboard_atom, utf8, property, window); + convert_selection(watch.display, watch.clipboard, watch.utf8, watch.property, watch.window); } } } @@ -377,6 +384,7 @@ namespace platf { XFixesSelectSelectionInput(display, window, clipboard_atom, XFixesSetSelectionOwnerNotifyMask); XFlush(display); + const clipboard_watch_t watch {display, window, event_base, clipboard_atom, utf8, incr, targets, property}; std::string owned; incr_transfer_t transfer; { @@ -392,7 +400,7 @@ namespace platf { while (XPending(display) != 0) { XEvent event; XNextEvent(display, &event); - handle_event(display, event, event_base, window, clipboard_atom, utf8, incr, targets, property, owned, transfer); + handle_event(watch, event, owned, transfer); } apply_pending(display, clipboard_atom, window, owned); From 00d34f29bcbb9c0d55b352d02e623c99c3d023f0 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:54:23 +0300 Subject: [PATCH 13/14] feat(clipboard): sync text and images with offer, request, and data messages The client and host now offer the MIME types on their clipboard, request the ones they want, and send each as data, using the protocol in moonlight-common-c. Large data goes in jumbo control messages, which the control stream can now send and receive. On Linux, a host copy is read as text and one image (PNG preferred) and offered once. A client clipboard replaces every format Sunshine serves. Large selections are read and served with INCR, and X errors on the clipboard display are logged instead of exiting. --- cmake/compile_definitions/common.cmake | 2 + src/input.cpp | 164 ++++++- src/platform/clipboard_sync.h | 339 +++++++++----- src/platform/common.h | 52 ++- src/platform/linux/clipboard.cpp | 617 +++++++++++++++++-------- src/platform/macos/input.cpp | 2 +- src/platform/windows/input.cpp | 2 +- src/stream.cpp | 341 +++++++++++--- tests/unit/test_clipboard.cpp | 292 ++++++++---- tests/unit/test_input.cpp | 43 +- third-party/moonlight-common-c | 2 +- 11 files changed, 1328 insertions(+), 528 deletions(-) diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index f3b9390b3a4..9be33bcc223 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -131,6 +131,8 @@ endif() list(APPEND PLATFORM_TARGET_FILES ${NVENC_SOURCES}) set(SUNSHINE_TARGET_FILES + "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/src/Clipboard.c" + "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/src/Clipboard.h" "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/src/Input.h" "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/src/Rtsp.h" "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/src/RtspParser.c" diff --git a/src/input.cpp b/src/input.cpp index 4b720f09119..3983ccc59b8 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -5,6 +5,7 @@ #include #include extern "C" { +#include #include #include } @@ -24,6 +25,7 @@ extern "C" { #include #include #include +#include // lib includes #include @@ -33,6 +35,7 @@ extern "C" { #include "globals.h" #include "input.h" #include "logging.h" +#include "platform/clipboard_sync.h" #include "platform/common.h" #include "platform/virtualhid_input.h" #include "thread_pool.h" @@ -319,7 +322,35 @@ namespace input { safe::mail_raw_t::event_t touch_port_event; ///< Touch port event. platf::feedback_queue_t feedback_queue; ///< Queue used to deliver controller feedback to the platform backend. - platf::clipboard_queue_t clipboard_queue; ///< Queue used to deliver host clipboard text to the client. + platf::clipboard_queue_t clipboard_queue; ///< Queue used to deliver clipboard messages to the client. + + /** + * @brief Client clipboard being received after the client offered it. + */ + struct clipboard_incoming_t { + std::uint32_t sequence = 0; ///< Client offer being received. + std::vector waiting; ///< Requested MIME types that have not arrived. + platf::clipboard_data_t data; ///< Formats received so far. + CLIPBOARD_ASSEMBLER assembler {}; ///< Chunks of the format being received. + + clipboard_incoming_t() = default; + clipboard_incoming_t(const clipboard_incoming_t &) = delete; + clipboard_incoming_t &operator=(const clipboard_incoming_t &) = delete; + + ~clipboard_incoming_t() { + ClipboardAssemblerReset(&assembler); + } + + /** + * @brief Forget the offer being received. + */ + void clear() { + sequence = 0; + waiting.clear(); + data = {}; + ClipboardAssemblerReset(&assembler); + } + } clipboard_incoming; ///< Client clipboard being received. std::list> input_queue; ///< Validated input packets waiting for processing. std::mutex input_queue_lock; ///< Input queue lock. @@ -430,7 +461,7 @@ namespace input { void rebind_input(const std::shared_ptr &input, const safe::mail_t &mail) { input->touch_port_event = mail->event(mail::touch_port); input->feedback_queue = mail->queue(mail::gamepad_feedback); - input->clipboard_queue = mail->queue(mail::clipboard); + input->clipboard_queue = mail->queue(mail::clipboard); for (int client_index = 0; client_index < input->gamepads.size(); ++client_index) { auto &gamepad = input->gamepads[client_index]; @@ -1325,28 +1356,111 @@ namespace input { } /** - * @brief Apply a client clipboard packet to the host clipboard. + * @brief Queue a clipboard request or data message for the client's control stream. * * @param input Stream input state. - * @param header Packet header at the start of the raw input bytes. + * @param action Message to send. + * @param sequence Offer sequence the message belongs to. + * @param mime MIME type the message is about. */ - void passthrough_clipboard(const std::shared_ptr &input, const NV_INPUT_HEADER *header) { - auto declared = util::endian::big(header->size); - constexpr auto prefix = sizeof(std::uint32_t); - if (declared < prefix) { - return; + void raise_clipboard(const std::shared_ptr &input, platf::clipboard_action_e action, std::uint32_t sequence, std::string mime) { + input->clipboard_queue->raise(platf::clipboard_message_t {action, sequence, std::move(mime), nullptr}); + } + + /** + * @brief Request the formats Sunshine wants from a client clipboard offer. + * + * @param input Stream input state. + * @param offer Decoded client offer. + */ + void accept_clipboard_offer(const std::shared_ptr &input, const CLIPBOARD_OFFER &offer) { + std::vector offered; + for (int i = 0; i < offer.mimeTypeCount; ++i) { + offered.emplace_back(offer.mimeTypes[i]); } - auto text_len = declared - prefix; - platf::clipboard_subscribe(input->clipboard_queue); - if (text_len == 0) { + auto &incoming = input->clipboard_incoming; + incoming.clear(); + incoming.sequence = offer.sequence; + incoming.waiting = platf::clipboard_wanted_formats(offered); + for (const auto &mime : incoming.waiting) { + raise_clipboard(input, platf::clipboard_action_e::request, offer.sequence, mime); + } + } + + /** + * @brief Add a chunk of client clipboard data, and set the host clipboard once every requested format arrived. + * + * @param input Stream input state. + * @param chunk Decoded data chunk. + */ + void accept_clipboard_chunk(const std::shared_ptr &input, const CLIPBOARD_CHUNK &chunk) { + auto &incoming = input->clipboard_incoming; + const std::string mime {chunk.mimeType}; + if (chunk.sequence != incoming.sequence || std::ranges::find(incoming.waiting, mime) == incoming.waiting.end()) { + return; + } + if (ClipboardAssemblerAdd(&incoming.assembler, &chunk) != 1) { return; } - const auto *text = reinterpret_cast(header) + sizeof(NV_INPUT_HEADER); - std::string copy(text_len, '\0'); - std::memcpy(copy.data(), static_cast(text), text_len); - platf::clipboard_set(copy, input->clipboard_queue); + const std::span bytes {incoming.assembler.data, incoming.assembler.length}; + if (platf::clipboard_is_text(mime)) { + incoming.data.text.assign(bytes.begin(), bytes.end()); + } else { + incoming.data.image_mime = mime; + incoming.data.image.assign(bytes.begin(), bytes.end()); + } + ClipboardAssemblerReset(&incoming.assembler); + + std::erase(incoming.waiting, mime); + if (incoming.waiting.empty()) { + platf::clipboard_set(std::exchange(incoming.data, {}), input->clipboard_queue); + } + } + + /** + * @brief Handle a client clipboard offer, request, or data packet. + * + * Any clipboard packet subscribes the client to host clipboard changes. + * + * @param input Stream input state. + * @param header Packet header at the start of the raw input bytes. + */ + void passthrough_clipboard(const std::shared_ptr &input, const NV_INPUT_HEADER *header) { + const auto *body = reinterpret_cast(header) + sizeof(NV_INPUT_HEADER); + const auto length = static_cast(util::endian::big(header->size) - sizeof(header->magic)); + platf::clipboard_subscribe(input->clipboard_queue); + + switch (util::endian::little(header->magic)) { + case SS_CLIPBOARD_OFFER_MAGIC: + { + CLIPBOARD_OFFER offer; + // An empty offer only subscribes + if (ClipboardDecodeOffer(body, length, &offer) && offer.mimeTypeCount > 0) { + accept_clipboard_offer(input, offer); + } + break; + } + case SS_CLIPBOARD_REQUEST_MAGIC: + { + CLIPBOARD_REQUEST request; + if (ClipboardDecodeRequest(body, length, &request)) { + raise_clipboard(input, platf::clipboard_action_e::send, request.sequence, request.mimeType); + } + break; + } + case SS_CLIPBOARD_DATA_MAGIC: + { + CLIPBOARD_CHUNK chunk; + if (ClipboardDecodeChunk(body, length, &chunk)) { + accept_clipboard_chunk(input, chunk); + } + break; + } + default: + break; + } } /** @@ -1805,11 +1919,12 @@ namespace input { return validate_fixed_input_packet(packet, declared_size); case UTF8_TEXT_EVENT_MAGIC: return declared_size - sizeof(header.magic) <= UTF8_TEXT_EVENT_MAX_COUNT; - case SS_CLIPBOARD_TEXT_MAGIC: - { - constexpr auto prefix = sizeof(std::uint32_t); - return declared_size >= prefix && declared_size - prefix <= SS_CLIPBOARD_TEXT_MAX; - } + case SS_CLIPBOARD_OFFER_MAGIC: + return declared_size - sizeof(header.magic) <= SS_CLIPBOARD_OFFER_MAX; + case SS_CLIPBOARD_REQUEST_MAGIC: + return declared_size - sizeof(header.magic) <= SS_CLIPBOARD_REQUEST_MAX; + case SS_CLIPBOARD_DATA_MAGIC: + return declared_size - sizeof(header.magic) <= SS_CLIPBOARD_DATA_HEADER_MAX + SS_CLIPBOARD_CHUNK_MAX; case MULTI_CONTROLLER_MAGIC_GEN5: return validate_fixed_input_packet(packet, declared_size); case SS_TOUCH_MAGIC: @@ -2219,7 +2334,9 @@ namespace input { case UTF8_TEXT_EVENT_MAGIC: passthrough(static_cast(static_cast(payload))); break; - case SS_CLIPBOARD_TEXT_MAGIC: + case SS_CLIPBOARD_OFFER_MAGIC: + case SS_CLIPBOARD_REQUEST_MAGIC: + case SS_CLIPBOARD_DATA_MAGIC: passthrough_clipboard(input, payload); break; case MULTI_CONTROLLER_MAGIC_GEN5: @@ -2362,6 +2479,7 @@ namespace input { * @param input Retained stream input state to reset. */ void reset_input_state(const std::shared_ptr &input) { + input->clipboard_incoming.clear(); reset_mouse_buttons(); reset_keyboard_keys(); reset_gamepads(input); @@ -2483,7 +2601,7 @@ namespace input { input = std::make_shared( mail->event(mail::touch_port), mail->queue(mail::gamepad_feedback), - mail->queue(mail::clipboard) + mail->queue(mail::clipboard) ); state.inputs.try_emplace(std::move(session_id), input); } diff --git a/src/platform/clipboard_sync.h b/src/platform/clipboard_sync.h index 249b9f641a1..858dfb3b2cf 100644 --- a/src/platform/clipboard_sync.h +++ b/src/platform/clipboard_sync.h @@ -1,6 +1,6 @@ /** * @file src/platform/clipboard_sync.h - * @brief Clipboard subscriber tracking and X11 selection decoding shared with tests. + * @brief Clipboard formats, subscriber tracking, and X11 selection transfers shared with tests. */ #pragma once @@ -8,28 +8,129 @@ #include #include #include +#include #include +#include #include #include #include #include +// lib includes +extern "C" { +#include +} + // local includes #include "src/platform/common.h" namespace platf { /** - * @brief Maximum UTF-8 clipboard payload accepted by the protocol. + * @brief Largest clipboard format accepted from a client or the host. + */ + inline constexpr std::size_t clipboard_max_bytes = SS_CLIPBOARD_DATA_MAX; + + /** + * @brief Report whether a MIME type is plain text. + * + * @param mime MIME type, with or without parameters. + * @return True for text/plain and text/plain with parameters such as a charset. + */ + inline bool clipboard_is_text(std::string_view mime) { + return mime == "text/plain" || mime.starts_with("text/plain;"); + } + + /** + * @brief List the MIME types a clipboard can be offered as. + * + * @param data Clipboard to offer. + * @return The text MIME type when there is text, then the image MIME type when there is an image. + */ + inline std::vector clipboard_mime_types(const clipboard_data_t &data) { + std::vector types; + if (!data.text.empty()) { + types.emplace_back(SS_CLIPBOARD_MIME_TEXT); + } + if (!data.image_mime.empty()) { + types.push_back(data.image_mime); + } + return types; + } + + /** + * @brief Find the bytes of one offered format. + * + * @param data Offered clipboard. + * @param mime MIME type that was requested. + * @return The format's bytes, or empty when the clipboard does not have that format. */ - inline constexpr std::size_t clipboard_max_bytes = 32755; + inline std::optional> clipboard_payload(const clipboard_data_t &data, std::string_view mime) { + if (clipboard_is_text(mime) && !data.text.empty()) { + return std::span {reinterpret_cast(data.text.data()), data.text.size()}; + } + if (!data.image_mime.empty() && mime == data.image_mime) { + return std::span {data.image}; + } + return std::nullopt; + } + + /** + * @brief Choose the formats to request from a client offer. + * + * Sunshine takes text and one image. PNG is preferred to JPEG because it is lossless. + * + * @param offered MIME types in the client offer. + * @return The offered MIME types to request. + */ + inline std::vector clipboard_wanted_formats(std::span offered) { + std::vector wanted; + if (const auto text = std::ranges::find_if(offered, clipboard_is_text); text != offered.end()) { + wanted.emplace_back(*text); + } + for (const std::string_view image : {std::string_view {SS_CLIPBOARD_MIME_PNG}, std::string_view {SS_CLIPBOARD_MIME_JPEG}}) { + if (std::ranges::find(offered, image) != offered.end()) { + wanted.emplace_back(image); + break; + } + } + return wanted; + } + + /** + * @brief Convert ISO-8859-1 text, which X11 uses for the STRING target, to UTF-8. + * + * @param latin1 Latin-1 bytes. + * @return The same text in UTF-8. + */ + inline std::string latin1_to_utf8(std::string_view latin1) { + std::string utf8; + utf8.reserve(latin1.size()); + for (const char c : latin1) { + const auto byte = static_cast(c); + if (byte < 0x80) { + utf8.push_back(c); + } else { + utf8.push_back(static_cast(0xC0 | (byte >> 6))); + utf8.push_back(static_cast(0x80 | (byte & 0x3F))); + } + } + return utf8; + } /** * @brief One client waiting for host clipboard updates. */ struct clipboard_subscriber_t { clipboard_queue_t queue; ///< Queue drained by that client's control stream. - std::string last_sent; ///< Last text delivered to this client, or applied from it. - bool has_snapshot = false; ///< Whether this client has received its initial clipboard value. + std::shared_ptr last; ///< Clipboard this client last received or supplied. Null before the first one. + }; + + /** + * @brief Queues that should be offered one clipboard, and the offer sequence to use. + */ + struct clipboard_delivery_t { + std::uint32_t sequence = 0; ///< Sequence number of the offer. + std::vector queues; ///< Subscribers that do not have this clipboard yet. }; /** @@ -41,7 +142,7 @@ namespace platf { * @brief Register a client queue. * * @param queue Queue drained by the client's control stream. - * @return True when the queue is new and still needs the current clipboard snapshot. + * @return True when the queue is new and still needs the current clipboard. */ bool add(clipboard_queue_t queue) { if (!queue) { @@ -52,14 +153,14 @@ namespace platf { return false; } } - entries_.push_back(clipboard_subscriber_t {std::move(queue), {}, false}); + entries_.push_back(clipboard_subscriber_t {std::move(queue), nullptr}); return true; } /** * @brief Drop a client queue when its session stops. * - * @param queue Queue that should no longer receive clipboard text. + * @param queue Queue that should no longer receive clipboard offers. */ void remove(const clipboard_queue_t &queue) { std::erase_if(entries_, [&queue](const auto &entry) { @@ -68,64 +169,37 @@ namespace platf { } /** - * @brief Deliver host text to every subscriber that does not already have it. - * - * A subscriber that has not received a snapshot gets the text even when an older - * subscriber was already given the same value. + * @brief Record a new host clipboard and select the subscribers that need it. * - * @param text UTF-8 clipboard text. - * @return Queues that should be raised with this text. - */ - std::vector publish(const std::string &text) { - std::vector delivered; - for (auto &entry : entries_) { - if (auto queue = deliver(entry, text)) { - delivered.push_back(std::move(queue)); - } - } - return delivered; - } - - /** - * @brief Record text applied from one client and forward it to the others. + * The origin client supplied this clipboard, so it is recorded without an offer. + * A subscriber that has not received a clipboard yet always gets one. * - * @param origin Queue belonging to the client that supplied the text. - * @param text UTF-8 clipboard text applied on the host. - * @return Queues other than the origin that should be raised with this text. + * @param data New host clipboard. + * @param origin Queue of the client that supplied the clipboard, or null for a host copy. + * @return Queues to offer the clipboard to, with a new sequence number when there are any. */ - std::vector note_local(const clipboard_queue_t &origin, const std::string &text) { - std::vector delivered; + clipboard_delivery_t publish(const std::shared_ptr &data, const clipboard_queue_t &origin = {}) { + clipboard_delivery_t delivery; for (auto &entry : entries_) { - if (origin && entry.queue == origin) { - entry.last_sent = text; - entry.has_snapshot = true; + const bool is_origin = origin && entry.queue == origin; + if (!is_origin && entry.last && *entry.last == *data) { continue; } - if (auto queue = deliver(entry, text)) { - delivered.push_back(std::move(queue)); + entry.last = data; + if (!is_origin) { + delivery.queues.push_back(entry.queue); } } - return delivered; - } - - private: - /** - * @brief Send text unless this subscriber already has that snapshot. - * - * @param entry Subscriber being considered. - * @param text UTF-8 clipboard text. - * @return The subscriber queue when a delivery is required. - */ - static clipboard_queue_t deliver(clipboard_subscriber_t &entry, std::string_view text) { - if (entry.has_snapshot && entry.last_sent == text) { - return {}; + if (!delivery.queues.empty()) { + sequence_ = sequence_ == UINT32_MAX ? 1 : sequence_ + 1; + delivery.sequence = sequence_; } - entry.last_sent = text; - entry.has_snapshot = true; - return entry.queue; + return delivery; } + private: std::vector entries_; ///< Subscribers registered for the current process. + std::uint32_t sequence_ = 0; ///< Sequence number of the last offer. }; /** @@ -150,7 +224,7 @@ namespace platf { } /** - * @brief Byte width of one X property item. + * @brief Byte width of one X property item, as stored in selection_fragment_t::data. * * @param format Property format in bits. 8, 16, and 32 are the X formats. * @return Number of bytes in one item. Unknown formats count as one byte. @@ -183,14 +257,15 @@ namespace platf { /** * @brief Maximum 32-bit units read from one selection property at a time. * - * This is the protocol cap rounded up to a 32-bit unit, which is the longest read - * XGetWindowProperty is given. A larger chunk stays on the window until a later read - * or an explicit delete brings bytes_after to zero. + * A larger property is read in several slices. X11 deletes it only after the + * slice that reaches its end. */ - inline constexpr unsigned long selection_property_long_length = (clipboard_max_bytes + 3) / 4; + inline constexpr unsigned long selection_property_long_length = 64 * 1024; /** * @brief One slice of a selection property, as XGetWindowProperty returns it. + * + * Format-32 items are stored as 4 bytes each in host byte order. */ struct selection_fragment_t { bool ok = false; ///< Whether the property read succeeded. @@ -209,6 +284,7 @@ namespace platf { int format = 0; ///< Property format from the first read. std::vector data; ///< Property bytes, capped at clipboard_max_bytes. bool acknowledged = false; ///< Whether the last read had no unread tail, so X11 deleted the property. + bool truncated = false; ///< Whether the property was larger than clipboard_max_bytes. }; /** @@ -216,23 +292,20 @@ namespace platf { * * @param out Destination bytes. * @param chunk Bytes returned by one property read. + * @return False when some bytes did not fit under the cap. */ - inline void append_property_bytes(std::vector &out, const std::vector &chunk) { - if (chunk.empty() || out.size() >= clipboard_max_bytes) { - return; - } - const auto take = std::min(chunk.size(), clipboard_max_bytes - out.size()); - const auto offset = out.size(); - out.resize(offset + take); - std::copy_n(chunk.data(), take, out.data() + offset); + inline bool append_property_bytes(std::vector &out, std::span chunk) { + const auto take = std::min(chunk.size(), clipboard_max_bytes - std::min(out.size(), clipboard_max_bytes)); + out.insert(out.end(), chunk.begin(), chunk.begin() + static_cast(take)); + return take == chunk.size(); } /** * @brief Read a selection property until X11 can delete it. * * Each fetch uses delete=True and selection_property_long_length. XGetWindowProperty - * deletes the property only when bytes_after is zero, so a chunk larger than the read - * is fetched again at the next 32-bit offset. Stored text still stops at the protocol cap. + * deletes the property only when bytes_after is zero, so a property larger than one read + * is fetched again at the next 32-bit offset. Stored bytes stop at the protocol cap. * Fetch returns ok=false when the X call fails. The fragment's data must own its bytes. * * @tparam Fetch Reads one slice. Called with the 32-bit offset and the maximum 32-bit length. @@ -257,7 +330,9 @@ namespace platf { loaded.format = fragment.format; started = true; } - append_property_bytes(loaded.data, fragment.data); + if (!append_property_bytes(loaded.data, fragment.data)) { + loaded.truncated = true; + } if (fragment.bytes_after == 0) { loaded.acknowledged = true; return loaded; @@ -271,7 +346,7 @@ namespace platf { } /** - * @brief Incremental X selection transfer assembled from INCR chunks. + * @brief Selection value received directly or assembled from INCR chunks. */ class incr_transfer_t { public: @@ -285,102 +360,110 @@ namespace platf { } /** - * @brief Accept one selection property and maybe finish a text value. + * @brief Accept one selection property and maybe finish a value. * - * An INCR property carries a byte-count announcement, not clipboard text. - * Format-8 UTF8_STRING and STRING chunks are appended until the empty property ends the transfer. - * An unsupported type discards the bytes and rejects the transfer. The transfer stays active - * until that empty text property so every chunk can be deleted, and the terminator is not published. + * An INCR property carries a byte-count announcement, not data. Format-8 chunks of + * the expected type are appended until an empty property ends the transfer. A chunk + * of another type, or data over clipboard_max_bytes, rejects the transfer. The transfer + * stays active until the empty property so every chunk is still deleted. * * @param actual_type Property type atom. * @param actual_format Property format, in bits. - * @param item_count Number of items of that format. - * @param data Property bytes. May be null when the item count is zero. - * @param utf8_atom Atom for UTF8_STRING. - * @param string_atom Atom for STRING. + * @param data Property bytes. + * @param truncated Whether the property was larger than clipboard_max_bytes. + * @param expected_type Type atom of the requested target. * @param incr_atom Atom for INCR. - * @return Completed clipboard text, or empty when the transfer is unfinished or ignored. + * @return The completed value, or empty when the transfer is unfinished or rejected. */ - std::optional consume( + std::optional> consume( std::uint64_t actual_type, int actual_format, - std::size_t item_count, - const std::byte *data, - std::uint64_t utf8_atom, - std::uint64_t string_atom, + std::span data, + bool truncated, + std::uint64_t expected_type, std::uint64_t incr_atom ) { + const bool expected = actual_format == 8 && actual_type == expected_type; if (active_) { - if ( - const bool text_chunk = actual_format == 8 && (actual_type == utf8_atom || actual_type == string_atom); - !text_chunk - ) { - rejected_ = true; - text_.clear(); - return std::nullopt; - } - if (item_count == 0) { + if (data.empty()) { active_ = false; - auto done = std::move(text_); - text_.clear(); - const bool rejected = rejected_; - rejected_ = false; - if (rejected) { + auto done = std::exchange(data_, {}); + if (std::exchange(rejected_, false)) { return std::nullopt; } return done; } - if (!rejected_) { - append_bytes(data, item_count); + if (!expected || truncated || !append_property_bytes(data_, data)) { + rejected_ = true; + data_.clear(); } return std::nullopt; } if (actual_type == incr_atom && actual_format == 32) { active_ = true; - text_.clear(); + rejected_ = false; + data_.clear(); return std::nullopt; } - if ((actual_type == utf8_atom || actual_type == string_atom) && actual_format == 8) { - std::string text; - append_to(text, data, item_count); - return text; + if (expected && !truncated) { + return std::vector {data.begin(), data.end()}; } - return std::nullopt; } private: + bool active_ = false; ///< Whether the empty INCR chunk has not arrived yet. + bool rejected_ = false; ///< Whether an unsupported or oversized chunk discarded this INCR transfer. + std::vector data_; ///< Bytes accumulated from INCR chunks. + }; + + /** + * @brief Slices of a value Sunshine sends to a requestor with the INCR protocol. + * + * The requestor deletes the property after reading each slice. Sunshine then writes + * the next slice, and finally an empty one. + */ + class incr_sender_t { + public: /** - * @brief Copy property bytes into a string without passing the protocol cap. + * @brief Start sending a value. * - * @param text Destination text. - * @param data Property bytes. May be null when the item count is zero. - * @param size Number of bytes available at data. + * @param size Total bytes to send. + * @param chunk_size Largest slice written at once. Must be nonzero. */ - static void append_to(std::string &text, const std::byte *data, std::size_t size) { - if (data == nullptr || size == 0 || text.size() >= clipboard_max_bytes) { - return; - } - const auto take = std::min(size, clipboard_max_bytes - text.size()); - const auto offset = text.size(); - text.resize(offset + take); - std::copy_n(data, take, reinterpret_cast(text.data() + offset)); + incr_sender_t(std::size_t size, std::size_t chunk_size): + size_ {size}, + chunk_size_ {chunk_size} { } /** - * @brief Append one INCR chunk to the transfer buffer. + * @brief Report whether the empty terminating slice has been handed out. * - * @param data Chunk bytes. - * @param size Number of bytes in the chunk. + * @return True when the transfer is complete. */ - void append_bytes(const std::byte *data, std::size_t size) { - append_to(text_, data, size); + [[nodiscard]] bool done() const { + return done_; } - bool active_ = false; ///< Whether the empty INCR chunk has not arrived yet. - bool rejected_ = false; ///< Whether an unsupported chunk discarded this INCR transfer. - std::string text_; ///< UTF-8 text accumulated from INCR chunks. + /** + * @brief Take the next slice to write. + * + * @return Offset and length of the next slice. A zero length is the terminator. + */ + std::pair next() { + const auto length = std::min(chunk_size_, size_ - offset_); + const std::pair slice {offset_, length}; + offset_ += length; + done_ = length == 0; + return slice; + } + + private: + std::size_t size_; ///< Total bytes to send. + std::size_t chunk_size_; ///< Largest slice written at once. + std::size_t offset_ = 0; ///< Bytes already handed out. + bool done_ = false; ///< Whether the terminator was handed out. }; } // namespace platf diff --git a/src/platform/common.h b/src/platform/common.h index 16435423e57..0d83bbbd13b 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -6,12 +6,15 @@ // standard includes #include +#include #include #include +#include #include #include #include #include +#include // lib includes #include @@ -276,16 +279,47 @@ namespace platf { using feedback_queue_t = safe::mail_raw_t::queue_t; /** - * @brief Clipboard text waiting to be sent to the client. + * @brief Clipboard contents synced between the host and clients. + * + * A clipboard holds text, one image, or both. An empty member means that format is absent. + */ + struct clipboard_data_t { + std::string text; ///< UTF-8 text. + std::string image_mime; ///< MIME type of image, or empty when there is no image. + std::vector image; ///< Encoded image bytes. + + /** + * @brief Compare two clipboards by content. + * + * @param other Clipboard to compare with. + * @return True when both clipboards hold the same formats and bytes. + */ + bool operator==(const clipboard_data_t &other) const = default; + }; + + /** + * @brief Clipboard control message the control stream should send to the client. + */ + enum class clipboard_action_e : std::uint8_t { + offer, ///< Offer data to the client under sequence. + request, ///< Ask the client for mime from its offer sequence. + send, ///< Send mime from the host offer sequence. + }; + + /** + * @brief Clipboard work queued for one client's control stream. */ - struct clipboard_text_t { - std::string text; ///< UTF-8 clipboard text. + struct clipboard_message_t { + clipboard_action_e action = clipboard_action_e::offer; ///< Control message to send. + std::uint32_t sequence = 0; ///< Offer sequence the message belongs to. + std::string mime; ///< MIME type for request and send. + std::shared_ptr data; ///< Clipboard offered by an offer message. }; /** - * @brief Queue used to deliver host clipboard text to the control stream. + * @brief Queue used to deliver clipboard messages to the control stream. */ - using clipboard_queue_t = safe::mail_raw_t::queue_t; + using clipboard_queue_t = safe::mail_raw_t::queue_t; namespace speaker { /** @@ -1251,12 +1285,12 @@ namespace platf { void unicode(input_t &input, const char *utf8, int size); /** - * @brief Replace the host clipboard with UTF-8 text from the client. + * @brief Replace the host clipboard with a clipboard from the client. * - * @param text Clipboard text. - * @param origin Queue of the client that supplied the text. That client is not sent an echo. + * @param data Clipboard formats received from the client. + * @param origin Queue of the client that supplied the clipboard. That client is not sent an echo. */ - void clipboard_set(std::string_view text, const clipboard_queue_t &origin); + void clipboard_set(clipboard_data_t data, const clipboard_queue_t &origin); /** * @brief Start forwarding host clipboard changes to one client. diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index 6bb89caf707..a154ec09049 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -7,10 +7,16 @@ #include #include +#include +#include #include #include +#include #include +#include #include +#include +#include #include #include #include @@ -19,7 +25,7 @@ #ifndef SUNSHINE_BUILD_X11 namespace platf { - void clipboard_set(std::string_view, const clipboard_queue_t &) { + void clipboard_set(clipboard_data_t, const clipboard_queue_t &) { // This build has no X11 display, so the host clipboard cannot be updated. } @@ -45,6 +51,11 @@ namespace platf { namespace platf { namespace { + /** + * @brief How long an INCR send may wait for the requestor before it is dropped. + */ + constexpr auto incr_send_timeout = std::chrono::seconds {30}; + /** * @brief Process-wide X11 clipboard watcher state. */ @@ -54,10 +65,9 @@ namespace platf { bool ready = false; ///< Whether the X11 watcher can accept clipboard operations. bool failed = false; ///< Whether opening the display or XFixes failed. bool started = false; ///< Whether the watcher thread has been launched. - bool has_pending = false; ///< Whether a client clipboard value is waiting to take selection ownership. bool pull = false; ///< Whether a new subscriber still needs the current host clipboard. int wake_write = -1; ///< Write end of the pipe that wakes the watcher thread. - std::string pending; ///< Client clipboard text waiting to become the selection owner. + std::shared_ptr pending; ///< Client clipboard waiting to become the selection. clipboard_subscribers_t subscribers; ///< One entry for each streaming client that opted in. std::jthread thread; ///< X11 watcher thread. }; @@ -73,34 +83,44 @@ namespace platf { } /** - * @brief Raise clipboard text on each queue that still needs it. + * @brief Wake the watcher thread so it handles new state. * - * @param queues Subscriber queues selected by the publish or local-set rules. - * @param text UTF-8 clipboard text. + * @param clipboard Watcher state. The caller holds its lock. */ - void raise_clipboard(const std::vector &queues, const std::string &text) { - if (queues.empty()) { - return; + void wake_watcher(const state_t &clipboard) { + if (clipboard.wake_write >= 0) { + char byte = 1; + if (write(clipboard.wake_write, &byte, 1) < 0) { + BOOST_LOG(debug) << "Clipboard watcher wakeup was not queued"sv; + } } + } - for (const auto &queue : queues) { - queue->raise(clipboard_text_t {text}); + /** + * @brief Queue a clipboard offer for each selected subscriber. + * + * @param delivery Subscribers and sequence chosen by clipboard_subscribers_t::publish(). + * @param data Clipboard to offer. + */ + void raise_offers(const clipboard_delivery_t &delivery, const std::shared_ptr &data) { + for (const auto &queue : delivery.queues) { + queue->raise(clipboard_message_t {clipboard_action_e::offer, delivery.sequence, {}, data}); } } /** - * @brief Publish host clipboard text to every subscriber that does not already have it. + * @brief Offer a host clipboard to every subscriber that does not already have it. * - * @param text UTF-8 clipboard text. + * @param data Host clipboard. */ - void publish(const std::string &text) { - std::vector queues; + void publish(const std::shared_ptr &data) { + clipboard_delivery_t delivery; { auto &clipboard = state(); std::lock_guard lock {clipboard.mutex}; - queues = clipboard.subscribers.publish(text); + delivery = clipboard.subscribers.publish(data); } - raise_clipboard(queues, text); + raise_offers(delivery, data); } /** @@ -127,52 +147,39 @@ namespace platf { } /** - * @brief Answer one X selection request with the text Sunshine owns. + * @brief Display connection owned by the watcher thread. + */ + std::atomic watcher_display {nullptr}; + + /** + * @brief X error handler that was installed before the watcher's. + */ + XErrorHandler previous_error_handler = nullptr; + + /** + * @brief Ignore X errors on the watcher's display and pass the rest on. * - * @param display X display connection owned by the watcher thread. - * @param text UTF-8 text currently owned by Sunshine. - * @param request Selection request from another client. - * @param utf8 UTF8_STRING atom. - * @param targets TARGETS atom. + * Other clients can destroy their windows during a selection transfer. The default + * Xlib handler exits the process on the resulting BadWindow error. + * + * @param display Display the error happened on. + * @param event X error. + * @return Zero. Xlib ignores the return value. */ - void serve_selection(Display *display, std::string_view text, const XSelectionRequestEvent *request, Atom utf8, Atom targets) { - XSelectionEvent selection {}; - selection.type = SelectionNotify; - selection.display = request->display; - selection.requestor = request->requestor; - selection.selection = request->selection; - selection.target = request->target; - selection.time = request->time; - - const bool supported = request->target == targets || request->target == utf8 || request->target == XA_STRING; - const auto property = static_cast(selection_reply_property(request->property, request->target, supported)); - selection.property = property; - if (property != None && request->target == targets) { - const std::array list {utf8, XA_STRING, targets}; - std::array bytes {}; - std::memcpy(bytes.data(), list.data(), bytes.size()); - XChangeProperty(display, request->requestor, property, XA_ATOM, 32, PropModeReplace, bytes.data(), static_cast(list.size())); - } else if (property != None) { - XChangeProperty( - display, - request->requestor, - property, - request->target, - 8, - PropModeReplace, - reinterpret_cast(text.data()), - static_cast(text.size()) - ); + int handle_x_error(Display *display, XErrorEvent *event) { + if (display == watcher_display.load()) { + BOOST_LOG(debug) << "Clipboard sync ignored X error "sv << static_cast(event->error_code); + return 0; } - - XEvent notify {}; - notify.xselection = selection; - XSendEvent(display, request->requestor, False, 0, ¬ify); + return previous_error_handler != nullptr ? previous_error_handler(display, event) : 0; } /** * @brief Read one slice of the selection property. * + * Format-32 items arrive from Xlib as longs. They are stored as 4-byte values so the + * slice uses the same 32-bit units as the X property offset. + * * @param display X display connection owned by the watcher thread. * @param window Window that holds the property. * @param property Property atom being read. @@ -199,11 +206,15 @@ namespace platf { fragment.item_count = item_count; fragment.bytes_after = bytes_after; if (data != nullptr) { - const auto limit = static_cast(length) * 4; - const auto reported = item_count * property_byte_width(actual_format); - const auto nbytes = std::min(reported, limit); - fragment.data.resize(nbytes); - if (nbytes > 0) { + if (actual_format == 32) { + fragment.data.resize(item_count * 4); + for (unsigned long i = 0; i < item_count; ++i) { + const auto item = static_cast(reinterpret_cast(data)[i]); + std::memcpy(fragment.data.data() + i * 4, &item, sizeof(item)); + } + } else { + const auto nbytes = std::min(item_count * property_byte_width(actual_format), static_cast(length) * 4); + fragment.data.resize(nbytes); std::copy_n(reinterpret_cast(data), nbytes, fragment.data.begin()); } XFree(data); @@ -212,112 +223,359 @@ namespace platf { } /** - * @brief Publish clipboard text from the window property, deleting an unread tail. + * @brief Watches the X11 CLIPBOARD selection, reads host copies, and serves client copies. * - * @param display X display connection owned by the watcher thread. - * @param window Window that holds the property. - * @param property Property atom being read. - * @param utf8 UTF8_STRING atom. - * @param incr INCR atom. - * @param transfer Incremental transfer assembled across property events. + * Every method runs on the watcher thread. */ - void publish_property(Display *display, Window window, Atom property, Atom utf8, Atom incr, incr_transfer_t &transfer) { - const auto loaded = load_selection_property([display, window, property](unsigned long offset, unsigned long length) { - return read_selection_fragment(display, window, property, offset, length); - }); - if (!loaded) { - return; + class watcher_t { + public: + /** + * @brief Set up the atoms and transfer limits for one display connection. + * + * @param display X display connection owned by the watcher thread. + * @param window Window used to own and request the selection. + * @param event_base First XFixes event code. + */ + watcher_t(Display *display, Window window, int event_base): + display_ {display}, + window_ {window}, + event_base_ {event_base}, + clipboard_ {XInternAtom(display, "CLIPBOARD", False)}, + targets_ {XInternAtom(display, "TARGETS", False)}, + utf8_ {XInternAtom(display, "UTF8_STRING", False)}, + incr_ {XInternAtom(display, "INCR", False)}, + png_ {XInternAtom(display, SS_CLIPBOARD_MIME_PNG, False)}, + jpeg_ {XInternAtom(display, SS_CLIPBOARD_MIME_JPEG, False)}, + property_ {XInternAtom(display, "SUNSHINE_CLIPBOARD", False)}, + // Keep each property write well under the server's request size limit + chunk_size_ {std::min(256 * 1024, static_cast(XMaxRequestSize(display)) * 4 - 64)} { + XFixesSelectSelectionInput(display_, window_, clipboard_, XFixesSetSelectionOwnerNotifyMask); + XFlush(display_); } - if (!loaded->acknowledged) { - XDeleteProperty(display, window, property); + + /** + * @brief Handle one X event. + * + * @param event Event just removed from the queue. + */ + void handle(const XEvent &event) { + if (event.type == SelectionRequest) { + serve(event.xselectionrequest); + } else if (event.type == SelectionNotify) { + converted(event.xselection); + } else if (event.type == PropertyNotify && event.xproperty.window == window_) { + if (event.xproperty.atom == property_ && event.xproperty.state == PropertyNewValue && fetching_ && transfer_.active()) { + read_reply(); + } + } else if (event.type == PropertyNotify) { + continue_send(event.xproperty); + } else if (event.type == event_base_ + XFixesSelectionNotify) { + XFixesSelectionNotifyEvent selection {}; + std::memcpy(&selection, &event, sizeof(selection)); + if (selection.selection == clipboard_ && selection.owner != window_ && selection.owner != None) { + owned_.reset(); + start_fetch(); + } + } } - const auto width = property_byte_width(loaded->format); - const auto items = loaded->data.size() / width; - const auto *bytes = loaded->data.empty() ? nullptr : loaded->data.data(); - if (const auto text = transfer.consume(loaded->type, loaded->format, items, bytes, utf8, XA_STRING, incr)) { - publish(*text); + + /** + * @brief Take ownership of the selection when a client clipboard is waiting. + */ + void take_pending() { + std::shared_ptr data; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + data = std::move(clipboard.pending); + } + if (!data) { + return; + } + + // A host clipboard still being read is now stale + fetching_ = false; + owned_ = std::move(data); + XSetSelectionOwner(display_, clipboard_, window_, CurrentTime); + XFlush(display_); } - } - /** - * @brief Take ownership of the selection when a client clipboard is waiting. - * - * @param display X display connection owned by the watcher thread. - * @param clipboard_atom CLIPBOARD atom. - * @param window Window that becomes the selection owner. - * @param owned Text Sunshine now owns. - */ - void apply_pending(Display *display, Atom clipboard_atom, Window window, std::string &owned) { - std::string text; - { - auto &clipboard = state(); - std::lock_guard lock {clipboard.mutex}; - if (!clipboard.has_pending) { + /** + * @brief Offer the current host clipboard to a new subscriber. + */ + void pull() { + if (fetching_) { + // The read in progress will reach the new subscriber too return; } - text = std::move(clipboard.pending); - clipboard.has_pending = false; + if (owned_ && XGetSelectionOwner(display_, clipboard_) == window_) { + publish(owned_); + } else { + start_fetch(); + } } - owned = std::move(text); - XSetSelectionOwner(display, clipboard_atom, window, CurrentTime); - XFlush(display); - } - /** - * @brief Ask the selection owner for its UTF-8 text. - * - * @param display X display connection owned by the watcher thread. - * @param clipboard_atom CLIPBOARD atom. - * @param utf8 UTF8_STRING atom. - * @param property Property that receives the reply. - * @param window Requestor window. - */ - void convert_selection(Display *display, Atom clipboard_atom, Atom utf8, Atom property, Window window) { - XConvertSelection(display, clipboard_atom, utf8, property, window, CurrentTime); - XFlush(display); - } + /** + * @brief Drop INCR sends whose requestor stopped reading. + */ + void prune_sends() { + const auto now = std::chrono::steady_clock::now(); + std::erase_if(sends_, [this, now](const send_t &send) { + if (now - send.started < incr_send_timeout) { + return false; + } + XSelectInput(display_, send.requestor, NoEventMask); + return true; + }); + } - /** - * @brief X11 objects used to answer clipboard selection events. - */ - struct clipboard_watch_t { - Display *display; ///< X display connection owned by the watcher thread. - Window window; ///< Watcher window. - int event_base; ///< First XFixes event code. - Atom clipboard; ///< CLIPBOARD atom. - Atom utf8; ///< UTF8_STRING atom. - Atom incr; ///< INCR atom. - Atom targets; ///< TARGETS atom. - Atom property; ///< Property used for selection replies. - }; + private: + /** + * @brief Value Sunshine sends to one requestor with the INCR protocol. + */ + struct send_t { + Window requestor; ///< Window that requested the value. + Atom property; ///< Property the requestor reads. + Atom type; ///< Type of the value. + std::span bytes; ///< Value being sent. data keeps it alive. + std::shared_ptr data; ///< Clipboard that holds bytes. + incr_sender_t sender; ///< Slices still to write. + std::chrono::steady_clock::time_point started; ///< When the transfer began. + }; + + /** + * @brief Ask the selection owner to convert the clipboard to one target. + * + * @param target Target atom. + */ + void request(Atom target) { + current_ = target; + transfer_ = {}; + XDeleteProperty(display_, window_, property_); + XConvertSelection(display_, clipboard_, target, property_, window_, CurrentTime); + XFlush(display_); + } - /** - * @brief Handle one X event for the clipboard watcher. - * - * @param watch Display, window, and atoms for this watcher. - * @param event Event just removed from the queue. - * @param owned Text Sunshine owns, when it owns the selection. - * @param transfer Incremental transfer assembled across property events. - */ - void handle_event(const clipboard_watch_t &watch, const XEvent &event, const std::string &owned, incr_transfer_t &transfer) { - if (event.type == SelectionRequest) { - serve_selection(watch.display, owned, &event.xselectionrequest, watch.utf8, watch.targets); - XFlush(watch.display); - } else if (event.type == SelectionNotify) { - if (event.xselection.property != None) { - publish_property(watch.display, watch.window, watch.property, watch.utf8, watch.incr, transfer); + /** + * @brief Start reading the host clipboard, beginning with its target list. + */ + void start_fetch() { + fetching_ = true; + fetched_ = {}; + remaining_.clear(); + request(targets_); + } + + /** + * @brief Request the next wanted target, or publish what was read. + */ + void next_target() { + if (remaining_.empty()) { + fetching_ = false; + if (!fetched_.text.empty() || !fetched_.image.empty()) { + publish(std::make_shared(std::move(fetched_))); + } + fetched_ = {}; + return; + } + const auto target = remaining_.front(); + remaining_.erase(remaining_.begin()); + request(target); + } + + /** + * @brief Handle the owner's answer to a conversion request. + * + * @param event SelectionNotify event. + */ + void converted(const XSelectionEvent &event) { + if (!fetching_ || event.requestor != window_ || event.target != current_) { + return; } - } else if (event.type == PropertyNotify && transfer.active() && event.xproperty.atom == watch.property && event.xproperty.state == PropertyNewValue) { - publish_property(watch.display, watch.window, watch.property, watch.utf8, watch.incr, transfer); - } else if (event.type == watch.event_base + XFixesSelectionNotify) { - XFixesSelectionNotifyEvent selection {}; - std::memcpy(&selection, &event, sizeof(selection)); - if (selection.selection == watch.clipboard && selection.owner != watch.window && selection.owner != None) { - transfer = {}; - convert_selection(watch.display, watch.clipboard, watch.utf8, watch.property, watch.window); + if (event.property == None) { + // Owners that predate TARGETS can still convert to UTF8_STRING + if (current_ == targets_) { + remaining_ = {utf8_}; + } + next_target(); + return; } + read_reply(); } - } + + /** + * @brief Read the reply property for the current target. + */ + void read_reply() { + const auto loaded = load_selection_property([this](unsigned long offset, unsigned long length) { + return read_selection_fragment(display_, window_, property_, offset, length); + }); + if (!loaded) { + next_target(); + return; + } + if (!loaded->acknowledged) { + XDeleteProperty(display_, window_, property_); + } + + if (current_ == targets_) { + choose_targets(*loaded); + next_target(); + return; + } + + auto value = transfer_.consume(loaded->type, loaded->format, loaded->data, loaded->truncated, current_, incr_); + if (value) { + store(std::move(*value)); + } else if (transfer_.active()) { + // Wait for the next INCR chunk + return; + } + next_target(); + } + + /** + * @brief Pick the text and image targets to read from the owner's target list. + * + * @param loaded TARGETS reply. + */ + void choose_targets(const loaded_selection_t &loaded) { + std::vector offered; + if (loaded.type == XA_ATOM && loaded.format == 32) { + for (std::size_t i = 0; i + 4 <= loaded.data.size(); i += 4) { + std::uint32_t atom = 0; + std::memcpy(&atom, loaded.data.data() + i, sizeof(atom)); + offered.push_back(atom); + } + } + const auto has = [&offered](Atom atom) { + return std::ranges::find(offered, atom) != offered.end(); + }; + + remaining_.clear(); + if (has(utf8_)) { + remaining_.push_back(utf8_); + } else if (has(XA_STRING)) { + remaining_.push_back(XA_STRING); + } + if (has(png_)) { + remaining_.push_back(png_); + } else if (has(jpeg_)) { + remaining_.push_back(jpeg_); + } + } + + /** + * @brief Keep a value read for the current target. + * + * @param value Bytes of the converted selection. + */ + void store(std::vector value) { + const std::string_view bytes {reinterpret_cast(value.data()), value.size()}; + if (current_ == utf8_) { + fetched_.text = bytes; + } else if (current_ == XA_STRING) { + fetched_.text = latin1_to_utf8(bytes); + } else if (!value.empty()) { + fetched_.image_mime = current_ == png_ ? SS_CLIPBOARD_MIME_PNG : SS_CLIPBOARD_MIME_JPEG; + fetched_.image.assign(bytes.begin(), bytes.end()); + } + } + + /** + * @brief Answer one selection request with the clipboard Sunshine owns. + * + * @param request Selection request from another client. + */ + void serve(const XSelectionRequestEvent &request) { + const auto data = owned_; + std::optional> bytes; + if (data && (request.target == utf8_ || request.target == XA_STRING)) { + bytes = clipboard_payload(*data, SS_CLIPBOARD_MIME_TEXT); + } else if (data && (request.target == png_ || request.target == jpeg_)) { + bytes = clipboard_payload(*data, request.target == png_ ? SS_CLIPBOARD_MIME_PNG : SS_CLIPBOARD_MIME_JPEG); + } + + const bool supported = request.target == targets_ || bytes.has_value(); + const auto property = static_cast(selection_reply_property(request.property, request.target, supported)); + if (property != None && request.target == targets_) { + std::vector list {targets_}; + if (data && !data->text.empty()) { + list.push_back(utf8_); + list.push_back(XA_STRING); + } + if (data && !data->image_mime.empty()) { + list.push_back(data->image_mime == SS_CLIPBOARD_MIME_PNG ? png_ : jpeg_); + } + XChangeProperty(display_, request.requestor, property, XA_ATOM, 32, PropModeReplace, reinterpret_cast(list.data()), static_cast(list.size())); + } else if (property != None && bytes->size() <= chunk_size_) { + XChangeProperty(display_, request.requestor, property, request.target, 8, PropModeReplace, bytes->data(), static_cast(bytes->size())); + } else if (property != None) { + // Too large for one request, so announce the size and send it in INCR slices + XSelectInput(display_, request.requestor, PropertyChangeMask); + const long size = static_cast(bytes->size()); + XChangeProperty(display_, request.requestor, property, incr_, 32, PropModeReplace, reinterpret_cast(&size), 1); + sends_.push_back(send_t {request.requestor, property, request.target, *bytes, data, incr_sender_t {bytes->size(), chunk_size_}, std::chrono::steady_clock::now()}); + } + + XEvent notify {}; + notify.xselection.type = SelectionNotify; + notify.xselection.display = request.display; + notify.xselection.requestor = request.requestor; + notify.xselection.selection = request.selection; + notify.xselection.target = request.target; + notify.xselection.property = property; + notify.xselection.time = request.time; + XSendEvent(display_, request.requestor, False, 0, ¬ify); + XFlush(display_); + } + + /** + * @brief Write the next INCR slice after the requestor read the last one. + * + * @param event PropertyNotify event from a requestor window. + */ + void continue_send(const XPropertyEvent &event) { + if (event.state != PropertyDelete) { + return; + } + const auto send = std::ranges::find_if(sends_, [&event](const send_t &candidate) { + return candidate.requestor == event.window && candidate.property == event.atom; + }); + if (send == sends_.end()) { + return; + } + + const auto [offset, length] = send->sender.next(); + XChangeProperty(display_, send->requestor, send->property, send->type, 8, PropModeReplace, send->bytes.data() + offset, static_cast(length)); + if (send->sender.done()) { + XSelectInput(display_, send->requestor, NoEventMask); + sends_.erase(send); + } + XFlush(display_); + } + + Display *display_; ///< X display connection owned by the watcher thread. + Window window_; ///< Window used to own and request the selection. + int event_base_; ///< First XFixes event code. + Atom clipboard_; ///< CLIPBOARD atom. + Atom targets_; ///< TARGETS atom. + Atom utf8_; ///< UTF8_STRING atom. + Atom incr_; ///< INCR atom. + Atom png_; ///< image/png atom. + Atom jpeg_; ///< image/jpeg atom. + Atom property_; ///< Property that receives conversion replies. + std::size_t chunk_size_; ///< Largest value written with one XChangeProperty. + + std::shared_ptr owned_; ///< Clipboard Sunshine serves while it owns the selection. + std::vector sends_; ///< INCR sends waiting for their requestors. + + bool fetching_ = false; ///< Whether the host clipboard is being read. + Atom current_ = None; ///< Target being converted. + std::vector remaining_; ///< Targets still to convert after current_. + clipboard_data_t fetched_; ///< Formats read so far. + incr_transfer_t transfer_; ///< INCR transfer for current_. + }; /** * @brief Take and clear the request for the current host clipboard. @@ -373,20 +631,12 @@ namespace platf { return; } - auto root = DefaultRootWindow(display); - auto window = XCreateSimpleWindow(display, root, 0, 0, 1, 1, 0, 0, 0); + watcher_display = display; + previous_error_handler = XSetErrorHandler(handle_x_error); + + auto window = XCreateSimpleWindow(display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0); XSelectInput(display, window, PropertyChangeMask); - auto clipboard_atom = XInternAtom(display, "CLIPBOARD", False); - auto utf8 = XInternAtom(display, "UTF8_STRING", False); - auto incr = XInternAtom(display, "INCR", False); - auto targets = XInternAtom(display, "TARGETS", False); - auto property = XInternAtom(display, "SUNSHINE_CLIPBOARD", False); - XFixesSelectSelectionInput(display, window, clipboard_atom, XFixesSetSelectionOwnerNotifyMask); - XFlush(display); - - const clipboard_watch_t watch {display, window, event_base, clipboard_atom, utf8, incr, targets, property}; - std::string owned; - incr_transfer_t transfer; + watcher_t watcher {display, window, event_base}; { auto &clipboard = state(); std::lock_guard lock {clipboard.mutex}; @@ -400,14 +650,14 @@ namespace platf { while (XPending(display) != 0) { XEvent event; XNextEvent(display, &event); - handle_event(watch, event, owned, transfer); + watcher.handle(event); } - apply_pending(display, clipboard_atom, window, owned); + watcher.take_pending(); if (consume_pull()) { - transfer = {}; - convert_selection(display, clipboard_atom, utf8, property, window); + watcher.pull(); } + watcher.prune_sends(); std::array fds {pollfd {xfd, POLLIN, 0}, pollfd {wake_read, POLLIN, 0}}; poll(fds.data(), fds.size(), 200); @@ -418,30 +668,24 @@ namespace platf { } } // namespace - void clipboard_set(std::string_view text, const clipboard_queue_t &origin) { - std::string capped {text}; - if (capped.size() > clipboard_max_bytes) { - capped.resize(clipboard_max_bytes); + void clipboard_set(clipboard_data_t data, const clipboard_queue_t &origin) { + if (data.text.empty() && data.image.empty()) { + return; } - std::vector queues; + const auto shared = std::make_shared(std::move(data)); + clipboard_delivery_t delivery; { auto &clipboard = state(); std::lock_guard lock {clipboard.mutex}; if (clipboard.failed || !clipboard.ready) { return; } - clipboard.pending = capped; - clipboard.has_pending = true; - queues = clipboard.subscribers.note_local(origin, capped); - if (clipboard.wake_write >= 0) { - char byte = 1; - if (write(clipboard.wake_write, &byte, 1) < 0) { - BOOST_LOG(debug) << "Clipboard watcher wakeup was not queued"sv; - } - } + clipboard.pending = shared; + delivery = clipboard.subscribers.publish(shared, origin); + wake_watcher(clipboard); } - raise_clipboard(queues, capped); + raise_offers(delivery, shared); } void clipboard_subscribe(clipboard_queue_t queue) { @@ -458,12 +702,7 @@ namespace platf { return; } clipboard.pull = true; - if (clipboard.wake_write >= 0) { - char byte = 1; - if (write(clipboard.wake_write, &byte, 1) < 0) { - BOOST_LOG(debug) << "Clipboard watcher wakeup was not queued"sv; - } - } + wake_watcher(clipboard); } void clipboard_unsubscribe(const clipboard_queue_t &queue) { diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 95f73958d32..f0daaee0e22 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -68,7 +68,7 @@ namespace platf { return gamepads; } - void clipboard_set(std::string_view, const clipboard_queue_t &) { + void clipboard_set(clipboard_data_t, const clipboard_queue_t &) { // macOS host clipboard sync is not implemented yet. } diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index 7cefbbfc15d..059a25c9470 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -1349,7 +1349,7 @@ namespace platf { return caps; } - void clipboard_set(std::string_view, const clipboard_queue_t &) { + void clipboard_set(clipboard_data_t, const clipboard_queue_t &) { // Windows host clipboard sync is not implemented yet. } diff --git a/src/stream.cpp b/src/stream.cpp index f8ee1975971..a0e19d86280 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,12 +4,14 @@ */ // standard includes +#include #include #include #include #include #include #include +#include #include #include @@ -20,6 +22,7 @@ extern "C" { // clang-format off +#include #include #include // clang-format on @@ -32,6 +35,7 @@ extern "C" { #include "input.h" #include "logging.h" #include "network.h" +#include "platform/clipboard_sync.h" #include "platform/common.h" #include "process.h" #include "stream.h" @@ -315,6 +319,22 @@ namespace stream { */ using control_encrypted_p = control_encrypted_t *; + /** + * @brief Packed encrypted control-channel envelope for messages too large for control_encrypted_t. + */ + struct control_encrypted_jumbo_t { + std::uint16_t encryptedHeaderType; ///< Always LE 0x0002. + std::uint32_t length; ///< Size of seq, tag, secondary header, and data. + std::uint32_t seq; ///< Monotonically increasing sequence number used as the AES-GCM IV. + + // encrypted control header type (no length) and payload data follow + }; + + /** + * @brief Wire type of control_encrypted_jumbo_t. + */ + constexpr std::uint16_t encrypted_jumbo_type = 0x0002; + /** * @brief Packed RTP and FEC headers for an audio recovery packet. */ @@ -333,6 +353,7 @@ namespace stream { static_assert(sizeof(control_adaptive_triggers_t) == 9 + 2 * DS_EFFECT_PAYLOAD_SIZE); static_assert(offsetof(control_adaptive_triggers_t, left) == 9); static_assert(offsetof(control_adaptive_triggers_t, right) == 9 + DS_EFFECT_PAYLOAD_SIZE); + static_assert(sizeof(control_encrypted_jumbo_t) == 10); /** * @brief Round a byte count up to the next PKCS#7 padding boundary. @@ -562,7 +583,9 @@ namespace stream { std::uint32_t seq; ///< Sequence number for the next encrypted control message. platf::feedback_queue_t feedback_queue; ///< Queue of controller feedback awaiting control-channel delivery. - platf::clipboard_queue_t clipboard_queue; ///< Queue of clipboard text awaiting control-channel delivery. + platf::clipboard_queue_t clipboard_queue; ///< Queue of clipboard messages awaiting control-channel delivery. + std::shared_ptr clipboard_offer; ///< Host clipboard last offered to the client. + std::uint32_t clipboard_sequence = 0; ///< Sequence number of clipboard_offer. safe::mail_raw_t::event_t hdr_queue; ///< Queue of HDR metadata awaiting control-channel delivery. } control; ///< Runtime state for the encrypted GameStream control channel. @@ -577,22 +600,13 @@ namespace stream { }; /** - * First part of cipher must be struct of type control_encrypted_t + * @brief Prepare the IV for one host-originated control message. * - * returns empty string_view on failure - * returns string_view pointing to payload data + * @param session Session whose outgoing IV is updated. + * @param seq Sequence number of the message. + * @return The session's outgoing IV. */ - static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::uint8_t *tagged_cipher, std::size_t tagged_size) { - if (tagged_size < sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size)) { - return {}; - } - - if (session->config.controlProtocolType != 13) { - return plaintext; - } - - auto seq = session->control.seq++; - + static crypto::aes_t *outgoing_control_iv(session_t *session, std::uint32_t seq) { auto &iv = session->control.outgoing_iv; if (session->config.encryptionFlagsEnabled & SS_ENC_CONTROL_V2) { // We use the deterministic IV construction algorithm specified in NIST SP 800-38D @@ -614,9 +628,29 @@ namespace stream { iv[0] = (std::uint8_t) seq; } + return &iv; + } + + /** + * First part of cipher must be struct of type control_encrypted_t + * + * returns empty string_view on failure + * returns string_view pointing to payload data + */ + static inline std::string_view encode_control(session_t *session, const std::string_view &plaintext, std::uint8_t *tagged_cipher, std::size_t tagged_size) { + if (tagged_size < sizeof(control_encrypted_t) + sizeof(crypto::cipher::tag_size)) { + return {}; + } + + if (session->config.controlProtocolType != 13) { + return plaintext; + } + + auto seq = session->control.seq++; + auto packet = (control_encrypted_p) tagged_cipher; - auto bytes = session->control.cipher.encrypt(plaintext, packet->payload(), &iv); + auto bytes = session->control.cipher.encrypt(plaintext, packet->payload(), outgoing_control_iv(session, seq)); if (bytes <= 0) { BOOST_LOG(error) << "Couldn't encrypt control data"sv; return {}; @@ -641,6 +675,38 @@ namespace stream { return encode_control(session, plaintext, tagged_cipher.data(), tagged_cipher.size()); } + /** + * @brief Encrypt a control message that is too large for control_encrypted_t. + * + * Jumbo messages need the SS_ENC_CONTROL_V2 encrypted control stream. + * + * @param session Session that owns the control stream. + * @param plaintext Control message type followed by the payload, without a length field. + * @param tagged_cipher Destination for the jumbo envelope, the GCM tag, and the ciphertext. + * @param tagged_size Size of tagged_cipher. + * @return The encoded message, or empty when the client cannot receive jumbo messages. + */ + static std::string_view encode_control_jumbo(session_t *session, const std::string_view &plaintext, std::uint8_t *tagged_cipher, std::size_t tagged_size) { + if (session->config.controlProtocolType != 13 || !(session->config.encryptionFlagsEnabled & SS_ENC_CONTROL_V2) || tagged_size < sizeof(control_encrypted_jumbo_t) + crypto::cipher::tag_size + plaintext.size()) { + return {}; + } + + auto seq = session->control.seq++; + + auto bytes = session->control.cipher.encrypt(plaintext, tagged_cipher + sizeof(control_encrypted_jumbo_t), outgoing_control_iv(session, seq)); + if (bytes <= 0) { + BOOST_LOG(error) << "Couldn't encrypt jumbo control data"sv; + return {}; + } + + auto packet = (control_encrypted_jumbo_t *) tagged_cipher; + packet->encryptedHeaderType = util::endian::little(encrypted_jumbo_type); + packet->length = util::endian::little(static_cast(sizeof(seq) + crypto::cipher::tag_size + bytes)); + packet->seq = util::endian::little(seq); + + return std::string_view {(char *) tagged_cipher, sizeof(control_encrypted_jumbo_t) + crypto::cipher::tag_size + bytes}; + } + /** * @brief Start periodic mDNS and service-discovery broadcasts. * @@ -754,7 +820,7 @@ namespace stream { */ void control_server_t::call(std::uint16_t type, session_t *session, const std::string_view &payload, bool reinjected) { // If we are using the encrypted control stream protocol, drop any messages that come off the wire unencrypted - if (session->config.controlProtocolType == 13 && !reinjected && type != packetTypes[IDX_ENCRYPTED]) { + if (session->config.controlProtocolType == 13 && !reinjected && type != packetTypes[IDX_ENCRYPTED] && type != encrypted_jumbo_type) { BOOST_LOG(error) << "Dropping unencrypted message on encrypted control stream: "sv << util::hex(type).to_string_view(); return; } @@ -1145,43 +1211,124 @@ namespace stream { } /** - * @brief Send clipboard text to the client over the control channel. + * @brief Send one clipboard message body to the client over the control channel. + * + * Bodies too large for control_encrypted_t are sent as jumbo messages. * * @param session Active streaming session. - * @param msg Clipboard text to send. + * @param type Clipboard control message type. + * @param body Message body. * @return 0 when the control message is queued. */ - int send_clipboard_text(session_t *session, const platf::clipboard_text_t &msg) { - if (!session->control.peer || msg.text.size() > SS_CLIPBOARD_TEXT_MAX) { + int send_clipboard_message(session_t *session, std::uint16_t type, std::span body) { + if (!session->control.peer) { return -1; } - control_header_v2 header {}; - header.type = SS_CLIPBOARD_CONTROL_PTYPE; - header.payloadLength = static_cast(sizeof(std::uint32_t) + msg.text.size()); + std::vector plaintext; + std::vector tagged; + std::string_view encoded; + // control_encrypted_t has a 16-bit length covering the sequence number, GCM tag, and control_header_v2. + if (sizeof(std::uint32_t) + crypto::cipher::tag_size + sizeof(control_header_v2) + body.size() <= 0xFFFF) { + control_header_v2 header {}; + header.type = type; + header.payloadLength = static_cast(body.size()); + + plaintext.resize(sizeof(header) + body.size()); + std::memcpy(plaintext.data(), &header, sizeof(header)); + std::ranges::copy(body, plaintext.begin() + sizeof(header)); + + tagged.resize(sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + crypto::cipher::tag_size); + encoded = encode_control(session, util::view(plaintext.data(), plaintext.data() + plaintext.size()), tagged.data(), tagged.size()); + } else { + const auto type_le = util::endian::little(type); - std::vector plaintext(sizeof(header) + header.payloadLength); - std::memcpy(plaintext.data(), &header, sizeof(header)); + plaintext.resize(sizeof(type_le) + body.size()); + std::memcpy(plaintext.data(), &type_le, sizeof(type_le)); + std::ranges::copy(body, plaintext.begin() + sizeof(type_le)); - auto length = util::endian::little(static_cast(msg.text.size())); - auto *payload = plaintext.data() + sizeof(header); - std::memcpy(payload, &length, sizeof(length)); - std::memcpy(payload + sizeof(length), msg.text.data(), msg.text.size()); + tagged.resize(sizeof(control_encrypted_jumbo_t) + crypto::cipher::tag_size + plaintext.size()); + encoded = encode_control_jumbo(session, util::view(plaintext.data(), plaintext.data() + plaintext.size()), tagged.data(), tagged.size()); + } - const auto tagged_size = sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(plaintext.size()) + crypto::cipher::tag_size; - std::vector tagged(tagged_size); - auto encoded = encode_control(session, util::view(plaintext.data(), plaintext.data() + plaintext.size()), tagged.data(), tagged.size()); - if (encoded.empty()) { + if (encoded.empty() || session->broadcast_ref->control_server.send(encoded, session->control.peer)) { return -1; } - if (session->broadcast_ref->control_server.send(encoded, session->control.peer)) { + return 0; + } + + /** + * @brief Send every chunk of one clipboard format the client requested. + * + * @param session Active streaming session. + * @param mime MIME type the client requested. + * @param sequence Offer sequence the request answers. + * @return 0 when every chunk is queued, or when the request is for an older offer. + */ + int send_clipboard_data(session_t *session, const std::string &mime, std::uint32_t sequence) { + const auto &offer = session->control.clipboard_offer; + if (!offer || sequence != session->control.clipboard_sequence) { + return 0; + } + + const auto data = platf::clipboard_payload(*offer, mime); + if (!data) { return -1; } + const auto size = static_cast(data->size()); + const auto count = ClipboardChunkCount(size); + for (std::uint16_t index = 0; index < count; ++index) { + const auto offset = std::size_t {index} * SS_CLIPBOARD_CHUNK_MAX; + const auto chunk = data->subspan(offset, std::min(data->size() - offset, SS_CLIPBOARD_CHUNK_MAX)); + std::vector body(SS_CLIPBOARD_DATA_HEADER_MAX + chunk.size()); + const auto length = ClipboardEncodeChunk(body.data(), static_cast(body.size()), mime.c_str(), sequence, index, count, chunk.data(), static_cast(chunk.size())); + if (length < 0 || send_clipboard_message(session, SS_CLIPBOARD_DATA_PTYPE, std::span {body}.first(static_cast(length)))) { + return -1; + } + } + return 0; } + /** + * @brief Send a clipboard offer, request, or data raised on the session's clipboard queue. + * + * @param session Active streaming session. + * @param msg Message raised by the platform clipboard or the input thread. + * @return 0 when the control messages are queued. + */ + int send_clipboard(session_t *session, const platf::clipboard_message_t &msg) { + switch (msg.action) { + case platf::clipboard_action_e::offer: + { + session->control.clipboard_offer = msg.data; + session->control.clipboard_sequence = msg.sequence; + + const auto mime_types = platf::clipboard_mime_types(*msg.data); + std::vector pointers; + for (const auto &mime : mime_types) { + pointers.push_back(mime.c_str()); + } + + std::array body {}; + const auto length = ClipboardEncodeOffer(body.data(), static_cast(body.size()), pointers.data(), static_cast(pointers.size()), msg.sequence); + return length < 0 ? -1 : send_clipboard_message(session, SS_CLIPBOARD_OFFER_PTYPE, std::span {body}.first(static_cast(length))); + } + case platf::clipboard_action_e::request: + { + std::array body {}; + const auto length = ClipboardEncodeRequest(body.data(), static_cast(body.size()), msg.mime.c_str(), msg.sequence); + return length < 0 ? -1 : send_clipboard_message(session, SS_CLIPBOARD_REQUEST_PTYPE, std::span {body}.first(static_cast(length))); + } + case platf::clipboard_action_e::send: + return send_clipboard_data(session, msg.mime, msg.sequence); + } + + return -1; + } + /** * @brief Send the selected HDR mode to the connected client over the control channel. * @@ -1218,6 +1365,71 @@ namespace stream { return 0; } + /** + * @brief Decrypt a client control message and handle the message inside it. + * + * @param server Control server that dispatches the decrypted message. + * @param session Session the message arrived on. + * @param seq Sequence number from the encrypted envelope. + * @param tagged_cipher GCM tag followed by the ciphertext. + * @param header_size Size of the decrypted control header: 4 for control_header_v2, or 2 for a jumbo message, which has no length field. + */ + void handle_encrypted_control(control_server_t *server, session_t *session, std::uint32_t seq, std::string_view tagged_cipher, std::size_t header_size) { + auto &cipher = session->control.cipher; + auto &iv = session->control.incoming_iv; + if (session->config.encryptionFlagsEnabled & SS_ENC_CONTROL_V2) { + // We use the deterministic IV construction algorithm specified in NIST SP 800-38D + // Section 8.2.1. The sequence number is our "invocation" field and the 'CC' in the + // high bytes is the "fixed" field. Because each client provides their own unique + // key, our values in the fixed field need only uniquely identify each independent + // use of the client's key with AES-GCM in our code. + // + // The sequence number is 32 bits long which allows for 2^32 control stream messages + // to be received from each client before the IV repeats. + iv.resize(12); + std::copy_n((uint8_t *) &seq, sizeof(seq), std::begin(iv)); + iv[10] = 'C'; // Client originated + iv[11] = 'C'; // Control stream + } else { + // Nvidia's old style encryption uses a 16-byte IV + iv.resize(16); + + iv[0] = (std::uint8_t) seq; + } + + std::vector plaintext; + if (cipher.decrypt(tagged_cipher, plaintext, &iv)) { + // something went wrong :( + + BOOST_LOG(error) << "Failed to verify tag"sv; + + session::stop(*session); + return; + } + + if (plaintext.size() < header_size) { + BOOST_LOG(warning) << "Control: Runt decrypted packet"sv; + return; + } + + auto type = *(std::uint16_t *) plaintext.data(); + std::string_view next_payload {(char *) plaintext.data() + header_size, plaintext.size() - header_size}; + + if (type == packetTypes[IDX_ENCRYPTED] || type == encrypted_jumbo_type) { + BOOST_LOG(error) << "Bad packet type [IDX_ENCRYPTED] found"sv; + session::stop(*session); + return; + } + + // IDX_INPUT_DATA callback will attempt to decrypt unencrypted data, therefore we need pass it directly + if (type == packetTypes[IDX_INPUT_DATA]) { + plaintext.erase(std::begin(plaintext), std::begin(plaintext) + static_cast(header_size)); + input::passthrough(session->input, std::move(plaintext)); + } else { + server->call(type, session, next_payload, true); + } + } + /** * @brief Run the broadcast control-channel worker thread. * @@ -1313,54 +1525,31 @@ namespace stream { auto tagged_cipher_length = length - 4; std::string_view tagged_cipher {(char *) header->payload(), (size_t) tagged_cipher_length}; - auto &cipher = session->control.cipher; - auto &iv = session->control.incoming_iv; - if (session->config.encryptionFlagsEnabled & SS_ENC_CONTROL_V2) { - // We use the deterministic IV construction algorithm specified in NIST SP 800-38D - // Section 8.2.1. The sequence number is our "invocation" field and the 'CC' in the - // high bytes is the "fixed" field. Because each client provides their own unique - // key, our values in the fixed field need only uniquely identify each independent - // use of the client's key with AES-GCM in our code. - // - // The sequence number is 32 bits long which allows for 2^32 control stream messages - // to be received from each client before the IV repeats. - iv.resize(12); - std::copy_n((uint8_t *) &seq, sizeof(seq), std::begin(iv)); - iv[10] = 'C'; // Client originated - iv[11] = 'C'; // Control stream - } else { - // Nvidia's old style encryption uses a 16-byte IV - iv.resize(16); - - iv[0] = (std::uint8_t) seq; - } - - std::vector plaintext; - if (cipher.decrypt(tagged_cipher, plaintext, &iv)) { - // something went wrong :( + handle_encrypted_control(server, session, seq, tagged_cipher, sizeof(control_header_v2)); + }); - BOOST_LOG(error) << "Failed to verify tag"sv; + server->map(encrypted_jumbo_type, [server](session_t *session, const std::string_view &payload) { + BOOST_LOG(verbose) << "type [IDX_ENCRYPTED_JUMBO]"sv; - session::stop(*session); + control_encrypted_jumbo_t header {}; + if (payload.size() < sizeof(header) - sizeof(header.encryptedHeaderType)) { + BOOST_LOG(warning) << "Control: Runt jumbo packet"sv; return; } + std::memcpy(&header, payload.data() - sizeof(header.encryptedHeaderType), sizeof(header)); - auto type = *(std::uint16_t *) plaintext.data(); - std::string_view next_payload {(char *) plaintext.data() + 4, plaintext.size() - 4}; + auto length = util::endian::little(header.length); + auto seq = util::endian::little(header.seq); - if (type == packetTypes[IDX_ENCRYPTED]) { - BOOST_LOG(error) << "Bad packet type [IDX_ENCRYPTED] found"sv; - session::stop(*session); + // The decrypted message holds at least the 2-byte message type + if (length < sizeof(seq) + crypto::cipher::tag_size + sizeof(std::uint16_t) || length > payload.size() - sizeof(header.length)) { + BOOST_LOG(warning) << "Control: Runt jumbo packet"sv; return; } - // IDX_INPUT_DATA callback will attempt to decrypt unencrypted data, therefore we need pass it directly - if (type == packetTypes[IDX_INPUT_DATA]) { - plaintext.erase(std::begin(plaintext), std::begin(plaintext) + 4); - input::passthrough(session->input, std::move(plaintext)); - } else { - server->call(type, session, next_payload, true); - } + std::string_view tagged_cipher {payload.data() + sizeof(header) - sizeof(header.encryptedHeaderType), length - sizeof(seq)}; + + handle_encrypted_control(server, session, seq, tagged_cipher, sizeof(std::uint16_t)); }); // This thread handles latency-sensitive control messages @@ -1426,7 +1615,7 @@ namespace stream { auto &clipboard_queue = session->control.clipboard_queue; while (session->control.peer && clipboard_queue->peek()) { auto clipboard_msg = clipboard_queue->pop(); - send_clipboard_text(session, *clipboard_msg); + send_clipboard(session, *clipboard_msg); } auto &hdr_queue = session->control.hdr_queue; @@ -2389,7 +2578,7 @@ namespace stream { session->control.connect_data = launch_session.control_connect_data; session->control.feedback_queue = mail->queue(mail::gamepad_feedback); - session->control.clipboard_queue = mail->queue(mail::clipboard); + session->control.clipboard_queue = mail->queue(mail::clipboard); session->control.hdr_queue = mail->event(mail::hdr); session->control.legacy_input_enc_iv = launch_session.iv; session->control.cipher = crypto::cipher::gcm_t { diff --git a/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp index 76368c8a183..aa800860930 100644 --- a/tests/unit/test_clipboard.cpp +++ b/tests/unit/test_clipboard.cpp @@ -1,17 +1,22 @@ /** * @file tests/unit/test_clipboard.cpp - * @brief Tests for clipboard subscription lifecycle and X selection decoding. + * @brief Tests for clipboard formats, subscription lifecycle, and X selection transfers. */ // test includes #include "../tests_common.h" // standard includes +#include #include #include #include #include +#include +#include #include +#include +#include #include // local includes @@ -24,29 +29,39 @@ namespace { * * @param mail Mailbox that owns the queue. * @param id Queue identifier unique within the mailbox. - * @return Queue that records delivered clipboard text. + * @return Queue that records clipboard offers. */ platf::clipboard_queue_t make_queue(const safe::mail_t &mail, const std::string &id) { - return mail->queue(id); + return mail->queue(id); } /** - * @brief Raise text on the queues selected by a subscriber update. + * @brief Make a shared clipboard holding text. * - * @param queues Queues that should receive the text. * @param text UTF-8 clipboard text. + * @return Clipboard with only text. */ - void deliver(const std::vector &queues, const std::string &text) { - for (const auto &queue : queues) { - queue->raise(platf::clipboard_text_t {text}); + std::shared_ptr text_clipboard(const std::string &text) { + return std::make_shared(platf::clipboard_data_t {text, {}, {}}); + } + + /** + * @brief Raise an offer on the queues selected by a subscriber update. + * + * @param delivery Queues and sequence selected by the update. + * @param data Clipboard to offer. + */ + void deliver(const platf::clipboard_delivery_t &delivery, const std::shared_ptr &data) { + for (const auto &queue : delivery.queues) { + queue->raise(platf::clipboard_message_t {platf::clipboard_action_e::offer, delivery.sequence, {}, data}); } } /** - * @brief Remove every pending clipboard value from a queue. + * @brief Remove every pending clipboard offer from a queue. * * @param queue Queue to drain. - * @return Text values in delivery order. + * @return Offered text values in delivery order. */ std::vector drain(const platf::clipboard_queue_t &queue) { std::vector texts; @@ -55,11 +70,21 @@ namespace { if (!item) { break; } - texts.push_back(item->text); + texts.push_back(item->data->text); } return texts; } + /** + * @brief View bytes as a span. + * + * @param text Bytes to view. + * @return The bytes of text. + */ + std::span bytes_of(std::string_view text) { + return std::as_bytes(std::span {text.data(), text.size()}); + } + /** * @brief Selection property that disappears only after its last byte is read. */ @@ -103,6 +128,10 @@ namespace { return fragment; } }; + + constexpr std::uint64_t utf8_atom = 1; ///< Stand-in UTF8_STRING atom. + constexpr std::uint64_t incr_atom = 3; ///< Stand-in INCR atom. + constexpr std::array incr_announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; ///< INCR size announcement. } // namespace TEST(ClipboardSubscriptionTest, DeliversOneHostChangeToEverySubscriber) { @@ -113,8 +142,11 @@ TEST(ClipboardSubscriptionTest, DeliversOneHostChangeToEverySubscriber) { EXPECT_TRUE(subscribers.add(first)); EXPECT_TRUE(subscribers.add(second)); - deliver(subscribers.publish("host"), "host"); + const auto host = text_clipboard("host"); + const auto delivery = subscribers.publish(host); + deliver(delivery, host); + EXPECT_NE(delivery.sequence, 0); EXPECT_EQ(drain(first), (std::vector {"host"})); EXPECT_EQ(drain(second), (std::vector {"host"})); } @@ -125,11 +157,12 @@ TEST(ClipboardSubscriptionTest, SendsTheCurrentClipboardToASubscriptionAddedLate auto second = make_queue(mail, "clipboard-b"); platf::clipboard_subscribers_t subscribers; EXPECT_TRUE(subscribers.add(first)); - deliver(subscribers.publish("same"), "same"); + const auto same = text_clipboard("same"); + deliver(subscribers.publish(same), same); EXPECT_EQ(drain(first), (std::vector {"same"})); EXPECT_TRUE(subscribers.add(second)); - deliver(subscribers.publish("same"), "same"); + deliver(subscribers.publish(text_clipboard("same")), same); EXPECT_TRUE(drain(first).empty()); EXPECT_EQ(drain(second), (std::vector {"same"})); @@ -144,13 +177,14 @@ TEST(ClipboardSubscriptionTest, RemovesASubscriberWhenTheSessionStops) { EXPECT_TRUE(subscribers.add(second)); subscribers.remove(first); - deliver(subscribers.publish("next"), "next"); + const auto next = text_clipboard("next"); + deliver(subscribers.publish(next), next); EXPECT_TRUE(drain(first).empty()); EXPECT_EQ(drain(second), (std::vector {"next"})); } -TEST(ClipboardSubscriptionTest, DoesNotEchoALocalSetToItsOrigin) { +TEST(ClipboardSubscriptionTest, DoesNotOfferAClientClipboardBackToItsOrigin) { auto mail = std::make_shared(); auto origin = make_queue(mail, "clipboard-origin"); auto other = make_queue(mail, "clipboard-other"); @@ -158,11 +192,14 @@ TEST(ClipboardSubscriptionTest, DoesNotEchoALocalSetToItsOrigin) { EXPECT_TRUE(subscribers.add(origin)); EXPECT_TRUE(subscribers.add(other)); - deliver(subscribers.note_local(origin, "from-origin"), "from-origin"); + const auto from_origin = text_clipboard("from-origin"); + const auto delivery = subscribers.publish(from_origin, origin); + deliver(delivery, from_origin); EXPECT_TRUE(drain(origin).empty()); EXPECT_EQ(drain(other), (std::vector {"from-origin"})); - EXPECT_TRUE(subscribers.publish("from-origin").empty()); + // The host selection now holds the same clipboard, so reading it back offers nothing + EXPECT_TRUE(subscribers.publish(text_clipboard("from-origin")).queues.empty()); } TEST(ClipboardSubscriptionTest, ResubscribingTheSameQueueDoesNotClearItsSnapshot) { @@ -170,13 +207,94 @@ TEST(ClipboardSubscriptionTest, ResubscribingTheSameQueueDoesNotClearItsSnapshot auto queue = make_queue(mail, "clipboard-a"); platf::clipboard_subscribers_t subscribers; EXPECT_TRUE(subscribers.add(queue)); - deliver(subscribers.publish("kept"), "kept"); + const auto kept = text_clipboard("kept"); + deliver(subscribers.publish(kept), kept); EXPECT_FALSE(subscribers.add(queue)); - EXPECT_TRUE(subscribers.publish("kept").empty()); + EXPECT_TRUE(subscribers.publish(text_clipboard("kept")).queues.empty()); EXPECT_EQ(drain(queue), (std::vector {"kept"})); } +TEST(ClipboardSubscriptionTest, UsesANewSequenceOnlyForAnOfferThatIsSent) { + auto mail = std::make_shared(); + auto queue = make_queue(mail, "clipboard-a"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(queue)); + + const auto first = subscribers.publish(text_clipboard("one")); + const auto repeat = subscribers.publish(text_clipboard("one")); + const auto second = subscribers.publish(text_clipboard("two")); + + EXPECT_TRUE(repeat.queues.empty()); + EXPECT_EQ(repeat.sequence, 0); + EXPECT_EQ(second.sequence, first.sequence + 1); +} + +TEST(ClipboardSubscriptionTest, OffersANewImageWithUnchangedText) { + auto mail = std::make_shared(); + auto queue = make_queue(mail, "clipboard-a"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(queue)); + EXPECT_EQ(subscribers.publish(std::make_shared(platf::clipboard_data_t {"caption", SS_CLIPBOARD_MIME_PNG, {1, 2}})).queues.size(), 1); + + const auto changed = subscribers.publish(std::make_shared(platf::clipboard_data_t {"caption", SS_CLIPBOARD_MIME_PNG, {3}})); + + EXPECT_EQ(changed.queues.size(), 1); +} + +TEST(ClipboardFormatTest, OffersTextThenTheImage) { + const platf::clipboard_data_t both {"text", SS_CLIPBOARD_MIME_JPEG, {1}}; + const platf::clipboard_data_t image_only {{}, SS_CLIPBOARD_MIME_PNG, {1}}; + + EXPECT_EQ(platf::clipboard_mime_types(both), (std::vector {SS_CLIPBOARD_MIME_TEXT, SS_CLIPBOARD_MIME_JPEG})); + EXPECT_EQ(platf::clipboard_mime_types(image_only), (std::vector {SS_CLIPBOARD_MIME_PNG})); + EXPECT_TRUE(platf::clipboard_mime_types({}).empty()); +} + +TEST(ClipboardFormatTest, FindsTheBytesOfARequestedFormat) { + const platf::clipboard_data_t data {"hi", SS_CLIPBOARD_MIME_PNG, {7, 8, 9}}; + + const auto text = platf::clipboard_payload(data, "text/plain"); + const auto image = platf::clipboard_payload(data, SS_CLIPBOARD_MIME_PNG); + + ASSERT_TRUE(text); + EXPECT_EQ(std::string(text->begin(), text->end()), "hi"); + ASSERT_TRUE(image); + EXPECT_EQ(std::vector(image->begin(), image->end()), (std::vector {7, 8, 9})); + EXPECT_FALSE(platf::clipboard_payload(data, SS_CLIPBOARD_MIME_JPEG)); + EXPECT_FALSE(platf::clipboard_payload({}, SS_CLIPBOARD_MIME_TEXT)); +} + +TEST(ClipboardFormatTest, RecognizesPlainTextWithAndWithoutParameters) { + EXPECT_TRUE(platf::clipboard_is_text("text/plain")); + EXPECT_TRUE(platf::clipboard_is_text(SS_CLIPBOARD_MIME_TEXT)); + EXPECT_FALSE(platf::clipboard_is_text("text/plainish")); + EXPECT_FALSE(platf::clipboard_is_text("text/html")); +} + +TEST(ClipboardFormatTest, RequestsTextAndThePreferredImage) { + const std::array offered {"text/html", SS_CLIPBOARD_MIME_JPEG, "text/plain", SS_CLIPBOARD_MIME_PNG}; + + EXPECT_EQ(platf::clipboard_wanted_formats(offered), (std::vector {"text/plain", SS_CLIPBOARD_MIME_PNG})); +} + +TEST(ClipboardFormatTest, RequestsJpegWhenItIsTheOnlyImage) { + const std::array offered {SS_CLIPBOARD_MIME_JPEG}; + + EXPECT_EQ(platf::clipboard_wanted_formats(offered), (std::vector {SS_CLIPBOARD_MIME_JPEG})); +} + +TEST(ClipboardFormatTest, RequestsNothingFromAnOfferWithoutSupportedFormats) { + const std::array offered {"text/html", "image/gif"}; + + EXPECT_TRUE(platf::clipboard_wanted_formats(offered).empty()); +} + +TEST(ClipboardFormatTest, ConvertsLatin1TextToUtf8) { + EXPECT_EQ(platf::latin1_to_utf8("plain"), "plain"); + EXPECT_EQ(platf::latin1_to_utf8("caf\xe9 \xff"), "caf\xc3\xa9 \xc3\xbf"); +} + TEST(ClipboardSelectionTest, UsesTheTargetAtomWhenTheSelectionPropertyIsNone) { EXPECT_EQ(platf::selection_reply_property(0, 44, true), 44); EXPECT_EQ(platf::selection_reply_property(7, 44, true), 7); @@ -187,103 +305,103 @@ TEST(ClipboardSelectionTest, RefusesAnUnsupportedSelectionTarget) { EXPECT_EQ(platf::selection_reply_property(7, 44, false), 0); } +TEST(ClipboardSelectionTest, ReturnsAValueReceivedInOneProperty) { + platf::incr_transfer_t transfer; + + const auto value = transfer.consume(utf8_atom, 8, bytes_of("hello"), false, utf8_atom, incr_atom); + + ASSERT_TRUE(value); + EXPECT_EQ(*value, (std::vector(bytes_of("hello").begin(), bytes_of("hello").end()))); +} + TEST(ClipboardSelectionTest, DoesNotPublishAnIncrSizeAnnouncement) { - constexpr std::uint64_t utf8 = 1; - constexpr std::uint64_t string_atom = 2; - constexpr std::uint64_t incr = 3; - constexpr std::array announcement {std::byte {0x2a}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; platf::incr_transfer_t transfer; - const auto text = transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr); + const auto value = transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom); - EXPECT_FALSE(text); + EXPECT_FALSE(value); EXPECT_TRUE(transfer.active()); } TEST(ClipboardSelectionTest, PublishesIncrChunksOnlyAfterTheTransferEnds) { - constexpr std::uint64_t utf8 = 1; - constexpr std::uint64_t string_atom = 2; - constexpr std::uint64_t incr = 3; - constexpr std::array announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; - constexpr std::array chunk {std::byte {'a'}, std::byte {'b'}, std::byte {'c'}, std::byte {'d'}}; platf::incr_transfer_t transfer; - EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); - EXPECT_FALSE(transfer.consume(utf8, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.consume(utf8_atom, 8, bytes_of("ab"), false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.consume(utf8_atom, 8, bytes_of("cd"), false, utf8_atom, incr_atom)); - const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); + const auto value = transfer.consume(utf8_atom, 8, {}, false, utf8_atom, incr_atom); - ASSERT_TRUE(text); - EXPECT_EQ(*text, "abcd"); + ASSERT_TRUE(value); + EXPECT_EQ(*value, (std::vector(bytes_of("abcd").begin(), bytes_of("abcd").end()))); EXPECT_FALSE(transfer.active()); } -TEST(ClipboardSelectionTest, IgnoresUnsupportedSelectionPropertyData) { - constexpr std::uint64_t utf8 = 1; - constexpr std::uint64_t string_atom = 2; - constexpr std::uint64_t incr = 3; - constexpr std::array chunk {std::byte {'n'}, std::byte {'o'}, std::byte {'p'}, std::byte {'e'}}; +TEST(ClipboardSelectionTest, IgnoresAPropertyOfAnotherType) { platf::incr_transfer_t transfer; - const auto text = transfer.consume(99, 8, chunk.size(), chunk.data(), utf8, string_atom, incr); + const auto value = transfer.consume(99, 8, bytes_of("nope"), false, utf8_atom, incr_atom); - EXPECT_FALSE(text); + EXPECT_FALSE(value); EXPECT_FALSE(transfer.active()); } -TEST(ClipboardSelectionTest, CapsClipboardTextAtTheProtocolLimit) { - constexpr std::uint64_t utf8 = 1; - constexpr std::uint64_t string_atom = 2; - constexpr std::uint64_t incr = 3; - const std::vector oversized(platf::clipboard_max_bytes + 10, std::byte {'z'}); +TEST(ClipboardSelectionTest, IgnoresAPropertyOverTheProtocolCap) { platf::incr_transfer_t transfer; - const auto text = transfer.consume(utf8, 8, oversized.size(), oversized.data(), utf8, string_atom, incr); + const auto value = transfer.consume(utf8_atom, 8, bytes_of("cut"), true, utf8_atom, incr_atom); - ASSERT_TRUE(text); - EXPECT_EQ(text->size(), platf::clipboard_max_bytes); + EXPECT_FALSE(value); } -TEST(ClipboardSelectionTest, DropsAnUnsupportedTypeReceivedDuringAnIncrTransfer) { - constexpr std::uint64_t utf8 = 1; - constexpr std::uint64_t string_atom = 2; - constexpr std::uint64_t incr = 3; - constexpr std::array announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; - constexpr std::array chunk {std::byte {'n'}, std::byte {'o'}, std::byte {'p'}, std::byte {'e'}}; +TEST(ClipboardSelectionTest, DropsAnIncrTransferWithAChunkOfAnotherType) { platf::incr_transfer_t transfer; - EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom)); - EXPECT_FALSE(transfer.consume(99, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(99, 8, bytes_of("nope"), false, utf8_atom, incr_atom)); EXPECT_TRUE(transfer.active()); - const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); + const auto value = transfer.consume(99, 8, {}, false, utf8_atom, incr_atom); - EXPECT_FALSE(text); + EXPECT_FALSE(value); EXPECT_FALSE(transfer.active()); } -TEST(ClipboardSelectionTest, AcceptsANewIncrTransferAfterRejectingAnUnsupportedChunk) { - constexpr std::uint64_t utf8 = 1; - constexpr std::uint64_t string_atom = 2; - constexpr std::uint64_t incr = 3; - constexpr std::array announcement {std::byte {0x04}, std::byte {0x00}, std::byte {0x00}, std::byte {0x00}}; - constexpr std::array chunk {std::byte {'a'}, std::byte {'b'}, std::byte {'c'}, std::byte {'d'}}; +TEST(ClipboardSelectionTest, DropsAnIncrTransferWithATruncatedChunk) { platf::incr_transfer_t transfer; - EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); - EXPECT_FALSE(transfer.consume(99, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); - EXPECT_FALSE(transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr)); + EXPECT_FALSE(transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.consume(utf8_atom, 8, bytes_of("big"), true, utf8_atom, incr_atom)); - EXPECT_FALSE(transfer.consume(incr, 32, 1, announcement.data(), utf8, string_atom, incr)); - EXPECT_FALSE(transfer.consume(utf8, 8, chunk.size(), chunk.data(), utf8, string_atom, incr)); - const auto text = transfer.consume(utf8, 8, 0, nullptr, utf8, string_atom, incr); + EXPECT_FALSE(transfer.consume(utf8_atom, 8, {}, false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.active()); +} - ASSERT_TRUE(text); - EXPECT_EQ(*text, "abcd"); +TEST(ClipboardSelectionTest, AcceptsANewIncrTransferAfterRejectingOne) { + platf::incr_transfer_t transfer; + EXPECT_FALSE(transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.consume(99, 8, bytes_of("nope"), false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.consume(utf8_atom, 8, {}, false, utf8_atom, incr_atom)); + + EXPECT_FALSE(transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.consume(utf8_atom, 8, bytes_of("abcd"), false, utf8_atom, incr_atom)); + const auto value = transfer.consume(utf8_atom, 8, {}, false, utf8_atom, incr_atom); + + ASSERT_TRUE(value); + EXPECT_EQ(value->size(), 4); +} + +TEST(ClipboardSelectionTest, StopsAppendingAtTheProtocolCap) { + std::vector out(platf::clipboard_max_bytes - 2); + + EXPECT_FALSE(platf::append_property_bytes(out, bytes_of("abc"))); + EXPECT_EQ(out.size(), platf::clipboard_max_bytes); + EXPECT_FALSE(platf::append_property_bytes(out, bytes_of("d"))); + EXPECT_EQ(out.size(), platf::clipboard_max_bytes); } -TEST(ClipboardSelectionTest, AcknowledgesAChunkLargerThanTheReadLimit) { +TEST(ClipboardSelectionTest, AcknowledgesAPropertyLargerThanOneRead) { capped_property_t property; - property.bytes.assign(64 * 1024, std::byte {'A'}); - property.bytes[platf::selection_property_long_length * 4] = std::byte {'B'}; + property.bytes.assign(platf::selection_property_long_length * 4 + 8, std::byte {'A'}); + property.bytes.back() = std::byte {'B'}; const auto loaded = platf::load_selection_property([&property](unsigned long offset, unsigned long length) { return property.read(offset, length); @@ -291,11 +409,10 @@ TEST(ClipboardSelectionTest, AcknowledgesAChunkLargerThanTheReadLimit) { ASSERT_TRUE(loaded); EXPECT_FALSE(property.exists); - EXPECT_GE(property.reads, 2); + EXPECT_EQ(property.reads, 2); EXPECT_TRUE(loaded->acknowledged); - EXPECT_EQ(loaded->data.size(), platf::clipboard_max_bytes); - EXPECT_EQ(loaded->data.front(), std::byte {'A'}); - EXPECT_EQ(loaded->data.back(), std::byte {'A'}); + EXPECT_FALSE(loaded->truncated); + EXPECT_EQ(loaded->data, property.bytes); } TEST(ClipboardSelectionTest, ReadsAShortPropertyInOneCall) { @@ -346,3 +463,14 @@ TEST(ClipboardSelectionTest, IgnoresAFailedPropertyRead) { EXPECT_FALSE(loaded); } + +TEST(ClipboardSelectionTest, SendsIncrSlicesThenAnEmptyTerminator) { + platf::incr_sender_t sender {10, 4}; + + EXPECT_EQ(sender.next(), (std::pair {0, 4})); + EXPECT_EQ(sender.next(), (std::pair {4, 4})); + EXPECT_EQ(sender.next(), (std::pair {8, 2})); + EXPECT_FALSE(sender.done()); + EXPECT_EQ(sender.next(), (std::pair {10, 0})); + EXPECT_TRUE(sender.done()); +} diff --git a/tests/unit/test_input.cpp b/tests/unit/test_input.cpp index 6028dee4e88..4139ee34be4 100644 --- a/tests/unit/test_input.cpp +++ b/tests/unit/test_input.cpp @@ -21,6 +21,7 @@ // moonlight-common-c includes extern "C" { +#include #include } @@ -202,31 +203,37 @@ TEST(InputPacketValidationTest, ValidatesUnicodePacketBoundsWithoutOverflow) { } TEST(InputPacketValidationTest, AcceptsEmptyAndMaximumClipboardPackets) { + constexpr std::array packet_specs { + input_packet_spec_t {SS_CLIPBOARD_OFFER_MAGIC, SS_CLIPBOARD_OFFER_MAX}, + input_packet_spec_t {SS_CLIPBOARD_REQUEST_MAGIC, SS_CLIPBOARD_REQUEST_MAX}, + input_packet_spec_t {SS_CLIPBOARD_DATA_MAGIC, SS_CLIPBOARD_DATA_HEADER_MAX + SS_CLIPBOARD_CHUNK_MAX}, + }; constexpr auto prefix = sizeof(std::uint32_t); - const auto empty_packet = make_input_packet(SS_CLIPBOARD_TEXT_MAGIC, prefix, sizeof(std::uint32_t) + prefix); - EXPECT_TRUE(input::testing::is_valid_input_packet(empty_packet)); + for (const auto &spec : packet_specs) { + const auto empty_packet = make_input_packet(spec.magic, prefix, sizeof(std::uint32_t) + prefix); + EXPECT_TRUE(input::testing::is_valid_input_packet(empty_packet)) << "magic=" << spec.magic; - constexpr auto maximum_declared_size = static_cast(prefix + SS_CLIPBOARD_TEXT_MAX); - const auto maximum_packet = make_input_packet( - SS_CLIPBOARD_TEXT_MAGIC, - maximum_declared_size, - sizeof(std::uint32_t) + maximum_declared_size - ); - EXPECT_TRUE(input::testing::is_valid_input_packet(maximum_packet)); + const auto maximum_declared_size = static_cast(prefix + spec.size); + const auto maximum_packet = make_input_packet(spec.magic, maximum_declared_size, sizeof(std::uint32_t) + maximum_declared_size); + EXPECT_TRUE(input::testing::is_valid_input_packet(maximum_packet)) << "magic=" << spec.magic; + } } TEST(InputPacketValidationTest, RejectsTruncatedAndOversizedClipboardPackets) { + constexpr std::array packet_specs { + input_packet_spec_t {SS_CLIPBOARD_OFFER_MAGIC, SS_CLIPBOARD_OFFER_MAX}, + input_packet_spec_t {SS_CLIPBOARD_REQUEST_MAGIC, SS_CLIPBOARD_REQUEST_MAX}, + input_packet_spec_t {SS_CLIPBOARD_DATA_MAGIC, SS_CLIPBOARD_DATA_HEADER_MAX + SS_CLIPBOARD_CHUNK_MAX}, + }; constexpr auto prefix = sizeof(std::uint32_t); - const auto truncated_packet = make_input_packet(SS_CLIPBOARD_TEXT_MAGIC, prefix + 4, sizeof(std::uint32_t) + prefix); - EXPECT_FALSE(input::testing::is_valid_input_packet(truncated_packet)); + for (const auto &spec : packet_specs) { + const auto truncated_packet = make_input_packet(spec.magic, prefix + 4, sizeof(std::uint32_t) + prefix); + EXPECT_FALSE(input::testing::is_valid_input_packet(truncated_packet)) << "magic=" << spec.magic; - constexpr auto oversized_declared_size = static_cast(prefix + SS_CLIPBOARD_TEXT_MAX + 1); - const auto oversized_packet = make_input_packet( - SS_CLIPBOARD_TEXT_MAGIC, - oversized_declared_size, - sizeof(std::uint32_t) + oversized_declared_size - ); - EXPECT_FALSE(input::testing::is_valid_input_packet(oversized_packet)); + const auto oversized_declared_size = static_cast(prefix + spec.size + 1); + const auto oversized_packet = make_input_packet(spec.magic, oversized_declared_size, sizeof(std::uint32_t) + oversized_declared_size); + EXPECT_FALSE(input::testing::is_valid_input_packet(oversized_packet)) << "magic=" << spec.magic; + } } TEST(InputPacketValidationTest, EnforcesEveryFixedPacketSize) { diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index f197f1d5e46..8968961b13a 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit f197f1d5e46262394c3c094c7d7fcf465dd50ab7 +Subproject commit 8968961b13ab6ae23274a79fc0f160d4cb7d2cb8 From 4ce9799477311143df6d414236008088ee829a57 Mon Sep 17 00:00:00 2001 From: Wouze <78015958+Wouze@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:49:44 +0300 Subject: [PATCH 14/14] fix(linux): build the selection reply in its own event struct The reply is filled as an XSelectionEvent and copied into the XEvent, so no inactive union member is written. --- src/platform/linux/clipboard.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/platform/linux/clipboard.cpp b/src/platform/linux/clipboard.cpp index a154ec09049..5b34137b75c 100644 --- a/src/platform/linux/clipboard.cpp +++ b/src/platform/linux/clipboard.cpp @@ -518,14 +518,18 @@ namespace platf { sends_.push_back(send_t {request.requestor, property, request.target, *bytes, data, incr_sender_t {bytes->size(), chunk_size_}, std::chrono::steady_clock::now()}); } + XSelectionEvent selection {}; + selection.type = SelectionNotify; + selection.display = request.display; + selection.requestor = request.requestor; + selection.selection = request.selection; + selection.target = request.target; + selection.property = property; + selection.time = request.time; + + // Copy the reply into the XEvent union instead of writing a member that is not active XEvent notify {}; - notify.xselection.type = SelectionNotify; - notify.xselection.display = request.display; - notify.xselection.requestor = request.requestor; - notify.xselection.selection = request.selection; - notify.xselection.target = request.target; - notify.xselection.property = property; - notify.xselection.time = request.time; + std::memcpy(¬ify, &selection, sizeof(selection)); XSendEvent(display_, request.requestor, False, 0, ¬ify); XFlush(display_); }