[feat] add web clipboard synchronization, refs #132

This commit is contained in:
dijunkun
2026-10-29 23:17:06 +08:00
parent 38a01b085a
commit 0f0dde8b49
7 changed files with 285 additions and 8 deletions
@@ -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<PeerEventHandler> events;
web_clipboard::Assembler assembler;
std::atomic<bool> 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<std::mutex> lock(pending_mutex_);
pending_remote_text_.reset();
pending_complete_ = {};
pending_allowed_ = {};
}
bool ClipboardController::WebSessionActive(
const std::string& remote_id, const std::shared_ptr<WebSession>& 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<WebSession> 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<WebSession>();
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<WebSession> 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<WebSession> 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<void(bool)> complete,
std::function<bool()> 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<void(bool)> superseded;
{
std::lock_guard<std::mutex> 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<gui_detail::RemoteSession> source_session;
uint64_t generation = 0;
std::function<void(bool)> complete;
std::function<bool()> allowed;
{
std::lock_guard<std::mutex> 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;
}
@@ -10,10 +10,12 @@
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
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<void(bool)> complete = {},
std::function<bool()> 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<WebSession>& 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<gui_detail::RemoteSession> pending_session_;
uint64_t pending_generation_ = 0;
std::function<void(bool)> pending_complete_;
std::function<bool()> 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<gui_detail::RemoteSession> deferred_local_session_;
std::mutex web_sessions_mutex_;
std::unordered_map<std::string, std::shared_ptr<WebSession>> web_sessions_;
};
} // namespace crossdesk
@@ -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;
@@ -14,6 +14,7 @@
#include <vector>
#include <display_stream_id.h>
#include <web_clipboard_protocol.h>
#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;
@@ -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));
+124
View File
@@ -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 <algorithm>
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
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<const unsigned char*>(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<const unsigned char*>(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 <typename Send>
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