Feature: Web remote desktop bidirectional text clipboard sync

Add keyboard-shortcut (Ctrl+C / Ctrl+X / Ctrl+V) text clipboard transfer
between the browser and the remote desktop, reusing the clipboard protocol
that already exists in the native viewer (COMMAND_SCREEN_GET_CLIPBOARD=24,
COMMAND_SCREEN_SET_CLIPBOARD=25, TOKEN_CLIPBOARD_TEXT=118) so the client
needs no change.

Local -> remote: pressing Ctrl+V reads the browser clipboard, pushes it to
the remote clipboard (UTF-8 -> GBK, matching the client's CF_TEXT/ANSI
handling) and then synthesizes a remote Ctrl+V so the pasted text is the
newly delivered one.

Remote -> local: Ctrl+C or Ctrl+X is forwarded to the remote as usual and,
after a short delay, the remote clipboard is pulled back and written to the
browser clipboard for a later local Ctrl+V.

Both the MFC and Go servers get the same three clipboard signaling paths
(clipboard_set, clipboard_get, clipboard_text) so the shared single-page
frontend behaves identically on either backend.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxianganddeepseek-v4-pro committed 2026-10-17 19:52:38 +02:00
1 parent e6b4fe3a85
commit 0c04f7516b
10 files changed
+278

No files matched your search

