From 0f0dde8b4988131bd6981010dd1926bebb09c15f Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 29 Oct 2026 23:04:28 +0800 Subject: [PATCH] [feat] add web clipboard synchronization, refs #132 --- .../clipboard/clipboard_controller.cpp | 136 +++++++++++++++++- .../features/clipboard/clipboard_controller.h | 18 ++- .../src/gui/runtime/connection_runtime.cpp | 1 + apps/desktop/src/gui/runtime/gui_runtime.cpp | 3 + .../src/gui/runtime/peer_data_callbacks.cpp | 9 +- deps/submodules/minirtc | 2 +- libs/wire/include/web_clipboard_protocol.h | 124 ++++++++++++++++ 7 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 libs/wire/include/web_clipboard_protocol.h diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp index 5fcb1cec..c1495b18 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp @@ -10,6 +10,7 @@ #include "rd_log.h" #include "runtime/gui_runtime.h" +#include "web_clipboard_protocol.h" namespace crossdesk { namespace { @@ -20,6 +21,12 @@ constexpr size_t kMaxClipboardTextBytes = 128 * 1024; } // namespace +struct ClipboardController::WebSession { + std::weak_ptr events; + web_clipboard::Assembler assembler; + std::atomic ready{false}; +}; + ClipboardController::ClipboardController(GuiRuntime &owner) : owner_(owner) {} void ClipboardController::SetEventType(uint32_t event_type) { @@ -44,12 +51,115 @@ void ClipboardController::Initialize() { void ClipboardController::Shutdown() { events_enabled_.store(false, std::memory_order_release); deferred_local_session_.reset(); + { + std::lock_guard lock(web_sessions_mutex_); + web_sessions_.clear(); + } std::lock_guard lock(pending_mutex_); pending_remote_text_.reset(); + pending_complete_ = {}; + pending_allowed_ = {}; +} + +bool ClipboardController::WebSessionActive( + const std::string& remote_id, const std::shared_ptr& session) { + if (!session || !events_enabled_.load(std::memory_order_acquire) || + !owner_.IsAuthorizedController(remote_id)) return false; + const auto events = session->events.lock(); + if (!events || events != owner_.ControllerEvents(remote_id) || + !events->DataAllowed()) return false; + std::lock_guard lock(web_sessions_mutex_); + const auto found = web_sessions_.find(remote_id); + return found != web_sessions_.end() && found->second == session; +} + +void ClipboardController::ForgetWeb(const std::string& remote_id) { + std::lock_guard lock(web_sessions_mutex_); + web_sessions_.erase(remote_id); +} + +void ClipboardController::ReceiveWeb(const char* data, size_t size, + const std::string& remote_id, + PeerEventHandler* source) { + namespace clip = web_clipboard; + if (!events_enabled_.load(std::memory_order_acquire) || owner_.unattended_host_ || + !owner_.IsAuthorizedController(remote_id) || !data || + size < clip::kHeaderBytes || size > clip::kHeaderBytes + clip::kChunkBytes) + return; + const auto events = owner_.ControllerEvents(remote_id); + if (!events || events.get() != source || !events->DataAllowed()) return; + const auto kind = clip::Read32(data), request = clip::Read32(data + 4); + const bool hello = kind == clip::kHello && !request && size == clip::kHeaderBytes && + clip::Read32(data + 8) == clip::kVersion && !clip::Read32(data + 12); + std::shared_ptr session; + std::string message; + { + std::lock_guard lock(web_sessions_mutex_); + if (!events_enabled_.load(std::memory_order_relaxed)) return; + auto found = web_sessions_.find(remote_id); + if (hello) { + // A new application handshake also invalidates unfinished writes from + // an earlier channel on the same controller connection. + session = std::make_shared(); + session->events = events; + web_sessions_[remote_id] = session; + } else { + if (found == web_sessions_.end() || found->second->events.lock() != events) return; + session = found->second; + } + if (!hello) { + if (!session->ready.load(std::memory_order_acquire) || + kind != clip::kText || !request) return; + message = session->assembler.Receive(std::string_view(data, size)); + } + } + if (hello) { + if (!WebSessionActive(remote_id, session)) return; + const auto ready = clip::Frame(clip::kReady, 0, clip::kVersion); + if (owner_.SendControllerData(remote_id, ready.data(), ready.size(), + clip::kStream) == 0) + session->ready.store(true, std::memory_order_release); + return; + } + if (message.empty()) return; + const std::weak_ptr weak = session; + auto allowed = [this, remote_id, weak] { + return WebSessionActive(remote_id, weak.lock()); + }; + auto complete = [this, remote_id, request, allowed](bool applied) { + if (!allowed()) return; + const auto ack = clip::Frame(applied ? clip::kApplied : clip::kRejected, request); + owner_.SendControllerData(remote_id, ack.data(), ack.size(), clip::kStream); + }; + QueueRemoteText(message.data() + clip::kHeaderBytes, + message.size() - clip::kHeaderBytes, {}, + std::move(complete), std::move(allowed)); +} + +int ClipboardController::SendToController(const std::string& remote_id, + const std::string& text) { + std::shared_ptr session; + { + std::lock_guard lock(web_sessions_mutex_); + const auto found = web_sessions_.find(remote_id); + if (found != web_sessions_.end()) session = found->second; + } + if (!session) return owner_.SendControllerData( + remote_id, text.data(), text.size(), owner_.clipboard_label_.c_str()); + if (!session->ready.load(std::memory_order_acquire)) return 0; + // Framing belongs to CrossDesk. Keep each complete packet within 16 KiB. + return web_clipboard::SendText(text, 0, 16 * 1024, + [this, &remote_id, &session](const std::string& frame) { + return WebSessionActive(remote_id, session) && + owner_.SendControllerData(remote_id, frame.data(), frame.size(), + web_clipboard::kStream) == 0; + }) ? 0 : -1; } void ClipboardController::QueueRemoteText(const char* data, size_t size, - const std::string& remote_id) { + const std::string& remote_id, + std::function complete, + std::function allowed) { if (!events_enabled_.load(std::memory_order_acquire)) { return; } @@ -70,6 +180,7 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size, if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load())) return; const auto generation = session ? session->input_generation_.load() : 0; + std::function superseded; { std::lock_guard lock(pending_mutex_); if (!events_enabled_.load(std::memory_order_relaxed)) { @@ -81,7 +192,11 @@ void ClipboardController::QueueRemoteText(const char* data, size_t size, pending_remote_id_ = remote_id; pending_session_ = session; pending_generation_ = generation; + superseded = std::move(pending_complete_); + pending_complete_ = std::move(complete); + pending_allowed_ = std::move(allowed); } + if (superseded) superseded(false); SDL_Event event{}; event.type = event_type_; @@ -101,27 +216,38 @@ void ClipboardController::ApplyPendingRemoteText() { std::string source_id; std::weak_ptr source_session; uint64_t generation = 0; + std::function complete; + std::function allowed; { std::lock_guard lock(pending_mutex_); pending_text.swap(pending_remote_text_); source_id.swap(pending_remote_id_); source_session.swap(pending_session_); generation = pending_generation_; + complete.swap(pending_complete_); + allowed.swap(pending_allowed_); } if (!source_id.empty()) { auto props = owner_.FindRemoteSession(source_id); if (!props || props != source_session.lock() || !props->clipboard_allowed_.load() || - props->input_generation_.load() != generation) + props->input_generation_.load() != generation) { + if (complete) complete(false); return; + } } - if (!pending_text || *pending_text == last_text_) { + if (!pending_text || (allowed && !allowed())) { + if (complete) complete(false); return; } // SDL clipboard functions must run on the thread that initialized SDL. + // Browser writes must also refresh an unchanged baseline: the local OS + // clipboard may have changed since the last poll. + if (!complete && *pending_text == last_text_) return; if (!SDL_SetClipboardText(pending_text->c_str())) { LOG_ERROR("Failed to set remote clipboard text: {}", SDL_GetError()); + if (complete) complete(false); return; } @@ -129,6 +255,7 @@ void ClipboardController::ApplyPendingRemoteText() { // the baseline first so that event cannot echo the text back to the sender. last_text_ = std::move(*pending_text); deferred_local_session_.reset(); + if (complete) complete(true); } void ClipboardController::HandleLocalUpdate() { @@ -228,8 +355,7 @@ int ClipboardController::SendToPeers(const std::string &text) { lock.unlock(); for (const auto& id : owner_.ConnectedControllers()) { - const int ret = owner_.SendControllerData(id, text.data(), text.size(), - owner_.clipboard_label_.c_str()); + const int ret = SendToController(id, text); if (ret != 0) return ret; } diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.h b/apps/desktop/src/gui/features/clipboard/clipboard_controller.h index f7206625..39f93960 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.h +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.h @@ -10,10 +10,12 @@ #include #include #include +#include #include #include #include #include +#include namespace crossdesk::gui_detail { struct RemoteSession; @@ -22,6 +24,7 @@ struct RemoteSession; namespace crossdesk { class GuiRuntime; +class PeerEventHandler; // Owns clipboard synchronization state and keeps feedback-loop prevention out // of the main UI coordinator. @@ -35,11 +38,20 @@ public: void Initialize(); void Shutdown(); void QueueRemoteText(const char* data, size_t size, - const std::string& remote_id = {}); + const std::string& remote_id = {}, + std::function complete = {}, + std::function allowed = {}); void ApplyPendingRemoteText(); void HandleLocalUpdate(); + void ReceiveWeb(const char* data, size_t size, const std::string& remote_id, + PeerEventHandler* source); + void ForgetWeb(const std::string& remote_id); private: + struct WebSession; + bool WebSessionActive(const std::string& remote_id, + const std::shared_ptr& session); + int SendToController(const std::string& remote_id, const std::string& text); int SendToPeers(const std::string &text); void SendDeferredLocalText(); @@ -51,10 +63,14 @@ private: std::string pending_remote_id_; std::weak_ptr pending_session_; uint64_t pending_generation_ = 0; + std::function pending_complete_; + std::function pending_allowed_; std::string last_text_; // UI thread only. Retain the destination of a local copy while its stream // window is unfocused; last_text_ holds the latest value to send on return. std::weak_ptr deferred_local_session_; + std::mutex web_sessions_mutex_; + std::unordered_map> web_sessions_; }; } // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index 1e5c355e..99e3c5ca 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -220,6 +220,7 @@ void GuiRuntime::HandleServerControllerDisconnected( std::memory_order_relaxed); #endif } + clipboard_.ForgetWeb(remote_id); if (has_connected_controller) { remote_client_id_ = remaining_controller_id; return; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index ba944449..e83e043c 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -14,6 +14,7 @@ #include #include +#include #include "localization.h" #include "platform/video_renderer.h" #include "rd_log.h" @@ -308,6 +309,8 @@ int GuiRuntime::CreateConnectionPeer() { AddDataStream(peer_, file_label_.c_str(), true); AddDataStream(peer_, file_feedback_label_.c_str(), true); AddDataStream(peer_, clipboard_label_.c_str(), true); + if (!unattended_host_) + AddDataStream(peer_, web_clipboard::kStream, true); return 0; } else { return -1; diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 35d8ba2f..0a43efc9 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -25,6 +25,7 @@ #include "platform.h" #include "rd_log.h" #include "runtime/gui_runtime.h" +#include "web_clipboard_protocol.h" #ifdef __OHOS__ #include "platform/harmony/input/system_shortcuts.h" #endif @@ -137,8 +138,14 @@ void PeerEventHandler::OnReceiveDataBuffer( if (runtime->unattended_host_ && (source_id == runtime->file_label_ || source_id == runtime->file_feedback_label_ || - source_id == runtime->clipboard_label_)) + source_id == runtime->clipboard_label_ || + source_id == web_clipboard::kStream)) return; + if (source_id == web_clipboard::kStream) { + runtime->clipboard_.ReceiveWeb( + data, size, handler->ConnectionKey(user_id, user_id_size), handler); + return; + } if (source_id == runtime->file_label_) { runtime->transfers_.Receive(data, size, handler, handler->ConnectionKey(user_id, user_id_size)); diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index 6cf03b95..cdcd0d8f 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit 6cf03b9565c4360c790d0cd956d69b679c46b029 +Subproject commit cdcd0d8fb0e9b688f3bf6416fe52b634cf20d9ca diff --git a/libs/wire/include/web_clipboard_protocol.h b/libs/wire/include/web_clipboard_protocol.h new file mode 100644 index 00000000..98b721ae --- /dev/null +++ b/libs/wire/include/web_clipboard_protocol.h @@ -0,0 +1,124 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-29 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _WEB_CLIPBOARD_PROTOCOL_H_ +#define _WEB_CLIPBOARD_PROTOCOL_H_ + +#include +#include +#include +#include +#include + +namespace crossdesk::web_clipboard { + +// CrossDesk application protocol, carried as opaque data by the RTC transport. +inline constexpr uint32_t kVersion = 1; +inline constexpr char kStream[] = "web-clipboard"; +inline constexpr size_t kMaxTextBytes = 128 * 1024; +inline constexpr size_t kHeaderBytes = 16; +inline constexpr size_t kChunkBytes = 16 * 1024; +inline constexpr uint32_t kText = 1, kApplied = 2, kRejected = 3; +inline constexpr uint32_t kHello = 4, kReady = 5; + +inline uint32_t Read32(const char* data) { + const auto* p = reinterpret_cast(data); + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | + (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} + +inline void Write32(char* data, uint32_t value) { + for (int i = 0; i < 4; ++i) data[i] = char(value >> (8 * i)); +} + +inline bool ValidText(std::string_view text) { + if (text.empty() || text.size() > kMaxTextBytes) return false; + const auto* bytes = reinterpret_cast(text.data()); + for (size_t i = 0; i < text.size();) { + const uint32_t first = bytes[i++]; + if (!first) return false; + if (first < 0x80) continue; + const int extra = first >= 0xc2 && first <= 0xdf ? 1 + : first >= 0xe0 && first <= 0xef ? 2 + : first >= 0xf0 && first <= 0xf4 ? 3 : -1; + if (extra < 0 || text.size() - i < size_t(extra)) return false; + uint32_t code = first & (0x7f >> (extra + 1)); + for (int j = 0; j < extra; ++j) { + const auto next = bytes[i++]; + if ((next & 0xc0) != 0x80) return false; + code = (code << 6) | (next & 0x3f); + } + if (code < (extra == 1 ? 0x80u : extra == 2 ? 0x800u : 0x10000u) || + code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return false; + } + return true; +} + +// Four little-endian uint32 fields: kind, request ID, total bytes, offset. +// ID zero is a host clipboard update. Browser writes use nonzero IDs; only +// the application may acknowledge them after writing the OS clipboard. +// HELLO/READY carry kVersion in the total field, with zero ID/offset and no payload. +inline std::string Frame(uint32_t kind, uint32_t id, uint32_t total = 0, + uint32_t offset = 0, std::string_view bytes = {}) { + std::string frame(kHeaderBytes, '\0'); + Write32(frame.data(), kind); + Write32(frame.data() + 4, id); + Write32(frame.data() + 8, total); + Write32(frame.data() + 12, offset); + frame.append(bytes); + return frame; +} + +template +bool SendText(std::string_view text, uint32_t id, size_t message_limit, + Send send) { + if (!ValidText(text) || + message_limit <= kHeaderBytes) return false; + const size_t chunk = std::min(kChunkBytes, message_limit - kHeaderBytes); + for (size_t offset = 0; offset < text.size(); offset += chunk) { + if (!send(Frame(kText, id, uint32_t(text.size()), uint32_t(offset), + text.substr(offset, chunk)))) return false; + } + return true; +} + +// One bounded in-flight value per ordered reliable channel. A new offset-zero +// frame supersedes an incomplete value, allowing recovery after send failure. +class Assembler { + public: + std::string Receive(std::string_view frame) { + if (frame.size() < kHeaderBytes || + frame.size() > kHeaderBytes + kChunkBytes) return Reset(); + const auto kind = Read32(frame.data()), id = Read32(frame.data() + 4); + const auto total = Read32(frame.data() + 8); + const auto offset = Read32(frame.data() + 12); + const auto bytes = frame.substr(kHeaderBytes); + if (kind != kText || !total || total > kMaxTextBytes || bytes.empty() || + offset > total || bytes.size() > total - offset || + bytes.find('\0') != std::string_view::npos) return Reset(); + if (offset == 0) { + text_.clear(); + id_ = id; + total_ = total; + } + if (id != id_ || total != total_ || offset != text_.size()) return Reset(); + text_.append(bytes); + if (text_.size() != total_) return {}; + if (!ValidText(text_)) return Reset(); + auto result = Frame(kText, id_, total_, 0, text_); + Reset(); + return result; + } + + private: + std::string Reset() { text_.clear(); total_ = id_ = 0; return {}; } + std::string text_; + uint32_t id_ = 0, total_ = 0; +}; + +} // namespace crossdesk::web_clipboard + +#endif