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/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index b47c48ac54e..be4c21afbb7 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -249,6 +249,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") @@ -330,7 +336,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 a6afa54fad6..24a50a88402 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -2,8 +2,10 @@ * @file src/input.cpp * @brief Definitions for gamepad, keyboard, and mouse input handling. */ +#include #include extern "C" { +#include #include #include } @@ -24,6 +26,7 @@ extern "C" { #include #include #include +#include // lib includes #include @@ -33,6 +36,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" @@ -305,13 +309,16 @@ 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 ): touch_port_event {std::move(touch_port_event)}, - feedback_queue {std::move(feedback_queue)} { + feedback_queue {std::move(feedback_queue)}, + clipboard_queue {std::move(clipboard_queue)} { } // Keep track of alt+ctrl+shift key combo @@ -326,6 +333,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 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. @@ -436,6 +472,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]; @@ -1360,6 +1397,114 @@ namespace input { platf::unicode(platf_input, packet->text, size); } + /** + * @brief Queue a clipboard request or data message for the client's control stream. + * + * @param input Stream input state. + * @param action Message to send. + * @param sequence Offer sequence the message belongs to. + * @param mime MIME type the message is about. + */ + 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 &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 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; + } + } + /** * @brief Allocate a virtual gamepad or refresh feedback after a retained controller arrives. * @@ -1820,6 +1965,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_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: @@ -2229,6 +2380,11 @@ namespace input { case UTF8_TEXT_EVENT_MAGIC: passthrough(static_cast(static_cast(payload))); break; + 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: passthrough(input, (PNV_MULTI_CONTROLLER_PACKET) payload); break; @@ -2371,6 +2527,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); @@ -2380,6 +2537,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); @@ -2490,7 +2648,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/clipboard_sync.h b/src/platform/clipboard_sync.h new file mode 100644 index 00000000000..858dfb3b2cf --- /dev/null +++ b/src/platform/clipboard_sync.h @@ -0,0 +1,469 @@ +/** + * @file src/platform/clipboard_sync.h + * @brief Clipboard formats, subscriber tracking, and X11 selection transfers shared with tests. + */ +#pragma once + +// standard includes +#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 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 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::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. + }; + + /** + * @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. + */ + 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), nullptr}); + return true; + } + + /** + * @brief Drop a client queue when its session stops. + * + * @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) { + return entry.queue == queue; + }); + } + + /** + * @brief Record a new host clipboard and select the subscribers that need it. + * + * 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 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. + */ + clipboard_delivery_t publish(const std::shared_ptr &data, const clipboard_queue_t &origin = {}) { + clipboard_delivery_t delivery; + for (auto &entry : entries_) { + const bool is_origin = origin && entry.queue == origin; + if (!is_origin && entry.last && *entry.last == *data) { + continue; + } + entry.last = data; + if (!is_origin) { + delivery.queues.push_back(entry.queue); + } + } + if (!delivery.queues.empty()) { + sequence_ = sequence_ == UINT32_MAX ? 1 : sequence_ + 1; + delivery.sequence = sequence_; + } + return delivery; + } + + private: + std::vector entries_; ///< Subscribers registered for the current process. + std::uint32_t sequence_ = 0; ///< Sequence number of the last offer. + }; + + /** + * @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 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. + */ + 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. + * + * 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 = 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. + 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. + bool truncated = false; ///< Whether the property was larger than clipboard_max_bytes. + }; + + /** + * @brief Copy property bytes without passing the protocol cap. + * + * @param out Destination bytes. + * @param chunk Bytes returned by one property read. + * @return False when some bytes did not fit under the cap. + */ + 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 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. + * @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; + } + if (!append_property_bytes(loaded.data, fragment.data)) { + loaded.truncated = true; + } + 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 Selection value received directly or 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 value. + * + * 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 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 The completed value, or empty when the transfer is unfinished or rejected. + */ + std::optional> consume( + std::uint64_t actual_type, + int actual_format, + 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 (data.empty()) { + active_ = false; + auto done = std::exchange(data_, {}); + if (std::exchange(rejected_, false)) { + return std::nullopt; + } + return done; + } + 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; + rejected_ = false; + data_.clear(); + return std::nullopt; + } + + 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 Start sending a value. + * + * @param size Total bytes to send. + * @param chunk_size Largest slice written at once. Must be nonzero. + */ + incr_sender_t(std::size_t size, std::size_t chunk_size): + size_ {size}, + chunk_size_ {chunk_size} { + } + + /** + * @brief Report whether the empty terminating slice has been handed out. + * + * @return True when the transfer is complete. + */ + [[nodiscard]] bool done() const { + return done_; + } + + /** + * @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 7351e64cae1..95c92cb86ad 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -6,11 +6,15 @@ // standard includes #include +#include #include #include +#include #include #include #include +#include +#include // lib includes #include @@ -274,6 +278,49 @@ namespace platf { */ using feedback_queue_t = safe::mail_raw_t::queue_t; + /** + * @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_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 clipboard messages to the control stream. + */ + using clipboard_queue_t = safe::mail_raw_t::queue_t; + namespace speaker { /** * @brief Enumerates supported speaker options. @@ -1239,6 +1286,28 @@ namespace platf { */ void unicode(input_t &input, const char *utf8, int size); + /** + * @brief Replace the host clipboard with a clipboard from the client. + * + * @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(clipboard_data_t data, const clipboard_queue_t &origin); + + /** + * @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 new file mode 100644 index 00000000000..5b34137b75c --- /dev/null +++ b/src/platform/linux/clipboard.cpp @@ -0,0 +1,719 @@ +/** + * @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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef SUNSHINE_BUILD_X11 + +namespace platf { + void clipboard_set(clipboard_data_t, 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 + #include + #include + #include + +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. + */ + struct state_t { + 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 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::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. + }; + + /** + * @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; + } + + /** + * @brief Wake the watcher thread so it handles new state. + * + * @param clipboard Watcher state. The caller holds its lock. + */ + 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; + } + } + } + + /** + * @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 Offer a host clipboard to every subscriber that does not already have it. + * + * @param data Host clipboard. + */ + void publish(const std::shared_ptr &data) { + clipboard_delivery_t delivery; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + delivery = clipboard.subscribers.publish(data); + } + raise_offers(delivery, data); + } + + /** + * @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 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. + * + * 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. + */ + 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; + } + 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. + * @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. + */ + 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) { + 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); + } + return fragment; + } + + /** + * @brief Watches the X11 CLIPBOARD selection, reads host copies, and serves client copies. + * + * Every method runs on the watcher thread. + */ + 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_); + } + + /** + * @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(); + } + } + } + + /** + * @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 Offer the current host clipboard to a new subscriber. + */ + void pull() { + if (fetching_) { + // The read in progress will reach the new subscriber too + return; + } + if (owned_ && XGetSelectionOwner(display_, clipboard_) == window_) { + publish(owned_); + } else { + start_fetch(); + } + } + + /** + * @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; + }); + } + + 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 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; + } + 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()}); + } + + 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 {}; + std::memcpy(¬ify, &selection, sizeof(selection)); + 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. + * + * @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; + } + const int wake_read = pipes[0]; + const int wake_write = pipes[1]; + + Display *display = XOpenDisplay(nullptr); + if (display == nullptr) { + BOOST_LOG(warning) << "Clipboard sync could not open the X display"sv; + fail_watcher(wake_read, wake_write, nullptr); + 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; + fail_watcher(wake_read, wake_write, display); + return; + } + + 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); + watcher_t watcher {display, window, event_base}; + { + auto &clipboard = state(); + std::lock_guard lock {clipboard.mutex}; + clipboard.wake_write = wake_write; + clipboard.ready = true; + clipboard.ready_cv.notify_all(); + } + + const int xfd = ConnectionNumber(display); + while (!stop.stop_requested()) { + while (XPending(display) != 0) { + XEvent event; + XNextEvent(display, &event); + watcher.handle(event); + } + + watcher.take_pending(); + if (consume_pull()) { + watcher.pull(); + } + watcher.prune_sends(); + + 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); + } + } + } + } // namespace + + void clipboard_set(clipboard_data_t data, const clipboard_queue_t &origin) { + if (data.text.empty() && data.image.empty()) { + return; + } + + 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 = shared; + delivery = clipboard.subscribers.publish(shared, origin); + wake_watcher(clipboard); + } + raise_offers(delivery, shared); + } + + 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::jthread(thread_main); + } + clipboard.ready_cv.wait(lock, [&clipboard]() { + return clipboard.ready || clipboard.failed; + }); + if (!clipboard.ready || !clipboard.subscribers.add(std::move(queue))) { + return; + } + clipboard.pull = true; + wake_watcher(clipboard); + } + + 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 dbc19cb27c8..f0daaee0e22 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -17,6 +17,7 @@ // local includes #include "src/config.h" +#include "src/platform/common.h" #include "src/platform/virtualhid_input.h" namespace platf { @@ -67,4 +68,16 @@ namespace platf { return gamepads; } + void clipboard_set(clipboard_data_t, const clipboard_queue_t &) { + // macOS host clipboard sync is not implemented yet. + } + + void clipboard_subscribe(clipboard_queue_t) { + // 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 73f2acf0383..059a25c9470 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -1348,4 +1348,16 @@ namespace platf { return caps; } + + void clipboard_set(clipboard_data_t, 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/src/stream.cpp b/src/stream.cpp index 28afa1c7300..7955f415cd8 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,13 +4,16 @@ */ // standard includes +#include #include #include #include #include #include #include +#include #include +#include // lib includes #include @@ -19,6 +22,8 @@ extern "C" { // clang-format off +#include +#include #include // clang-format on } @@ -30,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" @@ -313,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. */ @@ -331,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. @@ -560,6 +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 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. @@ -574,24 +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. */ - 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)" - ); - - 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 @@ -613,9 +628,29 @@ namespace stream { iv[0] = (std::uint8_t) seq; } - auto packet = (control_encrypted_p) tagged_cipher.data(); + return &iv; + } - auto bytes = session->control.cipher.encrypt(plaintext, packet->payload(), &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(), outgoing_control_iv(session, seq)); if (bytes <= 0) { BOOST_LOG(error) << "Couldn't encrypt control data"sv; return {}; @@ -627,7 +662,49 @@ 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()); + } + + /** + * @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}; } /** @@ -743,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; } @@ -1151,6 +1228,125 @@ namespace stream { return 0; } + /** + * @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 type Clipboard control message type. + * @param body Message body. + * @return 0 when the control message is queued. + */ + int send_clipboard_message(session_t *session, std::uint16_t type, std::span body) { + if (!session->control.peer) { + return -1; + } + + 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); + + 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)); + + 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()); + } + + if (encoded.empty() || session->broadcast_ref->control_server.send(encoded, session->control.peer)) { + return -1; + } + + 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. * @@ -1187,6 +1383,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. * @@ -1282,54 +1543,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 @@ -1392,6 +1630,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(session, *clipboard_msg); + } + auto &hdr_queue = session->control.hdr_queue; while (session->control.peer && hdr_queue->peek()) { auto hdr_info = hdr_queue->pop(); @@ -2404,6 +2648,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/tests/unit/test_clipboard.cpp b/tests/unit/test_clipboard.cpp new file mode 100644 index 00000000000..aa800860930 --- /dev/null +++ b/tests/unit/test_clipboard.cpp @@ -0,0 +1,476 @@ +/** + * @file tests/unit/test_clipboard.cpp + * @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 +#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 clipboard offers. + */ + platf::clipboard_queue_t make_queue(const safe::mail_t &mail, const std::string &id) { + return mail->queue(id); + } + + /** + * @brief Make a shared clipboard holding text. + * + * @param text UTF-8 clipboard text. + * @return Clipboard with only 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 offer from a queue. + * + * @param queue Queue to drain. + * @return Offered 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->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. + */ + 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; + } + }; + + 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) { + 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)); + + 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"})); +} + +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)); + 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(text_clipboard("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); + + 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, DoesNotOfferAClientClipboardBackToItsOrigin) { + 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)); + + 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"})); + + // 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) { + auto mail = std::make_shared(); + auto queue = make_queue(mail, "clipboard-a"); + platf::clipboard_subscribers_t subscribers; + EXPECT_TRUE(subscribers.add(queue)); + const auto kept = text_clipboard("kept"); + deliver(subscribers.publish(kept), kept); + EXPECT_FALSE(subscribers.add(queue)); + + 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); +} + +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, 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) { + platf::incr_transfer_t transfer; + + const auto value = transfer.consume(incr_atom, 32, incr_announcement, false, utf8_atom, incr_atom); + + EXPECT_FALSE(value); + EXPECT_TRUE(transfer.active()); +} + +TEST(ClipboardSelectionTest, PublishesIncrChunksOnlyAfterTheTransferEnds) { + platf::incr_transfer_t transfer; + 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 value = transfer.consume(utf8_atom, 8, {}, false, utf8_atom, incr_atom); + + ASSERT_TRUE(value); + EXPECT_EQ(*value, (std::vector(bytes_of("abcd").begin(), bytes_of("abcd").end()))); + EXPECT_FALSE(transfer.active()); +} + +TEST(ClipboardSelectionTest, IgnoresAPropertyOfAnotherType) { + platf::incr_transfer_t transfer; + + const auto value = transfer.consume(99, 8, bytes_of("nope"), false, utf8_atom, incr_atom); + + EXPECT_FALSE(value); + EXPECT_FALSE(transfer.active()); +} + +TEST(ClipboardSelectionTest, IgnoresAPropertyOverTheProtocolCap) { + platf::incr_transfer_t transfer; + + const auto value = transfer.consume(utf8_atom, 8, bytes_of("cut"), true, utf8_atom, incr_atom); + + EXPECT_FALSE(value); +} + +TEST(ClipboardSelectionTest, DropsAnIncrTransferWithAChunkOfAnotherType) { + 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_TRUE(transfer.active()); + + const auto value = transfer.consume(99, 8, {}, false, utf8_atom, incr_atom); + + EXPECT_FALSE(value); + EXPECT_FALSE(transfer.active()); +} + +TEST(ClipboardSelectionTest, DropsAnIncrTransferWithATruncatedChunk) { + platf::incr_transfer_t transfer; + 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(utf8_atom, 8, {}, false, utf8_atom, incr_atom)); + EXPECT_FALSE(transfer.active()); +} + +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, AcknowledgesAPropertyLargerThanOneRead) { + capped_property_t property; + 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); + }); + + ASSERT_TRUE(loaded); + EXPECT_FALSE(property.exists); + EXPECT_EQ(property.reads, 2); + EXPECT_TRUE(loaded->acknowledged); + EXPECT_FALSE(loaded->truncated); + EXPECT_EQ(loaded->data, property.bytes); +} + +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); +} + +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 0f322a48e53..78c14cd2eee 100644 --- a/tests/unit/test_input.cpp +++ b/tests/unit/test_input.cpp @@ -22,6 +22,7 @@ // lib includes extern "C" { +#include #include } @@ -202,6 +203,40 @@ TEST(InputPacketValidationTest, ValidatesUnicodePacketBoundsWithoutOverflow) { EXPECT_FALSE(input::testing::is_valid_input_packet(truncated_text)); } +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); + 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; + + 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); + 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; + + 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) { constexpr std::array packet_specs { input_packet_spec_t {MOUSE_MOVE_REL_MAGIC_GEN5, sizeof(NV_REL_MOUSE_MOVE_PACKET)}, diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index e80a786afed..4400d6055c0 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit e80a786afedeaccff63e839e68c8f3d8a057c13d +Subproject commit 4400d6055c0e808751dd182617a473faf716f8c9