+4
View File
@@ -1126,6 +1126,10 @@ VOID CScreenSpyDlg::OnReceiveComplete()
case TOKEN_CLIPBOARD_TEXT: {
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
UpdateServerClipboard(str.c_str(), str.length());
// 反向回传:远程剪贴板文本(UTF-8)→ Web 客户端
if (m_bIsWebSession && WebService().IsRunning()) {
WebService().NotifyClipboardText(m_ClientID, std::string(str.c_str(), str.length()));
}
break;
}
case TOKEN_BITMAPINFO: {
+111
View File
@@ -393,6 +393,10 @@ void CWebService::ServerThread(int port) {
HandleMouse(ws_ptr, msg);
} else if (cmd == "key") {
HandleKey(ws_ptr, msg);
} else if (cmd == "clipboard_set") {
HandleClipboardSet(ws_ptr, msg);
} else if (cmd == "clipboard_get") {
HandleClipboardGet(ws_ptr, token);
} else if (cmd == "rdp_reset") {
HandleRdpReset(ws_ptr, token);
} else if (cmd == "audio_toggle") {
@@ -929,6 +933,93 @@ void CWebService::HandleKey(void* ws_ptr, const std::string& msg) {
ctx->Send2Client(szData, length);
}
void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) {
// Parse JSON
Json::Value root;
Json::Reader reader;
if (!reader.parse(msg, root)) {
return;
}
std::string token = root.get("token", "").asString();
std::string text = root.get("text", "").asString();
if (text.empty()) return;
// Validate token
std::string username, role;
if (!ValidateToken(token, username, role)) {
return;
}
// Get device being watched
uint64_t device_id = 0;
{
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end()) {
device_id = it->second.watch_device_id;
it->second.last_activity = (uint64_t)time(nullptr);
}
}
if (device_id == 0) return;
// Get screen context
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
if (!ctx) {
return;
}
// UTF-8 → UTF-16 → GBK(936)。客户端 UpdateClientClipboard 以 CF_TEXT(ANSI/GBK) 落剪贴板,
// 故显式用 936 而非 CP_ACP,避免服务端 locale 非中文时出错。
int wlen = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0);
if (wlen <= 0) return;
std::wstring wide(wlen, L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), &wide[0], wlen);
int glen = WideCharToMultiByte(936, 0, wide.c_str(), wlen, nullptr, 0, nullptr, nullptr);
if (glen <= 0) return;
// 命令字节 + GBK 文本
std::vector<BYTE> szData(1 + glen);
szData[0] = COMMAND_SCREEN_SET_CLIPBOARD;
WideCharToMultiByte(936, 0, wide.c_str(), wlen, (char*)szData.data() + 1, glen, nullptr, nullptr);
ctx->Send2Client(szData.data(), (ULONG)szData.size());
}
void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token) {
// Validate token
std::string username, role;
if (!ValidateToken(token, username, role)) {
return;
}
// Get device being watched
uint64_t device_id = 0;
{
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end()) {
device_id = it->second.watch_device_id;
it->second.last_activity = (uint64_t)time(nullptr);
}
}
if (device_id == 0) return;
// Get screen context
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
if (!ctx) {
return;
}
// 单字节命令,客户端以 SendClientClipboard → TOKEN_CLIPBOARD_TEXT 回包
BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD;
ctx->Send2Client(&bToken, sizeof(BYTE));
}
void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
std::string username, role;
if (!ValidateToken(token, username, role)) {
@@ -1711,6 +1802,26 @@ void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
}
}
void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& text) {
if (m_bStopping) return;
Json::Value res;
res["cmd"] = "clipboard_text";
res["id"] = device_id;
res["text"] = text;
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
std::string json = Json::writeString(builder, res);
std::lock_guard<std::mutex> lock(m_ClientsMutex);
for (auto& [ws_ptr, client] : m_Clients) {
if (client.watch_device_id == device_id) {
SendText(ws_ptr, json);
}
}
}
bool CWebService::StartRemoteDesktop(uint64_t device_id) {
if (!m_pParentDlg) return false;
+5
View File
@@ -160,6 +160,9 @@ public:
// Cursor change notification (called from ScreenSpyDlg)
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
// 远程剪贴板文本回传通知(called from ScreenSpyDlg,payload 为 UTF-8)
void NotifyClipboardText(uint64_t device_id, const std::string& text);
// Get count of web clients watching a device
int GetWebClientCount(uint64_t device_id);
@@ -187,6 +190,8 @@ private:
void HandlePing(void* ws_ptr, const std::string& token);
void HandleMouse(void* ws_ptr, const std::string& msg);
void HandleKey(void* ws_ptr, const std::string& msg);
void HandleClipboardSet(void* ws_ptr, const std::string& msg);
void HandleClipboardGet(void* ws_ptr, const std::string& token);
void HandleRdpReset(void* ws_ptr, const std::string& token);
void HandleAudioToggle(void* ws_ptr, const std::string& token);
+14
View File
@@ -135,6 +135,8 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
// arrives shortly after via TOKEN_NEXTSCREEN.
case protocol.TokenNextScreen:
h.handleScreenFrame(ctx, data, false)
case protocol.TokenClipboardText:
h.handleClipboardText(ctx, data)
case protocol.TokenKeyframe:
// Sent by the client only when frameID % m_GOP == 0; the client's
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
@@ -427,6 +429,18 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
h.hub.PublishScreenFrame(deviceID, packet, isKey)
}
// handleClipboardText handles TOKEN_CLIPBOARD_TEXT (118): the device replies to
// a COMMAND_SCREEN_GET_CLIPBOARD request with its clipboard text as
// [118][UTF-8 text][NUL]. data[0] is the token; the payload is data[1:].
func (h *MyHandler) handleClipboardText(ctx *connection.Context, data []byte) {
deviceID := h.hub.ScreenDeviceID(ctx)
if deviceID == "" {
return // not a bound screen sub-conn — drop
}
text := strings.TrimRight(string(data[1:]), "\x00")
h.hub.PublishClipboardText(deviceID, text)
}
// handleLogin handles client login (TOKEN_LOGIN = 102)
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
info, err := protocol.ParseLoginInfo(data)
+11
View File
@@ -187,6 +187,9 @@ type EventHandler interface {
// Duplicates (same index as the previous frame) are filtered out by the
// hub before reaching subscribers.
OnCursorChange(deviceID string, index byte)
// OnClipboardText delivers the device's clipboard text (already UTF-8),
// captured in response to a browser "clipboard_get" request.
OnClipboardText(deviceID string, text string)
// OnTerminalReady fires once the device's shell sub-conn is bound and
// the server has sent COMMAND_NEXT to start its output read loop.
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
@@ -606,6 +609,14 @@ func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool
}
}
// PublishClipboardText relays a device's clipboard text (UTF-8) to all
// subscribers, e.g. in response to a browser "clipboard_get" request.
func (h *Hub) PublishClipboardText(deviceID string, text string) {
for _, s := range h.snapshotSubscribers() {
s.OnClipboardText(deviceID, text)
}
}
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
// device's live fields, then notifies subscribers. No-op if the device is
// not registered (e.g. heartbeat arriving for a connection that never sent
+2
View File
@@ -84,6 +84,8 @@ func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
func (c *captureHandler) OnClipboardText(_ string, _ string) {}
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
+3
View File
@@ -106,6 +106,8 @@ const (
CommandActived byte = 0 // COMMAND_ACTIVED
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
CommandScreenGetClipboard byte = 24 // COMMAND_SCREEN_GET_CLIPBOARD - request remote clipboard (single byte)
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
@@ -120,6 +122,7 @@ const (
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
TokenClipboardText byte = 118 // TOKEN_CLIPBOARD_TEXT - remote clipboard text [118][UTF-8][NUL]
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
+17
View File
@@ -300,6 +300,23 @@ func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
}
}
// OnClipboardText relays a device's clipboard text back to every browser
// watching it (in response to a "clipboard_get" request).
func (h *wsHub) OnClipboardText(deviceID string, text string) {
msg := mustJSON(map[string]any{
"cmd": "clipboard_text",
"id": deviceID,
"text": text,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnScreenFrame ships a screen packet to every browser currently watching
// this device. We hold the read lock for the whole iteration, but each
// queueBinary is non-blocking (drops on backpressure) so a slow viewer
+58
View File
@@ -8,6 +8,7 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
"golang.org/x/text/encoding/simplifiedchinese"
)
// rotateChallenge replaces the client's nonce with a fresh one and pushes a
@@ -64,6 +65,10 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleMouse(c, raw)
case "key":
h.handleKey(c, raw)
case "clipboard_set":
h.handleClipboardSet(c, raw)
case "clipboard_get":
h.handleClipboardGet(c, raw)
case "term_open":
h.handleTermOpen(c, raw)
case "term_input":
@@ -559,6 +564,59 @@ func (h *wsHub) handleKey(c *wsClient, raw []byte) {
_ = h.devices.SendToScreen(deviceID, pkt)
}
// handleClipboardSet relays the browser's local clipboard text into the remote
// device's clipboard via COMMAND_SCREEN_SET_CLIPBOARD (25). The client's
// UpdateClientClipboard stores the payload as CF_TEXT (ANSI/GBK), so we encode
// the UTF-8 input to GBK here — matching the C++ WebService (explicit code
// page 936) and the existing MCP remote_clipboard convention. Non-GBK glyphs
// (emoji, some accented Latin) collapse to '?' — an accepted limitation.
func (h *wsHub) handleClipboardSet(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "clipboard_set_result") {
return
}
var in struct {
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
if in.Text == "" {
return
}
deviceID := c.watching
if deviceID == "" {
return
}
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
if err != nil {
h.log.Warn("clipboard_set: GBK encode failed: %v", err)
return
}
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
if err := h.devices.SendToScreen(deviceID, payload); err != nil {
h.log.Warn("clipboard_set: device=%s: %v", deviceID, err)
return
}
h.log.Info("clipboard_set sent: device=%s len=%d", deviceID, len(gbk))
}
// handleClipboardGet asks the remote device for its clipboard text via
// COMMAND_SCREEN_GET_CLIPBOARD (24, a single byte). The device replies with
// TOKEN_CLIPBOARD_TEXT, which the hub relays back to the browser.
func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "clipboard_get_result") {
return
}
deviceID := c.watching
if deviceID == "" {
return
}
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil {
h.log.Warn("clipboard_get: device=%s: %v", deviceID, err)
return
}
}
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
// password is hashed with a fresh salt, the user table is persisted, and
+53
View File
@@ -1365,6 +1365,7 @@
<script>
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
let skipNextCtrlVKeyup = false; // 剪贴板桥:抑制 pasteToRemote 合成的 Ctrl+V 物理 keyup
// FPS 计数:原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
// 简单直接,与本次会话改动前一致。
// 网络流量统计:handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
@@ -1863,6 +1864,12 @@
applyRemoteCursor(msg.index);
}
break;
case 'clipboard_text':
// 远程剪贴板文本回传:写回浏览器(即本机)剪贴板
if (msg.text) {
try { navigator.clipboard.writeText(msg.text); } catch (e) {}
}
break;
case 'device_offline':
// Only handle if this is the device we're currently viewing
if (!token) break;
@@ -3714,6 +3721,28 @@
}
}
// 本地剪贴板 → 远程:读浏览器剪贴板,先 clipboard_set 到远程,再合成一次完整 Ctrl+V。
// 合成按键放在 readText 的 then 里,保证 clipboard_set 先于 Ctrl+V 到达(同一条 WS 有序)。
function pasteToRemote() {
const press = () => { sendKey(86, true, false); sendKey(86, false, false); };
navigator.clipboard.readText().then(function(text) {
if (text && ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text }));
}
press();
}).catch(function() { press(); }); // 读剪贴板被拒/为空:仍执行一次普通远程粘贴
}
// 远程剪贴板 → 本地:等远程完成拷贝(启发式延时)后请求远程剪贴板,回包写回浏览器剪贴板。
// 不保证 100% 命中,失败用户二次 Ctrl+C 即可。
function scheduleCopyFromRemote() {
setTimeout(function() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'clipboard_get', token }));
}
}, 250);
}
function showTouchIndicator(x, y) {
touchIndicator.style.left = x + 'px';
touchIndicator.style.top = y + 'px';
@@ -4198,14 +4227,38 @@
// controlling the remote — see the F11 handler above. Skip here
// to let that one handle it.
if (e.key === 'F11' && !controlEnabled) return;
// 文本剪贴板桥(secure context 下 navigator.clipboard 可用)。
// Ctrl/Cmd+V:把本地剪贴板文本先灌到远程剪贴板,再合成一次 Ctrl+V 粘贴。
// Ctrl/Cmd+C / Ctrl/Cmd+X:照常转发给远程(远程完成拷贝/剪切),随后延迟拉取远程剪贴板回本地。
if (controlEnabled && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
if (e.keyCode === 86) { // Ctrl/Cmd+V
e.preventDefault();
skipNextCtrlVKeyup = true;
pasteToRemote();
return;
}
// e.keyCode === 67(C)/88(X) 时落到下面的 sendKey 原样转发,转发后再延迟拉取
}
e.preventDefault();
sendKey(e.keyCode, true, e.altKey);
if (controlEnabled && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && (e.keyCode === 67 || e.keyCode === 88)) {
scheduleCopyFromRemote();
}
});
document.addEventListener('keyup', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return;
if (e.target.tagName === 'INPUT') return;
if (e.key === 'F11' && !controlEnabled) return;
// 由 pasteToRemote 合成的完整 Ctrl+V 按下:物理 keyup 不再转发,避免 down/up 乱序
if (e.keyCode === 86 && skipNextCtrlVKeyup) {
skipNextCtrlVKeyup = false;
e.preventDefault();
return;
}
e.preventDefault();
sendKey(e.keyCode, false, e.altKey);
});