[feat] support clipboard sharing, refs #35

This commit is contained in:
dijunkun
2025-12-29 00:45:17 +08:00
parent c70ebdfe15
commit 17b7ba6b72
8 changed files with 724 additions and 0 deletions

View File

@@ -209,6 +209,7 @@ int Render::ConnectTo(const std::string& remote_id, const char* password,
AddDataStream(props->peer_, props->data_label_.c_str(), false);
AddDataStream(props->peer_, props->file_label_.c_str(), true);
AddDataStream(props->peer_, props->file_feedback_label_.c_str(), true);
AddDataStream(props->peer_, props->clipboard_label_.c_str(), true);
props->connection_status_ = ConnectionStatus::Connecting;

View File

@@ -9,6 +9,7 @@
#include <thread>
#include "OPPOSans_Regular.h"
#include "clipboard.h"
#include "device_controller_factory.h"
#include "fa_regular_400.h"
#include "fa_solid_900.h"
@@ -717,6 +718,7 @@ int Render::CreateConnectionPeer() {
AddDataStream(peer_, data_label_.c_str(), false);
AddDataStream(peer_, file_label_.c_str(), true);
AddDataStream(peer_, file_feedback_label_.c_str(), true);
AddDataStream(peer_, clipboard_label_.c_str(), true);
return 0;
} else {
return -1;
@@ -1267,6 +1269,26 @@ void Render::InitializeModules() {
keyboard_capturer_ = (KeyboardCapturer*)device_controller_factory_->Create(
DeviceControllerFactory::Device::Keyboard);
CreateConnectionPeer();
// start clipboard monitoring with callback to send data to peers
Clipboard::StartMonitoring(
100, [this](const char* data, size_t size) -> int {
// send clipboard data to all connected peers
std::shared_lock lock(client_properties_mutex_);
int ret = -1;
for (const auto& [remote_id, props] : client_properties_) {
if (props && props->peer_ && props->connection_established_) {
ret = SendReliableDataFrame(props->peer_, data, size,
props->clipboard_label_.c_str());
if (ret != 0) {
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
remote_id.c_str(), ret);
}
}
}
return ret;
});
modules_inited_ = true;
}
}
@@ -1398,6 +1420,8 @@ void Render::HandleStreamWindow() {
}
void Render::Cleanup() {
Clipboard::StopMonitoring();
if (screen_capturer_) {
screen_capturer_->Destroy();
delete screen_capturer_;

View File

@@ -49,6 +49,7 @@ class Render {
std::string data_label_ = "control_data";
std::string file_label_ = "file";
std::string file_feedback_label_ = "file_feedback";
std::string clipboard_label_ = "clipboard";
std::string local_id_ = "";
std::string remote_id_ = "";
bool exit_ = false;
@@ -515,6 +516,7 @@ class Render {
std::string control_data_label_ = "control_data";
std::string file_label_ = "file";
std::string file_feedback_label_ = "file_feedback";
std::string clipboard_label_ = "clipboard";
Params params_;
// Map file_id to props for tracking file transfer progress via ACK
std::unordered_map<uint32_t, std::weak_ptr<SubStreamWindowProperties>>

View File

@@ -6,6 +6,7 @@
#include <fstream>
#include <unordered_map>
#include "clipboard.h"
#include "device_controller.h"
#include "file_transfer.h"
#include "localization.h"
@@ -327,6 +328,14 @@ void Render::OnReceiveDataBufferCb(const char* data, size_t size,
receiver.OnData(data, size);
return;
} else if (source_id == render->clipboard_label_) {
if (size > 0) {
std::string clipboard_text(data, size);
if (!Clipboard::SetText(clipboard_text)) {
LOG_ERROR("Failed to set clipboard content from remote");
}
}
return;
} else if (source_id == render->file_feedback_label_) {
if (size < sizeof(FileTransferAck)) {
LOG_ERROR("FileTransferAck: buffer too small, size={}", size);

534
src/tools/clipboard.cpp Normal file
View File

@@ -0,0 +1,534 @@
#include "clipboard.h"
#include <atomic>
#include <chrono>
#include <mutex>
#include <string>
#include <thread>
#include "rd_log.h"
#ifdef _WIN32
#include <windows.h>
#elif __linux__
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h>
#include <cstring>
#include <memory>
#endif
namespace crossdesk {
std::atomic<bool> g_monitoring{false};
std::thread g_monitor_thread;
std::mutex g_monitor_mutex;
std::string g_last_clipboard_text;
int g_check_interval_ms = 100;
Clipboard::OnClipboardChanged g_on_clipboard_changed;
#ifdef _WIN32
HWND g_clipboard_wnd = nullptr;
const char* g_clipboard_class_name = "CrossDeskClipboardMonitor";
#endif
#ifdef __linux__
Display* g_x11_display = nullptr;
Atom g_clipboard_atom = None;
Atom g_xfixes_selection_notify = None;
#endif
} // namespace crossdesk
namespace crossdesk {
#ifdef _WIN32
std::string Clipboard::GetText() {
if (!OpenClipboard(nullptr)) {
LOG_ERROR("Clipboard::GetText: failed to open clipboard");
return "";
}
std::string result;
HANDLE hData = GetClipboardData(CF_UNICODETEXT);
if (hData != nullptr) {
wchar_t* pszText = static_cast<wchar_t*>(GlobalLock(hData));
if (pszText != nullptr) {
int size_needed = WideCharToMultiByte(CP_UTF8, 0, pszText, -1, nullptr, 0,
nullptr, nullptr);
if (size_needed > 0) {
// -1 because WideCharToMultiByte contains '\0'
result.resize(size_needed - 1);
WideCharToMultiByte(CP_UTF8, 0, pszText, -1, &result[0], size_needed,
nullptr, nullptr);
}
GlobalUnlock(hData);
}
}
CloseClipboard();
return result;
}
bool Clipboard::SetText(const std::string& text) {
if (!OpenClipboard(nullptr)) {
LOG_ERROR("Clipboard::SetText: failed to open clipboard");
return false;
}
if (!EmptyClipboard()) {
LOG_ERROR("Clipboard::SetText: failed to empty clipboard");
CloseClipboard();
return false;
}
// Convert UTF-8 string to wide char
int size_needed =
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0);
if (size_needed <= 0) {
LOG_ERROR("Clipboard::SetText: failed to convert to wide char");
CloseClipboard();
return false;
}
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, size_needed * sizeof(wchar_t));
if (hMem == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to allocate memory");
CloseClipboard();
return false;
}
wchar_t* pszText = static_cast<wchar_t*>(GlobalLock(hMem));
if (pszText == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to lock memory");
GlobalFree(hMem);
CloseClipboard();
return false;
}
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, pszText, size_needed);
GlobalUnlock(hMem);
if (SetClipboardData(CF_UNICODETEXT, hMem) == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to set clipboard data");
GlobalFree(hMem);
CloseClipboard();
return false;
}
CloseClipboard();
return true;
}
bool Clipboard::HasText() {
if (!OpenClipboard(nullptr)) {
return false;
}
bool has_text = IsClipboardFormatAvailable(CF_UNICODETEXT) ||
IsClipboardFormatAvailable(CF_TEXT);
CloseClipboard();
return has_text;
}
#elif __APPLE__
// macOS implementation is in clipboard_mac.mm
#elif __linux__
std::string Clipboard::GetText() {
Display* display = XOpenDisplay(nullptr);
if (display == nullptr) {
LOG_ERROR("Clipboard::GetText: failed to open X display");
return "";
}
std::string result;
Window owner = XGetSelectionOwner(display, XA_PRIMARY);
if (owner == None) {
// Try using CLIPBOARD
owner =
XGetSelectionOwner(display, XInternAtom(display, "CLIPBOARD", False));
if (owner == None) {
XCloseDisplay(display);
return "";
}
}
Atom selection = XA_PRIMARY;
Atom target = XInternAtom(display, "UTF8_STRING", False);
if (target == None) {
target = XA_STRING;
}
XEvent event;
Window window = XCreateSimpleWindow(display, DefaultRootWindow(display), 0, 0,
1, 1, 0, 0, 0);
XSelectInput(display, window, PropertyChangeMask);
XConvertSelection(display, selection, target, XA_PRIMARY, window,
CurrentTime);
// Wait for selection conversion to complete
bool done = false;
while (!done) {
XNextEvent(display, &event);
if (event.type == SelectionNotify) {
if (event.xselection.property == None) {
// Try using CLIPBOARD
if (selection == XA_PRIMARY) {
selection = XInternAtom(display, "CLIPBOARD", False);
XConvertSelection(display, selection, target, XA_PRIMARY, window,
CurrentTime);
continue;
}
break;
}
Atom actual_type;
int actual_format;
unsigned long nitems;
unsigned long bytes_after;
unsigned char* data = nullptr;
if (XGetWindowProperty(display, window, XA_PRIMARY, 0, LONG_MAX / 4,
False, AnyPropertyType, &actual_type,
&actual_format, &nitems, &bytes_after,
&data) == Success) {
if (data != nullptr) {
result = std::string(reinterpret_cast<char*>(data), nitems);
XFree(data);
}
done = true;
}
}
}
XDestroyWindow(display, window);
XCloseDisplay(display);
return result;
}
bool Clipboard::SetText(const std::string& text) {
Display* display = XOpenDisplay(nullptr);
if (display == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to open X display");
return false;
}
Window window = XCreateSimpleWindow(display, DefaultRootWindow(display), 0, 0,
1, 1, 0, 0, 0);
Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
Atom utf8_string = XInternAtom(display, "UTF8_STRING", False);
Atom targets = XInternAtom(display, "TARGETS", False);
Atom xa_string = XA_STRING;
XSetSelectionOwner(display, clipboard, window, CurrentTime);
if (XGetSelectionOwner(display, clipboard) != window) {
LOG_ERROR("Clipboard::SetText: failed to set selection owner");
XDestroyWindow(display, window);
XCloseDisplay(display);
return false;
}
XChangeProperty(display, window, XA_PRIMARY, utf8_string, 8, PropModeReplace,
reinterpret_cast<const unsigned char*>(text.c_str()),
static_cast<int>(text.length()));
XEvent event;
while (true) {
XNextEvent(display, &event);
if (event.type == SelectionRequest) {
XSelectionRequestEvent* req = &event.xselectionrequest;
XSelectionEvent se;
se.type = SelectionNotify;
se.display = req->display;
se.requestor = req->requestor;
se.selection = req->selection;
se.time = req->time;
se.target = req->target;
se.property = req->property;
if (req->target == targets) {
// Return supported formats
Atom supported[] = {utf8_string, xa_string, targets};
XChangeProperty(display, req->requestor, req->property, XA_ATOM, 32,
PropModeReplace,
reinterpret_cast<unsigned char*>(supported), 3);
se.property = req->property;
} else if (req->target == utf8_string || req->target == xa_string) {
// Return text data
XChangeProperty(display, req->requestor, req->property, req->target, 8,
PropModeReplace,
reinterpret_cast<const unsigned char*>(text.c_str()),
static_cast<int>(text.length()));
se.property = req->property;
} else {
se.property = None;
}
XSendEvent(display, req->requestor, False, 0,
reinterpret_cast<XEvent*>(&se));
XSync(display, False);
} else if (event.type == SelectionClear) {
break;
}
}
XDestroyWindow(display, window);
XCloseDisplay(display);
return true;
}
bool Clipboard::HasText() {
Display* display = XOpenDisplay(nullptr);
if (display == nullptr) {
return false;
}
Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
Window owner = XGetSelectionOwner(display, clipboard);
if (owner == None) {
owner = XGetSelectionOwner(display, XA_PRIMARY);
}
XCloseDisplay(display);
return owner != None;
}
#else
std::string Clipboard::GetText() {
LOG_ERROR("Clipboard::GetText: unsupported platform");
return "";
}
bool Clipboard::SetText(const std::string& text) {
LOG_ERROR("Clipboard::SetText: unsupported platform");
return false;
}
bool Clipboard::HasText() {
LOG_ERROR("Clipboard::HasText: unsupported platform");
return false;
}
#endif
void HandleClipboardChange() {
if (!Clipboard::HasText()) {
std::lock_guard<std::mutex> lock(g_monitor_mutex);
if (!g_last_clipboard_text.empty()) {
g_last_clipboard_text.clear();
LOG_INFO("Clipboard content cleared");
}
return;
}
std::string current_text = Clipboard::GetText();
// Check if the content has changed
{
std::lock_guard<std::mutex> lock(g_monitor_mutex);
if (current_text != g_last_clipboard_text) {
g_last_clipboard_text = current_text;
if (!current_text.empty()) {
if (g_on_clipboard_changed) {
int ret = g_on_clipboard_changed(current_text.c_str(),
current_text.length());
if (ret != 0) {
LOG_WARN("Clipboard callback returned error: {}", ret);
}
}
}
}
}
}
#ifdef _WIN32
LRESULT CALLBACK ClipboardWndProc(HWND hwnd, UINT uMsg, WPARAM wParam,
LPARAM lParam) {
if (uMsg == WM_CLIPBOARDUPDATE) {
HandleClipboardChange();
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
static void MonitorThreadFunc() {
// Create a hidden window to receive clipboard messages
WNDCLASSA wc = {0};
wc.lpfnWndProc = ClipboardWndProc;
wc.hInstance = GetModuleHandle(nullptr);
wc.lpszClassName = g_clipboard_class_name;
RegisterClassA(&wc);
g_clipboard_wnd = CreateWindowA(g_clipboard_class_name, nullptr, 0, 0, 0, 0,
0, HWND_MESSAGE, nullptr, nullptr, nullptr);
if (!g_clipboard_wnd) {
LOG_ERROR("Failed to create clipboard monitor window");
g_monitoring.store(false);
return;
}
// Register clipboard format listener
if (!AddClipboardFormatListener(g_clipboard_wnd)) {
LOG_ERROR("Failed to add clipboard format listener");
DestroyWindow(g_clipboard_wnd);
g_clipboard_wnd = nullptr;
g_monitoring.store(false);
return;
}
LOG_INFO("Clipboard event monitoring started (Windows)");
MSG msg;
while (g_monitoring.load()) {
BOOL ret = GetMessage(&msg, nullptr, 0, 0);
if (ret == 0 || ret == -1) {
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
RemoveClipboardFormatListener(g_clipboard_wnd);
if (g_clipboard_wnd) {
DestroyWindow(g_clipboard_wnd);
g_clipboard_wnd = nullptr;
}
UnregisterClassA(g_clipboard_class_name, GetModuleHandle(nullptr));
}
#elif __APPLE__
// macOS use notification mechanism, need external functions
extern void StartMacOSClipboardMonitoring();
extern void StopMacOSClipboardMonitoring();
static void MonitorThreadFunc() { StartMacOSClipboardMonitoring(); }
#elif __linux__
static void MonitorThreadFunc() {
g_x11_display = XOpenDisplay(nullptr);
if (!g_x11_display) {
LOG_ERROR("Failed to open X display for clipboard monitoring");
g_monitoring.store(false);
return;
}
// Check if XFixes extension is available
int event_base, error_base;
if (!XFixesQueryExtension(g_x11_display, &event_base, &error_base)) {
LOG_WARN("XFixes extension not available, falling back to polling");
XCloseDisplay(g_x11_display);
g_x11_display = nullptr;
// fallback to polling mode
while (g_monitoring.load()) {
std::this_thread::sleep_for(
std::chrono::milliseconds(g_check_interval_ms));
if (!g_monitoring.load()) {
break;
}
HandleClipboardChange();
}
return;
}
g_clipboard_atom = XInternAtom(g_x11_display, "CLIPBOARD", False);
g_xfixes_selection_notify =
XInternAtom(g_x11_display, "XFIXES_SELECTION_NOTIFY", False);
// Create event window
Window root = DefaultRootWindow(g_x11_display);
Window event_window =
XCreateSimpleWindow(g_x11_display, root, 0, 0, 1, 1, 0, 0, 0);
// Select events to monitor
XFixesSelectSelectionInput(g_x11_display, event_window, g_clipboard_atom,
XFixesSetSelectionOwnerNotifyMask |
XFixesSelectionWindowDestroyNotifyMask |
XFixesSelectionClientCloseNotifyMask);
LOG_INFO("Clipboard event monitoring started (Linux XFixes)");
XEvent event;
while (g_monitoring.load()) {
XNextEvent(g_x11_display, &event);
if (event.type == event_base + XFixesSelectionNotify) {
HandleClipboardChange();
}
}
XFixesSelectSelectionInput(g_x11_display, event_window, g_clipboard_atom, 0);
XDestroyWindow(g_x11_display, event_window);
XCloseDisplay(g_x11_display);
g_x11_display = nullptr;
}
#else
// Fallback to polling mode for other platforms
static void MonitorThreadFunc() {
while (g_monitoring.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(g_check_interval_ms));
if (!g_monitoring.load()) {
break;
}
HandleClipboardChange();
}
}
#endif
void Clipboard::StartMonitoring(int check_interval_ms,
OnClipboardChanged on_changed) {
if (g_monitoring.load()) {
LOG_WARN("Clipboard monitoring is already running");
return;
}
g_check_interval_ms = check_interval_ms > 0 ? check_interval_ms : 100;
{
std::lock_guard<std::mutex> lock(g_monitor_mutex);
g_on_clipboard_changed = on_changed;
if (HasText()) {
g_last_clipboard_text = GetText();
} else {
g_last_clipboard_text.clear();
}
}
g_monitoring.store(true);
g_monitor_thread = std::thread(MonitorThreadFunc);
LOG_INFO("Clipboard event monitoring started");
}
void Clipboard::StopMonitoring() {
if (!g_monitoring.load()) {
return;
}
g_monitoring.store(false);
#ifdef _WIN32
if (g_clipboard_wnd) {
PostMessage(g_clipboard_wnd, WM_QUIT, 0, 0);
}
#elif __APPLE__
StopMacOSClipboardMonitoring();
#endif
if (g_monitor_thread.joinable()) {
g_monitor_thread.join();
}
{
std::lock_guard<std::mutex> lock(g_monitor_mutex);
g_last_clipboard_text.clear();
g_on_clipboard_changed = nullptr;
}
LOG_INFO("Clipboard monitoring stopped");
}
bool Clipboard::IsMonitoring() { return g_monitoring.load(); }
} // namespace crossdesk

38
src/tools/clipboard.h Normal file
View File

@@ -0,0 +1,38 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-12-28
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CLIPBOARD_H_
#define _CLIPBOARD_H_
#include <functional>
#include <string>
namespace crossdesk {
class Clipboard {
public:
using OnClipboardChanged = std::function<int(const char* data, size_t size)>;
Clipboard() = default;
~Clipboard() = default;
static std::string GetText();
static bool SetText(const std::string& text);
static bool HasText();
static void StartMonitoring(int check_interval_ms = 100,
OnClipboardChanged on_changed = nullptr);
static void StopMonitoring();
static bool IsMonitoring();
};
} // namespace crossdesk
#endif

113
src/tools/clipboard_mac.mm Normal file
View File

@@ -0,0 +1,113 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-12-18
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#include "clipboard.h"
#include <AppKit/AppKit.h>
#include <CoreFoundation/CoreFoundation.h>
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include "rd_log.h"
namespace crossdesk {
extern std::atomic<bool> g_monitoring;
extern std::mutex g_monitor_mutex;
extern std::string g_last_clipboard_text;
extern Clipboard::OnClipboardChanged g_on_clipboard_changed;
static CFRunLoopRef g_monitor_runloop = nullptr;
std::string Clipboard::GetText() {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
NSString* string = [pasteboard stringForType:NSPasteboardTypeString];
if (string == nil) {
return "";
}
return std::string([string UTF8String]);
}
}
bool Clipboard::SetText(const std::string& text) {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
[pasteboard clearContents];
NSString* string = [NSString stringWithUTF8String:text.c_str()];
if (string == nil) {
LOG_ERROR("Clipboard::SetText: failed to create NSString");
return false;
}
BOOL success = [pasteboard setString:string forType:NSPasteboardTypeString];
return success == YES;
}
}
bool Clipboard::HasText() {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
NSArray* types = [pasteboard types];
return [types containsObject:NSPasteboardTypeString];
}
}
extern void HandleClipboardChange();
void StartMacOSClipboardMonitoring() {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
// Store RunLoop reference for waking up
NSRunLoop* runLoop = [NSRunLoop currentRunLoop];
g_monitor_runloop = [runLoop getCFRunLoop];
if (g_monitor_runloop) {
CFRetain(g_monitor_runloop);
}
// Register for clipboard change notifications
id observer =
[[NSNotificationCenter defaultCenter] addObserverForName:NSPasteboardDidChangeNotification
object:pasteboard
queue:nil
usingBlock:^(NSNotification* notification) {
if (!g_monitoring.load()) {
return;
}
HandleClipboardChange();
}];
LOG_INFO("Clipboard event monitoring started (macOS)");
while (g_monitoring.load()) {
@autoreleasepool {
NSDate* date = [NSDate dateWithTimeIntervalSinceNow:0.1];
[runLoop runMode:NSDefaultRunLoopMode beforeDate:date];
}
}
// Cleanup
[[NSNotificationCenter defaultCenter] removeObserver:observer
name:NSPasteboardDidChangeNotification
object:pasteboard];
if (g_monitor_runloop) {
CFRelease(g_monitor_runloop);
g_monitor_runloop = nullptr;
}
}
}
void StopMacOSClipboardMonitoring() {
// Wake up the RunLoop immediately so it can check g_monitoring and exit
// This ensures the RunLoop exits promptly instead of waiting up to 0.1 seconds
if (g_monitor_runloop) {
CFRunLoopWakeUp(g_monitor_runloop);
}
}
} // namespace crossdesk

View File

@@ -173,6 +173,9 @@ target("tools")
set_kind("object")
add_deps("rd_log")
add_files("src/tools/*.cpp")
if is_os("macosx") then
add_files("src/tools/*.mm")
end
add_includedirs("src/tools", {public = true})
target("gui")